feat(app): 收藏与观看历史支持分页
This commit is contained in:
1 parent
ec15950851
commit
71b0c7315a
11 files changed
+672
-37
No files matched your search
+83
-2
@@ -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<AppDramaCardVO>`:
|
||||
|
||||
```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<ProgressRespVO>`:
|
||||
|
||||
```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<ProgressRespVO>`,
|
||||
行为在本版本保留;新客户端应使用分页响应。
|
||||
|
||||
### 1.6 离线下载(已实现)
|
||||
|
||||
服务端只发**授权**与**短时效地址**,分片由客户端直接向 CDN 拉取 ——
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 客户端收藏与观看历史分页
|
||||
|
||||
## 目标
|
||||
|
||||
- `/app/favorites` 改为 `PageResult<AppDramaCardVO>`,默认第 1 页、每页 20,最大 100。
|
||||
- `/app/watch/recent` 默认返回 `PageResult<ProgressRespVO>`;请求带 `limit` 时保留一版旧
|
||||
`List<ProgressRespVO>` 响应。
|
||||
- 观看历史补充短剧标题、封面、集号和总集数,剧/集均批量查询。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
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,不影响历史记录返回。
|
||||
- 不修改客户端、不改表结构、不启停服务、不操作真机。
|
||||
@@ -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<AppDramaCardVO>`。
|
||||
- 收藏关系使用 MyBatis-Plus `selectPage`,按 `created_at DESC, drama_id DESC` 排序;
|
||||
使用已上线 drama 的 `IN` 子查询过滤后,再对当前页短剧做一次批量装配。
|
||||
- `/app/watch/recent` 默认返回 `PageResult<ProgressRespVO>`,`pageIndex/pageSize`
|
||||
默认 1/20、上限 100;传 `limit` 时保留旧版 `List<ProgressRespVO>` 响应。
|
||||
- 观看进度使用 `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 查询。
|
||||
未启停服务、未操作真机、未修改客户端。
|
||||
|
||||
+10
-5
@@ -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<AppDramaCardVO>> list() {
|
||||
return Result.ok(appCatalogService.toCards(
|
||||
favoriteService.listDramas(SecurityUtils.getAppUserId())));
|
||||
public Result<PageResult<AppDramaCardVO>> list(
|
||||
@RequestParam(defaultValue = "1") int pageIndex,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
PageResult<Drama> page = favoriteService.page(
|
||||
SecurityUtils.getAppUserId(), pageIndex, pageSize);
|
||||
return Result.ok(new PageResult<>(
|
||||
page.getTotal(), appCatalogService.toCards(page.getList())));
|
||||
}
|
||||
|
||||
@Operation(summary = "取消收藏")
|
||||
|
||||
+12
-4
@@ -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<List<ProgressRespVO>> 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<ProgressRespVO> page = progressService.recentPage(
|
||||
userId, pageIndex, pageSize);
|
||||
return Result.ok(page);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除某部剧的观看记录")
|
||||
|
||||
+183
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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<UserFavorite> 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<Drama> result = favoriteService.page(1L, 2, 20);
|
||||
|
||||
assertThat(result.getTotal()).isEqualTo(25);
|
||||
assertThat(result.getList()).containsExactly(firstDrama, secondDrama);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Page<UserFavorite>> pageCaptor = ArgumentCaptor.forClass(Page.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Wrapper<UserFavorite>> 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;
|
||||
}
|
||||
}
|
||||
+126
@@ -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<WatchProgress> 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<ProgressRespVO> 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<Page<WatchProgress>> pageCaptor = ArgumentCaptor.forClass(Page.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Wrapper<WatchProgress>> 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;
|
||||
}
|
||||
}
|
||||
+32
-16
@@ -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<Long> dramaIds(Long userId) {
|
||||
return favoriteMapper.selectList(Wrappers.<UserFavorite>lambdaQuery()
|
||||
/** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */
|
||||
public PageResult<Drama> 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<UserFavorite> request = new Page<>(safeIndex, safeSize);
|
||||
String publishedDramaSql = "SELECT id FROM drama WHERE deleted = 0 AND status = '"
|
||||
+ ContentStatus.PUBLISHED.getCode() + "'";
|
||||
IPage<UserFavorite> result = favoriteMapper.selectPage(request,
|
||||
Wrappers.<UserFavorite>lambdaQuery()
|
||||
.eq(UserFavorite::getUserId, userId)
|
||||
.orderByDesc(UserFavorite::getCreatedAt))
|
||||
.stream().map(UserFavorite::getDramaId).toList();
|
||||
}
|
||||
|
||||
public List<Drama> listDramas(Long userId) {
|
||||
List<Long> ids = dramaIds(userId);
|
||||
.inSql(UserFavorite::getDramaId, publishedDramaSql)
|
||||
.orderByDesc(UserFavorite::getCreatedAt)
|
||||
.orderByDesc(UserFavorite::getDramaId));
|
||||
List<Long> ids = result.getRecords().stream()
|
||||
.map(UserFavorite::getDramaId)
|
||||
.toList();
|
||||
if (ids.isEmpty()) {
|
||||
return List.of();
|
||||
return new PageResult<>(result.getTotal(), List.of());
|
||||
}
|
||||
List<Drama> dramas = dramaService.listPublishedByIds(ids);
|
||||
// 按收藏时间排序,已被下架的剧自然被过滤掉
|
||||
Map<Long, Drama> dramaById = new HashMap<>();
|
||||
for (Drama drama : dramas) {
|
||||
dramaById.put(drama.getId(), drama);
|
||||
}
|
||||
List<Drama> 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)
|
||||
|
||||
+66
-10
@@ -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<ProgressRespVO> recent(Long userId, int limit) {
|
||||
List<WatchProgress> list = progressMapper.selectList(Wrappers.<WatchProgress>lambdaQuery()
|
||||
.eq(WatchProgress::getUserId, userId)
|
||||
.orderByDesc(WatchProgress::getUpdatedAt)
|
||||
.last("limit " + (limit <= 0 || limit > 100 ? 20 : limit)));
|
||||
List<ProgressRespVO> 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<ProgressRespVO> 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<WatchProgress> request = new Page<>(safeIndex, safeSize);
|
||||
IPage<WatchProgress> result = progressMapper.selectPage(request,
|
||||
Wrappers.<WatchProgress>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<ProgressRespVO> enrichDisplay(List<WatchProgress> records) {
|
||||
if (records.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> dramaIds = records.stream()
|
||||
.map(WatchProgress::getDramaId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
List<Long> episodeIds = records.stream()
|
||||
.map(WatchProgress::getEpisodeId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
Map<Long, Drama> dramaById = new HashMap<>();
|
||||
for (Drama drama : dramaService.listByIds(dramaIds)) {
|
||||
dramaById.put(drama.getId(), drama);
|
||||
}
|
||||
Map<Long, DramaEpisode> episodeById = new HashMap<>();
|
||||
for (DramaEpisode episode : episodeService.listByIds(episodeIds)) {
|
||||
episodeById.put(episode.getId(), episode);
|
||||
}
|
||||
List<ProgressRespVO> 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<WatchHistory> history(Long userId, int limit) {
|
||||
return historyMapper.selectList(Wrappers.<WatchHistory>lambdaQuery()
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user