diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 59dd970..eff1d80 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -290,16 +290,97 @@ app.register.max-per-device-per-day | --- | --- | --- | | POST | `/app/watch/progress` | 上报进度(建议每 10s 一次,切集时补一次) | | GET | `/app/watch/progress?dramaId=` | 某剧续看位置 | -| GET | `/app/watch/recent?limit=20` | 最近观看 | +| GET | `/app/watch/recent` | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | +| GET | `/app/watch/recent?limit=20` | 旧版最近观看 List 响应,仅保留一版兼容 | | DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 | | DELETE | `/app/watch/progress` | 清空记录 | | POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 | -| GET | `/app/favorites` | 收藏列表(卡片结构与首页一致) | +| GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | | DELETE | `/app/favorites/{dramaId}` | 取消收藏 | 进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。 这样用户换设备不会看到进度倒退。 +#### 收藏分页 + +`GET /app/favorites?pageIndex=1&pageSize=20` + +请求参数: + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `pageIndex` | int | 否 | 1 | 页码,小于 1 按 1 处理 | +| `pageSize` | int | 否 | 20 | 每页数量,上限 100,小于 1 按 1 处理 | + +响应 `data` 为 `PageResult`: + +```json +{ + "total": "23", + "list": [ + { + "id": "12", + "title": "替嫁新娘:总裁的掌心宠", + "cover": "/files/drama_cover/12.jpg", + "preview": {} + } + ] +} +``` + +排序固定为收藏时间倒序 `created_at DESC`,收藏时间相同时按 `drama_id DESC`。 +仅统计和返回仍处于已上线状态的短剧。 + +#### 最近观看分页 + +`GET /app/watch/recent?pageIndex=1&pageSize=20` + +请求参数: + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `pageIndex` | int | 否 | 1 | 页码,小于 1 按 1 处理 | +| `pageSize` | int | 否 | 20 | 每页数量,上限 100,小于 1 按 1 处理 | +| `limit` | int | 否 | 无 | 兼容旧版;只要传该参数就返回 List,不再返回 PageResult | + +分页响应 `data` 为 `PageResult`: + +```json +{ + "total": "18", + "list": [ + { + "dramaId": "12", + "episodeId": "88", + "dramaTitle": "替嫁新娘:总裁的掌心宠", + "dramaCover": "/files/drama_cover/12.jpg", + "episodeNumber": 6, + "totalEpisodes": 68, + "positionMs": "80000", + "durationMs": "120000", + "finished": false, + "playCount": 3, + "updatedAt": "2026-10-22 10:00:00" + } + ] +} +``` + +排序固定为 `updated_at DESC, drama_id DESC`。每页记录通过批量 IN 查询装配短剧和分集, +不逐条访问数据库。 + +`ProgressRespVO` 展示字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `dramaTitle` | string / null | 短剧标题 | +| `dramaCover` | string / null | 短剧封面 | +| `episodeNumber` | int / null | 当前续看集号 `ep_no` | +| `totalEpisodes` | int / null | 短剧总集数 | + +旧兼容请求 `GET /app/watch/recent?limit=20` 的 `data` 仍为 `List`, +行为在本版本保留;新客户端应使用分页响应。 + ### 1.6 离线下载(已实现) 服务端只发**授权**与**短时效地址**,分片由客户端直接向 CDN 拉取 —— diff --git a/docs/plans/2026-10-22-app-library-paging.md b/docs/plans/2026-10-22-app-library-paging.md new file mode 100644 index 0000000..9c0e1c3 --- /dev/null +++ b/docs/plans/2026-10-22-app-library-paging.md @@ -0,0 +1,24 @@ +# 客户端收藏与观看历史分页 + +## 目标 + +- `/app/favorites` 改为 `PageResult`,默认第 1 页、每页 20,最大 100。 +- `/app/watch/recent` 默认返回 `PageResult`;请求带 `limit` 时保留一版旧 + `List` 响应。 +- 观看历史补充短剧标题、封面、集号和总集数,剧/集均批量查询。 + +## 实现步骤 + +1. 收藏关系使用 `selectPage`,按 `created_at DESC, drama_id DESC` 稳定排序; + 只统计已上线短剧收藏,再按当前页 ID 批量加载短剧。 +2. 观看进度使用 `selectPage`,按 `updated_at DESC, drama_id DESC` 稳定排序。 +3. 当前页的 dramaId / episodeId 去重排序后,分别执行一次 IN 查询并装配展示字段。 +4. Controller 归一化 pageIndex/pageSize;观看历史根据是否传 `limit` 分流新旧响应。 +5. 更新 API 契约 §1.5,补 service/controller 测试。 + +## 风险与约束 + +- `limit` 分支仅用于旧客户端兼容,新分页响应与旧 List 响应不能混用。 +- 已下线短剧不计入收藏 total,也不出现在收藏页。 +- 观看历史展示字段找不到对应剧或集时保持 null,不影响历史记录返回。 +- 不修改客户端、不改表结构、不启停服务、不操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index ebc7169..467f7b5 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -25135,3 +25135,73 @@ $ mvn test 解读:14 个 reactor 模块全部通过;worker 真实 ffmpeg smoke 产出 HLS、封面、 VTT 与 sprite,缩略图单格宽高断言为 160x90。 +## 2026-10-22 客户端收藏与观看历史分页 + +- 分支:`feature/app-library-paging` +- 基线:`develop` / `origin/develop` = `ec15950` +- 隔离工作树:`/private/tmp/fruitserver-app-library-paging` +- 计划:`docs/plans/2026-10-22-app-library-paging.md` + +### 实现 + +- `/app/favorites` 增加 `pageIndex`(默认 1)和 `pageSize`(默认 20、上限 100), + 返回 `PageResult`。 +- 收藏关系使用 MyBatis-Plus `selectPage`,按 `created_at DESC, drama_id DESC` 排序; + 使用已上线 drama 的 `IN` 子查询过滤后,再对当前页短剧做一次批量装配。 +- `/app/watch/recent` 默认返回 `PageResult`,`pageIndex/pageSize` + 默认 1/20、上限 100;传 `limit` 时保留旧版 `List` 响应。 +- 观看进度使用 `selectPage`,按 `updated_at DESC, drama_id DESC` 排序。 +- `ProgressRespVO` 增加 `dramaTitle`、`dramaCover`、`episodeNumber`、`totalEpisodes`; + 当前页 dramaId / episodeId 去重排序后分别执行一次批量查询,无逐条 N+1。 +- `docs/api-phase1.md §1.5` 已同步分页参数、默认值、上限、稳定排序、响应样例及 + `limit` 兼容策略。 + +### 编译 + +```text +$ mvn -pl fruitshorts-app-api -am -DskipTests compile +[INFO] fruitshorts-module-playback ......................... SUCCESS +[INFO] fruitshorts-app-api .................................. SUCCESS +[INFO] BUILD SUCCESS +[INFO] Total time: 9.888 s +``` + +解读:`IPage/Page`、新 VO 字段和 Controller 返回类型均通过编译。 + +### 测试 + +首次全量测试的两个失败均为测试直接展开 LambdaWrapper SQL 时缺少 MyBatis-Plus +实体缓存,不是生产逻辑失败: + +```text +MybatisPlusException: can not find lambda cache for this entity +[UserFavorite] +[WatchProgress] +``` + +按项目现有测试模式增加 `TableInfoHelper.initTableInfo(...)` 后,定向测试先通过: + +```text +$ mvn -pl fruitshorts-app-api -am -Dtest=FavoriteServiceTest,WatchProgressServiceTest,AppLibraryPagingContractTest -Dsurefire.failIfNoSpecifiedTests=false test +Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +最终 app-api 及改动依赖模块全量门禁: + +```text +$ mvn -pl fruitshorts-app-api -am test +[INFO] Tests run: 109, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Reactor Summary for fruitshorts 1.0.0-SNAPSHOT: +[INFO] fruitshorts-module-content ......................... SUCCESS +[INFO] fruitshorts-module-playback ........................ SUCCESS +[INFO] fruitshorts-app-api ................................ SUCCESS +[INFO] BUILD SUCCESS +[INFO] Total time: 10.918 s +``` + +解读:收藏默认 pageSize=20、第二页不重叠、total 正确;观看历史分页、 +dramaTitle/episodeNumber/totalEpisodes 非空、越界页空 list 且 total 保持、 +`limit` 旧 List 兼容均通过;服务层断言 `selectPage`、稳定排序和两批 IN 查询。 +未启停服务、未操作真机、未修改客户端。 + 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 ebb873f..ef68fd8 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 @@ -4,7 +4,9 @@ package com.fruitshorts.app.controller; import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.vo.AppDramaCardVO; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.playback.service.FavoriteService; import com.fruitshorts.playback.vo.FavoriteToggleReqVO; @@ -18,10 +20,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - /** 收藏。 */ @Tag(name = "客户端-收藏") @RestController @@ -42,9 +43,13 @@ public class AppFavoriteController { /** 收藏列表,卡片结构与首页一致。 */ @Operation(summary = "我的收藏列表") @GetMapping - public Result> list() { - return Result.ok(appCatalogService.toCards( - favoriteService.listDramas(SecurityUtils.getAppUserId()))); + public Result> list( + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "20") int pageSize) { + PageResult page = favoriteService.page( + SecurityUtils.getAppUserId(), pageIndex, pageSize); + return Result.ok(new PageResult<>( + page.getTotal(), appCatalogService.toCards(page.getList()))); } @Operation(summary = "取消收藏") 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 e61813b..e43f845 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 @@ -2,6 +2,7 @@ package com.fruitshorts.app.controller; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.Result; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.playback.service.WatchProgressService; @@ -20,8 +21,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - /** 观看进度与观看记录。 */ @Tag(name = "客户端-观看进度") @RestController @@ -47,8 +46,17 @@ public class AppWatchController { @Operation(summary = "最近观看列表") @GetMapping("/recent") - public Result> recent(@RequestParam(defaultValue = "20") int limit) { - return Result.ok(progressService.recent(SecurityUtils.getAppUserId(), limit)); + public Result recent( + @RequestParam(required = false) Integer limit, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "20") int pageSize) { + Long userId = SecurityUtils.getAppUserId(); + if (limit != null) { + return Result.ok(progressService.recent(userId, limit)); + } + PageResult page = progressService.recentPage( + userId, pageIndex, pageSize); + return Result.ok(page); } @Operation(summary = "删除某部剧的观看记录") 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 new file mode 100644 index 0000000..6f56af3 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java @@ -0,0 +1,183 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.controller; + +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.content.dal.entity.Drama; +import com.fruitshorts.framework.security.LoginUser; +import com.fruitshorts.playback.service.FavoriteService; +import com.fruitshorts.playback.service.WatchProgressService; +import com.fruitshorts.playback.vo.ProgressRespVO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 客户端收藏与观看历史分页契约。 */ +class AppLibraryPagingContractTest { + + private static final long USER_ID = 1001L; + + private FavoriteService favoriteService; + private AppCatalogService catalogService; + private WatchProgressService progressService; + private MockMvc favoriteMvc; + private MockMvc watchMvc; + + @BeforeEach + void setUp() { + favoriteService = mock(FavoriteService.class); + catalogService = mock(AppCatalogService.class); + progressService = mock(WatchProgressService.class); + favoriteMvc = ControllerContractSupport.mockMvcOf( + new AppFavoriteController(favoriteService, catalogService)); + watchMvc = ControllerContractSupport.mockMvcOf( + new AppWatchController(progressService)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + new LoginUser(USER_ID, "app-user", "APP_USER", List.of()), + null, + List.of())); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void should_page_favorites_with_default_size_and_non_overlapping_second_page() + throws Exception { + Drama first = drama(101L, "收藏一"); + Drama second = drama(102L, "收藏二"); + Drama third = drama(103L, "收藏三"); + given(favoriteService.page(USER_ID, 1, 20)) + .willReturn(new PageResult<>(21L, List.of(first, second))); + given(favoriteService.page(USER_ID, 2, 20)) + .willReturn(new PageResult<>(21L, List.of(third))); + given(catalogService.toCards(List.of(first, second))) + .willReturn(List.of(card(101L), card(102L))); + given(catalogService.toCards(List.of(third))) + .willReturn(List.of(card(103L))); + + favoriteMvc.perform(get("/app/favorites")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value("21")) + .andExpect(jsonPath("$.data.list.length()").value(2)) + .andExpect(jsonPath("$.data.list[0].id").value("101")) + .andExpect(jsonPath("$.data.list[1].id").value("102")); + + favoriteMvc.perform(get("/app/favorites").param("pageIndex", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value("21")) + .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); + } + + @Test + void should_page_recent_watch_with_display_fields() throws Exception { + 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)) + .willReturn(new PageResult<>(23L, List.of(first, second))); + given(progressService.recentPage(USER_ID, 2, 20)) + .willReturn(new PageResult<>(23L, List.of(third))); + + watchMvc.perform(get("/app/watch/recent")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value("23")) + .andExpect(jsonPath("$.data.list.length()").value(2)) + .andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名一")) + .andExpect(jsonPath("$.data.list[0].episodeNumber").value(6)) + .andExpect(jsonPath("$.data.list[0].totalEpisodes").value(68)) + .andExpect(jsonPath("$.data.list[1].dramaTitle").value("剧名二")) + .andExpect(jsonPath("$.data.list[1].episodeNumber").value(2)); + + watchMvc.perform(get("/app/watch/recent").param("pageIndex", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value("23")) + .andExpect(jsonPath("$.data.list.length()").value(1)) + .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); + } + + @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)) + .willReturn(new PageResult<>(23L, List.of())); + + watchMvc.perform(get("/app/watch/recent").param("pageIndex", "9")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value("23")) + .andExpect(jsonPath("$.data.list").isArray()) + .andExpect(jsonPath("$.data.list").isEmpty()); + } + + @Test + void should_return_legacy_list_when_recent_limit_is_present() throws Exception { + given(progressService.recent(USER_ID, 5)) + .willReturn(List.of(progress(12L, 88L, 6, "剧名一", 68))); + + watchMvc.perform(get("/app/watch/recent").param("limit", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].dramaTitle").value("剧名一")) + .andExpect(jsonPath("$.data[0].episodeNumber").value(6)); + + verify(progressService).recent(USER_ID, 5); + } + + private Drama drama(Long id, String title) { + Drama drama = new Drama(); + drama.setId(id); + drama.setTitle(title); + return drama; + } + + private AppDramaCardVO card(Long id) { + AppDramaCardVO card = new AppDramaCardVO(); + card.setId(id); + return card; + } + + private ProgressRespVO progress(Long dramaId, Long episodeId, int episodeNumber, + String dramaTitle, int totalEpisodes) { + ProgressRespVO vo = new ProgressRespVO(); + vo.setDramaId(dramaId); + vo.setEpisodeId(episodeId); + vo.setEpisodeNumber(episodeNumber); + vo.setDramaTitle(dramaTitle); + vo.setDramaCover("/files/cover/" + dramaId + ".jpg"); + vo.setTotalEpisodes(totalEpisodes); + vo.setPositionMs(80_000L); + vo.setDurationMs(120_000L); + vo.setFinished(false); + vo.setPlayCount(1); + vo.setUpdatedAt(LocalDateTime.of(2026, 10, 22, 10, 0)); + return vo; + } +} 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 e1df968..d886ae1 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 @@ -2,13 +2,23 @@ package com.fruitshorts.playback.service; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +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.content.dal.entity.Drama; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.playback.dal.entity.UserFavorite; import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.Arrays; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -20,6 +30,12 @@ import static org.mockito.Mockito.verify; /** 收藏关系变化时同步维护短剧收藏数。 */ class FavoriteServiceTest { + @BeforeAll + static void initTableInfoCache() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, UserFavorite.class); + } + private UserFavoriteMapper favoriteMapper; private DramaService dramaService; private FavoriteService favoriteService; @@ -77,4 +93,46 @@ class FavoriteServiceTest { verify(dramaService, never()).adjustFavoriteCount(8L, -1); verify(dramaService).adjustFavoriteCount(9L, -1); } + + @Test + void should_use_database_page_and_keep_favorite_order_when_listing() { + UserFavorite first = favorite(51L); + UserFavorite second = favorite(52L); + Page databasePage = new Page<>(2, 20, 25); + databasePage.setRecords(List.of(first, second)); + given(favoriteMapper.selectPage(any(Page.class), any())).willReturn(databasePage); + Drama firstDrama = drama(51L); + Drama secondDrama = drama(52L); + given(dramaService.listPublishedByIds(List.of(51L, 52L))) + .willReturn(List.of(secondDrama, firstDrama)); + + PageResult result = favoriteService.page(1L, 2, 20); + + assertThat(result.getTotal()).isEqualTo(25); + assertThat(result.getList()).containsExactly(firstDrama, secondDrama); + @SuppressWarnings("unchecked") + ArgumentCaptor> pageCaptor = ArgumentCaptor.forClass(Page.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapperCaptor = + ArgumentCaptor.forClass(Wrapper.class); + verify(favoriteMapper).selectPage(pageCaptor.capture(), wrapperCaptor.capture()); + assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(2); + assertThat(pageCaptor.getValue().getSize()).isEqualTo(20); + assertThat(wrapperCaptor.getValue().getSqlSegment()) + .contains("created_at DESC") + .contains("drama_id DESC"); + verify(dramaService).listPublishedByIds(List.of(51L, 52L)); + } + + private UserFavorite favorite(Long dramaId) { + UserFavorite favorite = new UserFavorite(); + favorite.setDramaId(dramaId); + return favorite; + } + + private Drama drama(Long id) { + Drama drama = new Drama(); + drama.setId(id); + return drama; + } } 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 new file mode 100644 index 0000000..e8011e0 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java @@ -0,0 +1,126 @@ +// Author: fruit.com.cn + +package com.fruitshorts.playback.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +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.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.mapper.WatchHistoryMapper; +import com.fruitshorts.playback.dal.mapper.WatchProgressMapper; +import com.fruitshorts.playback.vo.ProgressRespVO; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** 观看历史分页、稳定排序与批量展示字段装配。 */ +class WatchProgressServiceTest { + + @BeforeAll + static void initTableInfoCache() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, WatchProgress.class); + } + + private WatchProgressMapper progressMapper; + private DramaService dramaService; + private EpisodeService episodeService; + private WatchProgressService progressService; + + @BeforeEach + void setUp() { + progressMapper = mock(WatchProgressMapper.class); + dramaService = mock(DramaService.class); + episodeService = mock(EpisodeService.class); + progressService = new WatchProgressService( + progressMapper, + mock(WatchHistoryMapper.class), + dramaService, + episodeService); + } + + @Test + void should_page_recent_and_fill_display_fields_with_two_batch_queries() { + WatchProgress first = progress(12L, 88L); + WatchProgress second = progress(13L, 99L); + Page databasePage = new Page<>(1, 20, 23); + databasePage.setRecords(List.of(first, second)); + given(progressMapper.selectPage(any(Page.class), any())).willReturn(databasePage); + given(dramaService.listByIds(List.of(12L, 13L))) + .willReturn(List.of(drama(13L, "剧名二", 20), drama(12L, "剧名一", 68))); + given(episodeService.listByIds(List.of(88L, 99L))) + .willReturn(List.of(episode(99L, 2), episode(88L, 6))); + + PageResult result = + progressService.recentPage(1001L, 1, 20); + + assertThat(result.getTotal()).isEqualTo(23); + assertThat(result.getList()).hasSize(2); + assertThat(result.getList().get(0).getDramaTitle()).isEqualTo("剧名一"); + assertThat(result.getList().get(0).getEpisodeNumber()).isEqualTo(6); + assertThat(result.getList().get(0).getTotalEpisodes()).isEqualTo(68); + assertThat(result.getList().get(1).getDramaTitle()).isEqualTo("剧名二"); + assertThat(result.getList().get(1).getEpisodeNumber()).isEqualTo(2); + + @SuppressWarnings("unchecked") + ArgumentCaptor> pageCaptor = ArgumentCaptor.forClass(Page.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapperCaptor = + ArgumentCaptor.forClass(Wrapper.class); + verify(progressMapper).selectPage(pageCaptor.capture(), wrapperCaptor.capture()); + assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(1); + assertThat(pageCaptor.getValue().getSize()).isEqualTo(20); + assertThat(wrapperCaptor.getValue().getSqlSegment()) + .contains("updated_at DESC") + .contains("drama_id DESC"); + verify(dramaService, times(1)).listByIds(List.of(12L, 13L)); + verify(episodeService, times(1)).listByIds(List.of(88L, 99L)); + } + + private WatchProgress progress(Long dramaId, Long episodeId) { + WatchProgress progress = new WatchProgress(); + progress.setUserId(1001L); + progress.setDramaId(dramaId); + progress.setEpisodeId(episodeId); + progress.setPositionMs(80_000L); + progress.setDurationMs(120_000L); + progress.setFinished(0); + progress.setPlayCount(1); + progress.setUpdatedAt(LocalDateTime.of(2026, 10, 22, 10, 0)); + return progress; + } + + private Drama drama(Long id, String title, int totalEpisodes) { + Drama drama = new Drama(); + drama.setId(id); + drama.setTitle(title); + drama.setCoverUrl("/files/cover/" + id + ".jpg"); + drama.setTotalEpisodes(totalEpisodes); + return drama; + } + + private DramaEpisode episode(Long id, int epNo) { + DramaEpisode episode = new DramaEpisode(); + episode.setId(id); + episode.setEpNo(epNo); + return episode; + } +} 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 0044932..0c3f1a9 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 @@ -2,8 +2,12 @@ package com.fruitshorts.playback.service; +import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.enums.ContentStatus; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.playback.dal.entity.UserFavorite; import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper; @@ -13,13 +17,17 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** 收藏:切换、列表、批量删除;收藏数在同一事务内同步维护。 */ @Service @RequiredArgsConstructor public class FavoriteService { + private static final int MAX_PAGE_SIZE = 100; + private final UserFavoriteMapper favoriteMapper; private final DramaService dramaService; @@ -55,30 +63,38 @@ public class FavoriteService { return count != null && count > 0; } - public List dramaIds(Long userId) { - return favoriteMapper.selectList(Wrappers.lambdaQuery() + /** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */ + public PageResult page(Long userId, 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) - .orderByDesc(UserFavorite::getCreatedAt)) - .stream().map(UserFavorite::getDramaId).toList(); - } - - public List listDramas(Long userId) { - List ids = dramaIds(userId); + .inSql(UserFavorite::getDramaId, publishedDramaSql) + .orderByDesc(UserFavorite::getCreatedAt) + .orderByDesc(UserFavorite::getDramaId)); + List ids = result.getRecords().stream() + .map(UserFavorite::getDramaId) + .toList(); if (ids.isEmpty()) { - return List.of(); + return new PageResult<>(result.getTotal(), List.of()); } List dramas = dramaService.listPublishedByIds(ids); - // 按收藏时间排序,已被下架的剧自然被过滤掉 + Map dramaById = new HashMap<>(); + for (Drama drama : dramas) { + dramaById.put(drama.getId(), drama); + } List ordered = new ArrayList<>(dramas.size()); for (Long id : ids) { - for (Drama drama : dramas) { - if (drama.getId().equals(id)) { - ordered.add(drama); - break; - } + Drama drama = dramaById.get(id); + if (drama != null) { + ordered.add(drama); } } - return ordered; + return new PageResult<>(result.getTotal(), ordered); } @Transactional(rollbackFor = Exception.class) 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 8955568..2c1e721 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 @@ -2,13 +2,16 @@ package com.fruitshorts.playback.service; +import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.common.pojo.PageResult; 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.entity.WatchProgress; import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; import com.fruitshorts.playback.dal.mapper.WatchProgressMapper; import com.fruitshorts.playback.vo.ProgressRespVO; @@ -19,7 +22,10 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; /** * 观看进度。 @@ -36,6 +42,8 @@ import java.util.List; public class WatchProgressService { private static final long FINISH_THRESHOLD_MS = 1000L; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; private final WatchProgressMapper progressMapper; private final WatchHistoryMapper historyMapper; @@ -110,15 +118,21 @@ public class WatchProgressService { /** 最近观看列表("我的-观看记录")。 */ public List recent(Long userId, int limit) { - List list = progressMapper.selectList(Wrappers.lambdaQuery() - .eq(WatchProgress::getUserId, userId) - .orderByDesc(WatchProgress::getUpdatedAt) - .last("limit " + (limit <= 0 || limit > 100 ? 20 : limit))); - List result = new ArrayList<>(list.size()); - for (WatchProgress item : list) { - result.add(toResp(item)); - } - return result; + int safeLimit = limit <= 0 || limit > MAX_PAGE_SIZE ? DEFAULT_PAGE_SIZE : limit; + return recentPage(userId, 1, safeLimit).getList(); + } + + /** 分页查询最近观看,按更新时间与 dramaId 稳定倒序。 */ + public PageResult recentPage(Long userId, 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) + .orderByDesc(WatchProgress::getUpdatedAt) + .orderByDesc(WatchProgress::getDramaId)); + return new PageResult<>(result.getTotal(), enrichDisplay(result.getRecords())); } @Transactional(rollbackFor = Exception.class) @@ -146,6 +160,48 @@ public class WatchProgressService { return vo; } + private List enrichDisplay(List records) { + if (records.isEmpty()) { + return List.of(); + } + List dramaIds = records.stream() + .map(WatchProgress::getDramaId) + .filter(Objects::nonNull) + .distinct() + .sorted() + .toList(); + List episodeIds = records.stream() + .map(WatchProgress::getEpisodeId) + .filter(Objects::nonNull) + .distinct() + .sorted() + .toList(); + Map dramaById = new HashMap<>(); + for (Drama drama : dramaService.listByIds(dramaIds)) { + dramaById.put(drama.getId(), drama); + } + Map episodeById = new HashMap<>(); + for (DramaEpisode episode : episodeService.listByIds(episodeIds)) { + episodeById.put(episode.getId(), episode); + } + List result = new ArrayList<>(records.size()); + for (WatchProgress progress : records) { + ProgressRespVO vo = toResp(progress); + Drama drama = dramaById.get(progress.getDramaId()); + if (drama != null) { + vo.setDramaTitle(drama.getTitle()); + vo.setDramaCover(drama.getCoverUrl()); + vo.setTotalEpisodes(drama.getTotalEpisodes()); + } + DramaEpisode episode = episodeById.get(progress.getEpisodeId()); + if (episode != null) { + vo.setEpisodeNumber(episode.getEpNo()); + } + result.add(vo); + } + return result; + } + /** 观看流水明细(客服视图与后续行为分析用),只读。 */ public List history(Long userId, int limit) { return historyMapper.selectList(Wrappers.lambdaQuery() diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java index 8f9f222..efb7da5 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java @@ -12,6 +12,14 @@ public class ProgressRespVO { private Long dramaId; private Long episodeId; + /** 短剧标题,仅列表类接口批量装配。 */ + private String dramaTitle; + /** 短剧封面,仅列表类接口批量装配。 */ + private String dramaCover; + /** 当前续看集号。 */ + private Integer episodeNumber; + /** 短剧总集数;短剧不存在时为空。 */ + private Integer totalEpisodes; private Long positionMs; private Long durationMs; private Boolean finished;