Author SHA1 Message Date
Zhou Weidong 07754ff4e4 docs(plans): 登记剧集列表分页合并提交作者偏差 2026-10-22 09:18:42 +08:00
Zhou Weidong 4159af1732 merge: 剧集列表接口支持分页 2026-10-21 23:09:42 +08:00
Zhou Weidong 0143fa036c feat(admin): 剧集列表接口支持分页 2026-10-21 23:09:30 +08:00
Zhou Weidong 5227762aeb docs(plans): 短剧价格同步幂等纠偏留痕 2026-10-21 22:56:36 +08:00
jianghaifeng a4317bb6f1 merge: 短剧保存时始终同步分集价格 2026-10-21 22:49:43 +08:00
Vicky 9adf8c5630 fix(content): 短剧保存时始终同步分集价格 2026-10-21 22:48:09 +08:00
Vicky 8bb7f63c62 test(content): 覆盖免费集数未变但分集漂移的修复场景 2026-10-21 22:48:09 +08:00
Zhou Weidong b8b3fd867b docs(plans): 短剧免费集数同步分集留痕 2026-10-21 22:34:21 +08:00
Zhou Weidong e92ec118fe merge: 短剧保存免费集数同步分集免费状态 2026-10-21 22:34:21 +08:00
Zhou Weidong 4c4b53cd69 fix(content): 短剧保存免费集数时同步分集免费状态 2026-10-21 22:34:21 +08:00
Zhou Weidong d7f226659c test(content): 覆盖短剧免费集数同步分集价格 2026-10-21 22:34:21 +08:00
Zhou Weidong 979e6ebea3 docs(plans): 登记 K100 提交 AI 痕迹已知偏差 2026-10-21 22:10:35 +08:00
Zhou WeidongandCursor 3a581163aa docs(plans): K100 剧集批量上传计划与执行留痕
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:54 +08:00
Zhou Weidong 97c2153c2e merge: 合并 K98 客户端修复留痕 2026-10-21 19:27:21 +08:00
Zhou Weidong 27a15dd1b4 docs: 记录 K98 登录键盘避让 2026-10-21 19:26:55 +08:00
Zhou Weidong cf37d6c408 Merge remote-tracking branch 'origin/develop' into develop 2026-10-21 18:52:27 +08:00
Zhou Weidong c9b8ec6805 fix(content): 短剧编辑未传字段不再覆盖存量数据 2026-10-21 18:52:11 +08:00
Zhou Weidong 31d88b634a merge: 合并 K97 客户端修复留痕 2026-10-21 18:47:03 +08:00
Zhou Weidong 5704f80929 docs: 记录 K97 iOS 评论键盘避让 2026-10-21 18:46:50 +08:00
Zhou Weidong 652ef050ee merge: 合并 K96 推荐流分类过滤 2026-10-21 18:24:26 +08:00
Zhou Weidong 8f35160b56 feat(app): 推荐流支持分类过滤 2026-10-21 18:23:57 +08:00
Zhou Weidong 0e8fb1582d merge: 合并 K95.2 取消回滚留痕 2026-10-21 18:08:05 +08:00
Zhou Weidong aa74770256 docs: 记录 K95.2 取消回滚 2026-10-21 18:07:56 +08:00
Zhou Weidong 01ae6d44f0 merge: 合并 K95.2 交付留痕 2026-10-21 18:06:45 +08:00
Zhou Weidong ff14762426 docs: 记录 K95.2 封面过渡 2026-10-21 18:06:35 +08:00
Zhou Weidong 419119725e merge: 合并 K95.1 交付留痕 2026-10-21 17:54:40 +08:00
Zhou Weidong b31784010c docs: 记录 K95.1 首次切换无感 2026-10-21 17:54:29 +08:00
Zhou Weidong 4049cfd2d0 merge: 合并 K95 交付留痕 2026-10-21 17:47:17 +08:00
Zhou Weidong c70054e83d docs: 记录 K95 频道切换修复留痕 2026-10-21 17:47:10 +08:00
Zhou Weidong 9baab3777a merge: 合并 K94 交付留痕 2026-10-21 17:34:35 +08:00
Zhou Weidong 3036b782f9 docs: 记录 K94 首页类型 Feed 留痕 2026-10-21 17:34:27 +08:00
Zhou Weidong 1fa3631e53 merge: 合并 K93.1 交付留痕 2026-10-21 17:17:27 +08:00
Zhou Weidong 2df4e5cc9f docs: 记录 K93.1 多选完整展示留痕 2026-10-21 17:17:24 +08:00
Zhou Weidong 8f1103dfa7 merge: 合并 K93 交付留痕 2026-10-21 17:02:11 +08:00
Zhou Weidong aa532eb568 docs: 完成 K93 交付留痕 2026-10-21 17:02:07 +08:00
Zhou Weidong 33c1172da5 merge: 合并 K93 短剧类型多选 2026-10-21 17:01:36 +08:00
Zhou Weidong 4c614a5ece feat(content): 短剧类型与内容类型支持多选 2026-10-21 17:01:12 +08:00
Zhou Weidong 030df5c5dc merge: 合并 K92 交付留痕 2026-10-21 16:45:38 +08:00
Zhou Weidong b82a05f82e docs: 完成 K92 交付留痕 2026-10-21 16:45:34 +08:00
Zhou Weidong ae053b0268 merge: 合并 K92 举报目标短剧与分集信息 2026-10-21 16:45:07 +08:00
Zhou Weidong 9a74876b3f feat(admin): 举报工单返回短剧与分集信息 2026-10-21 16:44:46 +08:00
Zhou Weidong 6ddddef5ec merge: 合并 K91 交付留痕 2026-10-21 16:36:43 +08:00
Zhou Weidong 0a2ca5d379 docs: 完成 K91 交付留痕 2026-10-21 16:36:39 +08:00
Zhou Weidong fe18a2c9ea merge: 合并 K91 观看记录与评论分集信息 2026-10-21 16:35:57 +08:00
Zhou Weidong 1d316791b1 feat(admin): 新增观看记录分页与评论分集信息 2026-10-21 16:35:26 +08:00
Zhou Weidong d52b805adf merge: 合并 K90 客户端分集互动留痕 2026-10-21 16:16:34 +08:00
Zhou Weidong 7a16feb548 docs: 记录 K90 客户端分集互动留痕 2026-10-21 16:16:30 +08:00
Zhou Weidong 08380a52c8 merge: 合并 K89.1 预览集查询顺序留痕 2026-10-21 16:04:12 +08:00
Zhou Weidong 96d58c53f7 docs(progress): 记录 K89.1 预览集查询顺序修复 2026-10-21 16:04:07 +08:00
Zhou Weidong 6252a07b5d merge: 合并 K89.1 预览集查询顺序修复 2026-10-21 16:03:12 +08:00
Zhou Weidong 3303a91e3c fix(content): 固定预览集查询顺序 2026-10-21 16:03:06 +08:00
Zhou Weidong 7ae05698fa merge: 合并 K89 分集点赞与评论统计留痕 2026-10-21 16:01:16 +08:00
Zhou Weidong c511d04415 docs(progress): 记录 K89 分集点赞与评论统计 2026-10-21 16:01:11 +08:00
Zhou Weidong dfc978c620 merge: 合并 K89 分集点赞与评论统计 2026-10-21 15:56:30 +08:00
Zhou Weidong c6f729fb27 feat(interaction): 点赞与评论按集统计 2026-10-21 15:56:26 +08:00
Zhou Weidong c7ff729f84 merge: 合并 K88 Web 原生门禁记录修正 2026-10-21 15:48:54 +08:00
Zhou Weidong e0ae38ce08 docs(progress): 修正 K88 Web 原生门禁记录 2026-10-21 15:48:50 +08:00
Zhou Weidong e2e2a21d2f merge: 合并 K88 内容类型与互动数据留痕 2026-10-21 15:46:22 +08:00
Zhou Weidong a3b6bf3a55 docs(progress): 记录 K88 内容类型与互动数据 2026-10-21 15:46:15 +08:00
Zhou Weidong fd402d9efe merge: 合并 K88 内容类型与分类 feed 2026-10-21 15:40:32 +08:00
Zhou Weidong 1052b97306 feat(content): 新增内容类型与首页分类 feed 2026-10-21 15:40:19 +08:00
Zhou Weidong d9cdbc8e15 merge: 合并 K87.1 下拉刷新冻结位移留痕 2026-10-21 15:26:22 +08:00
Zhou Weidong edd279372d docs(plans): 记录 K87.1 下拉刷新冻结位移 2026-10-21 15:26:15 +08:00
Zhou Weidong 7ab6dc62dd merge: 合并 K87 下拉刷新完成后回弹留痕 2026-10-21 15:14:26 +08:00
Zhou Weidong 22fc173305 docs(plans): 记录 K87 下拉刷新完成后回弹 2026-10-21 15:14:20 +08:00
Zhou Weidong 0b914dd274 merge: 合并 K86.22 评论输入面板安全区留痕 2026-10-21 15:02:04 +08:00
Zhou Weidong c7e5de5f99 docs(plans): 记录 K86.22 评论输入面板安全区修复 2026-10-21 15:01:58 +08:00
Zhou Weidong e6b5b904fc merge: 合并 K86.21 评论输入浮层稳定定位留痕 2026-10-21 14:57:00 +08:00
Zhou Weidong ba628e3a4a docs(plans): 记录 K86.21 评论输入浮层稳定定位 2026-10-21 14:56:56 +08:00
Zhou Weidong ad4be92189 merge: 合并 K86.20 评论输入浮层打开状态留痕 2026-10-21 14:50:21 +08:00
Zhou Weidong 5d018657d5 docs(progress): 记录 K86.20 评论输入浮层打开状态修正 2026-10-21 14:50:17 +08:00
Zhou Weidong 24bd00b537 merge: 合并 K86.19 评论输入实色填充留痕 2026-10-21 14:38:38 +08:00
Zhou Weidong 250a6142f3 docs(progress): 记录 K86.19 评论输入实色填充保障 2026-10-21 14:38:34 +08:00
Zhou Weidong e90debe810 merge: 合并 K86.18 输入浮层实底填充留痕 2026-10-21 14:32:03 +08:00
Zhou Weidong 2d8e5cf53e docs(progress): 记录 K86.18 输入浮层实底填充修正 2026-10-21 14:32:00 +08:00
Zhou Weidong 7ab22d26b1 merge: 合并 K86.18 评论输入透明浮层留痕 2026-10-21 14:26:34 +08:00
Zhou Weidong 5c7973471d docs(progress): 记录 K86.18 评论输入透明浮层 2026-10-21 14:26:30 +08:00
Zhou Weidong c81e3643d5 merge: 合并 K86.17 键盘收起位置固定留痕 2026-10-21 14:16:59 +08:00
Zhou Weidong 4297e02a67 docs(progress): 记录 K86.17 键盘收起位置固定修正 2026-10-21 14:16:55 +08:00
Zhou Weidong 844b6abb25 merge: 合并 K86.17 输入弹窗收起规则留痕 2026-10-21 14:12:18 +08:00
Zhou Weidong e6e8a347fc docs(progress): 记录 K86.17 输入弹窗收起规则 2026-10-21 14:12:13 +08:00
Zhou Weidong fc8f507864 merge: 合并 K86.16 评论浮层修正留痕 2026-10-21 14:01:18 +08:00
Zhou Weidong f016df0bf1 docs(progress): 记录 K86.16 评论浮层与透明遮罩 2026-10-21 14:01:13 +08:00
Zhou Weidong 141874a0bc merge: 合并 K86.15 评论成功提示留痕 2026-10-21 13:49:22 +08:00
Zhou Weidong d788d70752 docs(progress): 记录 K86.15 评论发布成功提示 2026-10-21 13:49:19 +08:00
Zhou Weidong cb45bafad6 merge: 合并 K86.14 紧凑弹窗修正留痕 2026-10-21 13:38:51 +08:00
Zhou Weidong 56532c2c03 docs(progress): 记录 K86.14 紧凑弹窗修正 2026-10-21 13:38:48 +08:00
Zhou Weidong 12502a2e69 merge: 合并 K86.14 评论输入独立弹窗留痕 2026-10-21 13:34:03 +08:00
Zhou Weidong e46a9fd347 docs(progress): 记录 K86.14 评论输入独立弹窗 2026-10-21 13:33:57 +08:00
Zhou Weidong 908023ddc4 merge: 合并 K86.13 评论面板半屏高度留痕 2026-10-21 13:21:55 +08:00
Zhou Weidong d9db07573e docs(progress): 记录 K86.13 评论面板半屏高度 2026-10-21 13:21:51 +08:00
Zhou Weidong b2621d67ca merge: 合并 K86.12 评论文字居中修复留痕 2026-10-21 13:18:03 +08:00
Zhou Weidong aaa6f06477 docs(progress): 记录 K86.12 评论文字居中修复 2026-10-21 13:17:56 +08:00
Zhou Weidong 562d8941b9 merge: 合并 K86.11 评论假输入框入口留痕 2026-10-21 13:14:23 +08:00
Zhou Weidong b286137cb0 docs(progress): 记录 K86.11 评论假输入框入口 2026-10-21 13:14:20 +08:00
Zhou Weidong 99e9b4f7bf merge: 合并 K86.10 评论面板高度与收起流程留痕 2026-10-21 13:06:24 +08:00
Zhou Weidong 12a011fdce docs(progress): 记录 K86.10 评论面板高度与收起流程 2026-10-21 13:06:20 +08:00
Zhou Weidong fa95bf05d5 merge: 合并 K86.9 评论面板键盘布局修复留痕 2026-10-21 13:01:52 +08:00
Zhou Weidong 5c631c6f23 docs(progress): 记录 K86.9 评论面板键盘布局修复 2026-10-21 13:01:48 +08:00
Zhou Weidong 19fd56d477 merge: 合并 K86.8 评论面板保持与焦点修复留痕 2026-10-21 12:55:22 +08:00
Zhou Weidong 35c8cf0b9c docs(progress): 记录 K86.8 评论面板保持与焦点修复 2026-10-21 12:55:18 +08:00
Zhou Weidong 4b6c4ba928 merge: 合并 K86.7 视频页键盘不压缩留痕 2026-10-21 12:49:27 +08:00
Zhou Weidong 4e93f7bdd4 docs(progress): 记录 K86.7 视频页键盘不压缩 2026-10-21 12:49:21 +08:00
Zhou Weidong 81b5c99b6a merge: 合并 K86.6 评论输入聚焦修复留痕 2026-10-21 12:43:04 +08:00
Zhou Weidong f496c9fa16 docs(progress): 记录 K86.6 评论输入聚焦修复 2026-10-21 12:42:53 +08:00
Zhou Weidong 1de35bfa30 merge: 合并 K86.5 隐藏评论操作入口留痕 2026-10-21 12:26:59 +08:00
Zhou Weidong 48fdee2c4b docs(progress): 记录 K86.5 隐藏评论操作入口 2026-10-21 12:26:49 +08:00
Zhou Weidong 3d15807919 merge: 合并 K86.4 评论输入面板留痕 2026-10-21 12:22:00 +08:00
Zhou Weidong 3c64525a3e docs(progress): 记录 K86.4 评论展开输入面板 2026-10-21 12:21:51 +08:00
Zhou Weidong 5103527635 merge: 合并 K86.3 Android 键盘修正留痕 2026-10-21 12:14:50 +08:00
Zhou Weidong 52bc80a402 docs(progress): 记录 K86.3 Android 键盘修正 2026-10-21 12:14:41 +08:00
Zhou Weidong 87c7f786d2 merge: 合并 K86.2 评论面板键盘修正留痕 2026-10-21 12:08:07 +08:00
Zhou Weidong 16e1657063 docs(progress): 记录 K86.2 评论面板键盘修正 2026-10-21 12:07:59 +08:00
Zhou Weidong f54f21be37 merge: 合并 K86.1 评论面板补修留痕 2026-10-21 12:01:10 +08:00
Zhou Weidong f357347d28 docs(progress): 记录 K86.1 评论面板补修 2026-10-21 12:01:00 +08:00
Zhou Weidong afe45fe8c5 merge: 合并 K86 评论面板计划与留痕 2026-10-21 11:53:35 +08:00
Zhou Weidong c415177287 docs(progress): 记录 K86 评论面板重做 2026-10-21 11:53:27 +08:00
Zhou Weidong 76d6cc9703 docs(plan): 添加 K86 评论面板重做计划 2026-10-21 11:49:56 +08:00
Zhou Weidong a674865538 merge: 合并 K85 评论数匿名放行补修 2026-10-21 11:46:34 +08:00
Zhou Weidong 708b39ffcc docs(progress): 记录 K85 评论数匿名放行补修 2026-10-21 11:46:25 +08:00
Zhou Weidong 7579d3163a fix(security): 放行评论数接口匿名访问 2026-10-21 11:46:18 +08:00
84 changed files with 7006 additions and 209 deletions

No files matched your search

+49 -13
View File
@@ -167,6 +167,7 @@ app.register.max-per-device-per-day
| --- | --- | --- | | --- | --- | --- |
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 | | GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
| GET | `/app/discover/feed` | 发现页分区 | | GET | `/app/discover/feed` | 发现页分区 |
| GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 |
| GET | `/app/categories` | 分类列表 | | GET | `/app/categories` | 分类列表 |
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) | | GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 | | GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
@@ -174,9 +175,25 @@ app.register.max-per-device-per-day
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`), > 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。 > 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
> >
> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串), > 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串)。
> 点赞、收藏取 `drama` 冗余计数字段,评论数由 `content_comment` 按 `drama_id` 批量统计, > `favoriteCount` 取整部剧;`likeCount` / `commentCount` 取卡片 `preview` 当前集的
> 排除逻辑删除与 `status = 'deleted'` 的记录。首页、发现、分类、收藏、搜索等列表卡片结构一致。 > `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`);
> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
>
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`,
> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。
#### 推荐类型流(`GET /app/feed`)
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。
- `categoryId` 可选;不传返回全部分类。传入后按 `category_ids` 整词匹配,
可与 `types` 叠加,只返回同时命中内容类型和分类的已上线短剧。
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
游标短剧必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
#### 短剧详情响应(`GET /app/dramas/{id}`) #### 短剧详情响应(`GET /app/dramas/{id}`)
@@ -208,6 +225,8 @@ app.register.max-per-device-per-day
"isFree": true, "isFree": true,
"locked": false, "locked": false,
"unlockCoin": 0, "unlockCoin": 0,
"likeCount": "45",
"commentCount": "8",
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..." "playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..."
} }
] ]
@@ -215,14 +234,16 @@ app.register.max-per-device-per-day
} }
``` ```
详情里的互动数量(K30 新增,供播放页右侧收藏/评论/点赞直接展示): 详情里的互动数量:
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `likeCount` | Long(JSON 字符串) | 取 `drama.like_count`,短剧累计点赞数 | | `likeCount` | Long(JSON 字符串) | 剧级历史汇总,保留兼容;新客户端按集使用 `episodes[].likeCount` |
| `favoriteCount` | Long(JSON 字符串) | 取 `drama.favorite_count`,短剧累计收藏数 | | `favoriteCount` | Long(JSON 字符串) | 取 `drama.favorite_count`,短剧累计收藏数 |
| `commentCount` | Long(JSON 字符串) | `content_comment` 中该剧未删除评论数:按 `drama_id` 统计,排除 `status = 'deleted'` 与逻辑删除记录 | | `commentCount` | Long(JSON 字符串) | `content_comment` 中该剧未删除评论数:按 `drama_id` 统计,排除 `status = 'deleted'` 与逻辑删除记录 |
| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 | | `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 |
| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` |
| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 |
计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发, 计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发,
客户端需要转成数字再展示(与 `viewCount` 相同)。 客户端需要转成数字再展示(与 `viewCount` 相同)。
@@ -243,7 +264,9 @@ app.register.max-per-device-per-day
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...", "playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
"qualities": ["AUTO"], "qualities": ["AUTO"],
"unlockCoin": 0, "unlockCoin": 0,
"previewSeconds": 0 "previewSeconds": 0,
"likeCount": "45",
"commentCount": "8"
} }
} }
``` ```
@@ -297,9 +320,9 @@ app.register.max-per-device-per-day
| --- | --- | --- | --- | | --- | --- | --- | --- |
| POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 | | POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 |
| GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 | | GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 |
| GET | `/app/comments/count?dramaId=` | 匿名 | 返回该剧未删除评论总数,`Long` 按字符串下发 | | GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 |
| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) | | DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) |
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `COMMENT` | | POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;新客户端点赞按集 |
| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 | | POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 |
**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。 **敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。
@@ -510,10 +533,10 @@ GET /app/notices?pageIndex=1&pageSize=20
| 方法 | 路径 | 权限点 | 说明 | | 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态筛选) | | GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态、`dramaTypes[]`、`categoryIds[]` 多选筛选) |
| GET | `/admin/dramas/{id}` | `drama:list` | 详情 | | GET | `/admin/dramas/{id}` | `drama:list` | 详情 |
| POST | `/admin/dramas` | `drama:create` | 新建短剧 | | POST | `/admin/dramas` | `drama:create` | 新建短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧 | | PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
| DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 | | DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 |
| POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 | | POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 |
| POST | `/admin/dramas/{id}/approve` | `drama:review` | 审核通过 | | POST | `/admin/dramas/{id}/approve` | `drama:review` | 审核通过 |
@@ -522,6 +545,15 @@ GET /app/notices?pageIndex=1&pageSize=20
| POST | `/admin/dramas/{id}/offline` | `drama:review` | 下线 | | POST | `/admin/dramas/{id}/offline` | `drama:review` | 下线 |
| GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 | | GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 |
多选字段说明:
- 保存优先使用 `dramaTypes[]` / `categoryIds[]`,去重并保持提交顺序;
`drama_type` / `category_id` 写第一个值,供旧客户端与旧接口兼容。
- 仅传 `dramaType` / `categoryId` 时保持原单值行为。
- 列表响应返回 `dramaTypes[]`、`categoryIds[]` 与按选中顺序映射的 `categoryNames[]`;
分类名缺失时回退 `genre`。
- 后台与 App 过滤均使用 `(',' || column || ',') LIKE '%,value,%'` 整词匹配。
**剧集** **剧集**
| 方法 | 路径 | 权限点 | 说明 | | 方法 | 路径 | 权限点 | 说明 |
@@ -573,6 +605,7 @@ GET /app/notices?pageIndex=1&pageSize=20
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 | | GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
| GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 | | GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 |
| GET | `/admin/app-users/{id}/watch-history` | `app-user:list` | 观看流水明细(带剧名/集号,客服核对用) | | GET | `/admin/app-users/{id}/watch-history` | `app-user:list` | 观看流水明细(带剧名/集号,客服核对用) |
| GET | `/admin/watch-history/page` | `watch-history:list` | 跨用户观看记录分页:keyword、userId、dramaTitle、dramaId、episodeId、source、finished、startTime、endTime |
| PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned | | PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned |
状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。 状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。
@@ -590,15 +623,18 @@ GET /app/notices?pageIndex=1&pageSize=20
| 方法 | 路径 | 权限点 | 说明 | | 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET | `/admin/comments/page` | `comment:list` | 检索评论,`status=pending` 即审核队列 | | GET | `/admin/comments/page` | `comment:list` | 检索评论;`status` 为空返回全部,`pending` 即审核队列 |
| GET | `/admin/comments/{id}/audit-records` | `comment:list` | 审核留证:机审与人审的完整链路 | | GET | `/admin/comments/{id}/audit-records` | `comment:list` | 审核留证:机审与人审的完整链路 |
| PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) | | PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) |
| DELETE | `/admin/comments/{id}` | `comment:audit` | 删除评论 | | DELETE | `/admin/comments/{id}` | `comment:audit` | 删除评论 |
| GET | `/admin/reports/page` | `report:list` | 举报工单检索(状态 / 类型 / 原因) | | GET | `/admin/reports/page` | `report:list` | 举报工单检索(状态 / 类型 / 原因),返回举报人、剧名、集号、集标题与目标摘要 |
| PUT | `/admin/reports/{id}/handle` | `report:handle` | 处理工单;结论成立会联动驳回被举报评论,可选择同时封禁被举报用户 | | PUT | `/admin/reports/{id}/handle` | `report:handle` | 处理工单;结论成立会联动驳回被举报评论,可选择同时封禁被举报用户 |
| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 | | GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 |
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) | | POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
后台评论行除 `dramaId/episodeId` 外,还返回 `dramaTitle/episodeNo/episodeTitle`;
剧或集已删除时对应展示字段留空。后台评论与举报的 `status` 空值均表示全部状态。
### 2.6 订单与权益(已实现) ### 2.6 订单与权益(已实现)
| 方法 | 路径 | 权限点 | 说明 | | 方法 | 路径 | 权限点 | 说明 |
@@ -0,0 +1,26 @@
# K100 后台剧集多选上传(分片 + 断点续传 + 批量建集)
> 日期:2026-10-21
> 范围:仅 `ReelShortWeb`;不改 FruitServer 后端、不改 ReelShort 客户端、不改接口契约。
## 目标
剧集管理页「批量上传」:多选视频 → 队列分片上传(文件并发 2 / 分片并发 3)→ 单项与字节加权总进度 → 失败隔离/可重试 → localStorage 断点续传 → 上传完成后批量创建分集。
## 步骤
1. 从 `develop` 拉 `feature/episode-batch-upload`。
2. 新建 `src/views/contentManagement/upload/uploadSessions.ts`:`reel_upload_sessions` 读写/过滤/清理。
3. 新建 `useUploadQueue.ts`:状态机、文件并发泵、进度加权、pause/retry/remove、complete 写回 assetId/url、挂接 session。
4. 新建 `UploadTaskRow.vue` / `BatchUploadDialog.vue` / `CreateEpisodesDialog.vue`。
5. `EpisodeManagementView.vue` 仅加「批量上传」入口,保留原单文件上传。
6. `zh-CN.ts` / `en.ts` 补齐文案;白名单 mp4/mov/mkv/webm;超限标红不入队;`beforeunload`。
7. 门禁:`pnpm lint:eslint` + `pnpm build:dev`。
8. 提交 `feat(admin): 剧集多选分片上传与断点续传` → `--no-ff` 合回 `develop` 并 push。
## 风险 / 待确认
- 续传需用户重新选择本地文件才能匹配;换机器/改名无法自动续传(产品已接受)。
- 暂停不 abort 进行中分片,已在传任务会跑完再停派发。
- `EpisodeManagementView.vue` 存量已超 500 行,本轮只加入口,不整体拆旧编辑器。
- 手工联调(多选/暂停/刷新续传/建集)由 PM 在 8080+3005 复核,本轮不操作真机、不 kill 已运行端口。
@@ -0,0 +1,59 @@
# K86 评论面板按参考图重做实施计划
## 目标
仅调整 ReelShort 客户端评论面板和评论行视觉,使顶部标题、评论元信息、回复入口和底部输入区接近参考图;不修改后端接口、控制器数据逻辑或评论业务能力。
## 范围
- `lib/pages/player/widgets/drama_comments_sheet.dart`
- `lib/pages/player/widgets/comment_tile.dart`
- `test/comment/drama_comments_sheet_test.dart`
- 必要的 PROGRESS 留痕
## 实现计划
1. 顶部栏
- 删除拖拽把手和剧名行。
- 左侧放置 44x44 向下箭头,保留 `player-comments-close`,点击关闭面板。
- 中间使用现有 `commentsWithCount` / `comments`,16px、w600、`AppColors.onSurface`,保留 `comment-count-label`。
- 右侧留空,保持标题视觉居中。
2. 评论行
- 头像改为 40px,回复行 28px;文字区左间距 12。
- 昵称 12px muted、正文 16px cream、行高 1.4。
- 时间与回复入口组合为灰色元信息行,时间继续使用 `formatLocalizedDateTime`。
- 删除点赞按钮、心形图标与数字展示,控制器点赞逻辑保留但不绑定 UI。
- 举报/删除入口改为右上角 18px 的更多图标,保留 `comment-menu-{id}` 和现有菜单行为。
- 去除行间分隔线,调整上下留白为 12/14。
3. 回复
- 展开/收起回复使用 12px muted 文本,前面增加 16x1 的 border 色短横线。
- 展开后的回复沿用相同评论行样式,并通过 40px 头像宽度加 12px 间距与正文对齐。
4. 输入区
- 输入框改为 44px 高、22px 圆角、`AppColors.commentInput` 底色。
- 保留登录提示、回复态 banner、橙色 38px 发送按钮及 `comment-post` Key。
- 保持现有键盘避让和面板高度 clamp。
5. 状态与交互
- 保留加载、错误、空态、待审核、展开回复、翻页、回复、删除、举报和发送逻辑。
- 保留登录闸、自动聚焦和登录后输入框聚焦行为。
## 测试
- 删除依赖 `comment-like-10` 的点赞展示用例。
- 断言不存在任何 `comment-like-*` 按钮或心形计数。
- 断言 `player-comments-close` 可关闭面板。
- 断言标题居中、字号和字重符合规格。
- 断言头像、正文、元信息、回复入口、输入框与发送按钮尺寸。
- 保持既有评论分页、登录、发布、展开回复、删除和举报用例通过。
## 门禁与 Git
- ReelShort 从 `develop` 创建 `fix/comments-sheet-style`。
- 提交:`fix(player): 评论面板按参考图重做`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 ReelShort `develop` 并 push,不使用 force push,不执行 `git add -A`。
- 门禁:`dart analyze lib test` 零告警、`flutter test` 全过。
- 不安装真机;完成后等待用户验收。
@@ -0,0 +1,46 @@
# K86.21 评论输入浮层稳定定位实施计划
## 目标
修复 Android 收起评论输入键盘时,输入浮层因键盘收起动画中的瞬时 inset 被逐帧 `max` 抬高而向上跳动的问题。评论列表面板、输入内容和关闭规则保持不变。
## 根因
- `CommentComposerSheet.build` 每次重建都用 `math.max` 累积当前 effective keyboard inset。
- IME 收起动画期间可能出现高于稳定值的瞬时 inset,累计后永久抬高 `_pinnedKeyboardInset`。
- 面板布局随后按更高的 pinned 值重新定位,造成输入浮层向上跳一小段。
## 范围
- ReelShort `lib/pages/player/widgets/comment_composer_sheet.dart`
- ReelShort `test/comment/drama_comments_sheet_test.dart`
- FruitServer `docs/plans/PROGRESS.md`
## 实现计划
1. 移除 `build` 中逐帧更新 `_pinnedKeyboardInset` 的逻辑,新增键盘可见状态和 80ms 稳定捕获 Timer。
2. effective inset 从 0 变为正数时启动 Timer;Timer 触发时只读取一次当前 inset,并取整后写入 pinned。
3. 键盘持续可见期间,后续 inset 变大或变小都不更新 pinned;inset 归零只取消 Timer 并重置可见状态,下一次弹起可重新捕获。
4. pinned 上限为屏幕高度的 60%;非有限值、负值或超过上限的异常值忽略,保证 pinned 不小于 0。
5. 保持 `Positioned(bottom: 0)`、白色 Material 铺到屏幕底部及 `Padding(bottom: pinned)` 布局,不使用位移或回落动画。
6. 返回键/IME 下箭头只收键盘;透明外部点击和发表成功仍为关闭入口。
## 测试计划
- 先新增失败测试:2200 高视口模拟 `0 → 1100(稳定)→ 1150(收起瞬时峰值)→ 600 → 0`,断言 1100 捕获后输入浮层 top、bottom、高度均保持不变。
- 运行旧实现确认红灯,记录实际偏移。
- 新实现后运行定向测试、完整评论面板测试文件、`dart analyze lib test` 和全量 `flutter test --no-pub`。
## 风险与处理
- 80ms 可能早于极慢 IME 动画结束;按任务要求采用固定稳定窗口,不在后续可见阶段继续累计。
- 超过屏幕 60% 的异常值被忽略后,需等下一次 0 → 正数弹起再捕获,避免错误定位。
- Timer 在 dispose 时取消,避免 Overlay 关闭后回调访问已卸载 State。
## Git
- ReelShort 从 `develop` 创建 `fix/composer-stable-inset`。
- 提交:`fix(player): 固定评论输入浮层位置避免收键盘跳动`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
@@ -0,0 +1,45 @@
# K86.22 评论输入浮层去除底部安全区位移实施计划
## 目标
修复 Android 收起评论输入键盘后,`SafeArea` 引入底部手势条 padding,导致输入区上移约 48px 的问题。输入区位置只由固定 `_pinnedKeyboardInset` 决定,键盘收起前后保持像素级一致。
## 根因
- 输入面板 Material 内使用 `SafeArea(top: false)`。
- 键盘可见时系统导航栏被 IME 覆盖,`MediaQuery.padding.bottom` 为 0。
- 键盘收起后手势条 padding 恢复,`SafeArea` 自动追加约 48px 底部内边距,把输入区顶高。
- K86.19 的白色实底和固定 12px 内容内边距本身正常,问题仅来自额外安全区。
## 范围
- ReelShort `lib/pages/player/widgets/comment_composer_sheet.dart`
- ReelShort `test/comment/drama_comments_sheet_test.dart`
- FruitServer `docs/plans/PROGRESS.md`
## 实现计划
1. 面板 `SafeArea` 设置 `bottom: false`,不再消费 `MediaQuery.padding.bottom`。
2. 保持 `Positioned(bottom: 0)`、白色 Material 铺到屏幕底部、顶部圆角 20。
3. 键盘区域继续由内部 `Padding(bottom: _pinnedKeyboardInset)` 决定,内容区底部保持固定 12。
4. 透明外部点击或发表成功仍为关闭入口;返回键/IME 下箭头只收键盘。
## 测试计划
- 先新增失败测试:视口 390×844,键盘弹起时 `viewInsets.bottom=180`、`padding.bottom=0`,记录输入框全局矩形。
- 收起键盘时同时设置 `viewInsets.bottom=0`、`padding.bottom=48`,断言输入框 top、bottom、height 偏移均小于 0.1。
- 旧实现预期因 SafeArea 多出 48px 而失败;修复后运行完整评论面板测试、静态分析和全量 Flutter 测试。
## 风险与处理
- 关闭底部 SafeArea 后,输入内容依赖固定 12px 底部内边距;与 K86.19 白色实底填充一致,不再叠加系统手势条。
- 面板本身仍延伸到屏幕底部,视觉圆角和白底不受影响。
- 不改 KeyboardInsetNotifier、pinned 稳定捕获、关闭规则或评论列表布局。
## Git
- ReelShort 从 `develop` 创建 `fix/composer-safearea`。
- 提交:`fix(player): 去除评论输入面板安全区导致的收键盘位移`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
@@ -0,0 +1,47 @@
# K87 下拉刷新完成后回弹实施计划
## 目标
将全局 `AppPullToRefresh` 改为刷新完成前保持下拉高度:松手达到阈值后内容停在 56px,显示“正在刷新”,`onRefresh` 完成后再用 200ms easeOut 回弹到 0。
## 根因
- 当前下拉位移主要由 `BouncingScrollPhysics` 的系统 overscroll 提供。
- 松手后滚动位置立即执行系统回弹,组件只保留一个小指示器。
- 刷新期间没有独立的内容保持位移,因此无法维持下拉状态。
## 范围
- ReelShort `lib/core/widgets/app_pull_to_refresh.dart`
- ReelShort `test/core/app_pull_to_refresh_test.dart`
- FruitServer `docs/plans/PROGRESS.md`
## 实现计划
1. 将 `AppPullToRefresh.physics` 从 Bouncing 改为 Clamping,阻止系统 overscroll 自行产生内容位移。
2. 使用 `AnimationController` 和 `Transform.translate` 统一控制内容位移;pull/armed 使用当前拖动距离,refreshing 固定 56,完成后回 0。
3. 松手达到阈值后,200ms easeOut 将当前距离过渡到 56;刷新期间忽略滚动通知并通过 `IgnorePointer` 阻止重复下拉。
4. `onRefresh` 完成后以 200ms easeOut 回到 0;指示器透明度跟随位移回弹淡出,结束后状态为 idle。
5. 检查全部 11 个使用方均传入 `AppPullToRefresh.physics`,保证全局组件行为一致。
## 测试计划
- 使用挂起的 `Completer` 模拟刷新未完成。
- 松手瞬间断言内容仍高于 hold,200ms 后稳定为 56,再等待 300ms 仍保持 56。
- 断言刷新状态显示 `CircularProgressIndicator` 与 `Refreshing…`。
- 刷新中再次下拉,断言 `onRefresh` 调用数仍为 1、offset 仍为 56。
- 完成 `Completer` 后断言 offset 回到 0、刷新指示器消失。
## 风险与处理
- 改为 Clamping 后不再依赖平台 overscroll 提供下拉视觉;内容位移完全由组件控制,避免双倍移动。
- 刷新期间阻止列表交互,避免用户手势破坏保持高度。
- 组件卸载时动画控制器会取消动画;异步刷新完成后仅在 mounted 时执行回弹收尾。
## Git
- ReelShort 从 `develop` 创建 `feat/pull-to-refresh-hold`。
- 提交:`feat(ui): 下拉刷新完成后才回弹`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
@@ -0,0 +1,46 @@
# K87.1 下拉刷新冻结位移实施计划
## 目标
修复刷新 pending 期间仍从松手距离回收到 56px 的视觉回弹。达到阈值后冻结当前下拉距离 D,刷新完成前 offset 不变化,完成后才用 200ms easeOut 回到 0。
## 根因
- K87 已将系统 physics 改为 Clamping,避免原生 overscroll 回弹叠加。
- 但 `_refresh` 仍先执行 `_animateOffsetTo(_holdDistance)`,会把松手时的 D(例如 96)回收到 56。
- 用户感知为“页面已经弹回去”,实际是组件自身在刷新开始时主动缩短 offset。
## 范围
- ReelShort `lib/core/widgets/app_pull_to_refresh.dart`
- ReelShort `test/core/app_pull_to_refresh_test.dart`
- FruitServer `docs/plans/PROGRESS.md`
## 实现计划
1. 保留 `AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics())`,系统不移动 overscroll 内容。
2. 删除 `_holdDistance` 与刷新前的 56px 过渡动画。
3. 达到阈值松手后立即进入 refreshing,AnimationController 保持当前 D;刷新期间滚动通知和手势均忽略。
4. `onRefresh` 完成后再从 D 执行 200ms easeOut 回 0,指示器随 offset 淡出。
5. 未达阈值松手时新增 `_retracting` 状态,执行 200ms easeOut 回到 0,不触发 `onRefresh`。
## 测试计划
- 达阈值测试:记录松手 offset D,挂起 300ms 后仍等于 D;刷新中重复下拉不改变 D、不增加调用。
- complete 后断言 offset 回 0、指示器消失。
- 未达阈值测试:松手后立即开始回弹,100ms 时 offset 介于 0 与 D 之间,最终回 0 且 `onRefresh` 未调用。
- 断言 `AppPullToRefresh.physics.parent` 为 `ClampingScrollPhysics`。
## 风险与处理
- 保持 D 时下拉越高,内容下移越明显,但这是用户松手时的真实位置,刷新完成后才回收。
- 未达阈值回弹期间通过 `_retracting` 忽略新滚动通知,避免动画目标被手势覆盖。
- 不逐个页面改动,所有使用共享 physics 与组件的页面自动生效。
## Git
- ReelShort 从 `develop` 创建 `fix/pull-refresh-freeze`。
- 提交:`fix(ui): 下拉刷新刷新期间冻结下拉位移`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
@@ -0,0 +1,51 @@
# K88 内容类型、互动数据编辑与分类 Feed 实施计划
## 目标
- 后台短剧管理支持内容类型和互动数据编辑:收藏、点赞、热度可编辑,评论数实时统计只读。
- 新增 `drama.drama_type`,取值为 `ANIME` / `LIVE`,默认 `LIVE`。
- App 新增 `GET /app/feed`,支持 `ANIME,LIVE` 多选类型,不传类型时返回推荐全部。
## FruitServer A
1. 数据库
- 新增 `sql/migrate-k88-drama-type.sql`,只提交迁移文件,不执行数据库变更。
- `sql/schema.sql` 的 drama 建表语句补 `drama_type`,默认 `LIVE`。
2. 后台管理
- `Drama`、`DramaSaveReqVO`、`DramaRespVO` 增加 `dramaType`。
- `DramaSaveReqVO` 增加可空 `favoriteCount`、`likeCount`、`heat`;服务层校验非负,`heat` 映射 `view_count`。
- create 默认内容类型 LIVE、互动数 0;update 仅覆盖请求非 null 的互动字段。
- 后台分页增加 `dramaTypes` 多选过滤,并保持 CommentService 批量回填评论数。
3. App Feed
- `AppDramaCardVO` 增加 `dramaType`,现有 home/discover/detail 仅新增字段。
- `GET /app/feed` 支持 `types`、`cursor`、`size`;size 默认 20、最大 50。
- 排序为 `recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- cursor 使用最后一条 drama id,服务端反查其 recommendWeight/viewCount/id,再按三元组做 keyset 查询,避免 offset 翻页重漏。
- 卡片复用现有 `toCards`,确保 preview、互动数、评论数与首页一致。
- 更新 `docs/api-phase1.md` 的 dramaType 与 `/app/feed` 契约。
4. 测试
- `/app/feed`:全部、ANIME、ANIME+LIVE、游标翻页不重不漏。
- 后台 update:部分字段更新、heat 写 viewCount、负数与非法 dramaType 报错。
## ReelShortWeb B
1. 类型定义补齐 dramaType、favoriteCount、likeCount、commentCount、heat。
2. 短剧列表增加内容类型筛选和标签列,播放量列改为热度,增加收藏/点赞/评论列。
3. 编辑弹窗增加必填内容类型、互动数据区块;评论数只读。
4. 不改审核、发布、删除逻辑。
## 风险与处理
- 推荐排序包含可空 recommend_weight,SQL 与 Java 条件必须显式处理 NULLS LAST。
- 三字段排序不能只按 id 做 cursor,需以 cursor id 反查上一页最后一条的排序值。
- 存量数据不主动修改,迁移默认 LIVE;数据库脚本由 PM 执行。
- 全部改动只使用显式文件暂存,不执行 `git add -A`,不改 specs 与 Flutter 客户端。
## 门禁与 Git
- FruitServer:`mvn test` 全过;提交 `feat(content): 新增内容类型与首页分类 feed`;`--no-ff` 合回 develop 并 push。
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 短剧列表展示并编辑互动数据`;`--no-ff` 合回 develop 并 push。
- 作者统一 `Zhou Weidong <502333560@qq.com>`;各自 PROGRESS 追加 K88 留痕。
+59
View File
@@ -0,0 +1,59 @@
# K89 点赞/评论按集、收藏/热度按剧实施计划
## 目标
- 收藏、热度继续归属整部剧。
- 点赞、评论归属具体剧集:新增 `drama_episode.like_count`,`content_like` 使用 `EPISODE` 目标。
- App 卡片、详情、播放响应按当前集装配点赞/评论;后台短剧/剧集页提供对应汇总与只读展示。
## FruitServer A
1. 数据库
- 新增 `sql/migrate-k89-episode-like.sql`,只提交不执行。
- `sql/schema-phase1.sql` 的 drama_episode 增量字段补 like_count,默认 0。
2. 分集点赞
- `DramaEpisode` 增加 likeCount;`EpisodeService` 提供原子增减与批量 SUM。
- `LikeService` 增加 `TYPE_EPISODE`,校验已上线剧集的已上线分集,切换时同步分集计数,返回该集最新值。
- 保留 DRAMA/COMMENT 点赞兼容。
3. 分集评论
- `CommentService.countByEpisodes` 按 episode_id 批量分组统计未删除评论。
- `/app/comments/count` 增加可选 episodeId;传集数返回该集,只有 dramaId 时维持全剧兼容语义。
4. App 装配
- 卡片 preview 集的 likeCount/commentCount 写入卡片;favoriteCount/viewCount 仍取剧。
- 详情 episodes[] 每项增加 likeCount/commentCount。
- 播放响应增加 episodeId 对应的 likeCount/commentCount。
5. 后台
- EpisodeRespVO 增加本集 like/comment 与所属剧 favorite/heat;Controller.list 批量回填。
- EpisodeSaveReqVO 增加可空 likeCount,update 仅覆盖非 null,负数 BAD_REQUEST。
- DramaRespVO likeCount 改为分集 SUM,commentCount 保持全剧汇总;分页一次批量聚合避免 N+1。
- 更新 API 契约。
6. 测试
- EPISODE 点赞 +1/-1、不越界、目标不存在。
- App 卡片按 preview 集、剧级 favorite/heat。
- 后台分集列表计数与剧级收藏/热度。
- 短剧分页 likeCount 为分集汇总。
## ReelShortWeb B
1. EpisodeItem / EpisodeSaveData 增加对应互动字段。
2. 剧集表格增加本集点赞、评论,以及所属剧收藏、热度列。
3. 编辑弹窗增加本集点赞输入和评论只读;“按集实时统计”。
4. 短剧管理保持收藏/热度可编辑,点赞/评论展示为后端只读汇总。
## 风险与处理
- 存量 `drama.like_count` 保留不删,不回填分集;新展示以分集 SUM 为准。
- App 评论数按 preview 集统计,只有 episodeId 为空的历史评论仍计入全剧汇总。
- 评论/点赞接口保持匿名/登录既有安全语义,不新增第三方依赖。
- 迁移脚本由 PM 执行;本地门禁不访问真实数据库。
## 门禁与 Git
- FruitServer:`mvn test` 全过;提交 `feat(interaction): 点赞与评论按集统计`;`--no-ff` 合回 develop 并 push。
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 剧集列表展示分集互动数据`;`--no-ff` 合回 develop 并 push。
- 作者统一 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`;各自 PROGRESS 追加 K89 留痕。
+114
View File
@@ -0,0 +1,114 @@
# K91 后台观看记录独立菜单、评论分集展示与全部状态筛选
## 目标
- FruitServer 新增跨用户观看记录分页接口,并给后台评论补充短剧名、集号与集标题。
- ReelShortWeb 新增「观看记录」独立页面,评论/举报状态筛选默认展示全部。
- 迁移脚本只提交文件,不执行数据库变更;不改 specs 仓库和 Flutter 客户端。
## 分支
- FruitServer:`feat/k91-watch-history`,从 `origin/develop` 创建。
- ReelShortWeb:`feat/k91-watch-history-page`,从 `origin/develop` 创建。
## A. FruitServer
### A1 观看记录分页
1. 新增 `WatchHistoryQueryReqVO`,字段:
`pageIndex/pageSize/keyword/userId/dramaTitle/dramaId/episodeId/source/finished/startTime/endTime`。
2. 新增 `AdminWatchHistoryVO`,返回用户、短剧、分集、进度、完成、来源、设备与观看时间。
3. 新增 `WatchHistoryAdminService`,直接注入:
- `WatchHistoryMapper`
- `AppUserMapper`
- `DramaMapper`
- `DramaEpisodeMapper`
4. 查询流程:
- `keyword` 先批量命中 `app_user.uid/nickname` 的 id;无命中直接空页。
- `dramaTitle` 先批量命中 `drama.title` 的 id;无命中直接空页。
- 精确 id 条件与模糊命中集合取交集。
- `finished` 在 SQL 层表达,允许区间两侧都落在 `duration_ms = 0` 等边界。
- 按 `watched_at DESC, id DESC` 排序。
- 对结果页的用户、剧、集各做一次批量查询后装配,禁止逐行查询。
5. 新增 `WatchHistoryAdminController`,权限点 `watch-history:list`,保留旧用户详情观看流水接口。
### A2 评论后台分集信息
1. `CommentRespVO` 增加 `dramaTitle/episodeNo/episodeTitle`。
2. 仅 `CommentService.adminPage` 对当前页批量补剧、集信息。
3. `toRespList` 的 C 端路径保持不增加查询。
### A3 SQL 与契约
1. 新增幂等迁移 `sql/migrate-k91-watch-history-menu.sql`:
- 权限点 `1105 watch-history:list`
- 菜单使用 id `34`,挂 parent `15`
- `component=views/userManagement/WatchHistoryView`
- 超管角色权限与菜单关联
2. 同步 `sql/data-phase1.sql`。
3. 更新 `docs/api-phase1.md` §2.3、§2.5。
### A4 测试
- `WatchHistoryAdminServiceTest` 覆盖:
- 默认分页与排序
- keyword 用户命中
- dramaTitle 剧名命中
- finished 真/假 SQL 条件
- 时间范围
- 命中集合为空直接空页
- pageSize 上限收敛为 100
- `CommentServiceTest` 覆盖后台页批量补齐剧名、集号、集标题。
- 门禁:`mvn test` 全模块通过。
## B. ReelShortWeb
### B1 类型/API
- 扩展 `WatchHistoryItem`:`userId/uid/nickname/episodeTitle`。
- 扩展 `CommentItem`:`dramaTitle/episodeNo/episodeTitle`。
- `appUser.ts` 新增 `pageWatchHistoryApi(params)`。
### B2 观看记录页面
- 新建 `src/views/userManagement/WatchHistoryView.vue`:
- 用户、短剧名称、来源、完成状态、时间范围筛选。
- 观看时间、用户、短剧、分集、进度、完成、来源、设备列。
- 10/20/50 分页。
- 复用 App 用户页的 `panel/console-toolbar/pagination/app-user-*` 视觉与格式化函数。
### B3 评论/举报全部状态
- 评论与举报状态首项增加 `{ value: '', label: '全部状态', tag: 'info' }`。
- 两个筛选默认值改为空串,请求时空串转 `undefined`。
- 评论表以「短剧 / 分集」替换短剧 ID 单列:
- 显示剧名与集号/集标题。
- 缺少剧名时回退短剧 id。
- 缺少集号时回退分集 id;历史 `episodeId=0` 显示未关联分集。
- 原始短剧 id / 分集 id 通过 tooltip 保留。
### B4 门禁
- `pnpm ts:check`
- `pnpm build:dev`
## 风险与待确认
1. `watch_history` 是月分区表,现有索引为 `(user_id, watched_at)` 与
`(drama_id, watched_at)`,没有跨用户的全局 `watched_at` 索引。本次按要求实现跨用户时间排序,
不擅自新增索引;数据量增长后由 PM 评估是否增加全局观看时间索引。
2. 用户/剧名模糊检索先取 id 集合,符合“命中为空不退化成全表”的要求;
超大关键词命中集合的批大小由后续实际数据量评估,本次不擅自加截断以免漏结果。
3. 历史评论的 `episode_id=0` 不回填;后台显示“未关联分集”,原始 id 仍保留在 tooltip。
4. 迁移脚本只写文件,不执行数据库;菜单落库由 PM 按发布流程执行。
5. 任务书建议菜单 id `26`,但当前 `data-phase1.sql` 已将 `26` 用作“创作者中心”目录,
`27-30` 为其子菜单。为避免覆盖线上已有菜单树,本次使用下一个空闲 id `34`;
父级、路由、组件与权限点均不变。
## 提交与合并
- FruitServer 提交:`feat(admin): 新增观看记录分页与评论分集信息`
- ReelShortWeb 提交:`feat(admin): 观看记录独立菜单与治理状态全部筛选`
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
- 作者与提交者:`Zhou Weidong <502333560@qq.com>`。
- 禁止 `git add -A`,所有文件逐项暂存。
+108
View File
@@ -0,0 +1,108 @@
# K92 举报工单展示短剧名称与分集
## 目标
- `/admin/reports/page` 不再只返回 `targetType / targetId`,而是返回举报人、目标短剧与分集的展示信息。
- Web 举报工单列表与处理弹窗使用同一份目标摘要,不再展示裸对象 ID。
- 不改既有举报处理逻辑、评论列表接口、观看记录接口或数据库结构。
## 分支
- FruitServer:`feat/k92-report-target`,从 `origin/develop` 创建。
- ReelShortWeb:`feat/k92-report-target-ui`,从 `origin/develop` 创建。
## A. FruitServer
### A1 返回模型
新增 `com.fruitshorts.admin.vo.AdminReportRespVO`,包含:
- 工单:`id/reasonCode/description/status/handleBy/handleAt/handleNote/createdAt`
- 举报人:`reporterId/reporterNickname/reporterUid`
- 目标:`targetType/targetId/targetSummary`
- 剧集:`dramaId/dramaTitle/episodeId/episodeNo/episodeTitle`
- 评论/用户:`commentContent/targetNickname/targetUid`
### A2 查询和解析
新增 `com.fruitshorts.admin.service.ReportAdminService`:
1. 分页查询 `content_report`,条件与 `ReportService.page` 保持一致:
`status/targetType/reasonCode/reporterId` 均仅在有文本或非空时加入,空值返回全部;
排序保持 `id DESC`。
2. 当前页批量查询:
- COMMENT 目标评论:一次查询,且必须包含逻辑删除记录,用于判断“评论已删除”。
- 评论与 DRAMA 目标剧:一次批量查询 `drama`。
- 评论目标分集:一次批量查询 `drama_episode`。
- USER 目标用户与全部 reporter:合并 id 后一次批量查询 `app_user`。
3. 禁止逐行查询。
`ContentCommentMapper` 增加显式批量查询方法,绕过 `@TableLogic` 的自动过滤;
普通 `selectBatchIds` 仍保持原行为。
### A3 摘要规则
- COMMENT:
- 正常评论且剧存在:`剧名 · 第 N 集`,有集标题时追加 ` · 集标题`。
- 评论删除、逻辑删除、评论不存在或剧不存在:`评论 #targetId`。
- DRAMA:
- 剧存在:剧名。
- 不存在:`短剧 #targetId`。
- USER:
- 用户存在:`昵称(uid)`。
- 不存在:`用户 #targetId`。
### A4 Controller 与文档
- `ReportAdminController.page` 改为返回 `Result<PageResult<AdminReportRespVO>>`。
- `handle` 继续调用原 `ReportService.handle`,不变更业务行为。
- 更新 `docs/api-phase1.md` §2.5。
### A5 测试
新增 `ReportAdminServiceTest`,覆盖:
- COMMENT / DRAMA / USER 三类摘要。
- 评论集标题、举报人昵称/uid、目标用户昵称/uid。
- 空筛选条件表示全部。
- 评论逻辑删除和目标剧不存在时退化文案。
- 验证按页批量查询,不产生 N+1。
门禁:`mvn test` 全模块通过。
## B. ReelShortWeb
### B1 类型
`ReportItem` 补齐后台新增字段,保留原 `targetId` 用于 tooltip 排查。
### B2 治理页
1. 「对象」列:
- 中文类型标签:评论 / 用户 / 短剧。
- 显示 `targetSummary`。
- tooltip 显示原始 `#targetId`,评论类型有内容时追加评论内容。
2. 删除独立「对象ID」列。
3. 「举报人」列显示昵称;无昵称回退 uid;再无回退 `#reporterId`。
4. 处理弹窗「工单对象」显示类型标签 + `targetSummary`。
### B3 门禁
- `pnpm ts:check`
- `pnpm build:dev`
- 对改动文件执行定向 ESLint
## 风险与说明
1. 评论逻辑删除记录默认被 MyBatis-Plus 过滤;本次使用显式 mapper 查询识别,
不改变评论正常查询和删除行为。
2. 已删除评论不展示正文,避免后台误读已删除内容;仍保留 `targetId` tooltip 便于排查。
3. 目标关联数据已物理删除时按任务书使用 `评论 #id / 短剧 #id / 用户 #id` 退化。
4. 本任务无数据库迁移,不改 specs 仓库和 Flutter 客户端。
## Git
- FruitServer:`feat(admin): 举报工单返回短剧与分集信息`
- ReelShortWeb:`feat(admin): 举报列表展示短剧与分集`
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
@@ -0,0 +1,121 @@
# K93 短剧类型与内容类型支持多选
## 目标
- 后台短剧基础信息中的「类型」「内容类型」改为至少选一项的多选。
- 后台列表筛选、列表展示支持多分类与多内容类型。
- App 分类分区和内容类型 feed 使用多标签字段做整词匹配。
- 单值字段继续保留,兼容创作者中心、旧接口和现有 App 卡片。
## 分支
- FruitServer:`feat/k93-drama-multi-tag`,从 `origin/develop` 创建。
- ReelShortWeb:`feat/k93-drama-multi-tag-ui`,从 `origin/develop` 创建。
## A. FruitServer
### A1 数据库
- 新增 `sql/migrate-k93-drama-multi-tag.sql`:
- `drama.drama_types VARCHAR(32) NOT NULL DEFAULT ''`
- `drama.category_ids VARCHAR(255) NOT NULL DEFAULT ''`
- 从现有 `drama_type / category_id` 幂等回填。
- 同步 `sql/schema.sql` 的 drama 建表字段。
- 只提交迁移文件,不执行数据库。
### A2 实体与 VO
- `Drama`:增加 `dramaTypes/categoryIds` 逗号字符串。
- `DramaSaveReqVO`:
- 新增 `List<String> dramaTypes`、`List<Long> categoryIds`,列表优先。
- 保留 `dramaType/categoryId` 单值兜底。
- `DramaPageReqVO`:增加 `List<Long> categoryIds`,保留 `categoryId`。
- `DramaRespVO`:增加 `dramaTypes/categoryIds/categoryNames`,旧单值字段语义不变。
### A3 保存规则
- 内容类型列表:normalize、去重、保持提交顺序;主值写第一个,完整列表写 `drama_types`。
- 分类列表:过滤 null/非正数、去重、保持提交顺序;主值写第一个或 0,完整列表写 `category_ids`。
- 列表为空时回退单值;单值也没有时保留当前值。
- 创建时内容类型最终为空则默认 LIVE。
- 非法内容类型继续返回 BAD_REQUEST。
### A4 查询规则
统一使用整词匹配:
```sql
(',' || column || ',') LIKE '%,value,%'
```
- 后台分页:
- `dramaTypes` 非空:`drama_types` token 匹配。
- `categoryIds` 非空:`category_ids` token 匹配。
- `categoryIds` 为空:回退 `category_id` 等值。
- App `listPublishedFeed`:`drama_types` token 匹配。
- App feed 游标:marker 的解析后 `dramaTypes` 与任一筛选类型相交才允许继续。
- App `listPublished(categoryId)`:`category_ids` token 匹配,使多分类剧出现在每个分类分区。
### A5 响应装配
- 解析 `drama_types/category_ids`,空值回退单值。
- `categoryNames` 按 categoryIds 顺序映射分类名。
- 分类名缺失时回退 `genre`。
- 现有 `dramaType/categoryId/category` 保持旧语义。
### A6 文档与测试
- 更新 `docs/api-phase1.md` 的后台保存/分页字段、App feed 和分类说明。
- 增加服务测试覆盖:
- 多类型/多分类去重、顺序与主值写回。
- 单值兜底行为不变。
- 后台分页 token 条件。
- feed 多标签 marker 可被 ANIME/LIVE 命中。
- App 分类分区 token 条件。
- 响应多值解析和分类名顺序。
- 门禁:`mvn test` 全模块通过。
## B. ReelShortWeb
### B1 类型
- `DramaItem` 增加 `dramaTypes/categoryIds/categoryNames`。
- `DramaSaveData` 增加 `dramaTypes/categoryIds`。
- `DramaPageParams` 增加 `categoryIds`。
### B2 表单
- `form.categoryIds: number[]`,默认空数组。
- `form.dramaTypes: string[]`,创建默认 `['LIVE']`。
- 两个下拉均使用 multiple、collapse-tags、collapse-tags-tooltip、filterable。
- 校验至少 1 项。
- 编辑时优先数组,缺失回退单值。
- 提交数组;`genre` 使用所选分类名以 ` / ` 拼接。
### B3 筛选与表格
- 分类筛选改为 `categoryIds: number[]`。
- 内容类型筛选改为 `dramaTypes: string[]`。
- 查询传数组,重置为空数组。
- 类型列显示 `categoryNames.join(' / ')`,回退 `genre`。
- 内容类型列逐个渲染标签,缺失时回退 `dramaType`。
### B4 门禁
- `pnpm ts:check`
- `pnpm build:dev`
- 对修改文件执行定向 ESLint
## 风险与说明
1. 新代码依赖迁移后的 `drama_types/category_ids` 列,发布顺序必须先执行迁移再启动新版本。
2. 逗号列仅存储受控内容类型和数字 id,不允许用户输入;查询值均来自校验后的数组。
3. 主值继续保留,降低旧客户端、创作者中心和旧接口的兼容风险。
4. 本次不改 specs 与 Flutter 客户端,不执行数据库迁移,不操作真机。
## Git
- FruitServer:`feat(content): 短剧类型与内容类型支持多选`
- ReelShortWeb:`feat(admin): 短剧类型与内容类型多选`
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
+126
View File
@@ -0,0 +1,126 @@
# K94 首页内容类型 Feed 与无感切换
## 目标
- 首页顶栏「漫剧 / 真人剧 / 推荐」接入既有 `/app/feed`。
- 每频道独立缓存与分页,切换时先同步展示缓存,再后台静默刷新。
- 推荐首屏复用 `/app/home/feed` 打平数据,首次进入不额外转圈。
- 无缓存频道只在黑色内容区显示小转圈,顶栏和底栏保持。
- 滑动接近末尾自动加载下一页,并在播放器队列中原地追加。
## 分支
- ReelShort:`feat/k94-home-type-feed`,从 `origin/develop` 创建。
- FruitServer 仅文档留痕,不改功能代码。
## A. 数据层
### A1 FeedPage
新增 `lib/core/data/models/feed_page.dart`:
- `dramas` 使用 `Drama.fromCardJson`。
- `nextCursor` 兼容数字/字符串,空值或字符串 `"null"` 转为 null。
- `hasMore` 读取布尔值。
### A2 CatalogApi / Repository
- `CatalogApi.feed({types, cursor, size})` 请求 `/app/feed`。
- types 为空不传;非空用逗号拼接。
- cursor 为空不传。
- 返回结构异常抛 `ApiException(-1, '返回数据格式异常')`。
- `CatalogRepository` 与 HTTP 实现增加 `fetchFeed`。
- 所有测试 CatalogRepository 实现同步补齐方法。
## B. 频道控制器
新增:
- `lib/pages/home/home_feed_channel.dart`
- `lib/core/data/services/home_feed_controller.dart`
`HomeFeedController` 使用每个频道独立状态:
- dramas / cursor / hasMore / loaded / loadingMore / requestSeq
- 当前 channel,默认 recommend
- `seedRecommend`:同步写入推荐首屏,不发请求
- `selectChannel`:同步切频道并 notify;无缓存加载第一页,有缓存静默刷新
- `refreshCurrent`:失败保留旧列表
- `loadMore`:cursor 追加,按 drama.id 去重
- 所有响应必须同时满足“仍为当前频道 + requestSeq 最新”才应用
- dispose 后停止 notify
## C. 首页接入
### C1 顶栏受控
`HomeFeedHeader`:
- 删除内部 tab 状态、comingSoon 和 SnackBar。
- 新增 `channel` 与 `onChannelSelected`。
- 三个 Key 保持不变。
- refresh 内部 loading 状态保留。
### C2 HomePage
- `_HomePageState` 用 `CatalogController.repository` 创建 HomeFeedController。
- Catalog 首次有数据时同步 seed recommend,随后在 post-frame 静默刷新 recommend。
- `_HomeTabs` 使用稳定 Key,删除 `ValueKey(catalog.dramas.first.id)`。
- 当前频道无数据时,保留 `_HomeTabs` 顶栏/底栏,body 用黑色背景 + 居中
`home-feed-channel-loading`。
- 当前频道有数据时把频道列表传给 PlayerPage。
- 刷新回调改 `refreshCurrent`;分页回调改 `loadMore`。
### C3 PlayerPage / PlayerLogic
- PlayerPage 增加 `feedChannel` 和 `onLoadMore`。
- 同频道 feed 列表变化:`updateFeed(..., keepDramaId: currentDramaId)`。
- channel 变化:`updateFeed(..., keepDramaId: null)`,回到新频道第 1 个。
- PlayerLogic 保存 factory/coordinator,支持需要换内容时替换 PlaybackSession。
- 空列表清空 queue/visibleIndices 并停止旧播放。
- 当前剧仍在新列表且索引不变时,只扩展队列与 episode 列表,不重新 select。
- PageView 在 `position >= visibleIndices.length - 3` 时调用 onLoadMore。
## D. 测试
新增 `test/core/data/services/home_feed_controller_test.dart`:
- recommendation seed 不发请求
- 三频道请求 types 分别为 `[] / [ANIME] / [LIVE]`
- 缓存频道切换不清空并后台刷新
- loadMore 追加去重、无更多后不再请求
- 串包只接受最新频道请求
- refresh 失败保留旧数据
新增顶栏 widget 测试:
- 三个 Key 存在
- 点击分别回调 anime/live/recommend
- 当前 channel 决定选中样式
PlayerLogic 测试补充:
- updateFeed 空列表安全
- 追加列表保持当前剧
- 切频道列表回到第 1 个
门禁:
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 风险与控制
1. PlaybackSession 的 episodes 是构造时绑定的列表;分页原地追加时会同步扩展该列表,
避免仅换 queue 而 session 仍指向旧列表。
2. 换频道可能释放并重建播放器 session,这是内容源变化;静默刷新/分页在顺序不变时
不重新 select 当前剧,避免视频重启。
3. 推荐 seed 只保证首帧立即有内容;后台 `/app/feed` 刷新失败时保留 seed。
4. 首页仍在 `/app/home/feed` 首次加载期间使用既有启动页;频道切换不再触发整页 splash。
## Git
- 提交:`feat(home): 首页按内容类型分频道 Feed 并支持分页`
- `--no-ff` 合回 ReelShort `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
@@ -0,0 +1,100 @@
# K95 修复首页频道快速切换卡 Loading
## 目标
- 频道请求完成后始终写回发起请求的频道缓存,不再因用户切走而丢失结果。
- 每频道明确区分 loaded / loading / in-flight / error,无请求时绝不显示首屏转圈。
- 首屏和刷新请求增加可注入的 10 秒超时兜底。
- PlayerLogic 保证连续 feed 更新串行应用,只让最后一次 feed/session 生效。
## 分支
- ReelShort:`fix/k95-feed-switch-loading`,从 `origin/develop` 创建。
- FruitServer 仅提交计划与 PROGRESS,不改功能代码。
## A. HomeFeedController
每个频道状态增加:
- `List<Drama> dramas`
- `String? cursor`
- `bool hasMore`
- `bool loaded`
- `bool loading`
- `bool loadingMore`
- `String? lastError`
- `int requestSeq`
- `Future<bool>? inFlight`
请求规则:
1. 响应成功/失败都写入发起请求的频道状态。
2. 只有频道仍为当前频道时才 `notifyListeners`。
3. `requestSeq` 只比较同一频道的新旧请求,不参与跨频道过滤。
4. 超时时间通过构造参数注入,默认 10 秒。
5. `loadingFirstPage = !loaded && loading`。
6. `selectChannel`:
- 有缓存:同步显示,未在飞时后台静默刷新
- 无缓存且正在飞:等待现有请求,不重复发
- 无缓存且有历史错误:自动重试
- 从未加载:发起首屏请求
7. `refreshCurrent` 同样复用 in-flight,防止并发重复刷新。
8. `loadMore` 失败保留 cursor/hasMore,允许下次继续重试。
## B. PlayerLogic / PlayerPage
### B1 Feed revision
- PlayerLogic 增加 `int feedRevision`。
- 每次 `updateFeed` 自增 revision,记录最新目标 feed。
- `_replaceSession` 与 `selectEpisode` 在 await 后校验 revision,过期流程直接返回。
- 连续 updateFeed 使用串行队列:当前应用结束后再应用最新待处理 feed。
### B2 空队列与频道切换
- 空 feed 不创建新 session,只清空 queue/visibleIndices 并停播。
- 空 → 非空必须恢复 route visible,并 select 第 0 个。
- 频道变化通过额外 reset 标志传入 `updateFeed`,即使 drama id 列表相同,
也重置到第 1 个并开始播放。
### B3 PlayerPage 更新判定
- `didUpdateWidget` 用 `feedDramas` 的 drama id 列表签名判断内容变化。
- 同一列表的新 List 实例不重复 updateFeed。
- 频道变化始终触发 updateFeed,并传 `resetToFirst=true`。
## C. 测试
### HomeFeedController
- A 挂起 → B 成功 → A 成功:A/B loaded 均为 true,切回 A 立即有数据。
- 同频道旧失败响应不得覆盖新成功响应。
- 无在飞请求时 `loadingFirstPage=false`。
- 永不返回的请求在注入超时后结束 loading、写 lastError、loaded=true。
- 失败频道再次 select 自动重试。
- loadMore 失败保留原有 hasMore/cursor。
### PlayerLogic
- 连续 5 次 updateFeed,最终 queue/session 对应最后一次列表。
- 空 → 非空会创建 session 并 select 第 0 个。
- 频道变化时相同 id 列表也回到第 1 个。
门禁:
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 风险与控制
1. 跨频道响应写回缓存必须与同频道 seq 校验同时存在;前者解决缓存丢失,后者解决同频道旧请求覆盖新结果。
2. Player session 替换以 revision 作为唯一有效性判断,避免旧 close/select 回调干扰新 session。
3. 空队列期间不创建 PlaybackSession;恢复非空时重新创建并起播。
4. 不改后端、ReelShortWeb、specs;不安装或操作真机。
## Git
- 提交:`fix(home): 修复频道快速切换卡 loading`
- `--no-ff` 合回 ReelShort `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
@@ -0,0 +1,83 @@
# K95.1 首次切换频道无感显示
## 目标
- 推荐首屏成功后后台预取 anime/live,避免第一次切换出现阻断式 loading。
- 目标频道尚未返回时保留上一频道 PlayerPage 和视频画面,只叠加轻量 loading 遮罩。
- 数据到达后原子替换为新频道 feed,并从第 1 个播放。
## 分支
- ReelShort:`fix/k95.1-seamless-first-switch`,从已包含 K95 的 `origin/develop` 创建。
- FruitServer 仅文档留痕,不改功能代码。
## A. HomeFeedController
新增 `Future<void> prefetchChannels()`:
- 仅处理 anime/live。
- 只对 `!loaded && !loading` 的频道请求。
- 请求结果写回对应频道缓存。
- 不切换当前频道、不改变当前列表;仅目标恰为当前频道时 notify。
- 失败只写 lastError,不向外抛。
- 成功预取设置一次性 `skipNextRefresh`:首次切到该频道直接使用缓存,不立刻重复静默刷新;
后续再次切回仍执行正常缓存静默刷新。
- 预取期间用户切换到该频道时等待已有 inFlight,不重复请求。
## B. HomePage / _HomeTabs
### B1 最近可见 feed
`_HomeTabsState` 维护:
- `List<Drama>? _visibleDramas`
- `HomeFeedChannel? _visibleChannel`
当前频道有数据时更新这两个值,并以当前频道渲染 PlayerPage。
### B2 首次切换
当前频道无数据且 loading:
- 继续复用上一频道 `_visibleDramas/_visibleChannel` 渲染同一个 PlayerPage。
- header 使用当前选中频道,但 PlayerPage.feedChannel 仍使用可见旧频道,避免队列更新。
- Stack 上叠加 `IgnorePointer + ColoredBox(black26) + 24px spinner`,
Key 为 `home-feed-channel-loading`。
- 数据返回后页面树保持同一层级的 PlayerPage,传入新 feed/channel,触发原子更新和第 1 个起播。
- 空结果/失败后才切到「暂无剧目 / 重试」占位。
### B3 Prefetch 触发
推荐首屏后台刷新成功后 `unawaited(prefetchChannels())`。
## C. 测试
HomeFeedController:
- prefetch 后首次 select anime 不再发请求。
- prefetch 期间选择 anime 不重复请求。
- prefetch 失败不影响推荐,也不向外抛。
Widget:
- 首切未缓存频道时 PlayerPage 仍在树中且出现 loading 遮罩。
- 数据返回后遮罩消失并使用新频道 feed。
门禁:
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 风险与控制
1. 预取完成与 select 同时发生时,select 等待同一 inFlight,避免重复请求。
2. 加载遮罩 IgnorePointer,不阻断顶栏/底栏交互。
3. PlayerPage 在正常和 loading 状态都放在相同 Stack 层级,保证 State 不因分支切换被销毁。
4. 不改后端、ReelShortWeb、specs;不安装或操作真机。
## Git
- 提交:`fix(home): 首切频道预取并保留播放画面`
- `--no-ff` 合回 ReelShort `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
@@ -0,0 +1,54 @@
# K95.2 首切频道用封面过渡
## 目标
- 首页频道切换后立即显示目标短剧封面,视频首帧就绪后再淡入。
- 首页模式视频初始化期间不再显示 `player-session-loading` 大转圈。
- 非首页播放器及票据加载状态保持现有行为。
## 分支
- ReelShort:`fix/k95.2-poster-transition`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. PlayerVideoSurface
- 新增可选参数 `posterUrl = ''`。
- 未 reveal 时:
- `posterUrl` 非空显示 `RemoteImage(path: posterUrl, fit: BoxFit.cover)`,
Key 为 `player-video-poster`。
- `posterUrl` 为空继续显示黑色 `ColoredBox`,保留
`player-fallback-background` Key。
- 视频 reveal 后继续通过现有 200ms `AnimatedOpacity` 淡入覆盖封面。
## B. PlayerPage
- `PlayerVideoSurface` 传入当前 `drama.posterUrl`。
- `player-session-loading` 条件增加 `!widget.homeMode`:
- 首页模式视频初始化期间只显示封面。
- 非首页播放器继续显示原 session loading。
- 首页 `player-ticket-loading` 不变。
## C. 测试
- 视频表面:非空 poster 显示 `player-video-poster`,空 poster 保留黑底且无封面。
- 首页模式:视频未就绪时不出现 `player-session-loading`。
- 非首页模式:视频未就绪时仍出现 `player-session-loading`。
- 复跑既有播放器、首页相关测试。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
## Git
- ReelShort 提交:`fix(home): 频道切换用封面过渡`
- `--no-ff` 合回 `develop` 并 push。
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不安装或操作真机;完成后停住等待验收。
+86
View File
@@ -0,0 +1,86 @@
# K96 剧场页服务端分类分页
## 目标
- `/app/feed` 支持按分类过滤,并与内容类型过滤叠加。
- 剧场页「全部」展示全部已上线短剧,任意分类展示该分类全部已上线短剧。
- 列表使用游标分页,切分类优先命中缓存,支持静默刷新、加载更多和失败重试。
## 分支
- FruitServer:`feat/k96-feed-category`
- ReelShort:`feat/k96-theater-feed`
## A. FruitServer
1. `AppCatalogController.feed` 增加可选 `Long categoryId` 并传给服务层。
2. `AppCatalogService.feed` 增加 `categoryId`,传给
`DramaService.listPublishedFeed`。
3. `DramaService.listPublishedFeed` 增加 `categoryId`:
- 非空时使用 `DramaMultiTagSupport.applyCategoryIdTokens`
按 `category_ids` 整词匹配。
- 游标 marker 必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
4. 补充服务层、控制器契约测试:
- 仅 categoryId。
- categoryId + types。
- 分类游标翻页。
- marker 分类不命中返回空。
5. 更新 `docs/api-phase1.md` 的 `/app/feed` 参数和分类过滤说明。
门禁:`mvn test` 全模块通过。
## B. ReelShort
### B1. API 与仓库
- `CatalogApi.feed` 增加可选 `String? categoryId`,非空才拼 query。
- `CatalogRepository.fetchFeed` 同步参数,HTTP 实现透传。
### B2. TheaterFeedController
- 新增 `lib/core/data/services/theater_feed_controller.dart`。
- 按 `String? categoryId` 独立缓存状态:
`dramas / cursor / hasMore / loaded / loading / loadingMore / lastError /
requestSeq / inFlight`。
- `select(id)` 同步切分类;有数据先渲染并静默刷新,无数据时加载。
- `loadMore` 游标追加并按 id 去重;`hasMore=false` 停止。
- `refreshCurrent` 失败保留旧数据;切换回失败分类自动重试。
- 单请求 10 秒超时,响应写回发起分类,requestSeq 仅处理同分类顺序。
### B3. 剧场接入
- `DiscoverPage` 创建并持有 TheaterFeedController,初始请求「全部」。
- 「全部」和分类 chips 的网格统一读取 `theater.dramas`,不再使用
`categories.feed.dramas` / `dramasOf`。
- `_selectCategory` 同时更新 `DiscoverLogic.selectedCategoryId` 和
`theater.select(categoryId)`。
- 滚动剩余不足 400px 时触发 `loadMore`;搜索模式不触发剧场分页。
- 下拉刷新并行执行 `categories.load()` 和 `theater.refreshCurrent()`。
- 无缓存首屏显示转圈;空、失败和重试保持既有剧场视觉。
### B4. 测试
- TheaterFeedController:
- 全部使用空 categoryId,分类使用对应 id。
- 缓存切回不闪烁。
- 分页去重、hasMore=false 停止。
- 失败保留旧数据、失败分类切回自动重试。
- 剧场 widget:
- 点击分类请求对应 categoryId。
- 接近底部触发下一页。
- 搜索、下拉刷新与既有布局不回归。
门禁:`dart analyze lib test` 零告警、`flutter test` 全过、
`flutter build apk --debug` 成功。
## Git
- FruitServer 提交:`feat(app): 推荐流支持分类过滤`
- ReelShort 提交:`feat(theater): 剧场分类列表服务端分页`
- 两仓库作者:`Zhou Weidong <502333560@qq.com>`,禁止 `git add -A`。
- 各自 `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改 specs、ReelShortWeb,不改首页 K94/K95 已交付逻辑。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,64 @@
# K97 修复 iOS 评论输入弹窗键盘避让
## 目标
- iOS 评论输入浮层实时跟随 `MediaQuery.viewInsets.bottom`,确保输入框与发表按钮
位于键盘上方。
- Android 保留既有 MethodChannel + 80ms 防抖 pinned 策略,不恢复底部 SafeArea,
避免键盘收起时 48px 抖动回归。
- 聚焦、`TextInput.show` 兜底和发表成功提示逻辑不变。
## 分支
- ReelShort:`fix/k97-ios-comment-keyboard`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. 平台区分
- `_isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android`。
- Android:
- 继续监听 `KeyboardInsetNotifier`。
- 继续使用 80ms 防抖后的 `_pinnedKeyboardInset`。
- iOS / 其他平台:
- 不读取 pinned/channel 值。
- `build` 中直接读取 `MediaQuery.viewInsetsOf(context).bottom`。
- inset 超过屏幕高度 60% 时按 0 处理,负值归零。
## B. 面板布局
- 面板主体使用 `AnimatedPadding`,只设置 bottom:
- `duration: 160ms`
- `curve: Curves.easeOutCubic`
- Android 使用 pinned 值。
- iOS / 其他平台使用实时 viewInsets。
- Material 仍铺到屏幕底部,用于键盘收起后的实色填充。
- Android 不包裹 SafeArea。
- iOS / 其他平台包裹 `SafeArea(top: false)`,保留 home indicator 底部避让。
## C. 测试
- iOS + `viewInsets.bottom=300`:
- 键盘避让 AnimatedPadding 的 bottom 为 300。
- `comment-composer-post` 底边位于屏幕高度减 300 之上。
- iOS + `viewInsets=0`:padding 为 0。
- Android 既有 pinned/channel、焦点、弹出、关闭、发布用例保持通过。
- 所有平台 override 测试结束恢复
`debugDefaultTargetPlatformOverride = null`。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
- `flutter build ios --simulator --no-codesign`
## Git
- ReelShort 提交:`fix(comment): 修复 iOS 评论输入弹窗键盘避让`
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,73 @@
# K98 修复登录与手机号登录键盘遮挡
## 目标
- 登录/注册页在邮箱或密码框聚焦后,自动滚动到键盘上方。
- 手机号登录弹窗的手机号/验证码框同样保持可见。
- Android `adjustNothing` 下通过既有 MethodChannel 获取键盘高度,iOS 使用实时
`MediaQuery.viewInsets`。
## 分支
- ReelShort:`fix/k98-auth-keyboard`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. 有效键盘高度
在 `keyboard_inset.dart` 增加:
```dart
double effectiveKeyboardInset(
BuildContext context,
KeyboardInsetNotifier notifier, {
double maxRatio = 0.6,
});
```
- 取 MediaQuery viewInsets 与 Android channel 逻辑像素值的最大值。
- Web/非 Android 不读取 channel。
- 超过屏幕高度 60% 时按 0 处理,负值/非法值归零。
## B. AuthPage
1. `initState` 启动 `KeyboardInsetNotifier`,监听并用 setState 刷新。
2. `SingleChildScrollView` 增加 `ScrollController`。
3. 底部 padding 使用固定底部留白 + effectiveKeyboardInset。
4. 邮箱/密码 FocusNode 监听焦点;聚焦或键盘高度变化后:
- 通过 FocusNode.context 的 RenderBox 获取字段全局底边。
- 与屏幕高度 - 键盘高度 - 24 比较。
- 被遮挡时用 200ms easeOutCubic animateTo 补足滚动。
5. `resizeToAvoidBottomInset` 保持 false。
## C. PhoneAuthSheet
1. 接入 KeyboardInsetNotifier 和 ScrollController。
2. 底部 padding 改用 effectiveKeyboardInset。
3. 手机号/验证码 FocusNode 增加与 AuthPage 相同的焦点/键盘变化滚动校正。
4. 保持现有 SingleChildScrollView、弹窗和返回键行为。
## D. 测试
- iOS viewInsets=300:登录密码框、注册密码框均在键盘上方。
- Android platform + MethodChannel 键盘高度:密码框仍在键盘上方。
- iOS:手机号弹窗验证码框在键盘上方。
- 所有平台 override 测试返回前显式恢复
`debugDefaultTargetPlatformOverride = null`。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
## Git
- ReelShort 提交:`fix(auth): 登录注册与手机号登录键盘避让`
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不改评论输入框已验证的 Android pinned 策略。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,30 @@
# 2026-10-21 | 短剧保存免费集数同步分集
## 目标
后台短剧编辑改 `freeEpisodes` / `unlockCoin` 后,同步 `drama_episode.is_free` / `unlock_coin`,与定价页 `PriceService.save()` 规则一致。
## 方案
1. 抽出 `EpisodePriceSync.syncByDramaRule(dramaId, freeEpisodes, unlockCoin)`,供 `DramaService.update` 与 `PriceService.save` 共用。
2. `DramaService.update` 在 `updateById` 后,仅当免费集数或解锁金币相对更新前有变化时调用同步。
3. TDD:先写 `DramaServiceTest` 复现失败,再改实现转绿。
## 范围
- 仅 `fruitshorts-module-content`
- 不改 Controller / VO / 契约 / 前端
## 门禁
```bash
mvn -pl fruitshorts-module-content test -Dtest=DramaServiceTest
mvn -pl fruitshorts-module-content test
```
## 分支 / 提交
- `feature/drama-free-episodes-sync` ← `develop`
- `test(content): 覆盖短剧免费集数同步分集价格`
- `fix(content): 短剧保存免费集数时同步分集免费状态`
- `merge --no-ff` 回 develop 并 push
@@ -0,0 +1,24 @@
# 2026-10-21 | 短剧保存始终同步分集价格(幂等纠偏)
## 背景
承接 K104。短剧 `free_episodes=9` 重存后,分集 5~9 仍收费。
## 根因
`DramaService.update()` 仅在 `freeEpisodes` / `unlockCoin` 相对更新前有变化时调用 `EpisodePriceSync`。历史数据已漂移时,同值重存不触发同步。
## 方案
1. 测试:覆盖「规则字段未变、分集已漂移」仍纠正。
2. `DramaService.update()`:每次保存都调用 `EpisodePriceSync.syncByDramaRule(drama)`。
3. `EpisodePriceSync`:仅对有差异的行走 `updateById`(已具备,保持)。
## 范围
只改 `fruitshorts-module-content` 的 `DramaService` / `EpisodePriceSync` / `DramaServiceTest`。不改 Controller / VO / 契约 / 前端。
## 提交
1. `test(content): 覆盖免费集数未变但分集漂移的修复场景`
2. `fix(content): 短剧保存时始终同步分集价格`
+74 -76
View File
@@ -1,97 +1,95 @@
# INBOX # INBOX
## K85 收藏数 / 评论数实时更新 > 本轮任务:K100 后台剧集多选上传(分片 + 断点续传 + 进度展示)。**[已处理]**
> 开工前置:先读 `AGENTS.md`、`CODING_STANDARDS.md`、`GIT_WORKFLOW.md` 本文件,再读
> `ReelShortWeb/src/utils/uploader.ts`、`src/api/admin/media.ts`、`src/views/contentManagement/EpisodeManagementView.vue`。
### 现象 ### Codex 回执(K100)
首页 feed 右侧互动栏已经能显示数字,但**收藏数、评论数点完不变**: - ReelShortWeb 分支:`feature/episode-batch-upload` → `--no-ff` 合入 `develop` 并 push
- 提交:`feat(admin): 剧集多选分片上传与断点续传`
- 门禁:`pnpm lint:eslint` 零错误;`pnpm build:dev` 成功
- 未验证:PM 手工多选/暂停/刷新续传/批量建集(8080+3005)
- 风险:续传需重选相同本地文件;暂停不 abort 进行中分片
- 收藏(点星标)后数字不动,杀掉 App 重进又回到旧值 ## 现状(已勘察,不要重复排查)
- 发表评论后评论数不动
### 已定位的根因(勘察结论,不要再重复排查) - 后端分片上传已具备且不改:`POST /admin/media/upload/init` → `POST /admin/media/upload/chunk` →
`POST /admin/media/upload/complete`;断点查询 `GET /admin/media/upload/{uploadId}`
返回 `receivedChunks/totalChunks/chunkSize/percent/status/assetId/url`。
- 前端已有 `src/utils/uploader.ts`:`uploadFile()` 分片并发 3、`resumeUpload()` 按服务端已收片数续传、
`probeVideoDuration()` 探测时长。**这两个函数已可用,本次在其上做批量队列 UI**。
- 当前剧集管理 `EpisodeManagementView.vue` 只有一个 `el-upload` 单文件入口(`http-request="uploadVideo"`),
**不支持多选、没有进度条、没有断点续传入口**,`onSession` 也没有落盘。
**后端(FruitServer)** ## 目标
1. `drama.favorite_count` 从建剧时置 0 之后**没有任何地方自增/自减**: 在剧集管理页提供「批量上传」:一次选多个视频 → 队列分片上传 → 每个文件独立进度 → 失败可重试 →
- `FavoriteService.toggle()` 只写/删 `user_favorite`,注释写着「计数走异步聚合」, 刷新/重进页面能续传 → 上传完成后批量创建分集。
但仓库里**没有这个聚合任务**,所以收藏数永远是 0
- `remove()` / `removeBatch()` 也不维护计数
- `LikeService` 是同步维护 `drama.like_count` 的(`DramaService.adjustLikeCount`),收藏应对齐这个做法
2. 数据库现状(本机 PG `fruitshorts`):`drama.favorite_count` 全为 0,
而 `user_favorite` 实际有记录(剧 1 有 4 条、剧 2 有 2 条、剧 5 有 5 条……)→ 需要回填
3. 评论数没有单个剧集的计数接口:`GET /app/comments` 是游标翻页,不返回总数
**客户端(ReelShort)** ## A. 上传队列(新建 `src/views/contentManagement/upload/useUploadQueue.ts`)
4. 首页 feed 的收藏回调(`player_view.dart` 约 468 行)是内联实现,**没有调用已有的 1. 输入:`File[]` + `dramaId` + 可选 `bizType`
`_applyFavoriteDelta`**;只有全屏播放页的 `_toggleSave`(`player_view_actions.dart`)调了 2. 每项状态机:`waiting → uploading → merging → done / failed / paused`;对外暴露 `items`、`overallPercent`、
→ 首页收藏后数字不变 `start()`、`pause()`、`retry(id)`、`retryAll()`、`remove(id)`、`clear()`
5. 两处 rail 的 `commentCount: drama.commentCount` 是静态值,评论面板关闭后不刷新 3. 队列并发:同时最多 2 个文件上传(文件内分片并发沿用 `uploadFile` 的 3)
6. 详情页 `DramaStatsCard` 用 `drama.favoriteCount`,`drama_detail_logic.toggleFavorite()` 4. 进度:文件内 `onProgress` 回调更新单项百分比;总进度 = 各文件字节加权,不是简单平均
切换后不更新显示 5. 失败隔离:单文件失败不影响其它文件,错误原因记在该项 `error`
6. 暂停语义(本轮简化,不做 abort):暂停后不再派发新任务,已在传的分片让其完成
7. 上传完成后 `completeUploadApi` 拿到 `assetId/url/durationSec` 写回该项
### 任务 ## B. 断点续传(关键)
#### A. FruitServer(后端) 1. 上传开始(拿到 `uploadId`)即写入 `localStorage`:键 `reel_upload_sessions`,
值数组项含 `{ uploadId, fileName, fileSize, lastModified, dramaId, bizType, createdAt }`
2. 页面进入时读取该键,过滤 `dramaId` 匹配且 24 小时内的会话,弹出提示「发现 N 个未完成上传,是否续传」
3. 续传前用 `file.name + size + lastModified` 匹配本地重新选择的文件;
**文件匹配不上(换机器/改名)则不自动续传,提示后由用户手动重选**
4. 续传走现有 `resumeUpload(file, uploadId)`;`GET /admin/media/upload/{uploadId}` 返回 404 或已 completed 的会话要从 localStorage 清理
5. 上传成功、用户移除、用户放弃续传三种情况都要清理对应记录
1. `fruitshorts-module-content/.../service/DramaService.java` ## C. 批量创建分集
- 新增 `adjustFavoriteCount(Long dramaId, int delta)`:SQL 自增,
参考现有 `adjustLikeCount`,减号用 `GREATEST(favorite_count - 1, 0)`
2. `fruitshorts-module-playback/.../service/FavoriteService.java`
- 类注释里「计数走异步聚合」改成与 `LikeService` 一致的同步维护说明
- `toggle()`:插入关系后 `adjustFavoriteCount(+1)`;`physicalDelete` 返回行数 > 0 时 `-1`
- `remove()`:删除成功(返回行数 > 0)时 `-1`
- `removeBatch()`:按 dramaId 逐个 `physicalDelete` 并按其返回值 `-1`
(保持与前端「取消收藏」一致的物理删除语义,避免逻辑删除挡住再次收藏)
3. `fruitshorts-app-api/.../controller/AppCommentController.java`
- 新增 `GET /app/comments/count?dramaId={id}` → `Result<Long>`,
内部 `commentService.countByDrama(dramaId)`(匿名可访问,与列表一致)
4. `sql/` 下加一次性回填脚本 `sql/backfill_favorite_count.sql`
- `UPDATE drama d SET favorite_count = (SELECT count(*) FROM user_favorite f WHERE f.drama_id = d.id AND f.deleted = 0);`
- 只放脚本,不要在应用启动时自动执行
5. 测试
- `FavoriteService` 单测:收藏 +1、取消 -1、不会减到负数、removeBatch 多剧集各自扣减
- `AppCommentController` 契约测试:`/app/comments/count` 返回数字
#### B. ReelShort(客户端) 1. 上传完成列表提供「批量创建分集」按钮
2. 默认值:按文件名自然排序(去扩展名后再比较,`第2集` 排在 `第10集` 之前);
集号 = 该剧当前最大集号 + 1 起顺延;标题取文件名去扩展名;是否免费沿用现有新建默认值
3. 弹窗内每行可改:集号、标题、是否免费;冲突集号在提交前本地校验并标红
4. 提交:逐条调用现有 `createEpisodeApi`(`src/api/admin/episode.ts`),
逐条显示成功/失败,失败的保留在弹窗内可重试;全部结束后刷新剧集列表
5. **不新增后端接口**,契约不动
1. `lib/core/data/remote/comment_api.dart` ## D. 组件拆分(红线:单文件 ≤ 500 行)
- 新增 `Future<int> count(String dramaId)`,解析 `data`(Long 可能是字符串,用现有 `jsonToInt`)
2. `lib/core/data/repositories/comment_repository.dart`
- 接口与 `HttpCommentRepository` 同步新增 `count`
3. `lib/pages/player/player_view.dart`
- 首页 feed 的 `onSave` 改为复用 `_toggleSave(context, drama)`(内部已有 `_applyFavoriteDelta`)
- 新增 `Map<String, int> _commentCounts`
- 新增 `_openComments(context, drama)`:`await openCommentsWithAuth(...)`,
结束后 `await commentRepository.count(drama.id)` 并 `setState` 写入 `_commentCounts`;
失败静默(保留旧值,不弹错)
- 首页 rail 与播放页 rail 的 `commentCount` 都改成
`_commentCounts[drama.id] ?? drama.commentCount`;`onComments` 都走 `_openComments`
4. `lib/pages/drama_detail/drama_detail_logic.dart`(收藏数同步)
- 记录本地收藏增量,把调整后的收藏数提供给 `DramaStatsCard`
(不要改 `Drama` 模型;用 getter 或在 view 层叠加 delta)
- 只在切换成功且状态确实变化时 ±1,失败回滚
5. 测试
- `CommentApi.count` 的解析测试(数字 / 字符串两种返回)
- 收藏增量逻辑(可抽纯函数或独立小对象)单测:+1、-1、失败不变
### 验收 - `src/views/contentManagement/upload/BatchUploadDialog.vue`:多选、队列列表、总进度、暂停/重试/移除
- `src/views/contentManagement/upload/UploadTaskRow.vue`:单行(文件名、大小、进度条、状态、操作)
- `src/views/contentManagement/upload/CreateEpisodesDialog.vue`:批量建分集表单
- `src/views/contentManagement/upload/useUploadQueue.ts`:队列逻辑(纯逻辑单元,不写 UI)
- `EpisodeManagementView.vue` 只加一个「批量上传」入口按钮,并保持原单文件上传入口可用
- 后端:`mvn test` 全过;`mvn -pl fruitshorts-app-api -am package -DskipTests` 出包; ## E. 细节要求
本地执行回填脚本后 `GET /app/home/feed` 的 `favoriteCount` 与 `user_favorite` 实际条数一致
- 客户端:`dart analyze lib test` 零告警、`flutter test` 全过
- 功能:收藏后 rail 数字立即 ±1;重进 App 数字不回退;发表评论后关闭面板,评论数 +1(重新拉取)
### Git - 文件类型白名单:`mp4/mov/mkv/webm`(`accept="video/*"` + `before-upload` 二次校验)
- 单文件大小上限从现有常量取值,超限直接标红不入队
- 所有文案走 `src/locales/zh-CN.ts` / `en.ts`,禁止硬编码中文
- 进度与错误展示用项目现有样式变量,不新引入 UI 依赖
- 上传期间:关闭页面前用 `beforeunload` 提示存在未完成上传
- FruitServer:从 `develop` 拉 `fix/favorite-comment-count`, ## F. 门禁
提交 `fix(interaction): 收藏计数同步维护并新增评论数接口`,`--no-ff` 合回 `develop` 并 push
- ReelShort:从 `develop` 拉 `fix/interaction-count-refresh`,
提交 `fix(player): 收藏与评论数实时刷新`,`--no-ff` 合回 `develop` 并 push
- 作者统一 `Zhou Weidong <502333560@qq.com>`;各自仓库 `docs/plans/PROGRESS.md` 追加 K85 留痕
### 约束 - `pnpm lint:eslint` 零错误
- `pnpm build:dev` 构建成功
- 手工验证(PM 会复核):本地起 8080 + 3005,多选 3 个视频 → 三条并行进度 → 中途暂停 → 刷新页面 → 出现续传提示 → 续传完成 → 批量建分集成功
- 全程中文;禁止 `git add -A`;不改 specs 仓库;不动 `pubspec.lock` 以外的无关文件 ## Git
- 完成后停住,等待验收
- ReelShortWeb:从 `develop` 拉 `feature/episode-batch-upload` → 提交
`feat(admin): 剧集多选分片上传与断点续传` → `--no-ff` 合回 `develop` 并 push
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`;源码首行 `// Author: fruit.com.cn`
## 约束
- 先写计划 `docs/plans/2026-10-21-K100-episode-batch-upload.md`,再改代码
- 每跑一个命令,把「命令 + 原始输出 + 解读」追加到 `docs/plans/PROGRESS.md`
- 不改 FruitServer、不改 ReelShort 客户端、不改接口契约
- 禁止 `git add -A`;不要操作真机
- 完成后停住等 PM 验收,回执需贴门禁原始输出
File diff suppressed because it is too large. Load diff
@@ -35,8 +35,8 @@ public class CommentAdminController {
private final CommentService commentService; private final CommentService commentService;
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */ /** 检索评论:status 为空时返回全部状态,传 pending 即审核队列。 */
@Operation(summary = "评论检索(默认按状态过滤,待审队列)") @Operation(summary = "评论检索(status 为空返回全部状态)")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('comment:list')") @PreAuthorize("@ss.hasPermission('comment:list')")
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) { public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
@@ -6,6 +6,7 @@ import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.DramaReviewRecord; import com.fruitshorts.content.dal.entity.DramaReviewRecord;
import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.DramaPageReqVO; import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO; import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.content.vo.DramaSaveReqVO; import com.fruitshorts.content.vo.DramaSaveReqVO;
@@ -38,13 +39,14 @@ public class DramaController {
private final DramaService dramaService; private final DramaService dramaService;
private final CommentService commentService; private final CommentService commentService;
private final EpisodeService episodeService;
@Operation(summary = "短剧分页查询") @Operation(summary = "短剧分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) { public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
PageResult<DramaRespVO> page = dramaService.page(req); PageResult<DramaRespVO> page = dramaService.page(req);
fillCommentCounts(page.getList()); fillInteractionCounts(page.getList());
return Result.ok(page); return Result.ok(page);
} }
@@ -53,7 +55,7 @@ public class DramaController {
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasPermission('drama:list')")
public Result<DramaRespVO> get(@PathVariable Long id) { public Result<DramaRespVO> get(@PathVariable Long id) {
DramaRespVO drama = dramaService.get(id); DramaRespVO drama = dramaService.get(id);
fillCommentCounts(List.of(drama)); fillInteractionCounts(List.of(drama));
return Result.ok(drama); return Result.ok(drama);
} }
@@ -141,12 +143,16 @@ public class DramaController {
SecurityUtils.getUserId(), SecurityUtils.getUsername()); SecurityUtils.getUserId(), SecurityUtils.getUsername());
} }
private void fillCommentCounts(List<DramaRespVO> dramas) { private void fillInteractionCounts(List<DramaRespVO> dramas) {
if (dramas == null || dramas.isEmpty()) { if (dramas == null || dramas.isEmpty()) {
return; return;
} }
List<Long> ids = dramas.stream().map(DramaRespVO::getId).toList(); List<Long> ids = dramas.stream().map(DramaRespVO::getId).toList();
Map<Long, Long> counts = commentService.countByDramas(ids); Map<Long, Long> likeCounts = episodeService.sumLikeCountsByDramas(ids);
dramas.forEach(drama -> drama.setCommentCount(counts.getOrDefault(drama.getId(), 0L))); Map<Long, Long> commentCounts = commentService.countByDramas(ids);
dramas.forEach(drama -> {
drama.setLikeCount(likeCounts.getOrDefault(drama.getId(), 0L));
drama.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L));
});
} }
} }
@@ -2,12 +2,16 @@
package com.fruitshorts.admin.controller; package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.EpisodeRespVO; import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO; import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import com.fruitshorts.content.vo.EpisodeSortReqVO; import com.fruitshorts.content.vo.EpisodeSortReqVO;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.interaction.service.CommentService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -24,6 +28,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.Map;
@Tag(name = "后台-剧集管理") @Tag(name = "后台-剧集管理")
@RestController @RestController
@@ -32,13 +37,27 @@ import java.util.List;
public class EpisodeController { public class EpisodeController {
private final EpisodeService episodeService; private final EpisodeService episodeService;
private final CommentService commentService;
private final DramaService dramaService;
@Operation(summary = "剧集列表") @Operation(summary = "剧集列表(分页)")
@GetMapping @GetMapping
@PreAuthorize("@ss.hasPermission('episode:list')") @PreAuthorize("@ss.hasPermission('episode:list')")
public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId, public Result<PageResult<EpisodeRespVO>> list(@RequestParam Long dramaId,
@RequestParam(required = false) String status) { @RequestParam(required = false) String status,
return Result.ok(episodeService.listByDrama(dramaId, status)); @RequestParam(required = false) Integer pageIndex,
@RequestParam(required = false) Integer pageSize) {
PageResult<EpisodeRespVO> page = episodeService.pageByDrama(dramaId, status, pageIndex, pageSize);
List<EpisodeRespVO> episodes = page.getList();
Map<Long, Long> commentCounts = commentService.countByEpisodes(
episodes.stream().map(EpisodeRespVO::getId).toList());
Drama drama = dramaService.getExisting(dramaId);
episodes.forEach(episode -> {
episode.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
episode.setFavoriteCount(drama.getFavoriteCount());
episode.setHeat(drama.getViewCount());
});
return Result.ok(new PageResult<>(page.getTotal(), episodes));
} }
@Operation(summary = "新建剧集") @Operation(summary = "新建剧集")
@@ -2,11 +2,12 @@
package com.fruitshorts.admin.controller; package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.ReportAdminService;
import com.fruitshorts.admin.vo.AdminReportRespVO;
import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.interaction.dal.entity.ContentReport;
import com.fruitshorts.interaction.service.ReportService; import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.ReportHandleReqVO; import com.fruitshorts.interaction.vo.ReportHandleReqVO;
import com.fruitshorts.interaction.vo.ReportQueryReqVO; import com.fruitshorts.interaction.vo.ReportQueryReqVO;
@@ -28,13 +29,14 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ReportAdminController { public class ReportAdminController {
private final ReportAdminService reportAdminService;
private final ReportService reportService; private final ReportService reportService;
@Operation(summary = "举报工单分页查询") @Operation(summary = "举报工单分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('report:list')") @PreAuthorize("@ss.hasPermission('report:list')")
public Result<PageResult<ContentReport>> page(ReportQueryReqVO req) { public Result<PageResult<AdminReportRespVO>> page(ReportQueryReqVO req) {
return Result.ok(reportService.page(req)); return Result.ok(reportAdminService.page(req));
} }
/** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */ /** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.WatchHistoryAdminService;
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台跨用户观看记录检索。 */
@Tag(name = "后台-观看记录")
@RestController
@RequestMapping("/admin/watch-history")
@RequiredArgsConstructor
public class WatchHistoryAdminController {
private final WatchHistoryAdminService watchHistoryAdminService;
@Operation(summary = "观看记录分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('watch-history:list')")
public Result<PageResult<AdminWatchHistoryVO>> page(WatchHistoryQueryReqVO req) {
return Result.ok(watchHistoryAdminService.page(req));
}
}
@@ -0,0 +1,283 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.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.admin.vo.AdminReportRespVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.entity.ContentReport;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentReportMapper;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 后台举报工单分页与目标展示信息装配。
*
* <p>举报只保存目标类型和 id;该服务在当前页一次性加载评论、剧、集和用户,
* 避免列表逐行查询,同时保持原举报处理流程不变。
*/
@Service
@RequiredArgsConstructor
public class ReportAdminService {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int DELETED_FLAG = 1;
private final ContentReportMapper reportMapper;
private final ContentCommentMapper commentMapper;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper episodeMapper;
private final AppUserMapper appUserMapper;
/** 按举报工单筛选条件分页查询,并补齐展示字段。 */
public PageResult<AdminReportRespVO> page(ReportQueryReqVO req) {
Page<ContentReport> page = new Page<>(
req.getPageIndex() == null || req.getPageIndex() < 1
? DEFAULT_PAGE_INDEX : req.getPageIndex(),
req.getPageSize() == null || req.getPageSize() < 1
? DEFAULT_PAGE_SIZE : req.getPageSize());
IPage<ContentReport> result = reportMapper.selectPage(page,
Wrappers.<ContentReport>lambdaQuery()
.eq(StringUtils.hasText(req.getStatus()),
ContentReport::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getTargetType()),
ContentReport::getTargetType, req.getTargetType())
.eq(StringUtils.hasText(req.getReasonCode()),
ContentReport::getReasonCode, req.getReasonCode())
.eq(req.getReporterId() != null,
ContentReport::getReporterId, req.getReporterId())
.orderByDesc(ContentReport::getId));
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
}
private List<AdminReportRespVO> toVoList(List<ContentReport> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, ContentComment> comments = loadComments(rows);
Map<Long, Drama> dramas = loadDramas(rows, comments);
Map<Long, DramaEpisode> episodes = loadEpisodes(comments);
Map<Long, AppUser> users = loadUsers(rows);
List<AdminReportRespVO> list = new ArrayList<>(rows.size());
for (ContentReport row : rows) {
list.add(toVo(row, comments, dramas, episodes, users));
}
return list;
}
private Map<Long, ContentComment> loadComments(List<ContentReport> rows) {
List<Long> ids = rows.stream()
.filter(row -> ReportService.TYPE_COMMENT.equals(row.getTargetType()))
.map(ContentReport::getTargetId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
Map<Long, ContentComment> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (ContentComment comment : commentMapper.selectByIdsIncludingDeleted(ids)) {
result.put(comment.getId(), comment);
}
return result;
}
private Map<Long, Drama> loadDramas(List<ContentReport> rows,
Map<Long, ContentComment> comments) {
Set<Long> ids = new LinkedHashSet<>();
for (ContentReport row : rows) {
if (ReportService.TYPE_DRAMA.equals(row.getTargetType())) {
addPositive(ids, row.getTargetId());
continue;
}
if (ReportService.TYPE_COMMENT.equals(row.getTargetType())) {
ContentComment comment = comments.get(row.getTargetId());
if (!isDeleted(comment)) {
addPositive(ids, comment.getDramaId());
}
}
}
Map<Long, Drama> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
result.put(drama.getId(), drama);
}
return result;
}
private Map<Long, DramaEpisode> loadEpisodes(Map<Long, ContentComment> comments) {
Set<Long> ids = new LinkedHashSet<>();
for (ContentComment comment : comments.values()) {
if (!isDeleted(comment)) {
addPositive(ids, comment.getEpisodeId());
}
}
Map<Long, DramaEpisode> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (DramaEpisode episode : episodeMapper.selectBatchIds(ids)) {
result.put(episode.getId(), episode);
}
return result;
}
private Map<Long, AppUser> loadUsers(List<ContentReport> rows) {
Set<Long> ids = new LinkedHashSet<>();
for (ContentReport row : rows) {
addPositive(ids, row.getReporterId());
if (ReportService.TYPE_USER.equals(row.getTargetType())) {
addPositive(ids, row.getTargetId());
}
}
Map<Long, AppUser> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (AppUser user : appUserMapper.selectBatchIds(ids)) {
result.put(user.getId(), user);
}
return result;
}
private AdminReportRespVO toVo(ContentReport report,
Map<Long, ContentComment> comments,
Map<Long, Drama> dramas,
Map<Long, DramaEpisode> episodes,
Map<Long, AppUser> users) {
AdminReportRespVO vo = new AdminReportRespVO();
copyReport(report, vo);
fillReporter(report, users.get(report.getReporterId()), vo);
if (ReportService.TYPE_COMMENT.equals(report.getTargetType())) {
fillCommentTarget(report, comments.get(report.getTargetId()), dramas, episodes, vo);
} else if (ReportService.TYPE_DRAMA.equals(report.getTargetType())) {
fillDramaTarget(report, dramas.get(report.getTargetId()), vo);
} else if (ReportService.TYPE_USER.equals(report.getTargetType())) {
fillUserTarget(report, users.get(report.getTargetId()), vo);
}
return vo;
}
private void copyReport(ContentReport report, AdminReportRespVO vo) {
vo.setId(report.getId());
vo.setReporterId(report.getReporterId());
vo.setTargetType(report.getTargetType());
vo.setTargetId(report.getTargetId());
vo.setReasonCode(report.getReasonCode());
vo.setDescription(report.getDescription());
vo.setStatus(report.getStatus());
vo.setHandleBy(report.getHandleBy());
vo.setHandleAt(report.getHandleAt());
vo.setHandleNote(report.getHandleNote());
vo.setCreatedAt(report.getCreatedAt());
}
private void fillReporter(ContentReport report, AppUser reporter, AdminReportRespVO vo) {
if (reporter != null) {
vo.setReporterNickname(reporter.getNickname());
vo.setReporterUid(reporter.getUid());
}
}
private void fillCommentTarget(ContentReport report, ContentComment comment,
Map<Long, Drama> dramas,
Map<Long, DramaEpisode> episodes,
AdminReportRespVO vo) {
if (isDeleted(comment)) {
vo.setTargetSummary("评论 #" + report.getTargetId());
return;
}
Drama drama = dramas.get(comment.getDramaId());
if (drama == null) {
vo.setTargetSummary("评论 #" + report.getTargetId());
return;
}
DramaEpisode episode = episodes.get(comment.getEpisodeId());
vo.setDramaId(comment.getDramaId());
vo.setDramaTitle(drama.getTitle());
vo.setEpisodeId(comment.getEpisodeId());
vo.setCommentContent(comment.getContent());
if (episode == null) {
vo.setTargetSummary(drama.getTitle());
return;
}
vo.setEpisodeNo(episode.getEpNo());
vo.setEpisodeTitle(episode.getTitle());
String summary = drama.getTitle() + " · 第 " + episode.getEpNo() + " 集";
if (StringUtils.hasText(episode.getTitle())) {
summary += " · " + episode.getTitle();
}
vo.setTargetSummary(summary);
}
private void fillDramaTarget(ContentReport report, Drama drama, AdminReportRespVO vo) {
vo.setDramaId(report.getTargetId());
if (drama == null) {
vo.setTargetSummary("短剧 #" + report.getTargetId());
return;
}
vo.setDramaTitle(drama.getTitle());
vo.setTargetSummary(drama.getTitle());
}
private void fillUserTarget(ContentReport report, AppUser user, AdminReportRespVO vo) {
if (user == null) {
vo.setTargetSummary("用户 #" + report.getTargetId());
return;
}
vo.setTargetNickname(user.getNickname());
vo.setTargetUid(user.getUid());
vo.setTargetSummary(formatUser(user, report.getTargetId()));
}
private String formatUser(AppUser user, Long fallbackId) {
boolean hasNickname = StringUtils.hasText(user.getNickname());
boolean hasUid = StringUtils.hasText(user.getUid());
if (hasNickname && hasUid) {
return user.getNickname() + "(" + user.getUid() + ")";
}
if (hasNickname) {
return user.getNickname();
}
if (hasUid) {
return user.getUid();
}
return "用户 #" + fallbackId;
}
private boolean isDeleted(ContentComment comment) {
return comment == null
|| CommentService.STATUS_DELETED.equals(comment.getStatus())
|| Integer.valueOf(DELETED_FLAG).equals(comment.getDeleted());
}
private void addPositive(Set<Long> ids, Long id) {
if (id != null && id > 0L) {
ids.add(id);
}
}
}
@@ -0,0 +1,198 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 后台观看记录分页与跨域装配。
*
* <p>观看流水是播放域的原始事件,用户、短剧与分集资料分别位于用户和内容域。
* 该服务位于 admin-api 聚合层,直接注入只读 Mapper,避免反向扩展业务模块依赖。
*/
@Service
@RequiredArgsConstructor
public class WatchHistoryAdminService {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 100;
private static final long FINISHED_TOLERANCE_MS = 1000L;
private final WatchHistoryMapper watchHistoryMapper;
private final AppUserMapper appUserMapper;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
/** 按后台筛选条件分页查询观看记录。 */
public PageResult<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
int pageIndex = req.getPageIndex() == null || req.getPageIndex() < 1
? DEFAULT_PAGE_INDEX : req.getPageIndex();
int pageSize = req.getPageSize() == null || req.getPageSize() < 1
? DEFAULT_PAGE_SIZE : Math.min(req.getPageSize(), MAX_PAGE_SIZE);
List<Long> matchedUserIds = StringUtils.hasText(req.getKeyword())
? searchUserIds(req.getKeyword()) : null;
if (matchedUserIds != null && matchedUserIds.isEmpty()) {
return emptyPage();
}
List<Long> matchedDramaIds = StringUtils.hasText(req.getDramaTitle())
? searchDramaIds(req.getDramaTitle()) : null;
if (matchedDramaIds != null && matchedDramaIds.isEmpty()) {
return emptyPage();
}
IPage<WatchHistory> result = watchHistoryMapper.selectPage(new Page<>(pageIndex, pageSize),
buildQuery(req, matchedUserIds, matchedDramaIds));
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
}
private LambdaQueryWrapper<WatchHistory> buildQuery(WatchHistoryQueryReqVO req,
List<Long> matchedUserIds,
List<Long> matchedDramaIds) {
LambdaQueryWrapper<WatchHistory> wrapper = Wrappers.<WatchHistory>lambdaQuery()
.eq(req.getUserId() != null, WatchHistory::getUserId, req.getUserId())
.in(matchedUserIds != null, WatchHistory::getUserId, matchedUserIds)
.eq(req.getDramaId() != null, WatchHistory::getDramaId, req.getDramaId())
.in(matchedDramaIds != null, WatchHistory::getDramaId, matchedDramaIds)
.eq(req.getEpisodeId() != null, WatchHistory::getEpisodeId, req.getEpisodeId())
.eq(StringUtils.hasText(req.getSource()), WatchHistory::getSource, req.getSource())
.ge(req.getStartTime() != null, WatchHistory::getWatchedAt, req.getStartTime())
.le(req.getEndTime() != null, WatchHistory::getWatchedAt, req.getEndTime());
applyFinished(wrapper, req.getFinished());
return wrapper.orderByDesc(WatchHistory::getWatchedAt)
.orderByDesc(WatchHistory::getId);
}
private void applyFinished(LambdaQueryWrapper<WatchHistory> wrapper, Boolean finished) {
if (Boolean.TRUE.equals(finished)) {
wrapper.gt(WatchHistory::getDurationMs, 0L)
.apply("position_ms + {0} >= duration_ms", FINISHED_TOLERANCE_MS);
} else if (Boolean.FALSE.equals(finished)) {
wrapper.and(query -> query.eq(WatchHistory::getDurationMs, 0L)
.or()
.apply("position_ms + {0} < duration_ms", FINISHED_TOLERANCE_MS));
}
}
private List<Long> searchUserIds(String keyword) {
String value = keyword.trim();
return appUserMapper.selectList(Wrappers.<AppUser>lambdaQuery()
.select(AppUser::getId)
.and(query -> query.like(AppUser::getUid, value)
.or()
.like(AppUser::getNickname, value)))
.stream()
.map(AppUser::getId)
.toList();
}
private List<Long> searchDramaIds(String dramaTitle) {
String value = dramaTitle.trim();
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
.select(Drama::getId)
.like(Drama::getTitle, value))
.stream()
.map(Drama::getId)
.toList();
}
private List<AdminWatchHistoryVO> toVoList(List<WatchHistory> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, AppUser> users = loadUsers(rows);
Map<Long, Drama> dramas = loadDramas(rows);
Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
List<AdminWatchHistoryVO> list = new ArrayList<>(rows.size());
for (WatchHistory row : rows) {
list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()),
episodes.get(row.getEpisodeId())));
}
return list;
}
private Map<Long, AppUser> loadUsers(List<WatchHistory> rows) {
Map<Long, AppUser> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getUserId).distinct().toList();
for (AppUser user : appUserMapper.selectBatchIds(ids)) {
result.put(user.getId(), user);
}
return result;
}
private Map<Long, Drama> loadDramas(List<WatchHistory> rows) {
Map<Long, Drama> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getDramaId).distinct().toList();
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
result.put(drama.getId(), drama);
}
return result;
}
private Map<Long, DramaEpisode> loadEpisodes(List<WatchHistory> rows) {
Map<Long, DramaEpisode> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getEpisodeId).distinct().toList();
for (DramaEpisode episode : dramaEpisodeMapper.selectBatchIds(ids)) {
result.put(episode.getId(), episode);
}
return result;
}
private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama,
DramaEpisode episode) {
AdminWatchHistoryVO vo = new AdminWatchHistoryVO();
vo.setId(row.getId());
vo.setUserId(row.getUserId());
vo.setDramaId(row.getDramaId());
vo.setEpisodeId(row.getEpisodeId());
vo.setPositionMs(row.getPositionMs());
vo.setDurationMs(row.getDurationMs());
vo.setSource(row.getSource());
vo.setDeviceId(row.getDeviceId());
vo.setWatchedAt(row.getWatchedAt());
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration);
if (user != null) {
vo.setUid(user.getUid());
vo.setNickname(user.getNickname());
}
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus()));
} else {
vo.setDramaPublished(false);
}
if (episode != null) {
vo.setEpisodeNo(episode.getEpNo());
vo.setEpisodeTitle(episode.getTitle());
}
return vo;
}
private PageResult<AdminWatchHistoryVO> emptyPage() {
return new PageResult<>(0L, List.of());
}
}
@@ -0,0 +1,35 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台举报工单视图,附带举报人与被举报目标的展示信息。 */
@Data
public class AdminReportRespVO {
private Long id;
private Long reporterId;
private String reporterNickname;
private String reporterUid;
private String targetType;
private Long targetId;
private String targetSummary;
private Long dramaId;
private String dramaTitle;
private Long episodeId;
private Integer episodeNo;
private String episodeTitle;
private String commentContent;
private String targetNickname;
private String targetUid;
private String reasonCode;
private String description;
private String status;
private Long handleBy;
private LocalDateTime handleAt;
private String handleNote;
private LocalDateTime createdAt;
}
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 后台观看记录行。
*
* <p>页面需要识别用户、短剧、分集与完播状态,因此由 admin-api 聚合流水与基础资料。
*/
@Data
public class AdminWatchHistoryVO {
private Long id;
private Long userId;
private String uid;
private String nickname;
private Long dramaId;
private String dramaTitle;
private Boolean dramaPublished;
private Long episodeId;
private Integer episodeNo;
private String episodeTitle;
private Long positionMs;
private Long durationMs;
private Boolean finished;
private String source;
private String deviceId;
private LocalDateTime watchedAt;
}
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/** 后台观看记录分页检索条件。 */
@Data
public class WatchHistoryQueryReqVO {
private Integer pageIndex = 1;
private Integer pageSize = 10;
/** uid / 昵称模糊检索 */
private String keyword;
private Long userId;
/** 短剧名称模糊检索 */
private String dramaTitle;
private Long dramaId;
private Long episodeId;
private String source;
private Boolean finished;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
}
@@ -5,6 +5,7 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport; import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.DramaPageReqVO; import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO; import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.interaction.service.CommentService; import com.fruitshorts.interaction.service.CommentService;
@@ -28,14 +29,16 @@ class DramaControllerContractTest {
private DramaService dramaService; private DramaService dramaService;
private CommentService commentService; private CommentService commentService;
private EpisodeService episodeService;
private MockMvc mockMvc; private MockMvc mockMvc;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
dramaService = mock(DramaService.class); dramaService = mock(DramaService.class);
commentService = mock(CommentService.class); commentService = mock(CommentService.class);
episodeService = mock(EpisodeService.class);
mockMvc = ControllerContractSupport.mockMvcOf( mockMvc = ControllerContractSupport.mockMvcOf(
new DramaController(dramaService, commentService)); new DramaController(dramaService, commentService, episodeService));
} }
@Test @Test
@@ -46,12 +49,15 @@ class DramaControllerContractTest {
.willReturn(new PageResult<>(2L, List.of(first, second))); .willReturn(new PageResult<>(2L, List.of(first, second)));
given(commentService.countByDramas(List.of(7L, 8L))) given(commentService.countByDramas(List.of(7L, 8L)))
.willReturn(Map.of(7L, 11L)); .willReturn(Map.of(7L, 11L));
given(episodeService.sumLikeCountsByDramas(List.of(7L, 8L)))
.willReturn(Map.of(7L, 3200L, 8L, 120L));
mockMvc.perform(get("/admin/dramas/page")) mockMvc.perform(get("/admin/dramas/page"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].likeCount").value("3200")) .andExpect(jsonPath("$.data.list[0].likeCount").value("3200"))
.andExpect(jsonPath("$.data.list[0].favoriteCount").value("1800")) .andExpect(jsonPath("$.data.list[0].favoriteCount").value("1800"))
.andExpect(jsonPath("$.data.list[0].dramaType").value("ANIME"))
.andExpect(jsonPath("$.data.list[0].commentCount").value("11")) .andExpect(jsonPath("$.data.list[0].commentCount").value("11"))
.andExpect(jsonPath("$.data.list[1].likeCount").value("120")) .andExpect(jsonPath("$.data.list[1].likeCount").value("120"))
.andExpect(jsonPath("$.data.list[1].favoriteCount").value("80")) .andExpect(jsonPath("$.data.list[1].favoriteCount").value("80"))
@@ -64,11 +70,13 @@ class DramaControllerContractTest {
void should_fill_comment_count_when_detail_is_requested() throws Exception { void should_fill_comment_count_when_detail_is_requested() throws Exception {
given(dramaService.get(7L)).willReturn(drama(7L, 3200L, 1800L)); given(dramaService.get(7L)).willReturn(drama(7L, 3200L, 1800L));
given(commentService.countByDramas(List.of(7L))).willReturn(Map.of(7L, 11L)); given(commentService.countByDramas(List.of(7L))).willReturn(Map.of(7L, 11L));
given(episodeService.sumLikeCountsByDramas(List.of(7L))).willReturn(Map.of(7L, 3200L));
mockMvc.perform(get("/admin/dramas/7")) mockMvc.perform(get("/admin/dramas/7"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.likeCount").value("3200")) .andExpect(jsonPath("$.data.likeCount").value("3200"))
.andExpect(jsonPath("$.data.favoriteCount").value("1800")) .andExpect(jsonPath("$.data.favoriteCount").value("1800"))
.andExpect(jsonPath("$.data.dramaType").value("ANIME"))
.andExpect(jsonPath("$.data.commentCount").value("11")); .andExpect(jsonPath("$.data.commentCount").value("11"));
} }
@@ -78,6 +86,7 @@ class DramaControllerContractTest {
drama.setTitle("短剧" + id); drama.setTitle("短剧" + id);
drama.setLikeCount(likeCount); drama.setLikeCount(likeCount);
drama.setFavoriteCount(favoriteCount); drama.setFavoriteCount(favoriteCount);
drama.setDramaType("ANIME");
return drama; return drama;
} }
} }
@@ -0,0 +1,155 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
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 EpisodeControllerContractTest {
private static final long DRAMA_ID = 5L;
private static final int DEFAULT_PAGE_SIZE = 100;
private EpisodeService episodeService;
private CommentService commentService;
private DramaService dramaService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
episodeService = mock(EpisodeService.class);
commentService = mock(CommentService.class);
dramaService = mock(DramaService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new EpisodeController(episodeService, commentService, dramaService));
Drama drama = new Drama();
drama.setFavoriteCount(88L);
drama.setViewCount(1000L);
given(dramaService.getExisting(DRAMA_ID)).willReturn(drama);
}
@Test
void should_return_episode_and_drama_interaction_counts() throws Exception {
given(episodeService.pageByDrama(eq(DRAMA_ID), isNull(), isNull(), isNull()))
.willReturn(new PageResult<>(2L, List.of(episode(11L, 10L), episode(12L, 20L))));
given(commentService.countByEpisodes(List.of(11L, 12L)))
.willReturn(Map.of(11L, 3L));
mockMvc.perform(get("/admin/episodes").param("dramaId", String.valueOf(DRAMA_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(2))
.andExpect(jsonPath("$.data.list[0].likeCount").value("10"))
.andExpect(jsonPath("$.data.list[0].commentCount").value("3"))
.andExpect(jsonPath("$.data.list[0].favoriteCount").value("88"))
.andExpect(jsonPath("$.data.list[0].heat").value("1000"))
.andExpect(jsonPath("$.data.list[1].likeCount").value("20"))
.andExpect(jsonPath("$.data.list[1].commentCount").value("0"));
}
@Test
void should_use_default_page_size_100_when_page_size_omitted() throws Exception {
given(episodeService.pageByDrama(eq(DRAMA_ID), isNull(), isNull(), isNull()))
.willReturn(PageResult.empty());
given(commentService.countByEpisodes(List.of())).willReturn(Map.of());
mockMvc.perform(get("/admin/episodes").param("dramaId", String.valueOf(DRAMA_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(0))
.andExpect(jsonPath("$.data.list").isArray())
.andExpect(jsonPath("$.data.list").isEmpty());
ArgumentCaptor<Integer> pageIndexCaptor = ArgumentCaptor.forClass(Integer.class);
ArgumentCaptor<Integer> pageSizeCaptor = ArgumentCaptor.forClass(Integer.class);
verify(episodeService).pageByDrama(
eq(DRAMA_ID), isNull(), pageIndexCaptor.capture(), pageSizeCaptor.capture());
assertThat(pageIndexCaptor.getValue()).isNull();
assertThat(pageSizeCaptor.getValue()).isNull();
assertThat(DEFAULT_PAGE_SIZE).isEqualTo(100);
}
@Test
void should_return_non_overlapping_ids_when_second_page_requested() throws Exception {
List<EpisodeRespVO> page1 = List.of(episode(1L, 1L), episode(2L, 2L));
List<EpisodeRespVO> page2 = List.of(episode(3L, 3L), episode(4L, 4L));
given(episodeService.pageByDrama(DRAMA_ID, null, 1, 2))
.willReturn(new PageResult<>(4L, page1));
given(episodeService.pageByDrama(DRAMA_ID, null, 2, 2))
.willReturn(new PageResult<>(4L, page2));
given(commentService.countByEpisodes(List.of(1L, 2L))).willReturn(Map.of());
given(commentService.countByEpisodes(List.of(3L, 4L))).willReturn(Map.of());
mockMvc.perform(get("/admin/episodes")
.param("dramaId", String.valueOf(DRAMA_ID))
.param("pageIndex", "1")
.param("pageSize", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(4))
.andExpect(jsonPath("$.data.list.length()").value(2));
String page2Body = mockMvc.perform(get("/admin/episodes")
.param("dramaId", String.valueOf(DRAMA_ID))
.param("pageIndex", "2")
.param("pageSize", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(4))
.andExpect(jsonPath("$.data.list[0].id").value("3"))
.andExpect(jsonPath("$.data.list[1].id").value("4"))
.andReturn()
.getResponse()
.getContentAsString();
Set<Long> page1Ids = page1.stream().map(EpisodeRespVO::getId).collect(Collectors.toSet());
Set<Long> page2Ids = page2.stream().map(EpisodeRespVO::getId).collect(Collectors.toSet());
assertThat(page1Ids).doesNotContainAnyElementsOf(page2Ids);
assertThat(page2Body).contains("\"id\":\"3\"");
}
@Test
void should_return_empty_list_with_correct_total_when_page_out_of_range() throws Exception {
given(episodeService.pageByDrama(DRAMA_ID, null, 99, 100))
.willReturn(new PageResult<>(5L, List.of()));
given(commentService.countByEpisodes(List.of())).willReturn(Map.of());
mockMvc.perform(get("/admin/episodes")
.param("dramaId", String.valueOf(DRAMA_ID))
.param("pageIndex", "99")
.param("pageSize", "100"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(5))
.andExpect(jsonPath("$.data.list").isArray())
.andExpect(jsonPath("$.data.list").isEmpty());
}
private EpisodeRespVO episode(Long id, Long likeCount) {
EpisodeRespVO episode = new EpisodeRespVO();
episode.setId(id);
episode.setDramaId(DRAMA_ID);
episode.setLikeCount(likeCount);
return episode;
}
}
@@ -0,0 +1,123 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
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.interaction.config.InteractionProperties;
import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.mapper.ContentAuditRecordMapper;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
import com.fruitshorts.interaction.service.AuditRecorder;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.interaction.service.SensitiveWordService;
import com.fruitshorts.interaction.vo.CommentQueryReqVO;
import com.fruitshorts.interaction.vo.CommentRespVO;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
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 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.verify;
/** 后台评论页批量补充短剧与分集展示字段。 */
class CommentServiceTest {
private ContentCommentMapper commentMapper;
private AppUserMapper appUserMapper;
private DramaService dramaService;
private EpisodeService episodeService;
private CommentService commentService;
@BeforeAll
static void initTableInfoCache() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
ContentComment.class);
}
@BeforeEach
void setUp() {
commentMapper = mock(ContentCommentMapper.class);
appUserMapper = mock(AppUserMapper.class);
dramaService = mock(DramaService.class);
episodeService = mock(EpisodeService.class);
commentService = new CommentService(
commentMapper,
mock(ContentLikeMapper.class),
mock(ContentAuditRecordMapper.class),
appUserMapper,
dramaService,
episodeService,
mock(SensitiveWordService.class),
mock(AuditRecorder.class),
mock(InteractionProperties.class));
}
@Test
void should_fill_drama_and_episode_metadata_when_admin_page_is_loaded() {
ContentComment withEpisode = comment(1L, 5L, 11L);
ContentComment legacy = comment(2L, 5L, 0L);
Page<ContentComment> page = new Page<>(1, 10);
page.setTotal(2L);
page.setRecords(List.of(withEpisode, legacy));
given(commentMapper.selectPage(any(), any())).willReturn(page);
AppUser user = new AppUser();
user.setId(7L);
user.setUid("uid-7");
user.setNickname("Viewer");
given(appUserMapper.selectBatchIds(any())).willReturn(List.of(user));
Drama drama = new Drama();
drama.setId(5L);
drama.setTitle("Signal");
given(dramaService.listByIds(any())).willReturn(List.of(drama));
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setEpNo(3);
episode.setTitle("The Call");
given(episodeService.listByIds(any())).willReturn(List.of(episode));
PageResult<CommentRespVO> result = commentService.adminPage(new CommentQueryReqVO());
assertThat(result.getList()).hasSize(2);
assertThat(result.getList().get(0).getDramaTitle()).isEqualTo("Signal");
assertThat(result.getList().get(0).getEpisodeNo()).isEqualTo(3);
assertThat(result.getList().get(0).getEpisodeTitle()).isEqualTo("The Call");
assertThat(result.getList().get(1).getDramaTitle()).isEqualTo("Signal");
assertThat(result.getList().get(1).getEpisodeNo()).isNull();
verify(dramaService).listByIds(List.of(5L));
verify(episodeService).listByIds(List.of(11L));
}
private ContentComment comment(Long id, Long dramaId, Long episodeId) {
ContentComment comment = new ContentComment();
comment.setId(id);
comment.setDramaId(dramaId);
comment.setEpisodeId(episodeId);
comment.setParentId(0L);
comment.setRootId(0L);
comment.setUserId(7L);
comment.setContent("comment");
comment.setStatus("published");
comment.setAuditReason("");
comment.setLikeCount(0);
comment.setReplyCount(0);
comment.setReportCount(0);
return comment;
}
}
@@ -0,0 +1,209 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.admin.vo.AdminReportRespVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.entity.ContentReport;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentReportMapper;
import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
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.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/** 后台举报工单目标摘要与批量装配。 */
class ReportAdminServiceTest {
private ContentReportMapper reportMapper;
private ContentCommentMapper commentMapper;
private DramaMapper dramaMapper;
private DramaEpisodeMapper episodeMapper;
private AppUserMapper appUserMapper;
private ReportAdminService service;
@BeforeAll
static void initTableInfoCache() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
ContentReport.class);
}
@BeforeEach
void setUp() {
reportMapper = mock(ContentReportMapper.class);
commentMapper = mock(ContentCommentMapper.class);
dramaMapper = mock(DramaMapper.class);
episodeMapper = mock(DramaEpisodeMapper.class);
appUserMapper = mock(AppUserMapper.class);
service = new ReportAdminService(reportMapper, commentMapper, dramaMapper,
episodeMapper, appUserMapper);
}
@Test
void should_build_summary_and_batch_relations_for_all_report_types() {
List<ContentReport> rows = List.of(
report(1L, 7L, ReportService.TYPE_COMMENT, 101L),
report(2L, 8L, ReportService.TYPE_DRAMA, 5L),
report(3L, 7L, ReportService.TYPE_USER, 9L));
stubPage(rows);
given(commentMapper.selectByIdsIncludingDeleted(any()))
.willReturn(List.of(comment(101L, 5L, 11L, "bad comment", 0)));
given(dramaMapper.selectBatchIds(any())).willReturn(List.of(drama(5L, "Signal")));
given(episodeMapper.selectBatchIds(any())).willReturn(List.of(episode(11L, 3, "The Call")));
given(appUserMapper.selectBatchIds(any())).willReturn(List.of(
user(7L, "Alice", "u-7"),
user(8L, "Bob", "u-8"),
user(9L, "Target", "u-9")));
PageResult<AdminReportRespVO> result = service.page(new ReportQueryReqVO());
AdminReportRespVO commentReport = result.getList().get(0);
assertThat(commentReport.getTargetSummary())
.isEqualTo("Signal · 第 3 集 · The Call");
assertThat(commentReport.getDramaTitle()).isEqualTo("Signal");
assertThat(commentReport.getEpisodeNo()).isEqualTo(3);
assertThat(commentReport.getCommentContent()).isEqualTo("bad comment");
assertThat(commentReport.getReporterNickname()).isEqualTo("Alice");
assertThat(commentReport.getReporterUid()).isEqualTo("u-7");
AdminReportRespVO dramaReport = result.getList().get(1);
assertThat(dramaReport.getTargetSummary()).isEqualTo("Signal");
assertThat(dramaReport.getDramaId()).isEqualTo(5L);
AdminReportRespVO userReport = result.getList().get(2);
assertThat(userReport.getTargetSummary()).isEqualTo("Target(u-9)");
assertThat(userReport.getTargetNickname()).isEqualTo("Target");
assertThat(userReport.getTargetUid()).isEqualTo("u-9");
verify(commentMapper, times(1)).selectByIdsIncludingDeleted(List.of(101L));
verify(dramaMapper, times(1)).selectBatchIds(any());
verify(episodeMapper, times(1)).selectBatchIds(any());
verify(appUserMapper, times(1)).selectBatchIds(any());
}
@Test
void should_leave_all_filters_unset_when_status_and_filters_are_empty() {
stubPage(List.of());
PageResult<AdminReportRespVO> result = service.page(new ReportQueryReqVO());
assertThat(result.getTotal()).isZero();
assertThat(result.getList()).isEmpty();
ArgumentCaptor<LambdaQueryWrapper<ContentReport>> captor = queryCaptor();
verify(reportMapper).selectPage(any(), captor.capture());
assertThat(captor.getValue().getSqlSegment())
.doesNotContain("status")
.doesNotContain("target_type")
.doesNotContain("reason_code")
.doesNotContain("reporter_id");
}
@Test
void should_fallback_to_ids_when_targets_are_deleted_or_missing() {
List<ContentReport> rows = List.of(
report(1L, 7L, ReportService.TYPE_COMMENT, 101L),
report(2L, 7L, ReportService.TYPE_DRAMA, 5L),
report(3L, 7L, ReportService.TYPE_USER, 9L));
stubPage(rows);
given(commentMapper.selectByIdsIncludingDeleted(any()))
.willReturn(List.of(comment(101L, 5L, 11L, "removed", 1)));
given(dramaMapper.selectBatchIds(any())).willReturn(List.of());
given(appUserMapper.selectBatchIds(any()))
.willReturn(List.of(user(7L, "Alice", "u-7")));
PageResult<AdminReportRespVO> result = service.page(new ReportQueryReqVO());
assertThat(result.getList()).extracting(AdminReportRespVO::getTargetSummary)
.containsExactly("评论 #101", "短剧 #5", "用户 #9");
assertThat(result.getList().get(0).getCommentContent()).isNull();
verify(episodeMapper, never()).selectBatchIds(any());
}
private void stubPage(List<ContentReport> rows) {
Page<ContentReport> page = new Page<>(1, 10);
page.setTotal(rows.size());
page.setRecords(rows);
given(reportMapper.selectPage(any(Page.class), any())).willReturn(page);
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<LambdaQueryWrapper<ContentReport>> queryCaptor() {
return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
}
private ContentReport report(Long id, Long reporterId, String type, Long targetId) {
ContentReport report = new ContentReport();
report.setId(id);
report.setReporterId(reporterId);
report.setTargetType(type);
report.setTargetId(targetId);
report.setReasonCode("VIOLATION");
report.setDescription("description");
report.setStatus("pending");
report.setHandleBy(0L);
report.setHandleNote("");
report.setCreatedAt(LocalDateTime.of(2026, 10, 21, 12, 0));
return report;
}
private ContentComment comment(Long id, Long dramaId, Long episodeId, String content,
int deleted) {
ContentComment comment = new ContentComment();
comment.setId(id);
comment.setDramaId(dramaId);
comment.setEpisodeId(episodeId);
comment.setUserId(9L);
comment.setContent(content);
comment.setStatus(deleted == 1 ? "deleted" : "published");
comment.setDeleted(deleted);
return comment;
}
private Drama drama(Long id, String title) {
Drama drama = new Drama();
drama.setId(id);
drama.setTitle(title);
return drama;
}
private DramaEpisode episode(Long id, int no, String title) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setEpNo(no);
episode.setTitle(title);
return episode;
}
private AppUser user(Long id, String nickname, String uid) {
AppUser user = new AppUser();
user.setId(id);
user.setNickname(nickname);
user.setUid(uid);
return user;
}
}
@@ -0,0 +1,260 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
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.never;
import static org.mockito.Mockito.verify;
/** 后台观看记录分页条件与批量装配。 */
class WatchHistoryAdminServiceTest {
private WatchHistoryMapper watchHistoryMapper;
private AppUserMapper appUserMapper;
private DramaMapper dramaMapper;
private DramaEpisodeMapper dramaEpisodeMapper;
private WatchHistoryAdminService service;
@BeforeAll
static void initTableInfoCache() {
MybatisConfiguration configuration = new MybatisConfiguration();
for (Class<?> type : List.of(AppUser.class, Drama.class, DramaEpisode.class,
WatchHistory.class)) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), type);
}
}
@BeforeEach
void setUp() {
watchHistoryMapper = mock(WatchHistoryMapper.class);
appUserMapper = mock(AppUserMapper.class);
dramaMapper = mock(DramaMapper.class);
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
service = new WatchHistoryAdminService(watchHistoryMapper, appUserMapper, dramaMapper,
dramaEpisodeMapper);
}
@Test
void should_use_default_page_and_newest_first_when_no_filters_are_given() {
WatchHistory row = history(100L, 7L, 5L, 11L);
stubPage(row);
stubBatchData();
PageResult<AdminWatchHistoryVO> result = service.page(new WatchHistoryQueryReqVO());
ArgumentCaptor<Page<WatchHistory>> pageCaptor = pageCaptor();
ArgumentCaptor<LambdaQueryWrapper<WatchHistory>> queryCaptor = queryCaptor();
verify(watchHistoryMapper).selectPage(pageCaptor.capture(), queryCaptor.capture());
assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(1);
assertThat(pageCaptor.getValue().getSize()).isEqualTo(10);
assertThat(queryCaptor.getValue().getSqlSegment())
.contains("watched_at DESC")
.contains("id DESC");
assertThat(result.getTotal()).isEqualTo(1L);
assertThat(result.getList().get(0).getUid()).isEqualTo("user-7");
assertThat(result.getList().get(0).getDramaTitle()).isEqualTo("Signal");
assertThat(result.getList().get(0).getEpisodeNo()).isEqualTo(1);
}
@Test
void should_filter_by_matched_user_ids_when_keyword_is_present() {
WatchHistory row = history(100L, 7L, 5L, 11L);
given(appUserMapper.selectList(any())).willReturn(List.of(user(7L, "Alice")));
stubPage(row);
stubBatchData();
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setKeyword("Ali");
service.page(req);
LambdaQueryWrapper<WatchHistory> query = capturedQuery();
assertThat(query.getSqlSegment()).contains("user_id IN");
assertThat(query.getParamNameValuePairs().containsValue(7L)).isTrue();
}
@Test
void should_filter_by_matched_drama_ids_when_title_is_present() {
WatchHistory row = history(100L, 7L, 5L, 11L);
given(dramaMapper.selectList(any())).willReturn(List.of(drama(5L, "Signal")));
stubPage(row);
stubBatchData();
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setDramaTitle("Sig");
service.page(req);
LambdaQueryWrapper<WatchHistory> query = capturedQuery();
assertThat(query.getSqlSegment()).contains("drama_id IN");
assertThat(query.getParamNameValuePairs().containsValue(5L)).isTrue();
}
@Test
void should_return_empty_page_without_querying_history_when_keyword_has_no_user() {
given(appUserMapper.selectList(any())).willReturn(List.of());
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setKeyword("missing");
PageResult<AdminWatchHistoryVO> result = service.page(req);
assertThat(result.getTotal()).isZero();
assertThat(result.getList()).isEmpty();
verify(watchHistoryMapper, never()).selectPage(any(), any());
}
@Test
void should_apply_true_finished_condition_in_sql() {
WatchHistory row = history(100L, 7L, 5L, 11L);
row.setPositionMs(59000L);
row.setDurationMs(60000L);
stubPage(row);
stubBatchData();
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setFinished(true);
PageResult<AdminWatchHistoryVO> result = service.page(req);
LambdaQueryWrapper<WatchHistory> query = capturedQuery();
assertThat(query.getSqlSegment())
.contains("duration_ms >")
.contains("position_ms +");
assertThat(query.getParamNameValuePairs().containsValue(1000L)).isTrue();
assertThat(result.getList().get(0).getFinished()).isTrue();
}
@Test
void should_apply_false_finished_condition_in_sql() {
WatchHistory row = history(100L, 7L, 5L, 11L);
row.setPositionMs(1000L);
row.setDurationMs(60000L);
stubPage(row);
stubBatchData();
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setFinished(false);
PageResult<AdminWatchHistoryVO> result = service.page(req);
LambdaQueryWrapper<WatchHistory> query = capturedQuery();
assertThat(query.getSqlSegment())
.contains("duration_ms =")
.contains("OR")
.contains("position_ms +");
assertThat(result.getList().get(0).getFinished()).isFalse();
}
@Test
void should_apply_closed_time_range_and_clamp_page_size() {
WatchHistory row = history(100L, 7L, 5L, 11L);
stubPage(row);
stubBatchData();
LocalDateTime start = LocalDateTime.of(2026, 10, 1, 0, 0);
LocalDateTime end = LocalDateTime.of(2026, 10, 21, 23, 59, 59);
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setPageSize(500);
req.setStartTime(start);
req.setEndTime(end);
service.page(req);
ArgumentCaptor<Page<WatchHistory>> pageCaptor = pageCaptor();
ArgumentCaptor<LambdaQueryWrapper<WatchHistory>> queryCaptor = queryCaptor();
verify(watchHistoryMapper).selectPage(pageCaptor.capture(), queryCaptor.capture());
assertThat(pageCaptor.getValue().getSize()).isEqualTo(100);
assertThat(queryCaptor.getValue().getSqlSegment())
.contains("watched_at >=")
.contains("watched_at <=");
}
private void stubPage(WatchHistory row) {
Page<WatchHistory> page = new Page<>(1, 10);
page.setTotal(1L);
page.setRecords(List.of(row));
given(watchHistoryMapper.selectPage(any(Page.class), any())).willReturn(page);
}
private void stubBatchData() {
given(appUserMapper.selectBatchIds(any())).willReturn(List.of(user(7L, "Alice")));
given(dramaMapper.selectBatchIds(any())).willReturn(List.of(drama(5L, "Signal")));
given(dramaEpisodeMapper.selectBatchIds(any())).willReturn(List.of(episode(11L)));
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<Page<WatchHistory>> pageCaptor() {
return ArgumentCaptor.forClass(Page.class);
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<LambdaQueryWrapper<WatchHistory>> queryCaptor() {
return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
}
private LambdaQueryWrapper<WatchHistory> capturedQuery() {
ArgumentCaptor<LambdaQueryWrapper<WatchHistory>> captor = queryCaptor();
verify(watchHistoryMapper).selectPage(any(), captor.capture());
return captor.getValue();
}
private WatchHistory history(Long id, Long userId, Long dramaId, Long episodeId) {
WatchHistory row = new WatchHistory();
row.setId(id);
row.setUserId(userId);
row.setDramaId(dramaId);
row.setEpisodeId(episodeId);
row.setPositionMs(1000L);
row.setDurationMs(60000L);
row.setSource("HOME");
row.setDeviceId("device-1");
row.setWatchedAt(LocalDateTime.of(2026, 10, 21, 12, 0));
return row;
}
private AppUser user(Long id, String nickname) {
AppUser user = new AppUser();
user.setId(id);
user.setUid("user-" + id);
user.setNickname(nickname);
return user;
}
private Drama drama(Long id, String title) {
Drama drama = new Drama();
drama.setId(id);
drama.setTitle(title);
drama.setStatus("published");
return drama;
}
private DramaEpisode episode(Long id) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setEpNo(1);
episode.setTitle("Episode 1");
return episode;
}
}
@@ -5,12 +5,15 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppCategoryVO; import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppFeedVO; import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -38,6 +41,16 @@ public class AppCatalogController {
return Result.ok(appCatalogService.discoverFeed()); return Result.ok(appCatalogService.discoverFeed());
} }
@Operation(summary = "推荐流:支持内容类型、分类过滤与游标翻页")
@GetMapping("/feed")
public Result<CursorResult<AppDramaCardVO>> feed(
@RequestParam(required = false) String types,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long cursor,
@RequestParam(required = false) Integer size) {
return Result.ok(appCatalogService.feed(types, categoryId, cursor, size));
}
@Operation(summary = "分类列表") @Operation(summary = "分类列表")
@GetMapping("/categories") @GetMapping("/categories")
public Result<List<AppCategoryVO>> categories() { public Result<List<AppCategoryVO>> categories() {
@@ -3,6 +3,8 @@
package com.fruitshorts.app.controller; package com.fruitshorts.app.controller;
import com.fruitshorts.common.pojo.CursorResult; import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils; import com.fruitshorts.framework.web.WebUtils;
@@ -24,6 +26,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** /**
* 评论。 * 评论。
* *
@@ -38,6 +42,7 @@ import org.springframework.web.bind.annotation.RestController;
public class AppCommentController { public class AppCommentController {
private final CommentService commentService; private final CommentService commentService;
private final EpisodeService episodeService;
@Operation(summary = "发布评论") @Operation(summary = "发布评论")
@PostMapping @PostMapping
@@ -63,9 +68,18 @@ public class AppCommentController {
return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query)); return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query));
} }
@Operation(summary = "短剧评论总数(可匿名访问)") @Operation(summary = "评论总数:传 episodeId 按集统计,仅 dramaId 时全剧统计(可匿名访问)")
@GetMapping("/count") @GetMapping("/count")
public Result<Long> count(@RequestParam Long dramaId) { public Result<Long> count(@RequestParam Long dramaId,
@RequestParam(required = false) Long episodeId) {
if (episodeId != null) {
DramaEpisode episode = episodeService.getPublished(episodeId);
if (!dramaId.equals(episode.getDramaId())) {
return Result.ok(0L);
}
return Result.ok(commentService.countByEpisodes(List.of(episodeId))
.getOrDefault(episodeId, 0L));
}
return Result.ok(commentService.countByDrama(dramaId)); return Result.ok(commentService.countByDrama(dramaId));
} }
@@ -5,14 +5,20 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils; import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.playback.service.PlaybackService; import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO; import com.fruitshorts.playback.vo.PlayRespVO;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -26,6 +32,8 @@ public class AppDramaController {
private final AppCatalogService appCatalogService; private final AppCatalogService appCatalogService;
private final PlaybackService playbackService; private final PlaybackService playbackService;
private final EpisodeService episodeService;
private final CommentService commentService;
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */ /** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
@Operation(summary = "短剧详情(含剧集列表,进详情页一次请求即可渲染)") @Operation(summary = "短剧详情(含剧集列表,进详情页一次请求即可渲染)")
@@ -41,10 +49,14 @@ public class AppDramaController {
@Operation(summary = "播放票据:判定权益后签发短时效播放地址") @Operation(summary = "播放票据:判定权益后签发短时效播放地址")
@GetMapping("/episodes/{id}/play") @GetMapping("/episodes/{id}/play")
public Result<PlayRespVO> play(@PathVariable Long id, HttpServletRequest request) { public Result<PlayRespVO> play(@PathVariable Long id, HttpServletRequest request) {
return Result.ok(playbackService.play( PlayRespVO vo = playbackService.play(
SecurityUtils.getUserIdOrNull(), SecurityUtils.getUserIdOrNull(),
id, id,
request.getHeader("X-Device-Id"), request.getHeader("X-Device-Id"),
WebUtils.getClientIp(request))); WebUtils.getClientIp(request));
DramaEpisode episode = episodeService.getExisting(id);
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setCommentCount(commentService.countByEpisodes(List.of(id)).getOrDefault(id, 0L));
return Result.ok(vo);
} }
} }
@@ -9,6 +9,7 @@ import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppEpisodeVO; import com.fruitshorts.app.vo.AppEpisodeVO;
import com.fruitshorts.app.vo.AppFeedVO; import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppSectionVO; import com.fruitshorts.app.vo.AppSectionVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.ContentBanner; import com.fruitshorts.content.dal.entity.ContentBanner;
import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.dal.entity.DramaEpisode;
@@ -27,6 +28,7 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Arrays;
/** /**
* C 端目录聚合:把内容域的只读视图组装成客户端一次请求就能渲染的结构。 * C 端目录聚合:把内容域的只读视图组装成客户端一次请求就能渲染的结构。
@@ -39,6 +41,8 @@ public class AppCatalogService {
private static final int HOME_SECTION_SIZE = 6; private static final int HOME_SECTION_SIZE = 6;
private static final int DISCOVER_SECTION_SIZE = 10; private static final int DISCOVER_SECTION_SIZE = 10;
private static final int MAX_CATEGORY_SECTIONS = 4; private static final int MAX_CATEGORY_SECTIONS = 4;
private static final int DEFAULT_FEED_SIZE = 20;
private static final int MAX_FEED_SIZE = 50;
private final DramaService dramaService; private final DramaService dramaService;
private final EpisodeService episodeService; private final EpisodeService episodeService;
@@ -70,6 +74,26 @@ public class AppCatalogService {
.toList(); .toList();
} }
public CursorResult<AppDramaCardVO> feed(
String types, Long categoryId, Long cursor, Integer size) {
int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE));
List<String> dramaTypes = types == null
? List.of()
: Arrays.stream(types.split(","))
.map(String::trim)
.filter(type -> !type.isEmpty())
.toList();
List<Drama> rows = dramaService.listPublishedFeed(
dramaTypes, categoryId, cursor, pageSize + 1);
if (rows.isEmpty()) {
return CursorResult.empty();
}
boolean hasMore = rows.size() > pageSize;
List<Drama> pageRows = hasMore ? rows.subList(0, pageSize) : rows;
Long nextCursor = hasMore ? pageRows.get(pageRows.size() - 1).getId() : null;
return new CursorResult<>(cards(pageRows, categoryNameMap()), nextCursor, hasMore);
}
/** /**
* 供收藏、观看记录等列表复用卡片结构:字段与首页卡片完全一致, * 供收藏、观看记录等列表复用卡片结构:字段与首页卡片完全一致,
* 客户端可以用同一套渲染逻辑,不用为每个列表各写一份。 * 客户端可以用同一套渲染逻辑,不用为每个列表各写一份。
@@ -84,6 +108,8 @@ public class AppCatalogService {
public AppDramaDetailVO dramaDetail(Long dramaId) { public AppDramaDetailVO dramaDetail(Long dramaId) {
Drama drama = dramaService.getPublished(dramaId); Drama drama = dramaService.getPublished(dramaId);
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId); List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
Map<Long, Long> episodeCommentCounts = commentService.countByEpisodes(
episodes.stream().map(DramaEpisode::getId).sorted().toList());
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), ""); String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
AppDramaDetailVO detail = new AppDramaDetailVO(); AppDramaDetailVO detail = new AppDramaDetailVO();
@@ -101,7 +127,9 @@ public class AppCatalogService {
detail.setLikeCount(drama.getLikeCount()); detail.setLikeCount(drama.getLikeCount());
detail.setFavoriteCount(drama.getFavoriteCount()); detail.setFavoriteCount(drama.getFavoriteCount());
detail.setCommentCount(commentService.countByDrama(dramaId)); detail.setCommentCount(commentService.countByDrama(dramaId));
detail.setEpisodes(episodes.stream().map(this::toEpisode).toList()); detail.setEpisodes(episodes.stream()
.map(episode -> toEpisode(episode, episodeCommentCounts))
.toList());
return detail; return detail;
} }
@@ -146,7 +174,8 @@ public class AppCatalogService {
private List<AppDramaCardVO> cards(List<Drama> dramas, Map<Long, String> categoryNames) { private List<AppDramaCardVO> cards(List<Drama> dramas, Map<Long, String> categoryNames) {
List<Long> ids = dramas.stream().map(Drama::getId).toList(); List<Long> ids = dramas.stream().map(Drama::getId).toList();
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids); Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids);
Map<Long, Long> commentCounts = commentService.countByDramas(ids); Map<Long, Long> commentCounts = commentService.countByEpisodes(
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
return dramas.stream() return dramas.stream()
.map(drama -> toCard( .map(drama -> toCard(
drama, categoryNames, previews.get(drama.getId()), commentCounts)) drama, categoryNames, previews.get(drama.getId()), commentCounts))
@@ -165,15 +194,18 @@ public class AppCatalogService {
card.setTotalEpisodes(drama.getTotalEpisodes()); card.setTotalEpisodes(drama.getTotalEpisodes());
card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1); card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
card.setViewCount(drama.getViewCount()); card.setViewCount(drama.getViewCount());
card.setLikeCount(drama.getLikeCount()); card.setLikeCount(preview == null || preview.getLikeCount() == null
? 0L : preview.getLikeCount());
card.setFavoriteCount(drama.getFavoriteCount()); card.setFavoriteCount(drama.getFavoriteCount());
card.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L)); card.setCommentCount(preview == null
? 0L : commentCounts.getOrDefault(preview.getId(), 0L));
card.setLanguage(drama.getLanguage()); card.setLanguage(drama.getLanguage());
card.setPreview(preview == null ? null : toEpisode(preview)); card.setDramaType(drama.getDramaType());
card.setPreview(preview == null ? null : toEpisode(preview, commentCounts));
return card; return card;
} }
private AppEpisodeVO toEpisode(DramaEpisode episode) { private AppEpisodeVO toEpisode(DramaEpisode episode, Map<Long, Long> commentCounts) {
AppEpisodeVO vo = new AppEpisodeVO(); AppEpisodeVO vo = new AppEpisodeVO();
vo.setId(episode.getId()); vo.setId(episode.getId());
vo.setNumber(episode.getEpNo()); vo.setNumber(episode.getEpNo());
@@ -185,6 +217,8 @@ public class AppCatalogService {
vo.setIsFree(free); vo.setIsFree(free);
vo.setLocked(!free); vo.setLocked(!free);
vo.setUnlockCoin(episode.getUnlockCoin()); vo.setUnlockCoin(episode.getUnlockCoin());
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
if (free) { if (free) {
// 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址 // 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址
vo.setPlayUrl(playbackSigner.sign(playableSource(episode))); vo.setPlayUrl(playbackSigner.sign(playableSource(episode)));
@@ -27,6 +27,8 @@ public class AppDramaCardVO {
/** 语种,供客户端按语言筛选(如 zh-CN / id)。 */ /** 语种,供客户端按语言筛选(如 zh-CN / id)。 */
@Schema(description = "短剧语种,如 zh-CN / id") @Schema(description = "短剧语种,如 zh-CN / id")
private String language; private String language;
/** 内容类型:ANIME=漫剧,LIVE=真人剧 */
private String dramaType;
/** /**
* 首个可播免费集。首页竖屏播放需要在列表阶段就能起播, * 首个可播免费集。首页竖屏播放需要在列表阶段就能起播,
* 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。 * 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。
@@ -17,6 +17,8 @@ public class AppEpisodeVO {
/** true 表示需要解锁后才能播放 */ /** true 表示需要解锁后才能播放 */
private Boolean locked; private Boolean locked;
private Integer unlockCoin; private Integer unlockCoin;
private Long likeCount;
private Long commentCount;
/** /**
* 可播放时的地址;锁定或不含片源时为空。 * 可播放时的地址;锁定或不含片源时为空。
* 详情页拿到就能直接播,不必按集再请求一次播放票据。 * 详情页拿到就能直接播,不必按集再请求一次播放票据。
@@ -9,9 +9,12 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppFeedVO; import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppSectionVO; import com.fruitshorts.app.vo.AppSectionVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.playback.service.PlaybackService; import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -42,7 +45,8 @@ class AppCatalogControllerContractTest {
catalogService = mock(AppCatalogService.class); catalogService = mock(AppCatalogService.class);
catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService)); catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService));
dramaMvc = ControllerContractSupport.mockMvcOf( dramaMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(catalogService, mock(PlaybackService.class))); new AppDramaController(catalogService, mock(PlaybackService.class),
mock(EpisodeService.class), mock(CommentService.class)));
} }
@Test @Test
@@ -86,6 +90,29 @@ class AppCatalogControllerContractTest {
.andExpect(jsonPath("$.data.sections").isArray()); .andExpect(jsonPath("$.data.sections").isArray());
} }
@Test
void should_return_typed_cursor_feed() throws Exception {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(12L);
card.setTitle("漫剧卡片");
card.setDramaType("ANIME");
given(catalogService.feed("ANIME", 7L, null, 20))
.willReturn(new CursorResult<>(List.of(card), 12L, true));
catalogMvc.perform(get("/app/feed")
.param("types", "ANIME")
.param("categoryId", "7")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].id").value("12"))
.andExpect(jsonPath("$.data.list[0].dramaType").value("ANIME"))
.andExpect(jsonPath("$.data.nextCursor").value("12"))
.andExpect(jsonPath("$.data.hasMore").value(true));
verify(catalogService).feed("ANIME", 7L, null, 20);
}
@Test @Test
void should_return_category_list_when_categories_are_requested() throws Exception { void should_return_category_list_when_categories_are_requested() throws Exception {
AppCategoryVO category = new AppCategoryVO(3L, "都市"); AppCategoryVO category = new AppCategoryVO(3L, "都市");
@@ -3,11 +3,16 @@
package com.fruitshorts.app.controller; package com.fruitshorts.app.controller;
import com.fruitshorts.app.support.ControllerContractSupport; import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService; import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -19,13 +24,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class AppCommentControllerContractTest { class AppCommentControllerContractTest {
private CommentService commentService; private CommentService commentService;
private EpisodeService episodeService;
private MockMvc mockMvc; private MockMvc mockMvc;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
commentService = mock(CommentService.class); commentService = mock(CommentService.class);
episodeService = mock(EpisodeService.class);
mockMvc = ControllerContractSupport.mockMvcOf( mockMvc = ControllerContractSupport.mockMvcOf(
new AppCommentController(commentService)); new AppCommentController(commentService, episodeService));
} }
@Test @Test
@@ -40,4 +47,21 @@ class AppCommentControllerContractTest {
verify(commentService).countByDrama(7L); verify(commentService).countByDrama(7L);
} }
@Test
void should_count_comments_by_episode_when_episode_id_is_given() throws Exception {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setDramaId(7L);
given(episodeService.getPublished(11L)).willReturn(episode);
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 4L));
mockMvc.perform(get("/app/comments/count")
.param("dramaId", "7")
.param("episodeId", "11"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").value("4"));
verify(commentService).countByEpisodes(List.of(11L));
}
} }
@@ -0,0 +1,135 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
import com.fruitshorts.framework.security.JwtTokenProvider;
import com.fruitshorts.framework.security.RestAuthEntryPoint;
import com.fruitshorts.framework.security.SecurityConfig;
import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
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;
/** 客户端公开只读接口的安全链契约:匿名请求必须直接进入控制器。 */
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration(classes = AppCommentCountAnonymousSecurityTest.TestConfig.class)
class AppCommentCountAnonymousSecurityTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private CommentService commentService;
@Autowired
private AppCatalogService catalogService;
@BeforeEach
void setUp() {
given(commentService.countByDrama(7L)).willReturn(9L);
given(catalogService.feed("ANIME", null, null, 20))
.willReturn(new CursorResult<>(List.of(), null, false));
}
@Test
void should_allow_anonymous_count_request_through_security_chain() throws Exception {
mockMvc.perform(get("/app/comments/count").param("dramaId", "7"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(9));
}
@Test
void should_allow_anonymous_typed_feed_request_through_security_chain() throws Exception {
mockMvc.perform(get("/app/feed")
.param("types", "ANIME")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list").isArray());
}
@Configuration
@EnableWebMvc
@EnableWebSecurity
@Import(SecurityConfig.class)
static class TestConfig {
@Bean
CommentService commentService() {
return mock(CommentService.class);
}
@Bean
AppCommentController appCommentController(CommentService commentService) {
return new AppCommentController(commentService, mock(EpisodeService.class));
}
@Bean
AppCatalogService appCatalogService() {
return mock(AppCatalogService.class);
}
@Bean
AppCatalogController appCatalogController(AppCatalogService appCatalogService) {
return new AppCatalogController(appCatalogService);
}
@Bean
JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(
mock(JwtTokenProvider.class),
mock(ObjectProvider.class));
}
@Bean
RestAuthEntryPoint restAuthEntryPoint(ObjectMapper objectMapper) {
return new RestAuthEntryPoint(
objectMapper,
new AccessDeniedRecorder(mock(ObjectProvider.class)));
}
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
MockMvc mockMvc(WebApplicationContext context) {
FilterChainProxy securityFilter =
context.getBean("springSecurityFilterChain", FilterChainProxy.class);
return MockMvcBuilders.webAppContextSetup(context)
.addFilters(securityFilter)
.build();
}
}
}
@@ -3,6 +3,9 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.support.ControllerContractSupport; import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.playback.service.PlaybackService; import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO; import com.fruitshorts.playback.vo.PlayRespVO;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -10,6 +13,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -30,13 +34,22 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class AppPlaybackContractTest { class AppPlaybackContractTest {
private PlaybackService playbackService; private PlaybackService playbackService;
private EpisodeService episodeService;
private CommentService commentService;
private MockMvc mockMvc; private MockMvc mockMvc;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
playbackService = mock(PlaybackService.class); playbackService = mock(PlaybackService.class);
episodeService = mock(EpisodeService.class);
commentService = mock(CommentService.class);
DramaEpisode episode = new DramaEpisode();
episode.setLikeCount(88L);
given(episodeService.getExisting(any())).willReturn(episode);
given(commentService.countByEpisodes(any())).willReturn(Map.of());
mockMvc = ControllerContractSupport.mockMvcOf( mockMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(mock(AppCatalogService.class), playbackService)); new AppDramaController(mock(AppCatalogService.class), playbackService,
episodeService, commentService));
} }
@Test @Test
@@ -52,7 +65,9 @@ class AppPlaybackContractTest {
.andExpect(jsonPath("$.data.episodeId").isString()) .andExpect(jsonPath("$.data.episodeId").isString())
.andExpect(jsonPath("$.data.accessible").value(true)) .andExpect(jsonPath("$.data.accessible").value(true))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE)) .andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1")); .andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"))
.andExpect(jsonPath("$.data.likeCount").value("88"))
.andExpect(jsonPath("$.data.commentCount").value("0"));
} }
@Test @Test
@@ -3,7 +3,9 @@ package com.fruitshorts.app.service;
import com.fruitshorts.app.vo.AppDramaCardVO; import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.BannerService; import com.fruitshorts.content.service.BannerService;
import com.fruitshorts.content.service.DramaCategoryService; import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.DramaService;
@@ -13,6 +15,7 @@ import com.fruitshorts.media.service.PlaybackSigner;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -54,10 +57,15 @@ class AppCatalogServiceTest {
drama.setLikeCount(3200L); drama.setLikeCount(3200L);
drama.setFavoriteCount(1800L); drama.setFavoriteCount(1800L);
drama.setViewCount(12400L); drama.setViewCount(12400L);
DramaEpisode firstEpisode = episode(11L, 42L, 55L);
DramaEpisode secondEpisode = episode(22L, 42L, 66L);
given(dramaService.getPublished(42L)).willReturn(drama); given(dramaService.getPublished(42L)).willReturn(drama);
given(episodeService.listPublishedByDrama(42L)).willReturn(List.of()); given(episodeService.listPublishedByDrama(42L))
.willReturn(List.of(secondEpisode, firstEpisode));
given(dramaCategoryService.listBasic()).willReturn(List.of()); given(dramaCategoryService.listBasic()).willReturn(List.of());
given(commentService.countByDrama(42L)).willReturn(96L); given(commentService.countByDrama(42L)).willReturn(96L);
given(commentService.countByEpisodes(List.of(11L, 22L)))
.willReturn(Map.of(11L, 2L, 22L, 4L));
AppDramaDetailVO detail = catalogService.dramaDetail(42L); AppDramaDetailVO detail = catalogService.dramaDetail(42L);
@@ -65,6 +73,8 @@ class AppCatalogServiceTest {
assertThat(detail.getLikeCount()).isEqualTo(3200L); assertThat(detail.getLikeCount()).isEqualTo(3200L);
assertThat(detail.getFavoriteCount()).isEqualTo(1800L); assertThat(detail.getFavoriteCount()).isEqualTo(1800L);
assertThat(detail.getCommentCount()).isEqualTo(96L); assertThat(detail.getCommentCount()).isEqualTo(96L);
assertThat(detail.getEpisodes().get(0).getLikeCount()).isEqualTo(66L);
assertThat(detail.getEpisodes().get(0).getCommentCount()).isEqualTo(4L);
verify(commentService).countByDrama(42L); verify(commentService).countByDrama(42L);
} }
@@ -82,10 +92,15 @@ class AppCatalogServiceTest {
second.setViewCount(800L); second.setViewCount(800L);
second.setLikeCount(120L); second.setLikeCount(120L);
second.setFavoriteCount(80L); second.setFavoriteCount(80L);
given(episodeService.firstPlayableByDramas(List.of(1L, 2L))).willReturn(Map.of()); DramaEpisode firstEpisode = episode(11L, 1L, 320L);
DramaEpisode secondEpisode = episode(22L, 2L, 120L);
Map<Long, DramaEpisode> previews = new LinkedHashMap<>();
previews.put(2L, secondEpisode);
previews.put(1L, firstEpisode);
given(episodeService.firstPlayableByDramas(List.of(1L, 2L))).willReturn(previews);
given(dramaCategoryService.listBasic()).willReturn(List.of()); given(dramaCategoryService.listBasic()).willReturn(List.of());
given(commentService.countByDramas(List.of(1L, 2L))) given(commentService.countByEpisodes(List.of(11L, 22L)))
.willReturn(Map.of(1L, 9L)); .willReturn(Map.of(11L, 9L));
List<AppDramaCardVO> cards = catalogService.toCards(List.of(first, second)); List<AppDramaCardVO> cards = catalogService.toCards(List.of(first, second));
@@ -96,7 +111,96 @@ class AppCatalogServiceTest {
assertThat(cards.get(1).getLikeCount()).isEqualTo(120L); assertThat(cards.get(1).getLikeCount()).isEqualTo(120L);
assertThat(cards.get(1).getFavoriteCount()).isEqualTo(80L); assertThat(cards.get(1).getFavoriteCount()).isEqualTo(80L);
assertThat(cards.get(1).getCommentCount()).isZero(); assertThat(cards.get(1).getCommentCount()).isZero();
verify(commentService).countByDramas(List.of(1L, 2L)); verify(commentService).countByEpisodes(List.of(11L, 22L));
verify(commentService, never()).countByDrama(any()); verify(commentService, never()).countByDrama(any());
} }
@Test
void should_filter_feed_by_drama_type_and_expose_it_on_card() {
Drama anime = drama(1L, "ANIME");
given(dramaService.listPublishedFeed(List.of("ANIME"), null, null, 21))
.willReturn(List.of(anime));
given(dramaCategoryService.listBasic()).willReturn(List.of());
DramaEpisode preview = episode(11L, 1L, 45L);
given(episodeService.firstPlayableByDramas(List.of(1L)))
.willReturn(Map.of(1L, preview));
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 3L));
CursorResult<AppDramaCardVO> result = catalogService.feed(
"ANIME", null, null, null);
assertThat(result.getList()).hasSize(1);
assertThat(result.getList().get(0).getDramaType()).isEqualTo("ANIME");
assertThat(result.getList().get(0).getCommentCount()).isEqualTo(3L);
assertThat(result.getList().get(0).getLikeCount()).isEqualTo(45L);
assertThat(result.isHasMore()).isFalse();
verify(dramaService).listPublishedFeed(List.of("ANIME"), null, null, 21);
}
@Test
void should_parse_multiple_feed_types() {
given(dramaService.listPublishedFeed(
List.of("ANIME", "LIVE"), null, null, 11))
.willReturn(List.of());
CursorResult<AppDramaCardVO> result = catalogService.feed(
"ANIME,LIVE", null, null, 10);
assertThat(result.getList()).isEmpty();
verify(dramaService).listPublishedFeed(
List.of("ANIME", "LIVE"), null, null, 11);
}
@Test
void should_filter_feed_by_category_id_only() {
given(dramaService.listPublishedFeed(List.of(), 7L, null, 21))
.willReturn(List.of());
CursorResult<AppDramaCardVO> result = catalogService.feed(
null, 7L, null, null);
assertThat(result.getList()).isEmpty();
verify(dramaService).listPublishedFeed(List.of(), 7L, null, 21);
}
@Test
void should_page_feed_with_cursor_without_duplicates() {
Drama first = drama(1L, "ANIME");
Drama second = drama(2L, "LIVE");
given(dramaService.listPublishedFeed(List.of(), null, null, 2))
.willReturn(List.of(first, second));
given(dramaService.listPublishedFeed(List.of(), null, 1L, 2))
.willReturn(List.of(second));
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of());
given(commentService.countByEpisodes(any())).willReturn(Map.of());
CursorResult<AppDramaCardVO> firstPage = catalogService.feed(
null, null, null, 1);
CursorResult<AppDramaCardVO> secondPage = catalogService.feed(
null, null, firstPage.getNextCursor(), 1);
assertThat(firstPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(1L);
assertThat(firstPage.getNextCursor()).isEqualTo(1L);
assertThat(firstPage.isHasMore()).isTrue();
assertThat(secondPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(2L);
assertThat(secondPage.getNextCursor()).isNull();
assertThat(secondPage.isHasMore()).isFalse();
}
private Drama drama(Long id, String dramaType) {
Drama drama = new Drama();
drama.setId(id);
drama.setTitle("短剧" + id);
drama.setDramaType(dramaType);
return drama;
}
private DramaEpisode episode(Long id, Long dramaId, Long likeCount) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setDramaId(dramaId);
episode.setLikeCount(likeCount);
return episode;
}
} }
@@ -109,4 +109,23 @@ class CommentServiceCountTest {
assertThat(lambda.getParamNameValuePairs().values()) assertThat(lambda.getParamNameValuePairs().values())
.contains(7L, 8L, "deleted"); .contains(7L, 8L, "deleted");
} }
@Test
void should_group_comment_counts_by_episode() {
ArgumentCaptor<Wrapper<ContentComment>> captor = ArgumentCaptor.forClass(Wrapper.class);
given(commentMapper.selectMaps(captor.capture())).willReturn(List.of(
Map.of("episode_id", 11L, "comment_count", 4L),
Map.of("episode_id", 12L, "comment_count", 2L)));
Map<Long, Long> counts = commentService.countByEpisodes(
Arrays.asList(11L, 12L, 12L, null));
assertThat(counts).containsEntry(11L, 4L).containsEntry(12L, 2L);
Wrapper<ContentComment> wrapper = captor.getValue();
assertThat(wrapper.getSqlSegment())
.contains("episode_id")
.contains("status")
.contains("deleted")
.contains("GROUP BY");
}
} }
@@ -0,0 +1,159 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
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.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.never;
import static org.mockito.Mockito.verify;
/** K88 App 推荐流的类型过滤与三字段 keyset 游标。 */
class DramaServiceFeedTest {
private DramaMapper dramaMapper;
private DramaService dramaService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class);
}
@BeforeEach
void setUp() {
dramaMapper = mock(DramaMapper.class);
dramaService = new DramaService(
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
}
@Test
@SuppressWarnings("unchecked")
void should_filter_types_and_apply_recommendation_cursor() {
Drama marker = new Drama();
marker.setId(9L);
marker.setStatus("published");
marker.setDramaType("ANIME");
marker.setRecommendWeight(30);
marker.setViewCount(1200L);
given(dramaMapper.selectById(9L)).willReturn(marker);
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of("ANIME"), null, 9L, 5);
verify(dramaMapper).selectList(captor.capture());
QueryWrapper<Drama> query = captor.getValue();
assertThat(query.getSqlSegment())
.contains("status")
.contains("drama_type")
.contains("recommend_weight")
.contains("view_count")
.contains("id");
assertThat(query.getParamNameValuePairs().values())
.contains("published", "%,ANIME,%", 30, 1200L, 9L);
}
@Test
@SuppressWarnings("unchecked")
void should_handle_null_recommend_weight_in_cursor() {
Drama marker = new Drama();
marker.setId(9L);
marker.setStatus("published");
marker.setDramaType("LIVE");
marker.setViewCount(1200L);
given(dramaMapper.selectById(9L)).willReturn(marker);
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of("LIVE"), null, 9L, 5);
verify(dramaMapper).selectList(captor.capture());
assertThat(captor.getValue().getSqlSegment())
.contains("recommend_weight IS NULL")
.contains("view_count")
.contains("id");
}
@Test
@SuppressWarnings("unchecked")
void should_apply_category_and_type_tokens_together() {
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of("ANIME"), 7L, null, 5);
verify(dramaMapper).selectList(captor.capture());
QueryWrapper<Drama> query = captor.getValue();
assertThat(query.getSqlSegment())
.contains("drama_types")
.contains("category_ids")
.contains("recommend_weight");
assertThat(query.getParamNameValuePairs().values())
.contains("%,ANIME,%", "%,7,%");
}
@Test
@SuppressWarnings("unchecked")
void should_apply_category_cursor_for_matching_marker() {
Drama marker = new Drama();
marker.setId(9L);
marker.setStatus("published");
marker.setDramaType("ANIME");
marker.setCategoryId(7L);
marker.setCategoryIds("7,8");
marker.setRecommendWeight(30);
marker.setViewCount(1200L);
given(dramaMapper.selectById(9L)).willReturn(marker);
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of(), 7L, 9L, 5);
verify(dramaMapper).selectList(captor.capture());
QueryWrapper<Drama> query = captor.getValue();
assertThat(query.getSqlSegment())
.contains("category_ids")
.contains("recommend_weight")
.contains("id");
assertThat(query.getParamNameValuePairs().values())
.contains("%,7,%", 30, 1200L, 9L);
}
@Test
void should_return_empty_when_cursor_marker_does_not_match_category() {
Drama marker = new Drama();
marker.setId(9L);
marker.setStatus("published");
marker.setDramaType("ANIME");
marker.setCategoryId(8L);
marker.setCategoryIds("8");
given(dramaMapper.selectById(9L)).willReturn(marker);
List<Drama> rows = dramaService.listPublishedFeed(List.of("ANIME"), 7L, 9L, 5);
assertThat(rows).isEmpty();
verify(dramaMapper, never()).selectList(any());
}
}
@@ -0,0 +1,127 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.vo.DramaSaveReqVO;
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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** K88 后台短剧内容类型与互动数据编辑规则。 */
class DramaServiceK88Test {
private DramaMapper dramaMapper;
private DramaService dramaService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class);
}
@BeforeEach
void setUp() {
dramaMapper = mock(DramaMapper.class);
dramaService = new DramaService(
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
}
@Test
void should_update_only_requested_counts_and_preserve_drama_type() {
Drama existing = drama();
given(dramaMapper.selectById(7L)).willReturn(existing);
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("更新后的标题");
req.setFavoriteCount(99L);
req.setHeat(500L);
dramaService.update(7L, req);
ArgumentCaptor<Drama> captor = ArgumentCaptor.forClass(Drama.class);
verify(dramaMapper).updateById(captor.capture());
Drama updated = captor.getValue();
assertThat(updated.getFavoriteCount()).isEqualTo(99L);
assertThat(updated.getLikeCount()).isEqualTo(20L);
assertThat(updated.getViewCount()).isEqualTo(500L);
assertThat(updated.getDramaType()).isEqualTo("ANIME");
}
@Test
void should_reject_negative_interaction_count() {
given(dramaMapper.selectById(7L)).willReturn(drama());
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("更新后的标题");
req.setFavoriteCount(-1L);
assertThatThrownBy(() -> dramaService.update(7L, req))
.isInstanceOf(ServiceException.class)
.satisfies(error -> assertThat(((ServiceException) error).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()));
verify(dramaMapper, never()).updateById(any(Drama.class));
}
@Test
void should_reject_invalid_drama_type() {
given(dramaMapper.selectById(7L)).willReturn(drama());
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("更新后的标题");
req.setDramaType("MOVIE");
assertThatThrownBy(() -> dramaService.update(7L, req))
.isInstanceOf(ServiceException.class)
.satisfies(error -> assertThat(((ServiceException) error).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()));
verify(dramaMapper, never()).updateById(any(Drama.class));
}
@Test
void should_default_new_drama_to_live_and_zero_counts() {
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("新短剧");
dramaService.create(req, 1L);
ArgumentCaptor<Drama> captor = ArgumentCaptor.forClass(Drama.class);
verify(dramaMapper).insert(captor.capture());
Drama created = captor.getValue();
assertThat(created.getDramaType()).isEqualTo("LIVE");
assertThat(created.getFavoriteCount()).isZero();
assertThat(created.getLikeCount()).isZero();
assertThat(created.getViewCount()).isZero();
}
private Drama drama() {
Drama drama = new Drama();
drama.setId(7L);
drama.setTitle("原标题");
drama.setStatus("published");
drama.setDramaType("ANIME");
drama.setFavoriteCount(10L);
drama.setLikeCount(20L);
drama.setViewCount(30L);
return drama;
}
}
@@ -0,0 +1,192 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.content.vo.DramaSaveReqVO;
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 java.util.Map;
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;
/** K93 短剧分类与内容类型多选。 */
class DramaServiceMultiTagTest {
private DramaMapper dramaMapper;
private DramaCategoryService categoryService;
private DramaService dramaService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class);
}
@BeforeEach
void setUp() {
dramaMapper = mock(DramaMapper.class);
categoryService = mock(DramaCategoryService.class);
dramaService = new DramaService(
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
categoryService);
}
@Test
void should_save_distinct_multi_tags_and_write_first_value_as_primary() {
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("多标签短剧");
req.setDramaTypes(List.of("live", "ANIME", "LIVE", " anime "));
req.setCategoryIds(Arrays.asList(3L, 1L, 3L, 0L, null));
req.setGenre("类型三 / 类型一");
dramaService.create(req, 1L);
ArgumentCaptor<Drama> captor = ArgumentCaptor.forClass(Drama.class);
verify(dramaMapper).insert(captor.capture());
Drama created = captor.getValue();
assertThat(created.getDramaType()).isEqualTo("LIVE");
assertThat(created.getDramaTypes()).isEqualTo("LIVE,ANIME");
assertThat(created.getCategoryId()).isEqualTo(3L);
assertThat(created.getCategoryIds()).isEqualTo("3,1");
}
@Test
void should_keep_single_value_behavior_when_lists_are_absent() {
Drama existing = drama(7L);
existing.setDramaType("ANIME");
existing.setDramaTypes("ANIME");
existing.setCategoryId(2L);
existing.setCategoryIds("2");
given(dramaMapper.selectById(7L)).willReturn(existing);
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("单值更新");
req.setDramaType("live");
req.setCategoryId(4L);
dramaService.update(7L, req);
ArgumentCaptor<Drama> captor = ArgumentCaptor.forClass(Drama.class);
verify(dramaMapper).updateById(captor.capture());
assertThat(captor.getValue().getDramaType()).isEqualTo("LIVE");
assertThat(captor.getValue().getDramaTypes()).isEqualTo("LIVE");
assertThat(captor.getValue().getCategoryId()).isEqualTo(4L);
assertThat(captor.getValue().getCategoryIds()).isEqualTo("4");
}
@Test
@SuppressWarnings("unchecked")
void should_apply_token_filters_for_admin_page_multi_tags() {
Page<Drama> page = new Page<>(1, 10);
page.setRecords(List.of());
given(dramaMapper.selectPage(any(Page.class), any())).willReturn(page);
DramaPageReqVO req = new DramaPageReqVO();
req.setDramaTypes(List.of("ANIME", "LIVE"));
req.setCategoryIds(List.of(2L, 3L));
ArgumentCaptor<LambdaQueryWrapper<Drama>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
dramaService.page(req);
verify(dramaMapper).selectPage(any(), captor.capture());
assertThat(captor.getValue().getSqlSegment())
.contains("drama_types")
.contains("category_ids")
.contains("LIKE");
assertThat(captor.getValue().getParamNameValuePairs().values())
.contains("%,ANIME,%", "%,LIVE,%", "%,2,%", "%,3,%");
}
@Test
@SuppressWarnings("unchecked")
void should_allow_multi_tag_cursor_marker_for_each_filter_type() {
Drama marker = drama(9L);
marker.setDramaType("ANIME");
marker.setDramaTypes("ANIME,LIVE");
marker.setRecommendWeight(30);
marker.setViewCount(1200L);
given(dramaMapper.selectById(9L)).willReturn(marker);
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of("ANIME"), null, 9L, 5);
dramaService.listPublishedFeed(List.of("LIVE"), null, 9L, 5);
verify(dramaMapper, times(2)).selectList(captor.capture());
assertThat(captor.getAllValues()).allSatisfy(query ->
assertThat(query.getSqlSegment())
.contains("drama_types")
.contains("LIKE"));
}
@Test
@SuppressWarnings("unchecked")
void should_match_multi_category_when_loading_app_category_section() {
Page<Drama> page = new Page<>(1, 10);
page.setRecords(List.of());
given(dramaMapper.selectPage(any(Page.class), any())).willReturn(page);
ArgumentCaptor<LambdaQueryWrapper<Drama>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
dramaService.listPublished(3L, 10);
verify(dramaMapper).selectPage(any(), captor.capture());
assertThat(captor.getValue().getSqlSegment())
.contains("category_ids")
.contains("LIKE");
assertThat(captor.getValue().getParamNameValuePairs().values()).contains("%,3,%");
}
@Test
void should_parse_multi_values_and_keep_category_name_order_in_response() {
Drama drama = drama(7L);
drama.setDramaType("LIVE");
drama.setDramaTypes("LIVE,ANIME");
drama.setCategoryId(3L);
drama.setCategoryIds("3,1");
drama.setGenre("Fallback Genre");
given(dramaMapper.selectById(7L)).willReturn(drama);
given(categoryService.nameMap()).willReturn(Map.of(1L, "Type A", 3L, "Type B"));
DramaRespVO vo = dramaService.get(7L);
assertThat(vo.getDramaTypes()).containsExactly("LIVE", "ANIME");
assertThat(vo.getCategoryIds()).containsExactly(3L, 1L);
assertThat(vo.getCategoryNames()).containsExactly("Type B", "Type A");
assertThat(vo.getDramaType()).isEqualTo("LIVE");
assertThat(vo.getCategoryId()).isEqualTo(3L);
}
private Drama drama(Long id) {
Drama drama = new Drama();
drama.setId(id);
drama.setStatus("published");
drama.setViewCount(0L);
return drama;
}
}
@@ -0,0 +1,113 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
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.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** K89:剧集点赞编辑、原子增减与剧级批量汇总。 */
class EpisodeServiceK89Test {
private DramaEpisodeMapper episodeMapper;
private EpisodeService episodeService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), DramaEpisode.class);
}
@BeforeEach
void setUp() {
episodeMapper = mock(DramaEpisodeMapper.class);
episodeService = new EpisodeService(episodeMapper, mock(DramaService.class));
}
@Test
@SuppressWarnings("unchecked")
void should_use_greatest_zero_when_episode_like_decrements() {
given(episodeMapper.update(isNull(), any())).willReturn(1);
ArgumentCaptor<Wrapper<DramaEpisode>> captor = ArgumentCaptor.forClass(Wrapper.class);
episodeService.adjustLikeCount(11L, -1);
verify(episodeMapper).update(isNull(), captor.capture());
LambdaUpdateWrapper<DramaEpisode> update =
(LambdaUpdateWrapper<DramaEpisode>) captor.getValue();
assertThat(update.getSqlSet()).contains("GREATEST(like_count - 1, 0)");
}
@Test
void should_update_episode_like_count_without_overwriting_when_null() {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setDramaId(5L);
episode.setEpNo(1);
episode.setLikeCount(8L);
given(episodeMapper.selectById(11L)).willReturn(episode);
EpisodeSaveReqVO req = new EpisodeSaveReqVO();
req.setDramaId(5L);
req.setNumber(1);
req.setLikeCount(20L);
episodeService.update(11L, req);
assertThat(episode.getLikeCount()).isEqualTo(20L);
}
@Test
void should_reject_negative_episode_like_count() {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setDramaId(5L);
episode.setEpNo(1);
given(episodeMapper.selectById(11L)).willReturn(episode);
EpisodeSaveReqVO req = new EpisodeSaveReqVO();
req.setDramaId(5L);
req.setNumber(1);
req.setLikeCount(-1L);
assertThatThrownBy(() -> episodeService.update(11L, req))
.isInstanceOf(ServiceException.class)
.satisfies(error -> assertThat(((ServiceException) error).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()));
verify(episodeMapper, never()).updateById(any(DramaEpisode.class));
}
@Test
void should_sum_episode_likes_by_drama() {
given(episodeMapper.selectMaps(any())).willReturn(List.of(
Map.of("drama_id", 5L, "like_count", 120L),
Map.of("drama_id", 6L, "like_count", 30L)));
Map<Long, Long> sums = episodeService.sumLikeCountsByDramas(List.of(5L, 6L));
assertThat(sums).containsEntry(5L, 120L).containsEntry(6L, 30L);
}
}
@@ -0,0 +1,112 @@
// Author: fruit.com.cn
package com.fruitshorts.interaction.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
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.interaction.dal.entity.ContentLike;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
import com.fruitshorts.interaction.vo.LikeToggleReqVO;
import com.fruitshorts.interaction.vo.LikeToggleRespVO;
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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** K89:点赞目标支持已上线的分集。 */
class LikeServiceEpisodeTest {
private ContentLikeMapper likeMapper;
private EpisodeService episodeService;
private DramaService dramaService;
private LikeService likeService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), ContentLike.class);
}
@BeforeEach
void setUp() {
likeMapper = mock(ContentLikeMapper.class);
episodeService = mock(EpisodeService.class);
dramaService = mock(DramaService.class);
likeService = new LikeService(
likeMapper,
mock(ContentCommentMapper.class),
dramaService,
episodeService);
}
@Test
void should_increment_episode_like_and_return_latest_count() {
DramaEpisode published = episode(6L);
given(episodeService.getPublished(11L)).willReturn(published);
given(dramaService.getPublished(5L)).willReturn(new Drama());
given(likeMapper.selectOne(any())).willReturn(null);
given(episodeService.getExisting(11L)).willReturn(published);
LikeToggleRespVO result = likeService.toggle(9L, request(11L));
assertThat(result.getLiked()).isTrue();
assertThat(result.getCount()).isEqualTo(6L);
verify(episodeService).adjustLikeCount(11L, 1);
}
@Test
void should_decrement_episode_like_when_cancelled() {
DramaEpisode published = episode(5L);
given(episodeService.getPublished(11L)).willReturn(published);
given(dramaService.getPublished(5L)).willReturn(new Drama());
given(likeMapper.selectOne(any())).willReturn(new ContentLike());
given(episodeService.getExisting(11L)).willReturn(published);
LikeToggleRespVO result = likeService.toggle(9L, request(11L));
assertThat(result.getLiked()).isFalse();
assertThat(result.getCount()).isEqualTo(5L);
verify(likeMapper).physicalDelete(9L, LikeService.TYPE_EPISODE, 11L);
verify(episodeService).adjustLikeCount(11L, -1);
}
@Test
void should_reject_missing_or_unpublished_episode() {
given(episodeService.getPublished(11L))
.willThrow(new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线"));
assertThatThrownBy(() -> likeService.toggle(9L, request(11L)))
.isInstanceOf(ServiceException.class);
verify(likeMapper, never()).insert(any(ContentLike.class));
}
private DramaEpisode episode(Long likeCount) {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setDramaId(5L);
episode.setLikeCount(likeCount);
return episode;
}
private LikeToggleReqVO request(Long episodeId) {
LikeToggleReqVO req = new LikeToggleReqVO();
req.setTargetType(LikeService.TYPE_EPISODE);
req.setTargetId(episodeId);
return req;
}
}
@@ -36,6 +36,7 @@ public class SecurityConfig {
"/app/auth/**", "/app/auth/**",
"/app/home/**", "/app/home/**",
"/app/discover/**", "/app/discover/**",
"/app/feed",
"/app/categories/**", "/app/categories/**",
"/app/banners/**", "/app/banners/**",
"/app/dramas/**", "/app/dramas/**",
@@ -74,6 +75,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
/* 评论列表允许匿名浏览;发布/删除仍需登录(未列入白名单即需鉴权) */ /* 评论列表允许匿名浏览;发布/删除仍需登录(未列入白名单即需鉴权) */
.requestMatchers(HttpMethod.GET, "/app/comments").permitAll() .requestMatchers(HttpMethod.GET, "/app/comments").permitAll()
.requestMatchers(HttpMethod.GET, "/app/comments/count").permitAll()
.requestMatchers(PUBLIC_PATHS).permitAll() .requestMatchers(PUBLIC_PATHS).permitAll()
.anyRequest().authenticated()) .anyRequest().authenticated())
.exceptionHandling(handling -> handling .exceptionHandling(handling -> handling
+5
View File
@@ -25,5 +25,10 @@
<groupId>com.fruitshorts</groupId> <groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-system</artifactId> <artifactId>fruitshorts-module-system</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -22,11 +22,17 @@ public class Drama {
private String title; private String title;
private String subtitle; private String subtitle;
private Long categoryId; private Long categoryId;
/** 分类多选,逗号分隔的 category_id,category_id 保留第一个 */
private String categoryIds;
private String genre; private String genre;
private String synopsis; private String synopsis;
private String coverUrl; private String coverUrl;
private String horizontalCoverUrl; private String horizontalCoverUrl;
private String language; private String language;
/** 内容类型:ANIME=漫剧,LIVE=真人剧 */
private String dramaType;
/** 内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个 */
private String dramaTypes;
private Integer totalEpisodes; private Integer totalEpisodes;
private Integer freeEpisodes; private Integer freeEpisodes;
private Integer unlockCoin; private Integer unlockCoin;
@@ -40,6 +40,8 @@ public class DramaEpisode {
private String status; private String status;
private LocalDateTime releaseAt; private LocalDateTime releaseAt;
private Long playCount; private Long playCount;
/** 本集点赞数(点赞目标为 EPISODE) */
private Long likeCount;
private Integer sort; private Integer sort;
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */ /** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@@ -0,0 +1,217 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** 短剧分类与内容类型多选值的存储、解析和整词过滤。 */
final class DramaMultiTagSupport {
static final String DEFAULT_DRAMA_TYPE = "LIVE";
private static final Set<String> DRAMA_TYPES = Set.of("ANIME", "LIVE");
private DramaMultiTagSupport() {
}
static void applyDramaTypes(Drama drama, DramaSaveReqVO req) {
List<String> values = normalizeDramaTypes(req.getDramaTypes());
if (values.isEmpty() && StringUtils.hasText(req.getDramaType())) {
values = List.of(normalizeDramaType(req.getDramaType()));
}
if (!values.isEmpty()) {
drama.setDramaType(values.get(0));
drama.setDramaTypes(String.join(",", values));
return;
}
if (!StringUtils.hasText(drama.getDramaTypes())
&& !StringUtils.hasText(drama.getDramaType())) {
drama.setDramaType(DEFAULT_DRAMA_TYPE);
drama.setDramaTypes(DEFAULT_DRAMA_TYPE);
}
}
static void applyCategoryIds(Drama drama, DramaSaveReqVO req) {
List<Long> values = normalizeCategoryIds(req.getCategoryIds());
if (values.isEmpty() && req.getCategoryId() != null && req.getCategoryId() > 0L) {
values = List.of(req.getCategoryId());
}
if (!values.isEmpty()) {
drama.setCategoryId(values.get(0));
drama.setCategoryIds(String.join(",", values.stream().map(String::valueOf).toList()));
return;
}
if (drama.getCategoryId() == null) {
drama.setCategoryId(0L);
}
if (drama.getCategoryIds() == null) {
drama.setCategoryIds("");
}
}
static void applyDramaTypeTokens(LambdaQueryWrapper<Drama> wrapper,
List<String> dramaTypes) {
wrapper.and(nested -> {
for (int index = 0; index < dramaTypes.size(); index++) {
if (index > 0) {
nested.or();
}
nested.apply("(',' || drama_types || ',') LIKE {0}",
tokenPattern(dramaTypes.get(index)));
}
});
}
static void applyCategoryIdTokens(LambdaQueryWrapper<Drama> wrapper,
List<Long> categoryIds) {
wrapper.and(nested -> {
for (int index = 0; index < categoryIds.size(); index++) {
if (index > 0) {
nested.or();
}
nested.apply("(',' || category_ids || ',') LIKE {0}",
tokenPattern(categoryIds.get(index)));
}
});
}
static void applyDramaTypeTokens(QueryWrapper<Drama> wrapper,
List<String> dramaTypes) {
wrapper.and(nested -> {
for (int index = 0; index < dramaTypes.size(); index++) {
if (index > 0) {
nested.or();
}
nested.apply("(',' || drama_types || ',') LIKE {0}",
tokenPattern(dramaTypes.get(index)));
}
});
}
static void applyCategoryIdTokens(QueryWrapper<Drama> wrapper,
List<Long> categoryIds) {
wrapper.and(nested -> {
for (int index = 0; index < categoryIds.size(); index++) {
if (index > 0) {
nested.or();
}
nested.apply("(',' || category_ids || ',') LIKE {0}",
tokenPattern(categoryIds.get(index)));
}
});
}
static List<String> parseDramaTypes(Drama drama) {
List<String> values = parseStringTokens(drama.getDramaTypes()).stream()
.map(DramaMultiTagSupport::normalizeDramaType)
.distinct()
.toList();
if (!values.isEmpty()) {
return values;
}
if (StringUtils.hasText(drama.getDramaType())) {
return List.of(normalizeDramaType(drama.getDramaType()));
}
return List.of(DEFAULT_DRAMA_TYPE);
}
static List<Long> parseCategoryIds(Drama drama) {
List<Long> values = parseLongTokens(drama.getCategoryIds());
if (values.isEmpty() && drama.getCategoryId() != null && drama.getCategoryId() > 0L) {
return List.of(drama.getCategoryId());
}
return values;
}
static List<String> categoryNames(Drama drama, List<Long> categoryIds,
Map<Long, String> names) {
List<String> values = categoryIds.stream()
.map(names::get)
.filter(StringUtils::hasText)
.toList();
if (values.isEmpty() && StringUtils.hasText(drama.getGenre())) {
return List.of(drama.getGenre());
}
return values;
}
static List<String> normalizeDramaTypes(List<String> dramaTypes) {
if (dramaTypes == null || dramaTypes.isEmpty()) {
return List.of();
}
return dramaTypes.stream()
.filter(StringUtils::hasText)
.map(DramaMultiTagSupport::normalizeDramaType)
.distinct()
.toList();
}
static List<Long> normalizeCategoryIds(List<Long> categoryIds) {
if (categoryIds == null || categoryIds.isEmpty()) {
return List.of();
}
return categoryIds.stream()
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
}
private static String normalizeDramaType(String dramaType) {
String normalized = dramaType == null ? "" : dramaType.trim().toUpperCase(Locale.ROOT);
if (!DRAMA_TYPES.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "内容类型仅支持 ANIME 或 LIVE");
}
return normalized;
}
private static List<String> parseStringTokens(String value) {
if (!StringUtils.hasText(value)) {
return List.of();
}
List<String> result = new ArrayList<>();
for (String token : value.split(",")) {
String trimmed = token.trim();
if (StringUtils.hasText(trimmed) && !result.contains(trimmed)) {
result.add(trimmed);
}
}
return result;
}
private static List<Long> parseLongTokens(String value) {
if (!StringUtils.hasText(value)) {
return List.of();
}
List<Long> result = new ArrayList<>();
for (String token : value.split(",")) {
Long parsed = parseLongValue(token);
if (parsed != null && parsed > 0L && !result.contains(parsed)) {
result.add(parsed);
}
}
return result;
}
private static Long parseLongValue(String value) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
private static String tokenPattern(Object value) {
return "%," + value + ",%";
}
}
@@ -3,6 +3,8 @@
package com.fruitshorts.content.service; package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ErrorCode;
@@ -40,16 +42,26 @@ public class DramaService {
private final DramaEpisodeMapper dramaEpisodeMapper; private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaReviewRecordMapper dramaReviewRecordMapper; private final DramaReviewRecordMapper dramaReviewRecordMapper;
private final DramaCategoryService dramaCategoryService; private final DramaCategoryService dramaCategoryService;
private final EpisodePriceSync episodePriceSync;
public PageResult<DramaRespVO> page(DramaPageReqVO req) { public PageResult<DramaRespVO> page(DramaPageReqVO req) {
List<String> dramaTypes = DramaMultiTagSupport.normalizeDramaTypes(req.getDramaTypes());
List<Long> categoryIds = DramaMultiTagSupport.normalizeCategoryIds(req.getCategoryIds());
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery() LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword()) .like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus()) .eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.eq(req.getCategoryId() != null, Drama::getCategoryId, req.getCategoryId()) .eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
.eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0) if (!dramaTypes.isEmpty()) {
.orderByDesc(Drama::getSort) DramaMultiTagSupport.applyDramaTypeTokens(wrapper, dramaTypes);
.orderByDesc(Drama::getId)); }
if (!categoryIds.isEmpty()) {
DramaMultiTagSupport.applyCategoryIdTokens(wrapper, categoryIds);
} else {
wrapper.eq(req.getCategoryId() != null, Drama::getCategoryId, req.getCategoryId());
}
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getId);
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(), toRespList(result.getRecords())); return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
} }
@@ -81,15 +93,15 @@ public class DramaService {
private Long insert(DramaSaveReqVO req, Long operatorId, Long creatorId) { private Long insert(DramaSaveReqVO req, Long operatorId, Long creatorId) {
Drama drama = new Drama(); Drama drama = new Drama();
apply(drama, req); apply(drama, req, true);
drama.setCode(generateCode()); drama.setCode(generateCode());
drama.setStatus(ContentStatus.DRAFT.getCode()); drama.setStatus(ContentStatus.DRAFT.getCode());
drama.setCreatorId(creatorId == null ? 0L : creatorId); drama.setCreatorId(creatorId == null ? 0L : creatorId);
drama.setTotalEpisodes(0); drama.setTotalEpisodes(0);
drama.setViewCount(0L); drama.setViewCount(nonNegativeOrZero(req.getHeat(), "热度"));
drama.setPlayCount(0L); drama.setPlayCount(0L);
drama.setFavoriteCount(0L); drama.setFavoriteCount(nonNegativeOrZero(req.getFavoriteCount(), "收藏数"));
drama.setLikeCount(0L); drama.setLikeCount(nonNegativeOrZero(req.getLikeCount(), "点赞数"));
drama.setCreateBy(operatorId == null ? 0L : operatorId); drama.setCreateBy(operatorId == null ? 0L : operatorId);
dramaMapper.insert(drama); dramaMapper.insert(drama);
return drama.getId(); return drama.getId();
@@ -98,8 +110,18 @@ public class DramaService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void update(Long id, DramaSaveReqVO req) { public void update(Long id, DramaSaveReqVO req) {
Drama drama = getExisting(id); Drama drama = getExisting(id);
apply(drama, req); apply(drama, req, false);
if (req.getFavoriteCount() != null) {
drama.setFavoriteCount(nonNegative(req.getFavoriteCount(), "收藏数"));
}
if (req.getLikeCount() != null) {
drama.setLikeCount(nonNegative(req.getLikeCount(), "点赞数"));
}
if (req.getHeat() != null) {
drama.setViewCount(nonNegative(req.getHeat(), "热度"));
}
dramaMapper.updateById(drama); dramaMapper.updateById(drama);
episodePriceSync.syncByDramaRule(drama);
} }
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -195,11 +217,13 @@ public class DramaService {
/** 已上线短剧,按运营排序 + 播放量 */ /** 已上线短剧,按运营排序 + 播放量 */
public List<Drama> listPublished(Long categoryId, long limit) { public List<Drama> listPublished(Long categoryId, long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit)); Page<Drama> page = new Page<>(1, Math.max(1, limit));
return dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery() LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()) .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode());
.eq(categoryId != null && categoryId > 0, Drama::getCategoryId, categoryId) if (categoryId != null && categoryId > 0) {
.orderByDesc(Drama::getSort) DramaMultiTagSupport.applyCategoryIdTokens(wrapper, List.of(categoryId));
.orderByDesc(Drama::getViewCount)).getRecords(); }
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getViewCount);
return dramaMapper.selectPage(page, wrapper).getRecords();
} }
/** 已上线短剧,按上线时间倒序 */ /** 已上线短剧,按上线时间倒序 */
@@ -211,6 +235,39 @@ public class DramaService {
.orderByDesc(Drama::getId)).getRecords(); .orderByDesc(Drama::getId)).getRecords();
} }
/**
* App 推荐流:按推荐权重、热度、ID 倒序,并支持内容类型多选。
* cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。
*/
public List<Drama> listPublishedFeed(
List<String> dramaTypes, Long categoryId, Long cursor, int limit) {
List<String> normalizedTypes = DramaMultiTagSupport.normalizeDramaTypes(dramaTypes);
QueryWrapper<Drama> query = new QueryWrapper<>();
query.eq("status", ContentStatus.PUBLISHED.getCode());
if (!normalizedTypes.isEmpty()) {
DramaMultiTagSupport.applyDramaTypeTokens(query, normalizedTypes);
}
if (categoryId != null) {
DramaMultiTagSupport.applyCategoryIdTokens(query, List.of(categoryId));
}
if (cursor != null) {
Drama marker = dramaMapper.selectById(cursor);
if (marker == null
|| !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus())
|| (!normalizedTypes.isEmpty()
&& DramaMultiTagSupport.parseDramaTypes(marker).stream()
.noneMatch(normalizedTypes::contains))
|| (categoryId != null
&& !DramaMultiTagSupport.parseCategoryIds(marker).contains(categoryId))) {
return List.of();
}
applyFeedCursor(query, marker);
}
int safeLimit = Math.max(1, limit);
query.last("ORDER BY recommend_weight DESC NULLS LAST, view_count DESC, id DESC LIMIT " + safeLimit);
return dramaMapper.selectList(query);
}
/** 已上线的热门短剧 */ /** 已上线的热门短剧 */
public List<Drama> listPublishedHot(long limit) { public List<Drama> listPublishedHot(long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit)); Page<Drama> page = new Page<>(1, Math.max(1, limit));
@@ -290,20 +347,50 @@ public class DramaService {
} }
} }
private void apply(Drama drama, DramaSaveReqVO req) { /**
* 字段映射。新建时(isNew=true)缺省字段按默认值补齐;
* 更新时只覆盖请求里显式传入的字段,漏传的字段保持库里原值,
* 避免编辑表单没带 sort / language / trailerUrl 时把已有数据清掉。
*/
private void apply(Drama drama, DramaSaveReqVO req, boolean isNew) {
drama.setTitle(req.getTitle()); drama.setTitle(req.getTitle());
drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle()); if (isNew || req.getSubtitle() != null) {
drama.setCategoryId(req.getCategoryId() == null ? 0L : req.getCategoryId()); drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle());
drama.setGenre(req.getGenre() == null ? "" : req.getGenre()); }
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription()); if (isNew || req.getCategoryIds() != null || req.getCategoryId() != null) {
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover()); DramaMultiTagSupport.applyCategoryIds(drama, req);
drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl()); }
if (isNew || req.getGenre() != null) {
drama.setGenre(req.getGenre() == null ? "" : req.getGenre());
}
if (isNew || req.getDescription() != null) {
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
}
if (isNew || req.getCover() != null) {
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover());
}
if (isNew || req.getTrailerUrl() != null) {
drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl());
}
drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl()); drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl());
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage()); if (isNew || req.getLanguage() != null) {
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes()); drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin()); }
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0); if (isNew || req.getDramaTypes() != null || req.getDramaType() != null) {
drama.setSort(req.getSort() == null ? 0 : req.getSort()); DramaMultiTagSupport.applyDramaTypes(drama, req);
}
if (isNew || req.getFreeEpisodes() != null) {
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
}
if (isNew || req.getUnlockCoin() != null) {
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
}
if (isNew || req.getIsHot() != null) {
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
}
if (isNew || req.getSort() != null) {
drama.setSort(req.getSort() == null ? 0 : req.getSort());
}
} }
private String generateCode() { private String generateCode() {
@@ -318,18 +405,29 @@ public class DramaService {
} }
Map<Long, String> categories = dramaCategoryService.nameMap(); Map<Long, String> categories = dramaCategoryService.nameMap();
return dramas.stream().map(drama -> { return dramas.stream().map(drama -> {
List<Long> categoryIds = DramaMultiTagSupport.parseCategoryIds(drama);
Long primaryCategoryId = categoryIds.isEmpty()
? (drama.getCategoryId() == null ? 0L : drama.getCategoryId())
: categoryIds.get(0);
List<String> categoryNames =
DramaMultiTagSupport.categoryNames(drama, categoryIds, categories);
List<String> dramaTypes = DramaMultiTagSupport.parseDramaTypes(drama);
DramaRespVO vo = new DramaRespVO(); DramaRespVO vo = new DramaRespVO();
vo.setId(drama.getId()); vo.setId(drama.getId());
vo.setCode(drama.getCode()); vo.setCode(drama.getCode());
vo.setTitle(drama.getTitle()); vo.setTitle(drama.getTitle());
vo.setSubtitle(drama.getSubtitle()); vo.setSubtitle(drama.getSubtitle());
vo.setCategoryId(drama.getCategoryId()); vo.setCategoryId(primaryCategoryId);
vo.setCategory(categories.getOrDefault(drama.getCategoryId(), "")); vo.setCategoryIds(categoryIds);
vo.setCategoryNames(categoryNames);
vo.setCategory(categories.getOrDefault(primaryCategoryId, ""));
vo.setGenre(drama.getGenre()); vo.setGenre(drama.getGenre());
vo.setDescription(drama.getSynopsis()); vo.setDescription(drama.getSynopsis());
vo.setCover(drama.getCoverUrl()); vo.setCover(drama.getCoverUrl());
vo.setTrailerUrl(drama.getTrailerUrl()); vo.setTrailerUrl(drama.getTrailerUrl());
vo.setLanguage(drama.getLanguage()); vo.setLanguage(drama.getLanguage());
vo.setDramaType(dramaTypes.get(0));
vo.setDramaTypes(dramaTypes);
vo.setEpisodes(drama.getTotalEpisodes()); vo.setEpisodes(drama.getTotalEpisodes());
vo.setFreeEpisodes(drama.getFreeEpisodes()); vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin()); vo.setUnlockCoin(drama.getUnlockCoin());
@@ -346,4 +444,34 @@ public class DramaService {
return vo; return vo;
}).toList(); }).toList();
} }
private void applyFeedCursor(QueryWrapper<Drama> query, Drama marker) {
Long viewCount = marker.getViewCount() == null ? 0L : marker.getViewCount();
Long id = marker.getId();
if (marker.getRecommendWeight() != null) {
Integer weight = marker.getRecommendWeight();
query.and(wrapper -> wrapper
.lt("recommend_weight", weight)
.or(or -> or.eq("recommend_weight", weight)
.and(inner -> inner
.lt("view_count", viewCount)
.or(tail -> tail.eq("view_count", viewCount).lt("id", id)))));
} else {
query.and(wrapper -> wrapper.isNull("recommend_weight")
.and(inner -> inner
.lt("view_count", viewCount)
.or(tail -> tail.eq("view_count", viewCount).lt("id", id))));
}
}
private Long nonNegativeOrZero(Long value, String fieldName) {
return value == null ? 0L : nonNegative(value, fieldName);
}
private Long nonNegative(Long value, String fieldName) {
if (value < 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST, fieldName + "不能小于 0");
}
return value;
}
} }
@@ -0,0 +1,74 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Objects;
/**
* 按短剧免费集数规则同步分集 {@code is_free} / {@code unlock_coin}。
*
* <p>定价页与短剧编辑共用同一套规则,避免两处口径漂移。</p>
*/
@Component
@RequiredArgsConstructor
public class EpisodePriceSync {
private final DramaEpisodeMapper dramaEpisodeMapper;
/**
* 按短剧当前免费集数与解锁金币同步全部分集。
*
* @return 实际写库的分集数
*/
public int syncByDramaRule(Drama drama) {
int freeEpisodes = drama.getFreeEpisodes() == null ? 0 : Math.max(0, drama.getFreeEpisodes());
int unlockCoin = drama.getUnlockCoin() == null ? 0 : Math.max(0, drama.getUnlockCoin());
return syncByDramaRule(drama.getId(), freeEpisodes, unlockCoin);
}
/**
* 全量同步某剧全部分集价格。
*
* <p>规则:{@code epNo > 0 && epNo <= freeEpisodes} 为免费且 coin=0,
* 否则收费且 coin={@code unlockCoin}。</p>
*
* @return 实际写库的分集数
*/
public int syncByDramaRule(Long dramaId, int freeEpisodes, int unlockCoin) {
List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.orderByAsc(DramaEpisode::getEpNo));
int changed = 0;
for (DramaEpisode episode : episodes) {
int epNo = episode.getEpNo() == null ? 0 : episode.getEpNo();
int isFree = resolveIsFree(epNo, freeEpisodes);
int coin = resolveUnlockCoin(isFree, unlockCoin);
if (!Objects.equals(episode.getIsFree(), isFree)
|| !Objects.equals(episode.getUnlockCoin(), coin)) {
episode.setIsFree(isFree);
episode.setUnlockCoin(coin);
dramaEpisodeMapper.updateById(episode);
changed++;
}
}
return changed;
}
/** 集号落在免费区间内则为免费。 */
public static int resolveIsFree(int epNo, int freeEpisodes) {
return epNo > 0 && epNo <= freeEpisodes ? 1 : 0;
}
/** 免费集 coin 置 0,收费集用短剧解锁金币。 */
public static int resolveUnlockCoin(int isFree, int unlockCoin) {
return isFree == 1 ? 0 : unlockCoin;
}
}
@@ -2,9 +2,14 @@
package com.fruitshorts.content.service; package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.enums.ContentStatus; import com.fruitshorts.content.enums.ContentStatus;
@@ -13,28 +18,52 @@ import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class EpisodeService { public class EpisodeService {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 100;
private static final int MAX_PAGE_SIZE = 200;
private final DramaEpisodeMapper dramaEpisodeMapper; private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaService dramaService; private final DramaService dramaService;
/** 后台:按短剧分页查询分集,status 在 SQL 层过滤;排序稳定(sort、ep_no)。 */
public PageResult<EpisodeRespVO> pageByDrama(Long dramaId, String status,
Integer pageIndex, Integer pageSize) {
dramaService.getExisting(dramaId);
int safeIndex = pageIndex == null || pageIndex < 1 ? DEFAULT_PAGE_INDEX : pageIndex;
int safeSize = pageSize == null || pageSize < 1
? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
IPage<DramaEpisode> result = dramaEpisodeMapper.selectPage(
new Page<>(safeIndex, safeSize), buildListQuery(dramaId, status));
return new PageResult<>(result.getTotal(), result.getRecords().stream().map(this::toResp).toList());
}
public List<EpisodeRespVO> listByDrama(Long dramaId, String status) { public List<EpisodeRespVO> listByDrama(Long dramaId, String status) {
dramaService.getExisting(dramaId); dramaService.getExisting(dramaId);
return dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery() return dramaEpisodeMapper.selectList(buildListQuery(dramaId, status))
.eq(DramaEpisode::getDramaId, dramaId)
.eq(status != null && !status.isBlank(), DramaEpisode::getStatus, status)
.orderByAsc(DramaEpisode::getSort)
.orderByAsc(DramaEpisode::getEpNo))
.stream().map(this::toResp).toList(); .stream().map(this::toResp).toList();
} }
private LambdaQueryWrapper<DramaEpisode> buildListQuery(Long dramaId, String status) {
return Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.eq(StringUtils.hasText(status), DramaEpisode::getStatus, status)
.orderByAsc(DramaEpisode::getSort)
.orderByAsc(DramaEpisode::getEpNo);
}
/** C 端:只返回已上线剧集,按播放顺序 */ /** C 端:只返回已上线剧集,按播放顺序 */
public List<DramaEpisode> listPublishedByDrama(Long dramaId) { public List<DramaEpisode> listPublishedByDrama(Long dramaId) {
return dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery() return dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
@@ -103,6 +132,7 @@ public class EpisodeService {
apply(episode, req); apply(episode, req);
episode.setStatus(req.getStatus() == null ? ContentStatus.DRAFT.getCode() : req.getStatus()); episode.setStatus(req.getStatus() == null ? ContentStatus.DRAFT.getCode() : req.getStatus());
episode.setPlayCount(0L); episode.setPlayCount(0L);
episode.setLikeCount(nonNegativeOrZero(req.getLikeCount()));
episode.setVideoAssetId(0L); episode.setVideoAssetId(0L);
dramaEpisodeMapper.insert(episode); dramaEpisodeMapper.insert(episode);
dramaService.syncTotalEpisodes(req.getDramaId()); dramaService.syncTotalEpisodes(req.getDramaId());
@@ -115,6 +145,9 @@ public class EpisodeService {
dramaService.getExisting(req.getDramaId()); dramaService.getExisting(req.getDramaId());
ensureEpNoAvailable(req.getDramaId(), req.getNumber(), id); ensureEpNoAvailable(req.getDramaId(), req.getNumber(), id);
apply(episode, req); apply(episode, req);
if (req.getLikeCount() != null) {
episode.setLikeCount(nonNegative(req.getLikeCount()));
}
if (req.getStatus() != null) { if (req.getStatus() != null) {
episode.setStatus(req.getStatus()); episode.setStatus(req.getStatus());
} }
@@ -157,6 +190,46 @@ public class EpisodeService {
.set(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode())); .set(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode()));
} }
/** 本集点赞数原子增减,避免并发读改写丢计数。 */
public void adjustLikeCount(Long episodeId, int delta) {
if (episodeId == null || delta == 0) {
return;
}
dramaEpisodeMapper.update(null, Wrappers.<DramaEpisode>lambdaUpdate()
.eq(DramaEpisode::getId, episodeId)
.setSql(delta > 0
? "like_count = like_count + 1"
: "like_count = GREATEST(like_count - 1, 0)"));
}
/** 批量汇总多部剧的分集点赞数,避免后台逐剧 N+1 查询。 */
public Map<Long, Long> sumLikeCountsByDramas(Collection<Long> dramaIds) {
if (dramaIds == null || dramaIds.isEmpty()) {
return Map.of();
}
List<Long> ids = dramaIds.stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
QueryWrapper<DramaEpisode> wrapper = Wrappers.query();
wrapper.select("drama_id AS drama_id", "COALESCE(SUM(like_count), 0) AS like_count")
.in("drama_id", ids)
.eq("deleted", 0)
.groupBy("drama_id");
List<Map<String, Object>> rows = dramaEpisodeMapper.selectMaps(wrapper);
if (rows == null || rows.isEmpty()) {
return Map.of();
}
Map<Long, Long> counts = new HashMap<>();
for (Map<String, Object> row : rows) {
Long dramaId = asLong(row.get("drama_id"));
if (dramaId != null) {
counts.put(dramaId, asLong(row.get("like_count"), 0L));
}
}
return counts;
}
private void ensureEpNoAvailable(Long dramaId, Integer epNo, Long excludeId) { private void ensureEpNoAvailable(Long dramaId, Integer epNo, Long excludeId) {
Long count = dramaEpisodeMapper.selectCount(Wrappers.<DramaEpisode>lambdaQuery() Long count = dramaEpisodeMapper.selectCount(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId) .eq(DramaEpisode::getDramaId, dramaId)
@@ -194,10 +267,31 @@ public class EpisodeService {
vo.setStatus(episode.getStatus()); vo.setStatus(episode.getStatus());
vo.setStatusText(ContentStatus.textOf(episode.getStatus())); vo.setStatusText(ContentStatus.textOf(episode.getStatus()));
vo.setViews(episode.getPlayCount()); vo.setViews(episode.getPlayCount());
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setSort(episode.getSort()); vo.setSort(episode.getSort());
return vo; return vo;
} }
private Long nonNegativeOrZero(Long value) {
return value == null ? 0L : nonNegative(value);
}
private Long nonNegative(Long value) {
if (value < 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "本集点赞数不能小于 0");
}
return value;
}
private Long asLong(Object value) {
return value instanceof Number number ? number.longValue() : null;
}
private long asLong(Object value, long defaultValue) {
Long number = asLong(value);
return number == null ? defaultValue : number;
}
private String formatDuration(Integer seconds) { private String formatDuration(Integer seconds) {
int value = seconds == null ? 0 : seconds; int value = seconds == null ? 0 : seconds;
return String.format("%02d:%02d", value / 60, value % 60); return String.format("%02d:%02d", value / 60, value % 60);
@@ -32,6 +32,7 @@ public class PriceService {
private final DramaService dramaService; private final DramaService dramaService;
private final DramaMapper dramaMapper; private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper; private final DramaEpisodeMapper dramaEpisodeMapper;
private final EpisodePriceSync episodePriceSync;
/** 读取当前价格配置:后台定价页进入时先看现状再改。 */ /** 读取当前价格配置:后台定价页进入时先看现状再改。 */
public PriceRespVO detail(Long dramaId) { public PriceRespVO detail(Long dramaId) {
@@ -72,55 +73,13 @@ public class PriceService {
drama.setUnlockCoin(unlockCoin); drama.setUnlockCoin(unlockCoin);
dramaMapper.updateById(drama); dramaMapper.updateById(drama);
int changed = episodePriceSync.syncByDramaRule(drama.getId(), freeEpisodes, unlockCoin);
changed += applyEpisodeOverrides(drama.getId(), unlockCoin, req);
List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery() List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, drama.getId()) .eq(DramaEpisode::getDramaId, drama.getId())
.orderByAsc(DramaEpisode::getEpNo)); .orderByAsc(DramaEpisode::getEpNo));
Map<Long, PriceSaveReqVO.EpisodePrice> overrides = new LinkedHashMap<>();
if (req.getEpisodes() != null) {
for (PriceSaveReqVO.EpisodePrice item : req.getEpisodes()) {
if (item != null && item.getEpisodeId() != null) {
overrides.put(item.getEpisodeId(), item);
}
}
}
int changed = 0;
for (DramaEpisode episode : episodes) {
int epNo = episode.getEpNo() == null ? 0 : episode.getEpNo();
int isFree = epNo > 0 && epNo <= freeEpisodes ? 1 : 0;
int coin = isFree == 1 ? 0 : unlockCoin;
PriceSaveReqVO.EpisodePrice override = overrides.get(episode.getId());
Integer previewSeconds = null;
if (override != null) {
if (override.getIsFree() != null) {
isFree = override.getIsFree() == 1 ? 1 : 0;
coin = isFree == 1 ? 0 : unlockCoin;
}
if (isFree == 0 && override.getUnlockCoin() != null) {
coin = Math.max(0, override.getUnlockCoin());
}
if (override.getPreviewSeconds() != null) {
previewSeconds = Math.max(0, override.getPreviewSeconds());
}
}
boolean priceChanged = !Objects.equals(episode.getIsFree(), isFree)
|| !Objects.equals(episode.getUnlockCoin(), coin);
boolean previewChanged = previewSeconds != null
&& !Objects.equals(episode.getPreviewSeconds(), previewSeconds);
if (priceChanged || previewChanged) {
episode.setIsFree(isFree);
episode.setUnlockCoin(coin);
if (previewSeconds != null) {
episode.setPreviewSeconds(previewSeconds);
}
dramaEpisodeMapper.updateById(episode);
changed++;
}
}
PriceRespVO vo = new PriceRespVO(); PriceRespVO vo = new PriceRespVO();
vo.setDramaId(drama.getId()); vo.setDramaId(drama.getId());
vo.setFreeEpisodes(freeEpisodes); vo.setFreeEpisodes(freeEpisodes);
@@ -139,4 +98,56 @@ public class PriceService {
}).toList()); }).toList());
return vo; return vo;
} }
/** 在整剧规则同步后再落单集覆盖(含试看秒数)。 */
private int applyEpisodeOverrides(Long dramaId, int unlockCoin, PriceSaveReqVO req) {
Map<Long, PriceSaveReqVO.EpisodePrice> overrides = new LinkedHashMap<>();
if (req.getEpisodes() != null) {
for (PriceSaveReqVO.EpisodePrice item : req.getEpisodes()) {
if (item != null && item.getEpisodeId() != null) {
overrides.put(item.getEpisodeId(), item);
}
}
}
if (overrides.isEmpty()) {
return 0;
}
List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.orderByAsc(DramaEpisode::getEpNo));
int changed = 0;
for (DramaEpisode episode : episodes) {
PriceSaveReqVO.EpisodePrice override = overrides.get(episode.getId());
if (override == null) {
continue;
}
int isFree = episode.getIsFree() == null ? 0 : episode.getIsFree();
int coin = episode.getUnlockCoin() == null ? 0 : episode.getUnlockCoin();
Integer previewSeconds = null;
if (override.getIsFree() != null) {
isFree = override.getIsFree() == 1 ? 1 : 0;
coin = EpisodePriceSync.resolveUnlockCoin(isFree, unlockCoin);
}
if (isFree == 0 && override.getUnlockCoin() != null) {
coin = Math.max(0, override.getUnlockCoin());
}
if (override.getPreviewSeconds() != null) {
previewSeconds = Math.max(0, override.getPreviewSeconds());
}
boolean priceChanged = !Objects.equals(episode.getIsFree(), isFree)
|| !Objects.equals(episode.getUnlockCoin(), coin);
boolean previewChanged = previewSeconds != null
&& !Objects.equals(episode.getPreviewSeconds(), previewSeconds);
if (priceChanged || previewChanged) {
episode.setIsFree(isFree);
episode.setUnlockCoin(coin);
if (previewSeconds != null) {
episode.setPreviewSeconds(previewSeconds);
}
dramaEpisodeMapper.updateById(episode);
changed++;
}
}
return changed;
}
} }
@@ -6,6 +6,8 @@ import com.fruitshorts.common.pojo.PageParam;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import java.util.List;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class DramaPageReqVO extends PageParam { public class DramaPageReqVO extends PageParam {
@@ -14,6 +16,10 @@ public class DramaPageReqVO extends PageParam {
private String keyword; private String keyword;
private String status; private String status;
private Long categoryId; private Long categoryId;
/** 分类多选,空时回退 categoryId */
private List<Long> categoryIds;
/** 是否只看热门 */ /** 是否只看热门 */
private Boolean isHot; private Boolean isHot;
/** 内容类型多选:ANIME/LIVE,空=全部 */
private List<String> dramaTypes;
} }
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 短剧列表/详情响应,字段名与后台页面表格列一致。 * 短剧列表/详情响应,字段名与后台页面表格列一致。
@@ -18,6 +19,8 @@ public class DramaRespVO {
private String title; private String title;
private String subtitle; private String subtitle;
private Long categoryId; private Long categoryId;
private List<Long> categoryIds;
private List<String> categoryNames;
private String category; private String category;
private String genre; private String genre;
private String description; private String description;
@@ -25,6 +28,8 @@ public class DramaRespVO {
/** 整剧预告片地址 */ /** 整剧预告片地址 */
private String trailerUrl; private String trailerUrl;
private String language; private String language;
private String dramaType;
private List<String> dramaTypes;
private Integer episodes; private Integer episodes;
private Integer freeEpisodes; private Integer freeEpisodes;
private Integer unlockCoin; private Integer unlockCoin;
@@ -3,8 +3,12 @@
package com.fruitshorts.content.vo; package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data; import lombok.Data;
import java.util.List;
@Data @Data
public class DramaSaveReqVO { public class DramaSaveReqVO {
@@ -13,14 +17,26 @@ public class DramaSaveReqVO {
private String subtitle; private String subtitle;
private Long categoryId; private Long categoryId;
/** 后台多选优先,创作者中心可继续只传 categoryId */
private List<Long> categoryIds;
private String genre; private String genre;
private String description; private String description;
private String cover; private String cover;
/** 整剧预告片地址(复用已有的 drama.trailer_url 列) */ /** 整剧预告片地址(复用已有的 drama.trailer_url 列) */
private String trailerUrl; private String trailerUrl;
private String language; private String language;
@Pattern(regexp = "ANIME|LIVE", message = "内容类型仅支持 ANIME 或 LIVE")
private String dramaType;
/** 后台多选优先,创作者中心可继续只传 dramaType */
private List<String> dramaTypes;
private Integer freeEpisodes; private Integer freeEpisodes;
private Integer unlockCoin; private Integer unlockCoin;
@PositiveOrZero(message = "收藏数不能小于 0")
private Long favoriteCount;
@PositiveOrZero(message = "点赞数不能小于 0")
private Long likeCount;
@PositiveOrZero(message = "热度不能小于 0")
private Long heat;
/** 是否热门 */ /** 是否热门 */
private Boolean isHot; private Boolean isHot;
private Integer sort; private Integer sort;
@@ -21,5 +21,13 @@ public class EpisodeRespVO {
private String status; private String status;
private String statusText; private String statusText;
private Long views; private Long views;
/** 本集点赞数 */
private Long likeCount;
/** 本集评论数 */
private Long commentCount;
/** 所属剧收藏数,只读 */
private Long favoriteCount;
/** 所属剧热度(drama.view_count),只读 */
private Long heat;
private Integer sort; private Integer sort;
} }
@@ -3,6 +3,7 @@
package com.fruitshorts.content.vo; package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data; import lombok.Data;
@Data @Data
@@ -20,6 +21,8 @@ public class EpisodeSaveReqVO {
private String videoUrl; private String videoUrl;
private Boolean isFree; private Boolean isFree;
private Integer unlockCoin; private Integer unlockCoin;
@PositiveOrZero(message = "本集点赞数不能小于 0")
private Long likeCount;
private String status; private String status;
private Integer sort; private Integer sort;
} }
@@ -0,0 +1,210 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.vo.DramaSaveReqVO;
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.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* 短剧保存时免费集数 / 解锁金币须同步分集价格。
*/
class DramaServiceTest {
private static final long DRAMA_ID = 1001L;
private static final int UNLOCK_COIN = 60;
private DramaMapper dramaMapper;
private DramaEpisodeMapper dramaEpisodeMapper;
private DramaService dramaService;
private List<DramaEpisode> episodes;
@BeforeAll
static void initTableInfoCache() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, Drama.class);
TableInfoHelper.initTableInfo(assistant, DramaEpisode.class);
}
@BeforeEach
void setUp() {
dramaMapper = mock(DramaMapper.class);
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
DramaReviewRecordMapper reviewRecordMapper = mock(DramaReviewRecordMapper.class);
DramaCategoryService categoryService = mock(DramaCategoryService.class);
EpisodePriceSync episodePriceSync = new EpisodePriceSync(dramaEpisodeMapper);
dramaService = new DramaService(
dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService, episodePriceSync);
episodes = new ArrayList<>();
given(dramaEpisodeMapper.selectList(any())).willAnswer(invocation -> List.copyOf(episodes));
given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> {
DramaEpisode updated = invocation.getArgument(0);
for (int i = 0; i < episodes.size(); i++) {
if (episodes.get(i).getId().equals(updated.getId())) {
episodes.set(i, updated);
break;
}
}
return 1;
});
}
@Test
void should_mark_episodes_4_and_5_free_when_free_episodes_increase_from_3_to_5() {
givenExistingDrama(3, UNLOCK_COIN);
seedEpisodesAlignedToFree(3, 6);
dramaService.update(DRAMA_ID, saveReq(5, UNLOCK_COIN));
Map<Integer, DramaEpisode> byEpNo = episodeMap();
assertThat(byEpNo.get(4).getIsFree()).isEqualTo(1);
assertThat(byEpNo.get(4).getUnlockCoin()).isZero();
assertThat(byEpNo.get(5).getIsFree()).isEqualTo(1);
assertThat(byEpNo.get(5).getUnlockCoin()).isZero();
assertThat(byEpNo.get(6).getIsFree()).isZero();
assertThat(byEpNo.get(6).getUnlockCoin()).isEqualTo(UNLOCK_COIN);
verify(dramaEpisodeMapper, times(2)).updateById(any(DramaEpisode.class));
}
@Test
void should_mark_episodes_3_to_5_paid_when_free_episodes_decrease_from_5_to_2() {
givenExistingDrama(5, UNLOCK_COIN);
seedEpisodesAlignedToFree(5, 5);
dramaService.update(DRAMA_ID, saveReq(2, UNLOCK_COIN));
Map<Integer, DramaEpisode> byEpNo = episodeMap();
assertThat(byEpNo.get(3).getIsFree()).isZero();
assertThat(byEpNo.get(3).getUnlockCoin()).isEqualTo(UNLOCK_COIN);
assertThat(byEpNo.get(4).getIsFree()).isZero();
assertThat(byEpNo.get(4).getUnlockCoin()).isEqualTo(UNLOCK_COIN);
assertThat(byEpNo.get(5).getIsFree()).isZero();
assertThat(byEpNo.get(5).getUnlockCoin()).isEqualTo(UNLOCK_COIN);
verify(dramaEpisodeMapper, times(3)).updateById(any(DramaEpisode.class));
}
@Test
void should_skip_episode_updates_when_free_episodes_unchanged() {
givenExistingDrama(3, UNLOCK_COIN);
seedEpisodesAlignedToFree(3, 4);
AtomicInteger episodeUpdates = new AtomicInteger();
given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> {
episodeUpdates.incrementAndGet();
return 1;
});
dramaService.update(DRAMA_ID, saveReq(3, UNLOCK_COIN));
assertThat(episodeUpdates.get()).isZero();
verify(dramaEpisodeMapper, times(1)).selectList(any());
verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class));
}
/**
* 历史漂移:短剧 freeEpisodes/unlockCoin 已是目标值,但分集 is_free 仍是旧规则。
* 同值重存必须纠偏,不能因「规则字段未变」短路跳过同步。
*/
@Test
void should_correct_drifted_episode_is_free_when_free_episodes_and_unlock_coin_unchanged() {
givenExistingDrama(9, UNLOCK_COIN);
// 规则已是免费 9 集,分集仍按旧规则只免费到第 4 集(5~9 仍收费)
seedEpisodesAlignedToFree(4, 9);
dramaService.update(DRAMA_ID, saveReq(9, UNLOCK_COIN));
Map<Integer, DramaEpisode> byEpNo = episodeMap();
for (int epNo = 1; epNo <= 9; epNo++) {
assertThat(byEpNo.get(epNo).getIsFree()).isEqualTo(1);
assertThat(byEpNo.get(epNo).getUnlockCoin()).isZero();
}
verify(dramaEpisodeMapper, times(5)).updateById(any(DramaEpisode.class));
}
@Test
void should_not_fail_when_drama_has_no_episodes() {
givenExistingDrama(3, UNLOCK_COIN);
episodes.clear();
assertThatCode(() -> dramaService.update(DRAMA_ID, saveReq(5, UNLOCK_COIN)))
.doesNotThrowAnyException();
verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class));
}
private void givenExistingDrama(int freeEpisodes, int unlockCoin) {
Drama drama = new Drama();
drama.setId(DRAMA_ID);
drama.setTitle("测试短剧");
drama.setFreeEpisodes(freeEpisodes);
drama.setUnlockCoin(unlockCoin);
drama.setSubtitle("");
drama.setGenre("");
drama.setSynopsis("");
drama.setCoverUrl("");
drama.setTrailerUrl("");
drama.setLanguage("id-ID");
drama.setIsHot(0);
drama.setSort(0);
given(dramaMapper.selectById(DRAMA_ID)).willReturn(drama);
given(dramaMapper.updateById(any(Drama.class))).willReturn(1);
}
private void seedEpisodesAlignedToFree(int freeEpisodes, int total) {
episodes.clear();
for (int epNo = 1; epNo <= total; epNo++) {
DramaEpisode episode = new DramaEpisode();
episode.setId((long) epNo);
episode.setDramaId(DRAMA_ID);
episode.setEpNo(epNo);
episode.setTitle("第" + epNo + "集");
boolean free = epNo <= freeEpisodes;
episode.setIsFree(free ? 1 : 0);
episode.setUnlockCoin(free ? 0 : UNLOCK_COIN);
episodes.add(episode);
}
}
private DramaSaveReqVO saveReq(int freeEpisodes, int unlockCoin) {
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("测试短剧");
req.setFreeEpisodes(freeEpisodes);
req.setUnlockCoin(unlockCoin);
return req;
}
private Map<Integer, DramaEpisode> episodeMap() {
ArgumentCaptor<DramaEpisode> captor = ArgumentCaptor.forClass(DramaEpisode.class);
verify(dramaEpisodeMapper, org.mockito.Mockito.atLeastOnce()).updateById(captor.capture());
Map<Integer, DramaEpisode> updated = captor.getAllValues().stream()
.collect(Collectors.toMap(DramaEpisode::getEpNo, Function.identity(), (a, b) -> b));
for (DramaEpisode episode : episodes) {
updated.putIfAbsent(episode.getEpNo(), episode);
}
return updated;
}
}
@@ -0,0 +1,151 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
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.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.vo.EpisodeRespVO;
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.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.verify;
/**
* 剧集后台分页:默认每页 100、上限 200、越界空列表、stable 排序走 DB selectPage。
*/
class EpisodeServicePageTest {
private static final long DRAMA_ID = 2001L;
private DramaEpisodeMapper dramaEpisodeMapper;
private DramaService dramaService;
private EpisodeService episodeService;
@BeforeAll
static void initTableInfoCache() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, DramaEpisode.class);
}
@BeforeEach
void setUp() {
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
dramaService = mock(DramaService.class);
episodeService = new EpisodeService(dramaEpisodeMapper, dramaService);
given(dramaService.getExisting(DRAMA_ID)).willReturn(new Drama());
}
@Test
void should_use_default_page_size_100_when_page_size_omitted() {
stubSelectPage(0L, List.of());
episodeService.pageByDrama(DRAMA_ID, null, null, null);
Page<?> page = capturePageArg();
assertThat(page.getCurrent()).isEqualTo(1L);
assertThat(page.getSize()).isEqualTo(100L);
}
@Test
void should_cap_page_size_at_200() {
stubSelectPage(0L, List.of());
episodeService.pageByDrama(DRAMA_ID, null, 1, 500);
Page<?> page = capturePageArg();
assertThat(page.getSize()).isEqualTo(200L);
}
@Test
void should_return_second_page_records_without_overlap_with_first() {
DramaEpisode first = episode(1L, 1, 1);
DramaEpisode second = episode(2L, 2, 2);
DramaEpisode third = episode(3L, 3, 3);
DramaEpisode fourth = episode(4L, 4, 4);
given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> {
Page<DramaEpisode> page = invocation.getArgument(0);
Page<DramaEpisode> result = new Page<>(page.getCurrent(), page.getSize(), 4L);
if (page.getCurrent() == 1L) {
result.setRecords(List.of(first, second));
} else if (page.getCurrent() == 2L) {
result.setRecords(List.of(third, fourth));
} else {
result.setRecords(List.of());
}
return result;
});
PageResult<EpisodeRespVO> page1 = episodeService.pageByDrama(DRAMA_ID, null, 1, 2);
PageResult<EpisodeRespVO> page2 = episodeService.pageByDrama(DRAMA_ID, null, 2, 2);
assertThat(page1.getTotal()).isEqualTo(4L);
assertThat(page2.getTotal()).isEqualTo(4L);
assertThat(page1.getList()).extracting(EpisodeRespVO::getId).containsExactly(1L, 2L);
assertThat(page2.getList()).extracting(EpisodeRespVO::getId).containsExactly(3L, 4L);
assertThat(page1.getList()).extracting(EpisodeRespVO::getId)
.doesNotContainAnyElementsOf(page2.getList().stream().map(EpisodeRespVO::getId).toList());
}
@Test
void should_return_empty_list_with_correct_total_when_page_out_of_range() {
given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> {
Page<DramaEpisode> page = invocation.getArgument(0);
Page<DramaEpisode> result = new Page<>(page.getCurrent(), page.getSize(), 5L);
result.setRecords(List.of());
return result;
});
PageResult<EpisodeRespVO> page = episodeService.pageByDrama(DRAMA_ID, "published", 99, 100);
assertThat(page.getTotal()).isEqualTo(5L);
assertThat(page.getList()).isEmpty();
verify(dramaEpisodeMapper).selectPage(any(Page.class), any());
}
@SuppressWarnings("unchecked")
private void stubSelectPage(long total, List<DramaEpisode> records) {
given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> {
Page<DramaEpisode> page = invocation.getArgument(0);
Page<DramaEpisode> result = new Page<>(page.getCurrent(), page.getSize(), total);
result.setRecords(records);
return result;
});
}
@SuppressWarnings("unchecked")
private Page<?> capturePageArg() {
ArgumentCaptor<Page> pageCaptor = ArgumentCaptor.forClass(Page.class);
verify(dramaEpisodeMapper).selectPage(pageCaptor.capture(), any());
return pageCaptor.getValue();
}
private DramaEpisode episode(Long id, int sort, int epNo) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setDramaId(DRAMA_ID);
episode.setSort(sort);
episode.setEpNo(epNo);
episode.setTitle("ep-" + epNo);
episode.setStatus("published");
episode.setIsFree(1);
episode.setUnlockCoin(0);
episode.setDurationSec(60);
episode.setPlayCount(0L);
episode.setLikeCount(0L);
return episode;
}
}
@@ -4,6 +4,31 @@ package com.fruitshorts.interaction.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.interaction.dal.entity.ContentComment; import com.fruitshorts.interaction.dal.entity.ContentComment;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
public interface ContentCommentMapper extends BaseMapper<ContentComment> { public interface ContentCommentMapper extends BaseMapper<ContentComment> {
/**
* 后台举报工单按评论 id 批量读取,包含逻辑删除记录。
*
* <p>普通 BaseMapper 查询会自动追加 deleted = 0,无法判断被举报评论是否已删除;
* 这里显式列出字段并绕过逻辑删除过滤。
*/
@Select("""
<script>
SELECT id, drama_id, episode_id, user_id, parent_id, root_id, content, status,
audit_type, audit_reason, like_count, reply_count, report_count, ip,
device_id, country_code, created_at, updated_at, deleted
FROM content_comment
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</script>
""")
List<ContentComment> selectByIdsIncludingDeleted(@Param("ids") Collection<Long> ids);
} }
@@ -11,6 +11,8 @@ import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.CursorResult; import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.PageResult; 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.DramaService;
import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.config.InteractionProperties; import com.fruitshorts.interaction.config.InteractionProperties;
@@ -52,6 +54,7 @@ import java.util.Set;
public class CommentService { public class CommentService {
private static final String DRAMA_ID_COLUMN = "drama_id"; private static final String DRAMA_ID_COLUMN = "drama_id";
private static final String EPISODE_ID_COLUMN = "episode_id";
private static final String COMMENT_COUNT_COLUMN = "comment_count"; private static final String COMMENT_COUNT_COLUMN = "comment_count";
public static final String STATUS_PENDING = "pending"; public static final String STATUS_PENDING = "pending";
@@ -222,6 +225,35 @@ public class CommentService {
return counts; return counts;
} }
/** 批量统计多集未删除评论数,按 episode_id 分组。 */
public Map<Long, Long> countByEpisodes(Collection<Long> episodeIds) {
if (episodeIds == null || episodeIds.isEmpty()) {
return Map.of();
}
List<Long> ids = episodeIds.stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
QueryWrapper<ContentComment> wrapper = Wrappers.query();
wrapper.select(EPISODE_ID_COLUMN, "COUNT(*) AS " + COMMENT_COUNT_COLUMN)
.in(EPISODE_ID_COLUMN, ids)
.eq("deleted", 0)
.ne("status", STATUS_DELETED)
.groupBy(EPISODE_ID_COLUMN);
List<Map<String, Object>> rows = commentMapper.selectMaps(wrapper);
if (rows == null || rows.isEmpty()) {
return Map.of();
}
Map<Long, Long> counts = new HashMap<>();
for (Map<String, Object> row : rows) {
Long episodeId = asLong(row.get(EPISODE_ID_COLUMN));
if (episodeId != null) {
counts.put(episodeId, asLong(row.get(COMMENT_COUNT_COLUMN), 0L));
}
}
return counts;
}
private Long asLong(Object value) { private Long asLong(Object value) {
return value instanceof Number number ? number.longValue() : null; return value instanceof Number number ? number.longValue() : null;
} }
@@ -245,7 +277,10 @@ public class CommentService {
.like(StringUtils.hasText(req.getKeyword()), ContentComment::getContent, .like(StringUtils.hasText(req.getKeyword()), ContentComment::getContent,
req.getKeyword()) req.getKeyword())
.orderByDesc(ContentComment::getId)); .orderByDesc(ContentComment::getId));
return new PageResult<>(result.getTotal(), toRespList(result.getRecords(), null)); List<ContentComment> rows = result.getRecords();
List<CommentRespVO> list = toRespList(rows, null);
fillAdminContentInfo(rows, list);
return new PageResult<>(result.getTotal(), list);
} }
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -361,6 +396,39 @@ public class CommentService {
return list; return list;
} }
private void fillAdminContentInfo(List<ContentComment> rows, List<CommentRespVO> list) {
if (rows.isEmpty()) {
return;
}
Map<Long, Drama> dramas = new HashMap<>();
List<Long> dramaIds = rows.stream().map(ContentComment::getDramaId).distinct().toList();
for (Drama drama : dramaService.listByIds(dramaIds)) {
dramas.put(drama.getId(), drama);
}
Map<Long, DramaEpisode> episodes = new HashMap<>();
List<Long> episodeIds = rows.stream()
.map(ContentComment::getEpisodeId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
if (!episodeIds.isEmpty()) {
for (DramaEpisode episode : episodeService.listByIds(episodeIds)) {
episodes.put(episode.getId(), episode);
}
}
for (CommentRespVO vo : list) {
Drama drama = dramas.get(vo.getDramaId());
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
}
DramaEpisode episode = episodes.get(vo.getEpisodeId());
if (episode != null) {
vo.setEpisodeNo(episode.getEpNo());
vo.setEpisodeTitle(episode.getTitle());
}
}
}
private CommentRespVO toResp(ContentComment comment, AppUser author, boolean liked, Long meUserId) { private CommentRespVO toResp(ContentComment comment, AppUser author, boolean liked, Long meUserId) {
CommentRespVO vo = new CommentRespVO(); CommentRespVO vo = new CommentRespVO();
vo.setId(comment.getId()); vo.setId(comment.getId());
@@ -5,7 +5,9 @@ package com.fruitshorts.interaction.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.dal.entity.ContentComment; import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.entity.ContentLike; import com.fruitshorts.interaction.dal.entity.ContentLike;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper; import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
@@ -29,17 +31,19 @@ import java.util.Locale;
public class LikeService { public class LikeService {
public static final String TYPE_DRAMA = "DRAMA"; public static final String TYPE_DRAMA = "DRAMA";
public static final String TYPE_EPISODE = "EPISODE";
public static final String TYPE_COMMENT = "COMMENT"; public static final String TYPE_COMMENT = "COMMENT";
private final ContentLikeMapper likeMapper; private final ContentLikeMapper likeMapper;
private final ContentCommentMapper commentMapper; private final ContentCommentMapper commentMapper;
private final DramaService dramaService; private final DramaService dramaService;
private final EpisodeService episodeService;
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public LikeToggleRespVO toggle(Long userId, LikeToggleReqVO req) { public LikeToggleRespVO toggle(Long userId, LikeToggleReqVO req) {
String type = req.getTargetType().trim().toUpperCase(Locale.ROOT); String type = req.getTargetType().trim().toUpperCase(Locale.ROOT);
if (!TYPE_DRAMA.equals(type) && !TYPE_COMMENT.equals(type)) { if (!TYPE_DRAMA.equals(type) && !TYPE_EPISODE.equals(type) && !TYPE_COMMENT.equals(type)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧或评论点赞"); throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧、剧集或评论点赞");
} }
Long targetId = req.getTargetId(); Long targetId = req.getTargetId();
validateTarget(type, targetId); validateTarget(type, targetId);
@@ -74,6 +78,10 @@ public class LikeService {
return dramaService.getExisting(targetId).getLikeCount() == null return dramaService.getExisting(targetId).getLikeCount() == null
? 0L : dramaService.getExisting(targetId).getLikeCount(); ? 0L : dramaService.getExisting(targetId).getLikeCount();
} }
if (TYPE_EPISODE.equals(type)) {
return episodeService.getExisting(targetId).getLikeCount() == null
? 0L : episodeService.getExisting(targetId).getLikeCount();
}
ContentComment comment = commentMapper.selectById(targetId); ContentComment comment = commentMapper.selectById(targetId);
return comment == null || comment.getLikeCount() == null ? 0L : comment.getLikeCount(); return comment == null || comment.getLikeCount() == null ? 0L : comment.getLikeCount();
} }
@@ -83,6 +91,10 @@ public class LikeService {
dramaService.adjustLikeCount(targetId, delta); dramaService.adjustLikeCount(targetId, delta);
return; return;
} }
if (TYPE_EPISODE.equals(type)) {
episodeService.adjustLikeCount(targetId, delta);
return;
}
commentMapper.update(null, Wrappers.<ContentComment>lambdaUpdate() commentMapper.update(null, Wrappers.<ContentComment>lambdaUpdate()
.eq(ContentComment::getId, targetId) .eq(ContentComment::getId, targetId)
.setSql(delta > 0 .setSql(delta > 0
@@ -95,6 +107,11 @@ public class LikeService {
dramaService.getPublished(targetId); dramaService.getPublished(targetId);
return; return;
} }
if (TYPE_EPISODE.equals(type)) {
DramaEpisode episode = episodeService.getPublished(targetId);
dramaService.getPublished(episode.getDramaId());
return;
}
ContentComment comment = commentMapper.selectById(targetId); ContentComment comment = commentMapper.selectById(targetId);
if (comment == null || CommentService.STATUS_PUBLISHED.equals(comment.getStatus()) == false) { if (comment == null || CommentService.STATUS_PUBLISHED.equals(comment.getStatus()) == false) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "评论不存在或未通过审核"); throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "评论不存在或未通过审核");
@@ -12,7 +12,12 @@ public class CommentRespVO {
private Long id; private Long id;
private Long dramaId; private Long dramaId;
/** 后台评论页展示用,C 端列表保持为空 */
private String dramaTitle;
private Long episodeId; private Long episodeId;
/** 后台评论页展示用,C 端列表保持为空 */
private Integer episodeNo;
private String episodeTitle;
private Long parentId; private Long parentId;
private Long rootId; private Long rootId;
private Long userId; private Long userId;
@@ -10,7 +10,7 @@ import lombok.Data;
@Data @Data
public class LikeToggleReqVO { public class LikeToggleReqVO {
/** DRAMA/COMMENT */ /** DRAMA/EPISODE/COMMENT */
@NotBlank(message = "缺少 targetType") @NotBlank(message = "缺少 targetType")
private String targetType; private String targetType;
@@ -27,4 +27,8 @@ public class PlayRespVO {
private Integer unlockCoin; private Integer unlockCoin;
/** 试看秒数,0 表示不限制 */ /** 试看秒数,0 表示不限制 */
private Integer previewSeconds; private Integer previewSeconds;
/** 本集点赞数 */
private Long likeCount;
/** 本集评论数 */
private Long commentCount;
} }
+7 -3
View File
@@ -39,7 +39,8 @@ ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
(1103, 'download:list', '查看下载授权', '用户管理', 'BUTTON', 103), (1103, 'download:list', '查看下载授权', '用户管理', 'BUTTON', 103),
(1104, 'download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104) (1104, 'download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104),
(1105, 'watch-history:list','查看观看记录', '用户管理', 'BUTTON', 105)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
@@ -80,6 +81,7 @@ FROM sys_role r
CROSS JOIN sys_permission p CROSS JOIN sys_permission p
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
AND p.code IN ('app-user:list', 'app-user:update', 'download:list', 'download:revoke', AND p.code IN ('app-user:list', 'app-user:update', 'download:list', 'download:revoke',
'watch-history:list',
'comment:list', 'comment:audit', 'report:list', 'report:handle', 'comment:list', 'comment:audit', 'report:list', 'report:handle',
'sensitive-word:list', 'sensitive-word:update', 'sensitive-word:list', 'sensitive-word:update',
'order:list', 'order:update', 'entitlement:list', 'entitlement:update', 'order:list', 'order:update', 'entitlement:list', 'entitlement:update',
@@ -130,7 +132,9 @@ WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_1200');
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES permission_code, type, always_show, affix, sort) VALUES
(15, 0, '用户管理', '/user', 'UserCenter', '#', '/user/app-users', 'material-symbols:group-rounded', 'app-user:list', 'CATALOG', 1, 0, 4), (15, 0, '用户管理', '/user', 'UserCenter', '#', '/user/app-users', 'material-symbols:group-rounded', 'app-user:list', 'CATALOG', 1, 0, 4),
(16, 15, 'App 用户', 'app-users', 'app-users', 'views/userManagement/AppUsersView', '', '', 'app-user:list', 'MENU', 0, 0, 1) (16, 15, 'App 用户', 'app-users', 'app-users', 'views/userManagement/AppUsersView', '', '', 'app-user:list', 'MENU', 0, 0, 1),
(34, 15, '观看记录', 'watch-history', 'watch-history',
'views/userManagement/WatchHistoryView', '', '', 'watch-history:list', 'MENU', 0, 0, 2)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title,
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component, route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component,
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
@@ -141,7 +145,7 @@ SELECT r.id * 1000000 + m.id, r.id, m.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_menu m CROSS JOIN sys_menu m
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
AND m.id IN (15, 16) AND m.id IN (15, 16, 34)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ---------- 菜单:内容治理 / 订单与权益 / 数据看板 ---------- -- ---------- 菜单:内容治理 / 订单与权益 / 数据看板 ----------
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE drama ADD COLUMN IF NOT EXISTS drama_type VARCHAR(16) NOT NULL DEFAULT 'LIVE';
COMMENT ON COLUMN drama.drama_type IS '内容类型:ANIME=漫剧,LIVE=真人剧';
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS like_count BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN drama_episode.like_count IS '本集点赞数(点赞目标为 EPISODE)';
+34
View File
@@ -0,0 +1,34 @@
-- Author: fruit.com.cn
-- K91 后台观看记录独立菜单与权限点(幂等,仅提交文件,由发布流程执行)
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
(1105, 'watch-history:list', '查看观看记录', '用户管理', 'BUTTON', 105)
ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id * 1000000 + p.id, r.id, p.id
FROM sys_role r
CROSS JOIN sys_permission p
WHERE r.code = 'super_admin' AND p.code = 'watch-history:list'
ON CONFLICT DO NOTHING;
-- 任务书建议使用菜单 id 26,但现有“创作者中心”已使用 26,27-30 为其子菜单。
-- 为避免覆盖已有菜单树,这里使用下一个空闲 id 34。
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(34, 15, '观看记录', 'watch-history', 'watch-history',
'views/userManagement/WatchHistoryView', '', '',
'watch-history:list', 'MENU', 0, 0, 2)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title,
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name,
component = EXCLUDED.component, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon,
permission_code = EXCLUDED.permission_code, type = EXCLUDED.type,
always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id * 1000000 + m.id, r.id, m.id
FROM sys_role r
CROSS JOIN sys_menu m
WHERE r.code = 'super_admin' AND m.id = 34
ON CONFLICT DO NOTHING;
+16
View File
@@ -0,0 +1,16 @@
-- Author: fruit.com.cn
-- K93 短剧分类与内容类型多选(幂等,仅提交文件,由发布流程执行)
ALTER TABLE drama ADD COLUMN IF NOT EXISTS drama_types VARCHAR(32) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama.drama_types IS '内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个';
ALTER TABLE drama ADD COLUMN IF NOT EXISTS category_ids VARCHAR(255) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama.category_ids IS
'短剧分类多选,逗号分隔的 category_id,category_id 保留第一个';
UPDATE drama SET drama_types = drama_type
WHERE (drama_types IS NULL OR drama_types = '') AND drama_type IS NOT NULL AND drama_type <> '';
UPDATE drama SET category_ids = category_id::text
WHERE (category_ids IS NULL OR category_ids = '') AND category_id IS NOT NULL AND category_id > 0;
+1
View File
@@ -44,6 +44,7 @@ ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS preview_seconds INT
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_url VARCHAR(512) NOT NULL DEFAULT ''; ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_url VARCHAR(512) NOT NULL DEFAULT '';
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_lang VARCHAR(16) NOT NULL DEFAULT ''; ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_lang VARCHAR(16) NOT NULL DEFAULT '';
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS is_vip_only SMALLINT NOT NULL DEFAULT 0; -- 预留:会员专属(第二阶段) ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS is_vip_only SMALLINT NOT NULL DEFAULT 0; -- 预留:会员专属(第二阶段)
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS like_count BIGINT NOT NULL DEFAULT 0; -- 本集点赞数
-- 媒资:CDN 地址与转码状态 -- 媒资:CDN 地址与转码状态
ALTER TABLE media_asset ADD COLUMN IF NOT EXISTS cdn_url VARCHAR(512) NOT NULL DEFAULT ''; ALTER TABLE media_asset ADD COLUMN IF NOT EXISTS cdn_url VARCHAR(512) NOT NULL DEFAULT '';
+3
View File
@@ -164,11 +164,14 @@ CREATE TABLE IF NOT EXISTS drama (
title VARCHAR(128) NOT NULL, title VARCHAR(128) NOT NULL,
subtitle VARCHAR(128) NOT NULL DEFAULT '', subtitle VARCHAR(128) NOT NULL DEFAULT '',
category_id BIGINT NOT NULL DEFAULT 0, category_id BIGINT NOT NULL DEFAULT 0,
category_ids VARCHAR(255) NOT NULL DEFAULT '',
genre VARCHAR(64) NOT NULL DEFAULT '', genre VARCHAR(64) NOT NULL DEFAULT '',
synopsis VARCHAR(1000) NOT NULL DEFAULT '', synopsis VARCHAR(1000) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '', cover_url VARCHAR(512) NOT NULL DEFAULT '',
horizontal_cover_url VARCHAR(512) NOT NULL DEFAULT '', horizontal_cover_url VARCHAR(512) NOT NULL DEFAULT '',
language VARCHAR(16) NOT NULL DEFAULT 'zh-CN', language VARCHAR(16) NOT NULL DEFAULT 'zh-CN',
drama_type VARCHAR(16) NOT NULL DEFAULT 'LIVE',
drama_types VARCHAR(32) NOT NULL DEFAULT '',
total_episodes INT NOT NULL DEFAULT 0, total_episodes INT NOT NULL DEFAULT 0,
free_episodes INT NOT NULL DEFAULT 3, free_episodes INT NOT NULL DEFAULT 3,
unlock_coin INT NOT NULL DEFAULT 0, unlock_coin INT NOT NULL DEFAULT 0,