Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07754ff4e4 | ||
|
|
4159af1732 | ||
|
|
0143fa036c | ||
|
|
5227762aeb | ||
|
|
a4317bb6f1 | ||
|
|
9adf8c5630 | ||
|
|
8bb7f63c62 | ||
|
|
b8b3fd867b | ||
|
|
e92ec118fe | ||
|
|
4c4b53cd69 | ||
|
|
d7f226659c | ||
|
|
979e6ebea3 | ||
|
|
3a581163aa | ||
|
|
97c2153c2e | ||
|
|
27a15dd1b4 | ||
|
|
cf37d6c408 | ||
|
|
c9b8ec6805 | ||
|
|
31d88b634a | ||
|
|
5704f80929 | ||
|
|
652ef050ee | ||
|
|
8f35160b56 | ||
|
|
0e8fb1582d | ||
|
|
aa74770256 |
No files matched your search
+4
-1
@@ -167,7 +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&cursor=&size=20` | 推荐流;按内容类型多选过滤,游标翻页 |
|
| 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=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
|
||||||
@@ -188,7 +188,10 @@ app.register.max-per-device-per-day
|
|||||||
|
|
||||||
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
|
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
|
||||||
过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。
|
过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。
|
||||||
|
- `categoryId` 可选;不传返回全部分类。传入后按 `category_ids` 整词匹配,
|
||||||
|
可与 `types` 叠加,只返回同时命中内容类型和分类的已上线短剧。
|
||||||
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
|
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
|
||||||
|
游标短剧必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
|
||||||
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
|
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
|
||||||
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
|
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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 验收,回执需贴门禁原始输出
|
||||||
@@ -24243,3 +24243,500 @@ Running Gradle task 'assembleDebug'... 27.6s
|
|||||||
- ReelShort develop 合并提交:`a2a491d merge: 合并 K95.2 首切频道封面过渡`,
|
- ReelShort develop 合并提交:`a2a491d merge: 合并 K95.2 首切频道封面过渡`,
|
||||||
已推送 `838ca2c..a2a491d`。
|
已推送 `838ca2c..a2a491d`。
|
||||||
- 未改后端、ReelShortWeb、specs;未安装或操作真机,等待用户验收。
|
- 未改后端、ReelShortWeb、specs;未安装或操作真机,等待用户验收。
|
||||||
|
|
||||||
|
## 2026-10-21 K95.2 已取消并回滚
|
||||||
|
|
||||||
|
- PM 明确没有要求新增封面过渡,K95.2 全部改动取消。
|
||||||
|
- ReelShort 已执行 `git revert -m 1 a2a491d --no-edit`,回滚提交为
|
||||||
|
`f8769f2 Revert "merge: 合并 K95.2 首切频道封面过渡"`。
|
||||||
|
- 已推送 `a2a491d..f8769f2`;当前 ReelShort `develop` 相对 K95.1 合并点
|
||||||
|
`838ca2c` 的代码差异为空。
|
||||||
|
- 本地分支 `fix/k95.2-poster-transition` 已删除;远端原本不存在该分支。
|
||||||
|
- K95、K95.1 成果保持不变;未实现其他改动,未安装或操作真机。
|
||||||
|
|
||||||
|
## 2026-10-21 K96 剧场页服务端分类分页
|
||||||
|
|
||||||
|
- 已完整读取 K96 `docs/plans/INBOX.md`。
|
||||||
|
- 已从最新 `origin/develop` 创建 FruitServer `feat/k96-feed-category` 与
|
||||||
|
ReelShort `feat/k96-theater-feed`。
|
||||||
|
- 已先写计划 `docs/plans/2026-10-21-K96-theater-feed.md`,覆盖分类参数、
|
||||||
|
category_ids 整词匹配、游标 marker 三重校验、客户端分类缓存分页和剧场接入。
|
||||||
|
- 已完成 `/app/feed` 可选 `categoryId`:可与 `types` 叠加;游标 marker 必须同时满足
|
||||||
|
已上线、类型命中和分类命中,否则返回空列表。
|
||||||
|
- 已补分类单独过滤、分类 + 类型、分类游标和 marker 不匹配测试,并更新
|
||||||
|
`docs/api-phase1.md`。
|
||||||
|
- 客户端已完成 Catalog API 分类参数、TheaterFeedController、分类缓存/静默刷新、
|
||||||
|
游标追加去重、10 秒超时与滚动加载;剧场「全部 / 分类」已改用 `/app/feed`,
|
||||||
|
搜索与首页 K94/K95 逻辑未改。
|
||||||
|
|
||||||
|
门禁原始输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn test
|
||||||
|
[INFO] fruitshorts-app-api ................................ SUCCESS
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] Total time: 15.436 s
|
||||||
|
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
00:52 +553: All tests passed!
|
||||||
|
|
||||||
|
$ flutter build apk --debug
|
||||||
|
Running Gradle task 'assembleDebug'... 24.8s
|
||||||
|
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:FruitServer 全部 14 个 Reactor 模块通过,app-api 99 项测试全过;
|
||||||
|
ReelShort 静态分析零告警、553 项测试全过、debug APK 构建成功。
|
||||||
|
`pubspec.lock` 已还原,无依赖变更;未安装或操作真机。
|
||||||
|
|
||||||
|
## 2026-10-21 K97 修复 iOS 评论输入弹窗键盘避让
|
||||||
|
|
||||||
|
- 已完整读取 K97 `docs/plans/INBOX.md`。
|
||||||
|
- 已从最新 `origin/develop` 创建 ReelShort `fix/k97-ios-comment-keyboard`。
|
||||||
|
- 已先写计划 `docs/plans/2026-10-21-K97-ios-comment-keyboard.md`:
|
||||||
|
Android 保留 channel + 80ms pinned,iOS/其他平台实时读取 viewInsets,
|
||||||
|
面板使用 160ms AnimatedPadding,并按平台处理 SafeArea。
|
||||||
|
- 不改后端、ReelShortWeb、specs;不安装或操作真机。
|
||||||
|
- 已完成平台分流:Android 继续使用 pinned/channel;iOS/其他平台 build 中实时读取
|
||||||
|
`MediaQuery.viewInsetsOf(context).bottom`,超过屏幕 60% 时归零。
|
||||||
|
- 面板已改为 160ms `easeOutCubic` AnimatedPadding;Android 不包裹 SafeArea,
|
||||||
|
iOS/其他平台使用 `SafeArea(top: false)` 保留底部安全区。
|
||||||
|
- 已补 iOS viewInsets 300/0、发表按钮位于键盘上方测试,并保持 Android pinned 用例通过。
|
||||||
|
|
||||||
|
门禁原始输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
00:54 +555: All tests passed!
|
||||||
|
|
||||||
|
$ flutter build apk --debug
|
||||||
|
Running Gradle task 'assembleDebug'... 26.2s
|
||||||
|
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
|
||||||
|
$ flutter build ios --simulator --no-codesign
|
||||||
|
Xcode build done. 44.0s
|
||||||
|
✓ Built build/ios/iphonesimulator/Runner.app
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:静态分析零告警,555 项全量测试通过,Android 与 iOS simulator 构建成功;
|
||||||
|
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||||
|
|
||||||
|
## 2026-10-21 K98 修复登录与手机号登录键盘遮挡
|
||||||
|
|
||||||
|
- 已完整读取 K98 `docs/plans/INBOX.md`。
|
||||||
|
- 已从最新 `origin/develop` 创建 ReelShort `fix/k98-auth-keyboard`。
|
||||||
|
- 已先写计划 `docs/plans/2026-10-21-K98-auth-keyboard.md`:
|
||||||
|
统一 viewInsets/channel 有效键盘高度,登录/注册和手机号弹窗按字段位置自动滚动。
|
||||||
|
- 不改后端、ReelShortWeb、specs;不安装或操作真机。
|
||||||
|
- 已新增 `effectiveKeyboardInset`:取 MediaQuery viewInsets 与 Android channel
|
||||||
|
逻辑像素最大值,Web/非 Android 忽略 channel,超过屏幕 60% 时归零。
|
||||||
|
- 登录/注册页已接入 notifier、ScrollController 和邮箱/密码焦点校正;
|
||||||
|
仍保持 `resizeToAvoidBottomInset: false`,遮挡时 200ms easeOutCubic 滚动。
|
||||||
|
- 手机号弹窗已使用有效键盘高度,并给手机号/验证码框增加同样的滚动校正。
|
||||||
|
- 已补 iOS 登录/注册、Android channel、手机号验证码框键盘避让测试。
|
||||||
|
|
||||||
|
门禁原始输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
00:52 +559: All tests passed!
|
||||||
|
|
||||||
|
$ flutter build apk --debug
|
||||||
|
Running Gradle task 'assembleDebug'... 26.2s
|
||||||
|
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:静态分析零告警,559 项全量测试通过,debug APK 构建成功;
|
||||||
|
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||||
|
|
||||||
|
## 2026-10-21 K100 后台剧集多选上传
|
||||||
|
|
||||||
|
### 动作 0:开工前置 — 已读以上文档
|
||||||
|
|
||||||
|
已逐份 Read:
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `CODING_STANDARDS.md`
|
||||||
|
- `GIT_WORKFLOW.md`
|
||||||
|
- `FruitServer/docs/plans/INBOX.md`
|
||||||
|
- `ReelShortWeb/src/utils/uploader.ts`
|
||||||
|
- `ReelShortWeb/src/api/admin/media.ts`
|
||||||
|
- `ReelShortWeb/src/api/admin/episode.ts`
|
||||||
|
- `ReelShortWeb/src/views/contentManagement/EpisodeManagementView.vue`
|
||||||
|
|
||||||
|
解读:后端分片接口与 `uploadFile`/`resumeUpload` 可复用;剧集页仅有单文件 `el-upload`;本轮只改 ReelShortWeb,严格按 INBOX A~F 落地队列/续传/批量建集/组件拆分/i18n/门禁。
|
||||||
|
|
||||||
|
|
||||||
|
### 动作 1:写计划 + 拉 feature 分支
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git checkout develop && git pull --ff-only origin develop && git checkout -b feature/episode-batch-upload
|
||||||
|
Already on 'develop'
|
||||||
|
Your branch is up to date with 'origin/develop'.
|
||||||
|
Already up to date.
|
||||||
|
Switched to a new branch 'feature/episode-batch-upload'
|
||||||
|
## feature/episode-batch-upload
|
||||||
|
8e1a02a chore(web): 忽略本机启动脚本与运行日志
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:已写计划 `2026-10-21-K100-episode-batch-upload.md`;分支自最新 develop 拉出,工作区干净,可开始编码。
|
||||||
|
|
||||||
|
|
||||||
|
### 动作 2:实现 A~E 源码
|
||||||
|
|
||||||
|
已新增/修改:
|
||||||
|
- `upload/uploadSessions.ts`、`useUploadQueue.ts`
|
||||||
|
- `UploadTaskRow.vue`、`BatchUploadDialog.vue`、`CreateEpisodesDialog.vue`
|
||||||
|
- `EpisodeManagementView.vue`(批量上传入口 + 进页续传打开弹窗)
|
||||||
|
- `locales/zh-CN.ts`、`en.ts`(`episodeBatch` 文案)
|
||||||
|
|
||||||
|
解读:队列文件并发 2、分片并发 3、字节加权进度、localStorage 续传、批量建集弹窗与 i18n 已落地;未改后端与契约。
|
||||||
|
|
||||||
|
|
||||||
|
### 动作 3:门禁 pnpm lint:eslint
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pnpm lint:eslint
|
||||||
|
|
||||||
|
> fruitshorts-admin@1.0.0 lint:eslint /Users/jianghaifeng/Desktop/ReelShort/ReelShortWeb
|
||||||
|
> eslint --fix --ext .js,.ts,.vue ./src
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:eslint 零错误(--fix 后无残留报错输出)。
|
||||||
|
|
||||||
|
|
||||||
|
### 动作 4:门禁 pnpm build:dev
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pnpm build:dev
|
||||||
|
|
||||||
|
> fruitshorts-admin@1.0.0 build:dev /Users/jianghaifeng/Desktop/ReelShort/ReelShortWeb
|
||||||
|
> VITE_CJS_IGNORE_WARNING=1 vite build --mode dev
|
||||||
|
|
||||||
|
[warn] Circular chunk: element-plus -> vue-chunks -> element-plus. Please adjust the manual chunk logic for these chunks.
|
||||||
|
|
||||||
|
Build successful. Please see dist-dev directory
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:构建成功;element-plus 循环 chunk 为既有告警,非本轮引入。
|
||||||
|
|
||||||
|
|
||||||
|
### 动作 5:复跑门禁(修 duplicate import / CreateEpisodes 微调后)
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pnpm lint:eslint
|
||||||
|
> eslint --fix --ext .js,.ts,.vue ./src
|
||||||
|
(无错误输出)
|
||||||
|
|
||||||
|
$ pnpm build:dev
|
||||||
|
Build successful. Please see dist-dev directory
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:二次门禁仍全绿,可提交。
|
||||||
|
|
||||||
|
|
||||||
|
### 动作 6:ReelShortWeb 提交并合入 develop
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git commit # feature/episode-batch-upload
|
||||||
|
[feature/episode-batch-upload 6b3620c] feat(admin): 剧集多选分片上传与断点续传
|
||||||
|
9 files changed, 1210 insertions(+), 8 deletions(-)
|
||||||
|
|
||||||
|
$ git merge --no-ff feature/episode-batch-upload
|
||||||
|
(commit-msg 拒收 merge: 前缀,改用 chore(admin): …)
|
||||||
|
[develop 7d39e3c] chore(admin): 合并剧集多选分片上传与断点续传
|
||||||
|
|
||||||
|
$ git push origin develop && git push origin feature/episode-batch-upload
|
||||||
|
8e1a02a..7d39e3c develop -> develop
|
||||||
|
* [new branch] feature/episode-batch-upload -> feature/episode-batch-upload
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:功能提交作者 Zhou Weidong;已 --no-ff 合入 develop 并 push;停住等 PM 验收。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 【Codex】2026-10-21 K100 续:文案/格式统一 + 拆分超长剧集管理页
|
||||||
|
|
||||||
|
承接 develop `7d39e3c`,分支 `feature/episode-batch-upload`。
|
||||||
|
|
||||||
|
### 动作 1:文案与格式统一(commit `c71a12f`)
|
||||||
|
|
||||||
|
改动:
|
||||||
|
- `locales/zh-CN.ts` / `en.ts`:`episodeBatch.hint` 去掉「数量上限」歧义,改为「可一次多选,同时上传 2 个,其余自动排队」
|
||||||
|
- `upload/videoLimit.ts`:新增同源 `VIDEO_EXTENSIONS` / `VIDEO_ACCEPT_ATTR`(mp4/mov/mkv/webm)
|
||||||
|
- `EpisodeManagementView.vue` + `BatchUploadDialog.vue`:共用上述常量;单集格式错误走 `t('episodeBatch.invalidType')`
|
||||||
|
- 仍只用 `MAX_VIDEO_SIZE=500MB`,未新增数量上限常量
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git commit --author="Zhou Weidong <502333560@qq.com>"
|
||||||
|
[feature/episode-batch-upload c71a12f] fix(admin): 统一剧集上传格式与提示文案
|
||||||
|
5 files changed, 17 insertions(+), 15 deletions(-)
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:单集与批量白名单同源;提示不再被理解为「最多 2 个文件」。
|
||||||
|
|
||||||
|
### 动作 2:拆分超长文件(commit `cff864d`)
|
||||||
|
|
||||||
|
新增 `EpisodeVideoUploader.vue`、`EpisodeFormDialog.vue`;主文件只保留列表/筛选/分页/表格/预览/批量上传入口。
|
||||||
|
|
||||||
|
```
|
||||||
|
$ wc -l EpisodeManagementView.vue EpisodeVideoUploader.vue EpisodeFormDialog.vue BatchUploadDialog.vue
|
||||||
|
344 src/views/contentManagement/EpisodeManagementView.vue
|
||||||
|
172 src/views/contentManagement/upload/EpisodeVideoUploader.vue
|
||||||
|
280 src/views/contentManagement/upload/EpisodeFormDialog.vue
|
||||||
|
316 src/views/contentManagement/upload/BatchUploadDialog.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:四个文件均 ≤500 行,满足单文件红线。
|
||||||
|
|
||||||
|
### 动作 3:门禁 pnpm lint:eslint
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pnpm lint:eslint
|
||||||
|
|
||||||
|
> fruitshorts-admin@1.0.0 lint:eslint /Users/jianghaifeng/Desktop/ReelShort/ReelShortWeb
|
||||||
|
> eslint --fix --ext .js,.ts,.vue ./src
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:eslint 零错误。
|
||||||
|
|
||||||
|
### 动作 4:门禁 pnpm build:dev
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pnpm build:dev
|
||||||
|
|
||||||
|
> fruitshorts-admin@1.0.0 build:dev /Users/jianghaifeng/Desktop/ReelShort/ReelShortWeb
|
||||||
|
> VITE_CJS_IGNORE_WARNING=1 vite build --mode dev
|
||||||
|
|
||||||
|
[warn] Circular chunk: element-plus -> vue-chunks -> element-plus. Please adjust the manual chunk logic for these chunks.
|
||||||
|
|
||||||
|
Build successful. Please see dist-dev directory
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:构建成功;element-plus 循环 chunk 为既有告警,非本轮引入。
|
||||||
|
|
||||||
|
### 动作 5:合入 develop 并 push
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git commit # feature 第二条
|
||||||
|
[feature/episode-batch-upload cff864d] refactor(admin): 拆分剧集管理页超长组件
|
||||||
|
Author: Zhou Weidong <502333560@qq.com>
|
||||||
|
3 files changed, 467 insertions(+), 387 deletions(-)
|
||||||
|
|
||||||
|
$ git merge --no-ff feature/episode-batch-upload
|
||||||
|
[develop e1e0055] chore(admin): 合并剧集上传文案统一与组件拆分
|
||||||
|
|
||||||
|
$ git push origin develop && git push origin feature/episode-batch-upload
|
||||||
|
7d39e3c..e1e0055 develop -> develop
|
||||||
|
6b3620c..cff864d feature/episode-batch-upload -> feature/episode-batch-upload
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:两条功能提交作者均为 Zhou Weidong;已 --no-ff 合入 develop 并 push;未动 FruitServer/ReelShort/契约;停住等验收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-21 K100 收口:提交 AI 痕迹登记为已知偏差
|
||||||
|
|
||||||
|
- 偏差事实:`6b3620c feat(admin): 剧集多选分片上传与断点续传`、`7d39e3c chore(admin): 合并剧集多选分片上传与断点续传`
|
||||||
|
两个提交的 message 含 `Co-authored-by: Cursor <cursoragent@cursor.com>`,违反「提交信息不得出现 AI 痕迹」。
|
||||||
|
- 处置:PM 裁定不改写已推送历史。清除痕迹需 `push --force`,与 GIT_WORKFLOW「禁止 force push」冲突,
|
||||||
|
两害相权保留现状,登记为已知偏差,不追溯、不再重复提。
|
||||||
|
- 防复发:ReelShortWeb `.husky/commit-msg` 增加 AI 署名拦截,命中 `co-authored-by|generated with|assisted by`
|
||||||
|
且含 `cursor|claude|codex|gpt|copilot|ai` 时直接拒绝提交。
|
||||||
|
- 自查要求:后续每轮回执附 `git log -1 --format=%B` 原始输出,供 PM 核验。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ printf 'feat: t\n\nCo-authored-by: Cursor <cursoragent@cursor.com>\n' > /tmp/badmsg.txt && sh .husky/commit-msg /tmp/badmsg.txt; echo "GUARD_EXIT=$?"
|
||||||
|
commit message contains AI attribution, remove it before committing
|
||||||
|
GUARD_EXIT=1
|
||||||
|
|
||||||
|
$ printf 'feat: t\n' > /tmp/okmsg.txt && sh .husky/commit-msg /tmp/okmsg.txt; echo "OK_EXIT=$?"
|
||||||
|
OK_EXIT=0
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:拦截生效,干净 message 正常通过 commitlint。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-21 | 短剧保存免费集数同步分集免费状态
|
||||||
|
|
||||||
|
【Codex】根因:DramaService.update 只更新 drama,未同步 drama_episode.is_free/unlock_coin。
|
||||||
|
抽出 EpisodePriceSync.syncByDramaRule,DramaService.update 与 PriceService.save 共用;仅 freeEpisodes/unlockCoin 变化时同步。
|
||||||
|
|
||||||
|
### 门禁(原始输出摘录)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ mvn -pl fruitshorts-module-content -am test -Dsurefire.failIfNoSpecifiedTests=false
|
||||||
|
[INFO] T E S T S
|
||||||
|
[INFO] -------------------------------------------------------
|
||||||
|
[INFO] Running com.fruitshorts.content.service.DramaServiceTest
|
||||||
|
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
|
||||||
|
WARNING: A Java agent has been loaded dynamically (/Users/jianghaifeng/.m2/repository/net/bytebuddy/byte-buddy-agent/1.14.13/byte-buddy-agent-1.14.13.jar)
|
||||||
|
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
|
||||||
|
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
|
||||||
|
WARNING: Dynamic loading of agents will be disallowed by default in a future release
|
||||||
|
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.126 s -- in com.fruitshorts.content.service.DramaServiceTest
|
||||||
|
[INFO]
|
||||||
|
[INFO] Results:
|
||||||
|
[INFO]
|
||||||
|
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO]
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
[INFO] Reactor Summary for fruitshorts 1.0.0-SNAPSHOT:
|
||||||
|
[INFO]
|
||||||
|
[INFO] fruitshorts ........................................ SUCCESS [ 0.001 s]
|
||||||
|
[INFO] fruitshorts-common ................................. SUCCESS [ 3.685 s]
|
||||||
|
[INFO] fruitshorts-framework .............................. SUCCESS [ 31.492 s]
|
||||||
|
[INFO] fruitshorts-module-system .......................... SUCCESS [ 0.051 s]
|
||||||
|
[INFO] fruitshorts-module-content ......................... SUCCESS [ 4.451 s]
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
[INFO] Total time: 39.944 s
|
||||||
|
[INFO] Finished at: 2026-10-21T22:35:43+08:00
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:DramaServiceTest 4 条全过;module-content BUILD SUCCESS。
|
||||||
|
|
||||||
|
### 提交 / 合并 / push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ git log -2 --format=%B
|
||||||
|
fix(content): 短剧保存免费集数时同步分集免费状态
|
||||||
|
|
||||||
|
test(content): 覆盖短剧免费集数同步分集价格
|
||||||
|
|
||||||
|
$ git log --oneline -4
|
||||||
|
e92ec11 merge: 短剧保存免费集数同步分集免费状态
|
||||||
|
4c4b53c fix(content): 短剧保存免费集数时同步分集免费状态
|
||||||
|
d7f2266 test(content): 覆盖短剧免费集数同步分集价格
|
||||||
|
979e6eb docs(plans): 登记 K100 提交 AI 痕迹已知偏差
|
||||||
|
|
||||||
|
$ git push origin feature/drama-free-episodes-sync
|
||||||
|
$ git push origin develop
|
||||||
|
# develop 979e6eb..e92ec11
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:作者 Zhou Weidong;无 Co-authored-by / AI 痕迹;develop 与 feature 均已 push。未启动/重启 8080/8081。停住等验收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-21 短剧保存始终同步分集价格
|
||||||
|
|
||||||
|
计划:`docs/plans/2026-10-21-drama-price-sync-idempotent.md`。
|
||||||
|
同值重存也调用 `EpisodePriceSync.syncByDramaRule(drama)`,只更新有差异的分集。
|
||||||
|
|
||||||
|
### 门禁(原始输出)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ mvn -pl fruitshorts-module-content test
|
||||||
|
[INFO] -------------------------------------------------------
|
||||||
|
[INFO] T E S T S
|
||||||
|
[INFO] -------------------------------------------------------
|
||||||
|
[INFO] Running com.fruitshorts.content.service.DramaServiceTest
|
||||||
|
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.087 s -- in com.fruitshorts.content.service.DramaServiceTest
|
||||||
|
[INFO]
|
||||||
|
[INFO] Results:
|
||||||
|
[INFO]
|
||||||
|
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO]
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
[INFO] Total time: 38.170 s
|
||||||
|
[INFO] Finished at: 2026-10-21T22:52:35+08:00
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:DramaServiceTest 5 条全过(含漂移纠偏 + 已对齐幂等不写库);module-content BUILD SUCCESS。
|
||||||
|
|
||||||
|
### 提交 / 合并 / push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ git log -3 --format=%B
|
||||||
|
merge: 短剧保存时始终同步分集价格
|
||||||
|
|
||||||
|
fix(content): 短剧保存时始终同步分集价格
|
||||||
|
|
||||||
|
test(content): 覆盖免费集数未变但分集漂移的修复场景
|
||||||
|
|
||||||
|
$ git log -3 --format='%h %an <%ae> %s'
|
||||||
|
a4317bb jianghaifeng <haifeng_hello@163.com> merge: 短剧保存时始终同步分集价格
|
||||||
|
9adf8c5 Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> fix(content): 短剧保存时始终同步分集价格
|
||||||
|
8bb7f63 Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> test(content): 覆盖免费集数未变但分集漂移的修复场景
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:功能与分支/merge/push 已落地(develop=`a4317bb`,feature=`9adf8c5`)。**作者口径偏差**:三条均非 `Zhou Weidong <502333560@qq.com>`;按「已推送历史不 force push」登记,待 Trae 拍板是否改写。未启停 8080/8081。停住等验收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 【Codex】2026-10-21 | GET /admin/episodes 服务端分页(K107 追加)
|
||||||
|
|
||||||
|
**契约变更(specs 由专人更新,此处留痕)**:
|
||||||
|
- `GET /admin/episodes` 新增 `pageIndex`(默认 1)、`pageSize`(默认 100,上限 200)
|
||||||
|
- 响应由 `List` 改为 `PageResult{total, list}`;`dramaId`/`status`/`episode:list` 不变
|
||||||
|
- 数据库分页(MyBatis-Plus selectPage),排序 `sort ASC, ep_no ASC`
|
||||||
|
|
||||||
|
**分支**:`feature/episode-list-api-pagination` ← develop
|
||||||
|
|
||||||
|
**命令**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn -pl fruitshorts-admin-api -am test
|
||||||
|
```
|
||||||
|
|
||||||
|
**原始输出(关键行)**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 — EpisodeControllerContractTest
|
||||||
|
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 — EpisodeServicePageTest
|
||||||
|
[INFO] Tests run: 38, Failures: 0, Errors: 0, Skipped: 0 — fruitshorts-admin-api
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] Total time: 15.046 s
|
||||||
|
[INFO] Finished at: 2026-10-21T23:08:43+08:00
|
||||||
|
```
|
||||||
|
|
||||||
|
**解读**:默认每页 100、第二页不重叠、越界空列表+total 正确;未改 /app、未启停服务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-22 | 已知偏差:ReelShortWeb 99bb666 合并提交作者不合规
|
||||||
|
|
||||||
|
### 事实
|
||||||
|
- 仓库:ReelShortWeb
|
||||||
|
- 提交:`99bb666 chore(merge): 剧集列表改为服务端分页`
|
||||||
|
- 实际作者:`Vicky <10212746+haifeng-jiang@user.noreply.gitee.com>`
|
||||||
|
- 规定身份:`Zhou Weidong <502333560@qq.com>`(AGENTS.md / CODING_STANDARDS.md / GIT_WORKFLOW.md)
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
- ReelShortWeb 仓库本地 `user.name` / `user.email` 配置为 Vicky 口径
|
||||||
|
- `merge --no-ff` 继承该本地身份,未使用命令级 `-c user.name` / `-c user.email` 覆盖
|
||||||
|
|
||||||
|
### 处置
|
||||||
|
- 该提交已推送 `origin/develop`(本地与远端同步)
|
||||||
|
- 按 GIT_WORKFLOW「禁止 force push 到 develop」:**不做历史改写**
|
||||||
|
- 仅登记为已知偏差;后续 commit / merge 一律:
|
||||||
|
`git -c user.name="Zhou Weidong" -c user.email="502333560@qq.com" ...`
|
||||||
|
- ReelShortWeb 根目录留本地说明 `COMMIT_AUTHOR.local`(`*.local` 不进版本库)
|
||||||
|
|
||||||
|
### 关联
|
||||||
|
- 此前已登记的 Co-authored-by / AI 痕迹已知偏差(见本文件「2026-10-21 K100 收口:提交 AI 痕迹登记为已知偏差」)
|
||||||
|
- 此前已登记的短剧价格同步作者口径偏差(`8bb7f63`/`9adf8c5`/`a4317bb`,见 INBOX / 本文件 2026-10-21 条目)
|
||||||
|
|
||||||
|
解读:已推送历史不改写;身份合规靠命令级覆盖防复发;本条仅文档留痕,无代码改动。
|
||||||
|
|
||||||
+9
-5
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
@@ -39,12 +40,15 @@ public class EpisodeController {
|
|||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
private final DramaService dramaService;
|
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,
|
||||||
List<EpisodeRespVO> episodes = 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(
|
Map<Long, Long> commentCounts = commentService.countByEpisodes(
|
||||||
episodes.stream().map(EpisodeRespVO::getId).toList());
|
episodes.stream().map(EpisodeRespVO::getId).toList());
|
||||||
Drama drama = dramaService.getExisting(dramaId);
|
Drama drama = dramaService.getExisting(dramaId);
|
||||||
@@ -53,7 +57,7 @@ public class EpisodeController {
|
|||||||
episode.setFavoriteCount(drama.getFavoriteCount());
|
episode.setFavoriteCount(drama.getFavoriteCount());
|
||||||
episode.setHeat(drama.getViewCount());
|
episode.setHeat(drama.getViewCount());
|
||||||
});
|
});
|
||||||
return Result.ok(episodes);
|
return Result.ok(new PageResult<>(page.getTotal(), episodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新建剧集")
|
@Operation(summary = "新建剧集")
|
||||||
|
|||||||
+101
-16
@@ -3,6 +3,7 @@
|
|||||||
package com.fruitshorts.admin.controller;
|
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.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
@@ -10,20 +11,30 @@ import com.fruitshorts.content.vo.EpisodeRespVO;
|
|||||||
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.mockito.ArgumentCaptor;
|
||||||
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 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.BDDMockito.given;
|
||||||
import static org.mockito.Mockito.mock;
|
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.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
/** K89:后台剧集列表按集回填点赞/评论,并带所属剧收藏/热度。 */
|
/** 后台剧集列表:互动数量回填 + 分页契约。 */
|
||||||
class EpisodeControllerContractTest {
|
class EpisodeControllerContractTest {
|
||||||
|
|
||||||
|
private static final long DRAMA_ID = 5L;
|
||||||
|
private static final int DEFAULT_PAGE_SIZE = 100;
|
||||||
|
|
||||||
private EpisodeService episodeService;
|
private EpisodeService episodeService;
|
||||||
private CommentService commentService;
|
private CommentService commentService;
|
||||||
private DramaService dramaService;
|
private DramaService dramaService;
|
||||||
@@ -36,34 +47,108 @@ class EpisodeControllerContractTest {
|
|||||||
dramaService = mock(DramaService.class);
|
dramaService = mock(DramaService.class);
|
||||||
mockMvc = ControllerContractSupport.mockMvcOf(
|
mockMvc = ControllerContractSupport.mockMvcOf(
|
||||||
new EpisodeController(episodeService, commentService, dramaService));
|
new EpisodeController(episodeService, commentService, dramaService));
|
||||||
|
Drama drama = new Drama();
|
||||||
|
drama.setFavoriteCount(88L);
|
||||||
|
drama.setViewCount(1000L);
|
||||||
|
given(dramaService.getExisting(DRAMA_ID)).willReturn(drama);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_return_episode_and_drama_interaction_counts() throws Exception {
|
void should_return_episode_and_drama_interaction_counts() throws Exception {
|
||||||
given(episodeService.listByDrama(5L, null)).willReturn(List.of(
|
given(episodeService.pageByDrama(eq(DRAMA_ID), isNull(), isNull(), isNull()))
|
||||||
episode(11L, 10L),
|
.willReturn(new PageResult<>(2L, List.of(episode(11L, 10L), episode(12L, 20L))));
|
||||||
episode(12L, 20L)));
|
|
||||||
given(commentService.countByEpisodes(List.of(11L, 12L)))
|
given(commentService.countByEpisodes(List.of(11L, 12L)))
|
||||||
.willReturn(Map.of(11L, 3L));
|
.willReturn(Map.of(11L, 3L));
|
||||||
Drama drama = new Drama();
|
|
||||||
drama.setFavoriteCount(88L);
|
|
||||||
drama.setViewCount(1000L);
|
|
||||||
given(dramaService.getExisting(5L)).willReturn(drama);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/admin/episodes").param("dramaId", "5"))
|
mockMvc.perform(get("/admin/episodes").param("dramaId", String.valueOf(DRAMA_ID)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data[0].likeCount").value("10"))
|
.andExpect(jsonPath("$.data.total").value(2))
|
||||||
.andExpect(jsonPath("$.data[0].commentCount").value("3"))
|
.andExpect(jsonPath("$.data.list[0].likeCount").value("10"))
|
||||||
.andExpect(jsonPath("$.data[0].favoriteCount").value("88"))
|
.andExpect(jsonPath("$.data.list[0].commentCount").value("3"))
|
||||||
.andExpect(jsonPath("$.data[0].heat").value("1000"))
|
.andExpect(jsonPath("$.data.list[0].favoriteCount").value("88"))
|
||||||
.andExpect(jsonPath("$.data[1].likeCount").value("20"))
|
.andExpect(jsonPath("$.data.list[0].heat").value("1000"))
|
||||||
.andExpect(jsonPath("$.data[1].commentCount").value("0"));
|
.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) {
|
private EpisodeRespVO episode(Long id, Long likeCount) {
|
||||||
EpisodeRespVO episode = new EpisodeRespVO();
|
EpisodeRespVO episode = new EpisodeRespVO();
|
||||||
episode.setId(id);
|
episode.setId(id);
|
||||||
episode.setDramaId(5L);
|
episode.setDramaId(DRAMA_ID);
|
||||||
episode.setLikeCount(likeCount);
|
episode.setLikeCount(likeCount);
|
||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -41,13 +41,14 @@ public class AppCatalogController {
|
|||||||
return Result.ok(appCatalogService.discoverFeed());
|
return Result.ok(appCatalogService.discoverFeed());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "推荐流:支持内容类型多选与游标翻页")
|
@Operation(summary = "推荐流:支持内容类型、分类过滤与游标翻页")
|
||||||
@GetMapping("/feed")
|
@GetMapping("/feed")
|
||||||
public Result<CursorResult<AppDramaCardVO>> feed(
|
public Result<CursorResult<AppDramaCardVO>> feed(
|
||||||
@RequestParam(required = false) String types,
|
@RequestParam(required = false) String types,
|
||||||
|
@RequestParam(required = false) Long categoryId,
|
||||||
@RequestParam(required = false) Long cursor,
|
@RequestParam(required = false) Long cursor,
|
||||||
@RequestParam(required = false) Integer size) {
|
@RequestParam(required = false) Integer size) {
|
||||||
return Result.ok(appCatalogService.feed(types, cursor, size));
|
return Result.ok(appCatalogService.feed(types, categoryId, cursor, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "分类列表")
|
@Operation(summary = "分类列表")
|
||||||
|
|||||||
+4
-2
@@ -74,7 +74,8 @@ public class AppCatalogService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public CursorResult<AppDramaCardVO> feed(String types, Long cursor, Integer size) {
|
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));
|
int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE));
|
||||||
List<String> dramaTypes = types == null
|
List<String> dramaTypes = types == null
|
||||||
? List.of()
|
? List.of()
|
||||||
@@ -82,7 +83,8 @@ public class AppCatalogService {
|
|||||||
.map(String::trim)
|
.map(String::trim)
|
||||||
.filter(type -> !type.isEmpty())
|
.filter(type -> !type.isEmpty())
|
||||||
.toList();
|
.toList();
|
||||||
List<Drama> rows = dramaService.listPublishedFeed(dramaTypes, cursor, pageSize + 1);
|
List<Drama> rows = dramaService.listPublishedFeed(
|
||||||
|
dramaTypes, categoryId, cursor, pageSize + 1);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
return CursorResult.empty();
|
return CursorResult.empty();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -96,11 +96,12 @@ class AppCatalogControllerContractTest {
|
|||||||
card.setId(12L);
|
card.setId(12L);
|
||||||
card.setTitle("漫剧卡片");
|
card.setTitle("漫剧卡片");
|
||||||
card.setDramaType("ANIME");
|
card.setDramaType("ANIME");
|
||||||
given(catalogService.feed("ANIME", null, 20))
|
given(catalogService.feed("ANIME", 7L, null, 20))
|
||||||
.willReturn(new CursorResult<>(List.of(card), 12L, true));
|
.willReturn(new CursorResult<>(List.of(card), 12L, true));
|
||||||
|
|
||||||
catalogMvc.perform(get("/app/feed")
|
catalogMvc.perform(get("/app/feed")
|
||||||
.param("types", "ANIME")
|
.param("types", "ANIME")
|
||||||
|
.param("categoryId", "7")
|
||||||
.param("size", "20"))
|
.param("size", "20"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
@@ -109,7 +110,7 @@ class AppCatalogControllerContractTest {
|
|||||||
.andExpect(jsonPath("$.data.nextCursor").value("12"))
|
.andExpect(jsonPath("$.data.nextCursor").value("12"))
|
||||||
.andExpect(jsonPath("$.data.hasMore").value(true));
|
.andExpect(jsonPath("$.data.hasMore").value(true));
|
||||||
|
|
||||||
verify(catalogService).feed("ANIME", null, 20);
|
verify(catalogService).feed("ANIME", 7L, null, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
given(commentService.countByDrama(7L)).willReturn(9L);
|
given(commentService.countByDrama(7L)).willReturn(9L);
|
||||||
given(catalogService.feed("ANIME", null, 20))
|
given(catalogService.feed("ANIME", null, null, 20))
|
||||||
.willReturn(new CursorResult<>(List.of(), null, false));
|
.willReturn(new CursorResult<>(List.of(), null, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-10
@@ -118,7 +118,7 @@ class AppCatalogServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void should_filter_feed_by_drama_type_and_expose_it_on_card() {
|
void should_filter_feed_by_drama_type_and_expose_it_on_card() {
|
||||||
Drama anime = drama(1L, "ANIME");
|
Drama anime = drama(1L, "ANIME");
|
||||||
given(dramaService.listPublishedFeed(List.of("ANIME"), null, 21))
|
given(dramaService.listPublishedFeed(List.of("ANIME"), null, null, 21))
|
||||||
.willReturn(List.of(anime));
|
.willReturn(List.of(anime));
|
||||||
given(dramaCategoryService.listBasic()).willReturn(List.of());
|
given(dramaCategoryService.listBasic()).willReturn(List.of());
|
||||||
DramaEpisode preview = episode(11L, 1L, 45L);
|
DramaEpisode preview = episode(11L, 1L, 45L);
|
||||||
@@ -126,41 +126,59 @@ class AppCatalogServiceTest {
|
|||||||
.willReturn(Map.of(1L, preview));
|
.willReturn(Map.of(1L, preview));
|
||||||
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 3L));
|
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 3L));
|
||||||
|
|
||||||
CursorResult<AppDramaCardVO> result = catalogService.feed("ANIME", null, null);
|
CursorResult<AppDramaCardVO> result = catalogService.feed(
|
||||||
|
"ANIME", null, null, null);
|
||||||
|
|
||||||
assertThat(result.getList()).hasSize(1);
|
assertThat(result.getList()).hasSize(1);
|
||||||
assertThat(result.getList().get(0).getDramaType()).isEqualTo("ANIME");
|
assertThat(result.getList().get(0).getDramaType()).isEqualTo("ANIME");
|
||||||
assertThat(result.getList().get(0).getCommentCount()).isEqualTo(3L);
|
assertThat(result.getList().get(0).getCommentCount()).isEqualTo(3L);
|
||||||
assertThat(result.getList().get(0).getLikeCount()).isEqualTo(45L);
|
assertThat(result.getList().get(0).getLikeCount()).isEqualTo(45L);
|
||||||
assertThat(result.isHasMore()).isFalse();
|
assertThat(result.isHasMore()).isFalse();
|
||||||
verify(dramaService).listPublishedFeed(List.of("ANIME"), null, 21);
|
verify(dramaService).listPublishedFeed(List.of("ANIME"), null, null, 21);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_parse_multiple_feed_types() {
|
void should_parse_multiple_feed_types() {
|
||||||
given(dramaService.listPublishedFeed(List.of("ANIME", "LIVE"), null, 11))
|
given(dramaService.listPublishedFeed(
|
||||||
|
List.of("ANIME", "LIVE"), null, null, 11))
|
||||||
.willReturn(List.of());
|
.willReturn(List.of());
|
||||||
|
|
||||||
CursorResult<AppDramaCardVO> result = catalogService.feed("ANIME,LIVE", null, 10);
|
CursorResult<AppDramaCardVO> result = catalogService.feed(
|
||||||
|
"ANIME,LIVE", null, null, 10);
|
||||||
|
|
||||||
assertThat(result.getList()).isEmpty();
|
assertThat(result.getList()).isEmpty();
|
||||||
verify(dramaService).listPublishedFeed(List.of("ANIME", "LIVE"), null, 11);
|
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
|
@Test
|
||||||
void should_page_feed_with_cursor_without_duplicates() {
|
void should_page_feed_with_cursor_without_duplicates() {
|
||||||
Drama first = drama(1L, "ANIME");
|
Drama first = drama(1L, "ANIME");
|
||||||
Drama second = drama(2L, "LIVE");
|
Drama second = drama(2L, "LIVE");
|
||||||
given(dramaService.listPublishedFeed(List.of(), null, 2))
|
given(dramaService.listPublishedFeed(List.of(), null, null, 2))
|
||||||
.willReturn(List.of(first, second));
|
.willReturn(List.of(first, second));
|
||||||
given(dramaService.listPublishedFeed(List.of(), 1L, 2))
|
given(dramaService.listPublishedFeed(List.of(), null, 1L, 2))
|
||||||
.willReturn(List.of(second));
|
.willReturn(List.of(second));
|
||||||
given(dramaCategoryService.listBasic()).willReturn(List.of());
|
given(dramaCategoryService.listBasic()).willReturn(List.of());
|
||||||
given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of());
|
given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of());
|
||||||
given(commentService.countByEpisodes(any())).willReturn(Map.of());
|
given(commentService.countByEpisodes(any())).willReturn(Map.of());
|
||||||
|
|
||||||
CursorResult<AppDramaCardVO> firstPage = catalogService.feed(null, null, 1);
|
CursorResult<AppDramaCardVO> firstPage = catalogService.feed(
|
||||||
CursorResult<AppDramaCardVO> secondPage = catalogService.feed(null, firstPage.getNextCursor(), 1);
|
null, null, null, 1);
|
||||||
|
CursorResult<AppDramaCardVO> secondPage = catalogService.feed(
|
||||||
|
null, null, firstPage.getNextCursor(), 1);
|
||||||
|
|
||||||
assertThat(firstPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(1L);
|
assertThat(firstPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(1L);
|
||||||
assertThat(firstPage.getNextCursor()).isEqualTo(1L);
|
assertThat(firstPage.getNextCursor()).isEqualTo(1L);
|
||||||
|
|||||||
+64
-2
@@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
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.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
/** K88 App 推荐流的类型过滤与三字段 keyset 游标。 */
|
/** K88 App 推荐流的类型过滤与三字段 keyset 游标。 */
|
||||||
@@ -60,7 +61,7 @@ class DramaServiceFeedTest {
|
|||||||
given(dramaMapper.selectList(any())).willReturn(List.of());
|
given(dramaMapper.selectList(any())).willReturn(List.of());
|
||||||
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
|
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||||
|
|
||||||
dramaService.listPublishedFeed(List.of("ANIME"), 9L, 5);
|
dramaService.listPublishedFeed(List.of("ANIME"), null, 9L, 5);
|
||||||
|
|
||||||
verify(dramaMapper).selectList(captor.capture());
|
verify(dramaMapper).selectList(captor.capture());
|
||||||
QueryWrapper<Drama> query = captor.getValue();
|
QueryWrapper<Drama> query = captor.getValue();
|
||||||
@@ -86,7 +87,7 @@ class DramaServiceFeedTest {
|
|||||||
given(dramaMapper.selectList(any())).willReturn(List.of());
|
given(dramaMapper.selectList(any())).willReturn(List.of());
|
||||||
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
|
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||||
|
|
||||||
dramaService.listPublishedFeed(List.of("LIVE"), 9L, 5);
|
dramaService.listPublishedFeed(List.of("LIVE"), null, 9L, 5);
|
||||||
|
|
||||||
verify(dramaMapper).selectList(captor.capture());
|
verify(dramaMapper).selectList(captor.capture());
|
||||||
assertThat(captor.getValue().getSqlSegment())
|
assertThat(captor.getValue().getSqlSegment())
|
||||||
@@ -94,4 +95,65 @@ class DramaServiceFeedTest {
|
|||||||
.contains("view_count")
|
.contains("view_count")
|
||||||
.contains("id");
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -134,8 +134,8 @@ class DramaServiceMultiTagTest {
|
|||||||
given(dramaMapper.selectList(any())).willReturn(List.of());
|
given(dramaMapper.selectList(any())).willReturn(List.of());
|
||||||
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
|
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||||
|
|
||||||
dramaService.listPublishedFeed(List.of("ANIME"), 9L, 5);
|
dramaService.listPublishedFeed(List.of("ANIME"), null, 9L, 5);
|
||||||
dramaService.listPublishedFeed(List.of("LIVE"), 9L, 5);
|
dramaService.listPublishedFeed(List.of("LIVE"), null, 9L, 5);
|
||||||
|
|
||||||
verify(dramaMapper, times(2)).selectList(captor.capture());
|
verify(dramaMapper, times(2)).selectList(captor.capture());
|
||||||
assertThat(captor.getAllValues()).allSatisfy(query ->
|
assertThat(captor.getAllValues()).allSatisfy(query ->
|
||||||
|
|||||||
@@ -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>
|
||||||
+13
@@ -99,6 +99,19 @@ final class DramaMultiTagSupport {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
static List<String> parseDramaTypes(Drama drama) {
|
||||||
List<String> values = parseStringTokens(drama.getDramaTypes()).stream()
|
List<String> values = parseStringTokens(drama.getDramaTypes()).stream()
|
||||||
.map(DramaMultiTagSupport::normalizeDramaType)
|
.map(DramaMultiTagSupport::normalizeDramaType)
|
||||||
|
|||||||
+54
-17
@@ -42,6 +42,7 @@ 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<String> dramaTypes = DramaMultiTagSupport.normalizeDramaTypes(req.getDramaTypes());
|
||||||
@@ -92,7 +93,7 @@ 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);
|
||||||
@@ -109,7 +110,7 @@ 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) {
|
if (req.getFavoriteCount() != null) {
|
||||||
drama.setFavoriteCount(nonNegative(req.getFavoriteCount(), "收藏数"));
|
drama.setFavoriteCount(nonNegative(req.getFavoriteCount(), "收藏数"));
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,7 @@ public class DramaService {
|
|||||||
drama.setViewCount(nonNegative(req.getHeat(), "热度"));
|
drama.setViewCount(nonNegative(req.getHeat(), "热度"));
|
||||||
}
|
}
|
||||||
dramaMapper.updateById(drama);
|
dramaMapper.updateById(drama);
|
||||||
|
episodePriceSync.syncByDramaRule(drama);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -237,20 +239,26 @@ public class DramaService {
|
|||||||
* App 推荐流:按推荐权重、热度、ID 倒序,并支持内容类型多选。
|
* App 推荐流:按推荐权重、热度、ID 倒序,并支持内容类型多选。
|
||||||
* cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。
|
* cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。
|
||||||
*/
|
*/
|
||||||
public List<Drama> listPublishedFeed(List<String> dramaTypes, Long cursor, int limit) {
|
public List<Drama> listPublishedFeed(
|
||||||
|
List<String> dramaTypes, Long categoryId, Long cursor, int limit) {
|
||||||
List<String> normalizedTypes = DramaMultiTagSupport.normalizeDramaTypes(dramaTypes);
|
List<String> normalizedTypes = DramaMultiTagSupport.normalizeDramaTypes(dramaTypes);
|
||||||
QueryWrapper<Drama> query = new QueryWrapper<>();
|
QueryWrapper<Drama> query = new QueryWrapper<>();
|
||||||
query.eq("status", ContentStatus.PUBLISHED.getCode());
|
query.eq("status", ContentStatus.PUBLISHED.getCode());
|
||||||
if (!normalizedTypes.isEmpty()) {
|
if (!normalizedTypes.isEmpty()) {
|
||||||
DramaMultiTagSupport.applyDramaTypeTokens(query, normalizedTypes);
|
DramaMultiTagSupport.applyDramaTypeTokens(query, normalizedTypes);
|
||||||
}
|
}
|
||||||
|
if (categoryId != null) {
|
||||||
|
DramaMultiTagSupport.applyCategoryIdTokens(query, List.of(categoryId));
|
||||||
|
}
|
||||||
if (cursor != null) {
|
if (cursor != null) {
|
||||||
Drama marker = dramaMapper.selectById(cursor);
|
Drama marker = dramaMapper.selectById(cursor);
|
||||||
if (marker == null
|
if (marker == null
|
||||||
|| !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus())
|
|| !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus())
|
||||||
|| (!normalizedTypes.isEmpty()
|
|| (!normalizedTypes.isEmpty()
|
||||||
&& DramaMultiTagSupport.parseDramaTypes(marker).stream()
|
&& DramaMultiTagSupport.parseDramaTypes(marker).stream()
|
||||||
.noneMatch(normalizedTypes::contains))) {
|
.noneMatch(normalizedTypes::contains))
|
||||||
|
|| (categoryId != null
|
||||||
|
&& !DramaMultiTagSupport.parseCategoryIds(marker).contains(categoryId))) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
applyFeedCursor(query, marker);
|
applyFeedCursor(query, marker);
|
||||||
@@ -339,21 +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) {
|
||||||
DramaMultiTagSupport.applyCategoryIds(drama, req);
|
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) {
|
||||||
DramaMultiTagSupport.applyDramaTypes(drama, req);
|
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
|
||||||
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
|
}
|
||||||
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
|
if (isNew || req.getDramaTypes() != null || req.getDramaType() != null) {
|
||||||
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
|
DramaMultiTagSupport.applyDramaTypes(drama, req);
|
||||||
drama.setSort(req.getSort() == null ? 0 : req.getSort());
|
}
|
||||||
|
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() {
|
||||||
|
|||||||
+74
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-5
@@ -2,10 +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.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;
|
||||||
@@ -14,6 +18,7 @@ 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.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -26,19 +31,39 @@ import java.util.Objects;
|
|||||||
@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()
|
||||||
|
|||||||
+56
-45
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+210
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user