Author SHA1 Message Date
Vicky 9adf8c5630 fix(content): 短剧保存时始终同步分集价格 2026-10-21 22:48:09 +08:00
Vicky 8bb7f63c62 test(content): 覆盖免费集数未变但分集漂移的修复场景 2026-10-21 22:48:09 +08:00
Zhou Weidong b8b3fd867b docs(plans): 短剧免费集数同步分集留痕 2026-10-21 22:34:21 +08:00
Zhou Weidong e92ec118fe merge: 短剧保存免费集数同步分集免费状态 2026-10-21 22:34:21 +08:00
Zhou Weidong 4c4b53cd69 fix(content): 短剧保存免费集数时同步分集免费状态 2026-10-21 22:34:21 +08:00
Zhou Weidong d7f226659c test(content): 覆盖短剧免费集数同步分集价格 2026-10-21 22:34:21 +08:00
Zhou Weidong 979e6ebea3 docs(plans): 登记 K100 提交 AI 痕迹已知偏差 2026-10-21 22:10:35 +08:00
Zhou WeidongandCursor 3a581163aa docs(plans): K100 剧集批量上传计划与执行留痕
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:54 +08:00
Zhou Weidong 97c2153c2e merge: 合并 K98 客户端修复留痕 2026-10-21 19:27:21 +08:00
Zhou Weidong 27a15dd1b4 docs: 记录 K98 登录键盘避让 2026-10-21 19:26:55 +08:00
Zhou Weidong cf37d6c408 Merge remote-tracking branch 'origin/develop' into develop 2026-10-21 18:52:27 +08:00
Zhou Weidong c9b8ec6805 fix(content): 短剧编辑未传字段不再覆盖存量数据 2026-10-21 18:52:11 +08:00
Zhou Weidong 31d88b634a merge: 合并 K97 客户端修复留痕 2026-10-21 18:47:03 +08:00
Zhou Weidong 5704f80929 docs: 记录 K97 iOS 评论键盘避让 2026-10-21 18:46:50 +08:00
Zhou Weidong 652ef050ee merge: 合并 K96 推荐流分类过滤 2026-10-21 18:24:26 +08:00
Zhou Weidong 8f35160b56 feat(app): 推荐流支持分类过滤 2026-10-21 18:23:57 +08:00
Zhou Weidong 0e8fb1582d merge: 合并 K95.2 取消回滚留痕 2026-10-21 18:08:05 +08:00
Zhou Weidong aa74770256 docs: 记录 K95.2 取消回滚 2026-10-21 18:07:56 +08:00
Zhou Weidong 01ae6d44f0 merge: 合并 K95.2 交付留痕 2026-10-21 18:06:45 +08:00
Zhou Weidong ff14762426 docs: 记录 K95.2 封面过渡 2026-10-21 18:06:35 +08:00
23 changed files with 1396 additions and 160 deletions

No files matched your search

+4 -1
View File
@@ -167,7 +167,7 @@ app.register.max-per-device-per-day
| --- | --- | --- |
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
| 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/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
@@ -188,7 +188,10 @@ app.register.max-per-device-per-day
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。
- `categoryId` 可选;不传返回全部分类。传入后按 `category_ids` 整词匹配,
可与 `types` 叠加,只返回同时命中内容类型和分类的已上线短剧。
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
游标短剧必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
@@ -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,54 @@
# K95.2 首切频道用封面过渡
## 目标
- 首页频道切换后立即显示目标短剧封面,视频首帧就绪后再淡入。
- 首页模式视频初始化期间不再显示 `player-session-loading` 大转圈。
- 非首页播放器及票据加载状态保持现有行为。
## 分支
- ReelShort:`fix/k95.2-poster-transition`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. PlayerVideoSurface
- 新增可选参数 `posterUrl = ''`。
- 未 reveal 时:
- `posterUrl` 非空显示 `RemoteImage(path: posterUrl, fit: BoxFit.cover)`,
Key 为 `player-video-poster`。
- `posterUrl` 为空继续显示黑色 `ColoredBox`,保留
`player-fallback-background` Key。
- 视频 reveal 后继续通过现有 200ms `AnimatedOpacity` 淡入覆盖封面。
## B. PlayerPage
- `PlayerVideoSurface` 传入当前 `drama.posterUrl`。
- `player-session-loading` 条件增加 `!widget.homeMode`:
- 首页模式视频初始化期间只显示封面。
- 非首页播放器继续显示原 session loading。
- 首页 `player-ticket-loading` 不变。
## C. 测试
- 视频表面:非空 poster 显示 `player-video-poster`,空 poster 保留黑底且无封面。
- 首页模式:视频未就绪时不出现 `player-session-loading`。
- 非首页模式:视频未就绪时仍出现 `player-session-loading`。
- 复跑既有播放器、首页相关测试。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
## Git
- ReelShort 提交:`fix(home): 频道切换用封面过渡`
- `--no-ff` 合回 `develop` 并 push。
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不安装或操作真机;完成后停住等待验收。
+86
View File
@@ -0,0 +1,86 @@
# K96 剧场页服务端分类分页
## 目标
- `/app/feed` 支持按分类过滤,并与内容类型过滤叠加。
- 剧场页「全部」展示全部已上线短剧,任意分类展示该分类全部已上线短剧。
- 列表使用游标分页,切分类优先命中缓存,支持静默刷新、加载更多和失败重试。
## 分支
- FruitServer:`feat/k96-feed-category`
- ReelShort:`feat/k96-theater-feed`
## A. FruitServer
1. `AppCatalogController.feed` 增加可选 `Long categoryId` 并传给服务层。
2. `AppCatalogService.feed` 增加 `categoryId`,传给
`DramaService.listPublishedFeed`。
3. `DramaService.listPublishedFeed` 增加 `categoryId`:
- 非空时使用 `DramaMultiTagSupport.applyCategoryIdTokens`
按 `category_ids` 整词匹配。
- 游标 marker 必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
4. 补充服务层、控制器契约测试:
- 仅 categoryId。
- categoryId + types。
- 分类游标翻页。
- marker 分类不命中返回空。
5. 更新 `docs/api-phase1.md` 的 `/app/feed` 参数和分类过滤说明。
门禁:`mvn test` 全模块通过。
## B. ReelShort
### B1. API 与仓库
- `CatalogApi.feed` 增加可选 `String? categoryId`,非空才拼 query。
- `CatalogRepository.fetchFeed` 同步参数,HTTP 实现透传。
### B2. TheaterFeedController
- 新增 `lib/core/data/services/theater_feed_controller.dart`。
- 按 `String? categoryId` 独立缓存状态:
`dramas / cursor / hasMore / loaded / loading / loadingMore / lastError /
requestSeq / inFlight`。
- `select(id)` 同步切分类;有数据先渲染并静默刷新,无数据时加载。
- `loadMore` 游标追加并按 id 去重;`hasMore=false` 停止。
- `refreshCurrent` 失败保留旧数据;切换回失败分类自动重试。
- 单请求 10 秒超时,响应写回发起分类,requestSeq 仅处理同分类顺序。
### B3. 剧场接入
- `DiscoverPage` 创建并持有 TheaterFeedController,初始请求「全部」。
- 「全部」和分类 chips 的网格统一读取 `theater.dramas`,不再使用
`categories.feed.dramas` / `dramasOf`。
- `_selectCategory` 同时更新 `DiscoverLogic.selectedCategoryId` 和
`theater.select(categoryId)`。
- 滚动剩余不足 400px 时触发 `loadMore`;搜索模式不触发剧场分页。
- 下拉刷新并行执行 `categories.load()` 和 `theater.refreshCurrent()`。
- 无缓存首屏显示转圈;空、失败和重试保持既有剧场视觉。
### B4. 测试
- TheaterFeedController:
- 全部使用空 categoryId,分类使用对应 id。
- 缓存切回不闪烁。
- 分页去重、hasMore=false 停止。
- 失败保留旧数据、失败分类切回自动重试。
- 剧场 widget:
- 点击分类请求对应 categoryId。
- 接近底部触发下一页。
- 搜索、下拉刷新与既有布局不回归。
门禁:`dart analyze lib test` 零告警、`flutter test` 全过、
`flutter build apk --debug` 成功。
## Git
- FruitServer 提交:`feat(app): 推荐流支持分类过滤`
- ReelShort 提交:`feat(theater): 剧场分类列表服务端分页`
- 两仓库作者:`Zhou Weidong <502333560@qq.com>`,禁止 `git add -A`。
- 各自 `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改 specs、ReelShortWeb,不改首页 K94/K95 已交付逻辑。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,64 @@
# K97 修复 iOS 评论输入弹窗键盘避让
## 目标
- iOS 评论输入浮层实时跟随 `MediaQuery.viewInsets.bottom`,确保输入框与发表按钮
位于键盘上方。
- Android 保留既有 MethodChannel + 80ms 防抖 pinned 策略,不恢复底部 SafeArea,
避免键盘收起时 48px 抖动回归。
- 聚焦、`TextInput.show` 兜底和发表成功提示逻辑不变。
## 分支
- ReelShort:`fix/k97-ios-comment-keyboard`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. 平台区分
- `_isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android`。
- Android:
- 继续监听 `KeyboardInsetNotifier`。
- 继续使用 80ms 防抖后的 `_pinnedKeyboardInset`。
- iOS / 其他平台:
- 不读取 pinned/channel 值。
- `build` 中直接读取 `MediaQuery.viewInsetsOf(context).bottom`。
- inset 超过屏幕高度 60% 时按 0 处理,负值归零。
## B. 面板布局
- 面板主体使用 `AnimatedPadding`,只设置 bottom:
- `duration: 160ms`
- `curve: Curves.easeOutCubic`
- Android 使用 pinned 值。
- iOS / 其他平台使用实时 viewInsets。
- Material 仍铺到屏幕底部,用于键盘收起后的实色填充。
- Android 不包裹 SafeArea。
- iOS / 其他平台包裹 `SafeArea(top: false)`,保留 home indicator 底部避让。
## C. 测试
- iOS + `viewInsets.bottom=300`:
- 键盘避让 AnimatedPadding 的 bottom 为 300。
- `comment-composer-post` 底边位于屏幕高度减 300 之上。
- iOS + `viewInsets=0`:padding 为 0。
- Android 既有 pinned/channel、焦点、弹出、关闭、发布用例保持通过。
- 所有平台 override 测试结束恢复
`debugDefaultTargetPlatformOverride = null`。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
- `flutter build ios --simulator --no-codesign`
## Git
- ReelShort 提交:`fix(comment): 修复 iOS 评论输入弹窗键盘避让`
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,73 @@
# K98 修复登录与手机号登录键盘遮挡
## 目标
- 登录/注册页在邮箱或密码框聚焦后,自动滚动到键盘上方。
- 手机号登录弹窗的手机号/验证码框同样保持可见。
- Android `adjustNothing` 下通过既有 MethodChannel 获取键盘高度,iOS 使用实时
`MediaQuery.viewInsets`。
## 分支
- ReelShort:`fix/k98-auth-keyboard`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. 有效键盘高度
在 `keyboard_inset.dart` 增加:
```dart
double effectiveKeyboardInset(
BuildContext context,
KeyboardInsetNotifier notifier, {
double maxRatio = 0.6,
});
```
- 取 MediaQuery viewInsets 与 Android channel 逻辑像素值的最大值。
- Web/非 Android 不读取 channel。
- 超过屏幕高度 60% 时按 0 处理,负值/非法值归零。
## B. AuthPage
1. `initState` 启动 `KeyboardInsetNotifier`,监听并用 setState 刷新。
2. `SingleChildScrollView` 增加 `ScrollController`。
3. 底部 padding 使用固定底部留白 + effectiveKeyboardInset。
4. 邮箱/密码 FocusNode 监听焦点;聚焦或键盘高度变化后:
- 通过 FocusNode.context 的 RenderBox 获取字段全局底边。
- 与屏幕高度 - 键盘高度 - 24 比较。
- 被遮挡时用 200ms easeOutCubic animateTo 补足滚动。
5. `resizeToAvoidBottomInset` 保持 false。
## C. PhoneAuthSheet
1. 接入 KeyboardInsetNotifier 和 ScrollController。
2. 底部 padding 改用 effectiveKeyboardInset。
3. 手机号/验证码 FocusNode 增加与 AuthPage 相同的焦点/键盘变化滚动校正。
4. 保持现有 SingleChildScrollView、弹窗和返回键行为。
## D. 测试
- iOS viewInsets=300:登录密码框、注册密码框均在键盘上方。
- Android platform + MethodChannel 键盘高度:密码框仍在键盘上方。
- iOS:手机号弹窗验证码框在键盘上方。
- 所有平台 override 测试返回前显式恢复
`debugDefaultTargetPlatformOverride = null`。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
## Git
- ReelShort 提交:`fix(auth): 登录注册与手机号登录键盘避让`
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不改评论输入框已验证的 Android pinned 策略。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,30 @@
# 2026-10-21 | 短剧保存免费集数同步分集
## 目标
后台短剧编辑改 `freeEpisodes` / `unlockCoin` 后,同步 `drama_episode.is_free` / `unlock_coin`,与定价页 `PriceService.save()` 规则一致。
## 方案
1. 抽出 `EpisodePriceSync.syncByDramaRule(dramaId, freeEpisodes, unlockCoin)`,供 `DramaService.update` 与 `PriceService.save` 共用。
2. `DramaService.update` 在 `updateById` 后,仅当免费集数或解锁金币相对更新前有变化时调用同步。
3. TDD:先写 `DramaServiceTest` 复现失败,再改实现转绿。
## 范围
- 仅 `fruitshorts-module-content`
- 不改 Controller / VO / 契约 / 前端
## 门禁
```bash
mvn -pl fruitshorts-module-content test -Dtest=DramaServiceTest
mvn -pl fruitshorts-module-content test
```
## 分支 / 提交
- `feature/drama-free-episodes-sync` ← `develop`
- `test(content): 覆盖短剧免费集数同步分集价格`
- `fix(content): 短剧保存免费集数时同步分集免费状态`
- `merge --no-ff` 回 develop 并 push
@@ -0,0 +1,24 @@
# 2026-10-21 | 短剧保存始终同步分集价格(幂等纠偏)
## 背景
承接 K104。短剧 `free_episodes=9` 重存后,分集 5~9 仍收费。
## 根因
`DramaService.update()` 仅在 `freeEpisodes` / `unlockCoin` 相对更新前有变化时调用 `EpisodePriceSync`。历史数据已漂移时,同值重存不触发同步。
## 方案
1. 测试:覆盖「规则字段未变、分集已漂移」仍纠正。
2. `DramaService.update()`:每次保存都调用 `EpisodePriceSync.syncByDramaRule(drama)`。
3. `EpisodePriceSync`:仅对有差异的行走 `updateById`(已具备,保持)。
## 范围
只改 `fruitshorts-module-content` 的 `DramaService` / `EpisodePriceSync` / `DramaServiceTest`。不改 Controller / VO / 契约 / 前端。
## 提交
1. `test(content): 覆盖免费集数未变但分集漂移的修复场景`
2. `fix(content): 短剧保存时始终同步分集价格`
+74 -76
View File
@@ -1,97 +1,95 @@
# INBOX
## 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 行)是内联实现,**没有调用已有的
`_applyFavoriteDelta`**;只有全屏播放页的 `_toggleSave`(`player_view_actions.dart`)调了
→ 首页收藏后数字不变
5. 两处 rail 的 `commentCount: drama.commentCount` 是静态值,评论面板关闭后不刷新
6. 详情页 `DramaStatsCard` 用 `drama.favoriteCount`,`drama_detail_logic.toggleFavorite()`
切换后不更新显示
1. 输入:`File[]` + `dramaId` + 可选 `bizType`
2. 每项状态机:`waiting → uploading → merging → done / failed / paused`;对外暴露 `items`、`overallPercent`、
`start()`、`pause()`、`retry(id)`、`retryAll()`、`remove(id)`、`clear()`
3. 队列并发:同时最多 2 个文件上传(文件内分片并发沿用 `uploadFile` 的 3)
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`
- 新增 `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` 返回数字
## C. 批量创建分集
#### B. ReelShort(客户端)
1. 上传完成列表提供「批量创建分集」按钮
2. 默认值:按文件名自然排序(去扩展名后再比较,`第2集` 排在 `第10集` 之前);
集号 = 该剧当前最大集号 + 1 起顺延;标题取文件名去扩展名;是否免费沿用现有新建默认值
3. 弹窗内每行可改:集号、标题、是否免费;冲突集号在提交前本地校验并标红
4. 提交:逐条调用现有 `createEpisodeApi`(`src/api/admin/episode.ts`),
逐条显示成功/失败,失败的保留在弹窗内可重试;全部结束后刷新剧集列表
5. **不新增后端接口**,契约不动
1. `lib/core/data/remote/comment_api.dart`
- 新增 `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、失败不变
## D. 组件拆分(红线:单文件 ≤ 500 行)
### 验收
- `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` 出包;
本地执行回填脚本后 `GET /app/home/feed` 的 `favoriteCount` 与 `user_favorite` 实际条数一致
- 客户端:`dart analyze lib test` 零告警、`flutter test` 全过
- 功能:收藏后 rail 数字立即 ±1;重进 App 数字不回退;发表评论后关闭面板,评论数 +1(重新拉取)
## E. 细节要求
### Git
- 文件类型白名单:`mp4/mov/mkv/webm`(`accept="video/*"` + `before-upload` 二次校验)
- 单文件大小上限从现有常量取值,超限直接标红不入队
- 所有文案走 `src/locales/zh-CN.ts` / `en.ts`,禁止硬编码中文
- 进度与错误展示用项目现有样式变量,不新引入 UI 依赖
- 上传期间:关闭页面前用 `beforeunload` 提示存在未完成上传
- FruitServer:从 `develop` 拉 `fix/favorite-comment-count`,
提交 `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 留痕
## F. 门禁
### 约束
- `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 验收,回执需贴门禁原始输出
+444
View File
@@ -24204,3 +24204,447 @@ Running Gradle task 'assembleDebug'... 8.7s
- ReelShort develop 合并提交:`838ca2c merge: 合并 K95.1 首次切换频道无感`,
已推送 `dfa79be..838ca2c`;K95 与 K95.1 已统一合回 `develop`。
- 未改后端、ReelShortWeb、specs;未安装或操作真机,等待用户验收。
## 2026-10-21 K95.2 首切频道用封面过渡
- 已完整读取 K95.2 `docs/plans/INBOX.md`;本批次只改 ReelShort Flutter 客户端。
- 已从最新 `origin/develop` 创建 `fix/k95.2-poster-transition` worktree。
- 已先写计划 `docs/plans/2026-10-21-K95.2-poster-transition.md`,明确封面底层、
视频淡入、首页隐藏 session 大转圈及非首页保持原行为。
- 不改后端、ReelShortWeb、specs;不安装或操作真机。
- 已完成 `PlayerVideoSurface(posterUrl)`:未 reveal 时优先显示
`RemoteImage` 封面 `player-video-poster`,空封面继续保留黑色
`player-fallback-background`;视频首帧仍用 200ms 淡入覆盖。
- `PlayerPage` 已传入当前短剧 `posterUrl`,首页模式隐藏
`player-session-loading`,非首页与本页 `player-ticket-loading` 行为不变。
- 已补 poster 非空/为空、首页与非首页 session loading、既有播放页封面断言测试。
门禁原始输出:
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:57 +547: All tests passed!
$ flutter build apk --debug
Running Gradle task 'assembleDebug'... 27.6s
✓ Built build/app/outputs/flutter-apk/app-debug.apk
```
解读:静态分析零告警,547 项全量测试通过,debug APK 构建成功;
`pubspec.lock` 已还原,无依赖变更。
### K95.2 交付结果
- ReelShort 功能提交:`c9adf8b fix(home): 频道切换用封面过渡`。
- ReelShort develop 合并提交:`a2a491d merge: 合并 K95.2 首切频道封面过渡`,
已推送 `838ca2c..a2a491d`。
- 未改后端、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)`,只更新有差异的分集。
```
$ mvn -pl fruitshorts-module-content -am test -Dtest=DramaServiceTest -Dsurefire.failIfNoSpecifiedTests=false
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.184 s -- in com.fruitshorts.content.service.DramaServiceTest
[INFO] BUILD SUCCESS
```
解读:新增「规则未变但分集漂移」用例;原「已对齐则不写库」仍通过,但会查询分集。
@@ -41,13 +41,14 @@ public class AppCatalogController {
return Result.ok(appCatalogService.discoverFeed());
}
@Operation(summary = "推荐流:支持内容类型多选与游标翻页")
@Operation(summary = "推荐流:支持内容类型、分类过滤与游标翻页")
@GetMapping("/feed")
public Result<CursorResult<AppDramaCardVO>> feed(
@RequestParam(required = false) String types,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long cursor,
@RequestParam(required = false) Integer size) {
return Result.ok(appCatalogService.feed(types, cursor, size));
return Result.ok(appCatalogService.feed(types, categoryId, cursor, size));
}
@Operation(summary = "分类列表")
@@ -74,7 +74,8 @@ public class AppCatalogService {
.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));
List<String> dramaTypes = types == null
? List.of()
@@ -82,7 +83,8 @@ public class AppCatalogService {
.map(String::trim)
.filter(type -> !type.isEmpty())
.toList();
List<Drama> rows = dramaService.listPublishedFeed(dramaTypes, cursor, pageSize + 1);
List<Drama> rows = dramaService.listPublishedFeed(
dramaTypes, categoryId, cursor, pageSize + 1);
if (rows.isEmpty()) {
return CursorResult.empty();
}
@@ -96,11 +96,12 @@ class AppCatalogControllerContractTest {
card.setId(12L);
card.setTitle("漫剧卡片");
card.setDramaType("ANIME");
given(catalogService.feed("ANIME", null, 20))
given(catalogService.feed("ANIME", 7L, null, 20))
.willReturn(new CursorResult<>(List.of(card), 12L, true));
catalogMvc.perform(get("/app/feed")
.param("types", "ANIME")
.param("categoryId", "7")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
@@ -109,7 +110,7 @@ class AppCatalogControllerContractTest {
.andExpect(jsonPath("$.data.nextCursor").value("12"))
.andExpect(jsonPath("$.data.hasMore").value(true));
verify(catalogService).feed("ANIME", null, 20);
verify(catalogService).feed("ANIME", 7L, null, 20);
}
@Test
@@ -56,7 +56,7 @@ class AppCommentCountAnonymousSecurityTest {
@BeforeEach
void setUp() {
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));
}
@@ -118,7 +118,7 @@ class AppCatalogServiceTest {
@Test
void should_filter_feed_by_drama_type_and_expose_it_on_card() {
Drama anime = drama(1L, "ANIME");
given(dramaService.listPublishedFeed(List.of("ANIME"), null, 21))
given(dramaService.listPublishedFeed(List.of("ANIME"), null, null, 21))
.willReturn(List.of(anime));
given(dramaCategoryService.listBasic()).willReturn(List.of());
DramaEpisode preview = episode(11L, 1L, 45L);
@@ -126,41 +126,59 @@ class AppCatalogServiceTest {
.willReturn(Map.of(1L, preview));
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().get(0).getDramaType()).isEqualTo("ANIME");
assertThat(result.getList().get(0).getCommentCount()).isEqualTo(3L);
assertThat(result.getList().get(0).getLikeCount()).isEqualTo(45L);
assertThat(result.isHasMore()).isFalse();
verify(dramaService).listPublishedFeed(List.of("ANIME"), null, 21);
verify(dramaService).listPublishedFeed(List.of("ANIME"), null, null, 21);
}
@Test
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());
CursorResult<AppDramaCardVO> result = catalogService.feed("ANIME,LIVE", null, 10);
CursorResult<AppDramaCardVO> result = catalogService.feed(
"ANIME,LIVE", null, null, 10);
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
void should_page_feed_with_cursor_without_duplicates() {
Drama first = drama(1L, "ANIME");
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));
given(dramaService.listPublishedFeed(List.of(), 1L, 2))
given(dramaService.listPublishedFeed(List.of(), null, 1L, 2))
.willReturn(List.of(second));
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of());
given(commentService.countByEpisodes(any())).willReturn(Map.of());
CursorResult<AppDramaCardVO> firstPage = catalogService.feed(null, null, 1);
CursorResult<AppDramaCardVO> secondPage = catalogService.feed(null, firstPage.getNextCursor(), 1);
CursorResult<AppDramaCardVO> firstPage = catalogService.feed(
null, null, null, 1);
CursorResult<AppDramaCardVO> secondPage = catalogService.feed(
null, null, firstPage.getNextCursor(), 1);
assertThat(firstPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(1L);
assertThat(firstPage.getNextCursor()).isEqualTo(1L);
@@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** K88 App 推荐流的类型过滤与三字段 keyset 游标。 */
@@ -60,7 +61,7 @@ class DramaServiceFeedTest {
given(dramaMapper.selectList(any())).willReturn(List.of());
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());
QueryWrapper<Drama> query = captor.getValue();
@@ -86,7 +87,7 @@ class DramaServiceFeedTest {
given(dramaMapper.selectList(any())).willReturn(List.of());
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());
assertThat(captor.getValue().getSqlSegment())
@@ -94,4 +95,65 @@ class DramaServiceFeedTest {
.contains("view_count")
.contains("id");
}
@Test
@SuppressWarnings("unchecked")
void should_apply_category_and_type_tokens_together() {
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of("ANIME"), 7L, null, 5);
verify(dramaMapper).selectList(captor.capture());
QueryWrapper<Drama> query = captor.getValue();
assertThat(query.getSqlSegment())
.contains("drama_types")
.contains("category_ids")
.contains("recommend_weight");
assertThat(query.getParamNameValuePairs().values())
.contains("%,ANIME,%", "%,7,%");
}
@Test
@SuppressWarnings("unchecked")
void should_apply_category_cursor_for_matching_marker() {
Drama marker = new Drama();
marker.setId(9L);
marker.setStatus("published");
marker.setDramaType("ANIME");
marker.setCategoryId(7L);
marker.setCategoryIds("7,8");
marker.setRecommendWeight(30);
marker.setViewCount(1200L);
given(dramaMapper.selectById(9L)).willReturn(marker);
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of(), 7L, 9L, 5);
verify(dramaMapper).selectList(captor.capture());
QueryWrapper<Drama> query = captor.getValue();
assertThat(query.getSqlSegment())
.contains("category_ids")
.contains("recommend_weight")
.contains("id");
assertThat(query.getParamNameValuePairs().values())
.contains("%,7,%", 30, 1200L, 9L);
}
@Test
void should_return_empty_when_cursor_marker_does_not_match_category() {
Drama marker = new Drama();
marker.setId(9L);
marker.setStatus("published");
marker.setDramaType("ANIME");
marker.setCategoryId(8L);
marker.setCategoryIds("8");
given(dramaMapper.selectById(9L)).willReturn(marker);
List<Drama> rows = dramaService.listPublishedFeed(List.of("ANIME"), 7L, 9L, 5);
assertThat(rows).isEmpty();
verify(dramaMapper, never()).selectList(any());
}
}
@@ -134,8 +134,8 @@ class DramaServiceMultiTagTest {
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of("ANIME"), 9L, 5);
dramaService.listPublishedFeed(List.of("LIVE"), 9L, 5);
dramaService.listPublishedFeed(List.of("ANIME"), null, 9L, 5);
dramaService.listPublishedFeed(List.of("LIVE"), null, 9L, 5);
verify(dramaMapper, times(2)).selectList(captor.capture());
assertThat(captor.getAllValues()).allSatisfy(query ->
+5
View File
@@ -25,5 +25,10 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-system</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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) {
List<String> values = parseStringTokens(drama.getDramaTypes()).stream()
.map(DramaMultiTagSupport::normalizeDramaType)
@@ -42,6 +42,7 @@ public class DramaService {
private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaReviewRecordMapper dramaReviewRecordMapper;
private final DramaCategoryService dramaCategoryService;
private final EpisodePriceSync episodePriceSync;
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
List<String> dramaTypes = DramaMultiTagSupport.normalizeDramaTypes(req.getDramaTypes());
@@ -92,7 +93,7 @@ public class DramaService {
private Long insert(DramaSaveReqVO req, Long operatorId, Long creatorId) {
Drama drama = new Drama();
apply(drama, req);
apply(drama, req, true);
drama.setCode(generateCode());
drama.setStatus(ContentStatus.DRAFT.getCode());
drama.setCreatorId(creatorId == null ? 0L : creatorId);
@@ -109,7 +110,7 @@ public class DramaService {
@Transactional(rollbackFor = Exception.class)
public void update(Long id, DramaSaveReqVO req) {
Drama drama = getExisting(id);
apply(drama, req);
apply(drama, req, false);
if (req.getFavoriteCount() != null) {
drama.setFavoriteCount(nonNegative(req.getFavoriteCount(), "收藏数"));
}
@@ -120,6 +121,7 @@ public class DramaService {
drama.setViewCount(nonNegative(req.getHeat(), "热度"));
}
dramaMapper.updateById(drama);
episodePriceSync.syncByDramaRule(drama);
}
@Transactional(rollbackFor = Exception.class)
@@ -237,20 +239,26 @@ public class DramaService {
* App 推荐流:按推荐权重、热度、ID 倒序,并支持内容类型多选。
* 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);
QueryWrapper<Drama> query = new QueryWrapper<>();
query.eq("status", ContentStatus.PUBLISHED.getCode());
if (!normalizedTypes.isEmpty()) {
DramaMultiTagSupport.applyDramaTypeTokens(query, normalizedTypes);
}
if (categoryId != null) {
DramaMultiTagSupport.applyCategoryIdTokens(query, List.of(categoryId));
}
if (cursor != null) {
Drama marker = dramaMapper.selectById(cursor);
if (marker == null
|| !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus())
|| (!normalizedTypes.isEmpty()
&& DramaMultiTagSupport.parseDramaTypes(marker).stream()
.noneMatch(normalizedTypes::contains))) {
.noneMatch(normalizedTypes::contains))
|| (categoryId != null
&& !DramaMultiTagSupport.parseCategoryIds(marker).contains(categoryId))) {
return List.of();
}
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.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle());
DramaMultiTagSupport.applyCategoryIds(drama, req);
drama.setGenre(req.getGenre() == null ? "" : req.getGenre());
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover());
drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl());
if (isNew || req.getSubtitle() != null) {
drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle());
}
if (isNew || req.getCategoryIds() != null || req.getCategoryId() != null) {
DramaMultiTagSupport.applyCategoryIds(drama, req);
}
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.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
DramaMultiTagSupport.applyDramaTypes(drama, req);
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
drama.setSort(req.getSort() == null ? 0 : req.getSort());
if (isNew || req.getLanguage() != null) {
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
}
if (isNew || req.getDramaTypes() != null || req.getDramaType() != null) {
DramaMultiTagSupport.applyDramaTypes(drama, req);
}
if (isNew || req.getFreeEpisodes() != null) {
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
}
if (isNew || req.getUnlockCoin() != null) {
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
}
if (isNew || req.getIsHot() != null) {
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
}
if (isNew || req.getSort() != null) {
drama.setSort(req.getSort() == null ? 0 : req.getSort());
}
}
private String generateCode() {
@@ -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;
}
}
@@ -32,6 +32,7 @@ public class PriceService {
private final DramaService dramaService;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
private final EpisodePriceSync episodePriceSync;
/** 读取当前价格配置:后台定价页进入时先看现状再改。 */
public PriceRespVO detail(Long dramaId) {
@@ -72,55 +73,13 @@ public class PriceService {
drama.setUnlockCoin(unlockCoin);
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()
.eq(DramaEpisode::getDramaId, drama.getId())
.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();
vo.setDramaId(drama.getId());
vo.setFreeEpisodes(freeEpisodes);
@@ -139,4 +98,56 @@ public class PriceService {
}).toList());
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;
}
}