Author SHA1 Message Date
Zhou Weidong 07754ff4e4 docs(plans): 登记剧集列表分页合并提交作者偏差 2026-10-22 09:18:42 +08:00
Zhou Weidong 4159af1732 merge: 剧集列表接口支持分页 2026-10-21 23:09:42 +08:00
Zhou Weidong 0143fa036c feat(admin): 剧集列表接口支持分页 2026-10-21 23:09:30 +08:00
Zhou Weidong 5227762aeb docs(plans): 短剧价格同步幂等纠偏留痕 2026-10-21 22:56:36 +08:00
jianghaifeng a4317bb6f1 merge: 短剧保存时始终同步分集价格 2026-10-21 22:49:43 +08:00
Vicky 9adf8c5630 fix(content): 短剧保存时始终同步分集价格 2026-10-21 22:48:09 +08:00
Vicky 8bb7f63c62 test(content): 覆盖免费集数未变但分集漂移的修复场景 2026-10-21 22:48:09 +08:00
Zhou Weidong b8b3fd867b docs(plans): 短剧免费集数同步分集留痕 2026-10-21 22:34:21 +08:00
Zhou Weidong e92ec118fe merge: 短剧保存免费集数同步分集免费状态 2026-10-21 22:34:21 +08:00
Zhou Weidong 4c4b53cd69 fix(content): 短剧保存免费集数时同步分集免费状态 2026-10-21 22:34:21 +08:00
Zhou Weidong d7f226659c test(content): 覆盖短剧免费集数同步分集价格 2026-10-21 22:34:21 +08:00
Zhou Weidong 979e6ebea3 docs(plans): 登记 K100 提交 AI 痕迹已知偏差 2026-10-21 22:10:35 +08:00
Zhou WeidongandCursor 3a581163aa docs(plans): K100 剧集批量上传计划与执行留痕
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:54 +08:00
Zhou Weidong 97c2153c2e merge: 合并 K98 客户端修复留痕 2026-10-21 19:27:21 +08:00
Zhou Weidong 27a15dd1b4 docs: 记录 K98 登录键盘避让 2026-10-21 19:26:55 +08:00
Zhou Weidong cf37d6c408 Merge remote-tracking branch 'origin/develop' into develop 2026-10-21 18:52:27 +08:00
Zhou Weidong c9b8ec6805 fix(content): 短剧编辑未传字段不再覆盖存量数据 2026-10-21 18:52:11 +08:00
Zhou Weidong 31d88b634a merge: 合并 K97 客户端修复留痕 2026-10-21 18:47:03 +08:00
Zhou Weidong 5704f80929 docs: 记录 K97 iOS 评论键盘避让 2026-10-21 18:46:50 +08:00
16 changed files with 1421 additions and 162 deletions

No files matched your search

@@ -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,64 @@
# K97 修复 iOS 评论输入弹窗键盘避让
## 目标
- iOS 评论输入浮层实时跟随 `MediaQuery.viewInsets.bottom`,确保输入框与发表按钮
位于键盘上方。
- Android 保留既有 MethodChannel + 80ms 防抖 pinned 策略,不恢复底部 SafeArea,
避免键盘收起时 48px 抖动回归。
- 聚焦、`TextInput.show` 兜底和发表成功提示逻辑不变。
## 分支
- ReelShort:`fix/k97-ios-comment-keyboard`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. 平台区分
- `_isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android`。
- Android:
- 继续监听 `KeyboardInsetNotifier`。
- 继续使用 80ms 防抖后的 `_pinnedKeyboardInset`。
- iOS / 其他平台:
- 不读取 pinned/channel 值。
- `build` 中直接读取 `MediaQuery.viewInsetsOf(context).bottom`。
- inset 超过屏幕高度 60% 时按 0 处理,负值归零。
## B. 面板布局
- 面板主体使用 `AnimatedPadding`,只设置 bottom:
- `duration: 160ms`
- `curve: Curves.easeOutCubic`
- Android 使用 pinned 值。
- iOS / 其他平台使用实时 viewInsets。
- Material 仍铺到屏幕底部,用于键盘收起后的实色填充。
- Android 不包裹 SafeArea。
- iOS / 其他平台包裹 `SafeArea(top: false)`,保留 home indicator 底部避让。
## C. 测试
- iOS + `viewInsets.bottom=300`:
- 键盘避让 AnimatedPadding 的 bottom 为 300。
- `comment-composer-post` 底边位于屏幕高度减 300 之上。
- iOS + `viewInsets=0`:padding 为 0。
- Android 既有 pinned/channel、焦点、弹出、关闭、发布用例保持通过。
- 所有平台 override 测试结束恢复
`debugDefaultTargetPlatformOverride = null`。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
- `flutter build ios --simulator --no-codesign`
## Git
- ReelShort 提交:`fix(comment): 修复 iOS 评论输入弹窗键盘避让`
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,73 @@
# K98 修复登录与手机号登录键盘遮挡
## 目标
- 登录/注册页在邮箱或密码框聚焦后,自动滚动到键盘上方。
- 手机号登录弹窗的手机号/验证码框同样保持可见。
- Android `adjustNothing` 下通过既有 MethodChannel 获取键盘高度,iOS 使用实时
`MediaQuery.viewInsets`。
## 分支
- ReelShort:`fix/k98-auth-keyboard`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. 有效键盘高度
在 `keyboard_inset.dart` 增加:
```dart
double effectiveKeyboardInset(
BuildContext context,
KeyboardInsetNotifier notifier, {
double maxRatio = 0.6,
});
```
- 取 MediaQuery viewInsets 与 Android channel 逻辑像素值的最大值。
- Web/非 Android 不读取 channel。
- 超过屏幕高度 60% 时按 0 处理,负值/非法值归零。
## B. AuthPage
1. `initState` 启动 `KeyboardInsetNotifier`,监听并用 setState 刷新。
2. `SingleChildScrollView` 增加 `ScrollController`。
3. 底部 padding 使用固定底部留白 + effectiveKeyboardInset。
4. 邮箱/密码 FocusNode 监听焦点;聚焦或键盘高度变化后:
- 通过 FocusNode.context 的 RenderBox 获取字段全局底边。
- 与屏幕高度 - 键盘高度 - 24 比较。
- 被遮挡时用 200ms easeOutCubic animateTo 补足滚动。
5. `resizeToAvoidBottomInset` 保持 false。
## C. PhoneAuthSheet
1. 接入 KeyboardInsetNotifier 和 ScrollController。
2. 底部 padding 改用 effectiveKeyboardInset。
3. 手机号/验证码 FocusNode 增加与 AuthPage 相同的焦点/键盘变化滚动校正。
4. 保持现有 SingleChildScrollView、弹窗和返回键行为。
## D. 测试
- iOS viewInsets=300:登录密码框、注册密码框均在键盘上方。
- Android platform + MethodChannel 键盘高度:密码框仍在键盘上方。
- iOS:手机号弹窗验证码框在键盘上方。
- 所有平台 override 测试返回前显式恢复
`debugDefaultTargetPlatformOverride = null`。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
## Git
- ReelShort 提交:`fix(auth): 登录注册与手机号登录键盘避让`
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不改评论输入框已验证的 Android pinned 策略。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,30 @@
# 2026-10-21 | 短剧保存免费集数同步分集
## 目标
后台短剧编辑改 `freeEpisodes` / `unlockCoin` 后,同步 `drama_episode.is_free` / `unlock_coin`,与定价页 `PriceService.save()` 规则一致。
## 方案
1. 抽出 `EpisodePriceSync.syncByDramaRule(dramaId, freeEpisodes, unlockCoin)`,供 `DramaService.update` 与 `PriceService.save` 共用。
2. `DramaService.update` 在 `updateById` 后,仅当免费集数或解锁金币相对更新前有变化时调用同步。
3. TDD:先写 `DramaServiceTest` 复现失败,再改实现转绿。
## 范围
- 仅 `fruitshorts-module-content`
- 不改 Controller / VO / 契约 / 前端
## 门禁
```bash
mvn -pl fruitshorts-module-content test -Dtest=DramaServiceTest
mvn -pl fruitshorts-module-content test
```
## 分支 / 提交
- `feature/drama-free-episodes-sync` ← `develop`
- `test(content): 覆盖短剧免费集数同步分集价格`
- `fix(content): 短剧保存免费集数时同步分集免费状态`
- `merge --no-ff` 回 develop 并 push
@@ -0,0 +1,24 @@
# 2026-10-21 | 短剧保存始终同步分集价格(幂等纠偏)
## 背景
承接 K104。短剧 `free_episodes=9` 重存后,分集 5~9 仍收费。
## 根因
`DramaService.update()` 仅在 `freeEpisodes` / `unlockCoin` 相对更新前有变化时调用 `EpisodePriceSync`。历史数据已漂移时,同值重存不触发同步。
## 方案
1. 测试:覆盖「规则字段未变、分集已漂移」仍纠正。
2. `DramaService.update()`:每次保存都调用 `EpisodePriceSync.syncByDramaRule(drama)`。
3. `EpisodePriceSync`:仅对有差异的行走 `updateById`(已具备,保持)。
## 范围
只改 `fruitshorts-module-content` 的 `DramaService` / `EpisodePriceSync` / `DramaServiceTest`。不改 Controller / VO / 契约 / 前端。
## 提交
1. `test(content): 覆盖免费集数未变但分集漂移的修复场景`
2. `fix(content): 短剧保存时始终同步分集价格`
+74 -76
View File
@@ -1,97 +1,95 @@
# INBOX # INBOX
## K85 收藏数 / 评论数实时更新 > 本轮任务:K100 后台剧集多选上传(分片 + 断点续传 + 进度展示)。**[已处理]**
> 开工前置:先读 `AGENTS.md`、`CODING_STANDARDS.md`、`GIT_WORKFLOW.md` 本文件,再读
> `ReelShortWeb/src/utils/uploader.ts`、`src/api/admin/media.ts`、`src/views/contentManagement/EpisodeManagementView.vue`。
### 现象 ### Codex 回执(K100)
首页 feed 右侧互动栏已经能显示数字,但**收藏数、评论数点完不变**: - ReelShortWeb 分支:`feature/episode-batch-upload` → `--no-ff` 合入 `develop` 并 push
- 提交:`feat(admin): 剧集多选分片上传与断点续传`
- 门禁:`pnpm lint:eslint` 零错误;`pnpm build:dev` 成功
- 未验证:PM 手工多选/暂停/刷新续传/批量建集(8080+3005)
- 风险:续传需重选相同本地文件;暂停不 abort 进行中分片
- 收藏(点星标)后数字不动,杀掉 App 重进又回到旧值 ## 现状(已勘察,不要重复排查)
- 发表评论后评论数不动
### 已定位的根因(勘察结论,不要再重复排查) - 后端分片上传已具备且不改:`POST /admin/media/upload/init` → `POST /admin/media/upload/chunk` →
`POST /admin/media/upload/complete`;断点查询 `GET /admin/media/upload/{uploadId}`
返回 `receivedChunks/totalChunks/chunkSize/percent/status/assetId/url`。
- 前端已有 `src/utils/uploader.ts`:`uploadFile()` 分片并发 3、`resumeUpload()` 按服务端已收片数续传、
`probeVideoDuration()` 探测时长。**这两个函数已可用,本次在其上做批量队列 UI**。
- 当前剧集管理 `EpisodeManagementView.vue` 只有一个 `el-upload` 单文件入口(`http-request="uploadVideo"`),
**不支持多选、没有进度条、没有断点续传入口**,`onSession` 也没有落盘。
**后端(FruitServer)** ## 目标
1. `drama.favorite_count` 从建剧时置 0 之后**没有任何地方自增/自减**: 在剧集管理页提供「批量上传」:一次选多个视频 → 队列分片上传 → 每个文件独立进度 → 失败可重试 →
- `FavoriteService.toggle()` 只写/删 `user_favorite`,注释写着「计数走异步聚合」, 刷新/重进页面能续传 → 上传完成后批量创建分集。
但仓库里**没有这个聚合任务**,所以收藏数永远是 0
- `remove()` / `removeBatch()` 也不维护计数
- `LikeService` 是同步维护 `drama.like_count` 的(`DramaService.adjustLikeCount`),收藏应对齐这个做法
2. 数据库现状(本机 PG `fruitshorts`):`drama.favorite_count` 全为 0,
而 `user_favorite` 实际有记录(剧 1 有 4 条、剧 2 有 2 条、剧 5 有 5 条……)→ 需要回填
3. 评论数没有单个剧集的计数接口:`GET /app/comments` 是游标翻页,不返回总数
**客户端(ReelShort)** ## A. 上传队列(新建 `src/views/contentManagement/upload/useUploadQueue.ts`)
4. 首页 feed 的收藏回调(`player_view.dart` 约 468 行)是内联实现,**没有调用已有的 1. 输入:`File[]` + `dramaId` + 可选 `bizType`
`_applyFavoriteDelta`**;只有全屏播放页的 `_toggleSave`(`player_view_actions.dart`)调了 2. 每项状态机:`waiting → uploading → merging → done / failed / paused`;对外暴露 `items`、`overallPercent`、
→ 首页收藏后数字不变 `start()`、`pause()`、`retry(id)`、`retryAll()`、`remove(id)`、`clear()`
5. 两处 rail 的 `commentCount: drama.commentCount` 是静态值,评论面板关闭后不刷新 3. 队列并发:同时最多 2 个文件上传(文件内分片并发沿用 `uploadFile` 的 3)
6. 详情页 `DramaStatsCard` 用 `drama.favoriteCount`,`drama_detail_logic.toggleFavorite()` 4. 进度:文件内 `onProgress` 回调更新单项百分比;总进度 = 各文件字节加权,不是简单平均
切换后不更新显示 5. 失败隔离:单文件失败不影响其它文件,错误原因记在该项 `error`
6. 暂停语义(本轮简化,不做 abort):暂停后不再派发新任务,已在传的分片让其完成
7. 上传完成后 `completeUploadApi` 拿到 `assetId/url/durationSec` 写回该项
### 任务 ## B. 断点续传(关键)
#### A. FruitServer(后端) 1. 上传开始(拿到 `uploadId`)即写入 `localStorage`:键 `reel_upload_sessions`,
值数组项含 `{ uploadId, fileName, fileSize, lastModified, dramaId, bizType, createdAt }`
2. 页面进入时读取该键,过滤 `dramaId` 匹配且 24 小时内的会话,弹出提示「发现 N 个未完成上传,是否续传」
3. 续传前用 `file.name + size + lastModified` 匹配本地重新选择的文件;
**文件匹配不上(换机器/改名)则不自动续传,提示后由用户手动重选**
4. 续传走现有 `resumeUpload(file, uploadId)`;`GET /admin/media/upload/{uploadId}` 返回 404 或已 completed 的会话要从 localStorage 清理
5. 上传成功、用户移除、用户放弃续传三种情况都要清理对应记录
1. `fruitshorts-module-content/.../service/DramaService.java` ## C. 批量创建分集
- 新增 `adjustFavoriteCount(Long dramaId, int delta)`:SQL 自增,
参考现有 `adjustLikeCount`,减号用 `GREATEST(favorite_count - 1, 0)`
2. `fruitshorts-module-playback/.../service/FavoriteService.java`
- 类注释里「计数走异步聚合」改成与 `LikeService` 一致的同步维护说明
- `toggle()`:插入关系后 `adjustFavoriteCount(+1)`;`physicalDelete` 返回行数 > 0 时 `-1`
- `remove()`:删除成功(返回行数 > 0)时 `-1`
- `removeBatch()`:按 dramaId 逐个 `physicalDelete` 并按其返回值 `-1`
(保持与前端「取消收藏」一致的物理删除语义,避免逻辑删除挡住再次收藏)
3. `fruitshorts-app-api/.../controller/AppCommentController.java`
- 新增 `GET /app/comments/count?dramaId={id}` → `Result<Long>`,
内部 `commentService.countByDrama(dramaId)`(匿名可访问,与列表一致)
4. `sql/` 下加一次性回填脚本 `sql/backfill_favorite_count.sql`
- `UPDATE drama d SET favorite_count = (SELECT count(*) FROM user_favorite f WHERE f.drama_id = d.id AND f.deleted = 0);`
- 只放脚本,不要在应用启动时自动执行
5. 测试
- `FavoriteService` 单测:收藏 +1、取消 -1、不会减到负数、removeBatch 多剧集各自扣减
- `AppCommentController` 契约测试:`/app/comments/count` 返回数字
#### B. ReelShort(客户端) 1. 上传完成列表提供「批量创建分集」按钮
2. 默认值:按文件名自然排序(去扩展名后再比较,`第2集` 排在 `第10集` 之前);
集号 = 该剧当前最大集号 + 1 起顺延;标题取文件名去扩展名;是否免费沿用现有新建默认值
3. 弹窗内每行可改:集号、标题、是否免费;冲突集号在提交前本地校验并标红
4. 提交:逐条调用现有 `createEpisodeApi`(`src/api/admin/episode.ts`),
逐条显示成功/失败,失败的保留在弹窗内可重试;全部结束后刷新剧集列表
5. **不新增后端接口**,契约不动
1. `lib/core/data/remote/comment_api.dart` ## D. 组件拆分(红线:单文件 ≤ 500 行)
- 新增 `Future<int> count(String dramaId)`,解析 `data`(Long 可能是字符串,用现有 `jsonToInt`)
2. `lib/core/data/repositories/comment_repository.dart`
- 接口与 `HttpCommentRepository` 同步新增 `count`
3. `lib/pages/player/player_view.dart`
- 首页 feed 的 `onSave` 改为复用 `_toggleSave(context, drama)`(内部已有 `_applyFavoriteDelta`)
- 新增 `Map<String, int> _commentCounts`
- 新增 `_openComments(context, drama)`:`await openCommentsWithAuth(...)`,
结束后 `await commentRepository.count(drama.id)` 并 `setState` 写入 `_commentCounts`;
失败静默(保留旧值,不弹错)
- 首页 rail 与播放页 rail 的 `commentCount` 都改成
`_commentCounts[drama.id] ?? drama.commentCount`;`onComments` 都走 `_openComments`
4. `lib/pages/drama_detail/drama_detail_logic.dart`(收藏数同步)
- 记录本地收藏增量,把调整后的收藏数提供给 `DramaStatsCard`
(不要改 `Drama` 模型;用 getter 或在 view 层叠加 delta)
- 只在切换成功且状态确实变化时 ±1,失败回滚
5. 测试
- `CommentApi.count` 的解析测试(数字 / 字符串两种返回)
- 收藏增量逻辑(可抽纯函数或独立小对象)单测:+1、-1、失败不变
### 验收 - `src/views/contentManagement/upload/BatchUploadDialog.vue`:多选、队列列表、总进度、暂停/重试/移除
- `src/views/contentManagement/upload/UploadTaskRow.vue`:单行(文件名、大小、进度条、状态、操作)
- `src/views/contentManagement/upload/CreateEpisodesDialog.vue`:批量建分集表单
- `src/views/contentManagement/upload/useUploadQueue.ts`:队列逻辑(纯逻辑单元,不写 UI)
- `EpisodeManagementView.vue` 只加一个「批量上传」入口按钮,并保持原单文件上传入口可用
- 后端:`mvn test` 全过;`mvn -pl fruitshorts-app-api -am package -DskipTests` 出包; ## E. 细节要求
本地执行回填脚本后 `GET /app/home/feed` 的 `favoriteCount` 与 `user_favorite` 实际条数一致
- 客户端:`dart analyze lib test` 零告警、`flutter test` 全过
- 功能:收藏后 rail 数字立即 ±1;重进 App 数字不回退;发表评论后关闭面板,评论数 +1(重新拉取)
### Git - 文件类型白名单:`mp4/mov/mkv/webm`(`accept="video/*"` + `before-upload` 二次校验)
- 单文件大小上限从现有常量取值,超限直接标红不入队
- 所有文案走 `src/locales/zh-CN.ts` / `en.ts`,禁止硬编码中文
- 进度与错误展示用项目现有样式变量,不新引入 UI 依赖
- 上传期间:关闭页面前用 `beforeunload` 提示存在未完成上传
- FruitServer:从 `develop` 拉 `fix/favorite-comment-count`, ## F. 门禁
提交 `fix(interaction): 收藏计数同步维护并新增评论数接口`,`--no-ff` 合回 `develop` 并 push
- ReelShort:从 `develop` 拉 `fix/interaction-count-refresh`,
提交 `fix(player): 收藏与评论数实时刷新`,`--no-ff` 合回 `develop` 并 push
- 作者统一 `Zhou Weidong <502333560@qq.com>`;各自仓库 `docs/plans/PROGRESS.md` 追加 K85 留痕
### 约束 - `pnpm lint:eslint` 零错误
- `pnpm build:dev` 构建成功
- 手工验证(PM 会复核):本地起 8080 + 3005,多选 3 个视频 → 三条并行进度 → 中途暂停 → 刷新页面 → 出现续传提示 → 续传完成 → 批量建分集成功
- 全程中文;禁止 `git add -A`;不改 specs 仓库;不动 `pubspec.lock` 以外的无关文件 ## Git
- 完成后停住,等待验收
- ReelShortWeb:从 `develop` 拉 `feature/episode-batch-upload` → 提交
`feat(admin): 剧集多选分片上传与断点续传` → `--no-ff` 合回 `develop` 并 push
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`;源码首行 `// Author: fruit.com.cn`
## 约束
- 先写计划 `docs/plans/2026-10-21-K100-episode-batch-upload.md`,再改代码
- 每跑一个命令,把「命令 + 原始输出 + 解读」追加到 `docs/plans/PROGRESS.md`
- 不改 FruitServer、不改 ReelShort 客户端、不改接口契约
- 禁止 `git add -A`;不要操作真机
- 完成后停住等 PM 验收,回执需贴门禁原始输出
+448
View File
@@ -24292,3 +24292,451 @@ Running Gradle task 'assembleDebug'... 24.8s
解读:FruitServer 全部 14 个 Reactor 模块通过,app-api 99 项测试全过; 解读:FruitServer 全部 14 个 Reactor 模块通过,app-api 99 项测试全过;
ReelShort 静态分析零告警、553 项测试全过、debug APK 构建成功。 ReelShort 静态分析零告警、553 项测试全过、debug APK 构建成功。
`pubspec.lock` 已还原,无依赖变更;未安装或操作真机。 `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 条目)
解读:已推送历史不改写;身份合规靠命令级覆盖防复发;本条仅文档留痕,无代码改动。
@@ -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 = "新建剧集")
@@ -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;
} }
+5
View File
@@ -25,5 +25,10 @@
<groupId>com.fruitshorts</groupId> <groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-system</artifactId> <artifactId>fruitshorts-module-system</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -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)
@@ -345,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() {
@@ -0,0 +1,74 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Objects;
/**
* 按短剧免费集数规则同步分集 {@code is_free} / {@code unlock_coin}。
*
* <p>定价页与短剧编辑共用同一套规则,避免两处口径漂移。</p>
*/
@Component
@RequiredArgsConstructor
public class EpisodePriceSync {
private final DramaEpisodeMapper dramaEpisodeMapper;
/**
* 按短剧当前免费集数与解锁金币同步全部分集。
*
* @return 实际写库的分集数
*/
public int syncByDramaRule(Drama drama) {
int freeEpisodes = drama.getFreeEpisodes() == null ? 0 : Math.max(0, drama.getFreeEpisodes());
int unlockCoin = drama.getUnlockCoin() == null ? 0 : Math.max(0, drama.getUnlockCoin());
return syncByDramaRule(drama.getId(), freeEpisodes, unlockCoin);
}
/**
* 全量同步某剧全部分集价格。
*
* <p>规则:{@code epNo > 0 && epNo <= freeEpisodes} 为免费且 coin=0,
* 否则收费且 coin={@code unlockCoin}。</p>
*
* @return 实际写库的分集数
*/
public int syncByDramaRule(Long dramaId, int freeEpisodes, int unlockCoin) {
List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.orderByAsc(DramaEpisode::getEpNo));
int changed = 0;
for (DramaEpisode episode : episodes) {
int epNo = episode.getEpNo() == null ? 0 : episode.getEpNo();
int isFree = resolveIsFree(epNo, freeEpisodes);
int coin = resolveUnlockCoin(isFree, unlockCoin);
if (!Objects.equals(episode.getIsFree(), isFree)
|| !Objects.equals(episode.getUnlockCoin(), coin)) {
episode.setIsFree(isFree);
episode.setUnlockCoin(coin);
dramaEpisodeMapper.updateById(episode);
changed++;
}
}
return changed;
}
/** 集号落在免费区间内则为免费。 */
public static int resolveIsFree(int epNo, int freeEpisodes) {
return epNo > 0 && epNo <= freeEpisodes ? 1 : 0;
}
/** 免费集 coin 置 0,收费集用短剧解锁金币。 */
public static int resolveUnlockCoin(int isFree, int unlockCoin) {
return isFree == 1 ? 0 : unlockCoin;
}
}
@@ -2,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()
@@ -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;
}
} }
@@ -0,0 +1,210 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* 短剧保存时免费集数 / 解锁金币须同步分集价格。
*/
class DramaServiceTest {
private static final long DRAMA_ID = 1001L;
private static final int UNLOCK_COIN = 60;
private DramaMapper dramaMapper;
private DramaEpisodeMapper dramaEpisodeMapper;
private DramaService dramaService;
private List<DramaEpisode> episodes;
@BeforeAll
static void initTableInfoCache() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, Drama.class);
TableInfoHelper.initTableInfo(assistant, DramaEpisode.class);
}
@BeforeEach
void setUp() {
dramaMapper = mock(DramaMapper.class);
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
DramaReviewRecordMapper reviewRecordMapper = mock(DramaReviewRecordMapper.class);
DramaCategoryService categoryService = mock(DramaCategoryService.class);
EpisodePriceSync episodePriceSync = new EpisodePriceSync(dramaEpisodeMapper);
dramaService = new DramaService(
dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService, episodePriceSync);
episodes = new ArrayList<>();
given(dramaEpisodeMapper.selectList(any())).willAnswer(invocation -> List.copyOf(episodes));
given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> {
DramaEpisode updated = invocation.getArgument(0);
for (int i = 0; i < episodes.size(); i++) {
if (episodes.get(i).getId().equals(updated.getId())) {
episodes.set(i, updated);
break;
}
}
return 1;
});
}
@Test
void should_mark_episodes_4_and_5_free_when_free_episodes_increase_from_3_to_5() {
givenExistingDrama(3, UNLOCK_COIN);
seedEpisodesAlignedToFree(3, 6);
dramaService.update(DRAMA_ID, saveReq(5, UNLOCK_COIN));
Map<Integer, DramaEpisode> byEpNo = episodeMap();
assertThat(byEpNo.get(4).getIsFree()).isEqualTo(1);
assertThat(byEpNo.get(4).getUnlockCoin()).isZero();
assertThat(byEpNo.get(5).getIsFree()).isEqualTo(1);
assertThat(byEpNo.get(5).getUnlockCoin()).isZero();
assertThat(byEpNo.get(6).getIsFree()).isZero();
assertThat(byEpNo.get(6).getUnlockCoin()).isEqualTo(UNLOCK_COIN);
verify(dramaEpisodeMapper, times(2)).updateById(any(DramaEpisode.class));
}
@Test
void should_mark_episodes_3_to_5_paid_when_free_episodes_decrease_from_5_to_2() {
givenExistingDrama(5, UNLOCK_COIN);
seedEpisodesAlignedToFree(5, 5);
dramaService.update(DRAMA_ID, saveReq(2, UNLOCK_COIN));
Map<Integer, DramaEpisode> byEpNo = episodeMap();
assertThat(byEpNo.get(3).getIsFree()).isZero();
assertThat(byEpNo.get(3).getUnlockCoin()).isEqualTo(UNLOCK_COIN);
assertThat(byEpNo.get(4).getIsFree()).isZero();
assertThat(byEpNo.get(4).getUnlockCoin()).isEqualTo(UNLOCK_COIN);
assertThat(byEpNo.get(5).getIsFree()).isZero();
assertThat(byEpNo.get(5).getUnlockCoin()).isEqualTo(UNLOCK_COIN);
verify(dramaEpisodeMapper, times(3)).updateById(any(DramaEpisode.class));
}
@Test
void should_skip_episode_updates_when_free_episodes_unchanged() {
givenExistingDrama(3, UNLOCK_COIN);
seedEpisodesAlignedToFree(3, 4);
AtomicInteger episodeUpdates = new AtomicInteger();
given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> {
episodeUpdates.incrementAndGet();
return 1;
});
dramaService.update(DRAMA_ID, saveReq(3, UNLOCK_COIN));
assertThat(episodeUpdates.get()).isZero();
verify(dramaEpisodeMapper, times(1)).selectList(any());
verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class));
}
/**
* 历史漂移:短剧 freeEpisodes/unlockCoin 已是目标值,但分集 is_free 仍是旧规则。
* 同值重存必须纠偏,不能因「规则字段未变」短路跳过同步。
*/
@Test
void should_correct_drifted_episode_is_free_when_free_episodes_and_unlock_coin_unchanged() {
givenExistingDrama(9, UNLOCK_COIN);
// 规则已是免费 9 集,分集仍按旧规则只免费到第 4 集(5~9 仍收费)
seedEpisodesAlignedToFree(4, 9);
dramaService.update(DRAMA_ID, saveReq(9, UNLOCK_COIN));
Map<Integer, DramaEpisode> byEpNo = episodeMap();
for (int epNo = 1; epNo <= 9; epNo++) {
assertThat(byEpNo.get(epNo).getIsFree()).isEqualTo(1);
assertThat(byEpNo.get(epNo).getUnlockCoin()).isZero();
}
verify(dramaEpisodeMapper, times(5)).updateById(any(DramaEpisode.class));
}
@Test
void should_not_fail_when_drama_has_no_episodes() {
givenExistingDrama(3, UNLOCK_COIN);
episodes.clear();
assertThatCode(() -> dramaService.update(DRAMA_ID, saveReq(5, UNLOCK_COIN)))
.doesNotThrowAnyException();
verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class));
}
private void givenExistingDrama(int freeEpisodes, int unlockCoin) {
Drama drama = new Drama();
drama.setId(DRAMA_ID);
drama.setTitle("测试短剧");
drama.setFreeEpisodes(freeEpisodes);
drama.setUnlockCoin(unlockCoin);
drama.setSubtitle("");
drama.setGenre("");
drama.setSynopsis("");
drama.setCoverUrl("");
drama.setTrailerUrl("");
drama.setLanguage("id-ID");
drama.setIsHot(0);
drama.setSort(0);
given(dramaMapper.selectById(DRAMA_ID)).willReturn(drama);
given(dramaMapper.updateById(any(Drama.class))).willReturn(1);
}
private void seedEpisodesAlignedToFree(int freeEpisodes, int total) {
episodes.clear();
for (int epNo = 1; epNo <= total; epNo++) {
DramaEpisode episode = new DramaEpisode();
episode.setId((long) epNo);
episode.setDramaId(DRAMA_ID);
episode.setEpNo(epNo);
episode.setTitle("第" + epNo + "集");
boolean free = epNo <= freeEpisodes;
episode.setIsFree(free ? 1 : 0);
episode.setUnlockCoin(free ? 0 : UNLOCK_COIN);
episodes.add(episode);
}
}
private DramaSaveReqVO saveReq(int freeEpisodes, int unlockCoin) {
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("测试短剧");
req.setFreeEpisodes(freeEpisodes);
req.setUnlockCoin(unlockCoin);
return req;
}
private Map<Integer, DramaEpisode> episodeMap() {
ArgumentCaptor<DramaEpisode> captor = ArgumentCaptor.forClass(DramaEpisode.class);
verify(dramaEpisodeMapper, org.mockito.Mockito.atLeastOnce()).updateById(captor.capture());
Map<Integer, DramaEpisode> updated = captor.getAllValues().stream()
.collect(Collectors.toMap(DramaEpisode::getEpNo, Function.identity(), (a, b) -> b));
for (DramaEpisode episode : episodes) {
updated.putIfAbsent(episode.getEpNo(), episode);
}
return updated;
}
}
@@ -0,0 +1,151 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.vo.EpisodeRespVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* 剧集后台分页:默认每页 100、上限 200、越界空列表、stable 排序走 DB selectPage。
*/
class EpisodeServicePageTest {
private static final long DRAMA_ID = 2001L;
private DramaEpisodeMapper dramaEpisodeMapper;
private DramaService dramaService;
private EpisodeService episodeService;
@BeforeAll
static void initTableInfoCache() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, DramaEpisode.class);
}
@BeforeEach
void setUp() {
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
dramaService = mock(DramaService.class);
episodeService = new EpisodeService(dramaEpisodeMapper, dramaService);
given(dramaService.getExisting(DRAMA_ID)).willReturn(new Drama());
}
@Test
void should_use_default_page_size_100_when_page_size_omitted() {
stubSelectPage(0L, List.of());
episodeService.pageByDrama(DRAMA_ID, null, null, null);
Page<?> page = capturePageArg();
assertThat(page.getCurrent()).isEqualTo(1L);
assertThat(page.getSize()).isEqualTo(100L);
}
@Test
void should_cap_page_size_at_200() {
stubSelectPage(0L, List.of());
episodeService.pageByDrama(DRAMA_ID, null, 1, 500);
Page<?> page = capturePageArg();
assertThat(page.getSize()).isEqualTo(200L);
}
@Test
void should_return_second_page_records_without_overlap_with_first() {
DramaEpisode first = episode(1L, 1, 1);
DramaEpisode second = episode(2L, 2, 2);
DramaEpisode third = episode(3L, 3, 3);
DramaEpisode fourth = episode(4L, 4, 4);
given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> {
Page<DramaEpisode> page = invocation.getArgument(0);
Page<DramaEpisode> result = new Page<>(page.getCurrent(), page.getSize(), 4L);
if (page.getCurrent() == 1L) {
result.setRecords(List.of(first, second));
} else if (page.getCurrent() == 2L) {
result.setRecords(List.of(third, fourth));
} else {
result.setRecords(List.of());
}
return result;
});
PageResult<EpisodeRespVO> page1 = episodeService.pageByDrama(DRAMA_ID, null, 1, 2);
PageResult<EpisodeRespVO> page2 = episodeService.pageByDrama(DRAMA_ID, null, 2, 2);
assertThat(page1.getTotal()).isEqualTo(4L);
assertThat(page2.getTotal()).isEqualTo(4L);
assertThat(page1.getList()).extracting(EpisodeRespVO::getId).containsExactly(1L, 2L);
assertThat(page2.getList()).extracting(EpisodeRespVO::getId).containsExactly(3L, 4L);
assertThat(page1.getList()).extracting(EpisodeRespVO::getId)
.doesNotContainAnyElementsOf(page2.getList().stream().map(EpisodeRespVO::getId).toList());
}
@Test
void should_return_empty_list_with_correct_total_when_page_out_of_range() {
given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> {
Page<DramaEpisode> page = invocation.getArgument(0);
Page<DramaEpisode> result = new Page<>(page.getCurrent(), page.getSize(), 5L);
result.setRecords(List.of());
return result;
});
PageResult<EpisodeRespVO> page = episodeService.pageByDrama(DRAMA_ID, "published", 99, 100);
assertThat(page.getTotal()).isEqualTo(5L);
assertThat(page.getList()).isEmpty();
verify(dramaEpisodeMapper).selectPage(any(Page.class), any());
}
@SuppressWarnings("unchecked")
private void stubSelectPage(long total, List<DramaEpisode> records) {
given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> {
Page<DramaEpisode> page = invocation.getArgument(0);
Page<DramaEpisode> result = new Page<>(page.getCurrent(), page.getSize(), total);
result.setRecords(records);
return result;
});
}
@SuppressWarnings("unchecked")
private Page<?> capturePageArg() {
ArgumentCaptor<Page> pageCaptor = ArgumentCaptor.forClass(Page.class);
verify(dramaEpisodeMapper).selectPage(pageCaptor.capture(), any());
return pageCaptor.getValue();
}
private DramaEpisode episode(Long id, int sort, int epNo) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setDramaId(DRAMA_ID);
episode.setSort(sort);
episode.setEpNo(epNo);
episode.setTitle("ep-" + epNo);
episode.setStatus("published");
episode.setIsFree(1);
episode.setUnlockCoin(0);
episode.setDurationSec(60);
episode.setPlayCount(0L);
episode.setLikeCount(0L);
return episode;
}
}