Author SHA1 Message Date
jianghaifeng 2904e59e71 Merge branch 'develop' of https://git.reelshort2.com:80/jianghaifeng/FruitServer into develop
backend-ci / test (push) Failing after 4m42s
2026-11-13 16:55:07 +08:00
Zhou Weidong a5c9357a24 fix: 消除 JWT/签名密钥源码硬编码,全部改为环境变量注入
backend-ci / test (push) Failing after 4m39s
- JwtProperties.adminSecret/appSecret 默认值从 dev 密钥改为空字符串
- MediaProperties.signatureSecret 默认值从 dev 密钥改为空字符串
- app-api/admin-api application.yml 三处密钥回退值均改为空
- docker-compose.yml CORS_ALLOWED_ORIGINS 追加 ymb-site 3007 端口白名单
- .gitignore 追加 *.secrets.env / SSL 证书 / nginx 部署目录忽略规则
2026-11-13 16:53:53 +08:00
Zhou Weidong 12c28963f5 feat(commerce): 完成奖励活动管理接口 2026-11-13 13:43:16 +08:00
jianghaifeng 32cac38a0e feat(auth): Google 登录支持多客户端 audience 校验
- GoogleAuthProperties 的 clientId 改为 clientIds 列表
- GoogleIdTokenVerifier 命中任一 clientId 即通过,兼容 iOS 与 Android 不同的 aud
- app-api 注入三个客户端 id,生产可用 GOOGLE_CLIENT_IDS 覆盖
2026-11-13 12:30:55 +08:00
Zhou Weidong a706621786 fix(commerce): 奖励任务菜单改用46避免覆盖转码设置
backend-ci / test (push) Failing after 5m11s
2026-11-09 10:35:37 +08:00
Zhou Weidong 64eadf1446 feat(commerce): 新增打赏任务中心与签到发奖
backend-ci / test (push) Failing after 4m53s
2026-11-09 10:32:53 +08:00
Zhou Weidong 2fee329002 feat(creator): 内容审核返回创作者与分集信息 2026-11-09 10:32:53 +08:00
Zhou Weidong 9b2833dfd3 feat(creator): 后台录入创作者即通过并可直接开号 2026-11-09 10:32:53 +08:00
Zhou Weidong af0bf7f90e feat(creator): 新增一键发布与删除作品接口 2026-11-09 10:32:46 +08:00
Zhou Weidong a36276051d fix(system): 修复转码设置菜单误下发导致前端白屏 2026-11-09 10:32:46 +08:00
jianghaifeng 3df66c2f4c refactor(media): 抽出 MediaStorage 接口为对象存储切换铺路
backend-ci / test (push) Failing after 5m7s
本地磁盘实现 MediaStorageService 改为实现接口,五处注入点
改为依赖接口;行为不变。后期新增 S3 实现以配置切换即可。
2026-11-05 11:31:31 +08:00
jianghaifeng 32ada4c2bf feat(content): 短剧支持审核通过并上线一步流转
backend-ci / test (push) Failing after 5m8s
新增 APPROVE_PUBLISH 动作与 approve-publish 接口,草稿/审核中/已下线
可直接流转为已上线,事务内同步上线草稿分集并记录一条审核记录,
后台审核操作由三步减为一步。
2026-11-05 10:55:52 +08:00
Zhou Weidong eba7d400e1 fix(deploy): 看门狗飞书告警中文乱码改 UTF-8 发送
backend-ci / test (push) Failing after 4m39s
PS5 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码导致中文变 ???,
改为手动转 UTF-8 字节数组并声明 charset=utf-8。
2026-11-02 14:52:41 +08:00
Zhou Weidong 3faeba0589 fix(deploy): 补充 CORS 允许来源避免跨域被拒
app-api 增加 frostgiant 域名(含 nginx 非标准 80 端口)与公网 IP 来源;
admin-api 增加公网/局域网 IP 与 admin.frostgiant.vip。
Spring setAllowedOrigins 为精确字符串匹配,来源必须逐条列全。
2026-11-02 14:52:41 +08:00
Zhou Weidong 8a5a381b44 feat(catalog): 新增分类推荐榜快照接口
backend-ci / test (push) Failing after 4m45s
2026-11-02 14:45:43 +08:00
jianghaifeng afd8d00752 feat(catalog): 卡片下发完整简介不再截断
backend-ci / test (push) Canceled after 0s
2026-10-30 22:53:22 +08:00
jianghaifeng 958de5ef22 feat(config): /app/config 下发登录页背景地址
backend-ci / test (push) Canceled after 0s
2026-10-30 17:14:46 +08:00
jianghaifeng f2103f6604 feat(cover): 短剧新增横版封面字段并在进度接口下发
backend-ci / test (push) Canceled after 0s
- Drama/DramaSaveReqVO/DramaRespVO 新增 horizontalCover,打通后台上传链路
- AppDramaCardVO/AppDramaDetailVO 新增 coverLandscape
- ProgressRespVO 新增 dramaCoverLandscape,继续观看横幅可直接取横版图
2026-10-29 17:32:49 +08:00
jianghaifeng 0d4d510aee feat(content): 标签增加语义码并随卡片下发
backend-ci / test (push) Canceled after 0s
标签名称会随语言和运营文案变化,客户端按名称判断板块(如推荐页「上新」
横滑)一改名就失效。content_tag 新增 code 列并在后台维护,
卡片与详情以 tagCode 下发,已有「上新 / 热剧 / 新剧 / 热播」一并回填。
2026-10-29 15:45:34 +08:00
Zhou Weidong 4d796089a0 ops(grafana): 告警规则入库(服务下线/响应慢/磁盘满)
backend-ci / test (push) Failing after 4m38s
2026-10-29 10:53:07 +08:00
Zhou Weidong f71498339c ci: 新增 Gitea Actions 流水线(测试/分析/构建门禁)
backend-ci / test (push) Failing after 7m31s
2026-10-29 10:47:44 +08:00
Zhou Weidong e4fe82283c docs: 更新第一阶段进度表与尚未完成清单(C2/C3 标记已实现) 2026-10-29 10:47:44 +08:00
Zhou Weidong 26102332ac fix(deploy): 看门狗改由用户会话任务启动 Docker Desktop
以 SYSTEM 身份直接 Start-Process Docker Desktop.exe 会命中 WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED,把 docker-desktop 发行版搞成僵死状态(VHD 已挂载无法终止),导致登录用户自己也起不来。改为触发以登录用户身份运行的 FruitShorts-DockerDesktop 计划任务(先停再起,规避 IgnoreNew)。
2026-10-29 10:11:02 +08:00
Zhou Weidong aa5954ef6a chore(merge): 合并 main 的印尼语敏感词库种子到 develop 2026-10-29 09:21:19 +08:00
Zhou Weidong 963bf5c0da test(playback): 补充合并预写缓冲与迟到条目丢弃用例 2026-10-28 19:02:33 +08:00
Zhou Weidong a71d2701d1 fix(playback): 登录合并前预写设备缓冲,迟到条目不得复活已删记录
- 新增 flushIdentity:把某身份缓冲里未落库的进度立即写进权威表;
  mergeDeviceIntoUser 先调用它再扫库,否则登录前 2 秒内的上报会被漏掉,
  稍后落库还会生成一条登录用户看不到的匿名孤儿行;合并完清掉设备侧缓冲。
- planOne 增加判定:逻辑删除会刷新 updated_at,上报时间不晚于它的条目直接丢弃,
  防止删除瞬间已 drain 的在途引用把 deleted 写回 0。
- 去掉 save(Long userId, ...) 上多余的 @Transactional(缓冲路径白开事务)。
2026-10-28 19:02:32 +08:00
Zhou Weidong 025fbe73f0 perf(playback): 上报改用 Lua 单次往返写入缓冲
原先 HSET、EXPIRE、LPUSH、LTRIM 分四次往返,进程若死在写状态与入队之间,
该剧进度只留在 HASH 里永远不落库、12 小时后被 TTL 清掉。合并成一个脚本后
热路径只需 1 次往返,脚本顺带返回 HLEN 是否超 30 决定要不要补裁剪。
2026-10-28 19:02:32 +08:00
Zhou Weidong 0d161f318f chore(merge): 合并 origin/develop 的看门狗周期巡检与 admin-api Redis 配置
watchdog.ps1 冲突取本地实现:计数落盘用 Import/Export 函数,另含 Webhook
三级兜底与告警节流,为远程内联版本的超集。
2026-10-28 18:53:11 +08:00
Zhou Weidong 2eac1fe31d test(playback): 补充缓冲删除双写、已删剧不读回与失效条目丢弃用例 2026-10-28 18:51:52 +08:00
Zhou Weidong 09b0f89e8d fix(deploy): 看门狗失败计数落盘并补齐告警 Webhook 兜底
计划任务每 2 分钟起新进程,进程内计数永远到不了阈值,告警静默哑火;计数改
JSON 落盘跨进程累加,达阈值后只在首达与每 30 轮提醒。Webhook 支持参数、环境
变量、deploy/feishu-webhook.txt 三级兜底,密钥文件不入库。
2026-10-28 18:51:52 +08:00
Zhou Weidong 8fb1f52777 fix(config): 调度线程池扩到 4 条,避免落库与其它定时任务互相排队 2026-10-28 18:51:52 +08:00
Zhou Weidong 44b7abcd0f fix(playback): 删除观看历史时同步清理进度缓冲
缓冲已是读接口的权威来源,只删库行会被读覆盖层读回、并被下一次落库把行复活。
缓冲补 remove/clear(HDEL/DEL),删除路径先清缓冲再删库行,读覆盖层按库行
deleted 过滤;落库时缓冲状态已失效的条目直接丢弃,不再整批退回队列。
2026-10-28 18:51:52 +08:00
Zhou Weidong 181b338780 fix(ops): 修复管理端 Redis 未配置与看门狗未真正周期巡检
- admin-api 的 application.yml 缺少 spring.data.redis 配置,容器内默认连
  127.0.0.1 导致 /actuator/health 返回 DOWN(redis 组件不可用);
  补齐后与 app-api 共用 REDIS_HOST/REDIS_PORT,健康检查恢复 UP
- 计划任务原先用 BootTrigger 挂 Repetition,Windows 不生效(Next Run Time
  为 N/A,9 小时只跑过一次),看门狗形同虚设;改为 TimeTrigger + PT2M
  无限重复 + StartWhenAvailable,实测每 2 分钟稳定巡检
- watchdog.ps1 的连续失败计数改为落盘 watchdog-state.json,避免每轮进程
  重启后清零导致「连续 3 次失败告警」永不触发
2026-10-28 18:23:48 +08:00
Zhou Weidong 657190d5a8 chore(deploy): 加入服务看门狗脚本与 Windows 计划任务注册文件
- watchdog.ps1:每 2 分钟健康检查,Docker/DB/Redis/API/Grafana 任一
  不健康自动重启,连续 3 次失败发飞书告警,C 盘超 85% 也告警
- backend-watchdog-task.xml:开机触发、2 分钟循环的计划任务配置
2026-10-28 15:00:58 +08:00
Zhou Weidong 3e2d6a06d9 perf(playback): 观看进度上报异步化(Redis write-behind 批量落库)
- POST /app/watch/progress 只写 Redis 缓冲立即返回(p95 75ms→2ms),
  WatchProgressFlusher 每 2s 把 watch:pg:dirty 队列按身份合并批量 UPSERT,
  Redis 异常自动回退同步写库,接口契约不变
- 读接口用缓冲覆盖 DB 行保证读己之写;Caffeine 缓存剧集校验免去上报路径两条 DB 查询
- 落库按「新增进度/新增流水/更新行」分组攒批(MyBatis BATCH 只合并连续同形状语句),
  配合 reWriteBatchedInserts 多值 INSERT,12.4 万积压 45 秒消化完,
  1650 QPS 持续压测队列稳态归零
2026-10-28 14:59:09 +08:00
Zhou Weidong fc9e429187 feat(monitoring): 接入 Loki + Promtail 日志采集,Grafana 可直接搜索容器日志 2026-10-28 10:38:41 +08:00
Zhou Weidong 5d139783c9 fix(media): 缩略图唯一索引改为部分索引,避免软删行占用 episode_id 导致登记冲突 2026-10-27 21:07:10 +08:00
Zhou Weidong ddd2406f32 fix(build): Docker 构建阶段固定阿里云 Maven 中央镜像
容器内从 Maven Central 拉依赖时 maven-filtering:3.3.1 解析失败,
且 dependency:go-offline 被 || true 静默吞掉,导致发布时才暴露。
构建阶段写入 settings.xml 走阿里云 central 镜像,并让 go-offline 失败即中断。
2026-10-27 20:14:19 +08:00
Vicky 272aa62434 feat: phase1 UI prep — P1 安全(XFF防护), P2 规模化(平台索引+region保护), P3 文档补全
P1 安全: WebUtils.getClientIp 改为 remoteAddr 优先 + 可信代理 CIDR 配置
P2 规模化: DISTINCT ON 查询补匹配排序的部分索引 (migrate-k113)
P2 一致性: syncCountryByIp regionCode 同语言/时区/币种加保护,不覆盖用户自定义
P3 文档: api-phase1.md 新增 platform 参数说明, IpRegionService 新增集成测试 + VERSION
新增: CountryProfile 52 国映射 + IpRegionService 离线 ip2region 解析
平台筛选: app-users/favorites/likes/watch-history 按最近一次会话 ANDROID/IOS 筛选
2026-10-27 15:45:15 +08:00
jianghaifeng a41ab18636 perf(admin): 优化观看记录列表并统一时间格式
- 新增 watched_at 索引,默认倒序翻页走 Merge Append 而不是全表排序
- 装配只取展示所需列,会话查询不再拉 refresh_token_hash
- 关键词预检索加 2000 条上限
- admin-api 的 LocalDateTime 统一输出 yyyy-MM-dd HH:mm:ss
2026-10-27 14:39:13 +08:00
jianghaifeng 8999ff395d feat(admin): 收藏/点赞/观看记录支持按客户端平台筛选
- 设备画像按 device_id 命中,登录用户按最近一次登录会话的平台命中
- 修正 orderBy 先于动态条件导致 SQL 拼接错误的问题
2026-10-27 14:27:10 +08:00
jianghaifeng 229f1d0198 refactor(device): 设备平台只记 iOS/Android
去掉系统版本、机型与版本号上报,表结构收敛为 device_id + platform。
2026-10-27 14:00:12 +08:00
jianghaifeng 2dadca6288 chore(merge): 合并设备画像与后台系统展示 2026-10-27 13:53:49 +08:00
Vicky 2cf8f9a1c3 feat(device): 按 device_id 记录客户端系统与机型
- 新增 app_device_profile 表与 upsert,匿名请求同样建档
- app-api 设备画像过滤器,同设备 60 秒节流写一次
- 收藏/点赞/观看列表优先用设备画像,缺失回退最近会话
2026-10-27 13:53:47 +08:00
jianghaifeng d9a1670a72 chore(merge): 合并互动计数校准 2026-10-27 13:29:09 +08:00
Vicky 5ff4626b33 chore(sql): 校准被压测数据污染的互动计数
剧集点赞数与剧收藏数改回按实际未删除互动行重算,可重复执行。
2026-10-27 13:29:09 +08:00
jianghaifeng 6bbde0b100 chore(merge): 合并剧级点赞历史数据清理 2026-10-27 13:28:15 +08:00
Vicky 884324b8fd chore(sql): 清理历史遗留的剧级点赞数据
客户端点赞入口只作用于剧集,13 条 DRAMA 记录为旧模型遗留;
软删并同步校准 drama.like_count。
2026-10-27 13:28:15 +08:00
jianghaifeng 7baaeddf26 chore(merge): 合并点赞筛选说明文档 2026-10-27 13:25:52 +08:00
Vicky 99d49a1765 docs(admin): 说明点赞列表三种目标与 episodeId 筛选边界
- 明确客户端点赞落 EPISODE,DRAMA 为历史数据
- 说明 episodeId 只命中该集及该集评论,不含对短剧本身的点赞
2026-10-27 13:25:51 +08:00
jianghaifeng 92f7606f65 chore(merge): 合并收藏与点赞列表查询加固 2026-10-27 13:23:23 +08:00
Vicky c2ba3df47c fix(admin): 加固收藏与点赞列表查询
- 关键词/剧名预检索加 2000 条上限,避免 IN 列表超限并给出可操作提示
- 新增 created_at 分页索引迁移,避免无条件列表退化为全表排序
- dramaPublished 改为三态,短剧已不存在时不再误报未发布
2026-10-27 13:23:21 +08:00
Zhou Weidong 226dc275bb merge(admin): 合并收藏与点赞只读查询 2026-10-27 12:32:23 +08:00
Zhou Weidong 7a2ff3f80e feat(admin): 新增收藏与点赞只读查询接口 2026-10-27 12:32:23 +08:00
Zhou Weidong 23321a0b0c feat(favorites): 支持设备批量取消收藏 2026-10-27 10:33:59 +08:00
Zhou Weidong 5d44ac239b fix(watch): 修复匿名进度查询参数类型 2026-10-27 10:01:21 +08:00
Zhou Weidong 848c746505 feat(watch): 支持设备观看与登录合并 2026-10-27 09:54:47 +08:00
Zhou Weidong 4b0fbd73d3 feat(interaction): 支持游客互动与登录合并 2026-10-26 22:34:14 +08:00
Zhou Weidong 2fe24b9db7 feat(interaction): 新增分集计数统计接口 2026-10-26 21:31:17 +08:00
Zhou Weidong 15b540cdd6 chore(merge): 合并内容多语言与转码通知 2026-10-24 16:57:31 +08:00
Zhou Weidong 50b3dd4a8e docs(api): 同步多语言筛选与转码通知契约 2026-10-24 16:53:26 +08:00
Zhou Weidong 1d34e47673 feat(admin): 增加工作台指标和转码通知 2026-10-24 16:53:26 +08:00
Zhou Weidong 2367a5d12b feat(app): 全部短剧改为请求体分页 2026-10-24 16:53:26 +08:00
Zhou Weidong ebc5f890fd feat(playback): 观看记录返回标题多语言 2026-10-24 16:53:26 +08:00
Zhou Weidong e60c47123c feat(content): 短剧分类和标签支持多语言 2026-10-24 16:53:26 +08:00
Zhou Weidong 5c20cf1ab9 Merge branch feat/app-version-and-c-end 2026-10-23 16:45:55 +08:00
Zhou Weidong 45cf405285 feat(api): 加C端分类频道和剧单详情接口 2026-10-23 16:45:28 +08:00
Zhou Weidong 4f80a691d1 feat(app-version): 加批量创建接口 2026-10-23 16:45:20 +08:00
Zhou Weidong 47f19a98eb chore(merge): 合并 App 版本管理后端 2026-10-23 15:27:35 +08:00
Zhou Weidong 62792413df feat(app): 新增后台 App 版本管理接口 2026-10-23 15:27:35 +08:00
Zhou Weidong 34c775620d chore(merge): 合并 compose Redis 依赖修复 2026-10-23 14:58:46 +08:00
Zhou Weidong b3774e0112 fix(deploy): compose 编排补充 REDIS_HOST 依赖 2026-10-23 14:58:46 +08:00
Zhou Weidong 0e6f7531d4 chore(merge): 合并大盘趋势与分布接口 2026-10-23 14:58:33 +08:00
Zhou Weidong 73a8db8498 feat(dashboard): 大盘趋势与分布接口 2026-10-23 14:58:33 +08:00
Zhou Weidong cf657707f9 chore(merge): 合并转码补齐功能 2026-10-23 14:14:39 +08:00
Zhou Weidong 40728e685b feat(media): 新增缺失转码产物自动补齐 2026-10-23 14:14:39 +08:00
Zhou Weidong 6abb09e5c2 chore(merge): 将 release/v1.0.0 同步到 main 2026-10-23 13:07:42 +08:00
Zhou Weidong df2da25833 chore(merge): 合并用户系统协议与播放改动 2026-10-23 12:34:26 +08:00
Zhou Weidong 1ea4cfeba3 test(media): 按物理删除校验重转码清理 2026-10-23 12:34:13 +08:00
Zhou Weidong ce6e3ef70a docs(api): 同步搜索协议播放与收藏契约 2026-10-23 12:34:13 +08:00
Zhou Weidong 87e20c31a9 feat(security): 允许匿名搜索与打开协议页 2026-10-23 12:34:13 +08:00
Zhou Weidong 22edf34ff1 feat(system): 支持后台维护三语协议 2026-10-23 12:34:13 +08:00
Zhou Weidong 16ba79a336 feat(interaction): 支持批量取消收藏 2026-10-23 12:34:13 +08:00
Zhou Weidong 9c25b1b397 fix(playback): 恢复已删除的观看进度 2026-10-23 12:34:13 +08:00
Zhou Weidong 5a80e6693f feat(playback): 播放票据返回各清晰度地址 2026-10-23 12:34:13 +08:00
Zhou Weidong ab58641bbe feat(user): 展示用户最近使用的系统 2026-10-23 12:34:13 +08:00
Zhou Weidong 45e3d84f59 docs(api): 补充分类与剧单管理接口文档 2026-10-22 22:17:58 +08:00
Zhou Weidong a3ff636e45 chore(sql): 补充转码设置菜单初始化补丁 2026-10-22 22:17:58 +08:00
Zhou Weidong 7b360ddccd feat(content): 新增运营分类与剧单分类管理 2026-10-22 22:17:58 +08:00
Zhou Weidong 758c7521bb Merge remote-tracking branch 'origin/develop' into develop 2026-10-22 18:21:05 +08:00
Zhou Weidong e730cfaa7d fix(media): 重转码物理清理旧产物避免唯一键冲突 2026-10-22 18:21:00 +08:00
Zhou Weidong 60a87a8d15 docs(client): 回填首页与我的视觉改版记录 2026-10-22 17:08:05 +08:00
Zhou Weidong 0b519b8ca1 docs(nav): 回填客户端四 Tab 导航记录 2026-10-22 16:22:07 +08:00
Zhou Weidong e6b4a747ce merge: 修复转码 rendition 幂等与失败落库 2026-10-22 15:37:01 +08:00
Zhou Weidong f009f75c03 docs(media): 回填转码修复记录 2026-10-22 15:36:51 +08:00
Zhou Weidong 91b621fe75 fix(media): 清理旧 rendition 并确保失败状态落库 2026-10-22 15:36:35 +08:00
Zhou Weidong 5d1a157085 merge: 修复转码任务认领与媒资校验 2026-10-22 15:27:58 +08:00
Zhou Weidong 5552caa6c1 fix(media): 修正转码任务认领与媒资 id 校验 2026-10-22 15:27:51 +08:00
Zhou Weidong ad84704d28 test(media): 覆盖转码认领与非法媒资 id 2026-10-22 15:27:37 +08:00
Zhou Weidong 08eddc9ed2 merge: 修复转码错误截断与进度上报 2026-10-22 15:13:50 +08:00
Zhou Weidong 660afaf66d fix(media): 转码错误信息截断并上报进度 2026-10-22 15:12:05 +08:00
Zhou Weidong f9bc6eee6d merge: 修复转码媒资 id 精度与存量兜底 2026-10-22 14:59:46 +08:00
Zhou Weidong 5ef5dd8eaf fix(media): 修正转码媒资 id 解析与存量分集补登记 2026-10-22 14:59:16 +08:00
Zhou Weidong a35b02dd18 merge: 修复转码保持原始宽高比 2026-10-22 14:31:30 +08:00
Zhou Weidong 04d25cd23d fix(media): 转码按短边等比定档并回写实际尺寸 2026-10-22 14:31:22 +08:00
Zhou Weidong 217ff24f1c test(media): 覆盖转码保持原始宽高比 2026-10-22 14:30:08 +08:00
Zhou Weidong 40a884cc85 merge: 补齐转码产物查询接口 2026-10-22 14:14:21 +08:00
Zhou Weidong 345408d5e7 feat(media): 新增清晰度与帧图查询接口 2026-10-22 14:14:13 +08:00
Zhou Weidong 090f149b31 test(media): 覆盖转码产物查询接口 2026-10-22 14:10:15 +08:00
Zhou Weidong f66eae5baf merge: 打通后台转码链路 2026-10-22 14:03:13 +08:00
Zhou Weidong 4219186ab0 feat(media): 上传与改片自动入队转码并支持手动重转 2026-10-22 14:03:04 +08:00
Zhou Weidong 7e6075a2e9 test(media): 覆盖转码入队与重转幂等 2026-10-22 14:01:26 +08:00
Zhou Weidong cd51aabe7e merge: 观看历史统一分页契约并移除 limit 2026-10-22 13:21:35 +08:00
Zhou Weidong c5468a669b refactor(app): 观看历史统一分页契约并移除 limit 2026-10-22 13:21:07 +08:00
Zhou Weidong 7b694938c6 merge: 收藏与观看历史支持分页 2026-10-22 12:23:52 +08:00
Zhou Weidong 71b0c7315a feat(app): 收藏与观看历史支持分页 2026-10-22 12:23:43 +08:00
Zhou Weidong ec15950851 merge: 修复转码分集绑定与产物回填 2026-10-22 12:04:08 +08:00
Zhou Weidong 7d563f2cd6 fix(media): 补齐分集转码绑定与产物回填 2026-10-22 12:03:58 +08:00
Zhou Weidong 6bdd245a83 merge: 合并转码配置与缩略图轴 2026-10-22 11:20:35 +08:00
Zhou Weidong 316baf28d6 feat(media): 支持后台转码配置与缩略图轴 2026-10-22 11:20:28 +08:00
Zhou Weidong 70da939a58 docs(plans): 合并剧集列表分页作者偏差登记 2026-10-22 09:18:42 +08:00
Zhou Weidong 07754ff4e4 docs(plans): 登记剧集列表分页合并提交作者偏差 2026-10-22 09:18:42 +08:00
Zhou Weidong 4159af1732 merge: 剧集列表接口支持分页 2026-10-21 23:09:42 +08:00
Zhou Weidong 0143fa036c feat(admin): 剧集列表接口支持分页 2026-10-21 23:09:30 +08:00
Zhou Weidong 5227762aeb docs(plans): 短剧价格同步幂等纠偏留痕 2026-10-21 22:56:36 +08:00
jianghaifeng a4317bb6f1 merge: 短剧保存时始终同步分集价格 2026-10-21 22:49:43 +08:00
Vicky 9adf8c5630 fix(content): 短剧保存时始终同步分集价格 2026-10-21 22:48:09 +08:00
Vicky 8bb7f63c62 test(content): 覆盖免费集数未变但分集漂移的修复场景 2026-10-21 22:48:09 +08:00
Zhou Weidong b8b3fd867b docs(plans): 短剧免费集数同步分集留痕 2026-10-21 22:34:21 +08:00
Zhou Weidong e92ec118fe merge: 短剧保存免费集数同步分集免费状态 2026-10-21 22:34:21 +08:00
Zhou Weidong 4c4b53cd69 fix(content): 短剧保存免费集数时同步分集免费状态 2026-10-21 22:34:21 +08:00
Zhou Weidong d7f226659c test(content): 覆盖短剧免费集数同步分集价格 2026-10-21 22:34:21 +08:00
Zhou Weidong 979e6ebea3 docs(plans): 登记 K100 提交 AI 痕迹已知偏差 2026-10-21 22:10:35 +08:00
Zhou WeidongandCursor 3a581163aa docs(plans): K100 剧集批量上传计划与执行留痕
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:54 +08:00
Zhou Weidong 97c2153c2e merge: 合并 K98 客户端修复留痕 2026-10-21 19:27:21 +08:00
Zhou Weidong 27a15dd1b4 docs: 记录 K98 登录键盘避让 2026-10-21 19:26:55 +08:00
Zhou Weidong cf37d6c408 Merge remote-tracking branch 'origin/develop' into develop 2026-10-21 18:52:27 +08:00
Zhou Weidong c9b8ec6805 fix(content): 短剧编辑未传字段不再覆盖存量数据 2026-10-21 18:52:11 +08:00
Zhou Weidong 31d88b634a merge: 合并 K97 客户端修复留痕 2026-10-21 18:47:03 +08:00
Zhou Weidong 5704f80929 docs: 记录 K97 iOS 评论键盘避让 2026-10-21 18:46:50 +08:00
Zhou Weidong 652ef050ee merge: 合并 K96 推荐流分类过滤 2026-10-21 18:24:26 +08:00
Zhou Weidong 8f35160b56 feat(app): 推荐流支持分类过滤 2026-10-21 18:23:57 +08:00
Zhou Weidong 0e8fb1582d merge: 合并 K95.2 取消回滚留痕 2026-10-21 18:08:05 +08:00
Zhou Weidong aa74770256 docs: 记录 K95.2 取消回滚 2026-10-21 18:07:56 +08:00
Zhou Weidong 01ae6d44f0 merge: 合并 K95.2 交付留痕 2026-10-21 18:06:45 +08:00
Zhou Weidong ff14762426 docs: 记录 K95.2 封面过渡 2026-10-21 18:06:35 +08:00
Zhou Weidong 419119725e merge: 合并 K95.1 交付留痕 2026-10-21 17:54:40 +08:00
Zhou Weidong b31784010c docs: 记录 K95.1 首次切换无感 2026-10-21 17:54:29 +08:00
Zhou Weidong 4049cfd2d0 merge: 合并 K95 交付留痕 2026-10-21 17:47:17 +08:00
Zhou Weidong c70054e83d docs: 记录 K95 频道切换修复留痕 2026-10-21 17:47:10 +08:00
Zhou Weidong 9baab3777a merge: 合并 K94 交付留痕 2026-10-21 17:34:35 +08:00
Zhou Weidong 3036b782f9 docs: 记录 K94 首页类型 Feed 留痕 2026-10-21 17:34:27 +08:00
Zhou Weidong 1fa3631e53 merge: 合并 K93.1 交付留痕 2026-10-21 17:17:27 +08:00
Zhou Weidong 2df4e5cc9f docs: 记录 K93.1 多选完整展示留痕 2026-10-21 17:17:24 +08:00
Zhou Weidong 8f1103dfa7 merge: 合并 K93 交付留痕 2026-10-21 17:02:11 +08:00
Zhou Weidong aa532eb568 docs: 完成 K93 交付留痕 2026-10-21 17:02:07 +08:00
Zhou Weidong 33c1172da5 merge: 合并 K93 短剧类型多选 2026-10-21 17:01:36 +08:00
Zhou Weidong 4c614a5ece feat(content): 短剧类型与内容类型支持多选 2026-10-21 17:01:12 +08:00
Zhou Weidong 030df5c5dc merge: 合并 K92 交付留痕 2026-10-21 16:45:38 +08:00
Zhou Weidong b82a05f82e docs: 完成 K92 交付留痕 2026-10-21 16:45:34 +08:00
Zhou Weidong ae053b0268 merge: 合并 K92 举报目标短剧与分集信息 2026-10-21 16:45:07 +08:00
Zhou Weidong 9a74876b3f feat(admin): 举报工单返回短剧与分集信息 2026-10-21 16:44:46 +08:00
Zhou Weidong 6ddddef5ec merge: 合并 K91 交付留痕 2026-10-21 16:36:43 +08:00
Zhou Weidong 0a2ca5d379 docs: 完成 K91 交付留痕 2026-10-21 16:36:39 +08:00
Zhou Weidong fe18a2c9ea merge: 合并 K91 观看记录与评论分集信息 2026-10-21 16:35:57 +08:00
Zhou Weidong 1d316791b1 feat(admin): 新增观看记录分页与评论分集信息 2026-10-21 16:35:26 +08:00
Zhou Weidong d52b805adf merge: 合并 K90 客户端分集互动留痕 2026-10-21 16:16:34 +08:00
Zhou Weidong 7a16feb548 docs: 记录 K90 客户端分集互动留痕 2026-10-21 16:16:30 +08:00
Zhou Weidong 08380a52c8 merge: 合并 K89.1 预览集查询顺序留痕 2026-10-21 16:04:12 +08:00
Zhou Weidong 96d58c53f7 docs(progress): 记录 K89.1 预览集查询顺序修复 2026-10-21 16:04:07 +08:00
Zhou Weidong 6252a07b5d merge: 合并 K89.1 预览集查询顺序修复 2026-10-21 16:03:12 +08:00
Zhou Weidong 3303a91e3c fix(content): 固定预览集查询顺序 2026-10-21 16:03:06 +08:00
Zhou Weidong 7ae05698fa merge: 合并 K89 分集点赞与评论统计留痕 2026-10-21 16:01:16 +08:00
Zhou Weidong c511d04415 docs(progress): 记录 K89 分集点赞与评论统计 2026-10-21 16:01:11 +08:00
Zhou Weidong dfc978c620 merge: 合并 K89 分集点赞与评论统计 2026-10-21 15:56:30 +08:00
Zhou Weidong c6f729fb27 feat(interaction): 点赞与评论按集统计 2026-10-21 15:56:26 +08:00
Zhou Weidong c7ff729f84 merge: 合并 K88 Web 原生门禁记录修正 2026-10-21 15:48:54 +08:00
Zhou Weidong e0ae38ce08 docs(progress): 修正 K88 Web 原生门禁记录 2026-10-21 15:48:50 +08:00
Zhou Weidong e2e2a21d2f merge: 合并 K88 内容类型与互动数据留痕 2026-10-21 15:46:22 +08:00
Zhou Weidong a3b6bf3a55 docs(progress): 记录 K88 内容类型与互动数据 2026-10-21 15:46:15 +08:00
Zhou Weidong fd402d9efe merge: 合并 K88 内容类型与分类 feed 2026-10-21 15:40:32 +08:00
Zhou Weidong 1052b97306 feat(content): 新增内容类型与首页分类 feed 2026-10-21 15:40:19 +08:00
Zhou Weidong d9cdbc8e15 merge: 合并 K87.1 下拉刷新冻结位移留痕 2026-10-21 15:26:22 +08:00
Zhou Weidong edd279372d docs(plans): 记录 K87.1 下拉刷新冻结位移 2026-10-21 15:26:15 +08:00
Zhou Weidong 7ab6dc62dd merge: 合并 K87 下拉刷新完成后回弹留痕 2026-10-21 15:14:26 +08:00
Zhou Weidong 22fc173305 docs(plans): 记录 K87 下拉刷新完成后回弹 2026-10-21 15:14:20 +08:00
Zhou Weidong 0b914dd274 merge: 合并 K86.22 评论输入面板安全区留痕 2026-10-21 15:02:04 +08:00
Zhou Weidong c7e5de5f99 docs(plans): 记录 K86.22 评论输入面板安全区修复 2026-10-21 15:01:58 +08:00
Zhou Weidong e6b5b904fc merge: 合并 K86.21 评论输入浮层稳定定位留痕 2026-10-21 14:57:00 +08:00
Zhou Weidong ba628e3a4a docs(plans): 记录 K86.21 评论输入浮层稳定定位 2026-10-21 14:56:56 +08:00
Zhou Weidong ad4be92189 merge: 合并 K86.20 评论输入浮层打开状态留痕 2026-10-21 14:50:21 +08:00
Zhou Weidong 5d018657d5 docs(progress): 记录 K86.20 评论输入浮层打开状态修正 2026-10-21 14:50:17 +08:00
Zhou Weidong 24bd00b537 merge: 合并 K86.19 评论输入实色填充留痕 2026-10-21 14:38:38 +08:00
Zhou Weidong 250a6142f3 docs(progress): 记录 K86.19 评论输入实色填充保障 2026-10-21 14:38:34 +08:00
Zhou Weidong e90debe810 merge: 合并 K86.18 输入浮层实底填充留痕 2026-10-21 14:32:03 +08:00
Zhou Weidong 2d8e5cf53e docs(progress): 记录 K86.18 输入浮层实底填充修正 2026-10-21 14:32:00 +08:00
Zhou Weidong 7ab22d26b1 merge: 合并 K86.18 评论输入透明浮层留痕 2026-10-21 14:26:34 +08:00
Zhou Weidong 5c7973471d docs(progress): 记录 K86.18 评论输入透明浮层 2026-10-21 14:26:30 +08:00
Zhou Weidong c81e3643d5 merge: 合并 K86.17 键盘收起位置固定留痕 2026-10-21 14:16:59 +08:00
Zhou Weidong 4297e02a67 docs(progress): 记录 K86.17 键盘收起位置固定修正 2026-10-21 14:16:55 +08:00
Zhou Weidong 844b6abb25 merge: 合并 K86.17 输入弹窗收起规则留痕 2026-10-21 14:12:18 +08:00
Zhou Weidong e6e8a347fc docs(progress): 记录 K86.17 输入弹窗收起规则 2026-10-21 14:12:13 +08:00
Zhou Weidong fc8f507864 merge: 合并 K86.16 评论浮层修正留痕 2026-10-21 14:01:18 +08:00
Zhou Weidong f016df0bf1 docs(progress): 记录 K86.16 评论浮层与透明遮罩 2026-10-21 14:01:13 +08:00
Zhou Weidong 141874a0bc merge: 合并 K86.15 评论成功提示留痕 2026-10-21 13:49:22 +08:00
Zhou Weidong d788d70752 docs(progress): 记录 K86.15 评论发布成功提示 2026-10-21 13:49:19 +08:00
Zhou Weidong cb45bafad6 merge: 合并 K86.14 紧凑弹窗修正留痕 2026-10-21 13:38:51 +08:00
Zhou Weidong 56532c2c03 docs(progress): 记录 K86.14 紧凑弹窗修正 2026-10-21 13:38:48 +08:00
Zhou Weidong 12502a2e69 merge: 合并 K86.14 评论输入独立弹窗留痕 2026-10-21 13:34:03 +08:00
Zhou Weidong e46a9fd347 docs(progress): 记录 K86.14 评论输入独立弹窗 2026-10-21 13:33:57 +08:00
Zhou Weidong 908023ddc4 merge: 合并 K86.13 评论面板半屏高度留痕 2026-10-21 13:21:55 +08:00
Zhou Weidong d9db07573e docs(progress): 记录 K86.13 评论面板半屏高度 2026-10-21 13:21:51 +08:00
Zhou Weidong b2621d67ca merge: 合并 K86.12 评论文字居中修复留痕 2026-10-21 13:18:03 +08:00
Zhou Weidong aaa6f06477 docs(progress): 记录 K86.12 评论文字居中修复 2026-10-21 13:17:56 +08:00
Zhou Weidong 562d8941b9 merge: 合并 K86.11 评论假输入框入口留痕 2026-10-21 13:14:23 +08:00
Zhou Weidong b286137cb0 docs(progress): 记录 K86.11 评论假输入框入口 2026-10-21 13:14:20 +08:00
Zhou Weidong 99e9b4f7bf merge: 合并 K86.10 评论面板高度与收起流程留痕 2026-10-21 13:06:24 +08:00
Zhou Weidong 12a011fdce docs(progress): 记录 K86.10 评论面板高度与收起流程 2026-10-21 13:06:20 +08:00
Zhou Weidong fa95bf05d5 merge: 合并 K86.9 评论面板键盘布局修复留痕 2026-10-21 13:01:52 +08:00
Zhou Weidong 5c631c6f23 docs(progress): 记录 K86.9 评论面板键盘布局修复 2026-10-21 13:01:48 +08:00
Zhou Weidong 19fd56d477 merge: 合并 K86.8 评论面板保持与焦点修复留痕 2026-10-21 12:55:22 +08:00
Zhou Weidong 35c8cf0b9c docs(progress): 记录 K86.8 评论面板保持与焦点修复 2026-10-21 12:55:18 +08:00
Zhou Weidong 4b6c4ba928 merge: 合并 K86.7 视频页键盘不压缩留痕 2026-10-21 12:49:27 +08:00
Zhou Weidong 4e93f7bdd4 docs(progress): 记录 K86.7 视频页键盘不压缩 2026-10-21 12:49:21 +08:00
Zhou Weidong 81b5c99b6a merge: 合并 K86.6 评论输入聚焦修复留痕 2026-10-21 12:43:04 +08:00
Zhou Weidong f496c9fa16 docs(progress): 记录 K86.6 评论输入聚焦修复 2026-10-21 12:42:53 +08:00
Zhou Weidong 1de35bfa30 merge: 合并 K86.5 隐藏评论操作入口留痕 2026-10-21 12:26:59 +08:00
Zhou Weidong 48fdee2c4b docs(progress): 记录 K86.5 隐藏评论操作入口 2026-10-21 12:26:49 +08:00
Zhou Weidong 3d15807919 merge: 合并 K86.4 评论输入面板留痕 2026-10-21 12:22:00 +08:00
Zhou Weidong 3c64525a3e docs(progress): 记录 K86.4 评论展开输入面板 2026-10-21 12:21:51 +08:00
Zhou Weidong 5103527635 merge: 合并 K86.3 Android 键盘修正留痕 2026-10-21 12:14:50 +08:00
Zhou Weidong 52bc80a402 docs(progress): 记录 K86.3 Android 键盘修正 2026-10-21 12:14:41 +08:00
Zhou Weidong 87c7f786d2 merge: 合并 K86.2 评论面板键盘修正留痕 2026-10-21 12:08:07 +08:00
Zhou Weidong 16e1657063 docs(progress): 记录 K86.2 评论面板键盘修正 2026-10-21 12:07:59 +08:00
Zhou Weidong f54f21be37 merge: 合并 K86.1 评论面板补修留痕 2026-10-21 12:01:10 +08:00
Zhou Weidong f357347d28 docs(progress): 记录 K86.1 评论面板补修 2026-10-21 12:01:00 +08:00
Zhou Weidong afe45fe8c5 merge: 合并 K86 评论面板计划与留痕 2026-10-21 11:53:35 +08:00
Zhou Weidong c415177287 docs(progress): 记录 K86 评论面板重做 2026-10-21 11:53:27 +08:00
Zhou Weidong 76d6cc9703 docs(plan): 添加 K86 评论面板重做计划 2026-10-21 11:49:56 +08:00
Zhou Weidong a674865538 merge: 合并 K85 评论数匿名放行补修 2026-10-21 11:46:34 +08:00
Zhou Weidong 708b39ffcc docs(progress): 记录 K85 评论数匿名放行补修 2026-10-21 11:46:25 +08:00
Zhou Weidong 7579d3163a fix(security): 放行评论数接口匿名访问 2026-10-21 11:46:18 +08:00
Zhou Weidong 8e6213aed8 merge: 合并 K85 双端进度留痕 2026-10-21 11:40:41 +08:00
Zhou Weidong b169173901 docs(progress): 记录 K85 双端完成与门禁结果 2026-10-21 11:40:32 +08:00
Zhou Weidong c08fb7b3aa merge: 合并 K85 收藏与评论计数 2026-10-21 11:34:16 +08:00
Zhou Weidong 65e8fbfc70 fix(interaction): 收藏计数同步维护并新增评论数接口 2026-10-21 11:33:59 +08:00
Zhou Weidong 88794b816e merge: 合并 K84-A 卡片互动计数 2026-10-21 11:14:35 +08:00
Zhou Weidong 256821b824 feat(content): 列表卡片与后台返回收藏点赞评论数 2026-10-21 11:14:18 +08:00
Zhou Weidong f90336963b docs(plans): 留痕 K83 互动栏图标资源 2026-10-21 11:02:58 +08:00
Zhou Weidong 4c20c82f96 docs(plans): 留痕 K82 右侧互动栏图标升级 2026-10-21 10:53:06 +08:00
Zhou Weidong 530da2b1b4 feat(monitoring): 监控编排统一到 FruitServer compose
把原先散落在 D:\fruitshorts\monitoring 的手工栈收编进仓库:prometheus 增加 cAdvisor 与 blackbox 抓取、15 天保留期;grafana 改为 provisioning 自动加载数据源与看板;新增 blackbox/cadvisor 服务;Grafana 管理员密码改走环境变量(必填),不再硬编码 admin/admin。
2026-10-21 10:51:28 +08:00
Zhou Weidong b391ad31af docs(plans): 留痕 K81 更多面板文字减重 2026-10-21 10:44:19 +08:00
Zhou Weidong 368cd04742 docs(plans): 留痕 K80 更多面板重做 2026-10-21 10:34:59 +08:00
Zhou Weidong f1c11df5d5 docs(plans): 留痕 K79 进度拖动防闪 2026-10-21 10:25:25 +08:00
Zhou Weidong 64ddfe8cae fix(deploy): 管理端不再校验 /files/episode 播放签名
admin-api 镜像固定 prod profile,application-prod.yml 把 signature-required 硬编码为 true,导致管理端分集页直接播放裸媒资地址时被 MediaSignatureFilter 拦成 403,视频不显示。管理端仅内网访问,这里将 protected-prefixes 置空;公开的 app-api 仍保持签名校验。
2026-10-21 10:17:48 +08:00
Zhou Weidong 82dd899ad0 docs(plans): 留痕 K78 底部导航放大 2026-10-21 10:16:48 +08:00
Zhou Weidong 3a25534bc1 docs(plans): 留痕 K77 播放加载进度态 2026-10-21 10:08:10 +08:00
Zhou Weidong da60bd8b0f docs(plans): 留痕 K76 开发默认地址 2026-10-21 09:58:35 +08:00
Zhou Weidong 60fa96b789 docs(plans): 留痕 K75 首页淡入转场 2026-10-21 09:24:25 +08:00
Zhou Weidong ad5b104e0d docs(plans): 留痕 K74 开发本机地址 2026-10-21 09:17:48 +08:00
Zhou Weidong e1580c70ed docs(plans): 留痕 K73 C3 埋点同步发布分支 2026-10-21 09:10:27 +08:00
Zhou Weidong 4f3bb0090c merge: 补齐 Redis+限流、埋点、Prometheus 监控缺失功能 2026-10-21 06:32:07 +08:00
Zhou Weidong 32710c2e06 feat: 接入 Prometheus 监控(放行 /actuator/prometheus + 指标暴露 + compose 编排) 2026-10-21 06:28:11 +08:00
Zhou Weidong 42dd68f266 feat: 埋点落库补充 app_event_log 建表 SQL 2026-10-21 06:28:11 +08:00
Zhou Weidong 6d3997cbae feat: C3 埋点事件上报(/app/events 匿名落库) 2026-10-21 06:25:25 +08:00
Zhou Weidong fa4977a71d feat: C2 Redis 接入 + 接口限流(RedisService + @RateLimit,验证码/登录防刷) 2026-10-21 06:24:55 +08:00
Zhou Weidong d1aaf2e5c0 docs(plans): 留痕 K72 develop 同步 release 与 main 2026-10-20 22:27:31 +08:00
Zhou Weidong 0b2709339e docs(plans): 留痕 K71 首页导航安全区底色 2026-10-20 22:16:19 +08:00
Zhou Weidong b5f1699240 docs(plans): 留痕 K70 开发默认公网地址 2026-10-20 21:56:50 +08:00
Zhou Weidong c2974957ba docs(plans): 留痕 K69 发布分支同步 main 2026-10-20 20:39:38 +08:00
Zhou Weidong bce8d37d88 docs(plans): 留痕 K68 编译期接口地址 2026-10-20 20:21:28 +08:00
Zhou Weidong fdc7314087 docs(plans): 留痕 K67 清理无引用代码 2026-10-20 20:06:58 +08:00
Zhou Weidong c5def287b8 docs(plans): 留痕 K66 启动页停留首页就绪 2026-10-20 19:58:09 +08:00
Zhou Weidong 5e509b20ed docs(plans): 留痕 K65 冷启动首页预取 2026-10-20 19:52:23 +08:00
Zhou Weidong f6ce79c65a docs(plans): 留痕 K64 原生品牌启动图 2026-10-20 19:42:45 +08:00
Zhou Weidong 0f477c6b15 docs(plans): 留痕 K63 播放器路由暂停修复 2026-10-20 19:25:44 +08:00
Zhou Weidong 0de7436baf docs(plans): 留痕 K62 安卓桌面图标资源 2026-10-20 19:17:15 +08:00
Zhou Weidong a755a5f657 docs(plans): 留痕 K61 导航栏闪白修复 2026-10-20 19:05:06 +08:00
Zhou Weidong 0a48d2739a docs(plans): 留痕 K60 原生开屏生成 2026-10-20 18:52:24 +08:00
Zhou Weidong 36cd6a0fcd docs(plans): 留痕 K59 隐藏视频调试文字 2026-10-20 18:44:14 +08:00
Zhou Weidong de8f02a542 docs(plans): 留痕 K58 浅色开屏 2026-10-20 18:38:34 +08:00
Zhou Weidong 374b0bd89c docs(plans): 留痕 K57 视频首帧防闪 2026-10-20 18:29:45 +08:00
Zhou Weidong efd1c0d38c docs(plans): 留痕 K56 服务地址编辑器 2026-10-20 18:24:23 +08:00
Zhou Weidong d214a9791c docs(plans): 留痕 K55 目录加载诊断 2026-10-20 18:18:12 +08:00
Zhou Weidong aadb02a9c4 docs(plans): 留痕 shared_preferences 2.5.6 兼容修复 2026-10-20 18:09:13 +08:00
Zhou Weidong 3afb182361 docs(plans): 留痕 K54 iOS 网络修复 2026-10-20 18:01:56 +08:00
Zhou Weidong e50e6c7170 docs(plans): 留痕 K53 详情页首帧修复 2026-10-20 17:54:52 +08:00
Zhou Weidong a38809cc5d docs(plans): 留痕 K52 原生开屏清理 2026-10-20 17:46:08 +08:00
Zhou Weidong 7fc5eb9067 docs(plans): 留痕 K50 开屏品牌强化 2026-10-20 17:43:14 +08:00
Zhou Weidong ce221c99c8 docs(plans): 留痕 K50 品牌图标资源 2026-10-20 17:38:49 +08:00
Zhou Weidong d34410971a docs(plans): 留痕 K51 详情页灰屏修复 2026-10-20 17:33:33 +08:00
Zhou Weidong 1de202ad4a docs(plans): 留痕 K50-A 品牌开屏动画 2026-10-20 17:20:38 +08:00
Zhou Weidong 38aac37a4e docs(plans): 留痕 K49 加速态清屏按钮 2026-10-20 17:12:58 +08:00
Zhou Weidong c6192fdbd0 docs(plans): 补充分类弧线方向留痕 2026-10-20 17:10:06 +08:00
Zhou Weidong f2a3d79f53 docs(plans): 留痕 K48 分类 tab 修正 2026-10-20 17:07:53 +08:00
Zhou Weidong f51adf5a98 docs(plans): 补充下拉刷新主色留痕 2026-10-20 17:03:56 +08:00
Zhou Weidong 1aa003a5af docs(plans): 留痕 K47 搜索清空按钮 2026-10-20 17:00:29 +08:00
Zhou Weidong 68c00db549 docs(plans): 补充下拉刷新跟随留痕 2026-10-20 16:57:56 +08:00
Zhou Weidong 8113a4efd3 docs(plans): 留痕 K46-A 榜单新剧分页 2026-10-20 16:53:35 +08:00
Zhou Weidong fd59d1a19d docs(plans): 留痕 K45-A 文字下拉刷新 2026-10-20 16:47:40 +08:00
Zhou Weidong df5cfb8c83 docs(plans): 留痕 K44-A 个人资料页修正 2026-10-20 16:42:49 +08:00
Zhou Weidong e08c73efe8 docs(plans): 留痕 K43-A 冷启动灰屏修复 2026-10-20 16:40:10 +08:00
Zhou Weidong 7a6ecc295f docs(plans): 补充 K42-A 收藏星标留痕 2026-10-20 16:37:18 +08:00
Zhou Weidong bc37f647ad docs(plans): 补充 K42-A 删除按钮留痕 2026-10-20 16:34:57 +08:00
Zhou Weidong 06757e9ab4 docs(plans): 留痕 K42-A 收藏与观看历史页 2026-10-20 16:32:44 +08:00
Zhou Weidong 2ed8f339db docs(plans): 留痕 K41-A 登录页重设计 2026-10-20 16:21:39 +08:00
Zhou Weidong a92f903738 docs(plans): 留痕详情页按钮统一 2026-10-20 16:16:25 +08:00
Zhou Weidong 90b4f9d73a docs(plans): 留痕 K40-B 评论入口登录闸 2026-10-20 16:13:03 +08:00
Zhou Weidong cd108ba6c5 docs(plans): 留痕 K39 返回按钮优化 2026-10-20 16:09:43 +08:00
Zhou Weidong 63e20c2968 docs(plans): 留痕 K40 播放占位与评论登录 2026-10-20 16:06:24 +08:00
Zhou Weidong 70949b8f4b docs(plans): 留痕 K39-A 二级页头统一 2026-10-20 15:55:51 +08:00
Zhou Weidong f54c009acd docs(plans): 留痕 K38-A 短剧详情页重做 2026-10-20 15:47:35 +08:00
Zhou Weidong a67bba1178 docs(plans): 留痕 K37-A 剧场筛选页 2026-10-20 15:40:39 +08:00
Zhou Weidong af2f2c9411 docs(plans): 留痕 K36-A 首映厅与档期预约 2026-10-20 15:29:08 +08:00
Zhou Weidong 7c5800bef1 docs(plans): 留痕 K34-H 吸顶闪动修复 2026-10-20 15:25:27 +08:00
Zhou Weidong ee43b4e186 docs(plans): 留痕 K35-A 创作页重做 2026-10-20 15:22:06 +08:00
Zhou Weidong bf9d611fcc docs(plans): 留痕 K34-G 剧场吸顶修复 2026-10-20 15:18:02 +08:00
Zhou Weidong 2bf9e8500c docs(plans): 留痕 K34-E 卡片直达播放 2026-10-20 15:15:36 +08:00
Zhou Weidong 9ffbdc0926 docs(plans): 留痕 K34-E 榜单与吸顶优化 2026-10-20 15:13:27 +08:00
Zhou Weidong 2a2e301e57 docs(plans): 留痕 K34-D 榜单三强横排 2026-10-20 15:08:59 +08:00
Zhou Weidong e14d266dfd docs(plans): 留痕 K34-C 榜单新剧预约页 2026-10-20 15:03:21 +08:00
Zhou Weidong f73efc8653 docs(plans): 留痕 K34-B 剧场搜索框样式 2026-10-20 14:58:31 +08:00
Zhou Weidong 041d97a0a3 docs(plans): 留痕 K34-A 剧场页精致化 2026-10-20 14:52:59 +08:00
Zhou Weidong a80a0ed4ca docs(plans): 留痕 K33-K 首页 feed 固定顶栏 2026-10-20 14:46:31 +08:00
Zhou Weidong 4a6a31d3cd docs(plans): 留痕暂停按钮尺寸调整 2026-10-20 14:41:33 +08:00
Zhou Weidong 8e1c8b7408 docs(plans): 留痕 K33-J 清屏进度与长按恢复 2026-10-20 14:38:24 +08:00
Zhou Weidong 800537076d docs(plans): 留痕 K33-I feed 清屏与寻址恢复 2026-10-20 14:33:53 +08:00
Zhou Weidong fbb1b6c30a docs(plans): 留痕 K33-H 画中画回前台复位 2026-10-20 14:29:15 +08:00
Zhou Weidong 75352e9817 docs(plans): 留痕 K33-G feed rail 与更多弹窗 2026-10-20 14:23:53 +08:00
Zhou Weidong 748dea3424 docs(plans): 留痕 K33-F feed 长按与刷新 2026-10-20 14:19:38 +08:00
Zhou Weidong d82d5c76be docs(plans): 留痕 K33-E 首页 feed 刷新与进度优化 2026-10-20 14:12:00 +08:00
Zhou Weidong 452fe74b3d docs(plans): 留痕 K33-D 首页 feed 精修 2026-10-20 14:05:42 +08:00
Zhou Weidong 053993dd5f docs(plans): 补充 K33-C 弹幕 key 清理留痕 2026-10-20 13:58:32 +08:00
Zhou Weidong 43d04519c2 docs(plans): 留痕 K33-C 首页 feed 精修 2026-10-20 13:57:07 +08:00
Zhou Weidong cc187a53c0 docs(plans): 留痕 K33-B 深色首页导航追加 2026-10-20 13:48:51 +08:00
Zhou Weidong ef526c6360 docs(plans): 留痕 K33-B feed 浮层重做 2026-10-20 13:46:36 +08:00
Zhou Weidong e66e1ed922 docs(plans): 补充 K33-A 用例清理留痕 2026-10-20 13:36:30 +08:00
Zhou Weidong c43321e19f docs(plans): 留痕 K33-A 首页沉浸式 feed 2026-10-20 13:35:25 +08:00
Zhou Weidong aa69d4b0c6 docs(plans): 留痕 K32-A 识别入口移除 2026-10-20 13:27:13 +08:00
Zhou Weidong 3755b766af docs(plans): 留痕 K32-A 剧场页重做 2026-10-20 13:24:38 +08:00
Zhou Weidong 83f7890851 docs(plans): 留痕 K31-G 首页通知铃铛 2026-10-20 13:12:00 +08:00
Zhou Weidong dfa24c0f43 docs(plans): 留痕 K31-F 移除锁定角标 2026-10-20 13:08:18 +08:00
Zhou Weidong 6435238ad7 docs(plans): 留痕 K31-E 首页剧卡压缩 2026-10-20 13:03:35 +08:00
Zhou Weidong 4aa80d0fc7 docs(plans): 留痕 K31-D 首页频道吸顶 2026-10-20 12:56:27 +08:00
Zhou Weidong 02a675a07c docs(plans): 留痕 K31-C 频道选中态主题色 2026-10-20 12:50:49 +08:00
Zhou Weidong c9561edb63 docs(plans): 留痕 K31-B 首页频道与轮播改版 2026-10-20 12:45:33 +08:00
Zhou Weidong ccf12cac8c docs(plans): 留痕 K31-A 加速态底部修正 2026-10-20 12:40:02 +08:00
Zhou Weidong 7d3d131f28 docs(plans): 恢复 INBOX 完整条目历史 2026-10-20 12:37:13 +08:00
Zhou Weidong fa6f443a98 docs(plans): 留痕 K31-A 播放页长按手势 2026-10-20 12:36:48 +08:00
Zhou Weidong 729b369408 docs(plans): 留痕 K30-F 半透明播放按钮 2026-10-20 12:30:15 +08:00
Zhou Weidong 065318accd docs(plans): 留痕 K30-F 播放按钮放大 2026-10-20 12:27:23 +08:00
Zhou Weidong 2cf9009a8c docs(plans): 留痕 K30-F 简介与暂停按钮修正 2026-10-20 12:24:55 +08:00
Zhou Weidong 447a767617 docs(plans): 留痕 K30-E 选集条箭头靠右 2026-10-20 12:19:36 +08:00
Zhou Weidong 8b176a6880 docs(plans): 留痕 K30-D 评论输入框修正 2026-10-20 12:16:19 +08:00
Zhou Weidong fdf3f04b23 docs(plans): 留痕 K30-C 评论面板视觉重做 2026-10-20 12:12:01 +08:00
Zhou Weidong 97b55185e6 docs(plans): 合并 K30-C 任务 2026-10-20 12:09:48 +08:00
Zhou Weidong 811b78375e docs(plans): 下发 K30-C 评论面板重做 2026-10-20 12:09:48 +08:00
Zhou Weidong 97b5c4ac37 docs(plans): 留痕 K30-B 播放页数量与评论面板 2026-10-20 12:06:36 +08:00
Zhou Weidong 0877b1bb0f docs(plans): 留痕 K30-A 详情互动数量 2026-10-20 12:02:36 +08:00
Zhou Weidong f19d21ef96 merge: 合并 K30-A 详情互动数量 2026-10-20 12:02:19 +08:00
Zhou Weidong 671953bff3 feat(app-api): 详情补充互动数量字段 2026-10-20 12:02:16 +08:00
Zhou Weidong 678652cb20 docs(plans): 合并 K30 任务 2026-10-20 11:59:00 +08:00
Zhou Weidong de584257e5 docs(plans): 下发 K30 互动数量与评论面板 2026-10-20 11:59:00 +08:00
Zhou Weidong a4ea054df7 docs(plans): 留痕 K29.9 播放页底部细节 2026-10-20 11:55:59 +08:00
Zhou Weidong d91d1d59b9 docs(plans): 合并 K29.9 任务 2026-10-20 11:51:53 +08:00
Zhou Weidong 16316e2f0a docs(plans): 下发 K29.9 播放页底部细节 2026-10-20 11:51:52 +08:00
Zhou Weidong caab79d4c2 docs(plans): 留痕 K29.8 更多面板小窗播放 2026-10-20 11:48:15 +08:00
Zhou Weidong 939d985f67 docs(plans): 留痕 K29.7 更多面板单行排列 2026-10-20 11:45:04 +08:00
Zhou Weidong c93ba886b4 docs(plans): 合并 K29.8 任务 2026-10-20 11:42:10 +08:00
Zhou Weidong 674b3d4ac8 docs(plans): 下发 K29.8 更多弹窗小窗播放 2026-10-20 11:42:10 +08:00
Zhou Weidong 4b0ca0e9e7 docs(plans): 合并 K29.7 任务 2026-10-20 11:41:48 +08:00
Zhou Weidong 3073852153 docs(plans): 下发 K29.7 弹窗单行排列 2026-10-20 11:41:48 +08:00
Zhou Weidong ac0639e157 docs(plans): 记录 K29.6 更多面板精简 2026-10-20 11:40:52 +08:00
Zhou Weidong e95bc0b513 docs(plans): 合并 K29.6 任务 2026-10-20 11:37:36 +08:00
Zhou Weidong b9d8a0aa9a docs(plans): 下发 K29.6 更多弹窗精简 2026-10-20 11:37:36 +08:00
Zhou Weidong edc0e24021 docs(plans): 修正 K29.5 进度条口径 2026-10-20 11:36:04 +08:00
Zhou Weidong 5b5c590cad docs(plans): 记录 K29.5 清屏范围 2026-10-20 11:33:52 +08:00
Zhou Weidong 2a1e3ac8b7 docs(plans): 合并 K29.5 进度条口径 2026-10-20 11:31:29 +08:00
Zhou Weidong 9cbfa874c9 docs(plans): K29.5 进度条清屏时保留 2026-10-20 11:31:29 +08:00
Zhou Weidong bd804ff05e docs(plans): 合并 K29.5 任务 2026-10-20 11:30:42 +08:00
Zhou Weidong 5c7128136d docs(plans): 下发 K29.5 清屏范围 2026-10-20 11:30:42 +08:00
Zhou Weidong cd2ab673a4 docs(plans): 记录 K29.4 透明播放页 2026-10-20 11:29:29 +08:00
Zhou Weidong dc124b17a9 docs(plans): 合并 K29.4 任务 2026-10-20 11:26:24 +08:00
Zhou Weidong 691ef33e5b docs(plans): 下发 K29.4 播放页去背景 2026-10-20 11:26:24 +08:00
Zhou Weidong c9b70e4e9b docs(plans): 记录 K29.3 深色播放器 2026-10-20 11:25:47 +08:00
Zhou Weidong 6e0757a246 docs(plans): 合并 K29.3 任务 2026-10-20 11:22:39 +08:00
Zhou Weidong 5c8088a29c docs(plans): 下发 K29.3 深色播放页 2026-10-20 11:22:39 +08:00
Zhou Weidong 4541ca6bce docs(plans): 记录 K29.2 底栏清屏 2026-10-20 11:19:47 +08:00
Zhou Weidong fe84d8b7b5 docs(plans): 合并 K29.2 任务 2026-10-20 11:16:59 +08:00
Zhou Weidong 8d9e3afc9a docs(plans): 下发 K29.2 底栏清屏 2026-10-20 11:16:59 +08:00
Zhou Weidong 988362db00 docs(plans): 记录 K29.1 播放页视觉收紧 2026-10-20 11:14:23 +08:00
Zhou Weidong 1816142929 docs(plans): 合并 K29.1 黑底修正 2026-10-20 11:08:26 +08:00
Zhou Weidong 44d319a911 docs(plans): K29.1 增加去黑底要求 2026-10-20 11:08:26 +08:00
Zhou Weidong 4ae07c7734 docs(plans): 合并 K29.1 任务 2026-10-20 11:07:30 +08:00
Zhou Weidong 0cc5d4f0ee docs(plans): 下发 K29.1 播放页视觉收紧 2026-10-20 11:07:30 +08:00
Zhou Weidong 10bb3dbe6e docs(plans): 修正 K29 最终门禁记录 2026-10-20 11:04:05 +08:00
Zhou Weidong 3334b45334 docs(plans): 记录 K29 播放页与语言入口 2026-10-20 11:02:18 +08:00
Zhou Weidong c1d29baa87 docs(plans): 合并 K29 任务 2026-10-20 10:48:53 +08:00
Zhou Weidong 4e2f80fefa docs(plans): 下发 K29 播放页与语言入口 2026-10-20 10:48:53 +08:00
Zhou Weidong df2023a523 docs(plans): 记录 K28-C 播放页与四面板 2026-10-20 10:42:58 +08:00
Zhou Weidong 62a0d3636d docs(plans): 记录 K28-B 创作与我的页面 2026-10-20 10:28:52 +08:00
Zhou Weidong 73404d68ed docs(plans): 记录 K28-A2 剧场重设计 2026-10-20 10:24:42 +08:00
Zhou Weidong 2715f4babe docs(plans): 合并 K28-R 重设计任务 2026-10-20 10:20:59 +08:00
Zhou Weidong e15926a15e docs(plans): 下发 K28-R 剩余 UI 重设计 2026-10-20 10:20:59 +08:00
Zhou Weidong bc426f73a3 docs(plans): 记录 K28-S 四槽导航修正 2026-10-20 10:18:40 +08:00
Zhou Weidong f58814eaea docs(plans): 记录 K28-S 浅色暖橙主题 2026-10-20 10:15:46 +08:00
Zhou Weidong c35507681e docs(plans): 合并 K28 导航口径修正 2026-10-20 10:07:52 +08:00
Zhou Weidong c40bd52a8e docs(plans): 修正 K28 导航仍为四槽 2026-10-20 10:07:52 +08:00
Zhou Weidong 691ec2e134 docs(plans): 合并 K28 视觉规范 2026-10-20 10:04:20 +08:00
Zhou Weidong 4a2e066608 docs(plans): 新增 K28 浅色暖橙视觉规范 2026-10-20 10:04:20 +08:00
Zhou Weidong 2ccfc14473 docs(plans): 标记 K28-A1 开始并去重 2026-10-20 09:58:14 +08:00
Zhou Weidong 6f18a8892b docs(plans): 记录 K28-A1 范围校正 2026-10-20 09:57:39 +08:00
Zhou Weidong b70e5a90fd docs(plans): 记录 K28-A 客户端 UI 重构 2026-10-20 09:50:15 +08:00
Zhou Weidong ae3c62b60f docs(plans): 合并 K28-A 拆分 2026-10-20 09:33:02 +08:00
Zhou Weidong bb0b7377fe docs(plans): 拆分 K28-A1 与 A2 2026-10-20 09:33:02 +08:00
Zhou Weidong 0ded8ddf19 docs(plans): 合并 K28 UI 任务书 2026-10-20 09:31:32 +08:00
Zhou Weidong b30123b727 docs(plans): 新增 K28 UI 简图任务书 2026-10-20 09:31:31 +08:00
Zhou Weidong 3d560b8caf docs(plans): 记录红果式UX改造与短剧上线流程留痕 2026-10-20 09:11:16 +08:00
Zhou Weidong 923fab80e8 Merge branch 'develop' of http://git.reelshort2.com/jianghaifeng/FruitServer into develop
# Conflicts:
#	docs/plans/INBOX.md
#	docs/plans/PROGRESS.md
2026-10-20 04:23:52 +08:00
Zhou Weidong 63b086998e feat(backend): 补齐 C 端商业化与账号缺口(金币充值/会员/搜索/语言筛选/Google登录/短信/商店可配置接入) 2026-10-20 04:20:42 +08:00
Zhou Weidong eeb5e0eaf8 docs(plans): 记录 release 合并 develop 2026-10-19 21:40:51 +08:00
Zhou Weidong e26e57ac18 docs(plans): 下发 K27.16 develop 合并 release 2026-10-19 21:18:44 +08:00
Zhou Weidong 83f13e9a6e docs(plans): 记录 Web release 修复同步 develop 2026-10-19 18:47:18 +08:00
Zhou Weidong 8b37e23951 feat: 印尼语敏感词库种子数据(id-ID 增量补全 78 词) 2026-10-19 18:43:01 +08:00
Zhou Weidong 212834d59d fix(backend): 修复上传转码与短剧发布链路(multipart 上限、转码防重、剧集发布、schema 字段) 2026-10-19 18:38:48 +08:00
Zhou Weidong 793d6e542a docs(plans): 记录 K27.15 API 地址恢复 2026-10-19 18:32:08 +08:00
Zhou Weidong 8b64060388 docs(plans): 下发 K27.15 API 地址改回 192.168.31.187 2026-10-19 18:29:48 +08:00
Zhou Weidong 7aebcba35c docs(plans): 记录 K27.14 API 地址 2026-10-19 18:28:02 +08:00
Zhou Weidong 2441234dba docs(plans): 下发 K27.14 API 地址改为 172.18.0.7 2026-10-19 18:26:05 +08:00
Zhou Weidong 1abc190f95 docs(plans): 记录 K27.13 API 地址 2026-10-19 18:20:22 +08:00
Zhou Weidong ab891a0840 docs(plans): 下发 K27.13 客户端 API 地址 2026-10-19 18:18:24 +08:00
Zhou Weidong 76e6c3546f docs(plans): 记录 K27.12 瀑布流刷新 2026-10-19 18:11:16 +08:00
Zhou Weidong 01911016d2 docs(plans): 下发 K27.12 发现页瀑布流与下拉刷新 2026-10-19 18:08:07 +08:00
Zhou Weidong 244a6ce79e docs(plans): 记录 K27.11 搜索失焦 2026-10-19 18:04:48 +08:00
Zhou Weidong f51f07d77e docs(plans): 记录 K27.10 封面调整 2026-10-19 18:02:32 +08:00
Zhou Weidong bd53efc4c4 docs(plans): 下发 K27.11 搜索框失焦 2026-10-19 18:00:49 +08:00
Zhou Weidong 317daf4c2b docs(plans): 下发 K27.10 发现页封面不裁剪 2026-10-19 18:00:04 +08:00
Zhou Weidong 01990259af docs(plans): 记录 K27.9 固定顶栏 2026-10-19 17:55:38 +08:00
Zhou Weidong d7e69fac80 docs(plans): 下发 K27.9 发现页固定顶栏 2026-10-19 17:51:48 +08:00
Zhou Weidong 0d2e6ba87f docs(plans): 记录 K27.8 rail 与倍速 2026-10-19 17:44:51 +08:00
Zhou Weidong 8767200235 docs(plans): 下发 K27.8 互动下移与倍速弹窗 2026-10-19 17:40:39 +08:00
Zhou Weidong d977f1472b docs(plans): 记录 K27.7 倍速弹窗 2026-10-19 17:33:03 +08:00
Zhou Weidong f6d6887b84 docs(plans): 记录 K27.6 清屏共享 2026-10-19 17:29:49 +08:00
Zhou Weidong 218bd880e1 docs(plans): 下发 K27.7 倍速弹窗 2026-10-19 17:26:47 +08:00
Zhou Weidong 4081b9c893 docs(plans): 下发 K27.6 集数左贴与清屏跨集共享 2026-10-19 17:26:19 +08:00
Zhou Weidong 19c608fb31 docs(plans): 记录 K27.5 中心按钮口径 2026-10-19 17:21:07 +08:00
Zhou Weidong 980ce77192 docs(plans): 下发 K27.5 中心按钮仅暂停时显示 2026-10-19 17:18:20 +08:00
Zhou Weidong 4070c4e39e docs(plans): 记录 K27.4 清屏保持 2026-10-19 17:14:50 +08:00
Zhou Weidong c21f0309ba docs(plans): 下发 K27.4 清屏保持与中心按钮常驻 2026-10-19 17:11:10 +08:00
Zhou Weidong 8a37996617 docs(plans): 记录 K27.3 清屏态修正 2026-10-19 17:06:44 +08:00
Zhou Weidong 5c2c273798 docs(plans): 下发 K27.3 清屏态点击与位置修正 2026-10-19 17:03:27 +08:00
Zhou Weidong e22e942c27 docs(plans): 记录 K27.2 退出清屏 2026-10-19 16:54:08 +08:00
Zhou Weidong 03e17166ab docs(plans): 记录 K27.1 暂停按钮口径 2026-10-19 16:51:32 +08:00
Zhou Weidong e03215f445 docs(plans): 下发 K27.2 退出清屏按钮 2026-10-19 16:49:30 +08:00
Zhou Weidong 80a3b27353 docs(plans): 下发 K27.1 中心按钮口径 2026-10-19 16:48:46 +08:00
Zhou Weidong 7801fad1b5 docs(plans): 标记 K27 已完成待验收 2026-10-19 16:46:54 +08:00
Zhou Weidong 98b3d30ca0 docs(plans): 记录 K27 清屏交互 2026-10-19 16:44:42 +08:00
Zhou Weidong d9088be2a6 docs(plans): 下发 K27 播放器控制层交互重做 2026-10-19 16:39:48 +08:00
Zhou Weidong 8e70dd220a docs(plans): 记录详情选集网格修复 2026-10-19 16:33:30 +08:00
Zhou Weidong 7b1030c1ba docs(plans): 记录六屏间距修复 2026-10-19 16:26:15 +08:00
Zhou Weidong 258be6e4e7 docs(plans): 记录播放器控制层修复 2026-10-19 16:17:06 +08:00
Zhou Weidong 696e36a991 docs(plans): 记录暂停按钮修复 2026-10-19 16:07:56 +08:00
Zhou Weidong 71e2f266ff docs(plans): 记录 K26 旧 UI 清理 2026-10-19 15:59:16 +08:00
Zhou Weidong 6fd532beae docs(plans): 记录 K26 六屏预览校正 2026-10-19 15:51:19 +08:00
Zhou Weidong 5cc6ce9d32 docs(plans): 下发 K26 移动端设计预览校正任务 2026-10-19 15:35:28 +08:00
Zhou Weidong 7613726b1a docs(plans): 记录播放器全屏修复 2026-10-19 15:24:49 +08:00
Zhou Weidong c69327062b docs(plans): 记录 K25 详情页与播放器 2026-10-19 15:17:34 +08:00
Zhou Weidong f21a334ca8 docs(plans): 下发 K25 详情页与播放器任务 2026-10-19 15:07:40 +08:00
Zhou Weidong cd711c5d5c docs(plans): 更新 K24 最终门禁输出 2026-10-19 15:05:10 +08:00
Zhou Weidong 0f8652c083 docs(plans): 记录 K24 客户端设计稿对齐 2026-10-19 15:03:04 +08:00
Zhou Weidong 93b2299630 docs(plans): 下发 K24 客户端设计稿对齐任务 2026-10-19 14:48:20 +08:00
Zhou Weidong d88a7de2eb docs(plans): 记录剧集编辑弹窗 2026-10-19 13:55:47 +08:00
Zhou Weidong 68d101cc8d docs(plans): 记录图片预览与轮播弹窗 2026-10-19 13:49:32 +08:00
Zhou Weidong 6127fef897 docs(plans): 记录剧集与子菜单布局修复 2026-10-19 13:32:27 +08:00
Zhou Weidong 3cbdca6861 docs(plans): 记录工作台菜单图标修复 2026-10-19 13:23:03 +08:00
Zhou Weidong c491c329af docs(plans): 记录 K23.1 布局修复 2026-10-19 12:58:40 +08:00
Zhou Weidong 801e904552 docs(plans): 记录 K23.1 后台修正回执 2026-10-19 12:35:01 +08:00
Zhou Weidong 38c8f51315 docs(plans): 增加 K23.1 后台修正计划 2026-10-19 12:25:42 +08:00
Zhou Weidong b8676d0731 docs(plans): 下发 K23.1 后台修正任务 2026-10-19 12:24:49 +08:00
Zhou Weidong 477014f64b docs(plans): 记录 K23 菜单对齐修复 2026-10-19 12:04:35 +08:00
Zhou Weidong 0ee3e4759e docs(plans): 记录 K23 后台视觉深化回执 2026-10-19 12:02:33 +08:00
Zhou Weidong b400e36d8f fix(sql): 清空二级菜单图标 2026-10-19 12:00:59 +08:00
Zhou Weidong 91bbfd5622 docs(plans): 更正 K23 子菜单图标口径 2026-10-19 11:52:29 +08:00
Zhou Weidong 5356f24632 docs(plans): 增加 K23 后台视觉深化计划 2026-10-19 11:50:04 +08:00
Zhou Weidong 9c96e18672 docs(plans): 下发 K23 后台视觉深化任务 2026-10-19 11:49:01 +08:00
Zhou Weidong ce655ce5db docs(plans): 记录首屏 loading 文字修复 2026-10-19 11:34:31 +08:00
Zhou Weidong c0fea67442 docs(plans): 记录 K22.1 后台收尾回执 2026-10-19 11:32:37 +08:00
Zhou Weidong 8aa84a39bc docs(plans): 增加 K22.1 后台收尾计划 2026-10-19 11:23:47 +08:00
Zhou Weidong 2807ae6a47 docs(plans): 下发 K22.1 后台收尾任务 2026-10-19 11:22:49 +08:00
Zhou Weidong c4281b08b7 docs(plans): 补充 K22 路由图标复核记录 2026-10-19 11:18:34 +08:00
Zhou Weidong 919e749554 docs(plans): 记录 K22 后台视觉统一回执 2026-10-19 11:14:21 +08:00
Zhou Weidong be9e381612 chore(sql): 合并 K22 菜单图标 2026-10-19 11:13:17 +08:00
Zhou Weidong c14b4cb9ad fix(sql): 统一后台菜单图标 2026-10-19 11:13:13 +08:00
Zhou Weidong 5e5517135a docs(plans): 增加 K22 后台视觉统一计划 2026-10-19 10:57:43 +08:00
Zhou Weidong b025bfda36 docs(plans): 下发 K22 后台视觉与图标统一任务 2026-10-19 10:55:46 +08:00
Zhou Weidong 81c1bbb1e5 docs(plans): 记录 K21 登录页优化回执 2026-10-19 10:35:11 +08:00
Zhou Weidong 0e3ddfc1b1 docs(plans): 增加 K21 登录页实施计划 2026-10-19 10:31:21 +08:00
Zhou Weidong af28161e2f docs(plans): 下发 K21 登录页 UI 优化任务 2026-10-19 10:30:33 +08:00
Zhou Weidong f6187df51a docs(plans): 记录 K20 视觉统一回执 2026-10-19 10:24:13 +08:00
Zhou Weidong 34357846f4 docs(plans): 增加 K20 视觉统一实施计划 2026-10-19 10:15:20 +08:00
Zhou Weidong 810d9fc2dc docs(plans): 下发 K20 客户端视觉统一任务 2026-10-19 10:14:15 +08:00
Zhou Weidong 131f0c4e06 docs(plans): 记录 K19 网络文案修复 2026-10-19 10:01:18 +08:00
Zhou Weidong 28d2679b83 docs(plans): 记录 K19 账号中心交付回执 2026-10-19 09:56:22 +08:00
Zhou Weidong 6b5e70e554 docs(plans): 增加 K19 账号中心实施计划 2026-10-19 09:46:27 +08:00
Zhou Weidong 5c93ba9131 docs(plans): 下发 K19 账号中心接口对接任务 2026-10-19 09:44:41 +08:00
Zhou Weidong 1bf74bdd4c Merge remote-tracking branch 'origin/develop' into feature/deploy-admin-api-lan-access 2026-10-19 09:39:37 +08:00
Zhou Weidong d3b347aa09 fix(backend): 合并 K16 注册限流配置返工 2026-10-17 23:40:37 +08:00
Zhou Weidong 21e0d7f383 docs(plans): 记录 K16 返工与限流往返验证 2026-10-17 23:40:22 +08:00
Zhou Weidong 35d92c0148 fix(backend): 后台注册限流配置优先并保留注入兜底 2026-10-17 23:39:48 +08:00
Zhou Weidong 903e7db085 docs(api): 合并注册限流配置键名修正 2026-10-17 23:35:38 +08:00
Zhou Weidong f68cd78f8b docs(api): 修正注册限流配置键名 2026-10-17 23:35:31 +08:00
Zhou Weidong 575187a678 docs(plans): 合并 K16 注册限流后台配置回执 2026-10-17 23:33:39 +08:00
Zhou Weidong c1c3c1ce9a docs(plans): 记录 K16 注册限流后台配置回执 2026-10-17 23:33:28 +08:00
Zhou Weidong 1493fce319 merge: 同步 K16 最新测试到回执分支 2026-10-17 23:32:47 +08:00
Zhou Weidong 5b7be2e28c merge: 合并注册限流缺省回退测试 2026-10-17 23:31:22 +08:00
Zhou Weidong f4003edc58 test(backend): 覆盖注册限流缺省回退 2026-10-17 23:31:11 +08:00
Zhou Weidong 56300754b3 merge: 合并 K16 后台注册限流配置修正 2026-10-17 23:29:40 +08:00
Zhou Weidong f1aaaee814 fix(backend): 改为后台配置注册限流阈值 2026-10-17 23:29:25 +08:00
Zhou Weidong 78fb99530d merge: 合并 K16 注册限流配置化 2026-10-17 23:22:51 +08:00
Zhou Weidong 758eb69f55 feat(backend): 注册限流阈值改为可配置 2026-10-17 23:22:28 +08:00
Zhou Weidong 3403a521eb docs(plans): 增加 K16 注册限流配置化计划 2026-10-17 23:16:51 +08:00
Zhou Weidong 461fb8ea15 docs(plans): K16 改为后台可配置口径 2026-10-17 23:16:31 +08:00
Zhou Weidong 6c966af5b2 docs(plans): 下发 K16 注册限流配置化任务 2026-10-17 23:15:24 +08:00
Zhou Weidong 81fcd9a817 docs(plans): 合并 K15 首页服务器修复回执 2026-10-17 23:14:08 +08:00
Zhou Weidong 8926f9b5f3 docs(plans): 记录 K15 首页服务器修复回执 2026-10-17 23:13:46 +08:00
Zhou Weidong 10794fa615 docs(plans): 合并 K14 客户端文档回执 2026-10-17 23:04:52 +08:00
Zhou Weidong 2490c78473 docs(plans): 记录 K14 客户端文档回执 2026-10-17 23:04:29 +08:00
Zhou Weidong 538a7df258 docs(plans): 下发 K14 客户端文档任务 2026-10-17 22:59:07 +08:00
Zhou Weidong 8b4e0ff5ca docs(plans): 合并 K13 开发服务器地址回执 2026-10-17 22:55:30 +08:00
Zhou Weidong 70b9823d5b docs(plans): 记录 K13 开发服务器地址回执 2026-10-17 22:55:10 +08:00
Zhou Weidong 6405dff7c5 docs(plans): 增加 K13 开发服务器地址实施计划 2026-10-17 22:49:58 +08:00
Zhou Weidong 1a5951ce98 docs(plans): 下发 K13 开发服务器地址设置 2026-10-17 22:48:10 +08:00
Zhou Weidong cd1c2bc34d docs(plans): 合并 K12 首页与空态视觉回执 2026-10-17 22:45:36 +08:00
Zhou Weidong b70449b232 docs(plans): 记录 K12 首页与空态视觉回执 2026-10-17 22:45:20 +08:00
Zhou Weidong e49664ce55 docs(plans): 增加 K12 首页与空态视觉修订计划 2026-10-17 22:40:28 +08:00
Zhou Weidong 87a37e987a docs(plans): 下发 K12 首页与空态视觉修订 2026-10-17 22:39:37 +08:00
Zhou Weidong fe2e179bdc docs(plans): 合并 K11 首页三列网格回执 2026-10-17 22:35:38 +08:00
Zhou Weidong 73292ba74d docs(plans): 记录 K11 首页三列网格实施回执 2026-10-17 22:35:19 +08:00
Zhou Weidong f97574b57e docs(plans): 增加 K11 首页三列网格实施计划 2026-10-17 22:30:39 +08:00
Zhou Weidong 2a06190cfe docs(plans): 下发 K11 首页三列网格任务 2026-10-17 22:29:26 +08:00
Zhou Weidong aa54e03d52 docs(plans): 合并 K10 统一空态回执 2026-10-17 21:56:48 +08:00
Zhou Weidong 3b2afc9f47 docs(plans): 记录 K10 统一空态实施回执 2026-10-17 21:56:35 +08:00
Zhou Weidong f1d56b1040 docs(plans): 增加 K10 统一空态实施计划 2026-10-17 17:39:28 +08:00
Zhou Weidong 3f84bc6568 docs(plans): 下发 K10 统一空态任务 2026-10-17 17:38:25 +08:00
Zhou Weidong deb6111419 docs(plans): 合并 iOS 依赖修复回执 2026-10-17 17:31:29 +08:00
Zhou Weidong a1f88b6553 docs(plans): 记录 iOS 依赖编译修复回执 2026-10-17 17:31:19 +08:00
Zhou Weidong b049db0162 docs(plans): 合并 K9 首页剧目网格回执 2026-10-17 17:25:48 +08:00
Zhou Weidong a5dad549f8 docs(plans): 记录 K9 首页剧目网格实施回执 2026-10-17 17:25:36 +08:00
Zhou Weidong 496d27334a docs(plans): 增加 K9 首页剧目网格实施计划 2026-10-17 17:21:33 +08:00
Zhou Weidong b434f6594b docs(plans): 下发 K9 首页剧目网格改造 2026-10-17 17:20:35 +08:00
Zhou Weidong ad13692b5d docs(plans): 合并 K8 登录页视觉回执 2026-10-17 17:15:53 +08:00
Zhou Weidong b5aed111a0 docs(plans): 记录 K8 登录页视觉实施回执 2026-10-17 17:15:43 +08:00
Zhou Weidong e7c11c3163 docs(plans): 增加 K8 登录页视觉实施计划 2026-10-17 17:09:29 +08:00
Zhou Weidong c33e6cdc9d docs(plans): 下发 K8 登录页视觉重设计 2026-10-17 17:07:56 +08:00
Zhou Weidong d51167550f docs(plans): 合并真机缺陷修复回执 2026-10-17 17:00:59 +08:00
Zhou Weidong ea97c8bc1b docs(plans): 记录首页轮播与观看历史真机修复回执 2026-10-17 17:00:47 +08:00
Zhou Weidong 9c6aaed645 docs(plans): 合并 K7 实施回执 2026-10-17 16:51:33 +08:00
Zhou Weidong c43604d725 docs(plans): 记录 K7 轮播与通知实施回执 2026-10-17 16:51:22 +08:00
Zhou Weidong f36fea083b docs(plans): 下发 K7 轮播图与站内信任务 2026-10-17 16:45:08 +08:00
Zhou Weidong 3afd4c6709 docs(plans): 合并 K6 缺陷修复回执 2026-10-17 16:42:23 +08:00
Zhou Weidong 0aeb9086d6 docs(plans): 记录 K6 导航与观看历史修复 2026-10-17 16:42:10 +08:00
Zhou Weidong c8c085b4e0 docs(plans): 合并 iOS SPM 修复回执 2026-10-17 16:33:11 +08:00
Zhou Weidong 7374045065 docs(plans): 记录 iOS SPM 构建修复与限制 2026-10-17 16:32:55 +08:00
Zhou Weidong 927d6ceab8 docs(plans): 合并 K6 实施回执 2026-10-17 16:17:28 +08:00
Zhou Weidong ab0d69914f docs(plans): 记录 K6 登录导航与震动回执 2026-10-17 16:17:16 +08:00
Zhou Weidong 93af19e548 docs(plans): K6 增加点击震动反馈要求 2026-10-17 16:07:05 +08:00
Zhou Weidong 79a1194257 docs(plans): 下发 K6 登录与底部导航 UI 任务 2026-10-17 16:04:11 +08:00
Zhou Weidong 61634415a9 docs(plans): 合并 K5 实施回执 2026-10-17 15:59:24 +08:00
Zhou Weidong 1bf0f654fb docs(plans): 记录 K5 主题与印尼语实施回执 2026-10-17 15:59:14 +08:00
Zhou Weidong f05476f6db docs(plans): 下发 K5 克莱因蓝主题任务 2026-10-17 15:51:04 +08:00
Zhou Weidong 61e85ab501 docs(plans): 合并 K3 修复回执 2026-10-17 15:30:07 +08:00
Zhou Weidong c2bd096ef3 docs(plans): 补记 K3 举报错误透传修复 2026-10-17 15:29:59 +08:00
Zhou Weidong 22f458606d docs(plans): 合并 K3 互动任务回执 2026-10-17 15:27:53 +08:00
Zhou Weidong 969538e6c9 docs(plans): 记录 K3 互动实施与验收回执 2026-10-17 15:27:42 +08:00
Zhou Weidong a0156ee946 docs(plans): 下发 K3 互动任务 2026-10-17 15:09:12 +08:00
Zhou Weidong 4a658fc67e docs(plans): 补齐 K2 真机验收与门禁回执 2026-10-17 15:02:28 +08:00
Zhou Weidong 698bb78aeb docs(plans): 记录 K2 实施与验收回执 2026-10-17 14:50:51 +08:00
jianghaifeng 4118efd0ca docs(inbox): 按 specs 补全 K2 执行规范与门禁 2026-10-17 14:17:17 +08:00
jianghaifeng 096e90c4c6 docs(inbox): 记录用户裁定——K2 先按现状契约执行,契约缺口后期再补 2026-10-17 14:12:37 +08:00
jianghaifeng 582453f0bd docs(inbox): 下发 K2 客户端第二批(分类/记录/收藏/下载授权 + UI) 2026-10-17 14:11:22 +08:00
jianghaifeng bad3bf4e79 docs: 记录 specs 只同步 main / specs 权威 / 执行前必读 INBOX 的裁定 2026-10-17 14:06:50 +08:00
jianghaifeng 7637e322ec docs: 记录 2026-10-17 三库全量拉取与 main→develop 同步结果 2026-10-17 13:50:01 +08:00
jianghaifeng 2d70858887 docs(plans): K1-verify 回执与留痕(真机验证交回用户,标注未验证) 2026-10-16 18:06:02 +08:00
jianghaifeng 53ac5d60d1 docs(plans): 新增 K1-verify 条目 + 红线第 10 条(设备验证必须设备侧取证)
背景:1-3/1-4 的真机验证用了错地址 10.157.242.1(开发机 VPN 网卡、手机不可达),
且当时的证据是本机 curl 本机网卡地址返回 200 —— 假阳性,已导致真机首页加载失败。
本条目要求用正确地址(ipconfig getifaddr en0 取)重做,且证据必须产自设备侧
(首选:integration_test 在手机进程里真请求并断言),拿不到就如实标「未验证」。
红线新增第 10 条,防止再犯。
2026-10-16 17:54:36 +08:00
jianghaifeng 40e2812677 docs(plans): K1 回执(客户端详情+播放+续看+收藏,含真数据/假数据清单) 2026-10-16 17:39:36 +08:00
jianghaifeng 59d064f8c6 docs(plans): 新增 K1 条目(客户端对接:详情+播放+进度续看+收藏,含 UI 三态)
PM 勘察:客户端只调了 6 个 C 端接口(契约已实现 44 个);无 playback/watch/library
api 类;WatchHistoryRepository 仍是 SharedPreferences 本地存储、mine_logic 用 demoProfileId
假数据;EngagementRepository 亦为本地。K1 打通首页→详情→播放→续看→收藏一条链路。
2026-10-16 17:25:00 +08:00
Zhou Weidong 9267a9026f Merge remote-tracking branch 'origin/develop' into feature/deploy-admin-api-lan-access 2026-10-16 16:59:51 +08:00
jianghaifeng 0cab094194 docs(plans): J1 回执(全局 ID 字符串化 + 契约统一),标为已处理·待用户验收 2026-10-16 16:48:07 +08:00
jianghaifeng f35db2cd3b feat(framework): 全局 Long 字符串化,ID 一律以字符串下发
用户裁定方案 (a):雪花 id 精度问题全局修,统一契约。

- 新增 JacksonLongToStringConfig(fruitshorts-framework):Long/Long.TYPE → ToStringSerializer,
  经 Jackson2ObjectMapperBuilderCustomizer 挂进 Spring Boot 的 ObjectMapper;admin-api 与
  app-api 都是 scanBasePackages=com.fruitshorts,一处生效两端一致
- 例外:PageResult.total 是计数不是 id,用 mixin 显式保持数字(前端分页组件要数字);
  Integer/int(pageIndex/pageSize/Result.code 等)不受影响
- 反序列化方向不变:请求体 'id:123'、路径变量、查询参数都接受字符串
- 契约 api-phase1.md §0 新增「ID 字段一律以字符串下发」总口径,§2.11/§2.12 改为引用 §0;
  I1 里逐字段加的 @JsonSerialize 与 Result<String> 回退成自然写法,避免多套口径
- 测试:新增 LongToStringSerializationTest(普通/嵌套/List/Map/分页 + 反序列化 + 19 位路径变量),
  契约测试的 standalone mapper 也挂上模块(否则测试假绿);C 端 4 条 id 断言改成 isString
- mvn test:commerce 33 / admin-api 21 / app-api 49 / worker 12 = 115 条全过
2026-10-16 16:44:13 +08:00
jianghaifeng c503629bf9 docs(plans): 新增 J1 条目(全局 ID 字符串化 + 契约统一,用户裁定方案 a)
雪花 id 19 位超出 JS 安全整数,I1 期间 Codex 实测与 PM 独立复核均复现
(点下架回 200002、/admin/app-users/page 下发裸数字)。用户裁定全局修改并统一契约。
本条目给出范围:framework 全局 Long→String、契约 §0 总口径、两个前端适配、
验证要求(含 node 逐位比对证据)与门禁。
2026-10-16 16:36:23 +08:00
jianghaifeng 10264c4ef6 docs(plans): I1 回执补充禅道同步说明(禅道暂无 I1 任务) 2026-10-16 16:34:45 +08:00
jianghaifeng e9877d78d8 docs(plans): I1 回执(商品配置 + 系统公告),标为已处理·待用户验收 2026-10-16 16:34:18 +08:00
jianghaifeng 8d19a0cc0a feat(admin): 商品配置与系统公告管理接口,id 以字符串下发
- /admin/products 六个接口 + 校验(code 唯一、store_product_id 同平台唯一、
  COIN_PACK 需 coin_amount>0、VIP 需 vip_days>0、VIP 只建不启)
- /admin/notices 六个接口,与 C 端 /app/notices 共用 OpsNoticeService.pageForApp 一套筛选口径
- 权限点 product:list/product:update/notice:list/notice:update + 菜单 31/32/33(sql/data-phase1.sql 幂等)
- 雪花 id 超过 JS 安全整数范围:id 以字符串下发,避免浏览器四舍五入导致回查 200002
- C 端商品查询收敛到 ProductService.findForStore,OrderService 不再自带一份
- 测试:module-commerce 33 / admin-api 16 / app-api 49 / worker 12,共 110 条全过
2026-10-16 16:33:15 +08:00
Zhou Weidong 20f19b13f2 fix(deploy): admin-api 端口改为对局域网开放以支持内网联调
compose 里 admin-api 原先把宿主端口绑在 127.0.0.1,只有本机能访问;
Web 后台在另一台机器上,走局域网连不上 8080,联调起不来。

改为监听局域网(8080:8080)。管理端仍然"仅内网":不在路由器做端口转发,
外网探测 154.16.27.173:8080 为 Connection refused,与
specs 04-部署架构 §四「管理后台仅内网/VPN 可访问」一致。

门禁:docker compose config 校验通过;tools/smoke.sh 全部 PASS;
内网实测 http://192.168.31.250:8080/healthz 与 /doc.html 均 200。
本次只改编排文件,未触碰 Java 代码,故未重跑 mvn test。
2026-10-16 16:25:26 +08:00
jianghaifeng 7513ac06bc docs(plans): 指令通道迁入本仓库(specs 改为只拉取,由项目负责人更新)
用户口径:specs 仓库今后只拉取、由项目负责人维护,我们不再向其提交;工作留痕与
指令队列改放本仓库 docs/plans/ 下,避免本地领先提交与上游 INBOX 反复冲突。

本文件包含双 Agent 协作红线(含"验收归用户""门禁口径"),以及当前待处理条目
I1:后台管理端商品(金币包)配置 + 系统公告管理(后端接口 + ReelShortWeb 页面)。
2026-10-16 16:13:22 +08:00
jianghaifeng 5609b42dcd docs: 对齐契约 §3 与 README 文档索引的进度口径
按实测修正 4 处状态描述,消除"已完成/未完成"口径不一致:

- api-phase1.md §3「契约与实现状态对照」:
  04 多码率为「转码 worker(C1)已产出」;06 分享埋点标「待 C3」;
  09 由"下一批"改为「Google Play 票据校验闭环已实现,Apple IAP 只留端口」;
  10 由"压测与埋点 下一批"改为「C1 已实现,C2/C3/压测 未做」;
  并补最后更新时间与「未完成清单见 README」的指向
- README.md 文档索引:api-phase1 的描述由"标注已实现 / 下一批"
  改为"含「契约与实现状态对照」"

为什么改:契约里的状态表过期会直接误导排期与联调判断,属最容易踩的坑。
2026-10-16 15:32:05 +08:00
jianghaifeng 50ffc974db docs(readme): 更新第一阶段进度:区分已完成批次与未完成清单
原进度表严重过期(仍写 06 待做、07 创作者中心待做、08 部分、09 待做),
与代码和 api-phase1.md 都不一致,容易误导排期与联调。本次按实测重写:

- 模块表 01-10 逐条核对(04/05/06/07/08 改为已实现;09/10 标部分并写明差什么)
- 新增「已完成批次」表,列出本轮冲刺 13 个批次与对应提交,便于回溯
- 新增「尚未完成」清单,分四类:后端研发 6 项、上线前必补 6 项(P0/Go-No-Go)、
  基础设施 6 项、非后端范围(客户端与 Web 分工),每条标注依据文档
- 写明已作废方案:module-analytics 收敛因创作者中心改由 Web 端承载而不再需要

为什么改:README 是接手者第一眼看的文档,进度表过期会直接误导排期与联调。
2026-10-16 15:31:33 +08:00
jianghaifeng 4418a840ec refactor(app-api): 按新口径撤除 App 侧创作者中心的 works / stats
为什么:用户口径变更「创作者中心是 web 端,和 app 客户端没关系」,契约已由 PM 提交 bb21920
移除两条 C 端定义;本提交把代码对齐到新契约,App 侧创作者相关只保留入驻申请。

- 删除 GET /app/creator/works、GET /app/creator/stats 两个端点及无用注入
- 删除 CreatorStatsService.java(213 行,与后台 07 看板重复的独立实现)
- 删除随之成为死代码的:CreatorAppService.requireActiveCreator / myWorks、
  CreatorCenterService.pageMyDramasOf、ErrorCode.CREATOR_NOT_ACTIVE(200007)
- 删除对应测试 7 条(works/stats 契约 4 条 + requireActive 3 条);
  保留 apply 幂等与协议版本校验、config 白名单、notices 空列表
- 不动 POST /app/creator/apply、GET /app/config、GET /app/notices;不改 docs/api-phase1.md
- 实测:mvn test 98 → 91 条全过;package 通过;/v3/api-docs 接口数 41 → 39,
  creator 相关只剩 /app/creator/apply;两个已撤除路径对已登录客户端返回 404
2026-10-16 15:21:13 +08:00
jianghaifeng bb219203bc docs(api): 移除 /app/creator/works 与 /app/creator/stats 的 C 端定义
创作者中心(工作台 / 作品管理 / 数据看板)由 Web 端承载,创作者登录后台管理使用
(见 api-phase1.md §2.10),App 客户端不提供创作者中心。因此契约 §1.9 去掉这两条,
只保留「创作者入驻申请 + 启动配置 + 系统公告」;§1.9 标题与说明同步修正,
apply 幂等表里"已通过"一行改为指向 Web 端。

为什么要改:原定义要求 App 侧再实现一份「创作者数据看板」,虽与后台 07 看板同口径,
但代码完全独立,两份长期必然漂移(后台数字与创作者看到的数字对不上是信任问题)。
按产品口径创作者中心不上 App,这份重复实现没有存在意义,移除即从根上消除漂移风险,
也避免前端照旧契约开发后返工。
2026-10-16 15:14:34 +08:00
jianghaifeng 14b5e4137f feat(app-api): E1 补全 5 个 C 端接口(创作者入驻/作品/看板 + 启动配置/公告)
为什么:联调 10-19 开始,前端照 api-phase1.md §1.9 写会 404,需要先把这 5 个补齐。

契约先行(12-上线检查清单 §一):先重写 api-phase1.md §1.9 —— 把 commerce 三条移出本节标"已实现",
补齐 5 个接口的请求/响应字段、错误码、分页口径、幂等矩阵,并新增 200007 CREATOR_NOT_ACTIVE。

- GET /app/config(匿名):硬编码白名单 6 键 + 契约默认值兜底,绝不整表下发
- GET /app/notices(匿名):ops_notice 只读分页(status/target/发布与过期时间),无数据返回空列表
- POST /app/creator/apply:幂等,防重靠 DB 唯一索引 uk_creator_user,并发冲突读回既有记录
- GET /app/creator/works:非生效创作者报 200007 + 分状态提示,不返回空数组冒充成功
- GET /app/creator/stats:与后台 07 看板同口径(字段/notes 逐条一致),null + notes 语义保留
- 冻结模块零改动:module-creator 仅新增 CreatorAppService/两个 VO 与 1 个公开方法 pageMyDramasOf,
  既有 pageMyDramas 一行未动;module-system 仅新增 OpsNotice 实体与 Mapper
- Swagger 中文注解按 S1 规范补齐;白名单与 /app/notices 加进安全白名单
- 实测:mvn test 98 条全过(app-api 41→56);package 通过;端到端五接口全部符合预期
  (重复 apply 同 id + created:false、pending 看 works/stats 报 200007、approved 后正常)
2026-10-16 14:56:44 +08:00
jianghaifeng 4c3d524eff Merge pull request 'Feature/deploy fix local bringup' (#1) from feature/deploy-fix-local-bringup into develop
Reviewed-on: #1
2026-10-16 06:35:34 +00:00
Zhou Weidong 4c4f64323a fix(worker): 补齐 prod 密钥与 CORS 映射以通过密钥校验
worker 的 application-prod.yml 没把 JWT_ADMIN_SECRET / JWT_APP_SECRET /
MEDIA_SIGNATURE_SECRET / CORS_ALLOWED_ORIGINS 映射成 fruitshorts.* 属性,
ProdSecretGuard 读到空值即拒绝启动,compose 里 worker 一直 Restarting。

按两个入口同一口径补齐映射:不写 dev 默认值,漏配仍 fail-fast;
并显式置 signature-required=true,与两个入口一致。

门禁:mvn test 全模块 BUILD SUCCESS(0 失败 0 错误,1 项 ffmpeg smoke 按用例设计跳过)。
2026-10-16 14:32:24 +08:00
Zhou Weidong 8b46e535c5 fix(deploy): 修正 PG18 数据卷挂载路径为 /var/lib/postgresql
docker-library/postgres 自 18 起把数据目录改成 /var/lib/postgresql/<major>/,
卷继续挂 /var/lib/postgresql/data 会被 entrypoint 判定为"残留数据"并拒绝启动;
表现为 db 容器反复 Restarting,依赖它的三个服务一起起不来。

改挂父目录后本地实测:docker compose up -d 正常,db 进入 healthy。
2026-10-16 14:32:24 +08:00
jianghaifeng 7dd4b15804 docs(deploy): 按 specs 约束补全部署文档(新增 DEPLOY-CHECKLIST.md)
为什么:用户要求"写好文档,一定要根据 specs 文档约束好"。把 specs 里与部署相关的
硬约束逐条对照现状后发现 9 条里有 6 条未覆盖或只写了一半,需要一次补齐并如实标注工程缺口。

- 新增 DEPLOY-CHECKLIST.md:环境映射(办公室主机 = test)、域名/HTTPS/反代(含 nginx 与证书建议)、
  网络分区、发布与回滚规范(语义化 tag / release→main / 回滚演练)、备份与恢复(specs 目标 vs 现状差距)、
  监控与告警现状、密钥托管与轮换、**上线检查清单对照表(docs/12 逐条 ✅⚠️❌ + 责任方)**、
  风险/待确认(R1–R10)、上线前必补清单(P0–P2)
- DEPLOY.md 增加"本机属于 test 环境"与交叉引用,并单列"上线前必须补的四条"提示
- 对照表里的 ✅ 都带证据(如分区生效先实测 playback_ticket_202610 落分区、_default = 0)
- 只写文档,未改任何实现;mvn test 83 条全过、两入口 package 通过
2026-10-16 13:20:31 +08:00
jianghaifeng cbb9961627 fix(deploy): 冒烟脚本与 prod Swagger 开关解耦,默认改联调口径
为什么:prod profile 下 SWAGGER_ENABLED 默认 false 会把 /doc.html 与 /v3/api-docs 一起关掉,
而 smoke.sh 硬期望这两者 200 —— 照文档默认配置部署完,冒烟必然假失败,会被误判成"部署挂了";
且联调(10-19)需要 /doc.html 在线可查。

- smoke.sh 自适应:显式 SWAGGER_ENABLED 优先;未设置则自动探测(200+openapi=开、统一信封
  code=404=关、其它响应仍 FAIL);关闭时这三项打印 SKIP 而不是 FAIL,开启时照旧断言 200(断言未删)
- 新增 SWAGGER_REQUIRED 硬开关:联调环境可要求文档必须在线,不满足直接失败
- .env.example 默认改 SWAGGER_ENABLED=true,并写明该开关一次管两个端点
- DEPLOY.md 同步口径与冒烟说明
- 实测:prod+false 时 SKIP + 其余全 PASS + 退出码 0;prod+true 时两端口 /v3/api-docs 与 /doc.html 均 200;
  mvn test 83 条全过、两入口 package 通过
2026-10-16 13:17:29 +08:00
jianghaifeng b567365460 feat(deploy): D1 后端部署就绪(配置外置 + prod fail-fast + 自包含 Compose + 冒烟/回滚)
为什么:用户下午要部署,但全仓没有任何部署产物;且生产漏配密钥会静默使用代码里的 dev 默认值,
属上线阻塞项(04-部署架构 / 12-上线检查清单)。

- 配置外置(三件):DB_URL、JWT 双密钥(**发现 app-secret 此前没外置,一直用 dev 默认值**)、
  MEDIA_SIGNATURE_SECRET、MEDIA_SIGNATURE_REQUIRED、CORS_ALLOWED_ORIGINS、MEDIA_ROOT_DIR
- 新增三份 application-prod.yml:签名强制开启、CORS 收紧、媒资指向挂载点、日志级别
- 新增 ProdSecretGuard(@Profile("prod")):密钥缺失/仍是 dev 默认值/不足 32 位、签名未开、
  DB_URL 或 CORS 为空 → 一次列全并终止启动(exit 1)
- 新增 /healthz 轻量健康端点:查明 /actuator/health 的"超时"是旧 jar 未重启所致,项目本就
  没有 actuator 依赖,故不引新依赖;同时把 /healthz 加进安全白名单
- Docker 化:多阶段 Dockerfile(非 root、tini、依赖层缓存、ffmpeg 只给 worker)、.dockerignore、
  自包含 docker-compose.yml(含 PostgreSQL 18 + Redis + 命名卷 + 按序初始化 sql + healthcheck)
- tools/smoke.sh(失败非 0 退出)+ DEPLOY.md(Compose 主线 / 裸 jar 兜底 / 回滚 / 故障速查)
- 实测:mvn test 83 条全过;两入口 package 通过;prod 缺密钥退出码 1 且报错明确、配齐后正常启动;
  冒烟脚本全 PASS;本机无 Docker,镜像与 compose 未经构建验证(已在回执如实标注)
2026-10-16 13:01:08 +08:00
jianghaifeng 9f76302e53 feat(media): C1 转码 worker(独立进程消费 transcode_task,产出四档 HLS + 封面)
为什么:codex-研发任务清单阶段 C 第一项要求消费 transcode_task 产出多码率 HLS + 截图;
勘察发现除 worker 外还缺两件——TranscodeService.enqueue 没有任何调用方(队列永远空)、
media_asset.hls_url/poster_url 没人回写,所以一并补齐,已有能力(条件更新认领 / 3 次重试)直接复用。

- 新增 TranscodeRunner 端口 + FfmpegTranscodeRunner(每档一条 ffmpeg 出 VOD HLS + 手写 master.m3u8 + 抽封面 + ffprobe 读时长)
- 新增 TranscodeWorker:claimNext → 转码 → 登记 media_rendition → 媒资 ready;失败记原因、第 3 次才落 FAILED 并把媒资置 failed
- 存储统一走 MediaStorageService(新增 fileFor / objectKeyOf,含目录穿越校验),worker 不自己拼磁盘路径
- 队列隔离:app-api/admin-api 默认 worker-enabled=false,新增独立模块 fruitshorts-transcode-worker(随机端口)
- MediaService 上传完成对视频素材入队并置 transcoding(图片仍直接 ready)
- 实测抓到并修掉两个真问题:① scale 未加 force_divisible_by=2 时 854x480 算出奇数宽 853,libx264 拒绝转码
  ② 只写 -hls_time 不强制关键帧时 8 秒片源只出 1 个分片,补 -force_key_frames 后按 6s 正确切分
- README 开发环境补 ffmpeg 9.0.2(只有 worker 需要)
- 实测:mvn test 83 条全过、0 skipped(新增 worker 12 条,含真实 ffmpeg smoke);
  端到端:正常源 SUCCESS + 4 档 rendition + 媒资 ready;坏源重试 3 次后 FAILED
2026-10-16 12:41:48 +08:00
jianghaifeng 1dc97b4bf6 feat(commerce): B1-2 恢复购买/退款拒付回收/对账真实比对 + Apple 端口预留
为什么:风险清单 R-03 把"票据校验 + 对账打通"列为上线阻塞项。勘察发现 ReconcileService 与
/admin/reconcile 两个接口 B1 之前就已实现,真缺口是:对账没跟商店比对、差额用 0 兜底(违反
"取不到就用 null"的契约)、退款回收只有人工入口且不幂等。按实际缺口做,不重复造。

- 恢复购买:补专项测试(换设备重提同一票据 → 同一订单、原交付时间不改写、不新增流水);
  补单能力沿用 B1-1 的服务端建单路径,恢复列表沿用 GET /app/orders,不新增接口
- 退款/拒付回收:GooglePlayPurchaseClient 增加 listVoidedPurchases(purchases.voidedpurchases,
  带分页);新增 StoreRefundService 拉取并按 (platform, orderId) 幂等回收,本地无单计入差异;
  OrderService 增加 refundByStoreCallback(已退款直接返回,不二次回收/不写第二条流水),
  人工 refund() 的既有契约(已退款报错)保持不变
- 对账:reconcile_task 增 store_amount_sum(可空)与 notes 两列(幂等 ALTER);
  run() 改为集合比对(本地缺票据 + 票据无本地单 + 商店已作废但本地无单),并在同一次 run 里
  顺带执行退款回收;storeAmountSum 恒为 null 并在 notes 说明需从 Play Console 财务报告获取;
  商店调用失败置 status=FAILED 且不写 SUCCESS+0
- Apple IAP:只留端口/枚举与明确提示,不实现真调用(B2 再实现)
- 契约同步 docs/api-phase1.md §1.8/§2.6;新增测试 17 条(commerce 14 + admin-api 3)
- 实测:mvn test 71 条全过(commerce 27 + admin-api 3 + app-api 41,B1-1 的 54 条未被打红);
  两个入口 package 通过;端到端手动对账返回 storeAmountSum=null + notes 说明缺什么
2026-10-16 11:52:10 +08:00
Zhou Weidong db897d50d6 feat(commerce): Google Play 票据校验闭环(校验→建单→发权益 + 幂等/防重放)
为什么:风险清单 R-03 把支付票据校验列为上线阻塞项;/app/orders/verify 此前只有 disabled
占位,且 verifyAndDeliver 要求订单事先存在——而 IAP 没有下单接口,接了校验也走不通。

- 新增 GooglePlayStoreVerifier(校验 purchaseState/productId/orderId)+ StorePlatforms(平台归一化)
- 新增 GooglePlayPurchaseClient 端口与 HttpGooglePlayPurchaseClient:JDK HttpClient + 服务账号
  RS256 JWT 换 token,不引入 Google SDK 依赖;token 按过期缓存
- StoreVerifier 实现由 fruitshorts.commerce.store.verifier 选择,默认 disabled,无凭据时对外行为不变
- OrderService 补齐闭环:校验通过后按 (platform, storeProductId) 找商品建单、写 store_receipt 审计、
  发金币并推进到 DELIVERED;幂等键 (platform, storeTransactionId) 与唯一索引双保险
- 防重放:重复提交同用户返回既有订单且不再发币;票据已被他人使用返回 403;校验失败不写任何数据
- 凭据只走配置/环境变量(packageName + service account JSON),不入库不提交
- 契约同步 docs/api-phase1.md §1.8;金额微单位换算与回退价有单测覆盖
- 实测:mvn test 54 条全过(commerce 13 + app-api 41,M2 的 37 条未被打红);
  两个入口 package 通过;默认 disabled 端到端返回 200003 且 orders 表零行
2026-10-16 11:03:33 +08:00
Zhou Weidong 27e689c771 fix(config): 修复合并后 app-api 重复的 springdoc 配置键 2026-10-16 10:52:17 +08:00
Vicky 5ce6de1e3b Merge remote-tracking branch 'origin/main' 2026-10-16 10:51:15 +08:00
Vicky c6fcfcc6ea docs(backend): S1 接口文档在线化——补 springdoc 配置 + 39 个 Controller 中文注解
为什么:前端联调依赖在线接口文档。S1 条目要求两个端口的 /v3/api-docs 能看、
且每个接口有中文分组与中文 summary。

诊断(与 PM 初步定位不同,有硬证据):app-api 的 /v3/api-docs、/doc.html、/、
/error 全部"挂住"(8s 超时 0 字节),根因不是 Spring Security 拦截,而是
运行中的进程握着一个被重新打包替换掉的旧 jar——spring-boot:repackage 把原 fat jar
原地改名为 .jar.original(同一 inode)再写新 jar,进程再去加载"启动后未加载过的类"
就必然 NoClassDefFoundError(日志里连 logback 的 ThrowableProxy 都加载不到),
线程直接死掉、既不响应也记不了日志。SecurityConfig.PUBLIC_PATHS 本来就包含
/doc.html、/swagger-ui/**、/v3/api-docs/**,未改任何安全配置。

- app-api 的 application.yml 补 springdoc.swagger-ui.path: /doc.html
- 39 个 Controller(app-api 10 + admin-api 29)补 @Tag(name=中文分组)
  与 @Operation(summary=中文描述),共 172 个接口;描述取自 api-phase1.md 与既有 Javadoc
- 两个入口类加 @OpenAPIDefinition:文档标题 + 顶层 tags 清单(springdoc 默认顶层 tags 为空)
- 纯新增 357 行、删除 0 行:业务逻辑、路径、字段零改动
- 实测:mvn test 37 条契约测试全过;两个入口 package BUILD SUCCESS;
  两端口 /v3/api-docs 中文 tags + 中文 summary,/doc.html 可打开,
  非白名单路径 5ms 内返回 {"code":401,...} 不再挂住
2026-10-16 10:44:37 +08:00
Vicky fafadfc6ec test(backend): C 端核心链路契约测试 37 条,mvn test 不再是 No tests to run
为什么:红线第 8 条要求后端 mvn test 通过且核心接口有契约测试;此前 13 个模块全是
No tests to run.,这个"通过"是空的。按 M2 口径只补 C 端核心链路最小集。

- 接入层契约(MockMvc,真控制器 + 真全局异常处理器 + service 替身):
  /app/auth 注册与登录、目录四条路由、播放票据 FREE 与 COIN_REQUIRED 的字段形态
- 业务规则(真 service + 替身 Mapper):
  EMAIL 免码不走验证码且 verified=0、PHONE 必须带码、验证码错/过期被拒、
  密码强度、注册频控 429、账号重复 200001、密码错误 100000、封禁 100001、
  免费/已购/未解锁/无片源四个播放分支
- 依赖只加到 fruitshorts-app-api(spring-boot-starter-test,test scope),不动其它模块
- 测试放在接入层模块,避免动 01-08 冻结模块目录;只新增测试,未改业务逻辑
- 实测:mvn test BUILD SUCCESS,app-api Tests run: 37, Failures: 0, Errors: 0;
  mvn -DskipTests package 仍 BUILD SUCCESS
2026-10-16 09:52:52 +08:00
Vicky 84aa0e17dd fix: S0 后续——种子脚本补显式主键(去掉 identity 后新库种不进数据)
为什么:S0 把主键默认值去掉了,但 sql/data.sql 和 sql/data-phase1.sql 里有一批 INSERT
没给 id(原来靠 IDENTITY 生成),在新库上直接报
`null value in column "id" ... violates not-null constraint`,30 条菜单只种进去 14 条。
线上库因为数据早已存在所以看不出来,是"新装"这条路断了。

- 关联表(sys_role_permission / sys_role_menu / sys_admin_user_role)改用
  「左键 * 1000000 + 右键」的确定性 ID,重复执行结果一致,也不会撞雪花 ID
- data-phase1 新增权限点用「1000 + sort」(1101-1156 保留区间)显式给 ID,避开早期 IDENTITY 用过的 1-251
- 商品占位行补 901-903;sys_config 补 1-2
- 删掉 data.sql 结尾"对齐 IDENTITY 序列"的收尾块(序列已不存在),改为写明种子主键约定
- 实测:新库上 schema+data 五个脚本连跑三轮零 ERROR 且行数稳定(30/48/75/51/6/3/2/5/4);
  线上库跑种子前后行数不变(30/48/75/51/3/2)
2026-10-16 09:18:09 +08:00
Vicky 37c9a141d3 feat: S0 主键改雪花(DDL 去 identity + 实体 ASSIGN_ID + 雪花配置)
为什么:自增主键把 ID 分配锁在单库序列上,分库分表时无法横向扩展。趁线上业务数据
近零(只有系统种子表有量)先改,代价最低。

- sql/schema.sql、sql/schema-phase1.sql、sql/partition-maintenance.sql:61 处
  GENERATED BY DEFAULT AS IDENTITY 去掉,主键改 BIGINT 无默认值
- 52 个实体 @TableId IdType.AUTO -> IdType.ASSIGN_ID;两个入口 id-type: assign_id,
  并加 global-config.sequence.worker-id/datacenter-id(单机固定值,多实例需按实例分配)
- 新增 sql/migrate-s0-snowflake.sql:存量库幂等迁移,走 DROP IDENTITY 保留数据,不重建表
- 实测:三份建表脚本在新库连跑两遍幂等;线上 57 张表 identity 归零;
  watch_history/playback_ticket/order_event 的主键与唯一索引仍含分区键
2026-10-16 09:00:38 +08:00
Vicky 6833e01e1f feat: C 端 1-1 邮箱+密码注册(免码不阻塞,带风控)
为什么:客户端要做的第一件事是账号,但现状下邮箱注册走不通 —— RegisterReqVO.code 是 @NotBlank,
而 OTP 通道现在是 LogOtpSender(只写日志、发不出去),等于注册入口堵死。本轮把"邮箱+密码"打通,
同时按口径补上免码必须带的风控。

- RegisterReqVO.code 放开非必填(PHONE 仍必填,必填性按 targetType 在服务端判断)
- 邮箱注册分支:格式校验(AppIdentifier.requireEmailFormat)+ 密码强度(8-64 位且含字母数字);
  传了 code 就顺手校验一次,邮箱验证能力保留但不阻塞
- 注册频控:同 IP 24h ≤5、同设备 24h ≤3(数 app_user.register_ip / register_device_id,不新建计数表),超出 429
- 顺带修一个真问题:login() 原来**无条件**把 credential 标成 verified=1,
  密码登录并不证明邮箱归用户所有,会把"未验证"状态悄悄抹掉;改成只有验证码登录才置 1
- docs/api-phase1.md 补注册契约表格与残留风险(IP/设备号可伪造、OTP 只写日志、邮箱可达性未验证)

实测:免码注册成功(邮箱大小写与空格被规范化);弱密码/坏邮箱 400;重复 200001;
邮箱+密码登录成功;PHONE 不带码仍报"请先获取验证码";同设备第 4 次、同 IP 第 6 次分别 429;
免码注册后 verified=0 且经一次密码登录仍是 0。
2026-10-15 17:20:31 +08:00
607 changed files with 65454 additions and 810 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# Author: fruit.com.cn
#
# 构建上下文里不需要的东西:产物、IDE、本地上传的媒资、日志。
# 注意别把 pom.xml / src / sql 排掉,镜像构建要用。
**/target/
.git/
.gitignore
.idea/
*.iml
*.log
data/
docs/
node_modules/
+41
View File
@@ -0,0 +1,41 @@
# Author: fruit.com.cn
#
# 环境变量样例:复制成 .env 后填真实值(.env 不要提交,已在 .gitignore 里)。
# 生产用 prod profile 时,下面标「必填」的项缺失/仍是 dev 默认值/长度不足 32 位 → 启动直接失败。
# 详细说明见 DEPLOY.md。
# ── 数据库 ─────────────────────────────────────────────
# Docker Compose 单机部署:db 是 compose 里的服务名,保持默认即可;
# 若数据库在别的主机/用云托管,把 DB_URL 换成外部地址。
DB_NAME=fruitshorts
DB_URL=jdbc:postgresql://db:5432/fruitshorts
DB_USER=fruitshorts
DB_PASSWORD=
# ── 密钥(必填,各 32 位以上,且不能用代码里的 dev 默认值)──
# 生成:openssl rand -base64 48
JWT_ADMIN_SECRET=
JWT_APP_SECRET=
MEDIA_SIGNATURE_SECRET=
# ── 生产开关 ───────────────────────────────────────────
# prod profile 会强制 true(缺了签名则启动失败)
MEDIA_SIGNATURE_REQUIRED=true
# 后台前端来源,逗号分隔;生产不许用 *
CORS_ALLOWED_ORIGINS=https://admin.example.com
# 媒资根目录:容器里是挂载点
MEDIA_ROOT_DIR=/data/uploads
# 是否开放在线接口文档(**/doc.html 与 /v3/api-docs 一起开关**,没有"只留一个"的中间态)
# true = 前端联调能看到 /doc.html(测试/联调环境建议 true,10-19 前端联调需要它)
# false = 两个都关闭(生产若不希望暴露 UI 就设 false,机器可读契约也一并关闭)
SWAGGER_ENABLED=true
# ── 监控 ───────────────────────────────────────────────
# Grafana 管理员密码(必填):3000 端口对外开放,不允许用默认 admin/admin
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=
# ── 可选:Google Play 商店票据校验(不配则保持 disabled)──
STORE_VERIFIER=disabled
GOOGLE_PLAY_PACKAGE_NAME=
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=
+25
View File
@@ -0,0 +1,25 @@
# Author: fruit.com.cn
# FruitServer 后端 CI:编译 + 测试门禁
name: backend-ci
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- name: 编译 + 单测
run: mvn -B -T 1C test -DfailIfNoTests=false
- name: 打包(跳过测试)
if: success()
run: mvn -B package -DskipTests -q
+19
View File
@@ -16,3 +16,22 @@ logs/
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
data/uploads/*
!data/uploads/.gitkeep
# 部署用的真实环境变量(含密钥),只提交 .env.example
.env
*.secrets.env
dump.rdb
# 看门狗飞书告警 Webhook,只存在于部署机
deploy/feishu-webhook.txt
# SSL 证书与私钥(.crt/.cer/.pem/.key/.p12/.jks)—— 部署机本地文件,禁止入库
*.crt
*.cer
*.pem
*.key
*.p12
*.jks
# 部署用 nginx 配置目录(挂载卷的本地配置,随环境而异)
nginx/
+256
View File
@@ -0,0 +1,256 @@
# Author: fruit.com.cn
#
# DEPLOY-CHECKLIST —— 部署合规视角(specs 约束逐条结论 + 上线检查清单对照 + 风险/待确认)
#
# 这份文档回答两个问题:
# 1) `docs/04-部署架构`、`docs/08-备份恢复方案`、`docs/10-安全方案`、`docs/11-发布与回滚方案`、
# `docs/12-上线检查清单`、`docs/07-监控告警方案`、`GIT_WORKFLOW.md`、`CODING_STANDARDS.md`
# 这些 specs 里跟部署有关的要求,**我们现在满足到哪一步**;
# 2) 上线前**必须补什么**、谁来补。
#
# 操作层面的"怎么部署/怎么回滚命令"在 `DEPLOY.md`(本文件只做结论与对照,避免两份口径分叉)。
# 结论用三态:✅ 已满足 | ⚠️ 部分满足(能用但有差距)| ❌ 未满足(上线前必补)
## 一、环境映射(对应 `04-部署架构`§一)
`04-部署架构`§一把环境分成四级:
| 环境 | 用途 | 数据 | 访问 |
| --- | --- | --- | --- |
| dev | 本地开发 | 本地 PostgreSQL | 内网 |
| test | 测试/联调 | 独立测试库 | 内网/VPN |
| pre | 预发(生产镜像演练) | 接近生产 | 受限 |
| prod | 生产 | 生产库 | 公网 |
**当前这台办公室主机 = `test` 环境**,依据三条:
1. 用途是"给前端联调 + 内部验收",不是对外生产(`04-部署架构`§一 test = 测试/联调);
2. 访问方式计划是内网/办公网(`admin-api` 只绑 `127.0.0.1`),而不是公网;
3. 用的是独立的开发库(`fruitshorts`,含种子数据),不是生产库。
规范要求与我们的对应:
- `docker-compose.yml` 用 **Docker Compose 单机编排** → 对应 §五「第一阶段(dev/test):Docker Compose 单机编排」,✅。
- 一台机器上合并 app/api/DB/Redis + worker 独立容器 → 对应 §三「精简方案:test 环境可将 app/api/DB/Redis 合并到 1 台 8C32G,转码 worker 独立 1 台」,✅(worker 是独立容器,但**与在线服务同机**:资源隔离靠容器限额,真正的分机留到生产)。
- **差距**:`pre` 环境(生产镜像演练)**尚未建立** —— 上线前建议在目标机用同一份 compose 跑一次"预发演练",即用生产镜像 + 接近生产的数据走一遍发布流程。状态:❌(流程未建立,不阻塞 test 联调)。
## 二、域名 / HTTPS / 反向代理(对应 `04-部署架构`§四 + `10-安全方案`§二)
**specs 原文**:`04-部署架构`§四「域名/SSL:管理端、客户端 API、媒资**分别域名**;**HTTPS 强制**」;
`10-安全方案`§二「传输安全:HTTPS 强制;密钥环境变量注入」。
**当前状态:❌ 未满足**。现在的形态是 `IP:端口 + HTTP`:
| 需要的域名 | 现状 | 上线前必须做 |
| --- | --- | --- |
| 管理端(后台 Web + `/admin/**`) | 无域名;`admin-api` 绑 `127.0.0.1:8080`,靠内网/SSH 隧道访问 | 申请域名(如 `admin.<domain>`)+ 证书;nginx 反代 → `127.0.0.1:8080`;**并保持只允许内网/VPN 来源**(见 §三) |
| 客户端 API(`/app/**`) | 无域名;`app-api` 监听 `8081`(HTTP) | 申请域名(如 `api.<domain>`)+ 证书;nginx 反代 → `8081`,只暴露 `/app/**`;**强制 HTTP→HTTPS 跳转** |
| 媒资(`/files/**`) | 无域名;与 app-api 同一端口 | 申请域名(如 `media.<domain>`)或直接走 CDN 域名;**播放地址已带签名参数**,CDN 需按签名做缓存策略(`04-部署架构`§四) |
**测试环境(本机)可以先用内网 HTTP 联调**(`04-部署架构`§一 test 是内网/VPN 访问),
但**上线(pre/prod)前必须满足上述三条 HTTPS + 独立域名**,否则不满足 `12-上线检查清单`§三「传输安全」。
### nginx + 证书的建议做法(上线前照做)
```nginx
# /etc/nginx/conf.d/fruitshorts.conf(示意;域名与证书路径按实际替换)
server { # 客户端 API
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
client_max_body_size 20m; # 分片上传,与 admin-api 的 multipart 上限一致
location / { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; }
}
server { # 管理端:只允许内网/VPN 网段
listen 443 ssl http2;
server_name admin.example.com;
allow 10.0.0.0/8; allow 192.168.0.0/16; deny all; # 按实际内网段调整
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; }
}
server { # HTTP 强制跳 HTTPS
listen 80;
server_name api.example.com admin.example.com;
return 301 https://$host$request_uri;
}
```
证书建议用 **Let's Encrypt(certbot)** 自动续期(`certbot --nginx -d api.example.com -d admin.example.com`),
或公司自有证书;无论哪种,**到期前必须能自动续**,否则接口会整体不可用。
反代配置文件需与本仓库一起纳入版本管理(放目标机的 `/etc/nginx/conf.d/`,另存一份到运维仓库)。
## 三、网络分区(对应 `04-部署架构`§四)
**specs 原文**:「网络分区:管理后台仅内网/VPN 可访问;客户端 API 公网。」
| 要求 | 现状 | 上线前必须做 |
| --- | --- | --- |
| 管理后台仅内网/VPN | ✅ 已满足:`docker-compose.yml` 里 admin-api 端口映射为 `127.0.0.1:8080:8080`(只绑本机,公网访问不到) | 上公网后仍要保证:只经 nginx + `allow/deny` 内网段暴露(见 §二示例) |
| 客户端 API 公网 | ⚠️ 部分满足:`app-api` 监听 `0.0.0.0:8081`,同网段可访问;**但还没有反代/域名,也没限制只暴露 `/app/**`** | 反代只放行 `/app/**`(`/files/**` 走媒资域名/CDN);不要把 `/v3/api-docs`、`/healthz` 直接暴露到公网(`/healthz` 可留给内网监控) |
**注意**:`/doc.html` 与 `/v3/api-docs` 由 `SWAGGER_ENABLED` 控制(见 `DEPLOY.md`)。
**联调环境建议 `true`**(前端要看),**公网生产建议 `false`**(不暴露接口清单)。
## 四、发布与回滚规范(对应 `11-发布与回滚方案`§一/§四 + `GIT_WORKFLOW.md`§四)
**specs 原文**:
- `11-发布与回滚方案`§一:「语义化版本 `v<major>.<minor>.<patch>`;分支 `main`(生产)← `release/*` ← `develop` ← `feature/*`;每次 `release`/`hotfix` 合并 `main` 必须打 tag,tag 不可移动/删除。」
- `GIT_WORKFLOW.md`§四:「`major` 破坏性变更、`minor` 新功能、`patch` 修复;发布后 tag 不可移动、不可删除(错误版本发新 tag 修正,不覆盖旧 tag)。」
- `11-发布与回滚方案`§四:「回滚演练:**上线前演练回滚**,确保可执行。」「数据库迁移脚本必须可回滚(向前兼容,避免破坏性变更)。」
| 要求 | 现状 | 上线前必须做 |
| --- | --- | --- |
| 语义化 tag `v<x.y.z>` | ⚠️ 部分满足:`DEPLOY.md` 里只是示例式地提到"每次发版打 tag",**没有命名规范** | 按 §四 规则打:首个上线版本 `v1.0.0`;之后功能 `v1.1.0`、修复 `v1.0.1`;**错误版本发新 tag,绝不移动/删除旧 tag** |
| release/hotfix 合并 main | ⚠️ 流程未走过:目前工作都在 `develop`,`main` 未动 | 上线时:`develop` → 建 `release/v1.0.0` → 在 test 环境验证 → 合并 `main` + 打 tag `v1.0.0`(**由用户操作,Codex 不碰 main**) |
| 回滚可执行 | ✅ 技术路径已写全(`DEPLOY.md`§七:切回上一个镜像 tag / git 回上一个 release tag + `up -d --build` + 重跑冒烟) | **上线前演练一次**(在 test 环境故意回滚到上一版本并跑 `tools/smoke.sh`),把演练记录留档 |
| 数据库迁移可回滚 | ⚠️ 部分满足:`sql/*.sql` 全是幂等增量(`ADD COLUMN IF NOT EXISTS`、`CREATE TABLE IF NOT EXISTS`),**没有破坏性变更**;`upsert` 语义的种子脚本也在 | 上线前确认:本次发布**不包含** `DROP/ALTER TYPE` 类破坏性语句(当前没有);如将来有,必须先写"回滚脚本 + 备份"再发 |
| 灰度/放量 | ❌ 未满足(单机 Compose 无灰度能力) | 上线首日按"先内测 → 再小流量(如有网关可切)";正式灰度需要 K8s/网关,属后续阶段(`04-部署架构`§五:生产上 K8s) |
## 五、备份与恢复(对应 `08-备份恢复方案`§二/§三)
**specs 目标**:全量每日 × 保持 30 天;**WAL 归档(实时)**;可选增量;**备份加密 + 异地存储**;
**RPO ≤ 5 分钟、RTO ≤ 30 分钟**;**每季度至少一次恢复演练**。
| 项 | specs 要求 | 现状 | 差距 / 上线前必须补 |
| --- | --- | --- | --- |
| 全量备份 | 每日 1 次,保留 30 天 | ⚠️ `DEPLOY.md`§七 给了**手动** `pg_dump` 命令;**没有定时任务、没有保留策略** | 目标机加 cron(或 compose 侧容器)每日 `pg_dump`,按日期命名保留 30 天;脚本可放 `tools/backup.sh`(本轮未写,属运维脚本) |
| WAL 归档 | 实时,用于 PITR | ❌ 未配置(PG 默认 `archive_mode=off`) | `postgresql.conf`:`wal_level=replica`、`archive_mode=on`、`archive_command` 推到异地;这是达成 **RPO≤5min** 的唯一途径 |
| 增量 | 可选 | ❌ 未做 | 有了 WAL 归档即可满足 RPO,增量备份可后补 |
| 加密 | 备份加密 | ❌ 未做 | `pg_dump` 管道给 `age`/`gpg`,或落到加密卷;密钥单独保管(与备份分开) |
| 异地 | 与生产不同可用区/账号 | ❌ 未做(本机 dump 还在同一台机器上) | 推到对象存储(另一账号/另一区);**"备份和数据库在同一台机器上"等于没备份** |
| 恢复演练 | 每季度 ≥ 1 次,记录归档 | ❌ 从未做过 | 首次上线前做一次"恢复到新库 + 起服务 + 冒烟",记录耗时(验 RTO)与丢失窗口(验 RPO) |
**结论:备份恢复这一项与 specs 差距最大,是上线前必须补的硬缺口(❌)。**
当前只有"能手动 dump",距离「RPO≤5min / RTO≤30min / 加密 / 异地 / 季度演练」还差整套机制。
建议排期:**上线前**至少做到「每日全量 + 异机保存 + 一次恢复演练」;**上线后两周内**补 WAL 归档与加密。
## 六、监控与告警现状(对应 `07-监控告警方案`§七)
**specs 原文**:`07-监控告警`§七「Prometheus/Loki/Grafana 尚未部署,**第一阶段先用日志 + `SlowRequestLogger` 兜底**」;
§六「告警渠道:飞书/钉钉/邮件 + 值班轮转」;§六「值班表与 Runbook 见 `docs/plans/`(上线前补)」。
| 项 | 现状 | 上线前必须补 |
| --- | --- | --- |
| 指标(Prometheus) | ❌ 未部署;**项目也没引 actuator**(`/actuator/health` 一直是未映射,见 `DEPLOY.md`§五说明) | 按 §七 的既定路线部署;要暴露指标需先给两个入口加 `spring-boot-starter-actuator` + `micrometer-registry-prometheus`(**属代码改动,需立项**) |
| 日志聚合(Loki) | ❌ 未部署;现在日志在容器 stdout(`docker compose logs`) | 部署 Loki + Promtail 采集容器日志 |
| 大盘(Grafana) | ❌ 未部署 | 按 §五 大盘定义建面板 |
| 第一阶段兜底 | ✅ 已具备:应用结构化日志(转码耗时/失败原因、业务异常、`SlowRequestLogger` 慢请求告警都在代码里)+ 容器级 `healthcheck`(`/healthz`) | 至少把"容器 unhealthy"接到人的手机上(可用 `docker compose events` + 简单脚本/飞书机器人) |
| 告警渠道 + 值班 + Runbook | ❌ 未定 | 定渠道(飞书/钉钉/邮件)、排值班表、把 `DEPLOY.md`§九「故障速查」扩成 Runbook |
**结论:监控告警 ❌ 未满足**,但按 specs §七 的既定节奏,**第一阶段允许先用日志兜底** ——
前提是"告警能到达人"。上线前最低要求:**容器 unhealthy / 冒烟失败能被发现**(哪怕先是人工每天跑一次 `tools/smoke.sh`)。
## 七、密钥托管与轮换(对应 `10-安全方案`§五)
**specs 原文**:§五「密钥托管:生产密钥**集中托管**,**最小权限**」;`12-上线检查清单`§三「JWT/签名密钥走环境变量,未硬编码/入库」「生产密钥集中托管」。
| 要求 | 现状 | 上线前必须补 |
| --- | --- | --- |
| 密钥走环境变量、未硬编码/入库 | ✅ 已满足:三个可运行件的密钥全部 `${ENV:...}` 注入;`.env` 已 gitignore;`.env.example` 只有占位;镜像里不含密钥 | 继续保持;**代码审查时禁止把密钥写回 yml** |
| 缺失即启动失败 | ✅ 已满足:`ProdSecretGuard` 在 `prod` profile 下校验三个密钥 + 签名开关 + DB_URL + CORS,缺失/仍是 dev 默认值/不足 32 位 → 退出码 1 | — |
| **集中托管** | ⚠️ 部分满足:现在是"目标机的 `.env` 文件"(文件权限 600),**没有密钥管理系统** | 短期:`.env` 权限 600、只有运维账号可读、**不进任何 git 仓库**、单独备份(加密);中期:迁到 K8s Secret / 云 KMS / Vault(`04-部署架构`§五 生产路线也是 K8s Secret) |
| 最小权限 | ⚠️ 部分满足:DB 用单一 `fruitshorts` 账号(读写全库) | 拆账号:应用用 `fruitshorts_app`(DML 权限、无 DDL);备份用只读账号;DDL 只留给运维 |
| 轮换 | ❌ 未做(也没有流程) | 定义轮换周期(建议 90 天或按事件):**先加新密钥 → 双密钥并行验证期 → 再下线旧密钥**;JWT 轮换会导致存量 token 失效,需选低峰并公告(`10-安全方案`§一 token 有效期 120min,影响窗口可控) |
## 八、上线检查清单对照表(逐条映射 `docs/12-上线检查清单.md`)
> 三态:✅ 已满足 | ⚠️ 部分满足 / 有条件满足 | ❌ 未满足(上线前必补)
> 「责任方」列区分:**Codex**(我能做的)/ **用户或运维**(需要账号、域名、外部资源)。
### 一、代码与门禁
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 三端门禁全绿 | ⚠️ | **后端 ✅**:`mvn test` 83 条全过;**前端(Flutter/Web)不在本工程范围**,由另一同事的 AI 负责,需其提供门禁结果 | 用户/前端 |
| 接口契约 `api-phase1.md` 已同步 | ✅ | 本轮 B1-1/B1-2 的 verify / reconcile 改动都已同步进契约 | Codex |
| 作者标识齐全 | ✅ | 远端已做全量补齐(`35be37c`),本轮新增文件也都带 `// Author: fruit.com.cn` | Codex |
| 代码规模红线、无关键路径 TODO | ⚠️ | 未做静态检查工具(后端无 checkstyle/pmd),**存量未逐文件核对**;无 TODO 扫描证据 | Codex(需立项) |
### 二、功能与测试
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 核心用例通过、P0 清零 | ⚠️ | 后端 83 条(含真实 ffmpeg smoke)全过;**前端与端到端用例不在本工程** | Codex/用户 |
| 支付闭环端到端 | ⚠️ | 票据校验/幂等/退款回收/对账**已实现并有单测**;**真实 Google Play 未联调**(缺 packageName + service account JSON,属付费凭据) | 用户(凭据)+ Codex(联调) |
| 播放长稳 + 弱网测试 | ❌ | 未做(需真机 + 网络模拟) | 前端/用户 |
| 多端(iOS/Android)回归 | ❌ | 未做(Android 无可用模拟器,iOS 有真机) | 前端/用户 |
### 三、安全
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 播放签名 `signature-required=true`(生产必开) | ✅ | `application-prod.yml` 写死 `true`,`ProdSecretGuard` 再校验一次,缺了直接拒启 | Codex |
| JWT/签名密钥走环境变量,未硬编码/入库 | ✅ | 三件全 `${ENV:...}`;`.env` gitignore;镜像不含密钥 | Codex |
| 越权/注入/敏感数据专项测试 | ⚠️ | **越权**:创作者中心有专项用例(B 批);**注入/SQL 注入**:全部走 MyBatis-Plus 参数绑定,无字符串拼接 SQL(人工核对),**无自动化用例**;敏感数据脱敏有实现(`AppIdentifier.mask`)但无专项测试 | Codex(需立项) |
| 生产密钥集中托管 | ⚠️ | 见 §七:现为 `.env` 文件权限隔离,未接 KMS/Vault | 用户/运维 |
### 四、基础设施
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 数据库分区已生效 | ✅ | `watch_history`/`playback_ticket`/`order_event` 均为声明式月分区,`partition-maintenance` 启动时+每日补齐。**实测证据**:`select tableoid::regclass, count(*) from playback_ticket group by 1` → `playback_ticket_202610 \| 1`(落月分区,不是 `_default`);`select count(*) from watch_history_default` → `0` | Codex |
| Redis/缓存(如接入)配置正确 | ⚠️ | compose 已备 Redis 容器,**代码尚未接入(C2 待做)**;当前不依赖 Redis | Codex(C2) |
| 媒资已迁 OSS/S3 + CDN(生产) | ❌ | **仍是本地磁盘 `./data/uploads`,与在线服务同机**;`MediaStorageService` 已把存储收敛到一处,切换 OSS 只需改它 + 转码产物上传路径 | 用户(账号)+ Codex |
| 备份恢复已配置并演练 | ❌ | 见 §五,差距最大 | 用户/运维 + Codex(脚本) |
| 监控告警已接入、告警可达 | ❌ | 见 §六 | 用户/运维 |
### 五、商店与合规
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 应用名称/截图/文案/隐私标签一致 | ❌ | 未办(客户端上架材料) | 用户 |
| App Privacy / Data Safety / SDK 清单 | ❌ | 未办 | 用户 |
| 隐私政策、用户协议已上线 | ❌ | 未办(后端有 `app_user_consent` 落库能力,文案与页面需法务/前端) | 用户 |
| 版权/素材/商标授权台账 | ❌ | 未办 | 用户 |
| 商店账号/证书/包名有效 | ❌ | 未办;**并且包名仍不一致**(Android `com.stormg.lumadrama` / iOS `com.stormg.lumadramv`,见 C 端回执) | 用户(建议上架前统一) |
### 六、发布与回滚
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 版本号 + tag 冻结(`v1.0.0`) | ❌ | 尚未打 tag(规范见 §四) | 用户(合并 main 时打) |
| 回滚方案已演练 | ❌ | 方案已写(`DEPLOY.md`§七),**未演练** | 用户/运维 |
| 值班表 + 告警 Runbook 就绪 | ⚠️ | `DEPLOY.md`§九「故障速查」可作为 Runbook 初稿;**值班表未定** | 用户 |
| 灰度/放量计划明确 | ❌ | 单机 Compose 无灰度能力;生产上 K8s 后再做 | 用户 |
### 七、观察窗口(上线后)
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 核心链路监控 | ❌ | 监控未接;**上线首日建议人工每 30 分钟跑一次 `tools/smoke.sh` + 看 `docker compose logs`** 作为过渡 | 用户/运维 |
| 支付/订单对账数据回看 | ⚠️ | 对账能力已在(`/admin/reconcile/run`,`storeAmountSum` 无数据源时返回 null + notes),**真实商店数据要等凭据** | Codex/用户 |
| 崩溃率/错误率观察 | ❌ | 未接监控;容器日志可人工查 | 用户/运维 |
| 问题复盘归档 | ⚠️ | 本工程有 PROGRESS/回执留痕习惯,可沿用 | Codex/用户 |
## 九、风险 / 待确认(`CODING_STANDARDS`§7 要求:技术决策必须留痕)
| # | 风险 / 待确认 | 影响 | 建议与责任方 |
| --- | --- | --- | --- |
| R1 | **媒资仍在本机磁盘**(`./data/uploads`),未迁 OSS/CDN | 单机磁盘满/机器损坏即媒资全丢;播放无法借 CDN 扛量 | `12-上线检查清单`§四把"迁 OSS/CDN"列为上线项,**属上线前必补**;`MediaStorageService` 已是唯一替换点,切换工作量可控(Codex 可做,需 OSS 账号) |
| R2 | **单点无高可用**:一台办公室主机跑全栈 | 机器/网络故障 = 服务全停;无灰度能力 | test 阶段可接受;**生产必须按 `04-部署架构`§三 生产规格(应用 2+、DB 主备)+ §五 上 K8s** |
| R3 | **备份未演练、无 WAL 归档、备份与库同机** | 真出事时可能既恢复不了也丢得多(RPO 远大于 5 分钟) | 上线前至少"每日全量 + 异机保存 + 一次恢复演练";两周内补 WAL 归档与加密(见 §五) |
| R4 | **监控与告警未接** | 故障靠人发现;上线首日风险最高的窗口无自动告警 | 短期用"人工冒烟 + 容器 healthcheck";随后按 `07-监控告警方案`部署(需先给服务加 actuator,属代码改动) |
| R5 | **商店合规未办**(隐私政策/Data Safety/SDK 清单/版权台账/包名) | 无法上架;印尼还涉及 Kominfo PSE 注册(未注册可能被整体封锁,见 `docs/08`/PRD 合规节) | 全部为用户侧工作;其中"包名统一"建议在上架前完成(一旦上架不可改) |
| R6 | **真实 Google Play 联调未做**(缺 packageName + service account JSON) | 支付闭环在真机/商店侧未验证;`STORE_VERIFIER=disabled` 时对外明确返回"尚未配置",不会产生假订单 | 用户提供凭据后,Codex 用沙盒测试购买联调(`STORE_VERIFIER=google-play`) |
| R7 | **HTTPS/域名未就绪**,当前 IP+HTTP | 不满足 `04-部署架构`§四与 `10-安全方案`§二;公网明文传输不可接受 | 上线前按 §二 配域名 + 证书 + nginx(用户/运维) |
| R8 | **DB 账号未做最小权限**(单账号读写全库) | 应用被攻破即拥有 DDL 能力 | 拆 `fruitshorts_app`(仅 DML)与备份只读账号(见 §七) |
| R9 | **`/swagger-ui/**` 静态资源在 `SWAGGER_ENABLED=false` 时仍可访问** | 不泄露接口信息(spec 与 config 都已 404),但严格合规环境下应彻底关闭 | 可选加固:`spring.web.resources` 排除 webjar;需你点头再改(Codex) |
| R10 | 转码 worker 与在线服务**同机**(虽为独立容器) | 大批量转码会抢 CPU,影响播放稳定性 | `04-部署架构`§三 的完整方案是 worker 独立 1 台;上线前如有第二台机器就拆开,否则给 worker 容器加 CPU 限额(Codex 可改 compose) |
## 十、上线前必补清单(按优先级)
| 优先级 | 事项 | 依赖 | 责任方 |
| --- | --- | --- | --- |
| P0 | HTTPS + 独立域名 + nginx 反代(§二) | 域名、证书 | 用户/运维 |
| P0 | 每日全量备份 + 异机保存 + 一次恢复演练(§五) | 目标机 + 异地存储 | 用户/运维(Codex 可提供脚本) |
| P0 | 媒资迁 OSS/CDN(R1) | 云账号 | 用户 + Codex |
| P1 | tag 规范落地 + 回滚演练(§四) | — | 用户(合并 main 时)/ Codex 配合 |
| P1 | 监控告警最低可用(§六:容器 unhealthy 能到人) | 通知渠道 | 用户/运维 |
| P1 | 真实 Google Play 联调(R6) | 付费凭据 | 用户 → Codex |
| P2 | DB 最小权限账号(R8)、密钥集中托管(§七) | 运维 | 用户/运维 |
| P2 | K8s 生产化(R2)、灰度能力、WAL 归档与加密 | 生产资源 | 后续阶段 |
+208
View File
@@ -0,0 +1,208 @@
# FruitShorts 后端部署与回滚(DEPLOY)
> 依据:`docs/04-部署架构.md`(第一阶段 **Docker Compose 单机**、镜像即交付物;生产才上 K8s)、
> `docs/11-发布与回滚方案.md`、`docs/12-上线检查清单.md`(**播放签名生产必开**、密钥走环境变量)。
> 目标机形态:**办公室一台主机**(§三 精简方案:app/api/DB/Redis 合并 1 台 8C32G,转码 worker 独立进程)。
> **本文只讲"怎么部署/怎么回滚"**。要回答"我们离 specs 还差什么、上线前必补什么",
> 看 **[DEPLOY-CHECKLIST.md](./DEPLOY-CHECKLIST.md)**(环境映射、域名/HTTPS、备份恢复、监控、发布与回滚规范、
> `docs/12-上线检查清单` 逐条对照表、风险/待确认)。
**这台办公室主机属于 `test` 环境**(`04-部署架构`§一:测试/联调、独立测试库、内网/VPN 访问);
`pre`/`prod` 的规格与前置条件见 `DEPLOY-CHECKLIST.md` §一。
## 一、两条部署路径(先选一条)
| 路径 | 适用 | 命令主线 |
| --- | --- | --- |
| **A. Docker Compose(推荐,文档口径)** | 目标主机装了 Docker + Compose;一条命令起全栈(含 PG 18 与 Redis) | 见 §三 |
| **B. 裸 jar(兜底)** | 主机没有 Docker,或只想起单个进程调试 | 见 §四 |
> 两条路径的**配置完全相同**(同一批环境变量),只是"谁来跑进程"不同。
## 二、环境变量清单
复制 `.env.example` 为 `.env` 填值(`.env` 已在 `.gitignore`,**不要提交**)。
使用 `--spring.profiles.active=prod`(Compose 里已默认)时,下表标 🔴 的项**缺失/仍是 dev 默认值/长度不足 32 位 → 启动直接失败**(fail-fast,见 §五)。
| 变量 | 必填 | 缺失后果 | 说明 |
| --- | --- | --- | --- |
| `DB_URL` | ✅ | 用默认 `jdbc:postgresql://127.0.0.1:5432/fruitshorts`,连不上就启动失败 | Compose 里默认指向 `db` 服务 |
| `DB_USER` / `DB_PASSWORD` | ✅ | 连库失败 | Compose 的 db 容器用同一组值创建库 |
| `DB_NAME` | Compose 必填 | 默认 `fruitshorts` | 只在 compose 里用到 |
| `JWT_ADMIN_SECRET` | 🔴 | **prod 拒绝启动** | 管理端令牌密钥,≥32 位,生成:`openssl rand -base64 48` |
| `JWT_APP_SECRET` | 🔴 | **prod 拒绝启动** | 客户端令牌密钥。**过去这个键没外置,一直用代码里的 dev 默认值**,务必注入 |
| `MEDIA_SIGNATURE_SECRET` | 🔴 | **prod 拒绝启动** | 播放地址签名密钥(`docs/12-上线检查清单`) |
| `MEDIA_SIGNATURE_REQUIRED` | 🔴 | prod 下不是 `true` 就拒绝启动 | 生产必须开;本地调试保持 false |
| `CORS_ALLOWED_ORIGINS` | 🔴 | prod 下为空则拒绝启动 | 逗号分隔的正式来源,**生产不许用 `*`** |
| `MEDIA_ROOT_DIR` | 建议 | 默认 `./data/uploads`,容器里必须为 `/data/uploads` | 媒资根目录(挂载卷) |
| `SERVER_PORT` | Compose 已设 | 默认 8081/8080 | app-api 8081 / admin-api 8080 |
| `SWAGGER_ENABLED` | 可选 | 默认 false(**测试/联调请在 `.env` 里设 `true`**,前端要看 `/doc.html`) | **一次开关两个**:`true` = `/doc.html` + `/v3/api-docs` 都开;`false` = 两个都关(生产不想暴露 UI 就设 false,机器可读契约也一并关闭) |
| `STORE_VERIFIER` / `GOOGLE_PLAY_*` | 可选 | 默认 `disabled` | 商店票据校验,没凭据就保持 disabled(对外返回"尚未配置") |
**密钥怎么生成**:`openssl rand -base64 48`(每个密钥单独生成,别复用)。
## 三、路径 A:办公室单机 + Docker Compose(主线)
目标主机前置:**Docker + Docker Compose**(`docker compose version` 有输出即可),建议 8C32G。
```bash
# 0) 取代码(或拿打包好的目录)并进入 FruitServer
cd FruitServer
# 1) 配置环境变量
cp .env.example .env
openssl rand -base64 48 # 生成三个密钥,分别填进 JWT_ADMIN_SECRET / JWT_APP_SECRET / MEDIA_SIGNATURE_SECRET
vi .env # 还要填 DB_PASSWORD、CORS_ALLOWED_ORIGINS
# 2) 构建并启动(首次会自动建表 + 造种子数据)
docker compose up -d --build
# 3) 看状态与日志
docker compose ps # 预期:db/redis healthy,app-api/admin-api healthy,transcode-worker running
docker compose logs -f app-api # 预期:出现 "生产配置校验通过" 与 "Started AppApiApplication"
# 4) 冒烟
tools/smoke.sh # 预期:全部 PASS,退出码 0
```
**端口与访问**:app-api `8081`(对客户端开放)、admin-api **只绑 `127.0.0.1:8080`**(管理端仅内网,公网通过 SSH 隧道或反代访问)、PG/Redis 不对宿主暴露。
**数据库初始化**:`sql/*.sql` 已挂进 `docker-entrypoint-initdb.d`(schema → schema-phase1 → partition → data → data-phase1),
**只在数据卷为空时执行**。改 SQL 后要重来:`docker compose down -v`(**会删数据**)再 `up`。
**日常操作**
```bash
docker compose ps # 状态
docker compose logs -f --tail=200 # 全部日志
docker compose restart app-api # 重启单个服务
docker compose up -d --build app-api # 只重建单个镜像
docker compose down # 停(保留数据卷)
```
## 四、路径 B:裸 jar(兜底)
目标主机前置:**JDK 21**;转码进程额外需要 **ffmpeg/ffprobe**(`brew install ffmpeg` 或 `apt install ffmpeg`)。
数据库:自己装 PostgreSQL 18 并导入 `sql/*.sql`(顺序同上)。
```bash
cd FruitServer
mvn -pl fruitshorts-app-api,fruitshorts-admin-api,fruitshorts-transcode-worker -am package -DskipTests
# 导出环境变量(示例;密钥务必换真实值)
export DB_URL=jdbc:postgresql://127.0.0.1:5432/fruitshorts DB_USER=fruitshorts DB_PASSWORD='***'
export JWT_ADMIN_SECRET="$(openssl rand -base64 48)" # 生产请固定保存,不要每次重新生成
export JWT_APP_SECRET="$(openssl rand -base64 48)"
export MEDIA_SIGNATURE_SECRET="$(openssl rand -base64 48)"
export MEDIA_SIGNATURE_REQUIRED=true CORS_ALLOWED_ORIGINS='https://admin.example.com'
export MEDIA_ROOT_DIR="$PWD/data/uploads"
# 起三个进程(生产用 prod profile;本地调试去掉 --spring.profiles.active=prod 即可)
nohup java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/admin.log 2>&1 &
nohup java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/app.log 2>&1 &
nohup java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/worker.log 2>&1 &
```
> ⚠️ **重新打包必须重启进程**:`spring-boot:repackage` 会原地替换 jar,运行中的进程再去加载"没加载过的类"
> 会抛 `NoClassDefFoundError`,表现为**请求挂住**(S1 记录过这个坑)。顺序永远是"改完 → 打包 → 重启 → 验证"。
## 五、prod fail-fast 是什么、长什么样
`prod` profile 下 `ProdSecretGuard` 会校验:JWT 双密钥 + 媒体签名密钥(非空、不是 dev 默认值、≥32 位)、
`signature-required=true`、`DB_URL` 非空、CORS 非空。任一不满足 → **打印全部问题并终止启动**:
```
ERROR c.f.framework.config.ProdSecretGuard : 生产配置校验未通过,拒绝启动(prod profile):
- fruitshorts.jwt.admin-secret(环境变量 JWT_ADMIN_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.jwt.app-secret(环境变量 JWT_APP_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.media.signature-secret(环境变量 MEDIA_SIGNATURE_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.cors.allowed-origins 为空(环境变量 CORS_ALLOWED_ORIGINS)—— 生产不允许用通配 *,必须显式列出来源
请按 FruitServer/DEPLOY.md 的环境变量清单补齐后重启。
```
exit code = **1**(部署脚本可以直接判失败)。**没有 prod profile 时这套校验不生效**,本地开发照旧开箱即用。
## 六、发布后验证(每次发版都做)
1. `docker compose ps`:三个应用容器 `Up (healthy)`(worker 是 `Up`)。
2. `tools/smoke.sh`:全 PASS、退出码 0。它检查 `/healthz`、`/app/categories`、`/app/home/feed`、
两个 `/v3/api-docs`、`/doc.html`,以及未授权路径是否按统一信封拒绝。
**关于在线文档那三项**:脚本会按 `SWAGGER_ENABLED` 自适应 —— `true` 时断言 200;
`false` 时打印 **SKIP**(不算失败),因为 `/doc.html` 与 `/v3/api-docs` 都被配置关掉了。
没设这个变量时脚本自动探测("200 且含 openapi"算开,"统一信封 code=404"算关,其它响应→FAIL)。
联调环境想强制要求文档在线:`SWAGGER_REQUIRED=true tools/smoke.sh`(不满足直接失败)。
3. `curl -s http://127.0.0.1:8081/healthz`:(从容器里或宿主机)应返回 `{"code":0,...,"status":"UP"}`。
4. 业务抽查:`curl -s http://127.0.0.1:8081/app/home/feed | head -c 200`(有内容);
后台 `http://127.0.0.1:8080/doc.html`(**需要 `.env` 里 `SWAGGER_ENABLED=true`**,联调环境请打开)。
5. 登录一个后台账号,确认能进后台首页(连通数据库与权限体系)。
## 七、回滚
**镜像/代码回滚**
```bash
# 用上一个版本的 tag(每次发版都打 tag:fruitshorts/app-api:2026-10-16-1)
docker compose stop app-api admin-api transcode-worker
docker tag fruitshorts/app-api:2026-10-15-2 fruitshorts/app-api:latest
docker compose up -d app-api admin-api transcode-worker
tools/smoke.sh
```
或用 git 回到上一个 tag 重新 `up -d --build`:
```bash
git fetch --tags && git checkout <上一个 release tag>
docker compose up -d --build
```
**数据库回滚(谨慎)**:schema 脚本是幂等增量(`ADD COLUMN IF NOT EXISTS` 等),**一般不需要回滚表结构**;
真要回滚数据用备份:`docker compose exec -T db pg_dump -U fruitshorts fruitshorts > backup.sql`。
**发布前先备份**是硬要求:
```bash
docker compose exec -T db pg_dump -U "${DB_USER:-fruitshorts}" "${DB_NAME:-fruitshorts}" > backup-$(date +%F-%H%M).sql
```
**回滚后必须重跑** `tools/smoke.sh`,并确认转码队列状态正常(后台「数据看板 → 转码看板」)。
## 八、未在本机验证的部分(如实说明)
本机**没有 Docker / colima / podman**(`docker` 命令不存在),所以:
- `Dockerfile`、`.dockerignore`、`docker-compose.yml` **未经本机构建与运行验证**(只做了语法与配置项静态检查)。
- 本机真实验证过的是:**裸 jar 路径**(两个入口 + worker 起停、`/healthz`、冒烟脚本)、
**prod fail-fast**(缺密钥时退出码 1 + 明确报错)、配置外置(`DB_URL` 等改动后仍能起)。
- 目标主机上有 Docker 之后,请**先跑 §三 的 1)–4) 步**确认;如报错把 `docker compose logs` 贴回来。
## 八之二、上线前必须补的(治理类,详见 DEPLOY-CHECKLIST.md)
以下四条**不是**"部署命令"层面的问题,但 `docs/12-上线检查清单` 把它们列为上线条件,此处给出醒目提示:
1. **HTTPS + 独立域名 + 反向代理**(管理端/客户端 API/媒资分域名,`04-部署架构`§四 + `10-安全方案`§二)—— 现在只有 IP:HTTP;
2. **备份恢复**:specs 要求每日全量×30 天 + WAL 归档 + 加密 + 异地 + **RPO≤5min / RTO≤30min** + 季度演练,
现在只有手动 `pg_dump`,且**备份与数据库在同一台机器上**(等于没备份);
3. **监控告警**:Prometheus/Grafana/Loki 未部署;第一阶段按 specs 允许用日志兜底,但"容器 unhealthy 要能通知到人";
4. **商店与合规**:隐私政策/Data Safety/SDK 清单/版权台账/包名统一,均未办(印尼还涉及 Kominfo PSE 注册)。
**逐条状态与"上线前必补清单(按优先级)"见 [DEPLOY-CHECKLIST.md](./DEPLOY-CHECKLIST.md)。**
## 九、故障速查
| 现象 | 先看 | 处理 |
| --- | --- | --- |
| 容器起不来,日志有"生产配置校验未通过" | §五 | 补 `.env` 里对应变量;密钥用 `openssl rand -base64 48` |
| `/healthz` 超时(不是快速 404/200) | 是不是刚重新打包没重启? | 重启进程(§四 的注意事项) |
| `app-api` healthy 但 `/app/*` 报连库失败 | `docker compose logs db` | 确认 `DB_URL` 指向 `db:5432`,`DB_PASSWORD` 与 db 容器一致 |
| 转码任务一直 `QUEUED` | worker 日志 | 确认 `transcode-worker` 在跑、`MEDIA_ROOT_DIR` 与 API 相同、镜像里装了 ffmpeg |
## 十、本地开发(不上 Docker)
```bash
mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests
java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar # 不带 prod profile,开箱即用
tools/smoke.sh # 本机一键冒烟
```
+86
View File
@@ -0,0 +1,86 @@
# Author: fruit.com.cn
#
# 多阶段构建:maven 构建 fat jar -> JRE 运行(非 root、tini 做 1 号进程)。
# 三个可运行件共用一份 Dockerfile,用 MODULE 构建参数区分。
#
# 构建(在 FruitServer 目录下执行):
# docker build -t fruitshorts/app-api:latest --build-arg MODULE=fruitshorts-app-api .
# docker build -t fruitshorts/admin-api:latest --build-arg MODULE=fruitshorts-admin-api .
# docker build -t fruitshorts/transcode-worker:latest --build-arg MODULE=fruitshorts-transcode-worker \
# --build-arg INSTALL_FFMPEG=true .
#
# 注意:本机当前没有 Docker(见 DEPLOY.md),这份 Dockerfile 未经本机构建验证。
# ---------- 1. 构建阶段 ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
# Maven Central 在国内网络下拉依赖不稳定(构建时曾出现 maven-filtering:3.3.1 解析失败),
# 构建阶段固定走阿里云 central 镜像;其它仓库仍直连。
RUN mkdir -p /root/.m2 \
&& printf '%s\n' \
'<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">' \
' <mirrors>' \
' <mirror>' \
' <id>aliyun-central</id>' \
' <mirrorOf>central</mirrorOf>' \
' <url>https://maven.aliyun.com/repository/central</url>' \
' </mirror>' \
' </mirrors>' \
'</settings>' > /root/.m2/settings.xml
# 先只拷各模块 pom 再拉依赖:依赖层可复用,改业务代码不会重新下载依赖
COPY pom.xml .
COPY fruitshorts-common/pom.xml fruitshorts-common/
COPY fruitshorts-framework/pom.xml fruitshorts-framework/
COPY fruitshorts-module-system/pom.xml fruitshorts-module-system/
COPY fruitshorts-module-content/pom.xml fruitshorts-module-content/
COPY fruitshorts-module-media/pom.xml fruitshorts-module-media/
COPY fruitshorts-module-user/pom.xml fruitshorts-module-user/
COPY fruitshorts-module-playback/pom.xml fruitshorts-module-playback/
COPY fruitshorts-module-interaction/pom.xml fruitshorts-module-interaction/
COPY fruitshorts-module-commerce/pom.xml fruitshorts-module-commerce/
COPY fruitshorts-module-creator/pom.xml fruitshorts-module-creator/
COPY fruitshorts-admin-api/pom.xml fruitshorts-admin-api/
COPY fruitshorts-app-api/pom.xml fruitshorts-app-api/
COPY fruitshorts-transcode-worker/pom.xml fruitshorts-transcode-worker/
RUN mvn -B -q -DskipTests dependency:go-offline
COPY . .
ARG MODULE=fruitshorts-app-api
RUN mvn -B -DskipTests -pl ${MODULE} -am package \
&& cp ${MODULE}/target/*.jar /build/app.jar
# ---------- 2. 运行阶段 ----------
FROM eclipse-temurin:21-jre
# ffmpeg 只有转码 worker 需要,用构建参数控制,避免把 80MB 装进两个 API 镜像
ARG INSTALL_FFMPEG=false
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini curl \
&& if [ "$INSTALL_FFMPEG" = "true" ]; then \
apt-get install -y --no-install-recommends ffmpeg; \
fi \
&& rm -rf /var/lib/apt/lists/*
# 非 root 运行;/data/uploads 预留给媒资挂载卷
RUN groupadd -r fruitshorts \
&& useradd -r -g fruitshorts -d /app -s /sbin/nologin fruitshorts \
&& mkdir -p /app /data/uploads \
&& chown -R fruitshorts:fruitshorts /app /data/uploads
WORKDIR /app
COPY --from=build --chown=fruitshorts:fruitshorts /build/app.jar /app/app.jar
USER fruitshorts
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -Duser.timezone=Asia/Shanghai" \
SPRING_PROFILES_ACTIVE=prod \
MEDIA_ROOT_DIR=/data/uploads
# 健康检查打 /healthz(不依赖 actuator,见 HealthController)
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://127.0.0.1:${SERVER_PORT:-8080}/healthz || exit 1
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
+87 -17
View File
@@ -7,7 +7,7 @@
| --- | --- |
| [docs/prd-phase1.md](docs/prd-phase1.md) | 第一阶段 PRD:范围、验收口径、里程碑 |
| [docs/backend-design-phase1.md](docs/backend-design-phase1.md) | 后端设计:1 万并发观看的容量推导、成本、部署 |
| [docs/api-phase1.md](docs/api-phase1.md) | **接口清单:后台管理 + 客户端,标注已实现 / 下一批** |
| [docs/api-phase1.md](docs/api-phase1.md) | **接口契约:后台管理 + 客户端;文末含「契约与实现状态对照」(逐模块标注已实现 / 未完成)** |
| [docs/architecture-design.md](docs/architecture-design.md) | 早期架构设计文档 |
| 项 | 版本 |
@@ -44,6 +44,7 @@ FruitServer/
| JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` |
| Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` |
| PostgreSQL | 18.6 | `brew install postgresql@18`,`brew services start postgresql@18` 启动 |
| ffmpeg | 9.0.2(Homebrew 当前稳定版) | `brew install ffmpeg`;**只有转码 worker 需要**,两个在线入口(8080/8081)不依赖它 |
> `postgresql@18` 是 keg-only,命令行工具在 `/opt/homebrew/opt/postgresql@18/bin`,
> 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。
@@ -51,8 +52,13 @@ FruitServer/
```sh
java -version # openjdk 21.0.12.1
mvn -v # Maven 3.10.0 / Java 21.0.12.1
ffmpeg -version # 转码 worker 用;没装也能跑在线服务,只是转码任务会一直排队
```
> **ffmpeg 只被 `fruitshorts-transcode-worker` 使用**:`backend-design-phase1.md` §8.5 要求转码与在线服务隔离,
> 所以 app-api / admin-api 里 `fruitshorts.media.transcode.worker-enabled` 默认 false,转码要单独起 worker 进程
> (`java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar`)。
说明:
- 工程编译目标为 Java 17(`maven.compiler.release=17`),在 JDK 21 上构建与运行。
@@ -185,18 +191,43 @@ java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar
## 第一阶段实现进度
> 最后更新:2026-10-24(拉取 `develop` 最新代码后)。**未完成清单见文末「尚未完成」。**
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护 |
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护;**雪花主键(S0)**、**Swagger 在线文档 `/doc.html`(S1)** |
| 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 |
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;补内容分级、可播区域、版权窗口、标签字段 |
| 04 播放器与播放控制 | 部分 | 播放票据 + 签名地址 + 进度已实现;多码率片源待转码 worker |
| 05 记录、收藏与下载 | 部分 | 进度、观看流水、收藏已实现;离线下载授权待做 |
| 06 评论、点赞与分享 | 待做 | 表已建,接口与实现待补 |
| 07 创作者中心 | 待做 | 表已建;转码任务与多码率产物记账已实现 |
| 08 平台运营后台 | 部分 | App 用户管理已实现;内容治理、订单、看板待做 |
| 09 分集收费与支付 | 待做 | 表已建;播放入口的权益判定端口已预留,接上即生效 |
| 10 质量、数据与上线 | 待做 | 压测、埋点、对账待做 |
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;内容分级、可播区域、版权窗口、标签字段 |
| 04 播放器与播放控制 | 已实现 | 播放票据 + 签名地址 + 进度;多码率 HLS 由转码 worker 产出(见 10) |
| 05 记录、收藏与下载 | 已实现 | 进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 已实现 | 评论(游标翻页/审核/删除)、点赞、举报、敏感词、分享 |
| 07 创作者中心 | 已实现 | **在后台管理(Web 端 `ReelShortWeb`)**:工作台、内容编辑、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;不含提现/结算。**App 客户端不提供创作者中心**(口径 2026-10-16) |
| 08 平台运营后台 | 已实现 | 用户管理、内容治理、订单与权益、创作者与内容审核、数据看板 + 四个后台页面 |
| 09 分集收费与支付 | 部分 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,文档口径为"预留") |
| 10 质量、数据与上线 | 部分 | **转码 worker(C1)、Redis 缓存与限流(C2)、埋点(C3)均已实现**;**压测未做** |
### 已完成批次(按提交顺序)
| 批次 | 内容 | 提交 |
| --- | --- | --- |
| S0 | 主键改雪花(58 表 / 52 实体 / 存量库幂等迁移脚本) | `37c9a14` `84aa0e1` |
| S1 | 接口文档在线化(39 个 Controller / 172 个接口中文注解 + `/doc.html`) | `c6fcfcc` |
| 契约补遗 | 补全接口契约遗漏(系统管理 / 内容 / watch-history / creators 账号 / media 详情) | `efe8af6` |
| M2 | 后端契约测试补齐(**0 → 37 条**) | `fafadfc` |
| B1-1 | Google Play 票据校验闭环(校验→建单→发权益) | `db897d5` |
| B1-2 | 恢复购买 / 退款拒付回收 / 对账真实比对 + Apple 端口预留 | `1dc97b4` |
| C1 | 转码 worker(独立进程消费 `transcode_task`,四档 HLS + 封面) | `9f76302` |
| D1 | 部署就绪(配置外置 + prod fail-fast + 自包含 Compose + 冒烟/回滚) | `b567365` |
| D1-fix | 冒烟脚本与 prod Swagger 开关解耦 | `cbb9961` |
| D2 | 部署文档按 specs 约束补全(新增 `DEPLOY-CHECKLIST.md`) | `7dd4b15` |
| 部署修复 | PG18 数据卷挂载路径 + worker 的 prod 密钥/CORS 映射(PR #1) | `8b46e53` `4c4f643` |
| E1 | 补 5 个 C 端接口(`/app/creator/apply`、`/app/config`、`/app/notices`) | `14b5e41` |
| 契约修正 | 移除 `/app/creator/works`、`/app/creator/stats` 的 C 端定义 | `bb21920` |
| E1-rollback | 撤除上述两条接口对应代码(创作者中心归 Web 端) | `4418a84` |
**门禁现状**:`mvn test` **91 条全过**(app-api 49 / commerce 27 / worker 12 / admin-api 3)。
> 客户端仓库 `ReelShort`(Flutter)另有已完成批次:网络层换 `dio`(`1853e18`)、客户端改名 `nontonki → FruitShorts` + 印尼语 `id-ID` 文案(`61454e3`)。
## 默认账号
@@ -393,15 +424,54 @@ public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) { ...
6. 表格状态列改用接口返回的 `status` / `statusText`,
大数值用 `src/utils/format.js` 的 `formatCount` 格式化。
## 尚未实现
## 尚未完成
评论点赞分享、订单与钱包权益(IAP / Google Play Billing 票据校验、对账、退款回收)、
创作者中心、内容治理与经营看板、转码 worker(消费 `transcode_task`,产出多码率 HLS)、
Redis 缓存与限流、离线下载授权、埋点与数据看板。
> 最后更新:2026-10-24。完成后请同步本表与 [docs/api-phase1.md](docs/api-phase1.md)。
这些模块的表结构、接口契约、状态机与权限点都已经冻结,见
[docs/api-phase1.md](docs/api-phase1.md) 里标注「下一批」的部分 —— 接下来是照契约填代码,
不会再有结构性改动。
### 一、后端研发(剩余)
> 已完成的项(不再列出):**C2 Redis 缓存与限流**(`framework/redis` 的 `RedisService` + `RateLimit` 切面)、**C3 埋点**(客户端埋点 SDK),均于 10-20 ~ 10-23 落地。
| # | 项 | 内容 | 依据 |
| --- | --- | --- | --- |
| 1 | **Apple IAP 票据校验** | 目前只留端口与枚举,未做真实校验(印尼 iOS 占比低,文档口径为"预留") | `api-phase1` §1.8 |
| 2 | **压测** | 目标 `P95 < 50ms`(预热后);容量口径文档里有两套("1 万并发"与"2 万并发 + 3000 QPS"),需定稿 | `09-测试计划` §五、`13/14-容量` |
| 3 | **对账数据源** | 结算金额/手续费/税只能从 Play Console 财务报告取(需报表权限);当前 `storeAmountSum` 恒为 `null` + `notes` 说明 | B1-2 待确认 |
| 4 | **退款口径完善** | 部分退款按比例回收、退款实时性(当前由每日对账 + 手动驱动,未接 RTDN 推送) | B1-2 待确认 |
### 二、上线前必补(P0 / Go-No-Go:非研发,但阻塞上线)
| # | 项 | 当前状态 | 目标 |
| --- | --- | --- | --- |
| 1 | 生产密钥集中托管 + 轮换 | 仅 `.env`(权限 600),无托管、无轮换流程 | 密钥集中托管、最小权限、可轮换(`10-安全方案` §五) |
| 2 | 安全专项测试 | 未做 | 越权/注入/敏感数据专项,高危上线前清零(`12-上线检查清单` §三) |
| 3 | 备份与恢复演练 | 仅手动 `pg_dump`,且与数据库**同机** | **RPO ≤ 5min / RTO ≤ 30min**;全量 + WAL 归档 + 加密 + 异地 + 季度演练(`08-备份恢复`) |
| 4 | 监控告警接入 | 未部署,仅日志 + `SlowRequestLogger` 兜底 | Prometheus / Grafana / Loki + P0/P1 告警规则(`07-监控告警`;**需先加 actuator 指标端点**) |
| 5 | HTTPS + 域名 + 反代 | 无 | 管理端 / 客户端 API / 媒资**分别域名**,HTTPS 强制(`04-部署架构` §四、`10-安全方案` §二) |
| 6 | 媒资迁 OSS/S3 + CDN | 本地磁盘 `./data/uploads` | 生产必须换 OSS/S3 + CDN(`08-备份恢复` §六、`12-上线检查清单` §四) |
### 三、基础设施
- **CI/CD 流水线**:当前手动跑门禁,未接流水线(`06-CI-CD方案`)
- **多实例 / 高可用**:当前单点部署;文档要求应用 `8C16G × 3~4`、转码 worker `8C16G × 2~3`,生产用 K8s(`14-生产资源规划`)
- **短信 / 邮件真实通道**:开发走 log,生产待接通道(`05-中间件清单`)
- **分区保留策略**:`partition.retention-enabled=false`,待合规确认后打开(`03-数据架构`)
- **`watch_history` 异步落库**:Kafka 异步通道为 Phase 2 预留(`13-容量与扩展规划` §四)
- 备份存储位置与成本待定(`08-备份恢复` §六)
### 四、不在后端范围(另有分工)
- **客户端 Flutter(`ReelShort`)**:A2~A6 与后端联调、B2 Google Play Billing 购买流程、秒开基线(需低端安卓设备);网络层 `dio`、改名与印尼语文案已完成
- **Web 后台(`ReelShortWeb`)**:由另一位同事侧负责
### 五、文档待同步(供文档维护人)
`docs/api-phase1.md` 与 specs `docs/` 下仍有 20+ 处**过期陈述**(例:`api-phase1` §3 仍把 09 支付标"下一批";specs `17-风险评估` 的 R-01/R-03/R-04/R-09 仍标"未开始")。
**遇到冲突以本表与本仓库 `api-phase1.md` 正文为准。**
> **已作废的方案**:曾提议新建 `fruitshorts-module-analytics` 收敛"两份创作者看板"。
> 因 2026-10-16 口径变更(**创作者中心由 Web 端承载**)撤除了 App 侧 `/app/creator/works`、`/app/creator/stats`,
> **两份看板问题已从根上消失,该方案不再需要**。
## 常见问题
+11
View File
@@ -0,0 +1,11 @@
# Author: fruit.com.cn
# blackbox-exporter 探针模块:只探测 HTTP 2xx,跟随重定向
modules:
http_2xx:
prober: http
timeout: 5s
http:
preferred_ip_protocol: ip4
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
valid_status_codes: [200]
follow_redirects: true
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>FruitShorts backend watchdog: health check every 2 minutes and restart unhealthy services.</Description>
</RegistrationInfo>
<Principals>
<Principal id="Author">
<UserId>S-1-5-18</UserId>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<StartWhenAvailable>true</StartWhenAvailable>
<ExecutionTimeLimit>PT10M</ExecutionTimeLimit>
</Settings>
<Triggers>
<!-- BootTrigger 上挂 <Repetition> 在 Windows 计划任务里不生效(导入后 Next Run Time 为 N/A,
实际只在开机跑一次)。改成 TimeTrigger + 无限重复,配合 StartWhenAvailable
保证开机后若错过也会立即补跑;这样才真正做到每 2 分钟巡检一次。 -->
<TimeTrigger>
<StartBoundary>2026-01-01T00:00:00</StartBoundary>
<Repetition>
<Interval>PT2M</Interval>
<Duration>P3650D</Duration>
<StopAtDurationEnd>false</StopAtDurationEnd>
</Repetition>
</TimeTrigger>
</Triggers>
<Actions Context="Author">
<Exec>
<Command>powershell.exe</Command>
<Arguments>-NoProfile -ExecutionPolicy Bypass -File "C:\Users\Administrator\Desktop\ReelShorts\FruitServer\deploy\watchdog.ps1" -ComposeDir "C:\Users\Administrator\Desktop\ReelShorts\FruitServer" -LogDir "C:\Users\Administrator\Desktop\ReelShorts\FruitServer\logs"</Arguments>
</Exec>
</Actions>
</Task>
+232
View File
@@ -0,0 +1,232 @@
# Author: fruit.com.cn
#
# 服务看门狗:每 2 分钟检查一次,挂了自动拉起,连续失败发飞书告警。
# 注册为 Windows 计划任务后无人值守运行。
param(
[string]$ComposeDir = "C:\FruitServer",
[string]$LogDir = "C:\FruitServer\logs",
[string]$FeishuWebhook = "",
[int]$MaxRestarts = 3,
[string]$FrontendTask = "FruitShorts-Web-3005"
)
$ErrorActionPreference = "Continue"
$ComposeFile = Join-Path $ComposeDir "docker-compose.yml"
$LogFile = Join-Path $LogDir "watchdog-$(Get-Date -Format 'yyyy-MM-dd').log"
# 连续失败计数落盘文件:计划任务每 2 分钟起一个新进程,计数只放进程内永远累积不到
# $MaxRestarts,告警会静默哑火。
$StateFile = Join-Path $LogDir "watchdog-state.json"
function Write-Log($msg) {
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg"
Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
}
function Send-Alert($title, $msg) {
if (-not $FeishuWebhook) { return }
$json = @{
msg_type = "text"
content = @{ text = "[$title] $msg" }
} | ConvertTo-Json -Depth 3
try {
# PS5 的 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码,中文会变成 ???,
# 必须手动转 UTF-8 字节数组发送
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $bytes -ContentType "application/json; charset=utf-8" -TimeoutSec 10
} catch {
Write-Log "飞书告警发送失败: $_"
}
}
# 连续失败计数的读写:JSON 落盘,跨进程保留
function Import-FailCounts {
$counts = @{}
if (-not (Test-Path $StateFile)) { return $counts }
try {
$saved = Get-Content -Path $StateFile -Raw | ConvertFrom-Json
foreach ($prop in $saved.PSObject.Properties) {
$counts[$prop.Name] = [int]$prop.Value
}
} catch {
Write-Log "看门狗计数文件解析失败,各服务按 0 重新计数: $_"
}
return $counts
}
function Export-FailCounts($counts) {
try {
$counts | ConvertTo-Json | Set-Content -Path $StateFile -Encoding UTF8
} catch {
Write-Log "看门狗计数文件写入失败: $_"
}
}
function Test-Docker {
docker info --format "{{.ServerVersion}}" 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
}
function Test-Http([string]$url) {
try {
$response = Invoke-WebRequest -Uri $url -TimeoutSec 10 -UseBasicParsing -ErrorAction Stop
return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400)
} catch {
return $false
}
}
function Test-Container([string]$container) {
$status = docker inspect -f "{{.State.Status}}" $container 2>$null
return ($status -eq "running")
}
function Invoke-Compose([string[]]$composeArgs) {
& docker compose -f $ComposeFile @composeArgs 2>&1 | Out-Null
return $LASTEXITCODE
}
# 确保日志目录存在
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
# 告警 Webhook:命令行参数 > 环境变量 > deploy 目录下的 feishu-webhook.txt。
# 计划任务注册命令里不写密钥(密钥不该进版本库),由部署机本地放文件或配环境变量。
if (-not $FeishuWebhook) { $FeishuWebhook = $env:FRUITSHORTS_FEISHU_WEBHOOK }
if (-not $FeishuWebhook) {
$webhookFile = Join-Path $PSScriptRoot "feishu-webhook.txt"
if (Test-Path $webhookFile) {
$FeishuWebhook = (Get-Content -Path $webhookFile -Raw -ErrorAction SilentlyContinue)
if ($FeishuWebhook) { $FeishuWebhook = $FeishuWebhook.Trim() }
}
}
if (-not $FeishuWebhook) {
Write-Log "未配置飞书 Webhook(参数/环境变量/feishu-webhook.txt 均为空),告警只写本地日志"
}
# Docker Desktop 登录后偶尔尚未就绪,先等待并尝试拉起。
# 注意:本脚本以 SYSTEM 身份运行,绝不能直接 Start-Process Docker Desktop.exe。
# WSL 不支持本地系统账户运行(WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED),
# 以 SYSTEM 启动会把 docker-desktop 发行版搞成僵死状态(VHD 已挂载无法终止),
# 结果是登录用户会话里的 Docker Desktop 也永远起不来。
# 因此改为触发以登录用户身份运行的 FruitShorts-DockerDesktop 计划任务。
if (-not (Test-Docker)) {
Write-Log "Docker 未就绪,尝试通过用户会话启动 Docker Desktop"
$desktopTask = "FruitShorts-DockerDesktop"
try {
# Docker Desktop.exe 是任务进程树的一部分,GUI 活着时任务一直是 Running,
# 直接 Start-ScheduledTask 会被 IgnoreNew 忽略,所以先结束旧实例再拉起。
$t = Get-ScheduledTask -TaskName $desktopTask -ErrorAction Stop
if ($t.State -eq "Running") {
Stop-ScheduledTask -TaskName $desktopTask -ErrorAction SilentlyContinue
Start-Sleep -Seconds 5
}
Start-ScheduledTask -TaskName $desktopTask -ErrorAction Stop
} catch {
Write-Log "触发 Docker Desktop 启动任务失败: $_"
}
for ($i = 0; $i -lt 18; $i++) {
Start-Sleep -Seconds 5
if (Test-Docker) { break }
}
}
if (-not (Test-Docker)) {
Write-Log "Docker 在 90 秒内未就绪,跳过本轮检查"
Send-Alert "服务异常" "Docker Desktop 无法启动,请人工检查桌面机。"
exit 1
}
# 健康检查定义:名称 → 检查方式
$checks = @(
@{ Name = "app-api"; Type = "http"; Url = "http://127.0.0.1:8081/healthz"; Service = "app-api"; Container = "fruitshorts-app-api" },
@{ Name = "admin-api"; Type = "http"; Url = "http://127.0.0.1:8080/healthz"; Service = "admin-api"; Container = "fruitshorts-admin-api" },
@{ Name = "frontend"; Type = "frontend"; Url = "http://127.0.0.1:3005/dist-dev/" },
@{ Name = "Grafana"; Type = "http"; Url = "http://127.0.0.1:3000/api/health"; Service = "grafana"; Container = "fruitshorts-grafana" },
@{ Name = "PostgreSQL"; Type = "docker"; Service = "db"; Container = "fruitshorts-db" },
@{ Name = "Redis"; Type = "docker"; Service = "redis"; Container = "fruitshorts-redis" },
@{ Name = "Prometheus"; Type = "http"; Url = "http://127.0.0.1:9090/-/healthy"; Service = "prometheus"; Container = "fruitshorts-prometheus" }
)
# 跟踪每个服务的连续失败次数(跨进程从状态文件恢复)
$failCounts = Import-FailCounts
foreach ($check in $checks) {
$name = $check.Name
$healthy = $false
if ($check.Type -eq "http") {
$healthy = Test-Http $check.Url
} elseif ($check.Type -eq "frontend") {
$healthy = Test-Http $check.Url
} else {
$healthy = Test-Container $check.Container
}
if ($healthy) {
if ($failCounts[$name] -gt 0) {
Write-Log "$name 恢复正常(此前连续失败 $($failCounts[$name]) 次)"
Send-Alert "服务恢复" "$name 已恢复正常"
}
$failCounts[$name] = 0
} else {
if ($null -eq $failCounts[$name]) {
$failCounts[$name] = 0
}
$failCounts[$name] = $failCounts[$name] + 1
$count = $failCounts[$name]
Write-Log "$name 不健康(第 $count 次)"
if ($check.Type -eq "frontend") {
Write-Log "$name 重启计划任务 $FrontendTask"
Stop-ScheduledTask -TaskName $FrontendTask -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
Start-ScheduledTask -TaskName $FrontendTask -ErrorAction SilentlyContinue
Start-Sleep -Seconds 6
$healthy = Test-Http $check.Url
} elseif ($check.Type -eq "http") {
Write-Log "$name 重启 Compose 服务 $($check.Service)"
Invoke-Compose @("restart", $check.Service) | Out-Null
Start-Sleep -Seconds 12
$healthy = Test-Http $check.Url
if (-not $healthy) {
Write-Log "$name 重启后仍不健康,执行 compose up -d"
Invoke-Compose @("up", "-d", $check.Service) | Out-Null
}
} else {
Write-Log "$name 拉起容器 $($check.Container)"
docker start $check.Container 2>&1 | Out-Null
Start-Sleep -Seconds 5
$healthy = Test-Container $check.Container
if (-not $healthy) {
Write-Log "$name 启动失败,执行 compose up -d"
Invoke-Compose @("up", "-d", $check.Service) | Out-Null
}
}
# 连续失败达到上限 → 告警。计数会跨进程累积,所以只在首次达阈值和
# 之后每 30 轮(约 1 小时)提醒一次,避免每 2 分钟刷屏。
if (-not $healthy -and $count -ge $MaxRestarts -and
($count -eq $MaxRestarts -or $count % 30 -eq 0)) {
Send-Alert "服务异常" "$name 已连续 $count 次不健康,已尝试重启。请人工介入检查。"
Write-Log "$name 连续 $count 次失败,已发告警"
} elseif ($healthy) {
Write-Log "$name 已自动恢复"
}
}
}
# 磁盘检查:超过 85% 告警
$drive = (Get-PSDrive C -ErrorAction SilentlyContinue)
if ($drive) {
$pct = [math]::Round(($drive.Used / ($drive.Used + $drive.Free)) * 100)
if ($pct -gt 85) {
Write-Log "磁盘使用率 ${pct}%(超过 85%)"
Send-Alert "磁盘告警" "C 盘使用率 ${pct}%,请清理日志或扩容"
}
}
# 本轮结果落盘,供下一轮(2 分钟后的新进程)继续累加
Export-FailCounts $failCounts
+267
View File
@@ -0,0 +1,267 @@
# Author: fruit.com.cn
#
# 办公室单机部署(docs/04-部署架构.md §三 精简方案 + §五 第一阶段用 Compose):
# app-api(8081) + admin-api(8080,只绑本机) + transcode-worker + PostgreSQL 18 + Redis
# 转码 worker 与在线服务**同机但独立进程/容器**(§8.5 隔离要求:不抢在线服务资源)。
#
# 用法(目标主机需要 Docker + Compose):
# cp .env.example .env # 填密钥与数据库密码
# docker compose up -d --build
# tools/smoke.sh
#
# 本文件在本机**未验证**(本机没有 Docker),见 DEPLOY.md「未在本机验证的部分」。
services:
# ─────────── 基础设施 ───────────
db:
image: postgres:18-alpine
container_name: fruitshorts-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-fruitshorts}
POSTGRES_USER: ${DB_USER:-fruitshorts}
POSTGRES_PASSWORD: ${DB_PASSWORD:?数据库密码必填,见 .env.example}
# 中文与印尼语内容需要 UTF-8(镜像默认已是 UTF8,这里显式一次)
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
# PostgreSQL 18 起镜像把数据放在 /var/lib/postgresql/<major>/,卷必须挂到父目录
- db-data:/var/lib/postgresql
# 首次启动自动建表 + 造种子数据;顺序即文件名顺序(脚本本身幂等,可重复执行)
- ./sql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./sql/schema-phase1.sql:/docker-entrypoint-initdb.d/02-schema-phase1.sql:ro
- ./sql/partition-maintenance.sql:/docker-entrypoint-initdb.d/03-partition-maintenance.sql:ro
- ./sql/data.sql:/docker-entrypoint-initdb.d/04-data.sql:ro
- ./sql/data-phase1.sql:/docker-entrypoint-initdb.d/05-data-phase1.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fruitshorts} -d ${DB_NAME:-fruitshorts}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
# 不映射端口:只有同网络的容器需要访问;本机要连库时再放开 5432
expose:
- "5432"
redis:
# 第一阶段还没有代码依赖 Redis(C2 才接缓存与限流),先把基础设施就位,
# 免得上线时再改编排。要持久化就留 volume,纯缓存场景可去掉。
image: redis:7-alpine
container_name: fruitshorts-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
expose:
- "6379"
# ─────────── 应用 ───────────
app-api:
build:
context: .
args:
MODULE: fruitshorts-app-api
image: fruitshorts/app-api:latest
container_name: fruitshorts-app-api
restart: unless-stopped
env_file: .env
environment:
SERVER_PORT: "8081"
# 默认连编排里的 db;要连外部库就在 .env 里覆盖 DB_URL。
# reWriteBatchedInserts=true:pgjdbc 把 JDBC 批内多条 INSERT 重写成多值
# INSERT,观看进度缓冲落库(watch:pg:dirty 消费任务)依赖它跑满吞吐。
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}?reWriteBatchedInserts=true}
# 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
# CORS:公网入口 nginx 把 SSL 跑在 80 端口上(腾讯云安全组只放 80),
# 浏览器 fetch 带 Origin 时端口不匹配会被 Spring CORS 拒(必须精确字符串匹配)。
CORS_ALLOWED_ORIGINS: "https://api.frostgiant.vip:80,https://api.frostgiant.vip,http://api.frostgiant.vip,https://39.162.50.99:8081,http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3007,http://192.168.31.250:3007"
ports:
- "8081:8081"
volumes:
# 媒资落盘:三个服务共享同一个卷
- ./data/uploads:/data/uploads
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8081/healthz || exit 1"]
interval: 30s
timeout: 5s
start_period: 40s
retries: 3
admin-api:
build:
context: .
args:
MODULE: fruitshorts-admin-api
image: fruitshorts/admin-api:latest
container_name: fruitshorts-admin-api
restart: unless-stopped
env_file: .env
environment:
SERVER_PORT: "8080"
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB"
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB"
# 浏览后台时 Origin 是实际访问地址,必须精确列全:本机、局域网、公网 IP、域名。
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005,https://api.frostgiant.vip,https://admin.frostgiant.vip"
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
# 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名),
# 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。
FRUITSHORTS_MEDIA_PROTECTED_PREFIXES: ""
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
ports:
# docs/12-上线检查清单「管理端仅内网」:只对局域网开放;不在路由器做端口转发,公网访问不到
- "8080:8080"
volumes:
- ./data/uploads:/data/uploads
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/healthz || exit 1"]
interval: 30s
timeout: 5s
start_period: 40s
retries: 3
transcode-worker:
build:
context: .
args:
MODULE: fruitshorts-transcode-worker
# worker 镜像里装 ffmpeg,两个 API 镜像不装(省 80MB)
INSTALL_FFMPEG: "true"
image: fruitshorts/transcode-worker:latest
container_name: fruitshorts-transcode-worker
restart: unless-stopped
env_file: .env
environment:
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
volumes:
# 与两个入口同一个媒资根,否则找不到源文件
- ./data/uploads:/data/uploads
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
# worker 不监听端口(server.port=0),用进程存活判断
test: ["CMD-SHELL", "pgrep -f app.jar >/dev/null || exit 1"]
interval: 60s
timeout: 5s
start_period: 40s
retries: 3
# ─────────── 监控 ───────────
# 编排统一在这里:prometheus(应用指标 + 容器指标 + 黑盒探活)+ grafana(自动加载看板)
prometheus:
image: prom/prometheus:latest
container_name: fruitshorts-prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
# 15 天保留期,避免磁盘无限增长
- "--storage.tsdb.retention.time=15d"
- "--web.enable-lifecycle"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9090/-/healthy >/dev/null || exit 1"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
grafana:
image: grafana/grafana:latest
container_name: fruitshorts-grafana
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- grafana-data:/var/lib/grafana
# 数据源与看板全部来自仓库,换机器不需要手工配
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?Grafana 管理员密码必填,见 .env.example}
GF_USERS_ALLOW_SIGN_UP: "false"
# 新用户默认界面语言:简体中文
GF_USERS_LANGUAGE: "zh-Hans"
TZ: Asia/Shanghai
depends_on:
- prometheus
- loki
# ─────────── 日志(Loki + Promtail)───────────
# Loki:日志存储与查询引擎;Promtail:采集容器 stdout/stderr 推给 Loki。
# Grafana 里通过 Loki 数据源可直接搜索/过滤各容器的请求日志。
loki:
image: grafana/loki:latest
container_name: fruitshorts-loki
restart: unless-stopped
command: -config.file=/etc/loki/local-config.yaml
volumes:
- ./loki-config.yml:/etc/loki/local-config.yaml:ro
- loki-data:/loki
expose:
- "3100"
# 镜像为 distroless(无 shell/wget),无法在容器内做 HTTP 探活;
# 靠 restart: unless-stopped 自动拉起,Grafana 数据源状态可反映可用性。
promtail:
image: grafana/promtail:latest
container_name: fruitshorts-promtail
restart: unless-stopped
command: -config.file=/etc/promtail/config.yml
volumes:
- ./promtail-config.yml:/etc/promtail/config.yml:ro
# 读取容器日志需要 Docker socket
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
- loki
# 黑盒探活:从外部探测接口可用性与耗时
blackbox:
image: prom/blackbox-exporter:latest
container_name: fruitshorts-blackbox
restart: unless-stopped
volumes:
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
# 容器资源指标(CPU / 内存 / 网络 / 重启),供 Prometheus 抓取
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: fruitshorts-cadvisor
restart: unless-stopped
privileged: true
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
volumes:
# 命名卷:跟着 compose 项目走,`docker compose down -v` 才会删
db-data:
redis-data:
prometheus-data:
grafana-data:
loki-data:
+1012 -44
View File
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
# I1 后台管理端:商品(金币包)配置 + 系统公告管理 —— 计划(2026-10-16)
> 依据:`FruitServer/docs/plans/INBOX.md` 17:30 条目。用户口径「现在做后台管理端,规范还是按照 specs」。
> **本轮=后端接口 + ReelShortWeb 页面一起做**。指令通道已迁到本仓库(`FruitServer/docs/plans/`),
> 留痕写 `FruitServer/docs/plans/PROGRESS.md`(本轮新建);**specs 只读、不再写入**。
## 一、两个缺口(PM 已勘察,直接用)
1. **商品/金币包配置后台无入口**:`product` 表 16 字段已建、3 行种子、C 端 verify 依赖 `store_product_id` 对齐商店商品,但 admin-api 没有任何商品 CRUD。
2. **公告有读没写**:`ops_notice` 表 0 行,E1 交付了 C 端读,后台没有写入口。
## 二、勘察补充(影响做法,已实测)
1. **前端挂载机制**(与条目描述略有出入,已核实):`src/utils/routerHelper.ts` 用
`import.meta.glob('../views/**/*.{vue,tsx}')` + 后端菜单的 `component` 字符串**动态解析**;
`src/router/index.ts` 的 `asyncRouterMap` 只静态声明了 dashboard/content/user/system 等早期分组,
commerce/governance/board 这些都没有静态登记。→ 所以新增页面**只需**:① 新建 `.vue`;
② `sql/data-phase1.sql` 里加菜单且 `component` 与视图路径一致。**静态路由不必改**(改了也无害)。
真正的坑是 `route_name` 唯一(曾因 `CreatorCenter` 重名导致 404),本轮命名会避开。
2. **id 保留段**:权限点已用到 1155、菜单已用到 30 → 权限点用 **1160-1163**、菜单用 **31/32/33**。
3. **前端既有约定**:api 封装在 `src/api/admin/*`(`import request from '@/axios'` + `PageResult`),
页面用 `useRbac().can(code)` 控权、`dateCell` 格式化日期、`PageHeading` 标题;金额/金币这类大数值本项目
没有 `formatCount`,实际用的是 `src/utils/format.js`,**我会用其中的数值格式化工具或 `toLocaleString`**,
并在回执说明(不新造 util)。
## 三、口径决定(需在回执与契约里写清)
**VIP 商品**:**允许创建、不允许启用**(`PUT /{id}/status` 把 `type=VIP` 置为启用 → `400`)。
理由:运营可以先把 VIP 商品与商店 product id 录好,等 B2 订阅上线后一键启用;直接拒绝创建会让上线日重新补数据。
B1-1 已定「VIP 订阅本轮不做」,所以**启用**才是会产生误导的动作,拦在这里。
## 四、后端做法
1. **契约先行**:`docs/api-phase1.md` 新增一节(放在 §2.6 订单与权益之后的新编号),写清两个模块的
接口、字段、校验、错误码、权限点。
2. **商品**(module-commerce 加 service/VO,admin-api 加 controller):
`GET /admin/products/page`(type/status/platform/keyword + 分页)、`GET /{id}`、`POST`、`PUT /{id}`、
`PUT /{id}/status`、`DELETE /{id}`;字段按表结构;校验:`code` 全局唯一、`store_product_id` 同 `platform` 唯一、
`type=COIN → coin_amount>0`、`type=VIP → vip_days>0`、**VIP 不可启用**。响应带 `statusText/typeText`(前端不写死中文)。
3. **公告**(module-system 加 `OpsNoticeService` + VO,admin-api 加 controller):
`GET /admin/notices/page`、`GET /{id}`、`POST`、`PUT /{id}`、`PUT /{id}/status`、`DELETE /{id}`。
**筛选逻辑收敛到 `OpsNoticeService.pageForApp()` 一处**,C 端 `AppNoticeService` 改为委托它
(改我自己 E1 写的文件),这样"后台发布 → C 端可见 / 过期或下架 → 不可见"用的是同一段条件,不会漂移。
4. **菜单与权限点**:`sql/data-phase1.sql` 幂等追加(权限点 1160-1163、菜单 31/32/33 与超管授权)。
商品挂「订单与权益」(id 22) 下;公告新建「运营管理」(id 32) 分组 + 子菜单 (33)。
5. **Swagger**:两个新控制器按 S1 规范加 `@Tag`/`@Operation`,并把分组补进 admin-api 的 `@OpenAPIDefinition`。
## 五、前端做法(ReelShortWeb,develop 分支)
- `src/api/admin/product.ts`、`src/api/admin/notice.ts`(按 `commerce.ts` 的写法)
- `src/views/commerce/ProductManagementView.vue`(列表/筛选/新建编辑弹窗/上下架/删除)
- `src/views/operation/NoticeManagementView.vue`(列表/新建编辑/发布下架/删除)
- `sql/data-phase1.sql` 的菜单 `component` 写 `views/commerce/ProductManagementView`、`views/operation/NoticeManagementView`
- **不改既有页面**;`route_name` 用 `commerce-products` / `operation-notices`(先 grep 确认不重名)
## 六、门禁
- 后端:`mvn test` 存量 **91 条不得跑红** + 新增测试(商品三类校验、公告可见性委托、
两个控制器的接入层契约、权限点注解检查)
- 前端:`pnpm build:pro` 通过 + eslint 零错误
- 端到端:后台登录 → 菜单出现两个新页面 → 建商品 → 用该商品在 C 端 `verify` 能对上
(`store_product_id` 命中,不再是 `200002 该商品未在后台配置`);公告发布 → C 端 `/app/notices` 可见 → 下架/过期 → 不可见
## 七、风险 / 待确认
1. **不新增静态路由**是否与条目"两边都要加"冲突:我按实测的加载机制做,若你要求同时静态登记,我再补(不影响正确性)。
2. `/app/orders/verify` 走真实 Google Play 才有完整链路;本机无商店凭据,端到端验证到"200002 消失、进入校验环节"为止。
3. 公告"发布时间"语义:`publish_at` 为空视为立即生效(C 端条件里已按"为空或 <= now"处理)。
+91
View File
@@ -0,0 +1,91 @@
# J1 全局 ID 字符串化(Long → String)+ 契约统一 —— 计划(2026-10-16)
> 依据:`docs/plans/INBOX.md` 18:30 条目。用户裁定:雪花 id 精度问题**按方案 (a) 全局修改、统一契约**。
> 起因:I1 实测「UI 点下架 → `200002 公告不存在`」,根因是 19 位雪花 id 超出 JS `Number.MAX_SAFE_INTEGER`
> (9007199254740991),浏览器 `JSON.parse` 会把它四舍五入。
## 一、方案
### 1. 后端:fruitshorts-framework 里注册全局序列化模块
- 新增 `com.fruitshorts.framework.web.JacksonLongToStringConfig`:
- `SimpleModule` 把 **`Long.class` 与 `Long.TYPE`(`long`)→ `ToStringSerializer`**;
- 通过 `Jackson2ObjectMapperBuilderCustomizer` 挂进 Spring Boot 自动配置的 `ObjectMapper`。
- 两个入口(admin-api / app-api)都是 `scanBasePackages = "com.fruitshorts"`,framework 在扫描范围内,
一处生效即两端生效;**不改任何 yml**。
- **不动 `Integer` / `int`**:`pageIndex` / `pageSize` / `Result.code` / `quantity` / `durationSec` …
仍是数字。
- **序列化方向**:`Long` 值一律输出成 JSON 字符串(含普通字段、嵌套对象、`List`、`Map` 值、分页 list 元素)。
- **反序列化方向**:Jackson 默认允许 `"123"` → `Long` 的标量强转,所以**请求体里 id 传字符串可以直接绑定**;
路径变量 `/…/{id}` 与查询参数走 Spring 的 `ConversionService`,本来就是 String → Long,不受影响。
这两条都要有测试兜住。
### 2. 一个刻意的例外:`PageResult.total` 保持数字
`PageResult.total` 是 `long` 计数,不是 id。放行它会让后台 ~13 处 `el-pagination :total=` 与 C 端分页全部变成
字符串,收益为零、风险不小。所以给这个字段加 `@JsonSerialize(using = NumberSerializer.class)` 显式保持数字,
并在契约 §0 里写明「**id 一律字符串;分页 total 是计数,仍是数字**」。
⚠️ 这是我自己拍的口径,已单独列进回执,若要求"Long 一律字符串"我再把这一行去掉。
### 3. 契约统一(`docs/api-phase1.md`)
- §0 通用约定新增「**所有 ID 字段(主键与外键)一律以字符串下发**」+ 示例 + `total` 例外说明。
- §2.11 / §2.12(I1 商品/公告)里逐模块写的 "id 以字符串下发" 改成引用 §0。
- **代码侧**:I1 给 `ProductRespVO` / `NoticeRespVO` 加的 `@JsonSerialize` 与 `Result<String>` 新建返回
**回退成自然写法**(全局生效后它们就是冗余的特例),保持"所有模块一个口径"。
### 4. ReelShortWeb 适配(审计结果见 §二)
把 id 当数字用的地方逐处改成字符串语义。**不建议**的写法(`Number(id)` / `id === 数字`)全部清掉。
### 5. ReelShort(Flutter)适配(最小改动)
**先说结论:客户端 model 里的 id 已经是 `(json['id'] ?? '').toString()` —— id 方向本来就是字符串,不会崩。**
会崩的是被全局规则扫到的 **Long 计数**(`as num?` 在收到字符串时直接抛异常)。逐个 Java 字段核过类型后,
只有两个字段真正会崩:
| Dart 位置 | 字段 | Java 类型 | 处理 |
| --- | --- | --- | --- |
| `lib/core/data/models/auth_session.dart:41` | `expiresIn` | `TokenRespVO.expiresIn` = `long` | 改成兼容解析 |
| `lib/core/data/models/drama.dart:101,117` | `viewCount` | `AppDramaCardVO/AppDramaDetailVO.viewCount` = `Long` | 改成兼容解析 |
| 其余 8 处 `as num?`(`durationSec`/`unlockCoin`/`number`/`totalEpisodes`/`freeEpisodes`/`code`…) | — | `Integer`/`int` | 不动(Java 侧不会被字符串化) |
做法:加一个兼容解析小工具(接受 `num` 或数字字符串),替换上面 3 处。**不做模型改造、不碰业务逻辑**,
避免和正在推进客户端的另一位同事冲突。
## 二、影响面清单(审计结果)
### ReelShortWeb(id 当数字用 → 必须改)
| 文件 | 问题 | 改法 |
| --- | --- | --- |
| `src/utils/creatorImpersonation.ts` | `Number(raw)` 读 localStorage 里的代看创作者 id → 19 位 id 被四舍五入,`X-Creator-Id` 头会带错值 | 存/读都按字符串 |
| `src/views/contentManagement/EpisodeManagementView.vue:58` | `Number(route.query.drama)` 与 `item.id ===` 比较 → 从短剧管理带 `?drama=` 跳过来选不中 | 按字符串比较 |
| `src/views/creator-center/composables/useDramaEdit.js:45` | `Number(route.params.id)` 与 `item.id ===` 比较 → 作品编辑找不到自己那条 | 按字符串比较 |
| `src/views/permission/composables/useMenuManagement.js:50,76,97,117` | 顶级菜单哨兵值是数字 `0`,而 `raw.parentId` 会变成字符串 `"0"` → 下拉回显错 | 哨兵统一成字符串 `'0'` |
### ReelShortWeb(核过不需要改)
- `total.value = res.data.total`(13 处)→ `total` 保持数字,无需改。
- `role.id === selectedRoleId`、`detail.id === row.id`、`useDramaEdit#findIndex`、`useCreatorPrice#find`
→ 两侧都来自接口,同为字符串,仍然相等。
- `formatCount(row.views)` → 内部 `Number(value)`,吃到字符串也正常。
- 全局 `parseInt`/`Number(`/id 算术/id 当数组下标/`a.id - b.id`:审计后**没有命中**。
### FruitServer(会跟着变化、需确认消费方)
- admin-api:所有 `Long` id、`viewCount`、`likeCount`、`memberCount` 等计数器 → 字符串。
- app-api:C 端 `id/dramaId/episodeId/userId`、`viewCount`、`positionMs/durationMs`、
`downloadedBytes/totalBytes`、`TokenRespVO.expiresIn` → 字符串;`Result.code`、`PageResult.total`、
`Integer` 字段 → 仍是数字。
## 三、风险 / 待确认
1. **`PageResult.total` 的例外**(见 §一.2)—— 我自己拍的,一句话可回退。
2. **契约测试的 standalone MockMvc 自带 mapper**:它本来是手搓的 `JsonMapper`,不含全局模块,
会让"字符串化"在测试里**假绿**。所以本轮把模块也挂进 `ControllerContractSupport`,让测试与生产一致;
代价是几条老断言(id 断数字)要改成断字符串。
3. **Flutter 仓库当前只有 `main`**(无 develop)。按条目要求在 develop 提交推送,会新建分支;
客户端由另一位同事推进,若他在 main 上继续,需要把这笔改动带过去 —— 回执里会写清。
4. `Map<Long, X>` 的 **key** 不在本次范围(只要求 Map 值);当前也没有接口返回 `Map<Long, ?>` 给前端。
5. 大 id 只在"新建后立刻用 id 操作"时暴露;老页面用种子小 id,所以**回归要靠端到端实测**,不能只看单测。
+95
View File
@@ -0,0 +1,95 @@
# K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整 —— 计划(2026-10-16)
> 依据:`docs/plans/INBOX.md` 19:40 条目。用户指令:「现在你要做的事对接客户端接口及 UI 调整,
> 仍然严格按照 specs 执行」→ 本轮做客户端 `ReelShort`(Flutter),目标是一条完整看剧闭环:
> **首页点进剧 → 详情正确 → 能起播 → 进度能续看 → 能收藏**,全链路走真接口。
> 契约以 `FruitServer/docs/api-phase1.md` §1.4 / §1.5 为准。
## 一、勘察更正(先说清,免得后面对不上)
条目里写「客户端只调了 6 个接口、没有详情和票据」—— 这条**不准确**,实测:
| 接口 | 现状 |
| --- | --- |
| `GET /app/dramas/{id}` | **已接**:`CatalogApi.dramaDetail` + `CatalogController.requireDetail`,详情页在用 |
| `GET /app/episodes/{id}/play` | **半接**:`CatalogApi.playTicket` + `CatalogController.resolvePlay` 存在,但**没有任何页面调用**;详情下发的 `playUrl`(免费集已带签名)被直接使用 |
| `/app/watch/*` | **没接**:`WatchHistoryRepository` 是 `SharedPreferences` 本地实现,`demoProfileId` 假身份 |
| `/app/favorites/*` | **没接**:收藏走本地 `EngagementController`(SharedPreferences),点赞也是本地(点赞属 K3,不动) |
所以本轮真正要做的是:**播放票据接上并带过期重取**、**进度从本地换远程**、**收藏换服务端**、
**三态 UI**,以及把详情页已有的加载/失败态补齐(空态、续看进度条)。
## 二、方案(分层:api → repository → logic → view)
### 1. 新增 api 类(`lib/core/data/remote/`)
| 文件 | 方法 | 对应接口 |
| --- | --- | --- |
| `playback_api.dart`(新增) | `ticket(episodeId)` | `GET /app/episodes/{id}/play` |
| `watch_api.dart`(新增) | `saveProgress` / `progressOfDrama` / `recent` | `POST /app/watch/progress`、`GET /app/watch/progress?dramaId=`、`GET /app/watch/recent?limit=` |
| `library_api.dart`(新增) | `toggleFavorite` / `favorites` / `removeFavorite` | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` |
票据调用从 `CatalogApi` **搬**到 `PlaybackApi`(条目要求新增 playback_api),
`CatalogRepository.resolvePlay` 随之移到新的 `PlaybackRepository`——这是本轮唯一"动到既有目录层"的地方,最小接线。
### 2. repository(保留抽象,测试注入假实现)
- `playback_repository.dart`(新增):`ticketFor(episodeId, {forceRefresh})`。
`HttpPlaybackRepository` 内含**短时缓存**:默认 TTL 10 分钟(签名有效期契约是 15 分钟,留余量),
**时钟与 TTL 可注入** → 过期自动重取,测试用假时钟钉死。
- `watch_history_repository.dart`(**改签名**):服务端按 `(user, drama)` 存一条续看记录,
所以接口从 `(profileId, dramaId, episodeId)` 变成
`progressFor(dramaId)` / `save({episodeId, position, duration, finished})` / `recent(limit)`;
`HttpWatchHistoryRepository` + `InMemoryWatchHistoryRepository`(测试用);
`demoProfileId` 删除;`clampResumePosition` 保留(续看钳位规则不变)。
- `favorite_repository.dart`(新增):`toggle` / `list` / `remove`;HTTP + 内存实现。
### 3. controller / logic
- `favorite_controller.dart`(新增):`savedIds` + `cards` + `loading/error`,
`toggle(dramaId)` **乐观更新 → 失败回滚**(`DELETE`/`toggle` 任一失败都回到原状态)。
- `DramaDetailLogic` / `MineLogic` / `PlayerLogic`:改用新的 `WatchHistoryRepository`,
续看点从 `WatchProgress(episodeId, positionMs)` 来(不再是本地 episodeId 字符串)。
- `PlayerLogic`:
- 起播前 `ticketFor(episode)`,把签名地址写回 session 的剧集对象;**地址原样交给播放器**
(`AppConfig.mediaUrl` 只补 host,不动 path/query —— 新增测试钉住 `?e=&s=` 原样保留);
- 票据拿不到 / `accessType=COIN_REQUIRED|NO_MEDIA` → 由**后端返回值**驱动 UI(锁定/无片源提示 + 重试);
- 进度上报**节流 10 秒**(原实现是 5 秒),切集/退出/切后台各补一次,`finished` 在播完时上报。
### 4. UI
- 详情页:加载中 / 失败(可重试)/ 空剧集三态;续看位置在"继续观看"按钮与对应剧集行上给进度条。
- 播放页:票据加载中 / 失败(可重试)/ 锁定(`accessType` 驱动)三态;收藏按钮真实状态 + 回滚 +
失败提示(未登录先引导登录,沿用现有 `ensureSignedIn`)。
- 我的页:最近观看卡片三态(服务端续看位置驱动)、收藏列表三态,去掉 `demoProfileId` 假身份。
- 文案:新增键写进 `app_en.arb` / `app_zh.arb` / `app_id.arb`,`flutter gen-l10n` 重新生成。
## 三、影响面清单
新增:`playback_api.dart`、`watch_api.dart`、`library_api.dart`、`watch_progress.dart`(模型)、
`playback_repository.dart`、`favorite_repository.dart`、`favorite_controller.dart`。
改:`watch_history_repository.dart`(签名)、`catalog_api.dart`(去掉 playTicket)、
`catalog_repository.dart`(去掉 resolvePlay)、`catalog_controller.dart`(同步)、
`play_ticket.dart`(补 dramaId/number/previewSeconds)、`app.dart`(注入三个仓库 + FavoriteController)、
`drama_detail_{logic,view,binding}.dart`、`mine_{logic,view,binding}.dart`、
`player_{logic,view,binding}.dart`、`discover_{logic,binding}.dart`(续看点来源)、
`app_{en,zh,id}.arb` + 生成的 4 个 l10n 文件。
测试:改 `test/support/fake_watch_history.dart`、`test/playback/watch_history_test.dart`、
`test/catalog/catalog_controller_test.dart`、`test/support/fake_catalog.dart`、
`test/{discover,mine,drama_detail,player}/…`(续看点改签名);新增
`playback_ticket_expiry_test.dart`、`progress_throttle_test.dart`、`watch_history_remote_test.dart`、
`favorite_controller_test.dart`、`player_ticket_gate_test.dart`、`integration/live_watch_flow_test.dart`。
## 四、风险 / 待确认
1. **改动面比"只加接口"大**:`WatchHistoryRepository` 的签名必须改(服务端是"每剧一条续看记录",
没有 profile 概念),这会连带改 4 个 logic + 6 个测试文件。这是接口模型决定的,不是顺手重构。
2. **未登录的边界**:`/app/watch/*` 与 `/app/favorites/*` 需要登录态。客户端策略:
未登录时**不发**这些请求(浏览/试看仍可匿名),收藏按钮沿用"先引导登录"。
3. **票据过期无法从响应判断**(契约里没有 expiresAt 字段),所以用**客户端 TTL(10 分钟)**保守失效重取;
若后端后续补 `expiresAt`,直接换成按字段判断即可(口径已写进计划)。
4. **`/app/watch/recent` 只给 dramaId/episodeId/位置**,没有剧名封面 —— K1 里"我的"页用
已加载的首页/发现卡片按 id 映射显示;映射不到就不显示(K2 的记录页再做成独立请求)。
5. 联调测试需要登录态:新增的 live 测试会**注册一个一次性邮箱账号**,跑完在库里清掉(回执写明)。
+90
View File
@@ -0,0 +1,90 @@
# 2026-10-17 K10 统一空列表占位(EmptyState)
## 一、目标与边界
新建统一 `EmptyState` 组件,替换任务书列出的 10 处纯文字空态,保留现有空态
文案、按钮行为和错误态逻辑。只改空态视觉、三语 l10n 与测试,不改数据、接口、
列表加载/失败逻辑或页面信息架构。
- 客户端分支:`feature/flutter-k10-empty-states`,基线 `develop 19babb8`。
- 文档分支:`docs/k10-empty-states`,FruitServer 基线 K10 任务书 `3f84bc6`。
- 不引入图片、SVG、字体或第三方依赖。
- 不跑真机,设备侧深色/浅色、小屏和页面状态由用户验收。
## 二、组件设计
`lib/core/widgets/empty_state.dart`:
1. 默认居中,最大宽度 280,外层 Padding 24。
2. 默认图形 96x96、圆角 28,背景 `AppColors.blue` 透明度 0.11;图标 42。
3. 深色图标色 `AppColors.blueSoft`,浅色图标色 `AppColors.blue`。
4. compact 图形 64x64、圆角 18、图标 28,用于评论弹窗。
5. 标题 16/w600(compact 14)、最多 2 行;可选副标题 13、`muted`、最多 2 行。
6. 可选主色 TextButton,最小点击区域 44x44;保留调用方动作。
7. 外层 `Semantics(label: title)`;图形、标题和副标题文本使用
`ExcludeSemantics`,避免重复朗读,操作按钮仍保持可访问。
8. 稳定 Key:`empty-state-art`、`empty-state-icon`、`empty-state-title`、
`empty-state-action`。
新增通用副标题 `emptyHint`:
- zh:有新内容时会显示在这里。
- en:New content will appear here.
- id:Konten baru akan muncul di sini.
## 三、10 处替换与图标
| 位置 | 图标 | 行为 |
|---|---|---|
| 首页 noStories | `movie_outlined` | 保留重试 |
| 首页分类空 | `grid_view_rounded` | 无新增动作 |
| 首页搜索空 | `search_off_rounded` | 无新增动作 |
| 收藏 noFavorites | `favorite_border_rounded` | 原按钮不变 |
| 通知 noNotices | `notifications_none_rounded` | 原下拉刷新不变 |
| 详情 noEpisodesYet | `video_library_outlined` | 保留重试 |
| 我的-片单 noSavedShows | `bookmark_border_rounded` | 无新增动作 |
| 我的-最近剧目 noStories | `history_rounded` | 无新增动作 |
| 下载 noDownloads | `download_outlined` | 原清空按钮不变 |
| 观看记录 noWatchRecords | `history_rounded` | 原清空/刷新逻辑不变 |
| 评论 noComments | `chat_bubble_outline_rounded` | compact |
首页错误态继续使用 `error_outline_rounded` 和 `catalogError`;分类、收藏、通知、
记录、评论等失败态继续使用原错误图标/重试,不允许用 `EmptyState` 冒充失败。
## 四、影响文件
- `lib/core/widgets/empty_state.dart`(新增)
- `lib/pages/home/home_view.dart`
- `lib/pages/discover/widgets/home_drama_grid.dart`
- `lib/pages/favorites/favorites_page.dart`
- `lib/pages/notices/notices_page.dart`
- `lib/pages/drama_detail/drama_detail_view.dart`
- `lib/pages/mine/mine_view.dart`
- `lib/pages/downloads/downloads_page.dart`
- `lib/pages/watch_records/watch_records_page.dart`
- `lib/pages/player/widgets/drama_comments_sheet.dart`
- `lib/l10n/app_en.arb`、`app_zh.arb`、`app_id.arb` 与生成文件
- 组件测试及收藏/下载/记录/通知/评论页面空态测试
- `FruitServer/docs/plans/2026-10-17-K10.md`
- `FruitServer/docs/plans/PROGRESS.md`
- `FruitServer/docs/plans/INBOX.md`
## 五、TDD 与门禁
1. 先新增 `EmptyState` 组件测试和五处页面空态测试,确认组件不存在时红灯。
2. 实现组件并替换 10 处空态后跑定向测试绿灯。
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
4. 构建前检查并发 `flutter run/build`;当前有用户 iOS `flutter run` 会话,
不主动终止,若仍未结束则等待其退出后再构建。
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
合并推送。作者与提交者统一为
`Zhou Weidong <502333560@qq.com>`。
## 六、风险与待确认
- 首页当前把 `catalog.error` 与空列表放在同一分支,需先拆出错误态,再单独用
`EmptyState` 渲染 noStories,避免错误态被误认为空态。
- 部分空态位于 Sliver、固定高度滚动区或弹窗内,需保持可滚动和居中。
- 组件外层统一读标题,若操作按钮位于同一 Semantics 容器内,必须确保按钮自身
语义未被 `ExcludeSemantics` 吞掉。
- 最大宽度 280 与 compact 64 图标容器需在 320px 宽度和浅/深色主题下无溢出。
+63
View File
@@ -0,0 +1,63 @@
# 2026-10-17 K11 首页网格 3 列 + 分类首位「全部」
## 一、目标与边界
在 K9 首页「全部剧目」网格上做最小修订:每行从 2 个改为 3 个,分类首个仍绑定
`CategoryController.allCode`,文案改为「全部 / All / Semua」。
- 客户端分支:`feature/flutter-k11-home-grid-3col`,基线 `develop 0466b4b`。
- 文档分支:`docs/k11-home-grid-3col`,FruitServer 基线任务书 `2a06190`。
- 不改轮播、空态、三态、数据源、接口、分页或其他页面。
- 不跑真机;设备侧 3 列布局、分类切换和详情跳转由用户验收。
## 二、实现方案
1. `home_drama_grid.dart`:
- `crossAxisCount: 3`
- `mainAxisSpacing: 14`
- `crossAxisSpacing: 10`
- `childAspectRatio: 2 / 3` 不变
2. 3 列下卡片仍只保留标题最多 2 行、类型最多 1 行;由现有 `Text` 的
`maxLines` / `ellipsis` 保证不溢出。
3. `discover_view.dart` 分类第一项 code 保持 `CategoryController.allCode`,
label 从 `l10n.forYou` 改为 `l10n.all`。
4. 已核对三份 ARB 已存在 `all` key:
- en:All
- zh:全部
- id:Semua
因此直接复用现有 l10n,不重复插入 JSON key;热门、最新和服务端分类顺序不变。
## 三、测试
- 更新 K9 网格断言:`crossAxisCount == 3`、`crossAxisSpacing == 10`、
`mainAxisSpacing == 14`、比例仍为 2/3。
- 新增首个分类 chip 文案断言为 `All`;切换分类时仍能更新网格。
- K9/K10 既有测试保留,不删除用例;只更新本次行为明确变化的参数断言。
- 组件/页面空态测试保持通过。
## 四、影响文件
- `ReelShort/lib/pages/discover/widgets/home_drama_grid.dart`
- `ReelShort/lib/pages/discover/discover_view.dart`
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`
- `FruitServer/docs/plans/2026-10-17-K11.md`
- `FruitServer/docs/plans/PROGRESS.md`
- `FruitServer/docs/plans/INBOX.md`
## 五、门禁与 Git
1. 先更新测试取得与 3 列/全部文案相关的红灯。
2. 实现后运行定向测试。
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
4. 当前有 Android `flutter run` 会话,不主动终止;构建前检查进程,必要时使用
独立工作区副本构建,避免争用原项目 `build/`。
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
合并推送。作者与提交者统一为
`Zhou Weidong <502333560@qq.com>`。
## 六、风险与待确认
- 3 列后单卡宽度变窄,长标题和类型必须继续 ellipsis,不允许改变卡片结构。
- 现有 `all` l10n 已具备三语,新增重复 key 会破坏 ARB;本批复用现有 key。
- 用户正在运行的 Flutter `run` 不终止;APK 构建需隔离原 `build/`。
+79
View File
@@ -0,0 +1,79 @@
# 2026-10-17 K12 首页去标题 + 空态视觉重做
## 一、目标与边界
在 K11 首页基础上做两项视觉修订:
1. 删除首页「全部剧目」标题,分类 chips 后直接显示网格。
2. 将 `EmptyState` 从单个实心圆角块改为轻盈的双层同心圆。
只改首页间距、`EmptyState` 视觉与对应测试,不改接口、数据、轮播、三态或其他页面。
- 客户端分支:`feature/flutter-k12-home-empty-polish`,基线 `develop b5a8656`。
- 文档分支:`docs/k12-home-empty-polish`,FruitServer 基线任务书 `87a37e9`。
- 不引入图片、SVG、字体或动画;不跑真机。
## 二、首页去标题
- 删除 `Key('home-all-dramas-title')` 的 Text。
- 删除标题与 chips 之间、标题与网格之间的多余间距。
- chips 与网格间距调整为 14px。
- 网格、数据、空态和分类切换逻辑保持不变。
## 三、EmptyState 双层同心圆
默认:
- 外层直径 84,blue 5.5% 填充,1px blue 11% 描边。
- 内层直径 56,blue 11% 填充。
- 图标 27px,深色 `blueSoft` / 浅色 `blue`。
- 标题 15/w600,与图形间距 14。
- 副标题 12.5 muted,行高 1.45,与标题间距 6。
- TextButton 13 主色,点击区至少 44,与副标题间距 8。
- 上下 Padding 28,最大宽度 260。
compact:
- 外层 60、内层 40、图标 21。
- 图形与标题间距 10,上下 Padding 16。
保留外层 Key `empty-state-art`,新增内层 Key `empty-state-art-inner`;继续使用
`empty-state-icon/title/action`。外层 Semantics 仍只读标题。
## 四、测试
- 更新组件测试:外/内层尺寸、图标尺寸、标题 15、副标题 12.5、按钮 44、
最大宽 260、compact 尺寸与深浅主题颜色。
- 首页测试:`home-all-dramas-title` 不存在;chips 存在后直接能找到
`home-all-dramas-grid`。
- K9/K10/K11 既有用例保留,不删除测试文件;只更新本次明确变化的视觉/锚点断言。
- 既有壳层测试中若用「All Dramas」作为页面定位锚点,改用当前稳定锚点
`All` chip,不降低导航流程覆盖。
## 五、影响文件
- `ReelShort/lib/core/widgets/empty_state.dart`
- `ReelShort/lib/pages/discover/discover_view.dart`
- `ReelShort/test/empty_state/empty_state_test.dart`
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`
- `ReelShort/test/widget_test.dart`(仅稳定锚点更新)
- `FruitServer/docs/plans/2026-10-17-K12.md`
- `FruitServer/docs/plans/PROGRESS.md`
- `FruitServer/docs/plans/INBOX.md`
## 六、门禁与 Git
1. 先更新组件与首页测试,取得标题仍存在、空态仍是旧尺寸的红灯。
2. 实现后跑定向测试。
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
4. 当前已确认真机会话停止且无并发构建;构建前再次检查。
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
合并推送。作者与提交者统一为
`Zhou Weidong <502333560@qq.com>`。
## 七、风险与待确认
- 删除标题后,部分既有测试用「All Dramas」文本定位页面;需改为稳定的
`All` chip 或网格 Key,保持原测试流程。
- 双层圆使用固定尺寸和 Stack,小屏需保证 84/60 不溢出。
- 副标题 12.5 与按钮 13 是视觉规格;测试按 double 精确断言。
+121
View File
@@ -0,0 +1,121 @@
# 2026-10-17 K13 运行时服务器地址设置(仅 debug)
## 一、目标与边界
解决 debug APK 不带 `--dart-define` 时固定回退到
`http://jiangdeMacBook-Air.local:8081` 的问题。增加仅 debug 可用的运行时地址
覆盖,保存后下次启动生效。
优先级:
```text
SharedPreferencesAsync dev.api_base_url
> --dart-define(API_BASE_URL)
> AppConfig 现有默认地址
```
- 客户端分支:`feature/flutter-k13-dev-server-setting`,基线 `b762289`。
- 文档分支:`docs/k13-dev-server-setting`,FruitServer 基线 `1a5951c`。
- release 不读取 SharedPreferences 覆盖,也不显示设置入口。
- 不做环境列表、远程配置、接口或后端改动。
- 不跑真机。当前有真机 `flutter run` 时,构建使用隔离副本,不终止用户会话。
## 二、运行时解析
新增 `lib/core/config/server_endpoint.dart`:
1. 常量 key:`dev.api_base_url`。
2. `normalize`:
- `trim()`;
- 只接受 `http://` 或 `https://`;
- `Uri.host` 必须非空;
- 去掉末尾所有 `/`。
3. `resolve`:
- 本地值有效时使用本地值;
- 否则使用有效 dart-define;
- 否则使用现有默认值。
4. `load({debug, preferences})`:
- debug=false 时立即返回,不创建/读取 SharedPreferences;
- debug=true 时读取并规范化,合法值写入 `AppConfig` 的运行时覆盖。
5. `save` 与 `clear`:
- save 前规范化,非法值返回 false 且不写;
- clear 删除 key。
`AppConfig` 增加运行时覆盖读写入口和编译期/默认地址 getter;`apiBaseUrl` 优先
返回已加载的运行时值。`main()` 在 `runApp` 前
`await ServerEndpoint.load(debug: kDebugMode)`,确保 `LumaDramaApp` 创建
`ApiClient` 前已加载完成。
## 三、Debug 设置入口
`MineLogic.showLanguage` 增加 `showServerSetting` 参数。我的页设置弹窗中在语言
列表下方增加「服务器地址」行:
- 仅 `MyPage.debugShowSetting == true` 时显示,默认值 `kDebugMode`,测试可注入;
- 副标题显示当前 `AppConfig.apiBaseUrl`;
- 点击打开编辑弹窗,预填当前地址;
- 保存合法地址后关闭编辑弹窗并提示“重启 App 生效”;
- 非法地址在输入框显示校验错误,不保存;
- 提供「恢复默认」,清除本地 key,并提示重启生效;
- release 默认不显示,也不触发本地读取。
新增三语文案:
- `serverAddress`
- `serverAddressHint`
- `saveAddress`
- `restartToApply`
- `restoreDefault`
- `invalidServerAddress`
## 四、测试
新增 `test/config/server_endpoint_test.dart`:
- 本地覆盖 > dart-define > 默认;
- 非法本地值忽略并回落;
- trim、末尾斜杠、http/https;
- `ftp://`、无 scheme、空 host 被拒绝;
- debug=false 不读取覆盖;
- save/clear 对 SharedPreferencesAsync 的行为。
新增/扩展设置弹窗测试:
- debug 注入 true:显示服务器地址行,可打开编辑弹窗;
- 注入 false:不显示入口;
- 非法地址显示错误且不写入。
既有测试只更新必要锚点/新增断言,不删除用例。
## 五、影响文件
- `ReelShort/lib/core/config/server_endpoint.dart`(新增)
- `ReelShort/lib/core/config/app_config.dart`
- `ReelShort/lib/main.dart`
- `ReelShort/lib/pages/mine/mine_view.dart`
- `ReelShort/lib/pages/mine/mine_logic.dart`
- `ReelShort/lib/l10n/app_en.arb`、`app_zh.arb`、`app_id.arb` 与生成文件
- `ReelShort/test/config/server_endpoint_test.dart`(新增)
- 我的设置弹窗测试
- `FruitServer/docs/plans/2026-10-17-K13.md`
- `FruitServer/docs/plans/PROGRESS.md`
- `FruitServer/docs/plans/INBOX.md`
## 六、门禁与 Git
1. 先写运行时解析与入口可见性测试,确认红灯。
2. 实现后跑定向测试。
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
4. 当前真机 run 会话不终止;构建在独立副本中进行,原 `build/` 不受影响。
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
合并推送。作者与提交者统一为
`Zhou Weidong <502333560@qq.com>`。
## 七、风险与待确认
- `AppConfig.apiBaseUrl` 当前是纯 getter;增加运行时静态状态后,测试必须显式
清理,避免用例间污染。
- release 的“完全不读取”由 `ServerEndpoint.load(debug: kDebugMode)` 和主入口
的 debug 参数共同保证;测试使用可注入 debug 参数验证。
- 保存和恢复默认都只在下次启动生效,当前 ApiClient 不在运行中重建。
- 设置入口只在 debug 显示,不修改发布包的信息架构。
+40
View File
@@ -0,0 +1,40 @@
# 2026-10-17 K16 注册限流可配置
## 范围
将 C 端注册的 IP/设备 24 小时限流阈值从硬编码改为后台 `sys_config` 配置:
```text
app.register.max-per-ip-per-day
app.register.max-per-device-per-day
```
运行时优先读取后台配置;键缺失或非整数时回退 `@Value` 注入值(默认 5/3);
值小于等于 0 时跳过对应 guard。注册接口、错误码和错误文案不变。
## 实现
1. `AppAuthService` 注入 `ConfigService`,从 `sys_config` 优先读取两个键;
再用 `@Value` 注入 yml 值作为缺失/非法时的 fallback。
2. `sql/data-phase1.sql` 幂等写入开发默认 0/0 和说明。
3. `application.yml` 只声明 fallback(默认 5/3),运行时不以后台固化值为准。
4. `docs/api-phase1.md` §1.1 补充后台配置键、缺省/非法回退和 0=不限制口径。
5. 测试 mock `ConfigService`,覆盖自定义、0、缺省/非数字回退。
6. 确认后台「系统设置」页面可访问 `/admin/configs`;若现有表单未展示新键,
记录为待后续 Web 表单补充,不把配置固化回 yml。
## 验证与交付
- `mvn test`
- `mvn -pl fruitshorts-app-api -am package -DskipTests`
- 停止当前 8081 服务,启动新 jar,`/healthz` 通过
- 使用新设备号连续注册至少 4 次,确认不再 429(接口自检)
- 后台把 IP 上限临时改为 2:前两次成功,第三次返回 429;恢复 0 后继续注册成功
- `feature/backend-register-rate-config-fix` 以 `--no-ff` 合并回 `develop` 并 push
- PROGRESS 记录命令、原始输出和解读
## 风险
- 配置 0 会关闭注册滥用保护,仅用于当前开发环境;生产需在后台恢复 5/3。
- 旧 8081 服务重启期间会有短暂不可用。
- 限流仍基于客户端可伪造 IP/设备号,本次不放宽其他风控。
+170
View File
@@ -0,0 +1,170 @@
# K2 客户端第二批:分类 + 观看记录 + 收藏列表 + 下载授权管理
> 依据:`docs/plans/INBOX.md` 2026-10-17 `[待处理] K2` 条目。
> 客户端基线:`ReelShort` `develop` / `00280ca`。
> 接口唯一权威:`FruitServer/docs/api-phase1.md` §0、§1.3、§1.5、§1.6。
> 本批只做 K2,完成后停下写回执,不连做 K3/K4。
## 一、范围与完成口径
### K2-A 分类
- 接 `GET /app/categories`,分类 id 全程按字符串处理。
- 发现页分类条改为服务端分类;保留现有分类条样式。
- 分类内容只从 `GET /app/discover/feed` 的 `sections[]` 中读取
`code == "category-{id}"` 的分区,不在客户端拼装分类请求。
- 分类在发现 feed 中没有对应分区时显示空态。
- 保留 `hot` / `new` 入口与既有样式;其内容同样只按发现 feed 中对应 `code`
分区读取,缺失时显示空态。
### K2-B 观看记录页
- 接 `GET /app/watch/recent?limit=20`、`DELETE /app/watch/progress/{dramaId}`、
`DELETE /app/watch/progress`。
- 最近记录先去重 `dramaId`,再按 id 补 `GET /app/dramas/{id}` 卡片数据。
- 详情补取并发上限 4,同页按 `dramaId` 只请求一次。
- 单条详情失败只影响该条:显示“加载失败”占位与单项重试,其余记录正常渲染。
- 独立“观看记录”页:封面、剧名、集号、进度、更新时间;点击详情并续看;
单条删除、清空均有二次确认;删除采用乐观更新,失败回滚并提示。
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
### K2-C 收藏列表页
- 复用 `GET /app/favorites`、`DELETE /app/favorites/{dramaId}`、
`POST /app/favorites/toggle`,不增加分页参数。
- 独立“我的收藏”页,复用卡片数据模型与列表卡片视觉;点击进详情。
- 取消收藏采用乐观更新,失败回滚并提示。
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
### K2-D 下载授权管理
- 接契约 §1.6 全部 8 个接口:
`GET /app/downloads/policy`、`PUT /app/downloads/settings`、
`POST /app/downloads`、`GET /app/downloads`、
`GET /app/downloads/{episodeId}/url`、`POST /app/downloads/{episodeId}/progress`、
`DELETE /app/downloads/{episodeId}`、`DELETE /app/downloads`。
- 详情页与播放页增加下载入口;后端拒绝时直接展示后端 `message`。
- 独立“我的下载”页:策略卡、授权列表、单条删除、清空、仅 Wi-Fi、最高清晰度。
- 授权状态与任务状态完全使用后端字符串,客户端不新增状态枚举、不自行判断付费/配额。
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
- 明确不做:文件分片落盘、断点续传、离线播放、`licenseKey` 本地校验。
`url` 与 `progress` 本批只提供并测试 API 方法,不驱动实际下载任务。
## 二、实施顺序与 TDD
1. 基线:从 `ReelShort/develop` 建 `feature/flutter-k2-library`。
2. K2-A:先写分类解析、`category-{id}` 映射、无分区空态和筛选状态测试,
确认红灯后实现;再跑该块门禁。
3. K2-B:先写控制器测试覆盖详情去重、并发上限 4、单条失败降级、
删除/清空乐观更新与回滚;确认红灯后实现 API、repository、controller、页面;
再跑门禁。
4. K2-C:先写独立收藏页取消收藏失败回滚、三态与登录引导测试,
确认红灯后实现页面与导航;再跑门禁。
5. K2-D:先写 DTO 字符串 ID/时间解析、策略/列表渲染、授权失败透传、
设置更新、删除/清空和未登录测试;确认红灯后实现;
`ApiClient` 仅补契约所需的 `PUT` 能力;再跑门禁。
6. 三份 ARB 一次补齐并生成 l10n;全量跑
`dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
7. 门禁全绿后合并回 `develop` 并 push;不动 `main`。
8. 在 `PROGRESS.md` 与 INBOX K2 条目追加回执;真机验证交用户执行。
## 三、预计影响文件
### 数据层
- 修改:`lib/core/data/remote/api_client.dart`(补 `put`)
- 修改:`lib/core/data/models/watch_progress.dart`(补 `updatedAt`)
- 修改:`lib/core/data/remote/watch_api.dart`(补清空)
- 修改:`lib/core/data/repositories/watch_history_repository.dart`(补清空)
- 新增:`lib/core/data/models/content_category.dart`
- 新增:`lib/core/data/remote/category_api.dart`
- 新增:`lib/core/data/repositories/category_repository.dart`
- 新增:`lib/core/data/services/category_controller.dart`
- 新增:`lib/core/data/models/watch_record.dart`
- 新增:`lib/core/data/services/watch_records_controller.dart`
- 新增:`lib/core/data/models/download_policy.dart`
- 新增:`lib/core/data/models/download_license.dart`
- 新增:`lib/core/data/remote/download_api.dart`
- 新增:`lib/core/data/repositories/download_repository.dart`
- 新增:`lib/core/data/services/download_controller.dart`
### 页面与共享 UI
- 修改:`lib/app.dart`(新仓库/控制器注入)
- 修改:`lib/core/widgets/auth_gate.dart`(页面级登录引导复用)
- 修改:`lib/pages/discover/discover_logic.dart`
- 修改:`lib/pages/discover/discover_view.dart`
- 修改:`lib/pages/home/home_view.dart`(我的页入口接线)
- 修改:`lib/pages/mine/mine_view.dart`(观看记录、收藏、下载入口)
- 修改:`lib/pages/drama_detail/drama_detail_view.dart`(下载入口)
- 修改:`lib/pages/player/player_view.dart`(当前集下载入口)
- 新增:`lib/pages/watch_records/watch_records_binding.dart`
- 新增:`lib/pages/watch_records/watch_records_view.dart`
- 新增:`lib/pages/favorites/favorites_binding.dart`
- 新增:`lib/pages/favorites/favorites_view.dart`
- 新增:`lib/pages/downloads/downloads_binding.dart`
- 新增:`lib/pages/downloads/downloads_view.dart`
- 视复用需要新增列表卡片/状态组件,预计位于 `lib/core/widgets/` 或各页面 `widgets/`。
### 文案
- 修改:`lib/l10n/app_en.arb`
- 修改:`lib/l10n/app_zh.arb`
- 修改:`lib/l10n/app_id.arb`
- 重新生成:`lib/l10n/app_localizations*.dart`
### 测试
- 修改:`test/catalog/catalog_controller_test.dart` 及分类筛选相关测试
- 修改:`test/discover/discover_logic_test.dart`
- 修改:`test/mine/mine_logic_test.dart`
- 修改:`test/library/favorite_controller_test.dart`
- 新增:分类 API/控制器与 section 映射测试
- 新增:观看记录 API、并发加载、单项降级、删除/清空回滚和页面三态测试
- 新增:收藏独立页、取消收藏回滚、三态和登录引导测试
- 新增:下载 DTO/API/controller/page 测试
- 新增:未登录 `AuthGate` 页面测试
## 四、契约缺口(本批登记,不改契约/后端)
1. `GET /app/watch/recent` 未返回剧名与封面,客户端只能先取最近记录,
再对去重后的 `dramaId` 调详情补齐卡片;代价是 N+1 请求。
建议后续在 `ProgressRespVO` 补卡片字段(需 specs 负责人改契约)。
2. `GET /app/favorites` 没有分页参数并返回全量数组,与
`TC-RECORD-04` 的“分页正确”不一致。本批按全量列表实现,
不自造 `page/size`。
3. `api-phase1.md` 没有搜索接口,本批不做搜索,也不以本地模糊匹配冒充搜索接口。
## 五、风险与控制
1. 记录页 N+1 详情请求可能放大流量和延迟:严格去重、并发上限 4、页内缓存,
单项失败降级,不在失败后自动无限重试。
2. 页面销毁时异步详情仍可能返回:controller 的 `notify` 做 disposed 保护,
不把迟到结果写回已退出页面。
3. `updatedAt` 为后端 `LocalDateTime` 字符串,解析失败只能显示占位,
不能让整条记录解析失败。
4. 下载契约里授权与任务状态均为字符串,客户端只原样展示/映射现有文案,
不根据未知值猜测业务状态。
5. 下载地址字段 `playUrl` 后端当前未填充,本批不做实际下载;不能据此宣称
“可离线播放”。
6. 本机可能缺少 Android SDK,`flutter build apk --debug` 若失败将保留原始输出,
回执标记“未验证”,不静默跳过。
7. 真机/模拟器可达性由用户验证;开发机 `curl` 不作为设备证据。
## 六、待确认(不阻塞本批)
1. 发现页 `hot` / `new` 入口保留,但当前后端 `discoverFeed()` 只生成
`category-{id}` 分区。若后续要展示热门/最新内容,需要后端在发现 feed 下发
对应分区;在此之前按契约显示空态。
2. 收藏分页与观看记录卡片字段待契约负责人后续补足;本批按 INBOX 降级方案执行。
3. 下载真实落盘、断点续传与离线播放需要存储、权限、加密播放方案,另立批次。
## 七、完成定义
- 四块均有“红 → 绿”测试证据。
- `dart analyze lib test` 零告警,`flutter test` 全过。
- `flutter build apk --debug` 通过,或保留缺 SDK 的原始失败输出并标记未验证。
- 三语言 ARB 齐全且生成物已更新。
- 独立记录、收藏、下载页和分类条均接契约接口;未登录入口可完成登录引导。
- 不改 `main`、不改 specs、不改 FruitServer 后端、不做搜索。
- `PROGRESS.md` 与 INBOX K2 回执包含验证步骤、预期结果、提交号与原始门禁输出。
+92
View File
@@ -0,0 +1,92 @@
# 2026-10-17 K3 客户端第三批:互动
## 一、目标与边界
本轮只完成 Flutter 客户端互动能力的真实接口接入:
- K3-A 评论:匿名游标列表、顶层评论与一层回复、发布、敏感词三档、删除、三态与重试。
- K3-B 点赞:剧集与评论点赞、乐观更新、失败回滚、未登录拦截、防连点。
- K3-C 举报:举报评论与短剧、固定原因单选、可选描述、成功与失败提示。
- K3-D 分享回归:沿用现有系统分享,核对标题、简介与 footer 的三语文案。
不做 K4、拉黑、举报用户、后台治理、图片/表情/@提及、无限嵌套、我的评论列表和并发压测。
后端代码、后台 Web、specs 仓库均不改;接口只使用 `FruitServer/docs/api-phase1.md` §1.7。
## 一之一、实测基线
- ReelShort:`develop` / `feature/flutter-k3-interaction` 均为 `45f5f25`,功能分支从 K2 合并点派生。
- FruitServer:`develop` 为 `a0156ee`,比 `origin/develop` 多 1 个仅包含 INBOX 的派单提交。
- 本计划文件在开工前的 FruitServer 工作区中已存在且尚未提交,本轮仅增量补充,不覆盖既有内容。
## 二、契约与数据口径
| 能力 | 接口 | 客户端处理 |
| --- | --- | --- |
| 评论列表 | `GET /app/comments?dramaId=&episodeId=&rootId=&cursor=&size=` | 首屏 20 条,按 `nextCursor` 续拉,用 `hasMore` 控制加载更多 |
| 发布评论 | `POST /app/comments` | 顶层传 `dramaId/content`,回复额外传 `parentId` |
| 删除评论 | `DELETE /app/comments/{id}` | 仅 `mine=true` 可操作,二次确认,失败恢复列表 |
| 点赞切换 | `POST /app/likes/toggle` | `DRAMA` / `COMMENT`,以响应 `{liked,count}` 为最终态 |
| 举报 | `POST /app/reports` | `COMMENT` / `DRAMA`,`reasonCode` 必填 |
| 系统分享 | 无后端接口 | 保持 `PlatformShareService` |
评论字段按 `CommentRespVO` 解析,ID 全部使用字符串。所有新增用户可见文案进入
`lib/l10n/app_{en,zh,id}.arb` 并重新生成。
## 三、已确认的契约缺口与降级
1. `AppDramaDetailVO` / `AppDramaCardVO` 没有 `likeCount` / `liked`。
降级:不展示剧集点赞数字,只维护当前会话内的 liked 状态。
建议:后端详情与卡片补 `likeCount` / `liked`,需 specs 负责人变更契约。
2. §1.7 没有定义 `reasonCode` 枚举,后端当前不校验。
降级:客户端固定 `SPAM / ABUSE / PORN / AD / OTHER` 五项,文案进三语 arb。
建议:后端补 `reasonCode` 枚举与后台工单映射。
3. §1.7 没有拉黑接口。
降级:本批不做拉黑,不提供入口。
## 四、预计影响文件
FruitServer 文档:
- `docs/plans/2026-10-17-K3.md`
- `docs/plans/PROGRESS.md`
- `docs/plans/INBOX.md`
ReelShort 客户端:
- `lib/core/data/remote/comment_api.dart`
- `lib/core/data/remote/interaction_api.dart`
- `lib/core/data/repositories/comment_repository.dart`
- `lib/core/data/repositories/interaction_repository.dart`
- `lib/core/data/services/comment_controller.dart`
- 互动相关模型、Provider 注入与页面/组件
- `lib/pages/player/widgets/drama_comments_sheet.dart`
- `lib/pages/player/player_view.dart`
- 详情页举报入口及互动弹窗
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
- 对应 `test/**/*_test.dart`
实际文件以现有 K1/K2 分层和注入方式为准,避免无关重构。
## 五、TDD 与执行顺序
1. 只读核对 `ReelShort` 与 `FruitServer` 的 `develop` 基线、工作区状态与提交。
2. 从 `develop` 建 `feature/flutter-k3-interaction`。
3. K3-A:先写评论分页、发布、敏感词、删除、失败回滚和未登录测试并确认红灯,再实现并转绿。
4. K3-B:先写剧集/评论点赞成功、计数、乐观更新、回滚和未登录测试并确认红灯,再实现并转绿。
5. K3-C:先写举报成功、重复举报 message 透传和未登录测试并确认红灯,再实现并转绿。
6. K3-D:补充分享文案回归检查,确认三语资源完整。
7. 每块执行定向测试和 `dart analyze lib test`。
8. 全量执行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
9. 分块提交,`--no-ff` 合并回 `develop` 并 push;文档单独提交并 push。
10. 在 `PROGRESS.md` 与 INBOX 写回执,列出用户真机验证步骤,并明确设备侧取证未由 Codex 执行。
## 六、风险与待确认
- 评论游标的实际响应字段以已实现后端 VO 为准;若与任务书 `nextCursor/hasMore` 不一致,先停止并在 INBOX 顶部登记待协助,不自行扩契约。
- 剧集点赞没有初始查询接口,页面重建后会话内状态可能丢失;本批按降级方案不伪造初始值。
- 匿名列表可见,但发布、点赞、举报必须登录;需确保 AuthGate 在发起业务请求前拦截。
- 举报重复与举报自己依赖后端 message,客户端只原样展示,不自行判断。
- 敏感词 `REVIEW` 的 pending 评论仅自己可见,客户端必须保留发布成功返回内容并显示“审核中”。
- 现有互动控制器为本地演示数据,移除时不得破坏 K2 的分类、记录、收藏、下载和播放进度逻辑。
- 全量构建可能受本机 Flutter/Android SDK 环境影响;若失败会记录原始输出,不以开发机接口请求代替设备证据。
- 当前没有待确认的产品问题;若执行中出现契约与实现不一致,按任务书先停并登记。
+82
View File
@@ -0,0 +1,82 @@
# 2026-10-17 K5 客户端视觉:克莱因蓝主题 + 印尼市场适配
## 一、目标与边界
本轮只完成 Flutter 客户端的主题色、蓝色收敛、印尼语 locale 和回归测试:
- K5-A:建立克莱因蓝 token 与浅色/深色显式 `ColorScheme`,主色统一 `#002FA7`。
- K5-B:清理页面层的旧蓝字面量,统一使用 `AppColors` / `ColorScheme`;不改语义色。
- K5-C:`LocaleController` 支持 `id`;未显式选择时跟随系统,非 `en/zh/id` 回退 `id`;
两个现有语言入口增加 Bahasa Indonesia。
- K5-D:补主题 token、对比度、硬编码扫描、locale 回退与语言切换测试,
回归深色/浅色、三态和小屏布局。
不做 K4、支付、Google 登录、新接口、下载落盘、App 图标/启动图或信息架构调整;
不改后端、`ReelShortWeb`、specs。
## 二、用户裁定色值
| Token | 值 | 用途 |
| --- | --- | --- |
| `blue` | `#002FA7` | Klein Blue / IKB 主色 |
| `bluePressed` | `#00227A` | 按下/激活态与大面积蓝色背景 |
| `blueSoft` | `#5B7BE0` | 深色背景上的高亮/次级文字与图标 |
| `onPrimary` | `#FFFFFF` | 主色上的文字/图标 |
| `ink` | `#0B0C10` | 保持现值 |
| `bar` | `#101114` | 保持现值 |
| `card` | `#17191F` | 保持现值 |
| `muted` | `#B7BDC9` | 保持现值 |
对比度口径:正文 ≥ 4.5:1,大字/图标 ≥ 3:1;错误红、成功绿、点赞红等语义色不改。
## 三、预计影响文件
- `lib/core/theme/app_colors.dart`
- `lib/app.dart`
- `lib/core/config/locale_controller.dart`
- `lib/pages/mine/mine_logic.dart`
- `lib/pages/home/widgets/home_quick_drawer.dart`
- `lib/pages/home/home_view.dart`
- `lib/pages/discover/discover_view.dart`
- `lib/pages/drama_detail/drama_detail_view.dart`
- `lib/pages/player/player_view.dart`
- `lib/pages/player/player_logic.dart`
- `lib/pages/player/widgets/seek_progress_bar.dart`
- `lib/pages/player/widgets/comment_tile.dart`
- `lib/pages/player/widgets/drama_comments_sheet.dart`
- `lib/pages/mine/mine_view.dart`
- `lib/pages/watch_records/watch_records_page.dart`
- `lib/pages/downloads/downloads_page.dart`
- `lib/pages/auth/auth_view.dart`
- `lib/core/widgets/auth_gate.dart`
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
- `test/theme/*`、`test/config/*`、相关页面 widget 测试
实际改动以静态扫描结果为准;不改业务逻辑和页面布局。
## 四、TDD 顺序
1. 写主题 token/对比度、硬编码蓝色扫描、locale `id` 持久化与未知语言回退测试,确认红灯。
2. K5-A:新增色值 token 和浅色/深色 `ColorScheme`,补主题测试转绿。
3. K5-B:按扫描结果替换页面蓝色字面量,补语言入口和 widget 回归。
4. K5-C:实现 `id` locale、系统语言跟随、未知系统语言回退 `id`,两个入口增加
Bahasa Indonesia。
5. K5-D:全量扫描、深色/浅色与小屏 widget 回归。
6. 门禁:`dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
7. `--no-ff` 合并回 `develop` 并 push;FruitServer 仅提交 `docs/plans`。
## 五、风险与处理
- 克莱因蓝在浅色背景上作为文字可能对比不足:正文继续使用现有着色,
主色主要用于按钮/图标/选中态;主色背景统一配 `onPrimary`。
- `blueSoft` 仅用于深色背景高亮,避免在浅色背景当正文色造成低对比度。
- 现有 `_orange` 等历史命名可能实际承载蓝色,替换时按语义改名,避免误导。
- 直接替换主题色可能影响 FilledButton、Slider、Switch 等 Material 控件:
通过显式 `ColorScheme` 和 widget 测试统一验证。
- 语言入口两处需同步,避免“我的”和快捷抽屉行为不一致。
- 真机、设备截图与系统语言切换证据由用户执行,Codex 不跑真机。
## 六、待确认
- 当前无产品歧义;按用户裁定的方案 A 色值执行。
- specs 无成文色板,若后续提供 Stitch 视觉稿,再按新稿更新 token 与页面映射。
+102
View File
@@ -0,0 +1,102 @@
# 2026-10-17 K6 客户端 UI:登录页 + 底部导航优化
## 一、目标与边界
本批只优化 Flutter 登录页和首页底部导航,基于 K5 克莱因蓝主题:
- K6-A:登录页品牌区、autofill、密码显隐、键盘 next/done 提交、
提交中禁用、主题语义错误色、小屏键盘可用性。
- K6-B:底部导航背景/边框/选中态统一使用 K5 token,安全区适配,
点击区域 ≥48pt,切换动效 ≤200ms。
- K6-C:新增可注入 `Haptics` 封装,只对关键操作提供 selection/light/medium 反馈。
- K6-D:新增 widget 测试并保持既有 Key、导航逻辑与沉浸隐藏行为。
不做忘记密码、Google/Apple 登录、隐私勾选、新 tab、页面信息架构调整;
不改接口、业务校验、注册/登录流程、后端、Web 或 specs。
## 二、设计口径
- 主色:`AppColors.blue = #002FA7`。
- 按下态/大面积激活背景:`AppColors.bluePressed = #00227A`。
- 深色底高亮:`AppColors.blueSoft = #5B7BE0`。
- 主色上的文字/图标:`AppColors.onPrimary = #FFFFFF`。
- 错误提示使用 `ColorScheme.error/onError` 对应语义色。
- 底栏背景使用现有 `AppColors.bar`,顶部边框使用主题 outline 或现有暗色 token;
页面不新增色值字面量。
## 三、预计影响文件
- `lib/pages/auth/auth_view.dart`
- `lib/pages/home/home_view.dart`
- `lib/core/platform/haptics.dart`
- `lib/app.dart`(Haptics Provider)
- 收藏、点赞、评论、举报、下载、记录相关页面回调
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
- `test/auth/auth_page_test.dart`
- `test/widget_test.dart`
- 必要时补首页导航专用测试文件
不修改 `HomeLogic` 的 tab 顺序、选中规则和 Key;不动播放页沉浸状态来源。
## 四、交互与可用性
### 登录页
- 品牌区显示 App 名和印尼市场副标题,全部走 l10n 三语。
- 邮箱 `TextInputAction.next`,提交后焦点进入密码框。
- 密码 `TextInputAction.done`,提交后直接执行现有 `_submit()`。
- 邮箱 `AutofillHints.email`,密码 `AutofillHints.password`。
- 密码显隐按钮语义标签走 l10n,所有点击区域 ≥44pt。
- `busy` 时禁用两个输入框、显隐按钮、提交和模式切换。
- 使用 `resizeToAvoidBottomInset` + ListView/SafeArea,保证小屏键盘下
submit 可滚动到可见区域。
### 底部导航
- 保持 4 个 tab 的数量、顺序、Key 和回调不变。
- 选中态采用克制的背景胶囊(现有方案收敛)或短指示条,二选一即可;
使用 `bluePressed` 胶囊 + `blueSoft` 高亮文字/图标。
- 底栏高度 + SafeArea bottom 适配 home indicator。
- 每个 tab 的可点击区域至少 48pt;动效控制在 200ms 内。
- `logic.videoImmersive == true` 时保持 `bottomNavigationBar: null`。
### 点击震动反馈
- 使用 Flutter 内置 `HapticFeedback`,统一封装为 `Haptics` 抽象 +
`FlutterHaptics` 默认实现,通过 Provider 注入,测试使用 fake。
- tab 真正切换:`selectionClick()`。
- 登录/注册提交、收藏/取消、点赞/取消、评论发送、举报提交、下载授权申请:
`lightImpact()`。
- 清空记录、清空下载、删除记录/授权:`mediumImpact()`。
- 全部 `unawaited`;平台不可用时默认实现静默忽略;普通列表/滚动/页面切换不触发。
## 五、TDD 顺序
1. 新增登录页密码显隐、autofill、next/done 焦点、busy 禁用、错误语义色测试。
2. 新增底栏 4 Key、tab 切换选中态、沉浸隐藏、最小点击区域和硬编码色扫描测试。
3. 新增 fake Haptics 测试,覆盖 tab selection、主操作 light、危险操作 medium、
普通列表不触发与默认实现异常静默。
4. 先运行定向测试确认红灯,再实现登录页、底栏与 Haptics 转绿。
5. 补 320×568 小屏与键盘弹出回归。
6. 全量门禁:`dart analyze lib test`、`flutter test`、
`flutter build apk --debug`。
## 六、风险与处理
- 登录页已有业务逻辑较长:仅提取品牌区和表单展示组件,不改
`AuthLogic`、接口调用与校验规则。
- 键盘焦点流转依赖 `FocusNode`:显式创建并释放,测试使用
`tester.testTextInput.receiveAction(TextInputAction.next/done)`。
- busy 状态下测试请求不能立即返回:使用可控 Fake Auth 或延迟 Future,
验证输入和按钮禁用,再释放请求避免测试悬挂。
- 底栏新增动画可能影响既有 `pumpAndSettle`:统一使用 ≤200ms,
不引入第三方动画库。
- Haptics 注入遗漏会让页面测试缺少 Provider:所有直接 pump 页面的测试
统一注入 fake;生产默认实现吞掉平台不可用异常,不影响业务。
- 安全区/小屏可能压缩按钮高度:使用最小点击区约束与
`SafeArea(minimum: ...)`,保持现有 Key 可定位。
## 七、待确认
- specs 无 UI 设计稿,按“视觉统一 + 可用性”执行;不引入图片资源。
- 当前无产品歧义;若真机出现键盘安全区差异,由用户提供设备侧截图后再做最小修正。
+91
View File
@@ -0,0 +1,91 @@
# 2026-10-17 K7 首页轮播图 + 站内信通知
## 一、目标与边界
本批只完成 Flutter 客户端两项能力:
- K7-A:增强现有 `CoverCarousel`,继续使用 `feed.banners` 和 3D 翻转风格,
增加自动轮播、交互暂停、指示器、失败占位和语义。
- K7-B:新增匿名站内信分页页,接入 `GET /app/notices`,在我的页增加通知入口。
- K7-C:补轮播/通知/响应式与 locale 时间测试。
不做已读未读、红点、FCM、通知设置、`/app/config noticeEnabled`、首页铃铛、
新 banner 接口、后端、Web 或 specs 修改。
## 二、契约口径
### Banner
- 数据源:`GET /app/home/feed` / `GET /app/discover/feed` 的 `banners[]`。
- 无独立接口;空列表继续 `SizedBox.shrink()`。
- 点击继续沿用 `linkType=DRAMA` + `linkValue` 的现有 `onOpen`。
### Notices
- `GET /app/notices?pageIndex=1&pageSize=20`,匿名。
- 响应:`{total, list:[{id,title,content,type,publishAt,expireAt}]}`。
- ID 全部按字符串;`total` 是数字。
- `list.length` 用于判断当前页是否有数据;是否加载更多按
“已加载数量 < total”判断。
- `type` 原样展示,不建立客户端枚举映射。
## 三、预计影响文件
轮播:
- `lib/pages/discover/widgets/cover_carousel.dart`
- `lib/pages/discover/discover_view.dart`
- `lib/core/widgets/remote_image.dart`(可选失败图标参数)
- `test/discover/cover_carousel_test.dart`
通知:
- `lib/core/data/models/notice.dart`
- `lib/core/data/remote/notice_api.dart`
- `lib/core/data/repositories/notice_repository.dart`
- `lib/core/data/services/notices_controller.dart`
- `lib/pages/notices/notices_page.dart`
- `lib/pages/mine/mine_view.dart`
- `lib/app.dart`(NoticeRepository Provider)
- `test/notice/**`
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
## 四、实现口径
### 轮播
- `Timer.periodic` 4.5s;单张不创建定时器;`dispose` 取消。
- 使用 `TickerMode.of(context)`/`Visibility` 语义监听页面可见性;
不可见时取消,恢复时重新启动。
- pointer down 暂停,up/cancel 恢复;拖拽时沿用现有 3D 翻转。
- 指示器采用底部圆点,使用 `AppColors.onPrimary` + `muted` 低透明度。
- 语义标签进入三语 arb。
- 图片失败使用现有 `RemoteImage` 并显示图标占位。
### 通知
- `NoticesController` 管理首屏、分页、加载更多、刷新、错误。
- `NoticesPage` 深色页,保留 K5/K6 主题与 Haptics 不触发滑动振动。
- 列表项标题/类型/时间/两行摘要;点击打开详情 Dialog。
- 详情 `content` 用可滚动 `Text` 保留 `\n`,长内容不溢出。
- `publishAt` / `expireAt` 使用现有 `formatLocalizedDateTime`。
- 我的页 `_menuCard` 增加通知入口,匿名可进入。
## 五、TDD 顺序
1. 轮播:自动切页、单张无 Timer、指示器、触摸暂停、点击回调、失败占位。
2. 通知 API/model:分页字段、字符串 ID、时间解析。
3. Controller:首屏、追加、加载更多条件、空/失败/刷新。
4. NoticesPage:三态、列表、分页、详情、长内容、小屏。
5. 我的页入口与三语文案。
6. 门禁:`dart analyze lib test`、`flutter test`、
`flutter build apk --debug`。
## 六、风险与待确认
- 自动轮播 Timer 与 3D AnimationController 并存,需避免 dispose 后 setState。
- 页面用 `IndexedStack` 保持状态,需通过 `TickerMode` 判断真实可见性。
- `total` 可能在分页期间变化,以本次响应 total 与已加载数量比较。
- 详情长内容必须可滚动,避免小屏 overflow。
- `noticeEnabled` 属 `/app/config`,本批不接;通知入口暂时常驻,已登记后续。
- 无独立 banner 接口和已读接口,不自行扩展契约。
+63
View File
@@ -0,0 +1,63 @@
# 2026-10-17 K8 登录页视觉重设计
## 一、目标与边界
将现有登录页从表单堆叠改为深色、克莱因蓝品牌的单页视觉,保留 K6 已完成的
AuthLogic、校验、接口、登录/注册流程、autofill、键盘流转、busy 状态和 Haptics。
- 客户端分支:`feature/flutter-k8-auth-visual`,基线 `develop 6cc5bfc`。
- 文档分支:`docs/k8-auth-visual`,FruitServer 只改 `docs/plans/`。
- 不改后端、Web、specs、接口契约、其他页面或业务逻辑。
- 不新增图片、字体、第三方依赖;品牌图形使用 Material Icons。
- 不跑真机,设备侧视觉、键盘与小屏验收由用户执行。
## 二、视觉与交互实现
1. 页面背景使用 `AppColors.ink`,顶部叠加克莱因蓝渐变光晕,颜色透明度不超过
0.18,并忽略指针事件。
2. 品牌区使用 48px 克莱因蓝圆角方块、白色剧集图标、App 名和现有
`authBrandTagline`。
3. 表单放入 20px 圆角、1px `outlineVariant` 边框、20px 内边距的
`AppColors.card` 卡片。
4. 输入框使用 52px 高、14px 圆角、`AppColors.bar` 填充,带邮箱/锁前缀图标;
聚焦时显示 1.5px 主色边框并高亮图标;密码显隐按钮保留。
5. 主按钮全宽 52px、26px 圆角、`AppColors.blue` 背景、白色 16/w700 文字,
pressed 态使用 `bluePressed`,loading 为 18px 白色环,busy 时输入、切换与
提交均禁用。
6. 登录/注册改为双段 pill,实际可见标签为 `signIn` / `signUp`;点击调用现有
`toggleMode()`。保留既有 `auth-switch-mode` 兼容入口,避免破坏既有测试。
7. 错误提示使用红色 10% 透明背景、12px 圆角、图标和主题 error 色文字的胶囊。
8. 首帧使用 220ms 的 `TweenAnimationBuilder` 完成淡入和上滑。
9. 保留 `auth-scroll`、`auth-email`、`auth-password`;新增
`auth-brand`、`auth-card`、`auth-mode-sign-in`、`auth-mode-sign-up`、
`auth-submit`、`auth-error-capsule` 等稳定 Key。
## 三、影响文件
- `ReelShort/lib/pages/auth/auth_view.dart`
- `ReelShort/test/auth/auth_visual_test.dart`(新增)
- `ReelShort/test/auth/auth_page_test.dart`(既有用例保持不变)
- `FruitServer/docs/plans/2026-10-17-K8.md`
- `FruitServer/docs/plans/PROGRESS.md`
- `FruitServer/docs/plans/INBOX.md`(完成后标记 K8 已处理)
## 四、TDD 与门禁
1. 先新增视觉测试并运行,确认分段控件、品牌、错误胶囊和尺寸断言红灯。
2. 实现登录页视觉后,定向 auth 测试绿灯。
3. 运行 `dart analyze lib test` 零告警。
4. 运行 `flutter test` 全过。
5. 构建前确认没有并发 `flutter run/build`;运行
`flutter build apk --debug`。
6. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
合并推送。提交作者与提交者统一为
`Zhou Weidong <502333560@qq.com>`。
## 五、风险与待确认
- 旧测试按文字链接和 `auth-switch-mode` TextButton 检查交互;本次通过保留
兼容入口满足旧用例,同时可见交互必须由新 pill 分段控件完成。
- 320px 小屏和键盘弹出时,卡片、错误胶囊及按钮需保持可滚动且不溢出。
- 当前检测到 Android `flutter run` 会话;构建门禁前必须复查,若仍占用
`build/` 则等待其结束,不能并发构建。
- specs 无登录页设计稿,颜色与尺寸以本 INBOX 条目为准,无需新增契约。
+70
View File
@@ -0,0 +1,70 @@
# 2026-10-17 K9 首页剧目列表改造(2 列全部剧目)
## 一、目标与边界
首页(发现 tab)移除「继续观看」区块,在分类 chips 下方以懒构建的 2 列网格展示
当前分类的全部剧目;保留 header、轮播、分类、三态、空态和底部导航。
- 客户端分支:`feature/flutter-k9-home-grid`,基线 `develop 99f64ab`。
- 文档分支:`docs/k9-home-grid`,FruitServer 基线 K9 任务书 `b434f65`。
- 只改首页(发现页)、三语 l10n、对应 widget 测试与 `docs/plans/`。
- 不改接口、数据模型、CategoryController 过滤逻辑、轮播、通知、观看记录、
收藏、下载或底部导航。
- 不新增图片、依赖或分页;继续使用 `RemoteImage` 懒加载。
- 不跑真机,设备侧滚动、布局、分类切换和详情跳转由用户验收。
## 二、实现方案
1. 删除 `_continueHeader`、`_dramaContent`、`_continueTile`、`_ShowcaseCard`
与 `_cardsFor`,首页不再构建或保留「继续观看」文案和代码。
2. 将页面滚动容器改为 `CustomScrollView`,依次放置:
carousel `SliverToBoxAdapter`、分类与标题 `SliverToBoxAdapter`、状态
`SliverToBoxAdapter` 或剧目 `SliverGrid`、底部留白 `SliverToBoxAdapter`。
3. 剧目网格使用 `SliverGridDelegateWithFixedCrossAxisCount`:
`crossAxisCount: 2`、`crossAxisSpacing: 12`、`mainAxisSpacing: 16`、
`childAspectRatio: 2 / 3`;左右由 `SliverPadding` 保持 16。
4. 卡片封面使用 2:3 比例、14px 圆角、`RemoteImage` 和现有底部深色渐变;
标题最多 2 行,类型最多 1 行。点击调用现有
`logic.openCard(..., embedded, onDramaSelected, onAction)`。
5. 数据直接使用 `CategoryController.visibleDramas`;`For You` 使用其全量
去重结果。分类切换后由 ChangeNotifier 驱动 sliver 内容更新。
6. 保留 `categories.loading` 首载进度、`categories.error` 重试、
`noCategoryDramas` / `noSearchResults` 空态文案。
7. l10n 新增 `allDramas`:
`全部剧目` / `All Dramas` / `Semua Drama`,并重新生成 l10n。
## 三、影响文件
- `ReelShort/lib/pages/discover/discover_view.dart`
- `ReelShort/lib/l10n/app_en.arb`
- `ReelShort/lib/l10n/app_zh.arb`
- `ReelShort/lib/l10n/app_id.arb`
- `ReelShort/lib/l10n/app_localizations*.dart`(生成物)
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`(新增)
- `ReelShort/test/widget_test.dart`(仅更新作废的「继续观看」断言)
- `FruitServer/docs/plans/2026-10-17-K9.md`
- `FruitServer/docs/plans/PROGRESS.md`
- `FruitServer/docs/plans/INBOX.md`
## 四、测试与门禁
1. 先新增 K9 首页 widget 测试并取得红灯。
2. 实现后验证:无「继续观看」、有「全部剧目」、2 列 sliver、左右/行列间距、
2:3 封面、分类切换更新、空态与失败态。
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`;
构建前检查无并发 `flutter run/build`。
4. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
合并推送。作者与提交者统一为
`Zhou Weidong <502333560@qq.com>`。
## 五、风险与待确认
- 既有 `test/widget_test.dart` 有两个壳层用例断言首页出现
`Continue Watching`。K9 明确要求该文案彻底消失,因此原断言与
`find.text('Continue Watching'), findsNothing` 不可能同时成立。
处理方式:保留两个既有用例及完整导航流程,只把已作废的文案断言更新为
`All Dramas`,不删除任何测试用例。
- 首页 feed 可能一次返回较多剧目;必须使用 `SliverGrid` 懒构建,不能嵌套
`GridView(shrinkWrap: true)`。
- 2:3 卡片在小屏需要保持标题 2 行和类型 1 行不溢出;测试覆盖 320px 宽度。
- 不改 `CategoryController` 的数据语义;筛选仍由现有分区 code 决定。
+87
View File
@@ -0,0 +1,87 @@
# 2026-10-19 K19 客户端账号中心
## 基线
- ReelShort `develop`: `7d68699`
- FruitServer `develop`: `5c93ba9`
- 只改 ReelShort;不改 FruitServer 业务代码、ReelShortWeb、specs、main。
## 范围
对接 `docs/api-phase1.md` §1.2 的六个个人中心接口:
| 方法 | 路径 | 客户端用途 |
| --- | --- | --- |
| GET | `/app/me` | 资料展示 |
| PUT | `/app/me` | 修改昵称、性别、生日 |
| GET | `/app/me/devices` | 只读登录设备列表 |
| GET | `/app/me/export` | 账号数据摘要与完整 JSON 复制 |
| POST | `/app/me/deactivate` | 申请注销并进入 15 天冷静期 |
| POST | `/app/me/deactivate/cancel` | 撤销待处理注销申请 |
## 接口口径
1. 资料响应仅使用:
`uid/nickname/avatarUrl/gender/countryCode/regionCode/language/timezone/currency/status/maskedTarget/createdAt/lastLoginAt/vipLevel/vipExpireAt`。
`vipLevel` 是整数;时间字段按 `LocalDateTime` 字符串解析。
2. 资料更新只发送本次变更字段:
`nickname/avatarUrl/gender/birthday/language/timezone/regionCode`。
本页只修改 `nickname/gender/birthday`;昵称 trim 后非空且不超过 30 字符,
性别取 `male/female/unknown`,生日格式 `yyyy-MM-dd`。
3. 设备数组只解析并展示
`deviceId/platform/deviceModel/firstSeenAt/lastSeenAt/status`;
`id/userId/deleted` 不展示、不打印。页面只读。
4. 导出按实际结构解析
`{profile, credentials, consents, devices}`,展示资料摘要、凭据、授权记录数和设备数;
“复制完整 JSON”只写系统剪贴板,不写本地文件。
5. 注销请求只发送 `reason`,不采集或上送密码;成功返回 `scheduledAt`。
成功后清本地会话,不调用 logout。撤销接口成功 `data=null`,失败原样透传后端文案。
6. 所有数值和 ID 使用 `jsonToId/jsonToInt`;错误由 `ApiException` 透传到逻辑层。
## 客户端改造
- `lib/core/data/remote/account_api.dart`
- `lib/core/data/models/account_profile.dart`
- `lib/core/data/models/account_device.dart`
- `lib/core/data/models/account_export.dart`
- `lib/core/data/repositories/account_repository.dart`
- `lib/pages/account/` 下四个页面及各自的 binding/logic
- `lib/pages/mine/mine_view.dart` 登录态新增“账号”卡
- `lib/core/data/services/auth_controller.dart` 新增 `applyProfile`
- `lib/app.dart` 注册 `AccountRepository` Provider
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
- 测试新增 `test/account/`、`test/support/fake_account_repository.dart`
## TDD 顺序
1. 先写 API/模型测试:六条路径、方法、请求体、字符串/数字 ID、缺字段;
运行定向测试确认红灯。
2. 实现 AccountApi、三个模型和 AccountRepository,定向测试转绿。
3. 先写 AuthController.applyProfile 测试:保留令牌、更新昵称/头像并持久化;
再实现。
4. 先写 profile/device/export/deactivate logic 测试:
资料保存回调登录态、注销成功清会话、撤销失败透传文案、导出复制内容;
再实现逻辑层。
5. 先写 widget 测试:我的页入口登录可见、资料回填与保存、设备空态、
注销二次确认、导出加载/失败/重试;再实现页面和入口。
6. 生成 l10n,跑全量门禁;最后一次全量测试与 APK 构建。
## 门禁
```text
dart analyze lib test
flutter test
flutter build apk --debug
```
Flutter 分支:`feature/flutter-k19-account`。
## 风险与待确认
- `GET /app/me` 不返回注销申请状态,UI 不猜测状态;撤销失败展示后端文案。
- 无设备下线接口,设备页只读;`TC-ACCOUNT-10` 的“单点下线”保留为缺口。
- 无头像上传接口,头像只展示;空值使用现有占位。
- 资料响应不含 `birthday`,也没有清空入口。本批生日只能设置,无法回填或清除;
不补后端字段,页面文案明确为本次设置。
- 注销成功后服务端立即吊销全部会话,本地同步清理;撤销入口在重新登录后仍可使用。
- 真机验证由用户执行,本批不提供开发机 curl 或设备侧结果作为证据。
+60
View File
@@ -0,0 +1,60 @@
# 2026-10-19 K20 客户端视觉统一
## 基线
- ReelShort `develop`: `42ce1d1`
- FruitServer `develop`: `810d9fc`
- 只改 ReelShort 视觉与布局;不改业务逻辑、接口、后端、Web、specs、main。
- 保留辣椒红 `AppColors.blue = #C4322A`。
## 统一 Token
新增 `lib/core/theme/app_dimens.dart`:
| Token | 值 |
| --- | --- |
| 页面左右边距 | 16 |
| 卡片圆角 / 内边距 | 16 / 16 |
| 内嵌块圆角 / 内边距 | 12 / 12 |
| 胶囊圆角 | 20 |
| caption / body / list / section / page | 12 / 14 / 15 / 16 / 20 |
| 导航标签 | 11 |
| 详情主标题 | 24 |
| 主按钮高度 | 48;整页主操作 52 |
| 点击区 | 44 × 44 |
| 导航高度 / 图标 | 64 / 22 |
| 进度条高度 / 圆角 | 4 / 4 |
| 账号卡高度 / 图标 / 标签 | 64 / 18 / 11 |
| 观看占位高度 | 114 |
## 改造顺序
1. 加 token 与主题:`AppDimens`、`AppColors.favorite`、深色唯一口径、
按钮最小高度、OutlinedButton 主题、AppBar/状态栏样式。
2. 新增 `AppBottomNav` / `AppBottomNavItem`,替换 home/discover/mine/player fallback。
3. 调整账号入口卡为 64 高、18 图标、11 标签、44 点击区,并回归 K19 首屏测试。
4. 收敛字号下限:业务文案 ≥12,导航 11;清理 9.5/10。
5. 收敛卡片圆角与间距:页面 16、卡片 16/12、区块 16/12。
6. 替换任务书列出的硬编码颜色;收藏红心使用 `AppColors.favorite`。
7. 统一详情、记录、下载进度条为 4 高、4 圆角、blueSoft/white24;播放器拖动条保持金色。
8. 统一页面/区块/列表标题层级与按钮高度,移除 mine 按钮 VisualDensity.compact。
9. 详情加载/失败分支补返回 AppBar,封面顶部加黑色渐变,详情边距 24→16。
10. 点赞/收藏选中态改实心图标;分类和 my tabs 补 selected 语义。
11. HeaderPill / LoginButton / 分类 Chip 点击区补到 44。
12. 列表页底部安全区补齐;观看记录占位高度 114,与真实卡片 padding 一致。
## 测试与门禁
- 新增 `AppDimens` token 测试与 `AppBottomNav` 组件测试。
- 不修改既有测试断言;视觉布局变化导致测试失败时改实现。
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
## 风险与边界
- 账号卡升高到 64 可能重新挤压“我的”页首屏;必须保持 K19 语言设置和观看历史入口测试通过。
- 保留页面级深色 Theme 包装以降低大范围回归风险,但根 `themeMode` 必须固定 `dark`。
- 不碰 K21 范围:搜索、Android 返回键、播放器切票黑屏、登录后续操作、下拉刷新、
注销状态闭环、日期/时长格式、语言上送。
- 真机验收由用户执行,本批不提供设备侧截图或日志。
+70
View File
@@ -0,0 +1,70 @@
# 2026-10-19 K21 登录页 UI 优化
## 基线
- ReelShort `develop`: `e44fbdf`
- FruitServer `develop`: `af28161`
- 只改 `ReelShort/lib/pages/auth/**`、l10n 与必要的新增测试。
## 布局方案
- 用 `LayoutBuilder + SingleChildScrollView + ConstrainedBox + Center`,
让品牌与卡片在可视区域垂直居中,键盘弹起时仍可滚动。
- 保留 `auth-scroll`,底部 padding 继续使用 `28 + viewInsets.bottom`。
- 返回按钮移到顶部 `Positioned + SafeArea` 浮层,不再占据内容流。
- 内容最大宽度继续为 440,页面左右边距 16。
- 保留主 background glow(alpha ≤0.18),新增独立 Key 的 warm glow(alpha ≤0.06)。
## 品牌与卡片
- 品牌区居中竖排:48×48 splash mark、App 名 26/w800、tagline 14/blueSoft。
- `auth-brand`、`auth-brand-mark` 保留,mark 尺寸严格 48×48。
- 卡片结构保持:模式切换 → 标题 25/w800 → 副标题 → 字段 → 错误胶囊 → 提交。
- 登录继续使用 `authSubtitle`;注册使用新增 `authSignUpSubtitle`。
- 注册态密码框下显示 12px 的 `authPasswordHint`,登录态隐藏。
## 字段错误与交互
- `AuthLogic` 增加 `fieldError`;`validate()` 记录具体错误,输入变化或切换模式时清除。
- 邮箱/密码字段按 `fieldError` 使用 1.5px semantic error `errorBorder` /
`focusedErrorBorder`。
- 保留 `auth-error-capsule`、`auth-error` 的文案、圆角 12 和 alpha 0.1。
- `_submit()` 首行 busy 短路;异步 catch 中的 `setState` 前检查 mounted。
- 密码框 done 提交仍走同一 `_submit()`,busy 时自然忽略。
- 提交按钮增加极淡辣椒红阴影,背景和按下色保持不变。
## 不可变清单
```text
auth-brand-mark 48x48
auth-card radius 20 / border outlineVariantDark / width 1
input height 52 / radius 14 / fill bar / focus blue 1.5
submit height 52 / radius 26 / blue / bluePressed
auth-enter-animation <=220ms / tween 0 -> 1
mode switch keys, hidden compatibility copy
error capsule radius 12 / alpha 0.1 / semantic color
small screen 320x568 + keyboard 260 scroll reachability
```
## TDD
1. 新增测试:注册副标题、注册态密码提示、登录态不显示提示、字段错误红框。
2. 先确认红灯。
3. 补 `authSignUpSubtitle` 三语并生成 l10n。
4. 调整 AuthLogic、表单字段、品牌和页面布局。
5. 跑两份既有 Auth 测试与新增测试,再跑全量门禁。
## 门禁
```text
dart analyze lib test
flutter test
flutter build apk --debug
```
## 风险
- 居中布局与小屏滚动会互相影响,必须保持 `auth-submit` 可滚动到。
- 字段红框不能通过 errorText 增加字段高度,否则会破坏 52 高度断言。
- 品牌改为图片资源后仍必须保持 `auth-brand-mark` 为 48×48。
- 不改接口、后端、Web、specs、main;真机由用户验收。
+56
View File
@@ -0,0 +1,56 @@
# 2026-10-19 K22.1 后台收尾
## 基线
- ReelShortWeb `develop`: `5494190`
- FruitServer `develop`: `2807ae6`
- 只改 ReelShortWeb 的后台视觉与资源引用、构建配置及无引用文件。
- 不改接口契约、后端业务、客户端与 main。
## 登录页
1. 保留 hero 品牌 mark、kicker 与标题。
2. 删除标题下方说明段落及 `pointDrama/pointPublish/pointAccess` 三个标签。
3. 删除不再使用的三语 key 与 hero 列表样式。
4. 删除注册切换、注册表单、入口事件及相关导出和文案。
## 品牌与图标
1. `brand-mark.png` 复制到 `public`,作为浏览器 favicon 与首屏 loading。
2. 首屏背景改为 `#100C0A`,loading mark 增加圆角。
3. 删除 `public/logo.png`、`public/favicon.ico`。
4. 四个 env 均关闭在线图标,由 `PurgeIcons` 本地打包 Material Symbols。
5. 构建后扫描旧前缀、`<el-icon>`、Element Plus 图标引用,并检查浏览器实际图标。
## Mock 清理
1. dev/pro/test 环境关闭 `VITE_USE_MOCK`。
2. 删除 `mock/` 与 `viteMockServe` 配置。
3. 全仓确认不再存在 mock 环境分支和旧 mock 图标来源。
## 文件删除
删除前分别对目标路径执行全仓 grep,确认只命中自身或已同步处理的引用:
- `src/assets/imgs/logo.png`
- `src/assets/imgs/avatar.jpg`
- `public/logo.png`
- `public/favicon.ico`
- `src/views/permission/CustomMenuView.vue`
- `src/views/Login/components/RegisterForm.vue`
- `mock/`
禁止删除仍被路由、菜单、组件或构建配置引用的文件。
## 门禁
- `pnpm lint:eslint`
- `pnpm build:pro`
- 浏览器自查登录页、后台壳、菜单、顶栏、表格工具栏与首屏 loading。
- 控制台无 `/logo.png`、mock 或旧图标资源 404。
## 风险
- 在线图标关闭后必须确认 PurgeIcons 已本地打包全部实际使用图标。
- 删除 mock 后需确认开发环境只依赖真实后台接口。
- 删除文件前先处理所有引用,避免残留 import 或路由失败。
+78
View File
@@ -0,0 +1,78 @@
# 2026-10-19 K22 后台管理视觉与交互统一
## 基线
- ReelShortWeb `develop`: `5c087f4`
- FruitServer `develop`: `b025bfd`
- 只改 Web 视觉/交互与 FruitServer 菜单图标 SQL,不改接口、客户端、main。
## 图标体系
1. 新增 `src/constants/icons.ts`:
- `AppIcons` 保存语义名到 `material-symbols:*-rounded` 的映射;
- `normalizeAppIcon()` 兼容后端旧短名和历史 Iconify 名;
- `MenuIconOptions` 供菜单管理页选择。
2. 新增 `src/components/AppIcon`:
- 复用现有 `Icon` 组件;
- 默认尺寸 18,支持颜色/悬停色;
- 全局注册 `AppIcon`。
3. 替换 `src/components/**`、`src/views/**` 的旧图标前缀与 Element Plus 图标组件。
4. 对 `node_modules/@iconify/json/json/material-symbols.json` 做存在性校验。
任务书要求的 `language/view-column/lock/chevron-down` 在当前 JSON 中没有对应的
`*-rounded` 名,实施时使用同语义且存在的
`translate-rounded/combine-columns-rounded/lock-person-rounded/keyboard-arrow-down-rounded`。
## 菜单落库
更新 `sql/data.sql` 与 `sql/data-phase1.sql` 中 id 1/2/3/6/15/17/20/22/24/26/32:
| 菜单 | 图标 |
| --- | --- |
| 工作台 | `material-symbols:dashboard-rounded` |
| 内容管理 | `material-symbols:video-library-rounded` |
| 后台管理 | `material-symbols:settings-rounded` |
| 用户管理 | `material-symbols:group-rounded` |
| 创作者管理 | `material-symbols:movie-edit-rounded` |
| 内容治理 | `material-symbols:policy-rounded` |
| 订单与权益 | `material-symbols:payments-rounded` |
| 数据看板 | `material-symbols:monitoring-rounded` |
| 创作者中心 | `material-symbols:video-camera-front-rounded` |
| 运营管理 | `material-symbols:campaign-rounded` |
执行两份 SQL,管理员登录后请求 `/admin/auth/menus`,断言所有 icon 以
`material-symbols:` 开头。
## 品牌与登录
- 复制客户端 `splash_mark.png` 为 `src/assets/imgs/brand-mark.png`。
- Logo 组件展开/收起统一使用品牌 mark。
- 删除 admin/admin123 占位、记住密码持久化、固定 `FruitShorts Studio`。
- 记住我只保存用户名;store reset 时删除 loginInfo。
- 表单回车提交、loading 覆盖请求与动态路由、防止重复。
- 960 以下压缩 hero,登录卡首屏完整。
## 全局壳与主题
- 修复深色模式菜单、头部、标签关闭、主题开关、面包屑对比度。
- body 去掉 `min-width: 980px`,补 1200/768 断点。
- 顶栏、标签页、表格工具栏、弹窗统一 AppIcon。
- CSS token:一组字体变量、4/6/12 圆角、`#409eff !important` 改
`var(--el-color-primary)`,删除或合并未使用壳层选择器。
- 用户信息读取 `userStore`,不再使用固定 avatar/Archer。
- 清除缓存增加二次确认。
## 门禁与自查
- 静态扫描:无旧图标前缀、无 `<el-icon>`、无页面 `@element-plus/icons-vue` 引用。
- `node_modules/@iconify/json/json/material-symbols.json` 逐个校验新图标。
- `pnpm lint:eslint` 零错误。
- `pnpm build:pro` 成功。
- 浏览器自查:登录页、侧边栏展开/折叠、顶栏、标签页、浅/深色、1024/800/375。
## 风险
- 图标批量替换可能影响动态图标与右键菜单,保留 normalize 兜底。
- 窄屏下侧栏与固定内容宽度可能冲突,优先保证内容区不裁切。
- 登录 loading 必须覆盖动态路由生成,避免二次提交。
- 本轮不碰表格、筛选、分页、拦截器、删除确认、rules、权限与上传校验。
+32
View File
@@ -0,0 +1,32 @@
# 2026-10-19 K23.1 后台修正
## 基线
- ReelShortWeb `develop`: `fe1ae95`
- FruitServer `develop`: `b8676d0`
- 只改 ReelShortWeb 视觉与交互,不改接口、后端、客户端、main。
## 品牌文字
1. 浅色菜单品牌字与浅色顶栏文字固定为 `#303133`。
2. 处理本地持久化的旧 `inherit` 值,避免刷新后仍继承主色。
3. 检查 Logo、ToolHeader、Breadcrumb 的实际颜色。
## 删除按钮
1. 扫描所有删除、移除、退款、回收、冻结危险按钮。
2. 统一 `type="danger"`、表格内 `size="small"`,禁止 link/plain/text 和浅色 `danger-action`。
3. 删除确认按钮保持 `el-button--danger` 实心红底。
4. 调整过窄操作列,保证按钮不换行。
## 布局回归
1. 1440×900、1024×768、375×812。
2. 检查侧栏品牌、菜单 42px 基线、popper、短剧编辑弹窗、锁屏弹窗和表格操作列。
3. 检查顶栏、标签页、面包屑图标与文字基线。
## 门禁
- `pnpm lint:eslint`
- `pnpm build:pro`
- 浏览器控制台 0 error、0 404,页面无横向溢出。
+41
View File
@@ -0,0 +1,41 @@
# 2026-10-19 K23 后台视觉深化
## 基线
- ReelShortWeb `develop`: `3e8312e`
- FruitServer `develop`: `9c96e18`
- 只改后台视觉/交互与菜单图标种子,不改接口契约、权限和业务流程。
## 主色
1. Element Plus 主色改为辣椒红 `#c4322a`,同步 light-3/5/7/8/9 与 dark-2。
2. 清理后台源码中的默认 Element 蓝与蓝色透明背景。
3. 验证按钮、开关、链接、选中菜单、标签页和焦点环对比度。
## 菜单
1. 一级菜单保留 Material Symbols Rounded 图标;所有二级菜单图标置空。
2. 开发库幂等执行后通过 `/admin/auth/menus` 检查一级图标存在、二级 icon 为空。
3. 菜单管理只允许目录菜单选择图标,保存子菜单时强制清空。
4. 一级菜单行高 44,二级菜单行高 40;父子文字统一落在 42px 基线。
5. 二级菜单不渲染图标、不保留图标空位,折叠态和 popper 同样只显示文字。
## 短剧编辑弹窗
1. 680px 三区块表单:基础信息、付费设置、封面。
2. 表单增加 model/rules,数字输入固定 180px 且控件在右。
3. 封面改为预览卡:basename、状态、更换/移除、类型/大小校验、进度与失败提示、查看原图。
4. 保存按钮防重复,保存中与上传中禁止关闭。
## 锁屏弹窗
1. 440px 紧凑弹窗,移除全屏入口。
2. 重做标题、说明、内容和底部按钮。
3. 支持回车锁定,删除调试日志。
## 门禁
- 图标名逐个对照 `node_modules/@iconify/json/json/material-symbols.json`。
- `pnpm lint:eslint` 零错误。
- `pnpm build:pro` 通过。
- 浏览器自查登录页、工作台、短剧管理、锁屏;1024/375、明暗主题、控制台 0 error、0 404。
+102
View File
@@ -0,0 +1,102 @@
# K24 计划:客户端 UI 对齐设计稿
日期:2026-10-19
客户端基线:`ReelShort/develop` = `b1c69e2`
文档基线:`FruitServer/develop` = `93b2299`
分支:`feature/flutter-k24-design-system`
## 一、范围
本批只做设计系统、3 Tab 底部导航、首页沉浸层、发现页:
- 设计 token:Obsidian Flare 色板、字体、间距/圆角、玻璃面板。
- 底部导航:Home / Discover / Me 三项;My List 不再独立成 Tab,内容并入 Me 页。
- 首页:保留 `PlayerPage(homeMode: true)` 播放链路,只替换叠加层与信息卡。
- 发现页:品牌行、搜索、分类 chips、Continue Watching、4 个快捷入口、2 列 Trending。
不做:详情页、播放器、我的页、登录页的视觉重做(K25/K26);后端、Web、specs、main。
## 二、设计口径
权威来源:`docs/design/README.md` + `screens/01-home.html` + `screens/02-discover.html`。
不采用 `DESIGN.md` 顶部 YAML 的 `#ffb4a5/#cdbdff`。
| 语义 | 值 |
| --- | --- |
| background / ink / bar | `#0B0C10` |
| card | `#16181F` |
| surface-elevated / raised | `#1D1E28` |
| primary / blue | `#FF5B3D` |
| pressed / bluePressed | `#FF4321` |
| secondary / lavender / blueSoft | `#9278EA` |
| gold | `#FBBF24` |
| success | `#34D399` |
| on-surface | `#FFFFFF` / `#E3E2E8` |
| muted | `#A1A1AA` |
| subtle | `#71717A` |
| border | `rgba(255,255,255,0.08)` |
字体:Outfit(标题)、Plus Jakarta Sans(正文),OFL TTF 本地打包,不运行时联网。
下载失败时保留系统字体,并在回执与 PROGRESS 标注未完成项。
## 三、TDD 顺序
1. 设计 token / 主题:先更新 `test/theme/app_theme_test.dart` 期望值与新增 token 断言,再改
`app_colors.dart`、`app_theme.dart`、`app_dimens.dart`。
2. GlassPanel:先写组件测试(blur、渐变、描边、圆角),再实现 `glass_panel.dart`。
3. 3 Tab:先改导航测试为 `tab-home/tab-discover/tab-my`,覆盖选中态、映射、沉浸隐藏;
再改 `app_bottom_nav.dart` 与 `home_view.dart`。
4. 首页:先用现有 Player 测试保证播放/进度/收藏不回归,再新增首页结构断言
(剧名、HOT、右侧互动栏、底部玻璃卡、Follow Series、Episodes)。
5. 发现页:先写搜索过滤、2 列网格、Continue Watching 隐藏/显示测试,再改
`discover_view.dart` / `discover_logic.dart` / `home_drama_grid.dart`。
6. 回归:更新 4 Tab 与轮播挂载相关断言,保留播放、进度、收藏、举报等行为测试。
## 四、影响文件
- `lib/core/theme/app_colors.dart`
- `lib/core/theme/app_theme.dart`
- `lib/core/theme/app_dimens.dart`
- `lib/core/widgets/glass_panel.dart`(新增)
- `lib/core/widgets/app_bottom_nav.dart`
- `lib/pages/home/home_view.dart`
- `lib/pages/home/home_logic.dart`
- `lib/pages/discover/discover_view.dart`
- `lib/pages/discover/discover_logic.dart`
- `lib/pages/discover/widgets/home_drama_grid.dart`
- `lib/pages/player/player_view.dart`(仅 homeMode 叠加层)
- `lib/pages/mine/mine_view.dart`(仅把 My List 区块并入 Me)
- `pubspec.yaml`、`assets/fonts/*`
- 相关 `test/**`
## 五、数据缺口与降级
| 设计字段 | 现有数据 | 处理 |
| --- | --- | --- |
| 评分 | `Drama` 无评分字段 | 隐藏,不伪造 |
| 完结状态 | 无完结字段 | 隐藏 |
| 榜单名次 | 无字段 | 用 `isHot` + `viewCount` 排序展示,不编名次 |
| VIP 徽章 | 无 VIP 字段;`Episode.locked` 仅集级 | 列表不显示 VIP 徽章 |
| Free 徽章 | `freeEpisodes` 仅详情接口 | 有值才显示 |
| 点赞/评论数 | 互动接口无剧级计数 | 只显示图标或文案,不编数字 |
| Continue Watching 集数/剩余时长 | `WatchProgress` 有位置/时长,无集号 | 有记录才显示,集号缺失时省略 |
## 六、风险
- 首页叠加层改动可能影响沉浸模式、PiP、下载入口与既有 tooltip;通过保留原有行为入口并跑
`test/widget_test.dart`、`test/home/**`、`test/player/**` 控制风险。
- 3 Tab 合并会触碰多个导航测试;只调整 Tab 数量/Key 断言,不改播放与收藏断言。
- 新字体增加安装包体积;只打包 Regular/SemiBold/Bold 必要字重。
- 发现页数据源仍用 `CategoryController.visibleDramas`,不新增接口。
## 七、门禁
```bash
flutter pub get
dart analyze lib test
flutter test
flutter build apk --debug
```
提交作者/提交者:`Zhou Weidong <502333560@qq.com>`。
+79
View File
@@ -0,0 +1,79 @@
# K25 计划:客户端详情页 + 播放器对齐设计稿
日期:2026-10-19
基线:`ReelShort/develop` = `4553606`
分支:`feature/flutter-k25-detail-player`
## 一、范围
只改详情页与 `homeMode=false` 的播放器:
- 详情页:16:9 hero、玻璃导航、元数据、简介展开、CTA、选集 chips、2 列剧集卡。
- 卡片播放器:顶部导航、9:10 视频框、进度条、三按钮行、精选评论卡。
- `homeMode=true` 的 K24 首页沉浸 feed 保持现状。
不改:后端接口/模型字段、首页、发现页、我的页、登录页、`playback_session`/ticket 协议。
## 二、设计口径
沿用 K24 Obsidian Flare token、Outfit/Plus Jakarta Sans、`GlassPanel`。
详情页结构:
1. 16:9 hero(当前模型只有 `posterUrl`,作为 hero 图;无 `coverUrl` 字段)。
2. 左侧玻璃返回,右侧分享/收藏/更多(举报在更多菜单)。
3. 集数 pill、24px 标题、Season 副标题、viewCount、题材标签。
4. 16px 简介卡,2 行 + More/Less。
5. 橙色全宽 CTA + 48×48 收藏/下载,续看进度与错误重试保留。
6. Episodes 标题 + 范围 chip(无区间数据时显示 All)+ 46×44 横向集数 chips。
7. 2 列剧集预览卡:缩略图 96×64、Free/Coin、时长、锁态、下载/播放入口。
播放器结构(`homeMode=false`):
1. 顶部返回、剧名 + Ep.x、更多(下载/PiP)。
2. 9:10 圆角 16 视频框,Auto-Next/1.0x pill、中心橙色播放、右侧点赞/全屏。
3. 1.5px 进度条,白色缓冲、橙色播放、白色 thumb;保留拖动 seek。
4. Episodes / Comments / Next Ep 三按钮;末集 Next Ep 置灰。
5. 精选评论卡复用 `CommentController`,无评论隐藏;VIP/总评论数字段缺失时隐藏。
## 三、TDD 顺序
1. 详情 widget 测试:hero 16:9、CTA 行、简介展开、集数 chips、2 列剧集卡。
2. 详情实现:先复用现有 `DramaDetailLogic`,只重排视图和控件。
3. 播放器 widget 测试:卡片模式结构、三按钮行、进度条颜色/thumb、末集禁用。
4. 播放器实现:新增卡片布局,保留原全屏 feed;复用 ticket/session/queue。
5. 回归:PiP、票据错误、进度、收藏、下载、举报、评论、自动连播。
## 四、数据缺口
| 设计字段 | 模型状态 | 处理 |
| --- | --- | --- |
| coverUrl | 无(只有 posterUrl) | hero 使用 posterUrl |
| 评分 | 无 | 隐藏 |
| 完结状态 | 无 | 隐藏 |
| Top 排名 | 无 | 隐藏 |
| Trailer/4K | 无 | 隐藏 |
| VIP | 无 | 隐藏 |
| 剧级点赞/评论总数 | 无 | 只显示文案/已加载数量,不伪造 |
| 场景标签 | 无 | 隐藏 |
| 集数范围 | 无 | 显示 All chip |
| 倍速能力 | 无播放速率接口 | 显示 1.0x,不伪造切换能力 |
## 五、风险
- 卡片播放器切换全屏时复用同一 `PageController`;只在二者之一挂载,避免控制器绑定两个 PageView。
- 自动连播依赖 PageController 动画,卡片模式保留 PageView 作为视频底层。
- 评论卡新增一次评论首屏请求;无评论/失败时直接隐藏,不影响播放。
- 详情页 hero、2 列剧集卡在小屏下需自适应缩略图宽度,避免溢出。
## 六、门禁与 Git
```bash
dart analyze lib test
flutter test
flutter build apk --debug
```
完成后 `--no-ff` 合并回 `develop` 并 push;作者/提交者
`Zhou Weidong <502333560@qq.com>`。真机验收由用户执行。
+83
View File
@@ -0,0 +1,83 @@
# K26 计划:客户端按「移动端设计预览」校正六屏
日期:2026-10-19
基线:`ReelShort/develop` = `eefabb6`
分支:`feature/flutter-k26-mobile-preview`
## 一、范围
按本地 `/Users/jianghaifeng/Desktop/ReelShort/docs/移动端设计预览.html`
校正六屏:首页、发现、详情、播放器、我的、登录。该文件优先于此前 Stitch screens。
保留 K24 的 Outfit / Plus Jakarta Sans、Provider 业务逻辑、现有接口与数据模型。
不改后端、Web、specs、main。
## 二、Token
| 语义 | 值 |
| --- | --- |
| primary | `#FF5B3D` |
| secondary | `#9278EA` |
| background | `#0B0C10` |
| nav | `#0D0D11` |
| card | `#1B1C21` |
| cardAlt | `#1C1D21` |
| input / quick | `#25252A` / `#202126` |
| gold card | `#201B1E` |
| text / dim / accent | `#E6E1E3` / `#D2CDD0` / `#FFA490` |
| border | `white 8%` |
圆角:卡片 10-14、按钮 9、chips 20、封面 10。
新增紧凑字号 token(9/10/11/12/13/14/15/18/20)与按钮高度 token(28/32/34/40/44)。
## 三、六屏
1. 首页:剧名 + 搜索/菜单;Like/Comment/Share/Favorite 四互动;底部玻璃卡
(tags、第 x/共 y 集、13px 标题、34px 追剧/选集);去掉 HOT/Season/Follow。
2. 发现:Logo + 发现 + 搜索/头像;36px 搜索框;分类 chips;52×66 Continue Watching;
4 个 62px 快捷入口;热门短剧标题 + 2 列封面高 120 网格。
3. 详情:190px hero + 28px 返回;N 集、18px 标题、11px 简介、40px 开始观看、选集 chips;
收藏/下载/举报收进更多;删除统计行、预览卡与底部 Tab。
4. 播放器:全屏;46px 顶栏;中心 52px 播放 + 1.0x;3px 进度;32px 选集/评论/下一集;
一条精选评论卡;无右侧互动栏;下载/PiP/互动收进更多。
5. 我的:顶部 Logo + 我的 + 搜索/头像;48px 用户行;金币卡;Continue Watching;
历史/收藏/喜欢;语言行;底部 3 Tab。
6. 登录:64px 渐变 Logo + 水果短剧 + 副标题;Google/手机号/邮箱三个 44px 入口;
Google/手机号置灰提示即将开放,邮箱进入 K21 表单;底部法律文案。
## 四、TDD 顺序
1. Token / AppDimens / GlassPanel 测试。
2. 首页 overlay 结构与底部导航测试。
3. 发现页搜索、Continue Watching、快捷入口、2 列网格测试。
4. 详情页 190px hero、40px CTA、选集 chips 测试。
5. 播放器全屏顶栏、三按钮、精选评论测试。
6. 我的页用户行、金币卡、Continue Watching、语言行测试。
7. 登录入口与邮箱二级表单测试。
8. 回归播放、进度、收藏、下载、PiP、票据错误、评论与导航。
## 五、数据缺口
- 剧级点赞/评论数、已完结、Top 排名、VIP、评分、场景标签无模型字段:隐藏。
- Continue Watching 仅有 `episodeId/position/duration`;集号能从剧集列表匹配时显示。
- 金币余额与 Google/手机号登录后端未就绪:金币显示 0,Google/手机号入口提示即将开放。
- 剧集时长只有 `durationSec`;缺失则不显示。
## 六、风险
- 六屏同时调整会触碰大量既有 widget 测试;只改视觉/布局与入口,不改业务逻辑。
- 登录页改成入口页后,K21 邮箱表单测试需要先点击邮箱入口;表单本身与认证逻辑保持不变。
- 播放器仍复用 ticket/PlaybackSession/PlaybackQueue;只替换非首页的 overlay。
- 卡片圆角与按钮高度变化可能影响小屏布局,用 320×568、360×720、390×844 回归。
## 七、门禁
```bash
dart analyze lib test
flutter test
flutter build apk --debug
```
完成后 `--no-ff` 合并回 `develop` 并 push;作者/提交者
`Zhou Weidong <502333560@qq.com>`。真机验收由用户执行。
+139
View File
@@ -0,0 +1,139 @@
# 2026-10-20 K28 客户端 UI 重构(按简图)
> 来源:用户 2026-10-19 提供的 5 屏 + 4 面板手绘简图。
> 原则:先出 UI,再划分功能任务;本批不改后端契约、不伪造接口、不放假按钮的“可用”承诺。
> 设计语言沿用现有深色主题(Obsidian Flare),简图只定结构与层级。
## 一、信息架构
底部 4 Tab:
| Tab | 页面 | 说明 |
|---|---|---|
| 首页 | `home` | 浏览型首页(Banner + 继续观看 + 热门漫剧) |
| 剧场 | `theater` | 分类 + 语言筛选 + 两列剧库 + 搜索 |
| 创作 | `create` | AI 制作 / 上传发布 两个入口 |
| 我的 | `mine` | 账户、金币、会员、菜单 |
播放页改为**推入式全屏页**(保留现有 `PlayerPage` 双模式能力),从首页 Banner/卡片、“继续观看”、剧场卡片、选集进入。
## 二、分屏口径
### 01 首页
- 顶栏:搜索框(占满剩余宽度)+ 头像入口 + 星期文案(周一…周日)。
- 频道 Tab:`热门 / 免费 / 新剧 / 榜单`,选中项主色下划线/胶囊;`榜单` 跳剧场榜单区。
- Hero Banner:封面图、剧名(大字号 Outfit)、一句文案、底部页码点;自动轮播 + 手势切换。
- 继续观看:标题行 + 「查看全部」;卡片含封面、剧名、`第 n 集 · 还剩 m 分钟`、播放按钮。
- 热门漫剧:标题行 + 「查看更多」;两列卡片,封面 + 标题 + 题材 + `免费/集数` + 热度。
- 无数据时用现有 `EmptyState`;下拉刷新可选(本批可不加)。
### 02 剧场
- 顶栏:搜索框 + 头像(与首页一致)。
- 分类 chips:全部 + 服务端分类;语言 chips:全部语言 / 中文 / 印尼语 / 马来语 / 英语。
- 两列剧卡:封面(原图比例铺满)、标题、题材、`免费` 角标、集数、热度。
- 搜索:保留现有搜索输入与结果页;点击页面失焦。
- 下拉刷新保留。
- 语言筛选:当前接口未下发 `language`,本批只做 UI 选中态,不做数据过滤;在任务清单标注为后端缺口。
### 03 创作
- 标题「创作」+ 副标题「让故事成为漫剧」。
- 卡 1「AI 制作」:从故事或想法开始,右侧箭头。
- 卡 2「上传发布」:发布已完成的漫剧,右侧箭头。
- 入口行为:本批统一进入「敬请期待 / 入驻申请」的诚实占位页,不做假上传、假 AI 生成。
- 后续功能任务单列(见第五节)。
### 04 我的
- 头像 + 昵称 + 签名/身份文案 + 右侧箭头。
- 三快捷入口:收藏/追剧、观看历史、本地下载。
- 金币卡:`金币余额` + 数值 + `充值` 按钮(跳现有钱包/充值页)。
- 会员卡:`开通会员,解锁更多精彩内容`(跳现有 VIP 页;无 C 端商品时展示“暂未开放”)。
- 菜单:我的作品、创作者中心、消息通知(支持红点)、设置、帮助与反馈。
- 未登录:头像区显示登录入口,金币/会员/菜单保持可点但需登录。
### 05 竖屏播放
- 顶栏:返回、更多(右上)。
- 右侧互动 rail:点赞(含数量)、评论(含数量)、收藏、分享。
- 底部:剧名、`第 n 集`、题材标签 + `关注`、简介、进度条 + `当前/总时长`、`选集`、`更多`。
- 中心播放按钮:仅暂停时显示(已定口径)。
- 清屏:保留现有清屏/退出清屏与“清屏状态跨集保持”。
### 面板
1. **选集**:标题「选集」+ 剧名 + `共 n 集` + 关闭;6 列等宽网格,当前集主色,锁定集显示锁图标,点击切集。
2. **评论**:标题 `评论 (n)` + 关闭;列表项(头像、昵称、时间、内容、点赞数、回复);底部输入框 + 发送。
3. **更多操作**:倍速(当前值)、清晰度(自动)、下载到本地、文字大小、投诉;行尾箭头/当前值。
4. **内容解锁**:金币解锁、看广告解锁、查看会员内容三条;底部说明「具体方式以作品而定」;仅展示可行入口,不可用项明确禁用/说明。
## 二·五 视觉风格 v2(浅色暖橙,2026-10-20 用户指定)
> 用户提供两张参考图(剧场页、竖屏剧集页),以此为准;K28 各屏不再沿用深色主题。
### 色板
| 用途 | 值 |
|---|---|
| 页面背景 | `#FFF8F0`(顶部带 `#FFE9D6` 暖色渐变) |
| 卡片/弹层 | `#FFFFFF` |
| 主色(按钮/选中/图标) | `#FF7A1A` |
| 主色渐变(中央按钮) | `#FF9A3D → #FF6A00` |
| 主文字 | `#1F1B16` |
| 次要文字 | `#8C837B` |
| 弱文字/占位 | `#B3AAA1` |
| 分割线/描边 | `#F0E4D6` |
| 粉色角标底 | `#FFE3EC`(文字 `#FF6A8A`) |
| 深色角标(锁定数) | `#33000000` + 白字 |
### 组件
- 圆角:卡片 16、封面 16、弹层 20、chips 999(胶囊)、按钮 999。
- 阴影:`0x14FF8A3D`,blur 18,y 6;卡片不使用重描边。
- 分类 chips:选中为橙色实心 + 白字;未选中为白底 + 深色字,间距 10。
- 剧卡:大封面(原图比例或 3:4)、左上「桃花源」粉色胶囊、右上锁图标 + 数量、封面右下「全 24 集 / 大结局」米色胶囊、标题 15–16 粗体、副行「更新至 18 集 · 09-04」12px 灰字、右下橙色圆形「+」。
- 底部导航:白/米色毛玻璃,**保持 4 槽(首页 / 剧场 / 创作 / 我的)**,选中图标与文字为橙色;参考图中间的橙色「+」不实现。
- 播放/剧集页:大标题 24 粗体 + 橙点、副标题 14 灰、右侧「…」与圆形按钮;Hero 大图 20 圆角 + 右上题材胶囊 + 分页点;下方横向剧集卡(第 01 集 初遇 / 更新中胶囊)。
### 落地方式
1. 先改 `AppColors/AppTheme/AppDimens` 全局 token,使现有组件整体切换为浅色暖橙。
2. `AppBottomNav` 保持 4 项(首页/剧场/创作/我的),只做视觉换肤;不新增中央按钮/`nav-center-action`。
3. K28-A1 先完成结构;样式统一由 K28-S 批次落地,后续 A2/B/C 直接使用新 token。
## 三、复用与约束
- 底部导航复用 `AppBottomNav`(需支持 4 项)。
- 主题色/字号沿用 `AppColors/AppDimens`;标题 Outfit、正文 Plus Jakarta Sans。
- 所有新增文案走 l10n(en/zh/id),禁止硬编码。
- 所有手写 Dart 首行 `// Author: fruit.com.cn`;public 类/方法有文档注释。
- Key 约定:`tab-home` / `tab-theater` / `tab-create` / `tab-mine`;`home-channel-*`;`theater-language-*`;`create-ai` / `create-upload`;`mine-wallet` / `mine-vip`;播放面板沿用 `player-episodes` / `player-comments` / `player-more-*` / `player-unlock-*`。
- 单文件 ≤500 行:现有 `player_view.dart` 已超限,本批允许拆文件,不新增超大文件。
## 四、实施批次(先 UI)
| 批次 | 范围 | 验收 |
|---|---|---|
| K28-A | 4 Tab IA + 首页 + 剧场 | 4 Tab 可切换;首页/剧场结构与简图一致;既有播放/详情链路可达;`dart analyze` 零告警 + `flutter test` 全过 |
| K28-B | 创作 + 我的 | 两个新页面结构与简图一致;金币/会员/菜单入口可达;未登录态正确 |
| K28-C | 竖屏播放 UI + 四面板 | 播放页结构与简图一致;四面板可开合;选集切集/评论/倍速/清屏回归通过 |
| K28-D | 回归与门禁 | l10n 三语齐全;全部测试通过;APK 构建成功;PROGRESS 留痕 |
## 五、UI 之后的功能任务(待排期)
1. 首页频道真实数据(热门/免费/新剧/榜单)与 Banner 数据补齐。
2. 剧场语言筛选:后端 VO 下发 `language` 后再接真实过滤。
3. 创作:AI 制作与上传发布需 C 端创作接口/资质(当前契约缺口,见 INBOX 顶部)。
4. 金币充值:支付通道接入前保持入口隐藏或明确“暂未开放”。
5. 会员:C 端会员商品/订阅接口缺失。
6. 看广告解锁:需要广告平台账号、广告位 ID 与发奖校验接口。
7. 播放清晰度:接口仅下发 `AUTO`,多清晰度需后端补齐。
8. 文字大小:需全局字号偏好与持久化。
## 六、风险 / 待确认
- 简图为浅色线稿,实际落地为现有深色主题;如需要浅色版需另出视觉稿。
- 「创作」两个入口在后端能力缺失时只做诚实占位,不做假流程。
- 首页频道与周几文案涉及新增 l10n key,需三语翻译。
+44
View File
@@ -0,0 +1,44 @@
# UX 改造计划:红果式 3 Tab + 剧场九宫格 + 我的页减法
日期:2026-10-20
仓库:`C:\work\ReelShort`(Flutter)
分支:`feature/ux-theatre-3tabs`(基线 `develop`)
## 一、范围
- 底部导航 4 → 3:推荐(播放)/ 发现(剧场)/ 我的。
- 删除独立榜单 tab,榜单内容并入发现页。
- 发现页改为剧场:顶部搜索框 + 分类九宫格 + 榜单区块 + 全部剧目网格。
- 点击分类进入该分类剧目列表页。
- 我的页只保留:继续观看、我的追剧、观看历史、下载、金币钱包、会员、设置。
- 移除手动语言切换,App 跟随系统(默认印尼语/英语,系统中文仍可解析)。
- 保留首页沉浸式上下滑播放。
## 二、实施顺序
1. 导航 4 → 3,删除 RankingPage 路由。
2. 发现页重构:分类九宫格、分类剧目页、榜单区块、搜索;删除语言筛选与发现页续看。
3. 我的页重构:删除语言行、内嵌历史/收藏/点赞列表、账号卡、通知入口;新增设置页承载通知与账号。
4. 清理死代码:RankingPage、LocaleController 手动选择、CategoryController 筛选态、DiscoverLogic 续看/语言筛选。
5. 更新测试与 l10n 断言,跑 `dart analyze lib test` + `flutter test`。
6. `--no-ff` 合并回 `develop` 并 `git push origin develop`。
## 三、风险与对策
- 玩家页从 My/Discover 返回依赖 action 枚举:保留 home/discover/resume 必要动作,删除 watchlist/detail 等不再产生的动作。
- 设置页承载账号卡与通知,避免删除入口后留下死路由。
- 分类九宫格点击后进入独立列表页,避免全局筛选态残留。
- 测试大量断言 4 Tab/语言行/内嵌列表;按新 UX 重写对应断言,不改播放链路。
- 参数 ≤5:Discover/My 页使用 callbacks 聚合对象,页面 constructor 保持 ≤5 参数。
## 四、门禁
```bash
dart analyze lib test
flutter test
```
## 五、待确认
- 系统语言为中文时是否仍显示中文:本计划保留系统中文解析,仅移除手动切换入口。
- 通知功能保留但收进设置页;若要求彻底删除通知功能,需要另行删除 Notice 客户端链路。
@@ -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,46 @@
# K85 收藏数 / 评论数实时更新实施计划
## 目标
修复收藏数、评论数在列表、播放页和详情页不实时更新的问题,并让服务端收藏计数与关系数据保持一致。
## 前置结论
根因已由任务书确认,不再重复排查:
- 收藏关系写入/删除时没有同步维护 `drama.favorite_count`。
- 客户端首页收藏未复用现有计数增量逻辑。
- 评论面板关闭后没有重新查询评论总数。
- 详情页收藏切换后没有本地收藏数增量。
## A. FruitServer
1. `DramaService` 新增原子收藏计数调整方法,参考现有点赞计数实现。
2. `FavoriteService` 在收藏、取消收藏、批量取消时按实际删除/插入结果同步增减,避免重复操作导致漂移。
3. 新增一次性 `sql/backfill_favorite_count.sql`,从 `user_favorite` 回填现有收藏数,不在应用启动时自动执行。
4. `AppCommentController` 新增匿名评论总数接口,复用 `CommentService.countByDrama`。
5. 补收藏同步维护、批量取消、评论计数接口测试。
6. 门禁:JDK 21 下完整 `mvn test`。
## B. ReelShort
1. `CommentApi` / `CommentRepository` 新增评论总数查询,兼容数字和字符串两种 Long 返回。
2. 播放器统一维护按剧评论数缓存;评论面板关闭后刷新,失败保留旧值。
3. 首页 feed 收藏复用 `_toggleSave`,与全屏播放页共用收藏增量更新。
4. 详情页收藏成功后本地显示收藏数增量,失败回滚,不修改 `Drama` 模型。
5. 补评论计数解析、收藏增量、首页 rail 刷新测试。
6. 门禁:`dart analyze lib test` 零告警、`flutter test` 全过。
## 风险与处理
- 并发收藏切换可能造成计数竞争:服务端使用 SQL 原子自增/自减,减法不低于 0。
- 历史收藏数全为 0:提供一次性回填脚本,由验收环境显式执行。
- 评论面板取消打开时仍刷新一次计数,用于获取匿名新增评论带来的总数变化;查询失败静默保留旧值。
- 详情页只在收藏状态确实变化时调整本地增量,避免重复点击造成累计误差。
## 合并与验收
- FruitServer:`fix/favorite-comment-count`,`--no-ff` 合回 `develop` 并推送。
- ReelShort:`fix/interaction-count-refresh`,`--no-ff` 合回 `develop` 并推送。
- 作者统一 `Zhou Weidong <502333560@qq.com>`。
- 不修改 specs 仓库,不执行真机安装;完成后等待用户验收。
@@ -0,0 +1,59 @@
# K86 评论面板按参考图重做实施计划
## 目标
仅调整 ReelShort 客户端评论面板和评论行视觉,使顶部标题、评论元信息、回复入口和底部输入区接近参考图;不修改后端接口、控制器数据逻辑或评论业务能力。
## 范围
- `lib/pages/player/widgets/drama_comments_sheet.dart`
- `lib/pages/player/widgets/comment_tile.dart`
- `test/comment/drama_comments_sheet_test.dart`
- 必要的 PROGRESS 留痕
## 实现计划
1. 顶部栏
- 删除拖拽把手和剧名行。
- 左侧放置 44x44 向下箭头,保留 `player-comments-close`,点击关闭面板。
- 中间使用现有 `commentsWithCount` / `comments`,16px、w600、`AppColors.onSurface`,保留 `comment-count-label`。
- 右侧留空,保持标题视觉居中。
2. 评论行
- 头像改为 40px,回复行 28px;文字区左间距 12。
- 昵称 12px muted、正文 16px cream、行高 1.4。
- 时间与回复入口组合为灰色元信息行,时间继续使用 `formatLocalizedDateTime`。
- 删除点赞按钮、心形图标与数字展示,控制器点赞逻辑保留但不绑定 UI。
- 举报/删除入口改为右上角 18px 的更多图标,保留 `comment-menu-{id}` 和现有菜单行为。
- 去除行间分隔线,调整上下留白为 12/14。
3. 回复
- 展开/收起回复使用 12px muted 文本,前面增加 16x1 的 border 色短横线。
- 展开后的回复沿用相同评论行样式,并通过 40px 头像宽度加 12px 间距与正文对齐。
4. 输入区
- 输入框改为 44px 高、22px 圆角、`AppColors.commentInput` 底色。
- 保留登录提示、回复态 banner、橙色 38px 发送按钮及 `comment-post` Key。
- 保持现有键盘避让和面板高度 clamp。
5. 状态与交互
- 保留加载、错误、空态、待审核、展开回复、翻页、回复、删除、举报和发送逻辑。
- 保留登录闸、自动聚焦和登录后输入框聚焦行为。
## 测试
- 删除依赖 `comment-like-10` 的点赞展示用例。
- 断言不存在任何 `comment-like-*` 按钮或心形计数。
- 断言 `player-comments-close` 可关闭面板。
- 断言标题居中、字号和字重符合规格。
- 断言头像、正文、元信息、回复入口、输入框与发送按钮尺寸。
- 保持既有评论分页、登录、发布、展开回复、删除和举报用例通过。
## 门禁与 Git
- ReelShort 从 `develop` 创建 `fix/comments-sheet-style`。
- 提交:`fix(player): 评论面板按参考图重做`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 ReelShort `develop` 并 push,不使用 force push,不执行 `git add -A`。
- 门禁:`dart analyze lib test` 零告警、`flutter test` 全过。
- 不安装真机;完成后等待用户验收。
@@ -0,0 +1,46 @@
# K86.21 评论输入浮层稳定定位实施计划
## 目标
修复 Android 收起评论输入键盘时,输入浮层因键盘收起动画中的瞬时 inset 被逐帧 `max` 抬高而向上跳动的问题。评论列表面板、输入内容和关闭规则保持不变。
## 根因
- `CommentComposerSheet.build` 每次重建都用 `math.max` 累积当前 effective keyboard inset。
- IME 收起动画期间可能出现高于稳定值的瞬时 inset,累计后永久抬高 `_pinnedKeyboardInset`。
- 面板布局随后按更高的 pinned 值重新定位,造成输入浮层向上跳一小段。
## 范围
- ReelShort `lib/pages/player/widgets/comment_composer_sheet.dart`
- ReelShort `test/comment/drama_comments_sheet_test.dart`
- FruitServer `docs/plans/PROGRESS.md`
## 实现计划
1. 移除 `build` 中逐帧更新 `_pinnedKeyboardInset` 的逻辑,新增键盘可见状态和 80ms 稳定捕获 Timer。
2. effective inset 从 0 变为正数时启动 Timer;Timer 触发时只读取一次当前 inset,并取整后写入 pinned。
3. 键盘持续可见期间,后续 inset 变大或变小都不更新 pinned;inset 归零只取消 Timer 并重置可见状态,下一次弹起可重新捕获。
4. pinned 上限为屏幕高度的 60%;非有限值、负值或超过上限的异常值忽略,保证 pinned 不小于 0。
5. 保持 `Positioned(bottom: 0)`、白色 Material 铺到屏幕底部及 `Padding(bottom: pinned)` 布局,不使用位移或回落动画。
6. 返回键/IME 下箭头只收键盘;透明外部点击和发表成功仍为关闭入口。
## 测试计划
- 先新增失败测试:2200 高视口模拟 `0 → 1100(稳定)→ 1150(收起瞬时峰值)→ 600 → 0`,断言 1100 捕获后输入浮层 top、bottom、高度均保持不变。
- 运行旧实现确认红灯,记录实际偏移。
- 新实现后运行定向测试、完整评论面板测试文件、`dart analyze lib test` 和全量 `flutter test --no-pub`。
## 风险与处理
- 80ms 可能早于极慢 IME 动画结束;按任务要求采用固定稳定窗口,不在后续可见阶段继续累计。
- 超过屏幕 60% 的异常值被忽略后,需等下一次 0 → 正数弹起再捕获,避免错误定位。
- Timer 在 dispose 时取消,避免 Overlay 关闭后回调访问已卸载 State。
## Git
- ReelShort 从 `develop` 创建 `fix/composer-stable-inset`。
- 提交:`fix(player): 固定评论输入浮层位置避免收键盘跳动`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
@@ -0,0 +1,45 @@
# K86.22 评论输入浮层去除底部安全区位移实施计划
## 目标
修复 Android 收起评论输入键盘后,`SafeArea` 引入底部手势条 padding,导致输入区上移约 48px 的问题。输入区位置只由固定 `_pinnedKeyboardInset` 决定,键盘收起前后保持像素级一致。
## 根因
- 输入面板 Material 内使用 `SafeArea(top: false)`。
- 键盘可见时系统导航栏被 IME 覆盖,`MediaQuery.padding.bottom` 为 0。
- 键盘收起后手势条 padding 恢复,`SafeArea` 自动追加约 48px 底部内边距,把输入区顶高。
- K86.19 的白色实底和固定 12px 内容内边距本身正常,问题仅来自额外安全区。
## 范围
- ReelShort `lib/pages/player/widgets/comment_composer_sheet.dart`
- ReelShort `test/comment/drama_comments_sheet_test.dart`
- FruitServer `docs/plans/PROGRESS.md`
## 实现计划
1. 面板 `SafeArea` 设置 `bottom: false`,不再消费 `MediaQuery.padding.bottom`。
2. 保持 `Positioned(bottom: 0)`、白色 Material 铺到屏幕底部、顶部圆角 20。
3. 键盘区域继续由内部 `Padding(bottom: _pinnedKeyboardInset)` 决定,内容区底部保持固定 12。
4. 透明外部点击或发表成功仍为关闭入口;返回键/IME 下箭头只收键盘。
## 测试计划
- 先新增失败测试:视口 390×844,键盘弹起时 `viewInsets.bottom=180`、`padding.bottom=0`,记录输入框全局矩形。
- 收起键盘时同时设置 `viewInsets.bottom=0`、`padding.bottom=48`,断言输入框 top、bottom、height 偏移均小于 0.1。
- 旧实现预期因 SafeArea 多出 48px 而失败;修复后运行完整评论面板测试、静态分析和全量 Flutter 测试。
## 风险与处理
- 关闭底部 SafeArea 后,输入内容依赖固定 12px 底部内边距;与 K86.19 白色实底填充一致,不再叠加系统手势条。
- 面板本身仍延伸到屏幕底部,视觉圆角和白底不受影响。
- 不改 KeyboardInsetNotifier、pinned 稳定捕获、关闭规则或评论列表布局。
## Git
- ReelShort 从 `develop` 创建 `fix/composer-safearea`。
- 提交:`fix(player): 去除评论输入面板安全区导致的收键盘位移`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
@@ -0,0 +1,47 @@
# K87 下拉刷新完成后回弹实施计划
## 目标
将全局 `AppPullToRefresh` 改为刷新完成前保持下拉高度:松手达到阈值后内容停在 56px,显示“正在刷新”,`onRefresh` 完成后再用 200ms easeOut 回弹到 0。
## 根因
- 当前下拉位移主要由 `BouncingScrollPhysics` 的系统 overscroll 提供。
- 松手后滚动位置立即执行系统回弹,组件只保留一个小指示器。
- 刷新期间没有独立的内容保持位移,因此无法维持下拉状态。
## 范围
- ReelShort `lib/core/widgets/app_pull_to_refresh.dart`
- ReelShort `test/core/app_pull_to_refresh_test.dart`
- FruitServer `docs/plans/PROGRESS.md`
## 实现计划
1. 将 `AppPullToRefresh.physics` 从 Bouncing 改为 Clamping,阻止系统 overscroll 自行产生内容位移。
2. 使用 `AnimationController` 和 `Transform.translate` 统一控制内容位移;pull/armed 使用当前拖动距离,refreshing 固定 56,完成后回 0。
3. 松手达到阈值后,200ms easeOut 将当前距离过渡到 56;刷新期间忽略滚动通知并通过 `IgnorePointer` 阻止重复下拉。
4. `onRefresh` 完成后以 200ms easeOut 回到 0;指示器透明度跟随位移回弹淡出,结束后状态为 idle。
5. 检查全部 11 个使用方均传入 `AppPullToRefresh.physics`,保证全局组件行为一致。
## 测试计划
- 使用挂起的 `Completer` 模拟刷新未完成。
- 松手瞬间断言内容仍高于 hold,200ms 后稳定为 56,再等待 300ms 仍保持 56。
- 断言刷新状态显示 `CircularProgressIndicator` 与 `Refreshing…`。
- 刷新中再次下拉,断言 `onRefresh` 调用数仍为 1、offset 仍为 56。
- 完成 `Completer` 后断言 offset 回到 0、刷新指示器消失。
## 风险与处理
- 改为 Clamping 后不再依赖平台 overscroll 提供下拉视觉;内容位移完全由组件控制,避免双倍移动。
- 刷新期间阻止列表交互,避免用户手势破坏保持高度。
- 组件卸载时动画控制器会取消动画;异步刷新完成后仅在 mounted 时执行回弹收尾。
## Git
- ReelShort 从 `develop` 创建 `feat/pull-to-refresh-hold`。
- 提交:`feat(ui): 下拉刷新完成后才回弹`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
@@ -0,0 +1,46 @@
# K87.1 下拉刷新冻结位移实施计划
## 目标
修复刷新 pending 期间仍从松手距离回收到 56px 的视觉回弹。达到阈值后冻结当前下拉距离 D,刷新完成前 offset 不变化,完成后才用 200ms easeOut 回到 0。
## 根因
- K87 已将系统 physics 改为 Clamping,避免原生 overscroll 回弹叠加。
- 但 `_refresh` 仍先执行 `_animateOffsetTo(_holdDistance)`,会把松手时的 D(例如 96)回收到 56。
- 用户感知为“页面已经弹回去”,实际是组件自身在刷新开始时主动缩短 offset。
## 范围
- ReelShort `lib/core/widgets/app_pull_to_refresh.dart`
- ReelShort `test/core/app_pull_to_refresh_test.dart`
- FruitServer `docs/plans/PROGRESS.md`
## 实现计划
1. 保留 `AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics())`,系统不移动 overscroll 内容。
2. 删除 `_holdDistance` 与刷新前的 56px 过渡动画。
3. 达到阈值松手后立即进入 refreshing,AnimationController 保持当前 D;刷新期间滚动通知和手势均忽略。
4. `onRefresh` 完成后再从 D 执行 200ms easeOut 回 0,指示器随 offset 淡出。
5. 未达阈值松手时新增 `_retracting` 状态,执行 200ms easeOut 回到 0,不触发 `onRefresh`。
## 测试计划
- 达阈值测试:记录松手 offset D,挂起 300ms 后仍等于 D;刷新中重复下拉不改变 D、不增加调用。
- complete 后断言 offset 回 0、指示器消失。
- 未达阈值测试:松手后立即开始回弹,100ms 时 offset 介于 0 与 D 之间,最终回 0 且 `onRefresh` 未调用。
- 断言 `AppPullToRefresh.physics.parent` 为 `ClampingScrollPhysics`。
## 风险与处理
- 保持 D 时下拉越高,内容下移越明显,但这是用户松手时的真实位置,刷新完成后才回收。
- 未达阈值回弹期间通过 `_retracting` 忽略新滚动通知,避免动画目标被手势覆盖。
- 不逐个页面改动,所有使用共享 physics 与组件的页面自动生效。
## Git
- ReelShort 从 `develop` 创建 `fix/pull-refresh-freeze`。
- 提交:`fix(ui): 下拉刷新刷新期间冻结下拉位移`。
- 作者:`Zhou Weidong <502333560@qq.com>`。
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
@@ -0,0 +1,51 @@
# K88 内容类型、互动数据编辑与分类 Feed 实施计划
## 目标
- 后台短剧管理支持内容类型和互动数据编辑:收藏、点赞、热度可编辑,评论数实时统计只读。
- 新增 `drama.drama_type`,取值为 `ANIME` / `LIVE`,默认 `LIVE`。
- App 新增 `GET /app/feed`,支持 `ANIME,LIVE` 多选类型,不传类型时返回推荐全部。
## FruitServer A
1. 数据库
- 新增 `sql/migrate-k88-drama-type.sql`,只提交迁移文件,不执行数据库变更。
- `sql/schema.sql` 的 drama 建表语句补 `drama_type`,默认 `LIVE`。
2. 后台管理
- `Drama`、`DramaSaveReqVO`、`DramaRespVO` 增加 `dramaType`。
- `DramaSaveReqVO` 增加可空 `favoriteCount`、`likeCount`、`heat`;服务层校验非负,`heat` 映射 `view_count`。
- create 默认内容类型 LIVE、互动数 0;update 仅覆盖请求非 null 的互动字段。
- 后台分页增加 `dramaTypes` 多选过滤,并保持 CommentService 批量回填评论数。
3. App Feed
- `AppDramaCardVO` 增加 `dramaType`,现有 home/discover/detail 仅新增字段。
- `GET /app/feed` 支持 `types`、`cursor`、`size`;size 默认 20、最大 50。
- 排序为 `recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- cursor 使用最后一条 drama id,服务端反查其 recommendWeight/viewCount/id,再按三元组做 keyset 查询,避免 offset 翻页重漏。
- 卡片复用现有 `toCards`,确保 preview、互动数、评论数与首页一致。
- 更新 `docs/api-phase1.md` 的 dramaType 与 `/app/feed` 契约。
4. 测试
- `/app/feed`:全部、ANIME、ANIME+LIVE、游标翻页不重不漏。
- 后台 update:部分字段更新、heat 写 viewCount、负数与非法 dramaType 报错。
## ReelShortWeb B
1. 类型定义补齐 dramaType、favoriteCount、likeCount、commentCount、heat。
2. 短剧列表增加内容类型筛选和标签列,播放量列改为热度,增加收藏/点赞/评论列。
3. 编辑弹窗增加必填内容类型、互动数据区块;评论数只读。
4. 不改审核、发布、删除逻辑。
## 风险与处理
- 推荐排序包含可空 recommend_weight,SQL 与 Java 条件必须显式处理 NULLS LAST。
- 三字段排序不能只按 id 做 cursor,需以 cursor id 反查上一页最后一条的排序值。
- 存量数据不主动修改,迁移默认 LIVE;数据库脚本由 PM 执行。
- 全部改动只使用显式文件暂存,不执行 `git add -A`,不改 specs 与 Flutter 客户端。
## 门禁与 Git
- FruitServer:`mvn test` 全过;提交 `feat(content): 新增内容类型与首页分类 feed`;`--no-ff` 合回 develop 并 push。
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 短剧列表展示并编辑互动数据`;`--no-ff` 合回 develop 并 push。
- 作者统一 `Zhou Weidong <502333560@qq.com>`;各自 PROGRESS 追加 K88 留痕。
+59
View File
@@ -0,0 +1,59 @@
# K89 点赞/评论按集、收藏/热度按剧实施计划
## 目标
- 收藏、热度继续归属整部剧。
- 点赞、评论归属具体剧集:新增 `drama_episode.like_count`,`content_like` 使用 `EPISODE` 目标。
- App 卡片、详情、播放响应按当前集装配点赞/评论;后台短剧/剧集页提供对应汇总与只读展示。
## FruitServer A
1. 数据库
- 新增 `sql/migrate-k89-episode-like.sql`,只提交不执行。
- `sql/schema-phase1.sql` 的 drama_episode 增量字段补 like_count,默认 0。
2. 分集点赞
- `DramaEpisode` 增加 likeCount;`EpisodeService` 提供原子增减与批量 SUM。
- `LikeService` 增加 `TYPE_EPISODE`,校验已上线剧集的已上线分集,切换时同步分集计数,返回该集最新值。
- 保留 DRAMA/COMMENT 点赞兼容。
3. 分集评论
- `CommentService.countByEpisodes` 按 episode_id 批量分组统计未删除评论。
- `/app/comments/count` 增加可选 episodeId;传集数返回该集,只有 dramaId 时维持全剧兼容语义。
4. App 装配
- 卡片 preview 集的 likeCount/commentCount 写入卡片;favoriteCount/viewCount 仍取剧。
- 详情 episodes[] 每项增加 likeCount/commentCount。
- 播放响应增加 episodeId 对应的 likeCount/commentCount。
5. 后台
- EpisodeRespVO 增加本集 like/comment 与所属剧 favorite/heat;Controller.list 批量回填。
- EpisodeSaveReqVO 增加可空 likeCount,update 仅覆盖非 null,负数 BAD_REQUEST。
- DramaRespVO likeCount 改为分集 SUM,commentCount 保持全剧汇总;分页一次批量聚合避免 N+1。
- 更新 API 契约。
6. 测试
- EPISODE 点赞 +1/-1、不越界、目标不存在。
- App 卡片按 preview 集、剧级 favorite/heat。
- 后台分集列表计数与剧级收藏/热度。
- 短剧分页 likeCount 为分集汇总。
## ReelShortWeb B
1. EpisodeItem / EpisodeSaveData 增加对应互动字段。
2. 剧集表格增加本集点赞、评论,以及所属剧收藏、热度列。
3. 编辑弹窗增加本集点赞输入和评论只读;“按集实时统计”。
4. 短剧管理保持收藏/热度可编辑,点赞/评论展示为后端只读汇总。
## 风险与处理
- 存量 `drama.like_count` 保留不删,不回填分集;新展示以分集 SUM 为准。
- App 评论数按 preview 集统计,只有 episodeId 为空的历史评论仍计入全剧汇总。
- 评论/点赞接口保持匿名/登录既有安全语义,不新增第三方依赖。
- 迁移脚本由 PM 执行;本地门禁不访问真实数据库。
## 门禁与 Git
- FruitServer:`mvn test` 全过;提交 `feat(interaction): 点赞与评论按集统计`;`--no-ff` 合回 develop 并 push。
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 剧集列表展示分集互动数据`;`--no-ff` 合回 develop 并 push。
- 作者统一 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`;各自 PROGRESS 追加 K89 留痕。
+114
View File
@@ -0,0 +1,114 @@
# K91 后台观看记录独立菜单、评论分集展示与全部状态筛选
## 目标
- FruitServer 新增跨用户观看记录分页接口,并给后台评论补充短剧名、集号与集标题。
- ReelShortWeb 新增「观看记录」独立页面,评论/举报状态筛选默认展示全部。
- 迁移脚本只提交文件,不执行数据库变更;不改 specs 仓库和 Flutter 客户端。
## 分支
- FruitServer:`feat/k91-watch-history`,从 `origin/develop` 创建。
- ReelShortWeb:`feat/k91-watch-history-page`,从 `origin/develop` 创建。
## A. FruitServer
### A1 观看记录分页
1. 新增 `WatchHistoryQueryReqVO`,字段:
`pageIndex/pageSize/keyword/userId/dramaTitle/dramaId/episodeId/source/finished/startTime/endTime`。
2. 新增 `AdminWatchHistoryVO`,返回用户、短剧、分集、进度、完成、来源、设备与观看时间。
3. 新增 `WatchHistoryAdminService`,直接注入:
- `WatchHistoryMapper`
- `AppUserMapper`
- `DramaMapper`
- `DramaEpisodeMapper`
4. 查询流程:
- `keyword` 先批量命中 `app_user.uid/nickname` 的 id;无命中直接空页。
- `dramaTitle` 先批量命中 `drama.title` 的 id;无命中直接空页。
- 精确 id 条件与模糊命中集合取交集。
- `finished` 在 SQL 层表达,允许区间两侧都落在 `duration_ms = 0` 等边界。
- 按 `watched_at DESC, id DESC` 排序。
- 对结果页的用户、剧、集各做一次批量查询后装配,禁止逐行查询。
5. 新增 `WatchHistoryAdminController`,权限点 `watch-history:list`,保留旧用户详情观看流水接口。
### A2 评论后台分集信息
1. `CommentRespVO` 增加 `dramaTitle/episodeNo/episodeTitle`。
2. 仅 `CommentService.adminPage` 对当前页批量补剧、集信息。
3. `toRespList` 的 C 端路径保持不增加查询。
### A3 SQL 与契约
1. 新增幂等迁移 `sql/migrate-k91-watch-history-menu.sql`:
- 权限点 `1105 watch-history:list`
- 菜单使用 id `34`,挂 parent `15`
- `component=views/userManagement/WatchHistoryView`
- 超管角色权限与菜单关联
2. 同步 `sql/data-phase1.sql`。
3. 更新 `docs/api-phase1.md` §2.3、§2.5。
### A4 测试
- `WatchHistoryAdminServiceTest` 覆盖:
- 默认分页与排序
- keyword 用户命中
- dramaTitle 剧名命中
- finished 真/假 SQL 条件
- 时间范围
- 命中集合为空直接空页
- pageSize 上限收敛为 100
- `CommentServiceTest` 覆盖后台页批量补齐剧名、集号、集标题。
- 门禁:`mvn test` 全模块通过。
## B. ReelShortWeb
### B1 类型/API
- 扩展 `WatchHistoryItem`:`userId/uid/nickname/episodeTitle`。
- 扩展 `CommentItem`:`dramaTitle/episodeNo/episodeTitle`。
- `appUser.ts` 新增 `pageWatchHistoryApi(params)`。
### B2 观看记录页面
- 新建 `src/views/userManagement/WatchHistoryView.vue`:
- 用户、短剧名称、来源、完成状态、时间范围筛选。
- 观看时间、用户、短剧、分集、进度、完成、来源、设备列。
- 10/20/50 分页。
- 复用 App 用户页的 `panel/console-toolbar/pagination/app-user-*` 视觉与格式化函数。
### B3 评论/举报全部状态
- 评论与举报状态首项增加 `{ value: '', label: '全部状态', tag: 'info' }`。
- 两个筛选默认值改为空串,请求时空串转 `undefined`。
- 评论表以「短剧 / 分集」替换短剧 ID 单列:
- 显示剧名与集号/集标题。
- 缺少剧名时回退短剧 id。
- 缺少集号时回退分集 id;历史 `episodeId=0` 显示未关联分集。
- 原始短剧 id / 分集 id 通过 tooltip 保留。
### B4 门禁
- `pnpm ts:check`
- `pnpm build:dev`
## 风险与待确认
1. `watch_history` 是月分区表,现有索引为 `(user_id, watched_at)` 与
`(drama_id, watched_at)`,没有跨用户的全局 `watched_at` 索引。本次按要求实现跨用户时间排序,
不擅自新增索引;数据量增长后由 PM 评估是否增加全局观看时间索引。
2. 用户/剧名模糊检索先取 id 集合,符合“命中为空不退化成全表”的要求;
超大关键词命中集合的批大小由后续实际数据量评估,本次不擅自加截断以免漏结果。
3. 历史评论的 `episode_id=0` 不回填;后台显示“未关联分集”,原始 id 仍保留在 tooltip。
4. 迁移脚本只写文件,不执行数据库;菜单落库由 PM 按发布流程执行。
5. 任务书建议菜单 id `26`,但当前 `data-phase1.sql` 已将 `26` 用作“创作者中心”目录,
`27-30` 为其子菜单。为避免覆盖线上已有菜单树,本次使用下一个空闲 id `34`;
父级、路由、组件与权限点均不变。
## 提交与合并
- FruitServer 提交:`feat(admin): 新增观看记录分页与评论分集信息`
- ReelShortWeb 提交:`feat(admin): 观看记录独立菜单与治理状态全部筛选`
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
- 作者与提交者:`Zhou Weidong <502333560@qq.com>`。
- 禁止 `git add -A`,所有文件逐项暂存。
+108
View File
@@ -0,0 +1,108 @@
# K92 举报工单展示短剧名称与分集
## 目标
- `/admin/reports/page` 不再只返回 `targetType / targetId`,而是返回举报人、目标短剧与分集的展示信息。
- Web 举报工单列表与处理弹窗使用同一份目标摘要,不再展示裸对象 ID。
- 不改既有举报处理逻辑、评论列表接口、观看记录接口或数据库结构。
## 分支
- FruitServer:`feat/k92-report-target`,从 `origin/develop` 创建。
- ReelShortWeb:`feat/k92-report-target-ui`,从 `origin/develop` 创建。
## A. FruitServer
### A1 返回模型
新增 `com.fruitshorts.admin.vo.AdminReportRespVO`,包含:
- 工单:`id/reasonCode/description/status/handleBy/handleAt/handleNote/createdAt`
- 举报人:`reporterId/reporterNickname/reporterUid`
- 目标:`targetType/targetId/targetSummary`
- 剧集:`dramaId/dramaTitle/episodeId/episodeNo/episodeTitle`
- 评论/用户:`commentContent/targetNickname/targetUid`
### A2 查询和解析
新增 `com.fruitshorts.admin.service.ReportAdminService`:
1. 分页查询 `content_report`,条件与 `ReportService.page` 保持一致:
`status/targetType/reasonCode/reporterId` 均仅在有文本或非空时加入,空值返回全部;
排序保持 `id DESC`。
2. 当前页批量查询:
- COMMENT 目标评论:一次查询,且必须包含逻辑删除记录,用于判断“评论已删除”。
- 评论与 DRAMA 目标剧:一次批量查询 `drama`。
- 评论目标分集:一次批量查询 `drama_episode`。
- USER 目标用户与全部 reporter:合并 id 后一次批量查询 `app_user`。
3. 禁止逐行查询。
`ContentCommentMapper` 增加显式批量查询方法,绕过 `@TableLogic` 的自动过滤;
普通 `selectBatchIds` 仍保持原行为。
### A3 摘要规则
- COMMENT:
- 正常评论且剧存在:`剧名 · 第 N 集`,有集标题时追加 ` · 集标题`。
- 评论删除、逻辑删除、评论不存在或剧不存在:`评论 #targetId`。
- DRAMA:
- 剧存在:剧名。
- 不存在:`短剧 #targetId`。
- USER:
- 用户存在:`昵称(uid)`。
- 不存在:`用户 #targetId`。
### A4 Controller 与文档
- `ReportAdminController.page` 改为返回 `Result<PageResult<AdminReportRespVO>>`。
- `handle` 继续调用原 `ReportService.handle`,不变更业务行为。
- 更新 `docs/api-phase1.md` §2.5。
### A5 测试
新增 `ReportAdminServiceTest`,覆盖:
- COMMENT / DRAMA / USER 三类摘要。
- 评论集标题、举报人昵称/uid、目标用户昵称/uid。
- 空筛选条件表示全部。
- 评论逻辑删除和目标剧不存在时退化文案。
- 验证按页批量查询,不产生 N+1。
门禁:`mvn test` 全模块通过。
## B. ReelShortWeb
### B1 类型
`ReportItem` 补齐后台新增字段,保留原 `targetId` 用于 tooltip 排查。
### B2 治理页
1. 「对象」列:
- 中文类型标签:评论 / 用户 / 短剧。
- 显示 `targetSummary`。
- tooltip 显示原始 `#targetId`,评论类型有内容时追加评论内容。
2. 删除独立「对象ID」列。
3. 「举报人」列显示昵称;无昵称回退 uid;再无回退 `#reporterId`。
4. 处理弹窗「工单对象」显示类型标签 + `targetSummary`。
### B3 门禁
- `pnpm ts:check`
- `pnpm build:dev`
- 对改动文件执行定向 ESLint
## 风险与说明
1. 评论逻辑删除记录默认被 MyBatis-Plus 过滤;本次使用显式 mapper 查询识别,
不改变评论正常查询和删除行为。
2. 已删除评论不展示正文,避免后台误读已删除内容;仍保留 `targetId` tooltip 便于排查。
3. 目标关联数据已物理删除时按任务书使用 `评论 #id / 短剧 #id / 用户 #id` 退化。
4. 本任务无数据库迁移,不改 specs 仓库和 Flutter 客户端。
## Git
- FruitServer:`feat(admin): 举报工单返回短剧与分集信息`
- ReelShortWeb:`feat(admin): 举报列表展示短剧与分集`
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
@@ -0,0 +1,121 @@
# K93 短剧类型与内容类型支持多选
## 目标
- 后台短剧基础信息中的「类型」「内容类型」改为至少选一项的多选。
- 后台列表筛选、列表展示支持多分类与多内容类型。
- App 分类分区和内容类型 feed 使用多标签字段做整词匹配。
- 单值字段继续保留,兼容创作者中心、旧接口和现有 App 卡片。
## 分支
- FruitServer:`feat/k93-drama-multi-tag`,从 `origin/develop` 创建。
- ReelShortWeb:`feat/k93-drama-multi-tag-ui`,从 `origin/develop` 创建。
## A. FruitServer
### A1 数据库
- 新增 `sql/migrate-k93-drama-multi-tag.sql`:
- `drama.drama_types VARCHAR(32) NOT NULL DEFAULT ''`
- `drama.category_ids VARCHAR(255) NOT NULL DEFAULT ''`
- 从现有 `drama_type / category_id` 幂等回填。
- 同步 `sql/schema.sql` 的 drama 建表字段。
- 只提交迁移文件,不执行数据库。
### A2 实体与 VO
- `Drama`:增加 `dramaTypes/categoryIds` 逗号字符串。
- `DramaSaveReqVO`:
- 新增 `List<String> dramaTypes`、`List<Long> categoryIds`,列表优先。
- 保留 `dramaType/categoryId` 单值兜底。
- `DramaPageReqVO`:增加 `List<Long> categoryIds`,保留 `categoryId`。
- `DramaRespVO`:增加 `dramaTypes/categoryIds/categoryNames`,旧单值字段语义不变。
### A3 保存规则
- 内容类型列表:normalize、去重、保持提交顺序;主值写第一个,完整列表写 `drama_types`。
- 分类列表:过滤 null/非正数、去重、保持提交顺序;主值写第一个或 0,完整列表写 `category_ids`。
- 列表为空时回退单值;单值也没有时保留当前值。
- 创建时内容类型最终为空则默认 LIVE。
- 非法内容类型继续返回 BAD_REQUEST。
### A4 查询规则
统一使用整词匹配:
```sql
(',' || column || ',') LIKE '%,value,%'
```
- 后台分页:
- `dramaTypes` 非空:`drama_types` token 匹配。
- `categoryIds` 非空:`category_ids` token 匹配。
- `categoryIds` 为空:回退 `category_id` 等值。
- App `listPublishedFeed`:`drama_types` token 匹配。
- App feed 游标:marker 的解析后 `dramaTypes` 与任一筛选类型相交才允许继续。
- App `listPublished(categoryId)`:`category_ids` token 匹配,使多分类剧出现在每个分类分区。
### A5 响应装配
- 解析 `drama_types/category_ids`,空值回退单值。
- `categoryNames` 按 categoryIds 顺序映射分类名。
- 分类名缺失时回退 `genre`。
- 现有 `dramaType/categoryId/category` 保持旧语义。
### A6 文档与测试
- 更新 `docs/api-phase1.md` 的后台保存/分页字段、App feed 和分类说明。
- 增加服务测试覆盖:
- 多类型/多分类去重、顺序与主值写回。
- 单值兜底行为不变。
- 后台分页 token 条件。
- feed 多标签 marker 可被 ANIME/LIVE 命中。
- App 分类分区 token 条件。
- 响应多值解析和分类名顺序。
- 门禁:`mvn test` 全模块通过。
## B. ReelShortWeb
### B1 类型
- `DramaItem` 增加 `dramaTypes/categoryIds/categoryNames`。
- `DramaSaveData` 增加 `dramaTypes/categoryIds`。
- `DramaPageParams` 增加 `categoryIds`。
### B2 表单
- `form.categoryIds: number[]`,默认空数组。
- `form.dramaTypes: string[]`,创建默认 `['LIVE']`。
- 两个下拉均使用 multiple、collapse-tags、collapse-tags-tooltip、filterable。
- 校验至少 1 项。
- 编辑时优先数组,缺失回退单值。
- 提交数组;`genre` 使用所选分类名以 ` / ` 拼接。
### B3 筛选与表格
- 分类筛选改为 `categoryIds: number[]`。
- 内容类型筛选改为 `dramaTypes: string[]`。
- 查询传数组,重置为空数组。
- 类型列显示 `categoryNames.join(' / ')`,回退 `genre`。
- 内容类型列逐个渲染标签,缺失时回退 `dramaType`。
### B4 门禁
- `pnpm ts:check`
- `pnpm build:dev`
- 对修改文件执行定向 ESLint
## 风险与说明
1. 新代码依赖迁移后的 `drama_types/category_ids` 列,发布顺序必须先执行迁移再启动新版本。
2. 逗号列仅存储受控内容类型和数字 id,不允许用户输入;查询值均来自校验后的数组。
3. 主值继续保留,降低旧客户端、创作者中心和旧接口的兼容风险。
4. 本次不改 specs 与 Flutter 客户端,不执行数据库迁移,不操作真机。
## Git
- FruitServer:`feat(content): 短剧类型与内容类型支持多选`
- ReelShortWeb:`feat(admin): 短剧类型与内容类型多选`
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
+126
View File
@@ -0,0 +1,126 @@
# K94 首页内容类型 Feed 与无感切换
## 目标
- 首页顶栏「漫剧 / 真人剧 / 推荐」接入既有 `/app/feed`。
- 每频道独立缓存与分页,切换时先同步展示缓存,再后台静默刷新。
- 推荐首屏复用 `/app/home/feed` 打平数据,首次进入不额外转圈。
- 无缓存频道只在黑色内容区显示小转圈,顶栏和底栏保持。
- 滑动接近末尾自动加载下一页,并在播放器队列中原地追加。
## 分支
- ReelShort:`feat/k94-home-type-feed`,从 `origin/develop` 创建。
- FruitServer 仅文档留痕,不改功能代码。
## A. 数据层
### A1 FeedPage
新增 `lib/core/data/models/feed_page.dart`:
- `dramas` 使用 `Drama.fromCardJson`。
- `nextCursor` 兼容数字/字符串,空值或字符串 `"null"` 转为 null。
- `hasMore` 读取布尔值。
### A2 CatalogApi / Repository
- `CatalogApi.feed({types, cursor, size})` 请求 `/app/feed`。
- types 为空不传;非空用逗号拼接。
- cursor 为空不传。
- 返回结构异常抛 `ApiException(-1, '返回数据格式异常')`。
- `CatalogRepository` 与 HTTP 实现增加 `fetchFeed`。
- 所有测试 CatalogRepository 实现同步补齐方法。
## B. 频道控制器
新增:
- `lib/pages/home/home_feed_channel.dart`
- `lib/core/data/services/home_feed_controller.dart`
`HomeFeedController` 使用每个频道独立状态:
- dramas / cursor / hasMore / loaded / loadingMore / requestSeq
- 当前 channel,默认 recommend
- `seedRecommend`:同步写入推荐首屏,不发请求
- `selectChannel`:同步切频道并 notify;无缓存加载第一页,有缓存静默刷新
- `refreshCurrent`:失败保留旧列表
- `loadMore`:cursor 追加,按 drama.id 去重
- 所有响应必须同时满足“仍为当前频道 + requestSeq 最新”才应用
- dispose 后停止 notify
## C. 首页接入
### C1 顶栏受控
`HomeFeedHeader`:
- 删除内部 tab 状态、comingSoon 和 SnackBar。
- 新增 `channel` 与 `onChannelSelected`。
- 三个 Key 保持不变。
- refresh 内部 loading 状态保留。
### C2 HomePage
- `_HomePageState` 用 `CatalogController.repository` 创建 HomeFeedController。
- Catalog 首次有数据时同步 seed recommend,随后在 post-frame 静默刷新 recommend。
- `_HomeTabs` 使用稳定 Key,删除 `ValueKey(catalog.dramas.first.id)`。
- 当前频道无数据时,保留 `_HomeTabs` 顶栏/底栏,body 用黑色背景 + 居中
`home-feed-channel-loading`。
- 当前频道有数据时把频道列表传给 PlayerPage。
- 刷新回调改 `refreshCurrent`;分页回调改 `loadMore`。
### C3 PlayerPage / PlayerLogic
- PlayerPage 增加 `feedChannel` 和 `onLoadMore`。
- 同频道 feed 列表变化:`updateFeed(..., keepDramaId: currentDramaId)`。
- channel 变化:`updateFeed(..., keepDramaId: null)`,回到新频道第 1 个。
- PlayerLogic 保存 factory/coordinator,支持需要换内容时替换 PlaybackSession。
- 空列表清空 queue/visibleIndices 并停止旧播放。
- 当前剧仍在新列表且索引不变时,只扩展队列与 episode 列表,不重新 select。
- PageView 在 `position >= visibleIndices.length - 3` 时调用 onLoadMore。
## D. 测试
新增 `test/core/data/services/home_feed_controller_test.dart`:
- recommendation seed 不发请求
- 三频道请求 types 分别为 `[] / [ANIME] / [LIVE]`
- 缓存频道切换不清空并后台刷新
- loadMore 追加去重、无更多后不再请求
- 串包只接受最新频道请求
- refresh 失败保留旧数据
新增顶栏 widget 测试:
- 三个 Key 存在
- 点击分别回调 anime/live/recommend
- 当前 channel 决定选中样式
PlayerLogic 测试补充:
- updateFeed 空列表安全
- 追加列表保持当前剧
- 切频道列表回到第 1 个
门禁:
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 风险与控制
1. PlaybackSession 的 episodes 是构造时绑定的列表;分页原地追加时会同步扩展该列表,
避免仅换 queue 而 session 仍指向旧列表。
2. 换频道可能释放并重建播放器 session,这是内容源变化;静默刷新/分页在顺序不变时
不重新 select 当前剧,避免视频重启。
3. 推荐 seed 只保证首帧立即有内容;后台 `/app/feed` 刷新失败时保留 seed。
4. 首页仍在 `/app/home/feed` 首次加载期间使用既有启动页;频道切换不再触发整页 splash。
## Git
- 提交:`feat(home): 首页按内容类型分频道 Feed 并支持分页`
- `--no-ff` 合回 ReelShort `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
@@ -0,0 +1,100 @@
# K95 修复首页频道快速切换卡 Loading
## 目标
- 频道请求完成后始终写回发起请求的频道缓存,不再因用户切走而丢失结果。
- 每频道明确区分 loaded / loading / in-flight / error,无请求时绝不显示首屏转圈。
- 首屏和刷新请求增加可注入的 10 秒超时兜底。
- PlayerLogic 保证连续 feed 更新串行应用,只让最后一次 feed/session 生效。
## 分支
- ReelShort:`fix/k95-feed-switch-loading`,从 `origin/develop` 创建。
- FruitServer 仅提交计划与 PROGRESS,不改功能代码。
## A. HomeFeedController
每个频道状态增加:
- `List<Drama> dramas`
- `String? cursor`
- `bool hasMore`
- `bool loaded`
- `bool loading`
- `bool loadingMore`
- `String? lastError`
- `int requestSeq`
- `Future<bool>? inFlight`
请求规则:
1. 响应成功/失败都写入发起请求的频道状态。
2. 只有频道仍为当前频道时才 `notifyListeners`。
3. `requestSeq` 只比较同一频道的新旧请求,不参与跨频道过滤。
4. 超时时间通过构造参数注入,默认 10 秒。
5. `loadingFirstPage = !loaded && loading`。
6. `selectChannel`:
- 有缓存:同步显示,未在飞时后台静默刷新
- 无缓存且正在飞:等待现有请求,不重复发
- 无缓存且有历史错误:自动重试
- 从未加载:发起首屏请求
7. `refreshCurrent` 同样复用 in-flight,防止并发重复刷新。
8. `loadMore` 失败保留 cursor/hasMore,允许下次继续重试。
## B. PlayerLogic / PlayerPage
### B1 Feed revision
- PlayerLogic 增加 `int feedRevision`。
- 每次 `updateFeed` 自增 revision,记录最新目标 feed。
- `_replaceSession` 与 `selectEpisode` 在 await 后校验 revision,过期流程直接返回。
- 连续 updateFeed 使用串行队列:当前应用结束后再应用最新待处理 feed。
### B2 空队列与频道切换
- 空 feed 不创建新 session,只清空 queue/visibleIndices 并停播。
- 空 → 非空必须恢复 route visible,并 select 第 0 个。
- 频道变化通过额外 reset 标志传入 `updateFeed`,即使 drama id 列表相同,
也重置到第 1 个并开始播放。
### B3 PlayerPage 更新判定
- `didUpdateWidget` 用 `feedDramas` 的 drama id 列表签名判断内容变化。
- 同一列表的新 List 实例不重复 updateFeed。
- 频道变化始终触发 updateFeed,并传 `resetToFirst=true`。
## C. 测试
### HomeFeedController
- A 挂起 → B 成功 → A 成功:A/B loaded 均为 true,切回 A 立即有数据。
- 同频道旧失败响应不得覆盖新成功响应。
- 无在飞请求时 `loadingFirstPage=false`。
- 永不返回的请求在注入超时后结束 loading、写 lastError、loaded=true。
- 失败频道再次 select 自动重试。
- loadMore 失败保留原有 hasMore/cursor。
### PlayerLogic
- 连续 5 次 updateFeed,最终 queue/session 对应最后一次列表。
- 空 → 非空会创建 session 并 select 第 0 个。
- 频道变化时相同 id 列表也回到第 1 个。
门禁:
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 风险与控制
1. 跨频道响应写回缓存必须与同频道 seq 校验同时存在;前者解决缓存丢失,后者解决同频道旧请求覆盖新结果。
2. Player session 替换以 revision 作为唯一有效性判断,避免旧 close/select 回调干扰新 session。
3. 空队列期间不创建 PlaybackSession;恢复非空时重新创建并起播。
4. 不改后端、ReelShortWeb、specs;不安装或操作真机。
## Git
- 提交:`fix(home): 修复频道快速切换卡 loading`
- `--no-ff` 合回 ReelShort `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
@@ -0,0 +1,83 @@
# K95.1 首次切换频道无感显示
## 目标
- 推荐首屏成功后后台预取 anime/live,避免第一次切换出现阻断式 loading。
- 目标频道尚未返回时保留上一频道 PlayerPage 和视频画面,只叠加轻量 loading 遮罩。
- 数据到达后原子替换为新频道 feed,并从第 1 个播放。
## 分支
- ReelShort:`fix/k95.1-seamless-first-switch`,从已包含 K95 的 `origin/develop` 创建。
- FruitServer 仅文档留痕,不改功能代码。
## A. HomeFeedController
新增 `Future<void> prefetchChannels()`:
- 仅处理 anime/live。
- 只对 `!loaded && !loading` 的频道请求。
- 请求结果写回对应频道缓存。
- 不切换当前频道、不改变当前列表;仅目标恰为当前频道时 notify。
- 失败只写 lastError,不向外抛。
- 成功预取设置一次性 `skipNextRefresh`:首次切到该频道直接使用缓存,不立刻重复静默刷新;
后续再次切回仍执行正常缓存静默刷新。
- 预取期间用户切换到该频道时等待已有 inFlight,不重复请求。
## B. HomePage / _HomeTabs
### B1 最近可见 feed
`_HomeTabsState` 维护:
- `List<Drama>? _visibleDramas`
- `HomeFeedChannel? _visibleChannel`
当前频道有数据时更新这两个值,并以当前频道渲染 PlayerPage。
### B2 首次切换
当前频道无数据且 loading:
- 继续复用上一频道 `_visibleDramas/_visibleChannel` 渲染同一个 PlayerPage。
- header 使用当前选中频道,但 PlayerPage.feedChannel 仍使用可见旧频道,避免队列更新。
- Stack 上叠加 `IgnorePointer + ColoredBox(black26) + 24px spinner`,
Key 为 `home-feed-channel-loading`。
- 数据返回后页面树保持同一层级的 PlayerPage,传入新 feed/channel,触发原子更新和第 1 个起播。
- 空结果/失败后才切到「暂无剧目 / 重试」占位。
### B3 Prefetch 触发
推荐首屏后台刷新成功后 `unawaited(prefetchChannels())`。
## C. 测试
HomeFeedController:
- prefetch 后首次 select anime 不再发请求。
- prefetch 期间选择 anime 不重复请求。
- prefetch 失败不影响推荐,也不向外抛。
Widget:
- 首切未缓存频道时 PlayerPage 仍在树中且出现 loading 遮罩。
- 数据返回后遮罩消失并使用新频道 feed。
门禁:
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 风险与控制
1. 预取完成与 select 同时发生时,select 等待同一 inFlight,避免重复请求。
2. 加载遮罩 IgnorePointer,不阻断顶栏/底栏交互。
3. PlayerPage 在正常和 loading 状态都放在相同 Stack 层级,保证 State 不因分支切换被销毁。
4. 不改后端、ReelShortWeb、specs;不安装或操作真机。
## Git
- 提交:`fix(home): 首切频道预取并保留播放画面`
- `--no-ff` 合回 ReelShort `develop` 并 push。
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
@@ -0,0 +1,54 @@
# K95.2 首切频道用封面过渡
## 目标
- 首页频道切换后立即显示目标短剧封面,视频首帧就绪后再淡入。
- 首页模式视频初始化期间不再显示 `player-session-loading` 大转圈。
- 非首页播放器及票据加载状态保持现有行为。
## 分支
- ReelShort:`fix/k95.2-poster-transition`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. PlayerVideoSurface
- 新增可选参数 `posterUrl = ''`。
- 未 reveal 时:
- `posterUrl` 非空显示 `RemoteImage(path: posterUrl, fit: BoxFit.cover)`,
Key 为 `player-video-poster`。
- `posterUrl` 为空继续显示黑色 `ColoredBox`,保留
`player-fallback-background` Key。
- 视频 reveal 后继续通过现有 200ms `AnimatedOpacity` 淡入覆盖封面。
## B. PlayerPage
- `PlayerVideoSurface` 传入当前 `drama.posterUrl`。
- `player-session-loading` 条件增加 `!widget.homeMode`:
- 首页模式视频初始化期间只显示封面。
- 非首页播放器继续显示原 session loading。
- 首页 `player-ticket-loading` 不变。
## C. 测试
- 视频表面:非空 poster 显示 `player-video-poster`,空 poster 保留黑底且无封面。
- 首页模式:视频未就绪时不出现 `player-session-loading`。
- 非首页模式:视频未就绪时仍出现 `player-session-loading`。
- 复跑既有播放器、首页相关测试。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
## Git
- ReelShort 提交:`fix(home): 频道切换用封面过渡`
- `--no-ff` 合回 `develop` 并 push。
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不安装或操作真机;完成后停住等待验收。
+86
View File
@@ -0,0 +1,86 @@
# K96 剧场页服务端分类分页
## 目标
- `/app/feed` 支持按分类过滤,并与内容类型过滤叠加。
- 剧场页「全部」展示全部已上线短剧,任意分类展示该分类全部已上线短剧。
- 列表使用游标分页,切分类优先命中缓存,支持静默刷新、加载更多和失败重试。
## 分支
- FruitServer:`feat/k96-feed-category`
- ReelShort:`feat/k96-theater-feed`
## A. FruitServer
1. `AppCatalogController.feed` 增加可选 `Long categoryId` 并传给服务层。
2. `AppCatalogService.feed` 增加 `categoryId`,传给
`DramaService.listPublishedFeed`。
3. `DramaService.listPublishedFeed` 增加 `categoryId`:
- 非空时使用 `DramaMultiTagSupport.applyCategoryIdTokens`
按 `category_ids` 整词匹配。
- 游标 marker 必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
4. 补充服务层、控制器契约测试:
- 仅 categoryId。
- categoryId + types。
- 分类游标翻页。
- marker 分类不命中返回空。
5. 更新 `docs/api-phase1.md` 的 `/app/feed` 参数和分类过滤说明。
门禁:`mvn test` 全模块通过。
## B. ReelShort
### B1. API 与仓库
- `CatalogApi.feed` 增加可选 `String? categoryId`,非空才拼 query。
- `CatalogRepository.fetchFeed` 同步参数,HTTP 实现透传。
### B2. TheaterFeedController
- 新增 `lib/core/data/services/theater_feed_controller.dart`。
- 按 `String? categoryId` 独立缓存状态:
`dramas / cursor / hasMore / loaded / loading / loadingMore / lastError /
requestSeq / inFlight`。
- `select(id)` 同步切分类;有数据先渲染并静默刷新,无数据时加载。
- `loadMore` 游标追加并按 id 去重;`hasMore=false` 停止。
- `refreshCurrent` 失败保留旧数据;切换回失败分类自动重试。
- 单请求 10 秒超时,响应写回发起分类,requestSeq 仅处理同分类顺序。
### B3. 剧场接入
- `DiscoverPage` 创建并持有 TheaterFeedController,初始请求「全部」。
- 「全部」和分类 chips 的网格统一读取 `theater.dramas`,不再使用
`categories.feed.dramas` / `dramasOf`。
- `_selectCategory` 同时更新 `DiscoverLogic.selectedCategoryId` 和
`theater.select(categoryId)`。
- 滚动剩余不足 400px 时触发 `loadMore`;搜索模式不触发剧场分页。
- 下拉刷新并行执行 `categories.load()` 和 `theater.refreshCurrent()`。
- 无缓存首屏显示转圈;空、失败和重试保持既有剧场视觉。
### B4. 测试
- TheaterFeedController:
- 全部使用空 categoryId,分类使用对应 id。
- 缓存切回不闪烁。
- 分页去重、hasMore=false 停止。
- 失败保留旧数据、失败分类切回自动重试。
- 剧场 widget:
- 点击分类请求对应 categoryId。
- 接近底部触发下一页。
- 搜索、下拉刷新与既有布局不回归。
门禁:`dart analyze lib test` 零告警、`flutter test` 全过、
`flutter build apk --debug` 成功。
## Git
- FruitServer 提交:`feat(app): 推荐流支持分类过滤`
- ReelShort 提交:`feat(theater): 剧场分类列表服务端分页`
- 两仓库作者:`Zhou Weidong <502333560@qq.com>`,禁止 `git add -A`。
- 各自 `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改 specs、ReelShortWeb,不改首页 K94/K95 已交付逻辑。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,64 @@
# K97 修复 iOS 评论输入弹窗键盘避让
## 目标
- iOS 评论输入浮层实时跟随 `MediaQuery.viewInsets.bottom`,确保输入框与发表按钮
位于键盘上方。
- Android 保留既有 MethodChannel + 80ms 防抖 pinned 策略,不恢复底部 SafeArea,
避免键盘收起时 48px 抖动回归。
- 聚焦、`TextInput.show` 兜底和发表成功提示逻辑不变。
## 分支
- ReelShort:`fix/k97-ios-comment-keyboard`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. 平台区分
- `_isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android`。
- Android:
- 继续监听 `KeyboardInsetNotifier`。
- 继续使用 80ms 防抖后的 `_pinnedKeyboardInset`。
- iOS / 其他平台:
- 不读取 pinned/channel 值。
- `build` 中直接读取 `MediaQuery.viewInsetsOf(context).bottom`。
- inset 超过屏幕高度 60% 时按 0 处理,负值归零。
## B. 面板布局
- 面板主体使用 `AnimatedPadding`,只设置 bottom:
- `duration: 160ms`
- `curve: Curves.easeOutCubic`
- Android 使用 pinned 值。
- iOS / 其他平台使用实时 viewInsets。
- Material 仍铺到屏幕底部,用于键盘收起后的实色填充。
- Android 不包裹 SafeArea。
- iOS / 其他平台包裹 `SafeArea(top: false)`,保留 home indicator 底部避让。
## C. 测试
- iOS + `viewInsets.bottom=300`:
- 键盘避让 AnimatedPadding 的 bottom 为 300。
- `comment-composer-post` 底边位于屏幕高度减 300 之上。
- iOS + `viewInsets=0`:padding 为 0。
- Android 既有 pinned/channel、焦点、弹出、关闭、发布用例保持通过。
- 所有平台 override 测试结束恢复
`debugDefaultTargetPlatformOverride = null`。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
- `flutter build ios --simulator --no-codesign`
## Git
- ReelShort 提交:`fix(comment): 修复 iOS 评论输入弹窗键盘避让`
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,73 @@
# K98 修复登录与手机号登录键盘遮挡
## 目标
- 登录/注册页在邮箱或密码框聚焦后,自动滚动到键盘上方。
- 手机号登录弹窗的手机号/验证码框同样保持可见。
- Android `adjustNothing` 下通过既有 MethodChannel 获取键盘高度,iOS 使用实时
`MediaQuery.viewInsets`。
## 分支
- ReelShort:`fix/k98-auth-keyboard`,从最新 `origin/develop` 创建。
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
## A. 有效键盘高度
在 `keyboard_inset.dart` 增加:
```dart
double effectiveKeyboardInset(
BuildContext context,
KeyboardInsetNotifier notifier, {
double maxRatio = 0.6,
});
```
- 取 MediaQuery viewInsets 与 Android channel 逻辑像素值的最大值。
- Web/非 Android 不读取 channel。
- 超过屏幕高度 60% 时按 0 处理,负值/非法值归零。
## B. AuthPage
1. `initState` 启动 `KeyboardInsetNotifier`,监听并用 setState 刷新。
2. `SingleChildScrollView` 增加 `ScrollController`。
3. 底部 padding 使用固定底部留白 + effectiveKeyboardInset。
4. 邮箱/密码 FocusNode 监听焦点;聚焦或键盘高度变化后:
- 通过 FocusNode.context 的 RenderBox 获取字段全局底边。
- 与屏幕高度 - 键盘高度 - 24 比较。
- 被遮挡时用 200ms easeOutCubic animateTo 补足滚动。
5. `resizeToAvoidBottomInset` 保持 false。
## C. PhoneAuthSheet
1. 接入 KeyboardInsetNotifier 和 ScrollController。
2. 底部 padding 改用 effectiveKeyboardInset。
3. 手机号/验证码 FocusNode 增加与 AuthPage 相同的焦点/键盘变化滚动校正。
4. 保持现有 SingleChildScrollView、弹窗和返回键行为。
## D. 测试
- iOS viewInsets=300:登录密码框、注册密码框均在键盘上方。
- Android platform + MethodChannel 键盘高度:密码框仍在键盘上方。
- iOS:手机号弹窗验证码框在键盘上方。
- 所有平台 override 测试返回前显式恢复
`debugDefaultTargetPlatformOverride = null`。
## 门禁
- `dart analyze lib test`
- `flutter test`
- `flutter build apk --debug`
## Git
- ReelShort 提交:`fix(auth): 登录注册与手机号登录键盘避让`
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
## 约束
- 全程中文;不改后端、ReelShortWeb、specs。
- 不改评论输入框已验证的 Android pinned 策略。
- 不安装或操作真机;完成后停住等待验收。
@@ -0,0 +1,30 @@
# 2026-10-21 | 短剧保存免费集数同步分集
## 目标
后台短剧编辑改 `freeEpisodes` / `unlockCoin` 后,同步 `drama_episode.is_free` / `unlock_coin`,与定价页 `PriceService.save()` 规则一致。
## 方案
1. 抽出 `EpisodePriceSync.syncByDramaRule(dramaId, freeEpisodes, unlockCoin)`,供 `DramaService.update` 与 `PriceService.save` 共用。
2. `DramaService.update` 在 `updateById` 后,仅当免费集数或解锁金币相对更新前有变化时调用同步。
3. TDD:先写 `DramaServiceTest` 复现失败,再改实现转绿。
## 范围
- 仅 `fruitshorts-module-content`
- 不改 Controller / VO / 契约 / 前端
## 门禁
```bash
mvn -pl fruitshorts-module-content test -Dtest=DramaServiceTest
mvn -pl fruitshorts-module-content test
```
## 分支 / 提交
- `feature/drama-free-episodes-sync` ← `develop`
- `test(content): 覆盖短剧免费集数同步分集价格`
- `fix(content): 短剧保存免费集数时同步分集免费状态`
- `merge --no-ff` 回 develop 并 push
@@ -0,0 +1,24 @@
# 2026-10-21 | 短剧保存始终同步分集价格(幂等纠偏)
## 背景
承接 K104。短剧 `free_episodes=9` 重存后,分集 5~9 仍收费。
## 根因
`DramaService.update()` 仅在 `freeEpisodes` / `unlockCoin` 相对更新前有变化时调用 `EpisodePriceSync`。历史数据已漂移时,同值重存不触发同步。
## 方案
1. 测试:覆盖「规则字段未变、分集已漂移」仍纠正。
2. `DramaService.update()`:每次保存都调用 `EpisodePriceSync.syncByDramaRule(drama)`。
3. `EpisodePriceSync`:仅对有差异的行走 `updateById`(已具备,保持)。
## 范围
只改 `fruitshorts-module-content` 的 `DramaService` / `EpisodePriceSync` / `DramaServiceTest`。不改 Controller / VO / 契约 / 前端。
## 提交
1. `test(content): 覆盖免费集数未变但分集漂移的修复场景`
2. `fix(content): 短剧保存时始终同步分集价格`
@@ -0,0 +1,24 @@
# 客户端收藏与观看历史分页
## 目标
- `/app/favorites` 改为 `PageResult<AppDramaCardVO>`,默认第 1 页、每页 20,最大 100。
- `/app/watch/recent` 默认返回 `PageResult<ProgressRespVO>`;请求带 `limit` 时保留一版旧
`List<ProgressRespVO>` 响应。
- 观看历史补充短剧标题、封面、集号和总集数,剧/集均批量查询。
## 实现步骤
1. 收藏关系使用 `selectPage`,按 `created_at DESC, drama_id DESC` 稳定排序;
只统计已上线短剧收藏,再按当前页 ID 批量加载短剧。
2. 观看进度使用 `selectPage`,按 `updated_at DESC, drama_id DESC` 稳定排序。
3. 当前页的 dramaId / episodeId 去重排序后,分别执行一次 IN 查询并装配展示字段。
4. Controller 归一化 pageIndex/pageSize;观看历史根据是否传 `limit` 分流新旧响应。
5. 更新 API 契约 §1.5,补 service/controller 测试。
## 风险与约束
- `limit` 分支仅用于旧客户端兼容,新分页响应与旧 List 响应不能混用。
- 已下线短剧不计入收藏 total,也不出现在收藏页。
- 观看历史展示字段找不到对应剧或集时保持 null,不影响历史记录返回。
- 不修改客户端、不改表结构、不启停服务、不操作真机。
@@ -0,0 +1,38 @@
# 转码产物查询接口
## 目标
- 媒体维度查询 rendition 与帧图产物。
- 分集维度聚合转码任务、rendition、帧图状态。
- `/admin/dashboard/transcode` 改为明确 VO,OpenAPI 不再出现匿名 Object。
## 设计
1. `MediaTranscodeOutputService` 统一读取媒体、任务、rendition 与 thumbnail。
2. rendition 按高度、视频码率、质量倒序,id 正序保证稳定。
3. thumbnail 优先读取 `thumbnails.vtt`,解析 cue 后分页;无记录返回 `EMPTY`,
记录失败返回 `FAILED`,不把缺产物当服务错误。
4. 分集聚合对不存在、无视频、无媒资、无任务返回明确 status 和空列表。
5. dashboard 使用强类型 queue/failed task VO,保留现有字段语义并补 total。
## 接口
- `GET /admin/media/{id}/renditions`
- `GET /admin/media/{id}/thumbnails?pageIndex=1&pageSize=100`
- `GET /admin/episodes/{id}/transcode`
- `GET /admin/dashboard/transcode`(响应类型由 Map 收敛为 VO)
缩略图响应内嵌 `PageResult<MediaThumbnailFrameVO>`,同时返回 sprite/VTT 元数据;
空产物 status 为 `EMPTY`,无绑定分集为 `NO_EPISODE`,损坏 VTT 为 `INVALID`。
## TDD
- 先提交媒体有/无产物、不存在、VTT 解析与分页边界测试。
- 再提交接口、VO、聚合服务和 dashboard 类型收敛实现。
- 门禁:`mvn -pl fruitshorts-module-media test`。
## 红线
- 不改 `/app` 契约、specs、ReelShortWeb、Flutter。
- 不改 P1 入队与手动重转逻辑。
- 不启停服务,不操作真机。
@@ -0,0 +1,40 @@
# 后台转码链路打通
## 现状取证
- 上传完成时 `MediaService.complete` 已为视频调用 `TranscodeService.enqueue`。
- 分集创建/替换视频地址时 `EpisodeService` 已调用
`EpisodeTranscodeBindingService.bind`,会复用并绑定同一媒体的未完成任务。
- 转码失败原因已写入 `transcode_task.error_msg`,最终状态写入 `status`。
- 缺口是后台手动触发/重转接口,以及对应幂等和链路测试。
## 实现
1. 保留上传完成和改片自动入队逻辑,补测试锁定行为。
2. `TranscodeService` 增加手动触发:
- QUEUED/RUNNING 直接返回现有任务;
- FAILED 重置原任务为 QUEUED;
- SUCCESS 才创建新任务,紧接着重复触发会复用新 QUEUED 任务。
3. 新增 `POST /admin/media/{id}/transcode`,校验媒资存在,复用 `episode:upload`。
4. 返回任务 id 与状态,便于后台提示。
实现时对同一媒资行使用 `FOR UPDATE`,让重复请求在事务内串行判定;
平台不存在的媒资保持 `DATA_NOT_FOUND`,不产生任务或 500。
## TDD 测试矩阵
| 场景 | 断言 |
| --- | --- |
| 视频上传完成 | `MediaService.complete` 调用一次 `enqueue(assetId, 0, 0, sourceUrl)` |
| 图片上传完成 | 不创建转码任务 |
| 手动触发成功媒资 | 新建一个 QUEUED 任务,重复调用复用同一任务 |
| 手动触发进行中媒资 | 直接返回 QUEUED/RUNNING 任务,不新增记录 |
| 手动触发失败媒资 | 原任务重置为 QUEUED,保留任务 id |
| 媒资不存在 | 返回既有 `DATA_NOT_FOUND`,控制器不产生 500 |
| worker 最终失败 | 任务状态 FAILED,`error_msg` 保留 ffmpeg 原因 |
## 验证
- 先提交失败测试,再提交实现。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`
- 不启停任何服务,不执行数据库变更,不改 Web/Flutter/specs/API 文档。
@@ -0,0 +1,53 @@
# 转码档位配置、雪碧图与播放缩略图轴
## 目标
1. 后台可配置转码档位与雪碧图参数,worker 对之后的新任务在 30 秒内自动生效。
2. 转码成功后生成雪碧图与 VTT,登记到 `media_thumbnail`。
3. 播放接口与详情剧集返回 `thumbnailsUrl`,无产物时为 `null`。
## 配置模型
- `sys_config.config_key = media.transcode.profile`,值为 JSON。
- 配置包含总开关、分片秒数、码率阶梯与雪碧图参数。
- 缺省配置等价于现有 360p/480p/720p/1080p 四档。
- 后台 GET 缺省或历史配置非法时返回 yml 等价默认值;PUT 非法时返回统一 400,且不写库。
- worker 读取配置缓存 30 秒;缺省、解析失败或校验失败时回退 yml 默认阶梯。
## 权限取舍
现有权限点没有 `media:transcode`。本次两个接口复用最小可用的
`episode:upload`,避免新增权限点导致已有角色需要补授权;不新增菜单或权限 SQL。
## 数据与产物
- 新增 `media_thumbnail`,按 `episode_id` 唯一,记录 VTT、雪碧图前缀、采样参数、
尺寸、帧数、时长、状态、错误与总大小。
- 雪碧图目录固定在 `transcoded/{taskId}/thumbnails/`:
`thumbnails.vtt` + `sprite_001.jpg` 等多张图。
- VTT cue 链接使用相对路径 `sprite_XXX.jpg#xywh=x,y,w,h`,同一 VTT 目录下可直接解析。
- 缩略图失败只把 `media_thumbnail.status` 置 `FAILED` 并记录错误;主 HLS 仍成功。
## 播放
- `PlayRespVO` 和 `AppEpisodeVO` 增加 `thumbnailsUrl`。
- 查询 `media_thumbnail` 仅读取 `READY` 产物;objectKey 经
`MediaStorageService.toPublicUrl` 后使用现有 `PlaybackSigner` 签名。
- 详情剧集列表批量查询缩略图,禁止 N+1。
- 不改变现有 HLS/原文件回落与 qualities 列表。
## 测试
- worker:配置解析、非法配置回退 yml、30 秒缓存、VTT 坐标、多雪碧图索引、
缩略图失败不阻断主转码。
- admin:GET 默认值、PUT 非法不落库、PUT 成功落库。
- playback:有 READY 缩略图返回签名 `thumbnailsUrl`,无产物返回 `null`。
- 门禁:`mvn -pl fruitshorts-transcode-worker,fruitshorts-admin-api,fruitshorts-app-api -am test`。
## 风险 / 边界
- 本次只对合并后的新转码任务生效,不回跑历史任务。
- 配置中的顶层 `enabled` 会持久化并返回;本单不改变既有队列消费开关语义,
worker 仍以 `worker-enabled` 决定是否消费任务。
- 雪碧图生成依赖 ffmpeg `tile` 滤镜;真实产物由 worker 门禁和 PM 后续环境验收,
本任务不启动服务、不改客户端。
@@ -0,0 +1,38 @@
# 转码分集绑定与运行态修复
## 已确认现场
admin-api/app-api/worker 均运行旧包;worker 未运行;`media_thumbnail` 表缺失;
新上传任务 drama_id/episode_id 为 0;旧 worker jar 缺 MediaThumbnail。
## 代码修复
1. 分集保存后按视频 URL 的 objectKey 查找 `media_asset`。
2. media 域新增绑定服务:
- 将 dramaId/episodeId 回填到对应 `transcode_task`;
- QUeued/RUNNING/SUCCESS 任务复用,不重复入队;
- FAILED/CANCELED 或不存在时新建任务;
- 已成功但 episodeId=0 的 rendition 同步回填。
3. `claimNext` 只认领 `episode_id > 0`,零绑定任务保留 QUEUED。
4. 缩略图登记使用 REQUIRES_NEW 独立事务;生成或登记失败只记 warn/error_msg,
主任务仍 SUCCESS。
5. 对 episode_id=0 且源文件不存在的明显测试垃圾标记 CANCELED,不删除。
## 迁移与运行
1. 确认 `media_thumbnail` 不存在后执行幂等 patch。
2. `mvn -DskipTests package` 全量重建。
3. 检查三个 jar 分别含:
- admin-api:MediaTranscodeProfile;
- app-api:PlayRespVO.thumbnailsUrl 对应新类;
- worker:MediaThumbnail / MediaThumbnailMapper。
4. 重启 8080/8081/worker,日志写 `logs/*.log`。
5. 冒烟 admin 配置、App 详情 thumbnailsUrl、worker 轮询。
6. 上传真实视频,创建绑定分集,等待 worker 消费,验证任务、四档 HLS、
media_thumbnail、VTT 与 sprite HTTP 200。
## 风险
- 旧 QUEUED 中无法绑定且源文件不存在的测试任务将被取消并记录 id。
- 真实视频端到端验证会写入运行数据库和媒资目录。
- 不修改 Web/Flutter,不操作真机。
@@ -0,0 +1,24 @@
# 转码任务认领与媒资 ID 校验
## 现场结论
- `media_asset.id=21` 确实存在,source_url 对应分集 id=13。
- 分集 13 的 video_asset_id=0,旧手动触发只拿媒资,不知道分集绑定,
因而创建了 media_asset_id=21、episode_id=0 的 QUEUED 任务。
- claim 只筛选 episode_id>0,因此该任务永久跳过且没有原因日志。
## 修复
1. POST /admin/media/{id}/transcode 严格只按 media_asset.id 查询;
不再把候选 id 当分集 id 兜底或补登记。
2. 媒资存在时按 source_url 反查分集;找到后绑定 drama_id/episode_id。
3. 找不到分集时拒绝建任务,避免生成永远无法认领的 episode_id=0 记录。
4. claim 增加 media_asset_id/source_url 条件,并记录被跳过任务 id 与原因。
5. RUNNING 无更新阈值收敛到 5 分钟,覆盖 worker 重启后的孤儿任务。
6. 一次性 patch 将 media_asset_id 不存在且 episode_id=0 的 QUEUED/RUNNING 标记 FAILED。
## 验证
- 非法 media id 拒绝、未绑定分集拒绝、合法 URL 自动绑定。
- claim 跳过原因、进度更新、僵尸任务恢复。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -0,0 +1,28 @@
# 转码媒资 ID 精度与存量兜底修复
## 取证
- 示例分集 `2113150431755636737` 的 `video_asset_id` 为
`2113150398670966786`,与 `media_asset.id` 完全一致。
- JS `Number('2113150398670966786')` 序列化为 `2113150398670966800`,
超出安全整数后发生精度丢失,导致手动转码查询了错误 ID。
## 修复
1. 聚合响应的 episodeId/mediaAssetId/taskId 使用字符串。
2. 手动转码仍由后端 Long 接收精确路径 ID。
3. 媒资不存在时通过 content 模块的端口按候选 ID 查找分集:
- 分集 id;
- video_asset_id;
- transcode_task.id。
4. 找到分集后按 video_url/source_url/objectKey 反查媒资;
文件存在但媒资缺失时补登记并回填分集 video_asset_id。
5. 无可用视频时保持明确空态/错误语义。
## 验证
- 超 2^53 ID 字符串契约。
- 聚合无媒资空态。
- video_url 反查命中。
- 文件存在时补登记。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -0,0 +1,27 @@
# 转码保持原始宽高比
## 目标
- 横屏源按档位短边作为高度,竖屏源按档位短边作为宽度。
- 源短边小于档位短边时按源短边输出,不放大。
- 输出宽高均为偶数,SAR 固定为 1。
- rendition 的 width/height 使用 ffprobe 探测到的实际值。
## 实施
1. ffprobe 读取源显示方向尺寸,旋转 90/270 度时交换宽高。
2. `effectiveShort = min(sourceShort, rungShort)` 并向下取偶数。
3. 横屏使用 `scale=-2:<effectiveShort>:flags=lanczos,setsar=1`;
竖屏使用 `scale=<effectiveShort>:-2:flags=lanczos,setsar=1`。
4. 转码完成后 ffprobe variant playlist,写入实际输出尺寸。
5. master playlist 的 RESOLUTION 同步使用实际尺寸。
## 不放大策略
选用“用源尺寸输出”:高于源短边的档位仍保留原 quality 标识和码率,
但分辨率封顶到源短边,避免所有档位都被跳过导致无产物。
## 验证
- 先补横屏、竖屏、小源码片、偶数、SAR=1 与实际尺寸回写测试。
- 门禁:`mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -0,0 +1,29 @@
# 转码错误截断与进度上报
## 问题
- ffmpeg 错误输出超过 `error_msg varchar(500)`,失败回写 UPDATE 再次失败,
任务停在 RUNNING/0。
- worker 未解析 ffmpeg progress,前端只能看到 0。
- 缺少 RUNNING 超时收口。
## 修复
1. SQL patch 将 transcode_task/media_thumbnail 的 error_msg 改为 TEXT,可重复执行。
2. 新增统一错误消息截断,最多保留 500 字符并附总长度;即使补丁尚未执行,
也不会超过旧 varchar(500) 限制。
3. ffmpeg 使用 `-progress` 临时文件,worker 节流为约 1 秒写 transcode_task.progress。
4. rendition 阶段映射 0-90%,封面/缩略图 90-99%,成功后 100。
5. 失败保留最后进度;RUNNING 超过 10 分钟无 updated_at 变化标记 FAILED。
6. 手动重转复用最新任务,SUCCESS/FAILED/CANCELED 原地重置为 QUEUED、progress=0。
7. 成功写 rendition 前替换同媒资旧产物,避免重复重转唯一键冲突。
## 风险
- 进度写入只允许 RUNNING 状态,避免覆盖已完成/失败状态。
- 历史僵尸任务由 worker 下一轮自动收口,无需手工 SQL。
## 验证
- 错误截断、进度更新、僵尸重置、重复触发幂等测试。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -0,0 +1,24 @@
# 转码 rendition 幂等与失败落库
## 根因
- 成功事务只按 media_asset_id 删除旧 rendition。
- 同一分集替换视频后 media_asset_id 改变、episode_id 不变,
旧行未被删除,触发 uk_rendition(episode_id, quality, format)。
- rendition 插入异常后任务未被可靠置为 FAILED。
## 修复
1. 成功事务删除条件分组为:
`media_asset_id = 本次媒体 OR (episode_id > 0 AND episode_id = 本次分集)`。
2. 缩略图登记按 episode_id 删除旧行后重新插入。
3. 主流程异常调用 failFinal,直接写 FAILED、错误、finished_at,保留 progress。
4. failFinal 自身异常在 worker 中 catch 并打印 ERROR,不静默。
5. error 截断同时保留头部与尾部,长度仍 <= 500。
## 验证
- 不同 media_asset_id、同 episode_id 的重复转码清理条件。
- thumbnail 旧行替换。
- 失败落库字段与尾段错误保留。
- `mvn -q -DskipTests=false test -pl ... -am`。
+19
View File
@@ -0,0 +1,19 @@
# 标签管理(替换短剧内容类型)
## 决定
不做「语言开关」总控。分类和剧单已经用 `name` / `nameEn` / `nameId` 三列。标签沿用同一套,避免两套国际化。语言总控要改短剧标题、简介、剧集标题,单独开一轮。
## 本轮
- 短剧编辑去掉「内容类型」。库里的 `drama_type` 先留着,推荐流的 `ANIME` / `LIVE` 筛选不受影响,后台不再改它。
- 内容管理下新增「标签管理」。
- 标签字段:中文名、英文名、印尼语名、排序、启用状态。三个名称都必填。
- 短剧 `tag_id` 单选,可空。编辑页用下拉,只列出启用中的标签。
- 已有短剧按原内容类型回填:`ANIME` → 漫剧,`LIVE` → 真人剧。
## 接口
- `GET/POST /admin/content-tags`,`PUT/DELETE /admin/content-tags/{id}`,权限沿用 `drama:list` / `create` / `update` / `delete`。
- 短剧保存增加 `tagId`。不传则保持原值;传 `0` 表示清空。
- 短剧列表返回 `tagId`、`tagName`、`tagNameEn`、`tagNameId`。
+95
View File
@@ -0,0 +1,95 @@
# INBOX
> 本轮任务: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)
- ReelShortWeb 分支:`feature/episode-batch-upload` → `--no-ff` 合入 `develop` 并 push
- 提交:`feat(admin): 剧集多选分片上传与断点续传`
- 门禁:`pnpm lint:eslint` 零错误;`pnpm build:dev` 成功
- 未验证:PM 手工多选/暂停/刷新续传/批量建集(8080+3005)
- 风险:续传需重选相同本地文件;暂停不 abort 进行中分片
## 现状(已勘察,不要重复排查)
- 后端分片上传已具备且不改:`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` 也没有落盘。
## 目标
在剧集管理页提供「批量上传」:一次选多个视频 → 队列分片上传 → 每个文件独立进度 → 失败可重试 →
刷新/重进页面能续传 → 上传完成后批量创建分集。
## A. 上传队列(新建 `src/views/contentManagement/upload/useUploadQueue.ts`)
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. 断点续传(关键)
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. 上传成功、用户移除、用户放弃续传三种情况都要清理对应记录
## C. 批量创建分集
1. 上传完成列表提供「批量创建分集」按钮
2. 默认值:按文件名自然排序(去扩展名后再比较,`第2集` 排在 `第10集` 之前);
集号 = 该剧当前最大集号 + 1 起顺延;标题取文件名去扩展名;是否免费沿用现有新建默认值
3. 弹窗内每行可改:集号、标题、是否免费;冲突集号在提交前本地校验并标红
4. 提交:逐条调用现有 `createEpisodeApi`(`src/api/admin/episode.ts`),
逐条显示成功/失败,失败的保留在弹窗内可重试;全部结束后刷新剧集列表
5. **不新增后端接口**,契约不动
## 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` 只加一个「批量上传」入口按钮,并保持原单文件上传入口可用
## E. 细节要求
- 文件类型白名单:`mp4/mov/mkv/webm`(`accept="video/*"` + `before-upload` 二次校验)
- 单文件大小上限从现有常量取值,超限直接标红不入队
- 所有文案走 `src/locales/zh-CN.ts` / `en.ts`,禁止硬编码中文
- 进度与错误展示用项目现有样式变量,不新引入 UI 依赖
- 上传期间:关闭页面前用 `beforeunload` 提示存在未完成上传
## F. 门禁
- `pnpm lint:eslint` 零错误
- `pnpm build:dev` 构建成功
- 手工验证(PM 会复核):本地起 8080 + 3005,多选 3 个视频 → 三条并行进度 → 中途暂停 → 刷新页面 → 出现续传提示 → 续传完成 → 批量建分集成功
## 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 验收,回执需贴门禁原始输出
+25903
View File
File diff suppressed because it is too large. Load diff
@@ -5,7 +5,50 @@ package com.fruitshorts;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.tags.Tag;
@OpenAPIDefinition(
info = @Info(
title = "FruitShorts 运营后台接口(/admin/**)",
version = "1.0.0",
description = "接口契约以 FruitServer/docs/api-phase1.md 为准"
),
tags = {
@Tag(name = "后台-管理员账号", description = "后台管理员账号管理"),
@Tag(name = "后台-App 用户", description = "App 用户查询与处置"),
@Tag(name = "后台-登录与个人", description = "管理端登录、菜单、个人资料与改密"),
@Tag(name = "后台-轮播图管理", description = "轮播图维护与排序"),
@Tag(name = "后台-评论审核", description = "评论检索与审核"),
@Tag(name = "后台-系统配置", description = "系统参数配置"),
@Tag(name = "后台-创作者管理", description = "创作者入驻审核与账号开通"),
@Tag(name = "创作者中心", description = "创作者工作台(作品、通知、看板)"),
@Tag(name = "创作者中心-内容与收费", description = "创作者内容编辑、提交审核与分集收费"),
@Tag(name = "后台-数据看板", description = "运营大盘与转码看板"),
@Tag(name = "后台-字典", description = "分类与状态字典"),
@Tag(name = "后台-离线下载管理", description = "下载授权查询、吊销与清理"),
@Tag(name = "后台-短剧分类", description = "短剧分类维护"),
@Tag(name = "后台-短剧管理", description = "短剧 CRUD 与审核发布流转"),
@Tag(name = "后台-权益管理", description = "权益补发与回收"),
@Tag(name = "后台-剧集管理", description = "剧集 CRUD 与排序"),
@Tag(name = "后台-媒资管理", description = "分片上传与媒资详情"),
@Tag(name = "后台-菜单管理", description = "菜单树维护"),
@Tag(name = "后台-操作日志", description = "操作日志检索"),
@Tag(name = "后台-订单管理", description = "订单检索、补发与退款"),
@Tag(name = "后台-商品配置", description = "商品(金币包)配置与上下架"),
@Tag(name = "后台-系统公告", description = "系统公告发布与管理(与 C 端口径一致)"),
@Tag(name = "后台-权限点", description = "权限点查询"),
@Tag(name = "后台-分集收费", description = "分集价格配置"),
@Tag(name = "后台-对账", description = "对账任务与手动对账"),
@Tag(name = "后台-举报工单", description = "举报工单处理"),
@Tag(name = "后台-角色分类", description = "角色分类维护"),
@Tag(name = "后台-角色管理", description = "角色与角色权限"),
@Tag(name = "后台-敏感词库", description = "敏感词维护"),
@Tag(name = "后台-内容提交审核", description = "创作者内容提交审核"),
@Tag(name = "后台-金币钱包", description = "金币流水与人工补币"),
}
)
@SpringBootApplication
@MapperScan("com.fruitshorts.**.dal.mapper")
public class FruitShortsApplication {
@@ -0,0 +1,28 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.config;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.format.DateTimeFormatter;
/**
* 后台接口的时间格式统一成 `yyyy-MM-dd HH:mm:ss`。
*
* <p>application.yml 里的 `spring.jackson.date-format` 只作用于 java.util.Date,
* LocalDateTime 仍会输出 ISO 的 `2026-10-27T14:14:12.644`,后台表格里会露出一串带 T 的原文。
* 该配置只放在 admin-api,C 端保持 ISO,避免影响客户端解析。
*/
@Configuration
public class AdminJacksonTimeConfig {
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Bean
public Jackson2ObjectMapperBuilderCustomizer adminLocalDateTimeFormat() {
return builder -> builder.serializers(new LocalDateTimeSerializer(DATE_TIME));
}
}
@@ -10,6 +10,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.AdminUserPageReqVO;
import com.fruitshorts.system.vo.AdminUserRespVO;
import com.fruitshorts.system.vo.AdminUserSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Tag(name = "后台-管理员账号")
@RestController
@RequestMapping("/admin/admin-users")
@RequiredArgsConstructor
@@ -32,18 +35,21 @@ public class AdminUserController {
private final AdminUserService adminUserService;
@Operation(summary = "管理员分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<PageResult<AdminUserRespVO>> page(AdminUserPageReqVO req) {
return Result.ok(adminUserService.page(req));
}
@Operation(summary = "管理员详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<AdminUserRespVO> get(@PathVariable Long id) {
return Result.ok(adminUserService.get(id));
}
@Operation(summary = "新建管理员")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "新增管理员")
@@ -51,6 +57,7 @@ public class AdminUserController {
return Result.ok(adminUserService.create(req));
}
@Operation(summary = "编辑管理员")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "编辑管理员")
@@ -59,6 +66,7 @@ public class AdminUserController {
return Result.ok();
}
@Operation(summary = "删除管理员")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "删除管理员")
@@ -67,6 +75,7 @@ public class AdminUserController {
return Result.ok();
}
@Operation(summary = "启用 / 停用管理员")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "修改管理员状态")
@@ -75,6 +84,7 @@ public class AdminUserController {
return Result.ok();
}
@Operation(summary = "重置管理员密码")
@PutMapping("/{id}/password")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "重置管理员密码")
@@ -4,6 +4,7 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.admin.service.AppSessionSystemService;
import com.fruitshorts.admin.vo.AppUserWatchHistoryVO;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
@@ -13,11 +14,14 @@ import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.user.dal.entity.AppUserBanRecord;
import com.fruitshorts.user.service.AppUserAdminService;
import com.fruitshorts.user.vo.AppUserQueryReqVO;
import com.fruitshorts.user.vo.AppUserAdminRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -32,6 +36,7 @@ import java.util.Map;
import java.util.stream.Collectors;
/** 后台用户管理。 */
@Tag(name = "后台-App 用户")
@RestController
@RequestMapping("/admin/app-users")
@RequiredArgsConstructor
@@ -41,19 +46,23 @@ public class AppUserAdminController {
private final WatchProgressService watchProgressService;
private final DramaService dramaService;
private final EpisodeService episodeService;
private final AppSessionSystemService sessionSystem;
@Operation(summary = "App 用户分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<PageResult<AppUserAdminRespVO>> page(AppUserQueryReqVO req) {
return Result.ok(appUserAdminService.page(req));
}
@Operation(summary = "App 用户详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<AppUserAdminRespVO> detail(@PathVariable Long id) {
return Result.ok(appUserAdminService.detail(id));
}
@Operation(summary = "封禁 / 处置记录")
@GetMapping("/{id}/ban-records")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<List<AppUserBanRecord>> banRecords(@PathVariable Long id) {
@@ -61,6 +70,7 @@ public class AppUserAdminController {
}
/** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */
@Operation(summary = "观看流水明细(客服核对用)")
@GetMapping("/{id}/watch-history")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<List<AppUserWatchHistoryVO>> watchHistory(@PathVariable Long id,
@@ -76,11 +86,15 @@ public class AppUserAdminController {
Map<Long, DramaEpisode> episodes = episodeService
.listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList())
.stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a));
return Result.ok(rows.stream().map(row -> toVo(row, dramas, episodes)).toList());
List<AppUserSession> sessions = sessionSystem.groupByUser(List.of(id))
.getOrDefault(id, List.of());
return Result.ok(rows.stream()
.map(row -> toVo(row, dramas, episodes, sessionSystem.match(sessions, row.getDeviceId())))
.toList());
}
private AppUserWatchHistoryVO toVo(WatchHistory row, Map<Long, Drama> dramas,
Map<Long, DramaEpisode> episodes) {
Map<Long, DramaEpisode> episodes, AppUserSession session) {
AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO();
vo.setId(row.getId());
vo.setDramaId(row.getDramaId());
@@ -93,6 +107,10 @@ public class AppUserAdminController {
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0 && position + 1000L >= duration);
if (session != null) {
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
}
Drama drama = dramas.get(row.getDramaId());
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
@@ -108,6 +126,7 @@ public class AppUserAdminController {
}
/** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */
@Operation(summary = "处置用户状态(封禁会立即吊销全部会话)")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('app-user:update')")
@OperationLog(module = "用户管理", action = "修改用户状态")
@@ -0,0 +1,98 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.service.AppVersionService;
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
import com.fruitshorts.system.vo.AppVersionRespVO;
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台 - App 版本管理。 */
@Tag(name = "后台-App 版本")
@RestController
@RequestMapping("/admin/app-versions")
@RequiredArgsConstructor
public class AppVersionAdminController {
private final AppVersionService appVersionService;
@Operation(summary = "版本分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('app-version:list')")
public Result<PageResult<AppVersionRespVO>> page(@Validated AppVersionQueryReqVO req) {
return Result.ok(appVersionService.page(req));
}
@Operation(summary = "版本详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:list')")
public Result<AppVersionRespVO> detail(@PathVariable Long id) {
return Result.ok(appVersionService.detail(id));
}
@Operation(summary = "新建版本(默认发布)")
@PostMapping
@PreAuthorize("@ss.hasPermission('app-version:create')")
@OperationLog(module = "版本管理", action = "新建版本")
public Result<Long> create(@Valid @RequestBody AppVersionSaveReqVO req) {
return Result.ok(appVersionService.create(req));
}
@Operation(summary = "批量新建版本(一次事务)")
@PostMapping("/batch")
@PreAuthorize("@ss.hasPermission('app-version:create')")
@OperationLog(module = "版本管理", action = "批量新建版本")
public Result<java.util.List<Long>> batchCreate(
@NotEmpty(message = "请至少传一条")
@Size(max = 16, message = "单次不超过 16 条")
@Valid @RequestBody java.util.List<@Valid AppVersionSaveReqVO> reqs) {
return Result.ok(appVersionService.batchCreate(reqs));
}
@Operation(summary = "编辑版本")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:update')")
@OperationLog(module = "版本管理", action = "编辑版本")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody AppVersionSaveReqVO req) {
appVersionService.update(id, req);
return Result.ok();
}
@Operation(summary = "发布 / 下架")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('app-version:update')")
@OperationLog(module = "版本管理", action = "版本上下架")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
appVersionService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "删除版本")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:delete')")
@OperationLog(module = "版本管理", action = "删除版本")
public Result<Void> delete(@PathVariable Long id) {
appVersionService.delete(id);
return Result.ok();
}
}
@@ -12,6 +12,8 @@ import com.fruitshorts.system.vo.LoginRespVO;
import com.fruitshorts.system.vo.MenuVO;
import com.fruitshorts.system.vo.PasswordChangeReqVO;
import com.fruitshorts.system.vo.ProfileRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -23,37 +25,44 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-登录与个人")
@RestController
@RequestMapping("/admin/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final WebUtils webUtils;
@Operation(summary = "管理端登录")
@PostMapping("/login")
@OperationLog(module = "认证", action = "登录")
public Result<LoginRespVO> login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) {
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
return Result.ok(authService.login(req, webUtils.clientIp(request)));
}
/** 当前用户的菜单路由树,结构与前端 element-plus-admin 的动态路由一致。 */
@Operation(summary = "当前用户菜单路由树")
@GetMapping("/menus")
public Result<List<MenuVO>> menus() {
return Result.ok(authService.listMenus(SecurityUtils.getUserId()));
}
@Operation(summary = "当前用户资料")
@GetMapping("/profile")
public Result<ProfileRespVO> profile() {
return Result.ok(authService.profile(SecurityUtils.getUserId()));
}
/** JWT 无状态,登出由前端清除 token 即可。 */
@Operation(summary = "登出")
@PostMapping("/logout")
@OperationLog(module = "认证", action = "退出登录")
public Result<Void> logout() {
return Result.ok();
}
@Operation(summary = "修改自己的登录密码")
@PostMapping("/password")
@OperationLog(module = "认证", action = "修改密码")
public Result<Void> changePassword(@Valid @RequestBody PasswordChangeReqVO req) {
@@ -9,6 +9,8 @@ import com.fruitshorts.content.vo.BannerRespVO;
import com.fruitshorts.content.vo.BannerSaveReqVO;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-轮播图管理")
@RestController
@RequestMapping("/admin/banners")
@RequiredArgsConstructor
@@ -32,6 +35,7 @@ public class BannerController {
private final BannerService bannerService;
@Operation(summary = "轮播图分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('banner:list')")
public Result<PageResult<BannerRespVO>> page(@RequestParam(required = false) String position,
@@ -41,12 +45,14 @@ public class BannerController {
return Result.ok(bannerService.page(position, status, pageIndex, pageSize));
}
@Operation(summary = "轮播图详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:list')")
public Result<BannerRespVO> get(@PathVariable Long id) {
return Result.ok(bannerService.get(id));
}
@Operation(summary = "新建轮播图")
@PostMapping
@PreAuthorize("@ss.hasPermission('banner:create')")
@OperationLog(module = "内容管理", action = "新增轮播图")
@@ -54,6 +60,7 @@ public class BannerController {
return Result.ok(bannerService.create(req, SecurityUtils.getUserId()));
}
@Operation(summary = "编辑轮播图")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "编辑轮播图")
@@ -62,6 +69,7 @@ public class BannerController {
return Result.ok();
}
@Operation(summary = "删除轮播图")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:delete')")
@OperationLog(module = "内容管理", action = "删除轮播图")
@@ -70,6 +78,7 @@ public class BannerController {
return Result.ok();
}
@Operation(summary = "上架 / 下架轮播图")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "修改轮播图状态")
@@ -78,6 +87,7 @@ public class BannerController {
return Result.ok();
}
@Operation(summary = "调整轮播排序")
@PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('banner:update')")
public Result<Void> sort(@RequestBody Map<String, List<Long>> body) {
@@ -11,6 +11,8 @@ import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.interaction.vo.CommentAuditReqVO;
import com.fruitshorts.interaction.vo.CommentQueryReqVO;
import com.fruitshorts.interaction.vo.CommentRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台评论审核。 */
@Tag(name = "后台-评论审核")
@RestController
@RequestMapping("/admin/comments")
@RequiredArgsConstructor
@@ -32,7 +35,8 @@ public class CommentAdminController {
private final CommentService commentService;
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */
/** 检索评论:status 为空时返回全部状态,传 pending 即审核队列。 */
@Operation(summary = "评论检索(status 为空返回全部状态)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('comment:list')")
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
@@ -40,12 +44,14 @@ public class CommentAdminController {
}
/** 审核留证:机审与人审的完整链路。 */
@Operation(summary = "评论审核留证(机审 + 人审链路)")
@GetMapping("/{id}/audit-records")
@PreAuthorize("@ss.hasPermission('comment:list')")
public Result<List<ContentAuditRecord>> auditRecords(@PathVariable Long id) {
return Result.ok(commentService.auditRecords(id));
}
@Operation(summary = "审核评论(通过 / 驳回)")
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('comment:audit')")
@OperationLog(module = "内容治理", action = "审核评论")
@@ -54,6 +60,7 @@ public class CommentAdminController {
return Result.ok();
}
@Operation(summary = "删除评论")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('comment:audit')")
@OperationLog(module = "内容治理", action = "删除评论")
@@ -6,6 +6,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.system.service.ConfigService;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.ConfigItemVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-系统配置")
@RestController
@RequestMapping("/admin/configs")
@RequiredArgsConstructor
@@ -24,12 +27,14 @@ public class ConfigController {
private final ConfigService configService;
@Operation(summary = "系统配置列表")
@GetMapping
@PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')")
public Result<List<ConfigItemVO>> list() {
return Result.ok(configService.list());
}
@Operation(summary = "保存系统配置")
@PutMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改系统设置")
@@ -0,0 +1,72 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.service.ContentTagService;
import com.fruitshorts.content.vo.ContentTagRespVO;
import com.fruitshorts.content.vo.ContentTagSaveReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 短剧标签管理。短剧编辑时单选其中一个启用标签。 */
@Tag(name = "后台-标签")
@RestController
@RequestMapping("/admin/content-tags")
@RequiredArgsConstructor
public class ContentTagController {
private final ContentTagService contentTagService;
@Operation(summary = "标签列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<ContentTagRespVO>> list(
@RequestParam(required = false) Boolean enabledOnly) {
if (Boolean.TRUE.equals(enabledOnly)) {
return Result.ok(contentTagService.listEnabled());
}
return Result.ok(contentTagService.list());
}
@Operation(summary = "新建标签")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增标签")
public Result<Long> create(@Valid @RequestBody ContentTagSaveReqVO req) {
return Result.ok(contentTagService.create(req));
}
@Operation(summary = "编辑标签")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑标签")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ContentTagSaveReqVO req) {
contentTagService.update(id, req);
return Result.ok();
}
@Operation(summary = "删除标签")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除标签")
public Result<Void> delete(@PathVariable Long id) {
contentTagService.delete(id);
return Result.ok();
}
}
@@ -15,6 +15,8 @@ import com.fruitshorts.creator.vo.CreatorAuditReqVO;
import com.fruitshorts.creator.vo.CreatorQueryReqVO;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台创作者:检索、详情、资质、入驻审核。 */
@Tag(name = "后台-创作者管理")
@RestController
@RequestMapping("/admin/creators")
@RequiredArgsConstructor
@@ -37,12 +40,14 @@ public class CreatorAdminController {
private final CreatorService creatorService;
private final CreatorAccountService creatorAccountService;
@Operation(summary = "创作者分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<PageResult<Creator>> page(CreatorQueryReqVO req) {
return Result.ok(creatorService.page(req));
}
@Operation(summary = "创作者详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<Creator> detail(@PathVariable Long id) {
@@ -50,12 +55,14 @@ public class CreatorAdminController {
}
/** 资质文件:审核前要看证件原件。 */
@Operation(summary = "创作者资质文件")
@GetMapping("/{id}/documents")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<List<CreatorDocument>> documents(@PathVariable Long id) {
return Result.ok(creatorService.documents(id));
}
@Operation(summary = "创作者入驻审核")
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "入驻审核")
@@ -63,7 +70,8 @@ public class CreatorAdminController {
return Result.ok(creatorService.audit(id, req, SecurityUtils.getUserId()));
}
/** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */
/** 手工录入创作者主体:录入即通过,可直接开号。 */
@Operation(summary = "手工录入创作者主体(录入即通过)")
@PostMapping
@PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "新增创作者")
@@ -71,7 +79,8 @@ public class CreatorAdminController {
return Result.ok(creatorService.create(req));
}
/** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */
/** 开通创作者账号:不再校验入驻审核,仅冻结状态拒绝;已开通则幂等返回。 */
@Operation(summary = "开通创作者账号(仅冻结状态拒绝,幂等)")
@PostMapping("/{id}/account")
@PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "开通创作者账号")
@@ -81,6 +90,7 @@ public class CreatorAdminController {
}
/** 重置创作者账号密码:一次性明文返回,旧密码立即失效。 */
@Operation(summary = "重置创作者账号密码(一次性明文返回)")
@PostMapping("/{id}/account/reset-password")
@PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "重置创作者密码")
@@ -11,6 +11,8 @@ import com.fruitshorts.creator.vo.CreatorCenterNoticeVO;
import com.fruitshorts.creator.vo.CreatorCenterProfileVO;
import com.fruitshorts.creator.vo.CreatorCenterTargetVO;
import com.fruitshorts.admin.service.CreatorDashboardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -28,6 +30,7 @@ import java.util.Map;
* 归属由 {@link CreatorCenterService#requireCurrentCreator()} 从登录态解析,
* 查询强制带 `where creator_id = 当前创作者`;访问他人作品统一 403。
*/
@Tag(name = "创作者中心")
@RestController
@RequestMapping("/admin/creator-center")
@RequiredArgsConstructor
@@ -37,6 +40,7 @@ public class CreatorCenterController {
private final CreatorDashboardService creatorDashboardService;
/** 当前创作者的主体信息与入驻状态。 */
@Operation(summary = "我的创作者资料与入驻状态")
@GetMapping("/profile")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterProfileVO> profile() {
@@ -44,6 +48,7 @@ public class CreatorCenterController {
}
/** 我的作品列表(含最近一次提交的审核进度)。 */
@Operation(summary = "我的作品列表(含最近一次审核进度)")
@GetMapping("/dramas/page")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PageResult<CreatorCenterDramaVO>> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
@@ -51,6 +56,7 @@ public class CreatorCenterController {
}
/** 我的作品详情:id 是别人的作品时返回 403,不区分"不存在"。 */
@Operation(summary = "我的作品详情")
@GetMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterDramaVO> myDrama(@PathVariable Long id) {
@@ -58,6 +64,7 @@ public class CreatorCenterController {
}
/** 我的通知:入驻审核结果 + 我提交内容的审核结果。 */
@Operation(summary = "我的通知(入驻与内容审核结果)")
@GetMapping("/notices")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<CreatorCenterNoticeVO>> notices() {
@@ -65,6 +72,7 @@ public class CreatorCenterController {
}
/** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */
@Operation(summary = "我的数据看板(播放 / 完播 / 收藏 / 评论 / 解锁收入)")
@GetMapping("/dashboard")
@PreAuthorize("@ss.hasPermission('creator-center:stat:view')")
public Result<Map<String, Object>> dashboard() {
@@ -75,6 +83,7 @@ public class CreatorCenterController {
* 代看目标下拉的数据源:只有带 `creator-center:impersonate` 的账号(第一阶段 = 超管)能调。
* 前端拿它渲染顶部的「当前创作者」下拉;没权限的账号不显示下拉,也不会请求这个接口。
*/
@Operation(summary = "代看可选创作者列表")
@GetMapping("/impersonation/targets")
@PreAuthorize("@ss.hasPermission('creator-center:impersonate')")
public Result<List<CreatorCenterTargetVO>> impersonationTargets() {
@@ -12,8 +12,12 @@ import com.fruitshorts.creator.service.CreatorContentService;
import com.fruitshorts.creator.vo.CreatorAttachReqVO;
import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.creator.vo.CreatorPublishReqVO;
import com.fruitshorts.creator.vo.CreatorPublishRespVO;
import com.fruitshorts.creator.vo.CreatorSubmitReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -34,6 +38,7 @@ import java.util.List;
* 与 {@link CreatorCenterController} 一样**不接收 creatorId**:归属由登录态解析,
* 服务层强制 `where creator_id = 当前创作者`,改别人的剧或分集一律 403。
*/
@Tag(name = "创作者中心-内容与收费")
@RestController
@RequestMapping("/admin/creator-center")
@RequiredArgsConstructor
@@ -42,6 +47,7 @@ public class CreatorContentController {
private final CreatorContentService creatorContentService;
/** 新建草稿。 */
@Operation(summary = "新建作品草稿")
@PostMapping("/dramas")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "新建作品")
@@ -49,7 +55,17 @@ public class CreatorContentController {
return Result.ok(creatorContentService.createDrama(req));
}
/** 一键发布:建作品 + 建分集 + 绑定视频(可选同时提交审核),一个事务完成。 */
@Operation(summary = "一键发布(建作品 / 分集 / 绑定视频 / 可选提交审核)")
@PostMapping("/publish")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "一键发布作品")
public Result<CreatorPublishRespVO> publish(@Valid @RequestBody CreatorPublishReqVO req) {
return Result.ok(creatorContentService.publish(req));
}
/** 编辑作品信息(仅草稿 / 已下线可改)。 */
@Operation(summary = "编辑作品信息(仅草稿 / 已下线可改)")
@PutMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "编辑作品")
@@ -59,6 +75,7 @@ public class CreatorContentController {
}
/** 分集列表。 */
@Operation(summary = "分集列表")
@GetMapping("/dramas/{id}/episodes")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<EpisodeRespVO>> listEpisodes(@PathVariable Long id) {
@@ -66,6 +83,7 @@ public class CreatorContentController {
}
/** 新增分集(集号不传自动排到最后)。 */
@Operation(summary = "新增分集")
@PostMapping("/dramas/{id}/episodes")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "新增分集")
@@ -74,6 +92,7 @@ public class CreatorContentController {
}
/** 编辑分集(时长 / 封面 / 片源 / 排序)。 */
@Operation(summary = "编辑分集(时长 / 封面 / 片源)")
@PutMapping("/dramas/{id}/episodes/{episodeId}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "编辑分集")
@@ -84,6 +103,7 @@ public class CreatorContentController {
}
/** 删除分集。 */
@Operation(summary = "删除分集")
@DeleteMapping("/dramas/{id}/episodes/{episodeId}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "删除分集")
@@ -92,7 +112,17 @@ public class CreatorContentController {
return Result.ok();
}
@Operation(summary = "删除作品")
@DeleteMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "删除作品")
public Result<Void> deleteDrama(@PathVariable Long id) {
creatorContentService.deleteDrama(id);
return Result.ok();
}
/** 批量排序:body 里按新顺序给出分集 id。 */
@Operation(summary = "分集批量排序")
@PutMapping("/dramas/{id}/episodes/sort")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "调整分集顺序")
@@ -102,6 +132,7 @@ public class CreatorContentController {
}
/** 提交审核:CREATE / UPDATE / PUBLISH / OFFLINE。 */
@Operation(summary = "提交审核(CREATE / UPDATE / PUBLISH / OFFLINE)")
@PostMapping("/dramas/{id}/submit")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "提交审核")
@@ -110,6 +141,7 @@ public class CreatorContentController {
}
/** 撤回自己的提交(撤回后作品退回草稿,可继续编辑)。 */
@Operation(summary = "撤回自己的提交")
@PostMapping("/submissions/{id}/withdraw")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "撤回提交")
@@ -118,6 +150,7 @@ public class CreatorContentController {
}
/** 我的提交历史(审核进度)。 */
@Operation(summary = "我的提交历史")
@GetMapping("/dramas/{id}/submissions")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) {
@@ -125,6 +158,7 @@ public class CreatorContentController {
}
/** 把上传好的视频产物挂到自己的分集上(产物必须是本人上传的)。 */
@Operation(summary = "把上传产物挂到自己的分集上")
@PostMapping("/dramas/{id}/episodes/{episodeId}/attach")
@PreAuthorize("@ss.hasPermission('creator-center:upload')")
@OperationLog(module = "创作者中心", action = "挂载分集视频")
@@ -135,6 +169,7 @@ public class CreatorContentController {
}
/** 分集收费与试看秒数:读现状。 */
@Operation(summary = "分集收费与试看秒数(读)")
@GetMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PriceRespVO> priceDetail(@PathVariable Long id) {
@@ -142,6 +177,7 @@ public class CreatorContentController {
}
/** 分集收费与试看秒数:保存(复用后台同一套 PriceService,改完立即影响付费判定)。 */
@Operation(summary = "分集收费与试看秒数(保存)")
@PutMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:price:update')")
@OperationLog(module = "创作者中心", action = "配置分集价格")
@@ -3,7 +3,12 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.DashboardService;
import com.fruitshorts.admin.vo.DashboardDistributionRespVO;
import com.fruitshorts.admin.vo.DashboardTrendRespVO;
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -18,6 +23,7 @@ import java.util.Map;
* 指标口径写在 DashboardService 的类注释与 docs/api-phase1.md 里;
* 取不到数据源的指标返回 null 并在 notes 里说明,不用 0 冒充。
*/
@Tag(name = "后台-数据看板")
@RestController
@RequestMapping("/admin/dashboard")
@RequiredArgsConstructor
@@ -26,6 +32,7 @@ public class DashboardAdminController {
private final DashboardService dashboardService;
/** 总览:用户 / 内容 / 播放 / 收入 / 成本。 */
@Operation(summary = "运营大盘总览(用户 / 内容 / 播放 / 收入 / 成本)")
@GetMapping("/overview")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<Map<String, Object>> overview() {
@@ -33,9 +40,26 @@ public class DashboardAdminController {
}
/** 转码看板:队列积压与失败任务明细。 */
@Operation(summary = "转码看板(队列积压与失败任务)")
@GetMapping("/transcode")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<Map<String, Object>> transcode() {
public Result<TranscodeDashboardRespVO> transcode() {
return Result.ok(dashboardService.transcode());
}
/** 近 7 天每日趋势(用户注册 / 播放事件 / 转码任务)。 */
@Operation(summary = "大盘趋势(近 7 天每日数据,给折线图用)")
@GetMapping("/trend")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<DashboardTrendRespVO> trend() {
return Result.ok(dashboardService.trend());
}
/** 分布:短剧状态 / 转码状态 / 片源覆盖 / 待处理审核。 */
@Operation(summary = "大盘分布(给饼图用)")
@GetMapping("/distribution")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<DashboardDistributionRespVO> distribution() {
return Result.ok(dashboardService.distribution());
}
}
@@ -6,6 +6,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.vo.DramaCategoryRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -19,6 +21,7 @@ import java.util.Map;
/**
* 字典接口:题材、内容状态等下拉数据。
*/
@Tag(name = "后台-字典")
@RestController
@RequestMapping("/admin/dicts")
@RequiredArgsConstructor
@@ -26,14 +29,16 @@ public class DictController {
private final DramaCategoryService dramaCategoryService;
@Operation(summary = "短剧分类字典")
@GetMapping("/drama-category")
@PreAuthorize("@ss.hasPermission('drama:list')")
@PreAuthorize("@ss.hasAnyPermission('drama:list','creator-center:view')")
public Result<List<DramaCategoryRespVO>> dramaCategories() {
return Result.ok(dramaCategoryService.list());
}
@Operation(summary = "内容状态字典")
@GetMapping("/content-status")
@PreAuthorize("@ss.hasPermission('drama:list')")
@PreAuthorize("@ss.hasAnyPermission('drama:list','creator-center:view')")
public Result<List<Map<String, String>>> contentStatus() {
return Result.ok(Arrays.stream(ContentStatus.values())
.map(status -> Map.of("code", status.getCode(), "text", status.getText()))
@@ -8,6 +8,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.playback.service.DownloadService;
import com.fruitshorts.playback.vo.DownloadLicenseRespVO;
import com.fruitshorts.playback.vo.DownloadQueryReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/** 后台下载授权管理。 */
@Tag(name = "后台-离线下载管理")
@RestController
@RequestMapping("/admin/downloads")
@RequiredArgsConstructor
@@ -27,12 +30,14 @@ public class DownloadAdminController {
private final DownloadService downloadService;
@Operation(summary = "下载授权分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('download:list')")
public Result<PageResult<DownloadLicenseRespVO>> page(DownloadQueryReqVO req) {
return Result.ok(downloadService.page(req));
}
@Operation(summary = "下载统计")
@GetMapping("/stats")
@PreAuthorize("@ss.hasPermission('download:list')")
public Result<Map<String, Object>> stats() {
@@ -40,6 +45,7 @@ public class DownloadAdminController {
}
/** 吊销单条授权:用户侧下次联网即失效。 */
@Operation(summary = "吊销单条下载授权")
@PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('download:revoke')")
@OperationLog(module = "下载管理", action = "吊销下载授权")
@@ -49,6 +55,7 @@ public class DownloadAdminController {
}
/** 手动触发过期清理,正常情况下由定时任务每天执行。 */
@Operation(summary = "手动触发过期授权清理")
@PostMapping("/cleanup")
@PreAuthorize("@ss.hasPermission('download:revoke')")
@OperationLog(module = "下载管理", action = "清理过期下载授权")
@@ -8,6 +8,8 @@ import com.fruitshorts.content.vo.DramaCategoryRespVO;
import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import java.util.List;
/**
* 短剧类型管理。类型属于短剧域,权限沿用 drama:*,不再单开权限点。
*/
@Tag(name = "后台-短剧分类")
@RestController
@RequestMapping("/admin/drama-categories")
@RequiredArgsConstructor
@@ -32,12 +35,14 @@ public class DramaCategoryController {
private final DramaCategoryService dramaCategoryService;
@Operation(summary = "分类列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> list() {
return Result.ok(dramaCategoryService.list());
}
@Operation(summary = "新建分类")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增短剧类型")
@@ -45,6 +50,7 @@ public class DramaCategoryController {
return Result.ok(dramaCategoryService.create(req));
}
@Operation(summary = "编辑分类")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧类型")
@@ -53,6 +59,7 @@ public class DramaCategoryController {
return Result.ok();
}
@Operation(summary = "删除分类")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧类型")
@@ -0,0 +1,126 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaClassificationRespVO;
import com.fruitshorts.content.vo.DramaClassificationSaveReqVO;
import com.fruitshorts.content.vo.DramaIdsReqVO;
import com.fruitshorts.content.vo.DramaRankingItemsReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 运营分类管理。分类与短剧类型(drama-categories)是两套独立概念,
* 分类可自定义增删,并关联任意多部短剧。
*/
@Tag(name = "后台-运营分类")
@RestController
@RequestMapping("/admin/drama-classifications")
@RequiredArgsConstructor
public class DramaClassificationController {
private final DramaClassificationService classificationService;
@Operation(summary = "分类列表(带关联数量)")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaClassificationRespVO>> list() {
return Result.ok(classificationService.list());
}
@Operation(summary = "新建分类")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增分类")
public Result<Long> create(@Valid @RequestBody DramaClassificationSaveReqVO req) {
return Result.ok(classificationService.create(req));
}
@Operation(summary = "编辑分类")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑分类")
public Result<Void> update(@PathVariable Long id,
@Valid @RequestBody DramaClassificationSaveReqVO req) {
classificationService.update(id, req);
return Result.ok();
}
@Operation(summary = "删除分类")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除分类")
public Result<Void> delete(@PathVariable Long id) {
classificationService.delete(id);
return Result.ok();
}
@Operation(summary = "分类下已关联的短剧")
@GetMapping("/{id}/dramas")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaBriefVO>> dramas(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(classificationService.pageDramas(id, keyword, pageIndex, pageSize));
}
@Operation(summary = "尚未关联到该分类的短剧")
@GetMapping("/{id}/dramas/candidates")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaBriefVO>> candidates(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(classificationService.pageCandidates(id, keyword, pageIndex, pageSize));
}
@Operation(summary = "批量关联短剧")
@PostMapping("/{id}/dramas")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "分类关联短剧")
public Result<Integer> bindDramas(@PathVariable Long id,
@Valid @RequestBody DramaIdsReqVO req) {
return Result.ok(classificationService.bindDramas(id, req.getDramaIds()));
}
@Operation(summary = "保存推荐榜完整内容与顺序")
@PutMapping("/{id}/ranking-items")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "保存推荐榜顺序")
public Result<Void> saveRankingItems(@PathVariable Long id,
@Valid @RequestBody DramaRankingItemsReqVO req) {
classificationService.saveRankingItems(id, req.getDramaIds());
return Result.ok();
}
@Operation(summary = "移除分类下的短剧")
@DeleteMapping("/{id}/dramas/{dramaId}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "分类移除短剧")
public Result<Void> unbindDrama(@PathVariable Long id, @PathVariable Long dramaId) {
classificationService.unbindDrama(id, dramaId);
return Result.ok();
}
}
@@ -6,12 +6,16 @@ import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import com.fruitshorts.content.vo.DramaStatusReqVO;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.interaction.service.CommentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,26 +29,37 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-短剧管理")
@RestController
@RequestMapping("/admin/dramas")
@RequiredArgsConstructor
public class DramaController {
private final DramaService dramaService;
private final CommentService commentService;
private final EpisodeService episodeService;
@Operation(summary = "短剧分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
return Result.ok(dramaService.page(req));
PageResult<DramaRespVO> page = dramaService.page(req);
fillInteractionCounts(page.getList());
return Result.ok(page);
}
@Operation(summary = "短剧详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<DramaRespVO> get(@PathVariable Long id) {
return Result.ok(dramaService.get(id));
DramaRespVO drama = dramaService.get(id);
fillInteractionCounts(List.of(drama));
return Result.ok(drama);
}
@Operation(summary = "新建短剧")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新建短剧")
@@ -52,6 +67,7 @@ public class DramaController {
return Result.ok(dramaService.create(req, SecurityUtils.getUserId()));
}
@Operation(summary = "编辑短剧")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧")
@@ -60,6 +76,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "删除短剧")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧")
@@ -69,6 +86,7 @@ public class DramaController {
}
/** 提交审核:内容运营即可操作。 */
@Operation(summary = "提交审核")
@PostMapping("/{id}/submit")
@PreAuthorize("@ss.hasAnyPermission('drama:review','drama:update')")
@OperationLog(module = "内容管理", action = "提交审核")
@@ -77,6 +95,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "审核通过")
@PostMapping("/{id}/approve")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "审核通过")
@@ -85,6 +104,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "审核驳回")
@PostMapping("/{id}/reject")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "驳回短剧")
@@ -93,6 +113,16 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "审核通过并上线")
@PostMapping("/{id}/approve-publish")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "审核通过并上线")
public Result<Void> approvePublish(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
changeStatus(id, "APPROVE_PUBLISH", req);
return Result.ok();
}
@Operation(summary = "上线")
@PostMapping("/{id}/publish")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧上线")
@@ -101,6 +131,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "下线")
@PostMapping("/{id}/offline")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧下线")
@@ -109,6 +140,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "审核记录")
@GetMapping("/{id}/review-records")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaReviewRecord>> reviewRecords(@PathVariable Long id) {
@@ -119,4 +151,17 @@ public class DramaController {
dramaService.changeStatus(id, action, req == null ? null : req.getReason(),
SecurityUtils.getUserId(), SecurityUtils.getUsername());
}
private void fillInteractionCounts(List<DramaRespVO> dramas) {
if (dramas == null || dramas.isEmpty()) {
return;
}
List<Long> ids = dramas.stream().map(DramaRespVO::getId).toList();
Map<Long, Long> likeCounts = episodeService.sumLikeCountsByDramas(ids);
Map<Long, Long> commentCounts = commentService.countByDramas(ids);
dramas.forEach(drama -> {
drama.setLikeCount(likeCounts.getOrDefault(drama.getId(), 0L));
drama.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L));
});
}
}
@@ -0,0 +1,115 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.service.DramaPlaylistCategoryService;
import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaIdsReqVO;
import com.fruitshorts.content.vo.DramaPlaylistCategoryRespVO;
import com.fruitshorts.content.vo.DramaPlaylistCategorySaveReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 剧单分类管理:分类直接关联短剧,没有「剧单」这一层。
* 与「类型」「分类管理」均无关。
*/
@Tag(name = "后台-剧单分类")
@RestController
@RequestMapping("/admin/drama-playlist-categories")
@RequiredArgsConstructor
public class DramaPlaylistCategoryController {
private final DramaPlaylistCategoryService categoryService;
@Operation(summary = "剧单分类列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaPlaylistCategoryRespVO>> list() {
return Result.ok(categoryService.list());
}
@Operation(summary = "新建剧单分类")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增剧单分类")
public Result<Long> create(@Valid @RequestBody DramaPlaylistCategorySaveReqVO req) {
return Result.ok(categoryService.create(req));
}
@Operation(summary = "编辑剧单分类")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑剧单分类")
public Result<Void> update(@PathVariable Long id,
@Valid @RequestBody DramaPlaylistCategorySaveReqVO req) {
categoryService.update(id, req);
return Result.ok();
}
@Operation(summary = "删除剧单分类")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除剧单分类")
public Result<Void> delete(@PathVariable Long id) {
categoryService.delete(id);
return Result.ok();
}
@Operation(summary = "分类下已关联的短剧")
@GetMapping("/{id}/dramas")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaBriefVO>> dramas(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(categoryService.pageDramas(id, keyword, pageIndex, pageSize));
}
@Operation(summary = "尚未关联到该分类的短剧")
@GetMapping("/{id}/dramas/candidates")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaBriefVO>> candidates(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(categoryService.pageCandidates(id, keyword, pageIndex, pageSize));
}
@Operation(summary = "批量关联短剧")
@PostMapping("/{id}/dramas")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "剧单分类关联短剧")
public Result<Integer> bindDramas(@PathVariable Long id,
@Valid @RequestBody DramaIdsReqVO req) {
return Result.ok(categoryService.bindDramas(id, req.getDramaIds()));
}
@Operation(summary = "移除分类下的短剧")
@DeleteMapping("/{id}/dramas/{dramaId}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "剧单分类移除短剧")
public Result<Void> unbindDrama(@PathVariable Long id, @PathVariable Long dramaId) {
categoryService.unbindDrama(id, dramaId);
return Result.ok();
}
}
@@ -8,6 +8,8 @@ import com.fruitshorts.commerce.vo.EntitlementGrantReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台权益:检索、人工补发、回收。 */
@Tag(name = "后台-权益管理")
@RestController
@RequestMapping("/admin/entitlements")
@RequiredArgsConstructor
@@ -28,6 +31,7 @@ public class EntitlementAdminController {
private final EntitlementService entitlementService;
@Operation(summary = "权益分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('entitlement:list')")
public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId,
@@ -39,6 +43,7 @@ public class EntitlementAdminController {
}
/** 客服补偿:直接给用户某集/整剧的播放权益。 */
@Operation(summary = "补发权益(客服补偿)")
@PostMapping
@PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "人工补发权益")
@@ -50,6 +55,7 @@ public class EntitlementAdminController {
}
/** 回收权益:退款、违规处置后用。 */
@Operation(summary = "回收权益(退款 / 违规处置)")
@PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "回收权益")
@@ -2,12 +2,18 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import com.fruitshorts.content.vo.EpisodeSortReqVO;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.interaction.service.CommentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -22,21 +28,39 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-剧集管理")
@RestController
@RequestMapping("/admin/episodes")
@RequiredArgsConstructor
public class EpisodeController {
private final EpisodeService episodeService;
private final CommentService commentService;
private final DramaService dramaService;
@Operation(summary = "剧集列表(分页)")
@GetMapping
@PreAuthorize("@ss.hasPermission('episode:list')")
public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId,
@RequestParam(required = false) String status) {
return Result.ok(episodeService.listByDrama(dramaId, status));
public Result<PageResult<EpisodeRespVO>> list(@RequestParam Long dramaId,
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer pageIndex,
@RequestParam(required = false) Integer pageSize) {
PageResult<EpisodeRespVO> page = episodeService.pageByDrama(dramaId, status, pageIndex, pageSize);
List<EpisodeRespVO> episodes = page.getList();
Map<Long, Long> commentCounts = commentService.countByEpisodes(
episodes.stream().map(EpisodeRespVO::getId).toList());
Drama drama = dramaService.getExisting(dramaId);
episodes.forEach(episode -> {
episode.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
episode.setFavoriteCount(drama.getFavoriteCount());
episode.setHeat(drama.getViewCount());
});
return Result.ok(new PageResult<>(page.getTotal(), episodes));
}
@Operation(summary = "新建剧集")
@PostMapping
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "新增剧集")
@@ -44,6 +68,7 @@ public class EpisodeController {
return Result.ok(episodeService.create(req));
}
@Operation(summary = "编辑剧集")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "编辑剧集")
@@ -52,6 +77,7 @@ public class EpisodeController {
return Result.ok();
}
@Operation(summary = "删除剧集")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "删除剧集")
@@ -60,6 +86,7 @@ public class EpisodeController {
return Result.ok();
}
@Operation(summary = "剧集排序")
@PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('episode:sort')")
@OperationLog(module = "内容管理", action = "调整剧集顺序")
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.EpisodeTranscodeAdminService;
import com.fruitshorts.admin.vo.EpisodeTranscodeRespVO;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台剧集转码聚合查询。 */
@Tag(name = "后台-剧集转码")
@RestController
@RequestMapping("/admin/episodes")
@RequiredArgsConstructor
public class EpisodeTranscodeController {
private final EpisodeTranscodeAdminService service;
@Operation(summary = "查询分集转码聚合信息")
@GetMapping("/{id}/transcode")
@PreAuthorize("@ss.hasPermission('episode:list')")
public Result<EpisodeTranscodeRespVO> detail(@PathVariable Long id) {
return Result.ok(service.detail(id));
}
}
@@ -0,0 +1,34 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.FavoriteAdminService;
import com.fruitshorts.admin.vo.AdminFavoriteVO;
import com.fruitshorts.admin.vo.FavoriteQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台收藏只读查询入口。 */
@Tag(name = "后台-收藏列表")
@RestController
@RequestMapping("/admin/favorites")
@RequiredArgsConstructor
public class FavoriteAdminController {
private final FavoriteAdminService service;
/** 按用户、设备、内容和时间分页查询收藏。 */
@Operation(summary = "收藏列表分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('favorite:list')")
public Result<PageResult<AdminFavoriteVO>> page(@Valid FavoriteQueryReqVO req) {
return Result.ok(service.page(req));
}
}
@@ -0,0 +1,47 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.service.LegalDocumentService;
import com.fruitshorts.system.vo.LegalDocumentVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 后台维护服务条款和隐私协议。公开页面由客户端服务按语言输出。
*/
@Tag(name = "后台-协议")
@RestController
@RequestMapping("/admin/legal-documents")
@RequiredArgsConstructor
public class LegalDocumentAdminController {
private final LegalDocumentService legalDocumentService;
@Operation(summary = "协议列表(服务条款、隐私协议,各含中英印尼语)")
@GetMapping
@PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')")
public Result<List<LegalDocumentVO>> list() {
return Result.ok(legalDocumentService.list());
}
@Operation(summary = "保存协议")
@PutMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改服务条款或隐私协议")
public Result<Void> save(@RequestBody List<LegalDocumentVO> items) {
legalDocumentService.save(items);
return Result.ok();
}
}
@@ -0,0 +1,34 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.LikeAdminService;
import com.fruitshorts.admin.vo.AdminLikeVO;
import com.fruitshorts.admin.vo.LikeQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台点赞只读查询入口。 */
@Tag(name = "后台-点赞列表")
@RestController
@RequestMapping("/admin/likes")
@RequiredArgsConstructor
public class LikeAdminController {
private final LikeAdminService service;
/** 按用户、设备、内容和时间分页查询点赞。 */
@Operation(summary = "点赞列表分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('like:list')")
public Result<PageResult<AdminLikeVO>> page(@Valid LikeQueryReqVO req) {
return Result.ok(service.page(req));
}
}
Loaded 100 of 607 files, more files were not shown because too many files have changed in this diff. Show more