feat(watch): 支持设备观看与登录合并

This commit is contained in:
Zhou Weidong committed 2026-10-27 09:54:47 +08:00
1 parent 4b0fbd73d3
commit 848c746505
14 files changed
+613 -70

No files matched your search

@@ -59,9 +59,15 @@ public class AppFavoriteController {
@GetMapping
public Result<PageResult<AppDramaCardVO>> 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<Drama> page = favoriteService.page(
SecurityUtils.getAppUserId(), pageIndex, pageSize);
identity, pageIndex, pageSize);
return Result.ok(new PageResult<>(
page.getTotal(), appCatalogService.toCards(page.getList())));
}
@@ -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<ProgressRespVO> save(@Valid @RequestBody ProgressSaveReqVO req) {
return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req));
public Result<ProgressRespVO> 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<ProgressRespVO> ofDrama(@RequestParam Long dramaId) {
return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId));
public Result<ProgressRespVO> 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<PageResult<ProgressRespVO>> 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<Void> remove(@PathVariable Long dramaId) {
progressService.remove(SecurityUtils.getAppUserId(), dramaId);
public Result<Void> 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<Void> clear() {
progressService.clear(SecurityUtils.getAppUserId());
public Result<Void> 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();
}
}
@@ -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());
}
}
@@ -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);
@@ -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) {
@@ -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");
}
}
@@ -157,6 +157,24 @@ class FavoriteServiceTest {
verify(dramaService).listPublishedByIds(List.of(51L, 52L));
}
@Test
void should_page_anonymous_favorites_by_device() {
Page<UserFavorite> 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<Wrapper<UserFavorite>> 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);
@@ -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<WatchProgress> progressCaptor =
ArgumentCaptor.forClass(WatchProgress.class);
verify(progressMapper).insert(progressCaptor.capture());
assertThat(progressCaptor.getValue().getUserId()).isNull();
assertThat(progressCaptor.getValue().getDeviceId()).isEqualTo("device-a");
ArgumentCaptor<WatchHistory> 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;
}
}