diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 4395593..e14f4bd 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -350,21 +350,27 @@ app.register.max-per-device-per-day ### 1.5 观看记录与收藏(已实现) -| 方法 | 路径 | 说明 | -| --- | --- | --- | -| POST | `/app/watch/progress` | 上报进度(建议每 10s 一次,切集时补一次) | -| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 | -| GET | `/app/watch/recent` | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | -| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 | -| DELETE | `/app/watch/progress` | 清空记录 | +| 方法 | 路径 | 鉴权 | 说明 | +| --- | --- | --- | --- | +| POST | `/app/watch/progress` | 可选登录 | 上报进度;匿名按 `X-Device-Id`,建议每 10s 一次,切集时补一次 | +| GET | `/app/watch/progress?dramaId=` | 可选登录 | 某剧续看位置,匿名按设备读取 | +| GET | `/app/watch/recent` | 可选登录 | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | +| DELETE | `/app/watch/progress/{dramaId}` | 可选登录 | 删除当前账号或设备的某剧记录 | +| DELETE | `/app/watch/progress` | 可选登录 | 清空当前账号或设备的记录 | | POST | `/app/favorites/toggle` | 可选登录 | 收藏/取消收藏,返回切换后状态;匿名按 `X-Device-Id` | -| GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | +| GET | `/app/favorites` | 可选登录 | 收藏分页;匿名按设备读取,`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | | DELETE | `/app/favorites/{dramaId}` | 可选登录 | 取消收藏;匿名按 `X-Device-Id` | -| POST | `/app/favorites/remove` | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` | +| POST | `/app/favorites/remove` | 登录 | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` | 进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。 这样用户换设备不会看到进度倒退。 +收藏与观看数据的身份解析顺序统一为登录 `userId` → `X-Device-Id` → 请求参数或请求体 +`deviceId`,全部缺失时返回业务码 `200007`。匿名读取不按设备限流;上报、删除和清空等写操作 +沿用设备级限流。登录或注册成功后,同一事务会把设备收藏、观看进度和观看流水合并到账号: +同一部剧的进度先比较集号,同集再比较播放位置,最后用更新时间兜底,只保留更靠前的一条; +观看流水按剧集去重并保留更靠后的位置。`playCount` 只取最终保留记录,不重复累加。 + #### 收藏分页 `GET /app/favorites?pageIndex=1&pageSize=20` @@ -484,8 +490,8 @@ app.register.max-per-device-per-day **非付费互动身份与合并**:点赞、收藏、评论的身份解析顺序为登录 `userId` → `X-Device-Id` → 请求体 `deviceId`。三者都没有时返回业务码 `200007`。匿名写入按设备号 每分钟限流;评论同时做长度、空白、短时间重复内容和频率校验。登录或注册成功后,服务端 -在同一登录事务内把该设备未归属的点赞、收藏、评论迁移到账号;账号已有同内容时保留账号 -记录并删除设备重复记录,计数同步回退,不重复累加。 +在同一登录事务内把该设备未归属的点赞、收藏、评论、观看进度和观看流水迁移到账号; +账号已有同内容时保留账号记录并删除设备重复记录,计数同步回退,不重复累加。 匿名评论展示为「游客」,头像为空占位。解锁、金币/VIP 权益、锁定集正片票据不在本规则内, 仍必须登录。 diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java index a8aa734..abf01f1 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java @@ -59,9 +59,15 @@ public class AppFavoriteController { @GetMapping public Result> list( @RequestParam(defaultValue = "1") int pageIndex, - @RequestParam(defaultValue = "20") int pageSize) { + @RequestParam(defaultValue = "20") int pageSize, + @RequestParam(required = false) String deviceId, + HttpServletRequest request) { + InteractionIdentity identity = identityResolver.resolve( + SecurityUtils.getUserIdOrNull(), + request.getHeader("X-Device-Id"), + deviceId); PageResult page = favoriteService.page( - SecurityUtils.getAppUserId(), pageIndex, pageSize); + identity, pageIndex, pageSize); return Result.ok(new PageResult<>( page.getTotal(), appCatalogService.toCards(page.getList()))); } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java index 7344709..c999a0c 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java @@ -4,6 +4,9 @@ package com.fruitshorts.app.controller; import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.common.interaction.InteractionIdentity; +import com.fruitshorts.app.service.AnonymousInteractionRateLimiter; +import com.fruitshorts.framework.interaction.InteractionIdentityResolver; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.playback.service.WatchProgressService; import com.fruitshorts.playback.vo.ProgressRespVO; @@ -11,6 +14,7 @@ import com.fruitshorts.playback.vo.ProgressSaveReqVO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -29,19 +33,34 @@ import org.springframework.web.bind.annotation.RestController; public class AppWatchController { private final WatchProgressService progressService; + private final InteractionIdentityResolver identityResolver; + private final AnonymousInteractionRateLimiter anonymousRateLimiter; /** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */ @Operation(summary = "上报观看进度(客户端每 10 秒一次)") @PostMapping("/progress") - public Result save(@Valid @RequestBody ProgressSaveReqVO req) { - return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req)); + public Result save(@Valid @RequestBody ProgressSaveReqVO req, + HttpServletRequest request) { + InteractionIdentity identity = identityResolver.resolve( + SecurityUtils.getUserIdOrNull(), + request.getHeader("X-Device-Id"), + req.getDeviceId()); + anonymousRateLimiter.check(identity, "watch"); + return Result.ok(progressService.save(identity, req)); } /** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */ @Operation(summary = "某部剧的续看位置") @GetMapping("/progress") - public Result ofDrama(@RequestParam Long dramaId) { - return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId)); + public Result ofDrama( + @RequestParam Long dramaId, + @RequestParam(required = false) String deviceId, + HttpServletRequest request) { + InteractionIdentity identity = identityResolver.resolve( + SecurityUtils.getUserIdOrNull(), + request.getHeader("X-Device-Id"), + deviceId); + return Result.ok(progressService.ofDrama(identity, dramaId)); } /** 最近观看分页,契约与 /app/favorites 一致:pageIndex/pageSize。 */ @@ -49,22 +68,43 @@ public class AppWatchController { @GetMapping("/recent") public Result> recent( @RequestParam(defaultValue = "1") int pageIndex, - @RequestParam(defaultValue = "20") int pageSize) { + @RequestParam(defaultValue = "20") int pageSize, + @RequestParam(required = false) String deviceId, + HttpServletRequest request) { + InteractionIdentity identity = identityResolver.resolve( + SecurityUtils.getUserIdOrNull(), + request.getHeader("X-Device-Id"), + deviceId); return Result.ok(progressService.recentPage( - SecurityUtils.getAppUserId(), pageIndex, pageSize)); + identity, pageIndex, pageSize)); } @Operation(summary = "删除某部剧的观看记录") @DeleteMapping("/progress/{dramaId}") - public Result remove(@PathVariable Long dramaId) { - progressService.remove(SecurityUtils.getAppUserId(), dramaId); + public Result remove( + @PathVariable Long dramaId, + @RequestParam(required = false) String deviceId, + HttpServletRequest request) { + InteractionIdentity identity = identityResolver.resolve( + SecurityUtils.getUserIdOrNull(), + request.getHeader("X-Device-Id"), + deviceId); + anonymousRateLimiter.check(identity, "watch"); + progressService.remove(identity, dramaId); return Result.ok(); } @Operation(summary = "清空观看记录") @DeleteMapping("/progress") - public Result clear() { - progressService.clear(SecurityUtils.getAppUserId()); + public Result clear( + @RequestParam(required = false) String deviceId, + HttpServletRequest request) { + InteractionIdentity identity = identityResolver.resolve( + SecurityUtils.getUserIdOrNull(), + request.getHeader("X-Device-Id"), + deviceId); + anonymousRateLimiter.check(identity, "watch"); + progressService.clear(identity); return Result.ok(); } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeService.java index 4a9df41..5fa61b2 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeService.java @@ -5,6 +5,7 @@ package com.fruitshorts.app.service; import com.fruitshorts.interaction.service.CommentService; import com.fruitshorts.interaction.service.LikeService; import com.fruitshorts.playback.service.FavoriteService; +import com.fruitshorts.playback.service.WatchProgressService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,6 +20,7 @@ public class InteractionAccountMergeService { private final LikeService likeService; private final FavoriteService favoriteService; private final CommentService commentService; + private final WatchProgressService watchProgressService; @Transactional(rollbackFor = Exception.class) public void merge(Long userId, String deviceId) { @@ -27,11 +29,15 @@ public class InteractionAccountMergeService { favoriteService.mergeDeviceIntoUser(userId, deviceId); CommentService.MergeResult comments = commentService.mergeDeviceIntoUser(userId, deviceId); + WatchProgressService.MergeResult watch = + watchProgressService.mergeDeviceIntoUser(userId, deviceId); log.info( "interaction-merge userId={} deviceId={} " + "likeMigrated={} likeDuplicates={} " + "favoriteMigrated={} favoriteDuplicates={} " - + "commentMigrated={} commentDuplicates={}", + + "commentMigrated={} commentDuplicates={} " + + "watchProgressMigrated={} watchProgressIgnored={} " + + "watchHistoryMigrated={} watchHistoryIgnored={}", userId, deviceId, likes.migrated(), @@ -39,6 +45,10 @@ public class InteractionAccountMergeService { favorites.migrated(), favorites.duplicates(), comments.migrated(), - comments.duplicates()); + comments.duplicates(), + watch.progressMigrated(), + watch.progressIgnored(), + watch.historyMigrated(), + watch.historyIgnored()); } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java index 9dd353e..fcdbbf6 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java @@ -8,9 +8,13 @@ import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.vo.AppEpisodeStatsVO; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.pojo.CursorResult; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.interaction.InteractionIdentity; import com.fruitshorts.interaction.service.LikeService; import com.fruitshorts.playback.service.FavoriteService; import com.fruitshorts.playback.service.PlaybackService; +import com.fruitshorts.playback.service.WatchProgressService; +import com.fruitshorts.playback.vo.ProgressRespVO; import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.framework.interaction.InteractionIdentityResolver; import com.fruitshorts.app.service.AnonymousInteractionRateLimiter; @@ -42,9 +46,11 @@ import org.springframework.web.servlet.config.annotation.EnableWebMvc; import java.util.List; import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -63,6 +69,10 @@ class AppCommentCountAnonymousSecurityTest { private AppCatalogService catalogService; @Autowired private AppEpisodeStatsService episodeStatsService; + @Autowired + private FavoriteService favoriteService; + @Autowired + private WatchProgressService watchProgressService; @BeforeEach void setUp() { @@ -78,6 +88,16 @@ class AppCommentCountAnonymousSecurityTest { stats.setLiked(true); stats.setFavorited(false); given(episodeStatsService.get(11L, null, "device-a")).willReturn(stats); + given(favoriteService.page(any(InteractionIdentity.class), any(Integer.class), any(Integer.class))) + .willReturn(new PageResult<>(0L, List.of())); + given(catalogService.toCards(List.of())).willReturn(List.of()); + given(watchProgressService.recentPage( + any(InteractionIdentity.class), any(Integer.class), any(Integer.class))) + .willReturn(new PageResult<>(0L, List.of())); + given(watchProgressService.ofDrama(any(InteractionIdentity.class), any(Long.class))) + .willReturn(null); + given(watchProgressService.save(any(InteractionIdentity.class), any())) + .willReturn(new ProgressRespVO()); } @Test @@ -128,6 +148,35 @@ class AppCommentCountAnonymousSecurityTest { .andExpect(jsonPath("$.data.favoriteCount").value(8)); } + @Test + void should_allow_anonymous_library_and_watch_operations() throws Exception { + mockMvc.perform(get("/app/favorites").header("X-Device-Id", "device-a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(get("/app/watch/recent").header("X-Device-Id", "device-a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(get("/app/watch/progress") + .header("X-Device-Id", "device-a") + .param("dramaId", "7")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(post("/app/watch/progress") + .header("X-Device-Id", "device-a") + .contentType("application/json") + .content("{\"episodeId\":11,\"positionMs\":1000}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(delete("/app/watch/progress/7") + .header("X-Device-Id", "device-a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(delete("/app/watch/progress") + .header("X-Device-Id", "device-a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + @Test void should_keep_paid_and_entitlement_operations_login_required() throws Exception { mockMvc.perform(post("/app/episodes/11/unlock")) @@ -183,6 +232,11 @@ class AppCommentCountAnonymousSecurityTest { return mock(FavoriteService.class); } + @Bean + WatchProgressService watchProgressService() { + return mock(WatchProgressService.class); + } + @Bean AppInteractionController appInteractionController( LikeService likeService, @@ -207,6 +261,17 @@ class AppCommentCountAnonymousSecurityTest { rateLimiter); } + @Bean + AppWatchController appWatchController( + WatchProgressService watchProgressService, + InteractionIdentityResolver identityResolver, + AnonymousInteractionRateLimiter rateLimiter) { + return new AppWatchController( + watchProgressService, + identityResolver, + rateLimiter); + } + @Bean AppEpisodeStatsService appEpisodeStatsService() { return mock(AppEpisodeStatsService.class); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java index 39e228d..6038e70 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java @@ -6,6 +6,7 @@ import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.support.ControllerContractSupport; import com.fruitshorts.app.vo.AppDramaCardVO; import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.interaction.InteractionIdentity; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.framework.security.LoginUser; import com.fruitshorts.framework.interaction.InteractionIdentityResolver; @@ -24,6 +25,8 @@ import java.time.LocalDateTime; import java.util.List; import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -46,14 +49,17 @@ class AppLibraryPagingContractTest { favoriteService = mock(FavoriteService.class); catalogService = mock(AppCatalogService.class); progressService = mock(WatchProgressService.class); + InteractionIdentityResolver identityResolver = new InteractionIdentityResolver(); + AnonymousInteractionRateLimiter rateLimiter = + mock(AnonymousInteractionRateLimiter.class); favoriteMvc = ControllerContractSupport.mockMvcOf( new AppFavoriteController( favoriteService, catalogService, - mock(InteractionIdentityResolver.class), - mock(AnonymousInteractionRateLimiter.class))); + identityResolver, + rateLimiter)); watchMvc = ControllerContractSupport.mockMvcOf( - new AppWatchController(progressService)); + new AppWatchController(progressService, identityResolver, rateLimiter)); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken( new LoginUser(USER_ID, "app-user", "APP_USER", List.of()), @@ -72,9 +78,9 @@ class AppLibraryPagingContractTest { Drama first = drama(101L, "收藏一"); Drama second = drama(102L, "收藏二"); Drama third = drama(103L, "收藏三"); - given(favoriteService.page(USER_ID, 1, 20)) + given(favoriteService.page(any(InteractionIdentity.class), eq(1), eq(20))) .willReturn(new PageResult<>(21L, List.of(first, second))); - given(favoriteService.page(USER_ID, 2, 20)) + given(favoriteService.page(any(InteractionIdentity.class), eq(2), eq(20))) .willReturn(new PageResult<>(21L, List.of(third))); given(catalogService.toCards(List.of(first, second))) .willReturn(List.of(card(101L), card(102L))); @@ -95,8 +101,8 @@ class AppLibraryPagingContractTest { .andExpect(jsonPath("$.data.list.length()").value(1)) .andExpect(jsonPath("$.data.list[0].id").value("103")); - verify(favoriteService).page(USER_ID, 1, 20); - verify(favoriteService).page(USER_ID, 2, 20); + verify(favoriteService).page(any(InteractionIdentity.class), eq(1), eq(20)); + verify(favoriteService).page(any(InteractionIdentity.class), eq(2), eq(20)); } @Test @@ -104,9 +110,9 @@ class AppLibraryPagingContractTest { ProgressRespVO first = progress(12L, 88L, 6, "剧名一", 68); ProgressRespVO second = progress(13L, 99L, 2, "剧名二", 20); ProgressRespVO third = progress(14L, 100L, 1, "剧名三", 16); - given(progressService.recentPage(USER_ID, 1, 20)) + given(progressService.recentPage(any(InteractionIdentity.class), eq(1), eq(20))) .willReturn(new PageResult<>(23L, List.of(first, second))); - given(progressService.recentPage(USER_ID, 2, 20)) + given(progressService.recentPage(any(InteractionIdentity.class), eq(2), eq(20))) .willReturn(new PageResult<>(23L, List.of(third))); watchMvc.perform(get("/app/watch/recent")) @@ -126,14 +132,14 @@ class AppLibraryPagingContractTest { .andExpect(jsonPath("$.data.list[0].dramaId").value("14")) .andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名三")); - verify(progressService).recentPage(USER_ID, 1, 20); - verify(progressService).recentPage(USER_ID, 2, 20); + verify(progressService).recentPage(any(InteractionIdentity.class), eq(1), eq(20)); + verify(progressService).recentPage(any(InteractionIdentity.class), eq(2), eq(20)); } @Test void should_keep_total_and_return_empty_list_when_watch_page_is_out_of_range() throws Exception { - given(progressService.recentPage(USER_ID, 9, 20)) + given(progressService.recentPage(any(InteractionIdentity.class), eq(9), eq(20))) .willReturn(new PageResult<>(23L, List.of())); watchMvc.perform(get("/app/watch/recent").param("pageIndex", "9")) @@ -146,7 +152,7 @@ class AppLibraryPagingContractTest { @Test void should_ignore_removed_limit_and_still_return_page_result() throws Exception { // limit 已从契约移除:即使客户端误传,也应走分页并返回 PageResult。 - given(progressService.recentPage(USER_ID, 1, 20)) + given(progressService.recentPage(any(InteractionIdentity.class), eq(1), eq(20))) .willReturn(new PageResult<>(3L, List.of(progress(12L, 88L, 6, "剧名一", 68)))); watchMvc.perform(get("/app/watch/recent").param("limit", "5")) @@ -156,7 +162,7 @@ class AppLibraryPagingContractTest { .andExpect(jsonPath("$.data.list.length()").value(1)) .andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名一")); - verify(progressService).recentPage(USER_ID, 1, 20); + verify(progressService).recentPage(any(InteractionIdentity.class), eq(1), eq(20)); } private Drama drama(Long id, String title) { diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/InteractionAccountMergeServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/InteractionAccountMergeServiceTest.java new file mode 100644 index 0000000..0223947 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/InteractionAccountMergeServiceTest.java @@ -0,0 +1,41 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.service; + +import com.fruitshorts.interaction.service.CommentService; +import com.fruitshorts.interaction.service.LikeService; +import com.fruitshorts.playback.service.FavoriteService; +import com.fruitshorts.playback.service.WatchProgressService; +import org.junit.jupiter.api.Test; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class InteractionAccountMergeServiceTest { + + @Test + void should_merge_all_device_interactions_and_watch_data() { + LikeService likeService = mock(LikeService.class); + FavoriteService favoriteService = mock(FavoriteService.class); + CommentService commentService = mock(CommentService.class); + WatchProgressService watchService = mock(WatchProgressService.class); + given(likeService.mergeDeviceIntoUser(9L, "device-a")) + .willReturn(new LikeService.MergeResult(1, 0)); + given(favoriteService.mergeDeviceIntoUser(9L, "device-a")) + .willReturn(new FavoriteService.MergeResult(1, 0)); + given(commentService.mergeDeviceIntoUser(9L, "device-a")) + .willReturn(new CommentService.MergeResult(1, 0)); + given(watchService.mergeDeviceIntoUser(9L, "device-a")) + .willReturn(new WatchProgressService.MergeResult(2, 1, 3, 2)); + InteractionAccountMergeService service = new InteractionAccountMergeService( + likeService, favoriteService, commentService, watchService); + + service.merge(9L, "device-a"); + + verify(likeService).mergeDeviceIntoUser(9L, "device-a"); + verify(favoriteService).mergeDeviceIntoUser(9L, "device-a"); + verify(commentService).mergeDeviceIntoUser(9L, "device-a"); + verify(watchService).mergeDeviceIntoUser(9L, "device-a"); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java index ef0eb35..2bdc863 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java @@ -157,6 +157,24 @@ class FavoriteServiceTest { verify(dramaService).listPublishedByIds(List.of(51L, 52L)); } + @Test + void should_page_anonymous_favorites_by_device() { + Page databasePage = new Page<>(1, 20, 0); + databasePage.setRecords(List.of()); + given(favoriteMapper.selectPage(any(Page.class), any())).willReturn(databasePage); + + favoriteService.page( + new InteractionIdentity(null, "device-a"), 1, 20); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapperCaptor = + ArgumentCaptor.forClass(Wrapper.class); + verify(favoriteMapper).selectPage(any(Page.class), wrapperCaptor.capture()); + assertThat(wrapperCaptor.getValue().getSqlSegment()) + .contains("user_id IS NULL") + .contains("device_id"); + } + private UserFavorite favorite(Long dramaId) { UserFavorite favorite = new UserFavorite(); favorite.setDramaId(dramaId); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java index d72060f..e117279 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java @@ -7,11 +7,13 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.interaction.InteractionIdentity; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.playback.dal.entity.WatchProgress; +import com.fruitshorts.playback.dal.entity.WatchHistory; import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; import com.fruitshorts.playback.dal.mapper.WatchProgressMapper; import com.fruitshorts.playback.vo.ProgressRespVO; @@ -43,6 +45,7 @@ class WatchProgressServiceTest { } private WatchProgressMapper progressMapper; + private WatchHistoryMapper historyMapper; private DramaService dramaService; private EpisodeService episodeService; private WatchProgressService progressService; @@ -50,11 +53,12 @@ class WatchProgressServiceTest { @BeforeEach void setUp() { progressMapper = mock(WatchProgressMapper.class); + historyMapper = mock(WatchHistoryMapper.class); dramaService = mock(DramaService.class); episodeService = mock(EpisodeService.class); progressService = new WatchProgressService( progressMapper, - mock(WatchHistoryMapper.class), + historyMapper, dramaService, episodeService); } @@ -120,7 +124,7 @@ class WatchProgressServiceTest { hidden.setId(9L); hidden.setUserId(7L); hidden.setDeleted(1); - given(progressMapper.selectAny(7L, 5L)).willReturn(hidden); + given(progressMapper.selectAnyForIdentity(7L, "", 5L)).willReturn(hidden); given(progressMapper.selectById(9L)).willReturn(hidden); ProgressSaveReqVO req = new ProgressSaveReqVO(); req.setEpisodeId(88L); @@ -134,6 +138,117 @@ class WatchProgressServiceTest { assertThat(hidden.getDeleted()).isZero(); } + @Test + void should_save_and_read_anonymous_progress_by_device() { + DramaEpisode episode = episode(88L, 1); + episode.setDramaId(5L); + given(episodeService.getPublished(88L)).willReturn(episode); + given(dramaService.getPublished(5L)).willReturn(drama(5L, "剧名", 10)); + given(progressMapper.selectAnyForIdentity(null, "device-a", 5L)) + .willReturn(null); + given(progressMapper.insert(any(WatchProgress.class))).willAnswer(invocation -> { + WatchProgress saved = invocation.getArgument(0); + saved.setId(99L); + return 1; + }); + given(progressMapper.selectById(99L)).willAnswer(invocation -> { + WatchProgress saved = new WatchProgress(); + saved.setId(99L); + saved.setDramaId(5L); + saved.setEpisodeId(88L); + saved.setPositionMs(12_000L); + saved.setDurationMs(60_000L); + saved.setPlayCount(1); + saved.setDeviceId("device-a"); + return saved; + }); + ProgressSaveReqVO req = new ProgressSaveReqVO(); + req.setEpisodeId(88L); + req.setPositionMs(12_000L); + req.setDurationMs(60_000L); + + progressService.save(new InteractionIdentity(null, "device-a"), req); + + ArgumentCaptor progressCaptor = + ArgumentCaptor.forClass(WatchProgress.class); + verify(progressMapper).insert(progressCaptor.capture()); + assertThat(progressCaptor.getValue().getUserId()).isNull(); + assertThat(progressCaptor.getValue().getDeviceId()).isEqualTo("device-a"); + ArgumentCaptor historyCaptor = + ArgumentCaptor.forClass(WatchHistory.class); + verify(historyMapper).insert(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getUserId()).isNull(); + assertThat(historyCaptor.getValue().getDeviceId()).isEqualTo("device-a"); + } + + @Test + void should_merge_watch_data_and_keep_more_advanced_progress() { + DramaEpisode episode = episode(88L, 2); + episode.setDramaId(5L); + given(episodeService.listByIds(List.of(88L))).willReturn(List.of(episode)); + + WatchProgress deviceProgress = progress(5L, 88L); + deviceProgress.setId(1L); + deviceProgress.setPositionMs(90_000L); + deviceProgress.setPlayCount(3); + given(progressMapper.selectList(any())).willReturn(List.of(deviceProgress)); + WatchProgress accountProgress = progress(5L, 88L); + accountProgress.setId(2L); + accountProgress.setUserId(9L); + accountProgress.setPositionMs(60_000L); + accountProgress.setPlayCount(2); + given(progressMapper.selectAnyForIdentity(9L, "", 5L)) + .willReturn(accountProgress); + + WatchHistory deviceHistory = history(5L, 88L, 90_000L); + deviceHistory.setId(3L); + given(historyMapper.selectList(any())).willReturn(List.of(deviceHistory)); + WatchHistory accountHistory = history(5L, 88L, 60_000L); + accountHistory.setId(4L); + accountHistory.setUserId(9L); + given(historyMapper.selectOne(any())).willReturn(accountHistory); + + WatchProgressService.MergeResult result = + progressService.mergeDeviceIntoUser(9L, "device-a"); + + assertThat(result.progressMigrated()).isEqualTo(1); + assertThat(result.progressIgnored()).isZero(); + assertThat(result.historyMigrated()).isEqualTo(1); + assertThat(result.historyIgnored()).isZero(); + assertThat(accountProgress.getPositionMs()).isEqualTo(90_000L); + assertThat(accountProgress.getPlayCount()).isEqualTo(3); + verify(progressMapper).updateAny(accountProgress); + verify(progressMapper).deleteById(1L); + verify(historyMapper).updateById(accountHistory); + verify(historyMapper).deleteById(3L); + } + + @Test + void should_ignore_device_progress_when_account_progress_is_ahead() { + DramaEpisode episode = episode(88L, 2); + episode.setDramaId(5L); + given(episodeService.listByIds(List.of(88L))).willReturn(List.of(episode)); + + WatchProgress deviceProgress = progress(5L, 88L); + deviceProgress.setId(1L); + deviceProgress.setPositionMs(50_000L); + given(progressMapper.selectList(any())).willReturn(List.of(deviceProgress)); + WatchProgress accountProgress = progress(5L, 88L); + accountProgress.setId(2L); + accountProgress.setUserId(9L); + accountProgress.setPositionMs(70_000L); + given(progressMapper.selectAnyForIdentity(9L, "", 5L)) + .willReturn(accountProgress); + + WatchProgressService.MergeResult result = + progressService.mergeDeviceIntoUser(9L, "device-a"); + + assertThat(result.progressMigrated()).isZero(); + assertThat(result.progressIgnored()).isEqualTo(1); + verify(progressMapper, never()).updateAny(any(WatchProgress.class)); + verify(progressMapper).deleteById(1L); + } + private Drama drama(Long id, String title, int totalEpisodes) { Drama drama = new Drama(); drama.setId(id); @@ -149,4 +264,15 @@ class WatchProgressServiceTest { episode.setEpNo(epNo); return episode; } + + private WatchHistory history(Long dramaId, Long episodeId, long positionMs) { + WatchHistory history = new WatchHistory(); + history.setDramaId(dramaId); + history.setEpisodeId(episodeId); + history.setPositionMs(positionMs); + history.setDurationMs(120_000L); + history.setDeviceId("device-a"); + history.setWatchedAt(LocalDateTime.of(2026, 10, 27, 9, 0)); + return history; + } } diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index 1bb6691..7c0a231 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -89,6 +89,12 @@ public class SecurityConfig { .requestMatchers(HttpMethod.DELETE, "/app/comments/*").permitAll() .requestMatchers(HttpMethod.GET, "/app/comments").permitAll() .requestMatchers(HttpMethod.GET, "/app/comments/count").permitAll() + .requestMatchers(HttpMethod.GET, "/app/favorites").permitAll() + .requestMatchers(HttpMethod.GET, "/app/watch/recent").permitAll() + .requestMatchers(HttpMethod.GET, "/app/watch/progress").permitAll() + .requestMatchers(HttpMethod.POST, "/app/watch/progress").permitAll() + .requestMatchers(HttpMethod.DELETE, "/app/watch/progress/*").permitAll() + .requestMatchers(HttpMethod.DELETE, "/app/watch/progress").permitAll() .requestMatchers(PUBLIC_PATHS).permitAll() .anyRequest().authenticated()) .exceptionHandling(handling -> handling diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java index 3a7c23d..88460fc 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java @@ -10,13 +10,21 @@ import org.apache.ibatis.annotations.Update; public interface WatchProgressMapper extends BaseMapper { - /** 含逻辑删除行。唯一索引不区分 deleted,再看同一部剧必须能找回旧行。 */ + /** 按登录用户或匿名设备读取同一部剧的记录,包含逻辑删除行。 */ @Select(""" SELECT * FROM watch_progress - WHERE user_id = #{userId} AND drama_id = #{dramaId} + WHERE ( + (#{userId} IS NOT NULL AND user_id = #{userId}) + OR + (#{userId} IS NULL AND user_id IS NULL AND device_id = #{deviceId}) + ) + AND drama_id = #{dramaId} LIMIT 1 """) - WatchProgress selectAny(@Param("userId") Long userId, @Param("dramaId") Long dramaId); + WatchProgress selectAnyForIdentity( + @Param("userId") Long userId, + @Param("deviceId") String deviceId, + @Param("dramaId") Long dramaId); /** 恢复并更新。手写 SQL 不带 deleted=0 条件,逻辑删除行也能写回。 */ @Update(""" diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java index 7a23d1b..9236504 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java @@ -152,14 +152,19 @@ public class FavoriteService { /** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */ public PageResult page(Long userId, int pageIndex, int pageSize) { + return page(new InteractionIdentity(userId, ""), pageIndex, pageSize); + } + + /** 分页查询当前登录账号或匿名设备的收藏短剧。 */ + public PageResult page( + InteractionIdentity identity, int pageIndex, int pageSize) { int safeIndex = Math.max(pageIndex, 1); int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE); Page request = new Page<>(safeIndex, safeSize); String publishedDramaSql = "SELECT id FROM drama WHERE deleted = 0 AND status = '" + ContentStatus.PUBLISHED.getCode() + "'"; IPage result = favoriteMapper.selectPage(request, - Wrappers.lambdaQuery() - .eq(UserFavorite::getUserId, userId) + identityQuery(identity) .inSql(UserFavorite::getDramaId, publishedDramaSql) .orderByDesc(UserFavorite::getCreatedAt) .orderByDesc(UserFavorite::getDramaId)); diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java index 1730692..619d42f 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java @@ -3,9 +3,11 @@ package com.fruitshorts.playback.service; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.interaction.InteractionIdentity; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.ContentLocaleTexts; @@ -21,6 +23,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.ArrayList; @@ -33,7 +36,7 @@ import java.util.Objects; * 观看进度。 * *

每次上报都写库在高并发下会成为瓶颈(1 万并发 × 每 10 秒一次 ≈ 1000 写/秒), - * 所以生产形态是「先写 Redis,后台任务按 user+drama 合并后批量 UPSERT」。 + * 所以生产形态是「先写 Redis,后台任务按登录账号或设备 + drama 合并后批量 UPSERT」。 * 这里先落地权威表与合并规则,缓存层在接入 Redis 时补齐,接口契约不变。 * *

合并规则:以客户端上报的 positionMs 为准,但只在「更靠后」时覆盖, @@ -53,6 +56,11 @@ public class WatchProgressService { @Transactional(rollbackFor = Exception.class) public ProgressRespVO save(Long userId, ProgressSaveReqVO req) { + return save(new InteractionIdentity(userId, ""), req); + } + + @Transactional(rollbackFor = Exception.class) + public ProgressRespVO save(InteractionIdentity identity, ProgressSaveReqVO req) { DramaEpisode episode = episodeService.getPublished(req.getEpisodeId()); Drama drama = dramaService.getPublished(episode.getDramaId()); long position = req.getPositionMs() == null ? 0L : Math.max(0L, req.getPositionMs()); @@ -60,11 +68,11 @@ public class WatchProgressService { boolean finished = Boolean.TRUE.equals(req.getFinished()) || (duration > 0 && position + FINISH_THRESHOLD_MS >= duration); - LoadedProgress loaded = loadForSave(userId, drama.getId(), episode, position, duration, + LoadedProgress loaded = loadForSave(identity, drama.getId(), episode, position, duration, finished, req); WatchProgress existing = loaded.progress(); if (loaded.created()) { - appendHistory(userId, drama.getId(), episode.getId(), position, duration, + appendHistory(identity, drama.getId(), episode.getId(), position, duration, existing.getSource(), existing.getDeviceId()); return toResp(progressMapper.selectById(existing.getId())); } @@ -74,7 +82,7 @@ public class WatchProgressService { && existing.getEpisodeId().equals(episode.getId()); if (hidden && sameEpisode) { existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1); - appendHistory(userId, drama.getId(), episode.getId(), position, duration, + appendHistory(identity, drama.getId(), episode.getId(), position, duration, req.getSource(), existing.getDeviceId()); } if (!sameEpisode) { @@ -84,7 +92,7 @@ public class WatchProgressService { existing.setDurationMs(duration); existing.setFinished(finished ? 1 : 0); existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1); - appendHistory(userId, drama.getId(), episode.getId(), position, duration, + appendHistory(identity, drama.getId(), episode.getId(), position, duration, req.getSource(), existing.getDeviceId()); } else { existing.setDurationMs(Math.max(existing.getDurationMs() == null ? 0L : existing.getDurationMs(), @@ -96,7 +104,9 @@ public class WatchProgressService { existing.setFinished(1); } } - existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); + existing.setDeviceId(identity.authenticated() + ? (req.getDeviceId() == null ? "" : req.getDeviceId()) + : identity.deviceId()); existing.setSource(req.getSource() == null ? "" : req.getSource()); if (hidden) { existing.setDeleted(0); @@ -107,18 +117,22 @@ public class WatchProgressService { return toResp(progressMapper.selectById(existing.getId())); } - private LoadedProgress loadForSave(Long userId, Long dramaId, DramaEpisode episode, long position, + private LoadedProgress loadForSave(InteractionIdentity identity, Long dramaId, + DramaEpisode episode, long position, long duration, boolean finished, ProgressSaveReqVO req) { - WatchProgress existing = progressMapper.selectAny(userId, dramaId); + WatchProgress existing = progressMapper.selectAnyForIdentity( + identity.userId(), identity.deviceId(), dramaId); if (existing != null) { return new LoadedProgress(existing, false); } - WatchProgress created = newProgress(userId, dramaId, episode, position, duration, finished, req); + WatchProgress created = newProgress( + identity, dramaId, episode, position, duration, finished, req); try { progressMapper.insert(created); return new LoadedProgress(created, true); } catch (DuplicateKeyException ex) { - WatchProgress raced = progressMapper.selectAny(userId, dramaId); + WatchProgress raced = progressMapper.selectAnyForIdentity( + identity.userId(), identity.deviceId(), dramaId); if (raced == null) { throw ex; } @@ -126,17 +140,20 @@ public class WatchProgressService { } } - private WatchProgress newProgress(Long userId, Long dramaId, DramaEpisode episode, long position, + private WatchProgress newProgress(InteractionIdentity identity, Long dramaId, + DramaEpisode episode, long position, long duration, boolean finished, ProgressSaveReqVO req) { WatchProgress progress = new WatchProgress(); - progress.setUserId(userId); + progress.setUserId(identity.userId()); progress.setDramaId(dramaId); progress.setEpisodeId(episode.getId()); progress.setPositionMs(position); progress.setDurationMs(duration); progress.setFinished(finished ? 1 : 0); progress.setPlayCount(1); - progress.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); + progress.setDeviceId(identity.authenticated() + ? (req.getDeviceId() == null ? "" : req.getDeviceId()) + : identity.deviceId()); progress.setSource(req.getSource() == null ? "" : req.getSource()); return progress; } @@ -145,8 +162,11 @@ public class WatchProgressService { } public ProgressRespVO ofDrama(Long userId, Long dramaId) { - WatchProgress progress = progressMapper.selectOne(Wrappers.lambdaQuery() - .eq(WatchProgress::getUserId, userId) + return ofDrama(new InteractionIdentity(userId, ""), dramaId); + } + + public ProgressRespVO ofDrama(InteractionIdentity identity, Long dramaId) { + WatchProgress progress = progressMapper.selectOne(identityQuery(identity) .eq(WatchProgress::getDramaId, dramaId) .last("limit 1")); return progress == null ? null : toResp(progress); @@ -154,12 +174,16 @@ public class WatchProgressService { /** 分页查询最近观看,按更新时间倒序并以 id 兜底保证稳定。 */ public PageResult recentPage(Long userId, int pageIndex, int pageSize) { + return recentPage(new InteractionIdentity(userId, ""), pageIndex, pageSize); + } + + public PageResult recentPage( + InteractionIdentity identity, int pageIndex, int pageSize) { int safeIndex = Math.max(pageIndex, 1); int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE); Page request = new Page<>(safeIndex, safeSize); IPage result = progressMapper.selectPage(request, - Wrappers.lambdaQuery() - .eq(WatchProgress::getUserId, userId) + identityQuery(identity) .orderByDesc(WatchProgress::getUpdatedAt) .orderByDesc(WatchProgress::getId)); return new PageResult<>(result.getTotal(), enrichDisplay(result.getRecords())); @@ -167,15 +191,23 @@ public class WatchProgressService { @Transactional(rollbackFor = Exception.class) public void remove(Long userId, Long dramaId) { - progressMapper.delete(Wrappers.lambdaQuery() - .eq(WatchProgress::getUserId, userId) + remove(new InteractionIdentity(userId, ""), dramaId); + } + + @Transactional(rollbackFor = Exception.class) + public void remove(InteractionIdentity identity, Long dramaId) { + progressMapper.delete(identityQuery(identity) .eq(WatchProgress::getDramaId, dramaId)); } @Transactional(rollbackFor = Exception.class) public void clear(Long userId) { - progressMapper.delete(Wrappers.lambdaQuery() - .eq(WatchProgress::getUserId, userId)); + clear(new InteractionIdentity(userId, "")); + } + + @Transactional(rollbackFor = Exception.class) + public void clear(InteractionIdentity identity) { + progressMapper.delete(identityQuery(identity)); } private ProgressRespVO toResp(WatchProgress progress) { @@ -248,19 +280,169 @@ public class WatchProgressService { * 追加一条观看流水。只在「首次观看一部剧」和「切换剧集」时调用, * 每 10 秒的进度上报不写这里 —— 否则单集会产出几十条噪音。 */ - private void appendHistory(Long userId, Long dramaId, Long episodeId, long positionMs, + private void appendHistory(InteractionIdentity identity, Long dramaId, Long episodeId, + long positionMs, long durationMs, String source, String deviceId) { WatchHistory history = new WatchHistory(); - history.setUserId(userId); + history.setUserId(identity.userId()); history.setDramaId(dramaId); history.setEpisodeId(episodeId); history.setPositionMs(positionMs); history.setDurationMs(durationMs); history.setSource(source == null ? "" : source); - history.setDeviceId(deviceId == null ? "" : deviceId); + history.setDeviceId(identity.authenticated() + ? (deviceId == null ? "" : deviceId) + : identity.deviceId()); historyMapper.insert(history); } + /** + * 登录时合并设备进度和观看流水。 + * + *

同一部剧只保留进度更靠前的账号记录:先比较集号,同集再比较播放位置, + * 最后用更新时间兜底。playCount 只取保留记录,不把两条记录的计数相加。 + */ + @Transactional(rollbackFor = Exception.class) + public MergeResult mergeDeviceIntoUser(Long userId, String deviceId) { + if (userId == null || !StringUtils.hasText(deviceId)) { + return new MergeResult(0, 0, 0, 0); + } + List deviceProgress = progressMapper.selectList( + Wrappers.lambdaQuery() + .isNull(WatchProgress::getUserId) + .eq(WatchProgress::getDeviceId, deviceId)); + Map episodeNumbers = episodeNumbers(deviceProgress); + int progressMigrated = 0; + int progressIgnored = 0; + for (WatchProgress device : deviceProgress) { + WatchProgress account = progressMapper.selectAnyForIdentity( + userId, "", device.getDramaId()); + if (account == null) { + device.setUserId(userId); + progressMapper.updateById(device); + progressMigrated++; + continue; + } + if (progressAhead(device, account, episodeNumbers)) { + copyProgress(device, account); + account.setDeleted(0); + progressMapper.updateAny(account); + progressMigrated++; + } else { + progressIgnored++; + } + progressMapper.deleteById(device.getId()); + } + + List deviceHistory = historyMapper.selectList( + Wrappers.lambdaQuery() + .isNull(WatchHistory::getUserId) + .eq(WatchHistory::getDeviceId, deviceId)); + int historyMigrated = 0; + int historyIgnored = 0; + for (WatchHistory device : deviceHistory) { + WatchHistory account = historyMapper.selectOne( + Wrappers.lambdaQuery() + .eq(WatchHistory::getUserId, userId) + .eq(WatchHistory::getDramaId, device.getDramaId()) + .eq(WatchHistory::getEpisodeId, device.getEpisodeId()) + .orderByDesc(WatchHistory::getWatchedAt) + .last("limit 1")); + if (account == null) { + device.setUserId(userId); + historyMapper.updateById(device); + historyMigrated++; + continue; + } + if (historyAhead(device, account)) { + account.setPositionMs(device.getPositionMs()); + account.setDurationMs(device.getDurationMs()); + account.setSource(device.getSource()); + account.setWatchedAt(device.getWatchedAt()); + historyMapper.updateById(account); + historyMigrated++; + } else { + historyIgnored++; + } + historyMapper.deleteById(device.getId()); + } + return new MergeResult( + progressMigrated, progressIgnored, historyMigrated, historyIgnored); + } + + public record MergeResult( + int progressMigrated, + int progressIgnored, + int historyMigrated, + int historyIgnored) { + } + + private Map episodeNumbers(List progress) { + List episodeIds = progress.stream() + .map(WatchProgress::getEpisodeId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map result = new HashMap<>(); + if (episodeIds.isEmpty()) { + return result; + } + for (DramaEpisode episode : episodeService.listByIds(episodeIds)) { + result.put(episode.getId(), episode.getEpNo()); + } + return result; + } + + private boolean progressAhead( + WatchProgress candidate, + WatchProgress current, + Map episodeNumbers) { + Integer candidateNo = episodeNumbers.get(candidate.getEpisodeId()); + Integer currentNo = episodeNumbers.get(current.getEpisodeId()); + if (candidateNo != null && currentNo != null && !candidateNo.equals(currentNo)) { + return candidateNo > currentNo; + } + long candidatePosition = candidate.getPositionMs() == null ? 0L : candidate.getPositionMs(); + long currentPosition = current.getPositionMs() == null ? 0L : current.getPositionMs(); + if (candidatePosition != currentPosition) { + return candidatePosition > currentPosition; + } + return candidate.getUpdatedAt() != null + && (current.getUpdatedAt() == null + || candidate.getUpdatedAt().isAfter(current.getUpdatedAt())); + } + + private void copyProgress(WatchProgress source, WatchProgress target) { + target.setEpisodeId(source.getEpisodeId()); + target.setPositionMs(source.getPositionMs()); + target.setDurationMs(source.getDurationMs()); + target.setFinished(source.getFinished()); + target.setPlayCount(source.getPlayCount()); + target.setDeviceId(source.getDeviceId()); + target.setSource(source.getSource()); + } + + private boolean historyAhead(WatchHistory candidate, WatchHistory current) { + long candidatePosition = candidate.getPositionMs() == null ? 0L : candidate.getPositionMs(); + long currentPosition = current.getPositionMs() == null ? 0L : current.getPositionMs(); + if (candidatePosition != currentPosition) { + return candidatePosition > currentPosition; + } + return candidate.getWatchedAt() != null + && (current.getWatchedAt() == null + || candidate.getWatchedAt().isAfter(current.getWatchedAt())); + } + + private LambdaQueryWrapper identityQuery(InteractionIdentity identity) { + if (identity.authenticated()) { + return Wrappers.lambdaQuery() + .eq(WatchProgress::getUserId, identity.userId()); + } + return Wrappers.lambdaQuery() + .isNull(WatchProgress::getUserId) + .eq(WatchProgress::getDeviceId, identity.deviceId()); + } + /** 供后续批量合并任务使用:只更新进度字段,避免整行覆盖。 */ public void mergeInto(Long userId, Long dramaId, Long episodeId, long positionMs) { progressMapper.update(null, Wrappers.lambdaUpdate() diff --git a/sql/migrate-k106-anonymous-watch.sql b/sql/migrate-k106-anonymous-watch.sql new file mode 100644 index 0000000..a66f331 --- /dev/null +++ b/sql/migrate-k106-anonymous-watch.sql @@ -0,0 +1,24 @@ +-- 观看进度与观看流水支持 deviceId 归属;登录时由账号事务合并。 + +ALTER TABLE watch_progress + ADD COLUMN IF NOT EXISTS device_id VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE watch_progress + ALTER COLUMN user_id DROP NOT NULL; +DROP INDEX IF EXISTS uk_watch_progress; +CREATE UNIQUE INDEX IF NOT EXISTS uk_watch_progress_user + ON watch_progress (user_id, drama_id) + WHERE user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uk_watch_progress_device + ON watch_progress (device_id, drama_id) + WHERE user_id IS NULL AND device_id <> ''; +CREATE INDEX IF NOT EXISTS idx_watch_progress_device + ON watch_progress (device_id, updated_at DESC) + WHERE device_id <> ''; + +ALTER TABLE watch_history + ADD COLUMN IF NOT EXISTS device_id VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE watch_history + ALTER COLUMN user_id DROP NOT NULL; +CREATE INDEX IF NOT EXISTS idx_watch_history_device + ON watch_history (device_id, watched_at DESC) + WHERE user_id IS NULL AND device_id <> '';