Author SHA1 Message Date
Zhou Weidong 7dc4345060 feat: 印尼语敏感词库种子数据(id-ID 增量补全 78 词) 2026-10-17 09:36:07 +08:00
Zhou Weidong abea0a6c39 feat: 合规红线(提交审核接入敏感词检查 + 内容审核抽象 + 合规声明) 2026-10-17 03:03:51 +08:00
Zhou Weidong 9d92fa2a73 feat: C3 埋点事件上报(/app/events 匿名落库) 2026-10-17 02:55:46 +08:00
Zhou Weidong 952057ac51 feat: C2 Redis 接入 + 接口限流(RedisService + @RateLimit,验证码/登录防刷) 2026-10-17 02:49:10 +08:00
Zhou Weidong f03fcdede5 feat: B1 Google Play Billing 票据校验
- 新增 GooglePlayVerifier(调 Google Play Developer API 二次校验票据)
- StoreVerifier.verify 扩展 productId 参数,订单/控制器链路同步
- DisabledStoreVerifier 加条件装配,Google 启用后自动切换
- application.yml 增加 pay.google 配置(凭证经环境变量注入,默认关闭)
2026-10-17 02:35:36 +08:00
Zhou Weidong bbf46b3445 feat: 主键自增改雪花 ID(为分库分表预留)
- 52 个 entity 主键 @TableId 由 IdType.AUTO 改为 IdType.ASSIGN_ID(雪花)
- 双服务 application.yml 的 mybatis-plus id-type 由 auto 改为 assign_id
- schema 脚本主键去掉 GENERATED BY DEFAULT AS IDENTITY
2026-10-17 02:26:43 +08:00
Zhou Weidong 5f9483a86c feat: 接口字段补 @Schema 中文说明 + 精简冗余字段 + 接入 Prometheus 监控
- 全部 VO 字段补 @Schema(description) 中文说明,在线文档字段可读
- AppEpisodeVO 精简 duration/isFree 冗余字段(保留 durationSec/locked)
- framework 引入 actuator + micrometer-registry-prometheus
- SecurityConfig 放行 /actuator/prometheus,双服务配置 management 暴露指标
- 新增 deploy/docker-compose.yml + prometheus.yml 监控部署配置
2026-10-17 02:24:25 +08:00
347 changed files with 1849 additions and 42472 deletions

No files matched your search

-13
View File
@@ -1,13 +0,0 @@
# Author: fruit.com.cn
#
# 构建上下文里不需要的东西:产物、IDE、本地上传的媒资、日志。
# 注意别把 pom.xml / src / sql 排掉,镜像构建要用。
**/target/
.git/
.gitignore
.idea/
*.iml
*.log
data/
docs/
node_modules/
-41
View File
@@ -1,41 +0,0 @@
# 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=
-3
View File
@@ -16,6 +16,3 @@ logs/
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
data/uploads/*
!data/uploads/.gitkeep
# 部署用的真实环境变量(含密钥),只提交 .env.example
.env
-256
View File
@@ -1,256 +0,0 @@
# 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
@@ -1,208 +0,0 @@
# 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 # 本机一键冒烟
```
-72
View File
@@ -1,72 +0,0 @@
# 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
# 先只拷各模块 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 || true
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"]
+17 -87
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,7 +44,6 @@ 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"`。
@@ -52,13 +51,8 @@ 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 上构建与运行。
@@ -191,43 +185,18 @@ java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar
## 第一阶段实现进度
> 最后更新:2026-10-16(后端 `develop` 分支 `4418a84`)。**未完成清单见文末「尚未完成」。**
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护;**雪花主键(S0)**、**Swagger 在线文档 `/doc.html`(S1)** |
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护 |
| 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 |
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;内容分级、可播区域、版权窗口、标签字段 |
| 04 播放器与播放控制 | 已实现 | 播放票据 + 签名地址 + 进度;多码率 HLS 由转码 worker 产出(见 10) |
| 05 记录、收藏与下载 | 已实现 | 进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 已实现 | 评论(游标翻页/审核/删除)、点赞、举报、敏感词;**分享埋点待 C3** |
| 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`)。
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;补内容分级、可播区域、版权窗口、标签字段 |
| 04 播放器与播放控制 | 部分 | 播放票据 + 签名地址 + 进度已实现;多码率片源待转码 worker |
| 05 记录、收藏与下载 | 部分 | 进度、观看流水、收藏已实现;离线下载授权待做 |
| 06 评论、点赞与分享 | 待做 | 表已建,接口与实现待补 |
| 07 创作者中心 | 待做 | 表已建;转码任务与多码率产物记账已实现 |
| 08 平台运营后台 | 部分 | App 用户管理已实现;内容治理、订单、看板待做 |
| 09 分集收费与支付 | 待做 | 表已建;播放入口的权益判定端口已预留,接上即生效 |
| 10 质量、数据与上线 | 待做 | 压测、埋点、对账待做 |
## 默认账号
@@ -424,54 +393,15 @@ public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) { ...
6. 表格状态列改用接口返回的 `status` / `statusText`,
大数值用 `src/utils/format.js` 的 `formatCount` 格式化。
## 尚未完成
## 尚未实现
> 最后更新:2026-10-16。完成后请同步本表与 [docs/api-phase1.md](docs/api-phase1.md)。
评论点赞分享、订单与钱包权益(IAP / Google Play Billing 票据校验、对账、退款回收)、
创作者中心、内容治理与经营看板、转码 worker(消费 `transcode_task`,产出多码率 HLS)、
Redis 缓存与限流、离线下载授权、埋点与数据看板。
### 一、后端研发(剩余)
| # | 项 | 内容 | 依据 |
| --- | --- | --- | --- |
| 1 | **C2 Redis 缓存与限流** | 首页/发现/分类等热点接口缓存(Cache-Aside)+ 验证码/注册/登录限流;生产 Redis 规格 `4C16G 主从 2~3 哨兵` | 任务清单 C2、`17-风险评估` R-10、`10-安全方案` §三 |
| 2 | **C3 埋点** | 播放/支付/转化事件 + 数据字典对齐(含 06 的分享埋点) | 任务清单 C3、`api-phase1` §3 |
| 3 | **Apple IAP 票据校验** | 目前只留端口与枚举,未做真实校验(印尼 iOS 占比低,文档口径为"预留") | `api-phase1` §1.8 |
| 4 | **压测** | 目标 `P95 < 50ms`(预热后);容量口径文档里有两套("1 万并发"与"2 万并发 + 3000 QPS"),需定稿 | `09-测试计划` §五、`13/14-容量` |
| 5 | **对账数据源** | 结算金额/手续费/税只能从 Play Console 财务报告取(需报表权限);当前 `storeAmountSum` 恒为 `null` + `notes` 说明 | B1-2 待确认 |
| 6 | **退款口径完善** | 部分退款按比例回收、退款实时性(当前由每日对账 + 手动驱动,未接 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`,
> **两份看板问题已从根上消失,该方案不再需要**。
这些模块的表结构、接口契约、状态机与权限点都已经冻结,见
[docs/api-phase1.md](docs/api-phase1.md) 里标注「下一批」的部分 —— 接下来是照契约填代码,
不会再有结构性改动。
## 常见问题
-11
View File
@@ -1,11 +0,0 @@
# 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
+32
View File
@@ -0,0 +1,32 @@
# Author: fruit.com.cn
# 监控组件编排:Prometheus + Grafana(Loki 日志后续接入)
services:
prometheus:
image: prom/prometheus:latest
container_name: fruitshorts-prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: fruitshorts-grafana
ports:
- "3000:3000"
volumes:
- grafana-data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
restart: unless-stopped
volumes:
prometheus-data:
grafana-data:
+16
View File
@@ -0,0 +1,16 @@
# Author: fruit.com.cn
# Prometheus 抓取配置:抓取后端双服务的 actuator 指标
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: "fruitshorts-app-api"
metrics_path: "/actuator/prometheus"
static_configs:
- targets: ["host.docker.internal:8081"]
- job_name: "fruitshorts-admin-api"
metrics_path: "/actuator/prometheus"
static_configs:
- targets: ["host.docker.internal:8080"]
-218
View File
@@ -1,218 +0,0 @@
# 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
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
ports:
- "8081:8081"
volumes:
# 媒资落盘:三个服务共享同一个卷
- ./data/uploads:/data/uploads
depends_on:
db:
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"
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005"
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
# 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名),
# 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。
FRUITSHORTS_MEDIA_PROTECTED_PREFIXES: ""
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}}
volumes:
# 与两个入口同一个媒资根,否则找不到源文件
- ./data/uploads:/data/uploads
depends_on:
db:
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"
TZ: Asia/Shanghai
depends_on:
- prometheus
# 黑盒探活:从外部探测接口可用性与耗时
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:
+25 -404
View File
@@ -16,29 +16,6 @@
`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。
**ID 字段一律以字符串下发(2026-10-16 J1 起,全局生效)**
所有 ID 字段(主键与外键,例如 `id` / `dramaId` / `episodeId` / `userId` / `orderId` …)
在 JSON 里都是**字符串**,不是数字:
```json
{ "code": 0, "message": "ok", "data": { "id": "2111012753262120962", "dramaId": "5" } }
```
原因:S0 之后主键是雪花 id,19 位十进制数已超过 JS 的 `Number.MAX_SAFE_INTEGER`
(`9007199254740991`)。裸数字下发会被浏览器/IEEE754 客户端四舍五入
(实测库里的 `2111012753262120962` 在浏览器里变成 `2111012753262121000`),
页面再拿它回查就是 `200002 数据不存在`。
约定与边界:
- **id 回传时按字符串传**(路径变量 / 查询参数 / 请求体都接受字符串,服务端自己转 `Long`);
- 实现方式:服务端**全局**把 `Long` / `long` 序列化成字符串(`fruitshorts-framework` 的
`JacksonLongToStringConfig`),**各模块不再逐字段加注解**;
- **例外:分页 `total` 仍是数字** —— 它是条数不是 id,前端分页组件要数字;
`Integer` / `int` 字段(`pageIndex` / `pageSize` / 各类计数如 `totalEpisodes`、状态码 `code`)
也**保持数字**。
**鉴权**:`Authorization: Bearer <token>`。
管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。
客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。
@@ -97,44 +74,6 @@ POST /app/auth/register
}
```
**邮箱 + 密码注册(C 端第一批口径)**
```json
POST /app/auth/register
{
"targetType": "EMAIL", "target": "User@Example.com ", "password": "nonton2026",
"deviceId": "d-1", "countryCode": "ID", "agreementVersion": "2026-10-01"
}
```
| 规则 | 口径 |
| --- | --- |
| `targetType=EMAIL` | **`code` 可以不传**(邮箱+密码直接注册);传了就走一次验证码校验(邮箱验证能力保留) |
| `targetType=PHONE` | 维持原状:`code` **必填** |
| 邮箱规范化 | 去空格 + 转小写(`batch1@Example.com ` → `batch1@example.com`);格式不合法直接 `400 邮箱格式不正确` |
| 密码强度 | 8–64 位,且**同时包含字母与数字**(不强制特殊字符:低端机输入成本高,拦机器注册靠频控) |
| 注册频控 | 同 IP / 同设备 24h 默认上限分别为 5 / 3 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 |
频控口径落库在 `app_user.register_ip` / `app_user.register_device_id` 上(按 24h 计数,不新建计数表)。
注册频控阈值存储在 `sys_config`,可在后台「系统配置」修改:
```text
app.register.max-per-ip-per-day
app.register.max-per-device-per-day
```
运行时优先读取 `sys_config`。键缺失或值不是整数时,回退 `application.yml`
中的 `fruitshorts.auth.register-max-per-ip-per-day` /
`fruitshorts.auth.register-max-per-device-per-day`,缺省为 5 / 3;
值小于等于 0 表示对应维度不限制。
开发种子配置为 0 / 0,生产建议恢复为 5 / 3。
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。
响应(登录、刷新同结构):
```json
@@ -165,88 +104,10 @@ app.register.max-per-device-per-day
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集 |
| GET | `/app/discover/feed` | 发现页分区 |
| GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 |
| GET | `/app/categories` | 分类列表 |
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
>
> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串)。
> `favoriteCount` 取整部剧;`likeCount` / `commentCount` 取卡片 `preview` 当前集的
> `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`);
> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
>
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`,
> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。
#### 推荐类型流(`GET /app/feed`)
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。
- `categoryId` 可选;不传返回全部分类。传入后按 `category_ids` 整词匹配,
可与 `types` 叠加,只返回同时命中内容类型和分类的已上线短剧。
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
游标短剧必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
#### 短剧详情响应(`GET /app/dramas/{id}`)
```json
{
"code": 0, "message": "ok",
"data": {
"id": "5",
"title": "命运的相遇",
"synopsis": "一次意外的相遇,让两个世界的人走到一起。",
"cover": "/files/drama/202610/cover.jpg",
"genre": "都市 · 情感",
"category": "都市",
"totalEpisodes": 80,
"freeEpisodes": 10,
"unlockCoin": 30,
"isHot": true,
"viewCount": "12400",
"likeCount": "3200",
"favoriteCount": "1800",
"commentCount": "96",
"episodes": [
{
"id": "2111012753262120962",
"number": 1,
"title": "第 1 集",
"duration": "03:00",
"durationSec": 180,
"isFree": true,
"locked": false,
"unlockCoin": 0,
"likeCount": "45",
"commentCount": "8",
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..."
}
]
}
}
```
详情里的互动数量:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `likeCount` | Long(JSON 字符串) | 剧级历史汇总,保留兼容;新客户端按集使用 `episodes[].likeCount` |
| `favoriteCount` | Long(JSON 字符串) | 取 `drama.favorite_count`,短剧累计收藏数 |
| `commentCount` | Long(JSON 字符串) | `content_comment` 中该剧未删除评论数:按 `drama_id` 统计,排除 `status = 'deleted'` 与逻辑删除记录 |
| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 |
| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` |
| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 |
计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发,
客户端需要转成数字再展示(与 `viewCount` 相同)。
### 1.4 播放(已实现,链路已重做)
@@ -264,9 +125,7 @@ app.register.max-per-device-per-day
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
"qualities": ["AUTO"],
"unlockCoin": 0,
"previewSeconds": 0,
"likeCount": "45",
"commentCount": "8"
"previewSeconds": 0
}
}
```
@@ -320,9 +179,8 @@ app.register.max-per-device-per-day
| --- | --- | --- | --- |
| POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 |
| GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 |
| GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 |
| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) |
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;新客户端点赞按集 |
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `COMMENT` |
| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 |
**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。
@@ -338,155 +196,24 @@ app.register.max-per-device-per-day
| --- | --- | --- |
| GET | `/app/wallet` | 金币余额 + 最近流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
| GET | `/app/products` | 充值商品列表(只返回上架商品,`type` 可选 `COIN_PACK`/`VIP`) |
| POST | `/app/orders` | 创建充值订单(`body` 传 `productId`,返回 `CREATED` 订单,等待支付) |
| GET | `/app/orders` | 我的订单 |
| POST | `/app/orders/verify` | 提交 Google Play 票据:服务端校验 → 建单 → 发权益(详见下方说明) |
> `GET /app/products` 只返回 `status=1`(上架)的商品,按 `sort` 升序、`id` 降序。
> `POST /app/orders` 请求体 `{ "productId": 123 }`,商品下架时返回 `400 商品已下架`;
> 订单 `coinAmount = coinAmount + bonusCoin`(与商店票据口径一致),状态为 `CREATED`。
> VIP 商品下单后交付时发放 `TYPE_VIP` 权益(`dramaId=0`、`episodeId=0`,永久有效),
> 播放/下载的权益判定在 `EntitlementPort` 中增加「存在有效 VIP 权益即放行」的判定。
| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据。**本轮只留端口不接商店**,会返回 `200003 尚未配置` |
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
**`POST /app/orders/verify`(B1-1:Google Play 票据校验闭环)**
### 1.9 创作者(下一批)
请求体:
```json
{ "platform": "GOOGLE_PLAY", "productId": "coin_550", "receipt": "<purchaseToken>" }
```
| 字段 | 说明 |
| --- | --- |
| `platform` | `GOOGLE` / `GOOGLE_PLAY`(两者等价,入库统一记 `GOOGLE`);Apple 走 B1-2 |
| `productId` | 商店商品 id(Google Play 的 `purchases.products.get` 必需),与本平台 `product.store_product_id` 对得上才发权益 |
| `receipt` | Google Play 的 `purchaseToken` |
行为与错误码:
| 场景 | 返回 |
| --- | --- |
| 校验通过 | `code=0`,`data` 是订单(`status=DELIVERED`、`coinAmount`=商品金币+赠送、`storeTransactionId`=Google `orderId`) |
| 同一票据**重复**提交(同用户) | `code=0` 返回**既有订单**,不重复发币、不重复建单 |
| 票据已绑定**其他账号** | `403 该票据已绑定其他账号,不能重复使用`(防跨用户重放) |
| 票据无效 / 未购买 / 商品不一致 / 缺 orderId | `200003` + 具体原因;**不产生订单、不发权益** |
| 商品未在后台配置 | `200002 该商品未在后台配置` |
| 平台不在支持范围 | `400 不支持的商店平台` |
| 商店未配置(默认 `verifier=disabled`) | `200003 Apple / Google 商店校验尚未配置`(与 B1-1 之前的行为一致) |
幂等键是 `(platform, store_transaction_id)`(DB 唯一索引 `uk_orders_store_txn`),
重复/并发提交由数据库兜底;票据审计写 `store_receipt`(唯一索引 `uk_store_receipt(platform, transaction_id)`),
B1-2 的退款/拒付复验依赖它。服务端凭据(`packageName` + service account JSON)走配置/环境变量,不入库。
**恢复购买**:客户端换设备后重新提交同一个 `purchaseToken`(或调用 `GET /app/orders` 拉已购列表),
服务端按 `(platform, storeTransactionId)` 识别"权益已发过",**返回既有订单、不重复发币**;
若商店侧有购买而本地没有订单(例如换机前没同步),会**按校验结果补单落库**再发放,不需要人工介入。
### 1.9 创作者入驻、启动配置与公告(C 端,已实现)
> 说明 1:本节原来把 `/app/orders`、`/app/wallet`、`/app/episodes/{id}/unlock` 也列在这里,
> 那三条**属「金币与订单」,已在 §1.8 实现并冻结**,因此已从本节移出。
>
> 说明 2(**口径变更 2026-10-16**):**创作者中心(工作台 / 作品管理 / 数据看板)由 Web 端承载** ——
> 创作者登录后台管理使用,见 §2.10;**App 客户端不提供创作者中心**。
> 因此本节**已移除** `GET /app/creator/works`、`GET /app/creator/stats` 两条原定义。
> C 端只保留「入驻申请」+「启动配置」+「系统公告」,下面三条即全部。
| 方法 | 路径 | 鉴权 | 说明 |
| --- | --- | --- | --- |
| POST | `/app/creator/apply` | 登录 | 创作者入驻申请(**幂等**:重复提交返回当前状态,不重复建档) |
| GET | `/app/config` | **匿名** | 启动配置(协议版本、最低版本、开关;**只下发白名单键**) |
| GET | `/app/notices` | **匿名** | 系统公告(无数据返回空列表,不是 404) |
#### 1.9.1 `GET /app/config`(匿名)
客户端**登录前**就要拿,用于:协议版本校验、强制更新判断、功能开关。
```json
{
"code": 0, "message": "ok",
"data": {
"agreementVersion": "2026-10-01",
"minAppVersion": "1.0.0",
"forceUpdate": false,
"noticeEnabled": true,
"coinUnlockEnabled": true,
"downloadEnabled": true
}
}
```
| 键(对客户端字段) | 后台配置键 | 契约默认值 | 说明 |
| --- | --- | --- | --- |
| `agreementVersion` | `app.agreement.version` | `2026-10-01` | 当前协议版本;`/app/creator/apply` 必须与它一致 |
| `minAppVersion` | `app.min.version` | `1.0.0` | 低于它客户端应提示升级 |
| `forceUpdate` | `app.force.update` | `false` | 是否强制更新 |
| `noticeEnabled` | `app.notice.enabled` | `true` | 公告入口开关 |
| `coinUnlockEnabled` | `app.coin.unlock.enabled` | `true` | 金币解锁开关 |
| `downloadEnabled` | `app.download.enabled` | `true` | 离线下载开关 |
**口径**:**只下发上述白名单键**(服务端硬编码白名单),后台配置里的其它键(可能含内部开关)
**一律不下发**;库里缺键时用「契约默认值」兜底,**不返回 null 让客户端自己猜默认值**。
#### 1.9.2 `GET /app/notices`(匿名)
```
GET /app/notices?pageIndex=1&pageSize=20
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `pageIndex` | int | 否 | 默认 1 |
| `pageSize` | int | 否 | 默认 20,最大 50 |
```json
{
"code": 0, "message": "ok",
"data": {
"total": 1,
"list": [
{ "id": 2110123456789, "title": "开服公告", "content": "…", "type": "SYSTEM",
"publishAt": "2026-10-16 10:00:00", "expireAt": null }
]
}
}
```
**口径**:只返回 `status=1`(启用)且 `target ∈ {ALL, USER}`、`publish_at <= 现在`、
`expire_at` 为空或未过期的公告;按 `publish_at desc, id desc` 排序。
**表里没有数据时返回 `total=0, list=[]`(HTTP 与 code 都是成功),不是 404。**
#### 1.9.3 `POST /app/creator/apply`(登录)
```json
{
"name": "星野工作室", "type": "COMPANY",
"contactName": "张三", "contactMobile": "08123456789", "contactEmail": "creator@example.com",
"countryCode": "ID", "agreementVersion": "2026-10-01"
}
```
| 字段 | 必填 | 校验 |
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `name` | ✅ | 1–128 字 |
| `type` | 否 | `INDIVIDUAL`(默认)/ `COMPANY` |
| `contactName` / `contactMobile` / `contactEmail` | 否 | 只是联系方式,不参与鉴权 |
| `countryCode` | 否 | 默认 `ID` |
| `agreementVersion` | ✅ | **必须等于 `/app/config` 的 `agreementVersion`**,否则 `400` |
**幂等口径(硬要求)**:以「**C 端账号唯一**」防重(DB 唯一索引 `uk_creator_user(user_id) where user_id > 0`):
| 情况 | 行为 |
| --- | --- |
| 首次申请 | 建档,`status=pending`,返回 `{id, code, status, statusText, auditNote, appliedAt}` |
| 已申请(pending) | **返回当前状态**,不重复建档、不改动已有记录 |
| 已通过(approved) | 返回 `status=approved`(**创作者工作台在 Web 端**,见 §2.10;App 端只提示"已通过,请到创作者后台使用") |
| 被驳回 / 冻结 | 返回当前状态与 `auditNote`(是否允许重新申请由运营在后台处理,本期不做二次申请) |
| 并发重复提交 | 唯一索引兜底:捕获冲突后**读回已有记录返回**,不报 500 |
| GET | `/app/orders` | 我的订单 |
| GET | `/app/wallet` | 金币余额与流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集 |
| POST | `/app/creator/apply` | 创作者入驻 |
| GET | `/app/creator/works` | 我的作品与审核状态 |
| GET | `/app/creator/stats` | 创作者数据看板 |
| GET | `/app/config` | 启动配置(协议版本、开关、最低版本) |
| GET | `/app/notices` | 系统公告 |
## 2. 后台管理接口(/admin/**)
@@ -533,10 +260,10 @@ GET /app/notices?pageIndex=1&pageSize=20
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态、`dramaTypes[]`、`categoryIds[]` 多选筛选) |
| GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态筛选) |
| GET | `/admin/dramas/{id}` | `drama:list` | 详情 |
| POST | `/admin/dramas` | `drama:create` | 新建短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
| POST | `/admin/dramas` | `drama:create` | 新建短剧 |
| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧 |
| DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 |
| POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 |
| POST | `/admin/dramas/{id}/approve` | `drama:review` | 审核通过 |
@@ -545,15 +272,6 @@ GET /app/notices?pageIndex=1&pageSize=20
| POST | `/admin/dramas/{id}/offline` | `drama:review` | 下线 |
| GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 |
多选字段说明:
- 保存优先使用 `dramaTypes[]` / `categoryIds[]`,去重并保持提交顺序;
`drama_type` / `category_id` 写第一个值,供旧客户端与旧接口兼容。
- 仅传 `dramaType` / `categoryId` 时保持原单值行为。
- 列表响应返回 `dramaTypes[]`、`categoryIds[]` 与按选中顺序映射的 `categoryNames[]`;
分类名缺失时回退 `genre`。
- 后台与 App 过滤均使用 `(',' || column || ',') LIKE '%,value,%'` 整词匹配。
**剧集**
| 方法 | 路径 | 权限点 | 说明 |
@@ -605,7 +323,6 @@ GET /app/notices?pageIndex=1&pageSize=20
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
| GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 |
| GET | `/admin/app-users/{id}/watch-history` | `app-user:list` | 观看流水明细(带剧名/集号,客服核对用) |
| GET | `/admin/watch-history/page` | `watch-history:list` | 跨用户观看记录分页:keyword、userId、dramaTitle、dramaId、episodeId、source、finished、startTime、endTime |
| PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned |
状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。
@@ -623,18 +340,15 @@ GET /app/notices?pageIndex=1&pageSize=20
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/comments/page` | `comment:list` | 检索评论;`status` 为空返回全部,`pending` 即审核队列 |
| GET | `/admin/comments/page` | `comment:list` | 检索评论,`status=pending` 即审核队列 |
| GET | `/admin/comments/{id}/audit-records` | `comment:list` | 审核留证:机审与人审的完整链路 |
| PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) |
| DELETE | `/admin/comments/{id}` | `comment:audit` | 删除评论 |
| GET | `/admin/reports/page` | `report:list` | 举报工单检索(状态 / 类型 / 原因),返回举报人、剧名、集号、集标题与目标摘要 |
| GET | `/admin/reports/page` | `report:list` | 举报工单检索(状态 / 类型 / 原因) |
| PUT | `/admin/reports/{id}/handle` | `report:handle` | 处理工单;结论成立会联动驳回被举报评论,可选择同时封禁被举报用户 |
| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 |
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
后台评论行除 `dramaId/episodeId` 外,还返回 `dramaTitle/episodeNo/episodeTitle`;
剧或集已删除时对应展示字段留空。后台评论与举报的 `status` 空值均表示全部状态。
### 2.6 订单与权益(已实现)
| 方法 | 路径 | 权限点 | 说明 |
@@ -649,23 +363,6 @@ GET /app/notices?pageIndex=1&pageSize=20
| POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 |
| POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) |
**对账口径(B1-2 起生效,字段名不变,新增两个字段)**
| 字段 | 含义 |
| --- | --- |
| `totalCount` | 当期本地有效订单数(排除 `CREATED`/`CLOSED`) |
| `matchedCount` | 其中能在 `store_receipt` 找到同一 `(platform, transaction_id)` 的笔数 |
| `diffCount` | 差异笔数 = 本地缺票据 + 票据无本地订单 + **商店已作废但本地无单** |
| `amountSum` | 本地订单金额合计(**本地口径**,不是商店结算口径) |
| `storeAmountSum` | 商店侧结算金额。Developer API 不提供(要向 Play Console 财务报告取)→ **无数据源时为 `null`** |
| `notes` | 说明差异构成、本次退款回收情况、以及"缺哪些数据源"。**契约强制:取不到的指标用 `null` + `notes` 说明,不许用 0 冒充** |
| `status` | 商店侧调用失败时为 `FAILED`(不会写成 `SUCCESS` + 一堆 0) |
`POST /admin/reconcile/run` 同时会**拉取商店已作废交易并回收权益与金币**(退款/拒付回收),
回收是幂等的:同一笔作废交易重复出现在时间窗里,只回收一次。
口径细节:Google 一次性商品的作废是**整笔**,拿不到部分金额,因此按整单回收;
找不到本地订单的作废交易会计入 `diffCount` 并在 `notes` 里点名。
### 2.7 创作者与内容审核(已实现)
| 方法 | 路径 | 权限点 | 说明 |
@@ -881,93 +578,17 @@ GET /app/notices?pageIndex=1&pageSize=20
| --- | --- | --- | --- |
| GET | `/admin/creator-center/impersonation/targets` | `creator-center:impersonate` | 代看目标下拉的数据源(id / 名称 / 状态) |
### 2.11 商品(金币包)配置(已实现)
> 背景:`product` 表 16 个字段早就建好,但后台一直没有入口,运营只能改库;而 C 端 `/app/orders/verify`
> 依赖 `store_product_id` 对齐商店商品(对不上就是 `200002 该商品未在后台配置`)。本节补齐 CRUD。
> **不改表结构。**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/products/page` | `product:list` | 分页检索(`type` / `status` / `platform` / `keyword`,分页沿用 `{total,list}`) |
| GET | `/admin/products/{id}` | `product:list` | 商品详情 |
| POST | `/admin/products` | `product:update` | 新建 |
| PUT | `/admin/products/{id}` | `product:update` | 编辑 |
| PUT | `/admin/products/{id}/status` | `product:update` | 上架 / 下架(`status`:1 上架、0 下架) |
| DELETE | `/admin/products/{id}` | `product:update` | 删除(逻辑删除) |
**字段**(与表一致,响应里额外带 `statusText` / `typeText`,前端不再写死中文):
| 字段 | 说明 |
| --- | --- |
| `code` | 商品编码,**全局唯一** |
| `name` | 展示名 |
| `type` | `COIN_PACK`(金币包)/ `VIP`(会员,第二阶段) |
| `platform` | `ALL` / `GOOGLE` / `APPLE` |
| `storeProductId` | 商店商品 id,**同一 `platform` 下唯一**(空串表示未绑定,允许多个) |
| `coinAmount` / `bonusCoin` | 金币数与赠送(`type=COIN_PACK` 时 `coinAmount > 0`) |
| `priceAmount` / `currency` / `displayPrice` | 价格与展示价 |
| `regionCodes` | 生效区域,逗号分隔,空=全区域 |
| `vipDays` | 会员天数(`type=VIP` 时 `> 0`) |
| `sort` / `status` | 排序 / 上下架 |
| `effectiveAt` / `expireAt` | 生效与失效时间(可空) |
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**(本节不再单独规定)。
**校验与错误码**:
| 场景 | 结果 |
| --- | --- |
| `code` 已存在 | `200001 商品编码已存在:{code}` |
| `storeProductId` 在同 `platform` 下已存在 | `200001 该平台下商店商品 id 已被占用:{id}` |
| `type=COIN_PACK` 但 `coinAmount <= 0` | `400 金币包的金币数量必须大于 0` |
| `type=VIP` 但 `vipDays <= 0` | `400 会员商品的会员天数必须大于 0` |
| **`type=VIP` 且要启用**(`status=1`) | `400 会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)` |
| 商品不存在 | `200002 商品不存在` |
> **VIP 口径(2026-10-16 定,与 B1-1「VIP 订阅本轮不做」一致)**:**允许创建、不允许启用**。
> 运营可以先录好商品与商店 product id,等 B2 上线后直接上下架;**启用**才是会误导客户端的动作,拦在这里。
### 2.12 系统公告管理(已实现)
> 背景:`ops_notice` 表 0 行、契约原先写着"没有产出方";E1 已交付 C 端 `GET /app/notices`(匿名只读)。
> 本节补齐后台写入。**不改表结构。**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/notices/page` | `notice:list` | 分页检索(`status` / `target` / `type` / `keyword`) |
| GET | `/admin/notices/{id}` | `notice:list` | 公告详情 |
| POST | `/admin/notices` | `notice:update` | 新建 |
| PUT | `/admin/notices/{id}` | `notice:update` | 编辑 |
| PUT | `/admin/notices/{id}/status` | `notice:update` | 发布(`status=1`)/ 下架(`status=0`) |
| DELETE | `/admin/notices/{id}` | `notice:update` | 删除(逻辑删除) |
**字段**:`title` / `content` / `target`(`ALL` / `USER` / `CREATOR`)/ `type`(`SYSTEM` / `AUDIT` / `PAYMENT` / `ACTIVITY`)/
`status` / `publishAt` / `expireAt`;响应另带 `statusText` / `targetText` / `typeText`。
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**。
**与 C 端口径严格对齐(同一条查询实现)**:C 端 `GET /app/notices` 只返回
`status=1` 且 `target ∈ {ALL, USER}`、`publish_at` 为空或 `<= now`、`expire_at` 为空或未过期的公告,
按 `publish_at desc, id desc` 排序。**后台发布即 C 端可见;下架或过期后 C 端不可见。**
(实现上收敛在 `OpsNoticeService.pageForApp(...)` 一处,避免两端条件漂移。)
**筛选与错误码**:`title` 必填(1–128);`content` 最长 2000;公告不存在 → `200002 公告不存在`;
`status` 只接受 0/1,其他值 → `400`。
## 3. 契约与实现状态对照
> 最后更新:2026-10-16(`develop` 分支)。未完成项汇总见仓库根 `README.md`「尚未完成」。
| 模块 | 表结构 | 接口 | 代码 |
| --- | --- | --- | --- |
| 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现;**S0 雪花主键**、**S1 Swagger 在线文档** 已完成 |
| 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现 |
| 02 用户账号与登录 | 已建 | 已冻结 | **已实现**(手机号/邮箱,第三方登录走同一凭证表) |
| 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) |
| 04 播放器与播放控制 | 已建 | 已冻结 | **已实现**:票据 + 签名 + 进度;多码率 HLS 由转码 worker(C1)产出 |
| 04 播放器与播放控制 | 已建 | 已冻结 | 票据 + 签名 + 进度 已实现;多码率依赖转码 worker |
| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;**分享埋点待 C3** |
| 07 创作者中心 | 已建 | 已冻结 | **已实现**(**在后台管理 Web 端**):创作者工作台(我的作品 / 审核进度 / 通知)、内容编辑与分集管理、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;Web 端页面见 2.10,不含提现 / 结算。**App 客户端不提供创作者中心**(口径 2026-10-16) |
| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;分享埋点在下一批 |
| 07 创作者中心 | 已建 | 已冻结 | **已实现**:创作者工作台(我的作品 / 审核进度 / 通知)、内容编辑与分集管理、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;Web 端页面见 2.10,不含提现 / 结算 |
| 08 平台运营后台 | 已建 | 已冻结 | **已实现**:后台接口(用户管理、内容治理、订单与权益、创作者与内容审核、数据看板)与后台页面(内容治理 / 订单与权益 / 创作者审核 / 数据看板,见 2.9)全部落地 |
| 09 分集收费与支付 | 已建 | 已冻结 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,口径为"预留") |
| 10 质量、数据与上线 | 已建 | 已冻结 | **转码 worker(C1)已实现**;**Redis 缓存与限流(C2)、埋点(C3)、压测 未做** |
| 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) |
| 10 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 |
-68
View File
@@ -1,68 +0,0 @@
# 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
@@ -1,91 +0,0 @@
# 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
@@ -1,95 +0,0 @@
# 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
@@ -1,90 +0,0 @@
# 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
@@ -1,63 +0,0 @@
# 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
@@ -1,79 +0,0 @@
# 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
@@ -1,121 +0,0 @@
# 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
@@ -1,40 +0,0 @@
# 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
@@ -1,170 +0,0 @@
# 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
@@ -1,92 +0,0 @@
# 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
@@ -1,82 +0,0 @@
# 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
@@ -1,102 +0,0 @@
# 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
@@ -1,91 +0,0 @@
# 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
@@ -1,63 +0,0 @@
# 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
@@ -1,70 +0,0 @@
# 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
@@ -1,87 +0,0 @@
# 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
@@ -1,60 +0,0 @@
# 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
@@ -1,70 +0,0 @@
# 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
@@ -1,56 +0,0 @@
# 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
@@ -1,78 +0,0 @@
# 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
@@ -1,32 +0,0 @@
# 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
@@ -1,41 +0,0 @@
# 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
@@ -1,102 +0,0 @@
# 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
@@ -1,79 +0,0 @@
# 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
@@ -1,83 +0,0 @@
# 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
@@ -1,139 +0,0 @@
# 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
@@ -1,44 +0,0 @@
# 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 客户端链路。
@@ -1,26 +0,0 @@
# 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 已运行端口。
@@ -1,46 +0,0 @@
# 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 仓库,不执行真机安装;完成后等待用户验收。
@@ -1,59 +0,0 @@
# 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` 全过。
- 不安装真机;完成后等待用户验收。
@@ -1,46 +0,0 @@
# 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 留痕;不安装真机,完成后等待用户验收。
@@ -1,45 +0,0 @@
# 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 留痕;不安装真机,完成后等待用户验收。
@@ -1,47 +0,0 @@
# 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 留痕;不安装真机,完成后等待用户验收。
@@ -1,46 +0,0 @@
# 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 留痕;不安装真机,完成后等待用户验收。
@@ -1,51 +0,0 @@
# 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
@@ -1,59 +0,0 @@
# 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
@@ -1,114 +0,0 @@
# 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
@@ -1,108 +0,0 @@
# 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`。
@@ -1,121 +0,0 @@
# 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
@@ -1,126 +0,0 @@
# 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`。
@@ -1,100 +0,0 @@
# 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`。
@@ -1,83 +0,0 @@
# 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`。
@@ -1,54 +0,0 @@
# 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
@@ -1,86 +0,0 @@
# 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 已交付逻辑。
- 不安装或操作真机;完成后停住等待验收。
@@ -1,64 +0,0 @@
# 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。
- 不安装或操作真机;完成后停住等待验收。
@@ -1,73 +0,0 @@
# 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 策略。
- 不安装或操作真机;完成后停住等待验收。
@@ -1,30 +0,0 @@
# 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
@@ -1,24 +0,0 @@
# 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): 短剧保存时始终同步分集价格`
-95
View File
@@ -1,95 +0,0 @@
# 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 验收,回执需贴门禁原始输出
-24715
View File
File diff suppressed because it is too large. Load diff
@@ -5,50 +5,7 @@ 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 {
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Tag(name = "后台-管理员账号")
@Tag(name = "管理员管理")
@RestController
@RequestMapping("/admin/admin-users")
@RequiredArgsConstructor
@@ -35,7 +35,7 @@ public class AdminUserController {
private final AdminUserService adminUserService;
@Operation(summary = "管理员分页查询")
@Operation(summary = "管理员分页检索")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<PageResult<AdminUserRespVO>> page(AdminUserPageReqVO req) {
@@ -49,7 +49,7 @@ public class AdminUserController {
return Result.ok(adminUserService.get(id));
}
@Operation(summary = "新建管理员")
@Operation(summary = "新增管理员")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "新增管理员")
@@ -75,7 +75,7 @@ public class AdminUserController {
return Result.ok();
}
@Operation(summary = "启用 / 停用管理员")
@Operation(summary = "修改管理员状态")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "修改管理员状态")
@@ -34,7 +34,7 @@ import java.util.Map;
import java.util.stream.Collectors;
/** 后台用户管理。 */
@Tag(name = "后台-App 用户")
@Tag(name = "App 用户管理")
@RestController
@RequestMapping("/admin/app-users")
@RequiredArgsConstructor
@@ -45,21 +45,21 @@ public class AppUserAdminController {
private final DramaService dramaService;
private final EpisodeService episodeService;
@Operation(summary = "App 用户分页查询")
@Operation(summary = "用户分页检索(uid/昵称/手机号/邮箱/状态/国家/注册来源)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<PageResult<AppUserAdminRespVO>> page(AppUserQueryReqVO req) {
return Result.ok(appUserAdminService.page(req));
}
@Operation(summary = "App 用户详情")
@Operation(summary = "用户详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<AppUserAdminRespVO> detail(@PathVariable Long id) {
return Result.ok(appUserAdminService.detail(id));
}
@Operation(summary = "封禁 / 处置记录")
@Operation(summary = "封禁与申诉记录")
@GetMapping("/{id}/ban-records")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<List<AppUserBanRecord>> banRecords(@PathVariable Long id) {
@@ -67,7 +67,7 @@ public class AppUserAdminController {
}
/** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */
@Operation(summary = "观看流水明细(客服核对用)")
@Operation(summary = "观看流水明细(客服核对用,带剧名与集号)")
@GetMapping("/{id}/watch-history")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<List<AppUserWatchHistoryVO>> watchHistory(@PathVariable Long id,
@@ -115,7 +115,7 @@ public class AppUserAdminController {
}
/** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */
@Operation(summary = "处置用户状态(封禁会立即吊销全部会话)")
@Operation(summary = "修改用户状态(normal/limited/banned,封禁立即吊销全部会话)")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('app-user:update')")
@OperationLog(module = "用户管理", action = "修改用户状态")
@@ -25,7 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-登录与个人")
@Tag(name = "后台认证")
@RestController
@RequestMapping("/admin/auth")
@RequiredArgsConstructor
@@ -33,7 +33,7 @@ public class AuthController {
private final AuthService authService;
@Operation(summary = "管理端登录")
@Operation(summary = "后台登录,返回 token")
@PostMapping("/login")
@OperationLog(module = "认证", action = "登录")
public Result<LoginRespVO> login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) {
@@ -54,14 +54,14 @@ public class AuthController {
}
/** JWT 无状态,登出由前端清除 token 即可。 */
@Operation(summary = "登出")
@Operation(summary = "登出(JWT 无状态,前端清除 token 即可)")
@PostMapping("/logout")
@OperationLog(module = "认证", action = "退出登录")
public Result<Void> logout() {
return Result.ok();
}
@Operation(summary = "修改自己的登录密码")
@Operation(summary = "修改密码")
@PostMapping("/password")
@OperationLog(module = "认证", action = "修改密码")
public Result<Void> changePassword(@Valid @RequestBody PasswordChangeReqVO req) {
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-轮播图管理")
@Tag(name = "轮播图管理")
@RestController
@RequestMapping("/admin/banners")
@RequiredArgsConstructor
@@ -35,7 +35,7 @@ public class BannerController {
private final BannerService bannerService;
@Operation(summary = "轮播图分页查询")
@Operation(summary = "轮播图分页检索(按位置/状态过滤)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('banner:list')")
public Result<PageResult<BannerRespVO>> page(@RequestParam(required = false) String position,
@@ -52,7 +52,7 @@ public class BannerController {
return Result.ok(bannerService.get(id));
}
@Operation(summary = "新建轮播图")
@Operation(summary = "新增轮播图")
@PostMapping
@PreAuthorize("@ss.hasPermission('banner:create')")
@OperationLog(module = "内容管理", action = "新增轮播图")
@@ -78,7 +78,7 @@ public class BannerController {
return Result.ok();
}
@Operation(summary = "上架 / 下架轮播图")
@Operation(summary = "修改轮播图状态")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "修改轮播图状态")
@@ -87,7 +87,7 @@ public class BannerController {
return Result.ok();
}
@Operation(summary = "调整轮播排序")
@Operation(summary = "调整轮播图排序")
@PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('banner:update')")
public Result<Void> sort(@RequestBody Map<String, List<Long>> body) {
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台评论审核。 */
@Tag(name = "后台-评论审核")
@Tag(name = "评论治理")
@RestController
@RequestMapping("/admin/comments")
@RequiredArgsConstructor
@@ -35,8 +35,8 @@ public class CommentAdminController {
private final CommentService commentService;
/** 检索评论:status 为空时返回全部状态,传 pending 即审核队列。 */
@Operation(summary = "评论检索(status 为空返回全部状态)")
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */
@Operation(summary = "评论检索(status=pending 即审核队列)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('comment:list')")
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
@@ -44,14 +44,14 @@ public class CommentAdminController {
}
/** 审核留证:机审与人审的完整链路。 */
@Operation(summary = "评论审核留证(机审 + 人审链路)")
@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 = "审核评论(通过 / 驳回)")
@Operation(summary = "审核评论(通过/驳回,带原因码与说明)")
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('comment:audit')")
@OperationLog(module = "内容治理", action = "审核评论")
@@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-系统配置")
@Tag(name = "系统配置")
@RestController
@RequestMapping("/admin/configs")
@RequiredArgsConstructor
@@ -34,7 +34,7 @@ public class ConfigController {
return Result.ok(configService.list());
}
@Operation(summary = "保存系统配置")
@Operation(summary = "批量修改系统配置")
@PutMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改系统设置")
@@ -31,7 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台创作者:检索、详情、资质、入驻审核。 */
@Tag(name = "后台-创作者管理")
@Tag(name = "创作者管理")
@RestController
@RequestMapping("/admin/creators")
@RequiredArgsConstructor
@@ -40,7 +40,7 @@ public class CreatorAdminController {
private final CreatorService creatorService;
private final CreatorAccountService creatorAccountService;
@Operation(summary = "创作者分页查询")
@Operation(summary = "创作者分页检索")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<PageResult<Creator>> page(CreatorQueryReqVO req) {
@@ -55,14 +55,14 @@ public class CreatorAdminController {
}
/** 资质文件:审核前要看证件原件。 */
@Operation(summary = "创作者资质文件")
@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 = "创作者入驻审核")
@Operation(summary = "入驻审核(通过/驳回/冻结)")
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "入驻审核")
@@ -71,7 +71,7 @@ public class CreatorAdminController {
}
/** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */
@Operation(summary = "手工录入创作者主体")
@Operation(summary = "手工录入创作者主体(C 端申请未实现前的打通用口)")
@PostMapping
@PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "新增创作者")
@@ -80,7 +80,7 @@ public class CreatorAdminController {
}
/** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */
@Operation(summary = "开通创作者账号(仅审核通过可开通,幂等)")
@Operation(summary = "开通创作者账号(仅审核通过可开,幂等)")
@PostMapping("/{id}/account")
@PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "开通创作者账号")
@@ -40,7 +40,7 @@ public class CreatorCenterController {
private final CreatorDashboardService creatorDashboardService;
/** 当前创作者的主体信息与入驻状态。 */
@Operation(summary = "我的创作者资料与入驻状态")
@Operation(summary = "当前创作者的主体信息与入驻状态")
@GetMapping("/profile")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterProfileVO> profile() {
@@ -48,7 +48,7 @@ public class CreatorCenterController {
}
/** 我的作品列表(含最近一次提交的审核进度)。 */
@Operation(summary = "我的作品列表(含最近一次审核进度)")
@Operation(summary = "我的作品列表(含最近一次提交的审核进度)")
@GetMapping("/dramas/page")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PageResult<CreatorCenterDramaVO>> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
@@ -56,7 +56,7 @@ public class CreatorCenterController {
}
/** 我的作品详情:id 是别人的作品时返回 403,不区分"不存在"。 */
@Operation(summary = "我的作品详情")
@Operation(summary = "我的作品详情(他人作品返回 403)")
@GetMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterDramaVO> myDrama(@PathVariable Long id) {
@@ -64,7 +64,7 @@ public class CreatorCenterController {
}
/** 我的通知:入驻审核结果 + 我提交内容的审核结果。 */
@Operation(summary = "我的通知(入驻与内容审核结果)")
@Operation(summary = "我的通知(入驻审核 + 内容审核结果)")
@GetMapping("/notices")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<CreatorCenterNoticeVO>> notices() {
@@ -72,7 +72,7 @@ public class CreatorCenterController {
}
/** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */
@Operation(summary = "我的数据看板(播放 / 完播 / 收藏 / 评论 / 解锁收入)")
@Operation(summary = "我的数据看板(播放/完播/收藏/评论/解锁收入,只读)")
@GetMapping("/dashboard")
@PreAuthorize("@ss.hasPermission('creator-center:stat:view')")
public Result<Map<String, Object>> dashboard() {
@@ -83,7 +83,7 @@ public class CreatorCenterController {
* 代看目标下拉的数据源:只有带 `creator-center:impersonate` 的账号(第一阶段 = 超管)能调。
* 前端拿它渲染顶部的「当前创作者」下拉;没权限的账号不显示下拉,也不会请求这个接口。
*/
@Operation(summary = "代看可选创作者列表")
@Operation(summary = "代看目标下拉数据源(仅持 creator-center:impersonate 可调)")
@GetMapping("/impersonation/targets")
@PreAuthorize("@ss.hasPermission('creator-center:impersonate')")
public Result<List<CreatorCenterTargetVO>> impersonationTargets() {
@@ -36,7 +36,7 @@ import java.util.List;
* 与 {@link CreatorCenterController} 一样**不接收 creatorId**:归属由登录态解析,
* 服务层强制 `where creator_id = 当前创作者`,改别人的剧或分集一律 403。
*/
@Tag(name = "创作者中心-内容与收费")
@Tag(name = "创作者作品")
@RestController
@RequestMapping("/admin/creator-center")
@RequiredArgsConstructor
@@ -54,7 +54,7 @@ public class CreatorContentController {
}
/** 编辑作品信息(仅草稿 / 已下线可改)。 */
@Operation(summary = "编辑作品信息(仅草稿 / 已下线可改)")
@Operation(summary = "编辑作品信息(仅草稿/已下线可改)")
@PutMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "编辑作品")
@@ -72,7 +72,7 @@ public class CreatorContentController {
}
/** 新增分集(集号不传自动排到最后)。 */
@Operation(summary = "新增分集")
@Operation(summary = "新增分集(不传集号自动排到最后)")
@PostMapping("/dramas/{id}/episodes")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "新增分集")
@@ -81,7 +81,7 @@ public class CreatorContentController {
}
/** 编辑分集(时长 / 封面 / 片源 / 排序)。 */
@Operation(summary = "编辑分集(时长 / 封面 / 片源)")
@Operation(summary = "编辑分集(时长/封面/片源/排序)")
@PutMapping("/dramas/{id}/episodes/{episodeId}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "编辑分集")
@@ -102,7 +102,7 @@ public class CreatorContentController {
}
/** 批量排序:body 里按新顺序给出分集 id。 */
@Operation(summary = "分集批量排序")
@Operation(summary = "批量排序分集")
@PutMapping("/dramas/{id}/episodes/sort")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "调整分集顺序")
@@ -112,7 +112,7 @@ public class CreatorContentController {
}
/** 提交审核:CREATE / UPDATE / PUBLISH / OFFLINE。 */
@Operation(summary = "提交审核(CREATE / UPDATE / PUBLISH / OFFLINE)")
@Operation(summary = "提交审核(CREATE/UPDATE/PUBLISH/OFFLINE)")
@PostMapping("/dramas/{id}/submit")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "提交审核")
@@ -121,7 +121,7 @@ public class CreatorContentController {
}
/** 撤回自己的提交(撤回后作品退回草稿,可继续编辑)。 */
@Operation(summary = "撤回自己的提交")
@Operation(summary = "撤回提交(撤回后作品退回草稿)")
@PostMapping("/submissions/{id}/withdraw")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "撤回提交")
@@ -130,7 +130,7 @@ public class CreatorContentController {
}
/** 我的提交历史(审核进度)。 */
@Operation(summary = "我的提交历史")
@Operation(summary = "我的提交历史(审核进度)")
@GetMapping("/dramas/{id}/submissions")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) {
@@ -138,7 +138,7 @@ public class CreatorContentController {
}
/** 把上传好的视频产物挂到自己的分集上(产物必须是本人上传的)。 */
@Operation(summary = "把上传产物挂到自己的分集上")
@Operation(summary = "挂载分集视频产物(产物必须是本人上传)")
@PostMapping("/dramas/{id}/episodes/{episodeId}/attach")
@PreAuthorize("@ss.hasPermission('creator-center:upload')")
@OperationLog(module = "创作者中心", action = "挂载分集视频")
@@ -149,7 +149,7 @@ public class CreatorContentController {
}
/** 分集收费与试看秒数:读现状。 */
@Operation(summary = "分集收费与试看秒数(读)")
@Operation(summary = "分集收费与试看秒数:读取")
@GetMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PriceRespVO> priceDetail(@PathVariable Long id) {
@@ -157,7 +157,7 @@ public class CreatorContentController {
}
/** 分集收费与试看秒数:保存(复用后台同一套 PriceService,改完立即影响付费判定)。 */
@Operation(summary = "分集收费与试看秒数(保存)")
@Operation(summary = "分集收费与试看秒数:保存(改完立即影响付费判定)")
@PutMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:price:update')")
@OperationLog(module = "创作者中心", action = "配置分集价格")
@@ -20,7 +20,7 @@ import java.util.Map;
* 指标口径写在 DashboardService 的类注释与 docs/api-phase1.md 里;
* 取不到数据源的指标返回 null 并在 notes 里说明,不用 0 冒充。
*/
@Tag(name = "后台-数据看板")
@Tag(name = "数据看板")
@RestController
@RequestMapping("/admin/dashboard")
@RequiredArgsConstructor
@@ -29,7 +29,7 @@ public class DashboardAdminController {
private final DashboardService dashboardService;
/** 总览:用户 / 内容 / 播放 / 收入 / 成本。 */
@Operation(summary = "运营大盘总览(用户 / 内容 / 播放 / 收入 / 成本)")
@Operation(summary = "总览:用户/内容/播放/收入/成本")
@GetMapping("/overview")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<Map<String, Object>> overview() {
@@ -37,7 +37,7 @@ public class DashboardAdminController {
}
/** 转码看板:队列积压与失败任务明细。 */
@Operation(summary = "转码看板(队列积压与失败任务)")
@Operation(summary = "转码看板:队列积压与失败任务明细")
@GetMapping("/transcode")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<Map<String, Object>> transcode() {
@@ -21,7 +21,7 @@ import java.util.Map;
/**
* 字典接口:题材、内容状态等下拉数据。
*/
@Tag(name = "后台-字典")
@Tag(name = "数据字典")
@RestController
@RequestMapping("/admin/dicts")
@RequiredArgsConstructor
@@ -29,7 +29,7 @@ public class DictController {
private final DramaCategoryService dramaCategoryService;
@Operation(summary = "短剧分类字典")
@Operation(summary = "题材字典(下拉数据)")
@GetMapping("/drama-category")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> dramaCategories() {
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/** 后台下载授权管理。 */
@Tag(name = "后台-离线下载管理")
@Tag(name = "下载管理")
@RestController
@RequestMapping("/admin/downloads")
@RequiredArgsConstructor
@@ -30,14 +30,14 @@ public class DownloadAdminController {
private final DownloadService downloadService;
@Operation(summary = "下载授权分页查询")
@Operation(summary = "下载授权分页检索(含剧名与集号)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('download:list')")
public Result<PageResult<DownloadLicenseRespVO>> page(DownloadQueryReqVO req) {
return Result.ok(downloadService.page(req));
}
@Operation(summary = "下载统计")
@Operation(summary = "授权与任务分布统计")
@GetMapping("/stats")
@PreAuthorize("@ss.hasPermission('download:list')")
public Result<Map<String, Object>> stats() {
@@ -45,7 +45,7 @@ public class DownloadAdminController {
}
/** 吊销单条授权:用户侧下次联网即失效。 */
@Operation(summary = "吊销单条下载授权")
@Operation(summary = "吊销授权(用户侧下次联网即失效)")
@PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('download:revoke')")
@OperationLog(module = "下载管理", action = "吊销下载授权")
@@ -55,7 +55,7 @@ public class DownloadAdminController {
}
/** 手动触发过期清理,正常情况下由定时任务每天执行。 */
@Operation(summary = "手动触发过期授权清理")
@Operation(summary = "手动清理过期授权(默认每天凌晨自动执行)")
@PostMapping("/cleanup")
@PreAuthorize("@ss.hasPermission('download:revoke')")
@OperationLog(module = "下载管理", action = "清理过期下载授权")
@@ -27,7 +27,7 @@ import java.util.List;
/**
* 短剧类型管理。类型属于短剧域,权限沿用 drama:*,不再单开权限点。
*/
@Tag(name = "后台-短剧分类")
@Tag(name = "短剧类型")
@RestController
@RequestMapping("/admin/drama-categories")
@RequiredArgsConstructor
@@ -35,14 +35,14 @@ public class DramaCategoryController {
private final DramaCategoryService dramaCategoryService;
@Operation(summary = "分类列表")
@Operation(summary = "短剧类型列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> list() {
return Result.ok(dramaCategoryService.list());
}
@Operation(summary = "新建分类")
@Operation(summary = "新增短剧类型")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增短剧类型")
@@ -50,7 +50,7 @@ public class DramaCategoryController {
return Result.ok(dramaCategoryService.create(req));
}
@Operation(summary = "编辑分类")
@Operation(summary = "编辑短剧类型")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧类型")
@@ -59,7 +59,7 @@ public class DramaCategoryController {
return Result.ok();
}
@Operation(summary = "删除分类")
@Operation(summary = "删除短剧类型")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧类型")
@@ -6,14 +6,12 @@ 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.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.interaction.service.CommentService;
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;
@@ -29,34 +27,27 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-短剧管理")
@Tag(name = "短剧管理")
@RestController
@RequestMapping("/admin/dramas")
@RequiredArgsConstructor
public class DramaController {
private final DramaService dramaService;
private final CommentService commentService;
private final EpisodeService episodeService;
@Operation(summary = "短剧分页查询")
@Operation(summary = "短剧分页检索(含热门、状态筛选)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
PageResult<DramaRespVO> page = dramaService.page(req);
fillInteractionCounts(page.getList());
return Result.ok(page);
return Result.ok(dramaService.page(req));
}
@Operation(summary = "短剧详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<DramaRespVO> get(@PathVariable Long id) {
DramaRespVO drama = dramaService.get(id);
fillInteractionCounts(List.of(drama));
return Result.ok(drama);
return Result.ok(dramaService.get(id));
}
@Operation(summary = "新建短剧")
@@ -104,7 +95,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "审核驳回")
@Operation(summary = "驳回短剧")
@PostMapping("/{id}/reject")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "驳回短剧")
@@ -113,7 +104,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "上线")
@Operation(summary = "短剧上线")
@PostMapping("/{id}/publish")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧上线")
@@ -122,7 +113,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "下线")
@Operation(summary = "短剧下线")
@PostMapping("/{id}/offline")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧下线")
@@ -142,17 +133,4 @@ 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));
});
}
}
@@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台权益:检索、人工补发、回收。 */
@Tag(name = "后台-权益管理")
@Tag(name = "权益管理")
@RestController
@RequestMapping("/admin/entitlements")
@RequiredArgsConstructor
@@ -31,7 +31,7 @@ public class EntitlementAdminController {
private final EntitlementService entitlementService;
@Operation(summary = "权益分页查询")
@Operation(summary = "权益分页检索")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('entitlement:list')")
public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId,
@@ -43,7 +43,7 @@ public class EntitlementAdminController {
}
/** 客服补偿:直接给用户某集/整剧的播放权益。 */
@Operation(summary = "补发权益(客服补偿)")
@Operation(summary = "人工补发权益(客服补偿)")
@PostMapping
@PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "人工补发权益")
@@ -55,7 +55,7 @@ public class EntitlementAdminController {
}
/** 回收权益:退款、违规处置后用。 */
@Operation(summary = "回收权益(退款 / 违规处置)")
@Operation(summary = "回收权益(退款/违规处置用,播放判定实时生效)")
@PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "回收权益")
@@ -2,16 +2,12 @@
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;
@@ -28,39 +24,24 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-剧集管理")
@Tag(name = "剧集管理")
@RestController
@RequestMapping("/admin/episodes")
@RequiredArgsConstructor
public class EpisodeController {
private final EpisodeService episodeService;
private final CommentService commentService;
private final DramaService dramaService;
@Operation(summary = "剧集列表(分页)")
@Operation(summary = "某剧分集列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('episode:list')")
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));
public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId,
@RequestParam(required = false) String status) {
return Result.ok(episodeService.listByDrama(dramaId, status));
}
@Operation(summary = "新建剧集")
@Operation(summary = "新增分集")
@PostMapping
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "新增剧集")
@@ -68,7 +49,7 @@ public class EpisodeController {
return Result.ok(episodeService.create(req));
}
@Operation(summary = "编辑剧集")
@Operation(summary = "编辑分集")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "编辑剧集")
@@ -77,7 +58,7 @@ public class EpisodeController {
return Result.ok();
}
@Operation(summary = "删除剧集")
@Operation(summary = "删除分集")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "删除剧集")
@@ -86,7 +67,7 @@ public class EpisodeController {
return Result.ok();
}
@Operation(summary = "剧集排序")
@Operation(summary = "批量排序分集")
@PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('episode:sort')")
@OperationLog(module = "内容管理", action = "调整剧集顺序")
@@ -28,7 +28,7 @@ import org.springframework.web.multipart.MultipartFile;
/**
* 媒资上传(本地存储版):init 建会话 → chunk 传分片 → complete 合并出地址。
*/
@Tag(name = "后台-媒资管理")
@Tag(name = "媒资上传")
@RestController
@RequestMapping("/admin/media")
@RequiredArgsConstructor
@@ -36,7 +36,7 @@ public class MediaController {
private final MediaService mediaService;
@Operation(summary = "初始化分片上传")
@Operation(summary = "建上传会话(返回 uploadId / chunkSize / totalChunks)")
@PostMapping("/upload/init")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<UploadInitRespVO> init(@Valid @RequestBody UploadInitReqVO req) {
@@ -45,7 +45,7 @@ public class MediaController {
return Result.ok(mediaService.init(req, SecurityUtils.getUserId()));
}
@Operation(summary = "上传分片")
@Operation(summary = "上传分片(可重复传同一片)")
@PostMapping("/upload/chunk")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<Integer> uploadChunk(@RequestParam String uploadId,
@@ -56,7 +56,7 @@ public class MediaController {
return Result.ok(index);
}
@Operation(summary = "完成上传")
@Operation(summary = "合并分片出媒资(同一 uploadId 幂等)")
@PostMapping("/upload/complete")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<MediaAssetRespVO> complete(@Valid @RequestBody UploadCompleteReqVO req) {
@@ -65,7 +65,7 @@ public class MediaController {
}
/** 断点续传:查上传会话进度(只能查自己发起的会话)。 */
@Operation(summary = "查询上传进度(断点续传)")
@Operation(summary = "断点续传进度查询(只能查自己发起的会话)")
@GetMapping("/upload/{uploadId}")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<UploadProgressRespVO> progress(@PathVariable String uploadId) {
@@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-菜单管理")
@Tag(name = "菜单管理")
@RestController
@RequestMapping("/admin/menus")
@RequiredArgsConstructor
@@ -38,7 +38,7 @@ public class MenuController {
return Result.ok(menuService.listAll());
}
@Operation(summary = "新建菜单")
@Operation(summary = "新增菜单")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "新增菜单")
@@ -1,90 +0,0 @@
// 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.OpsNoticeService;
import com.fruitshorts.system.vo.NoticeQueryReqVO;
import com.fruitshorts.system.vo.NoticeRespVO;
import com.fruitshorts.system.vo.NoticeSaveReqVO;
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.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;
/**
* 后台系统公告管理。
*
* <p>发布后的可见性与 C 端 `GET /app/notices` **共用同一套筛选实现**
* (`OpsNoticeService.pageForApp`):`status=1` + `target ∈ {ALL, USER}` + 已到发布时间 + 未过期。
*/
@Tag(name = "后台-系统公告")
@RestController
@RequestMapping("/admin/notices")
@RequiredArgsConstructor
public class NoticeAdminController {
private final OpsNoticeService opsNoticeService;
@Operation(summary = "公告分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('notice:list')")
public Result<PageResult<NoticeRespVO>> page(@Validated NoticeQueryReqVO req) {
return Result.ok(opsNoticeService.pageForAdmin(req));
}
@Operation(summary = "公告详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('notice:list')")
public Result<NoticeRespVO> detail(@PathVariable Long id) {
return Result.ok(opsNoticeService.detail(id));
}
@Operation(summary = "新建公告(默认发布,C 端立即可见;返回新公告 id,按 §0 以字符串下发)")
@PostMapping
@PreAuthorize("@ss.hasPermission('notice:update')")
@OperationLog(module = "系统公告", action = "新建公告")
public Result<Long> create(@Valid @RequestBody NoticeSaveReqVO req) {
return Result.ok(opsNoticeService.create(req));
}
@Operation(summary = "编辑公告")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('notice:update')")
@OperationLog(module = "系统公告", action = "编辑公告")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody NoticeSaveReqVO req) {
opsNoticeService.update(id, req);
return Result.ok();
}
@Operation(summary = "发布 / 下架(下架后 C 端立即不可见)")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('notice:update')")
@OperationLog(module = "系统公告", action = "公告上下架")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
opsNoticeService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "删除公告")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('notice:update')")
@OperationLog(module = "系统公告", action = "删除公告")
public Result<Void> delete(@PathVariable Long id) {
opsNoticeService.delete(id);
return Result.ok();
}
}
@@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "后台-操作日志")
@Tag(name = "操作日志")
@RestController
@RequestMapping("/admin/operation-logs")
@RequiredArgsConstructor
@@ -24,7 +24,7 @@ public class OperationLogController {
private final OperationLogService operationLogService;
@Operation(summary = "操作日志分页查询")
@Operation(summary = "操作日志分页检索")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('log:list')")
public Result<PageResult<OperationLogRespVO>> page(OperationLogPageReqVO req) {
@@ -26,7 +26,7 @@ import java.util.List;
import java.util.Map;
/** 后台订单:检索、补发、退款回收。 */
@Tag(name = "后台-订单管理")
@Tag(name = "订单管理")
@RestController
@RequestMapping("/admin/orders")
@RequiredArgsConstructor
@@ -34,7 +34,7 @@ public class OrderAdminController {
private final OrderService orderService;
@Operation(summary = "订单分页查询")
@Operation(summary = "订单分页检索")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<PageResult<Orders>> page(OrderQueryReqVO req) {
@@ -49,7 +49,7 @@ public class OrderAdminController {
}
/** 订单状态流转流水:支付争议时的证据链。 */
@Operation(summary = "订单状态流转流水")
@Operation(summary = "订单状态流转流水(支付争议证据链)")
@GetMapping("/{orderNo}/events")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<List<OrderEvent>> events(@PathVariable String orderNo) {
@@ -57,7 +57,7 @@ public class OrderAdminController {
}
/** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */
@Operation(summary = "补发订单(已支付未交付重新交付,幂等)")
@Operation(summary = "订单补发(已交付时幂等返回)")
@PostMapping("/{orderNo}/compensate")
@PreAuthorize("@ss.hasPermission('order:update')")
@OperationLog(module = "订单管理", action = "补发订单")
@@ -66,7 +66,7 @@ public class OrderAdminController {
}
/** 退款/撤销:回收权益并扣回金币。 */
@Operation(summary = "退款 / 撤销(回收权益并扣回金币)")
@Operation(summary = "订单退款/撤销(回收权益并扣回金币)")
@PostMapping("/{orderNo}/refund")
@PreAuthorize("@ss.hasPermission('order:update')")
@OperationLog(module = "订单管理", action = "订单退款")
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-权限点")
@Tag(name = "权限管理")
@RestController
@RequestMapping("/admin/permissions")
@RequiredArgsConstructor
@@ -24,7 +24,7 @@ public class PermissionController {
private final PermissionService permissionService;
/** 按分组返回权限点,直接喂给前端的角色权限配置页。 */
@Operation(summary = "权限点列表(按分组返回)")
@Operation(summary = "权限点分组列表(角色权限配置页用)")
@GetMapping
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<List<PermissionGroupVO>> groups() {
@@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台分集价格与免费集配置。 */
@Tag(name = "后台-分集收费")
@Tag(name = "分集价格")
@RestController
@RequestMapping("/admin/prices")
@RequiredArgsConstructor
@@ -28,14 +28,14 @@ public class PriceAdminController {
private final PriceService priceService;
/** 当前价格配置:整剧默认 + 分集明细。 */
@Operation(summary = "分集价格配置(整剧默认 + 分集明细)")
@Operation(summary = "当前价格配置:整剧默认 + 分集明细")
@GetMapping
@PreAuthorize("@ss.hasPermission('price:update')")
public Result<PriceRespVO> detail(@RequestParam Long dramaId) {
return Result.ok(priceService.detail(dramaId));
}
@Operation(summary = "保存分集价格配置")
@Operation(summary = "保存价格配置(改价立即影响播放与下载判定)")
@PutMapping
@PreAuthorize("@ss.hasPermission('price:update')")
@OperationLog(module = "定价管理", action = "配置分集价格")
@@ -1,90 +0,0 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.commerce.service.ProductService;
import com.fruitshorts.commerce.vo.ProductQueryReqVO;
import com.fruitshorts.commerce.vo.ProductRespVO;
import com.fruitshorts.commerce.vo.ProductSaveReqVO;
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;
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;
/**
* 后台商品(金币包)配置。
*
* <p>商品是 C 端 `POST /app/orders/verify` 的发币依据(按 `store_product_id` 对齐商店商品),
* 所以 `code` 与"同平台 store_product_id"的唯一性、以及 type 与金额的匹配都在服务端强校验。
*/
@Tag(name = "后台-商品配置")
@RestController
@RequestMapping("/admin/products")
@RequiredArgsConstructor
public class ProductAdminController {
private final ProductService productService;
@Operation(summary = "商品分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('product:list')")
public Result<PageResult<ProductRespVO>> page(@Validated ProductQueryReqVO req) {
return Result.ok(productService.page(req));
}
@Operation(summary = "商品详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('product:list')")
public Result<ProductRespVO> detail(@PathVariable Long id) {
return Result.ok(productService.detail(id));
}
@Operation(summary = "新建商品(默认下架,需显式上架;返回新商品 id,按 §0 以字符串下发)")
@PostMapping
@PreAuthorize("@ss.hasPermission('product:update')")
@OperationLog(module = "商品配置", action = "新建商品")
public Result<Long> create(@Valid @RequestBody ProductSaveReqVO req) {
return Result.ok(productService.create(req));
}
@Operation(summary = "编辑商品")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('product:update')")
@OperationLog(module = "商品配置", action = "编辑商品")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ProductSaveReqVO req) {
productService.update(id, req);
return Result.ok();
}
@Operation(summary = "上架 / 下架(会员商品在订阅上线前不能启用)")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('product:update')")
@OperationLog(module = "商品配置", action = "商品上下架")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
productService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "删除商品")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('product:update')")
@OperationLog(module = "商品配置", action = "删除商品")
public Result<Void> delete(@PathVariable Long id) {
productService.delete(id);
return Result.ok();
}
}
@@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
/** 后台对账:手动触发与历史查看。 */
@Tag(name = "后台-对账")
@Tag(name = "对账管理")
@RestController
@RequestMapping("/admin/reconcile")
@RequiredArgsConstructor
@@ -28,7 +28,7 @@ public class ReconcileAdminController {
private final ReconcileService reconcileService;
@Operation(summary = "对账任务列表")
@Operation(summary = "对账历史分页")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<PageResult<ReconcileTask>> page(@RequestParam(defaultValue = "1") Integer pageIndex,
@@ -37,7 +37,7 @@ public class ReconcileAdminController {
}
/** 手动对账:默认前一天,可指定日期与渠道。 */
@Operation(summary = "手动触发对账")
@Operation(summary = "手动对账(默认前一天,可指定日期与渠道)")
@PostMapping("/run")
@PreAuthorize("@ss.hasPermission('reconcile:run')")
@OperationLog(module = "订单管理", action = "手动对账")
@@ -2,12 +2,11 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.ReportAdminService;
import com.fruitshorts.admin.vo.AdminReportRespVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.interaction.dal.entity.ContentReport;
import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.ReportHandleReqVO;
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
@@ -23,24 +22,23 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台举报工单。 */
@Tag(name = "后台-举报工单")
@Tag(name = "举报工单")
@RestController
@RequestMapping("/admin/reports")
@RequiredArgsConstructor
public class ReportAdminController {
private final ReportAdminService reportAdminService;
private final ReportService reportService;
@Operation(summary = "举报工单分页查询")
@Operation(summary = "举报工单检索(状态/类型/原因)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('report:list')")
public Result<PageResult<AdminReportRespVO>> page(ReportQueryReqVO req) {
return Result.ok(reportAdminService.page(req));
public Result<PageResult<ContentReport>> page(ReportQueryReqVO req) {
return Result.ok(reportService.page(req));
}
/** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */
@Operation(summary = "处理举报工单(可联动驳回评论与封禁用户)")
@Operation(summary = "处理工单(结论成立联动驳回被举报评论,可选择同时封禁用户)")
@PutMapping("/{id}/handle")
@PreAuthorize("@ss.hasPermission('report:handle')")
@OperationLog(module = "内容治理", action = "处理举报")
@@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-角色分类")
@Tag(name = "角色分类")
@RestController
@RequestMapping("/admin/role-categories")
@RequiredArgsConstructor
@@ -38,7 +38,7 @@ public class RoleCategoryController {
return Result.ok(roleCategoryService.list());
}
@Operation(summary = "新建角色分类")
@Operation(summary = "新增角色分类")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "新增角色分类")
@@ -26,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-角色管理")
@Tag(name = "角色管理")
@RestController
@RequestMapping("/admin/roles")
@RequiredArgsConstructor
@@ -34,7 +34,7 @@ public class RoleController {
private final RoleService roleService;
@Operation(summary = "角色分页查询")
@Operation(summary = "角色分页检索")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<PageResult<RoleRespVO>> page(@RequestParam(required = false) String keyword,
@@ -43,7 +43,7 @@ public class RoleController {
return Result.ok(roleService.page(keyword, pageIndex, pageSize));
}
@Operation(summary = "角色下拉列表")
@Operation(summary = "角色全量列表")
@GetMapping("/list")
public Result<List<RoleRespVO>> list() {
return Result.ok(roleService.listAll());
@@ -56,7 +56,7 @@ public class RoleController {
return Result.ok(roleService.get(id));
}
@Operation(summary = "新建角色")
@Operation(summary = "新增角色")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "新增角色")
@@ -82,7 +82,7 @@ public class RoleController {
return Result.ok();
}
@Operation(summary = "角色已授权限")
@Operation(summary = "查询角色权限码")
@GetMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<List<String>> permissions(@PathVariable Long id) {
@@ -24,7 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台敏感词库。改动即时生效(服务端词库缓存会失效重建)。 */
@Tag(name = "后台-敏感词库")
@Tag(name = "敏感词")
@RestController
@RequestMapping("/admin/sensitive-words")
@RequiredArgsConstructor
@@ -32,7 +32,7 @@ public class SensitiveWordAdminController {
private final SensitiveWordService sensitiveWordService;
@Operation(summary = "敏感词分页查询")
@Operation(summary = "敏感词分页检索")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('sensitive-word:list')")
public Result<PageResult<SensitiveWord>> page(@RequestParam(required = false) String keyword,
@@ -51,7 +51,7 @@ public class SensitiveWordAdminController {
return Result.ok(sensitiveWordService.create(req));
}
@Operation(summary = "编辑敏感词")
@Operation(summary = "修改敏感词")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('sensitive-word:update')")
@OperationLog(module = "内容治理", action = "修改敏感词")
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台内容提交审核:检索、详情、审核(通过 / 驳回 / 撤回)。 */
@Tag(name = "后台-内容提交审核")
@Tag(name = "内容提交审核")
@RestController
@RequestMapping("/admin/submissions")
@RequiredArgsConstructor
@@ -30,7 +30,7 @@ public class SubmissionAdminController {
private final ContentSubmissionService submissionService;
@Operation(summary = "内容提交分页查询")
@Operation(summary = "内容提交分页检索")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('submission:list')")
public Result<PageResult<ContentSubmission>> page(SubmissionQueryReqVO req) {
@@ -44,7 +44,7 @@ public class SubmissionAdminController {
return Result.ok(submissionService.getExisting(id));
}
@Operation(summary = "内容提交审核")
@Operation(summary = "审核内容提交(通过后按类型落短剧状态机)")
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('submission:audit')")
@OperationLog(module = "内容审核", action = "提交审核")
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台钱包:人工补币/扣回,全部进流水且必须带幂等键。 */
@Tag(name = "后台-金币钱包")
@Tag(name = "钱包管理")
@RestController
@RequestMapping("/admin/wallets")
@RequiredArgsConstructor
@@ -36,7 +36,7 @@ public class WalletAdminController {
private final WalletService walletService;
private final AppUserMapper appUserMapper;
@Operation(summary = "金币流水")
@Operation(summary = "金币流水查询")
@GetMapping("/{userId}/ledger")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<List<CoinLedger>> ledger(@PathVariable Long userId,
@@ -44,7 +44,7 @@ public class WalletAdminController {
return Result.ok(walletService.recentLedger(userId, limit));
}
@Operation(summary = "人工补币")
@Operation(summary = "人工补币/扣回(必须带幂等键,先校验用户存在)")
@PostMapping("/coins")
@PreAuthorize("@ss.hasPermission('wallet:update')")
@OperationLog(module = "订单管理", action = "人工补币")
@@ -1,33 +0,0 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.WatchHistoryAdminService;
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台跨用户观看记录检索。 */
@Tag(name = "后台-观看记录")
@RestController
@RequestMapping("/admin/watch-history")
@RequiredArgsConstructor
public class WatchHistoryAdminController {
private final WatchHistoryAdminService watchHistoryAdminService;
@Operation(summary = "观看记录分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('watch-history:list')")
public Result<PageResult<AdminWatchHistoryVO>> page(WatchHistoryQueryReqVO req) {
return Result.ok(watchHistoryAdminService.page(req));
}
}
@@ -1,283 +0,0 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.admin.vo.AdminReportRespVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.entity.ContentReport;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentReportMapper;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 后台举报工单分页与目标展示信息装配。
*
* <p>举报只保存目标类型和 id;该服务在当前页一次性加载评论、剧、集和用户,
* 避免列表逐行查询,同时保持原举报处理流程不变。
*/
@Service
@RequiredArgsConstructor
public class ReportAdminService {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int DELETED_FLAG = 1;
private final ContentReportMapper reportMapper;
private final ContentCommentMapper commentMapper;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper episodeMapper;
private final AppUserMapper appUserMapper;
/** 按举报工单筛选条件分页查询,并补齐展示字段。 */
public PageResult<AdminReportRespVO> page(ReportQueryReqVO req) {
Page<ContentReport> page = new Page<>(
req.getPageIndex() == null || req.getPageIndex() < 1
? DEFAULT_PAGE_INDEX : req.getPageIndex(),
req.getPageSize() == null || req.getPageSize() < 1
? DEFAULT_PAGE_SIZE : req.getPageSize());
IPage<ContentReport> result = reportMapper.selectPage(page,
Wrappers.<ContentReport>lambdaQuery()
.eq(StringUtils.hasText(req.getStatus()),
ContentReport::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getTargetType()),
ContentReport::getTargetType, req.getTargetType())
.eq(StringUtils.hasText(req.getReasonCode()),
ContentReport::getReasonCode, req.getReasonCode())
.eq(req.getReporterId() != null,
ContentReport::getReporterId, req.getReporterId())
.orderByDesc(ContentReport::getId));
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
}
private List<AdminReportRespVO> toVoList(List<ContentReport> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, ContentComment> comments = loadComments(rows);
Map<Long, Drama> dramas = loadDramas(rows, comments);
Map<Long, DramaEpisode> episodes = loadEpisodes(comments);
Map<Long, AppUser> users = loadUsers(rows);
List<AdminReportRespVO> list = new ArrayList<>(rows.size());
for (ContentReport row : rows) {
list.add(toVo(row, comments, dramas, episodes, users));
}
return list;
}
private Map<Long, ContentComment> loadComments(List<ContentReport> rows) {
List<Long> ids = rows.stream()
.filter(row -> ReportService.TYPE_COMMENT.equals(row.getTargetType()))
.map(ContentReport::getTargetId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
Map<Long, ContentComment> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (ContentComment comment : commentMapper.selectByIdsIncludingDeleted(ids)) {
result.put(comment.getId(), comment);
}
return result;
}
private Map<Long, Drama> loadDramas(List<ContentReport> rows,
Map<Long, ContentComment> comments) {
Set<Long> ids = new LinkedHashSet<>();
for (ContentReport row : rows) {
if (ReportService.TYPE_DRAMA.equals(row.getTargetType())) {
addPositive(ids, row.getTargetId());
continue;
}
if (ReportService.TYPE_COMMENT.equals(row.getTargetType())) {
ContentComment comment = comments.get(row.getTargetId());
if (!isDeleted(comment)) {
addPositive(ids, comment.getDramaId());
}
}
}
Map<Long, Drama> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
result.put(drama.getId(), drama);
}
return result;
}
private Map<Long, DramaEpisode> loadEpisodes(Map<Long, ContentComment> comments) {
Set<Long> ids = new LinkedHashSet<>();
for (ContentComment comment : comments.values()) {
if (!isDeleted(comment)) {
addPositive(ids, comment.getEpisodeId());
}
}
Map<Long, DramaEpisode> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (DramaEpisode episode : episodeMapper.selectBatchIds(ids)) {
result.put(episode.getId(), episode);
}
return result;
}
private Map<Long, AppUser> loadUsers(List<ContentReport> rows) {
Set<Long> ids = new LinkedHashSet<>();
for (ContentReport row : rows) {
addPositive(ids, row.getReporterId());
if (ReportService.TYPE_USER.equals(row.getTargetType())) {
addPositive(ids, row.getTargetId());
}
}
Map<Long, AppUser> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (AppUser user : appUserMapper.selectBatchIds(ids)) {
result.put(user.getId(), user);
}
return result;
}
private AdminReportRespVO toVo(ContentReport report,
Map<Long, ContentComment> comments,
Map<Long, Drama> dramas,
Map<Long, DramaEpisode> episodes,
Map<Long, AppUser> users) {
AdminReportRespVO vo = new AdminReportRespVO();
copyReport(report, vo);
fillReporter(report, users.get(report.getReporterId()), vo);
if (ReportService.TYPE_COMMENT.equals(report.getTargetType())) {
fillCommentTarget(report, comments.get(report.getTargetId()), dramas, episodes, vo);
} else if (ReportService.TYPE_DRAMA.equals(report.getTargetType())) {
fillDramaTarget(report, dramas.get(report.getTargetId()), vo);
} else if (ReportService.TYPE_USER.equals(report.getTargetType())) {
fillUserTarget(report, users.get(report.getTargetId()), vo);
}
return vo;
}
private void copyReport(ContentReport report, AdminReportRespVO vo) {
vo.setId(report.getId());
vo.setReporterId(report.getReporterId());
vo.setTargetType(report.getTargetType());
vo.setTargetId(report.getTargetId());
vo.setReasonCode(report.getReasonCode());
vo.setDescription(report.getDescription());
vo.setStatus(report.getStatus());
vo.setHandleBy(report.getHandleBy());
vo.setHandleAt(report.getHandleAt());
vo.setHandleNote(report.getHandleNote());
vo.setCreatedAt(report.getCreatedAt());
}
private void fillReporter(ContentReport report, AppUser reporter, AdminReportRespVO vo) {
if (reporter != null) {
vo.setReporterNickname(reporter.getNickname());
vo.setReporterUid(reporter.getUid());
}
}
private void fillCommentTarget(ContentReport report, ContentComment comment,
Map<Long, Drama> dramas,
Map<Long, DramaEpisode> episodes,
AdminReportRespVO vo) {
if (isDeleted(comment)) {
vo.setTargetSummary("评论 #" + report.getTargetId());
return;
}
Drama drama = dramas.get(comment.getDramaId());
if (drama == null) {
vo.setTargetSummary("评论 #" + report.getTargetId());
return;
}
DramaEpisode episode = episodes.get(comment.getEpisodeId());
vo.setDramaId(comment.getDramaId());
vo.setDramaTitle(drama.getTitle());
vo.setEpisodeId(comment.getEpisodeId());
vo.setCommentContent(comment.getContent());
if (episode == null) {
vo.setTargetSummary(drama.getTitle());
return;
}
vo.setEpisodeNo(episode.getEpNo());
vo.setEpisodeTitle(episode.getTitle());
String summary = drama.getTitle() + " · 第 " + episode.getEpNo() + " 集";
if (StringUtils.hasText(episode.getTitle())) {
summary += " · " + episode.getTitle();
}
vo.setTargetSummary(summary);
}
private void fillDramaTarget(ContentReport report, Drama drama, AdminReportRespVO vo) {
vo.setDramaId(report.getTargetId());
if (drama == null) {
vo.setTargetSummary("短剧 #" + report.getTargetId());
return;
}
vo.setDramaTitle(drama.getTitle());
vo.setTargetSummary(drama.getTitle());
}
private void fillUserTarget(ContentReport report, AppUser user, AdminReportRespVO vo) {
if (user == null) {
vo.setTargetSummary("用户 #" + report.getTargetId());
return;
}
vo.setTargetNickname(user.getNickname());
vo.setTargetUid(user.getUid());
vo.setTargetSummary(formatUser(user, report.getTargetId()));
}
private String formatUser(AppUser user, Long fallbackId) {
boolean hasNickname = StringUtils.hasText(user.getNickname());
boolean hasUid = StringUtils.hasText(user.getUid());
if (hasNickname && hasUid) {
return user.getNickname() + "(" + user.getUid() + ")";
}
if (hasNickname) {
return user.getNickname();
}
if (hasUid) {
return user.getUid();
}
return "用户 #" + fallbackId;
}
private boolean isDeleted(ContentComment comment) {
return comment == null
|| CommentService.STATUS_DELETED.equals(comment.getStatus())
|| Integer.valueOf(DELETED_FLAG).equals(comment.getDeleted());
}
private void addPositive(Set<Long> ids, Long id) {
if (id != null && id > 0L) {
ids.add(id);
}
}
}
@@ -1,198 +0,0 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 后台观看记录分页与跨域装配。
*
* <p>观看流水是播放域的原始事件,用户、短剧与分集资料分别位于用户和内容域。
* 该服务位于 admin-api 聚合层,直接注入只读 Mapper,避免反向扩展业务模块依赖。
*/
@Service
@RequiredArgsConstructor
public class WatchHistoryAdminService {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 100;
private static final long FINISHED_TOLERANCE_MS = 1000L;
private final WatchHistoryMapper watchHistoryMapper;
private final AppUserMapper appUserMapper;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
/** 按后台筛选条件分页查询观看记录。 */
public PageResult<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
int pageIndex = req.getPageIndex() == null || req.getPageIndex() < 1
? DEFAULT_PAGE_INDEX : req.getPageIndex();
int pageSize = req.getPageSize() == null || req.getPageSize() < 1
? DEFAULT_PAGE_SIZE : Math.min(req.getPageSize(), MAX_PAGE_SIZE);
List<Long> matchedUserIds = StringUtils.hasText(req.getKeyword())
? searchUserIds(req.getKeyword()) : null;
if (matchedUserIds != null && matchedUserIds.isEmpty()) {
return emptyPage();
}
List<Long> matchedDramaIds = StringUtils.hasText(req.getDramaTitle())
? searchDramaIds(req.getDramaTitle()) : null;
if (matchedDramaIds != null && matchedDramaIds.isEmpty()) {
return emptyPage();
}
IPage<WatchHistory> result = watchHistoryMapper.selectPage(new Page<>(pageIndex, pageSize),
buildQuery(req, matchedUserIds, matchedDramaIds));
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
}
private LambdaQueryWrapper<WatchHistory> buildQuery(WatchHistoryQueryReqVO req,
List<Long> matchedUserIds,
List<Long> matchedDramaIds) {
LambdaQueryWrapper<WatchHistory> wrapper = Wrappers.<WatchHistory>lambdaQuery()
.eq(req.getUserId() != null, WatchHistory::getUserId, req.getUserId())
.in(matchedUserIds != null, WatchHistory::getUserId, matchedUserIds)
.eq(req.getDramaId() != null, WatchHistory::getDramaId, req.getDramaId())
.in(matchedDramaIds != null, WatchHistory::getDramaId, matchedDramaIds)
.eq(req.getEpisodeId() != null, WatchHistory::getEpisodeId, req.getEpisodeId())
.eq(StringUtils.hasText(req.getSource()), WatchHistory::getSource, req.getSource())
.ge(req.getStartTime() != null, WatchHistory::getWatchedAt, req.getStartTime())
.le(req.getEndTime() != null, WatchHistory::getWatchedAt, req.getEndTime());
applyFinished(wrapper, req.getFinished());
return wrapper.orderByDesc(WatchHistory::getWatchedAt)
.orderByDesc(WatchHistory::getId);
}
private void applyFinished(LambdaQueryWrapper<WatchHistory> wrapper, Boolean finished) {
if (Boolean.TRUE.equals(finished)) {
wrapper.gt(WatchHistory::getDurationMs, 0L)
.apply("position_ms + {0} >= duration_ms", FINISHED_TOLERANCE_MS);
} else if (Boolean.FALSE.equals(finished)) {
wrapper.and(query -> query.eq(WatchHistory::getDurationMs, 0L)
.or()
.apply("position_ms + {0} < duration_ms", FINISHED_TOLERANCE_MS));
}
}
private List<Long> searchUserIds(String keyword) {
String value = keyword.trim();
return appUserMapper.selectList(Wrappers.<AppUser>lambdaQuery()
.select(AppUser::getId)
.and(query -> query.like(AppUser::getUid, value)
.or()
.like(AppUser::getNickname, value)))
.stream()
.map(AppUser::getId)
.toList();
}
private List<Long> searchDramaIds(String dramaTitle) {
String value = dramaTitle.trim();
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
.select(Drama::getId)
.like(Drama::getTitle, value))
.stream()
.map(Drama::getId)
.toList();
}
private List<AdminWatchHistoryVO> toVoList(List<WatchHistory> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, AppUser> users = loadUsers(rows);
Map<Long, Drama> dramas = loadDramas(rows);
Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
List<AdminWatchHistoryVO> list = new ArrayList<>(rows.size());
for (WatchHistory row : rows) {
list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()),
episodes.get(row.getEpisodeId())));
}
return list;
}
private Map<Long, AppUser> loadUsers(List<WatchHistory> rows) {
Map<Long, AppUser> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getUserId).distinct().toList();
for (AppUser user : appUserMapper.selectBatchIds(ids)) {
result.put(user.getId(), user);
}
return result;
}
private Map<Long, Drama> loadDramas(List<WatchHistory> rows) {
Map<Long, Drama> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getDramaId).distinct().toList();
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
result.put(drama.getId(), drama);
}
return result;
}
private Map<Long, DramaEpisode> loadEpisodes(List<WatchHistory> rows) {
Map<Long, DramaEpisode> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getEpisodeId).distinct().toList();
for (DramaEpisode episode : dramaEpisodeMapper.selectBatchIds(ids)) {
result.put(episode.getId(), episode);
}
return result;
}
private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama,
DramaEpisode episode) {
AdminWatchHistoryVO vo = new AdminWatchHistoryVO();
vo.setId(row.getId());
vo.setUserId(row.getUserId());
vo.setDramaId(row.getDramaId());
vo.setEpisodeId(row.getEpisodeId());
vo.setPositionMs(row.getPositionMs());
vo.setDurationMs(row.getDurationMs());
vo.setSource(row.getSource());
vo.setDeviceId(row.getDeviceId());
vo.setWatchedAt(row.getWatchedAt());
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration);
if (user != null) {
vo.setUid(user.getUid());
vo.setNickname(user.getNickname());
}
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus()));
} else {
vo.setDramaPublished(false);
}
if (episode != null) {
vo.setEpisodeNo(episode.getEpNo());
vo.setEpisodeTitle(episode.getTitle());
}
return vo;
}
private PageResult<AdminWatchHistoryVO> emptyPage() {
return new PageResult<>(0L, List.of());
}
}
@@ -1,35 +0,0 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台举报工单视图,附带举报人与被举报目标的展示信息。 */
@Data
public class AdminReportRespVO {
private Long id;
private Long reporterId;
private String reporterNickname;
private String reporterUid;
private String targetType;
private Long targetId;
private String targetSummary;
private Long dramaId;
private String dramaTitle;
private Long episodeId;
private Integer episodeNo;
private String episodeTitle;
private String commentContent;
private String targetNickname;
private String targetUid;
private String reasonCode;
private String description;
private String status;
private Long handleBy;
private LocalDateTime handleAt;
private String handleNote;
private LocalDateTime createdAt;
}
@@ -1,33 +0,0 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 后台观看记录行。
*
* <p>页面需要识别用户、短剧、分集与完播状态,因此由 admin-api 聚合流水与基础资料。
*/
@Data
public class AdminWatchHistoryVO {
private Long id;
private Long userId;
private String uid;
private String nickname;
private Long dramaId;
private String dramaTitle;
private Boolean dramaPublished;
private Long episodeId;
private Integer episodeNo;
private String episodeTitle;
private Long positionMs;
private Long durationMs;
private Boolean finished;
private String source;
private String deviceId;
private LocalDateTime watchedAt;
}
@@ -2,6 +2,7 @@
package com.fruitshorts.admin.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@@ -15,18 +16,30 @@ import java.time.LocalDateTime;
@Data
public class AppUserWatchHistoryVO {
@Schema(description = "ID")
private Long id;
@Schema(description = "短剧 ID")
private Long dramaId;
@Schema(description = "剧名")
private String dramaTitle;
/** 短剧当前是否已上线,用于识别"用户说看了但剧已下线"的情况 */
@Schema(description = "短剧当前是否已上线")
private Boolean dramaPublished;
@Schema(description = "剧集 ID")
private Long episodeId;
@Schema(description = "集号")
private Integer episodeNo;
@Schema(description = "播放进度(毫秒)")
private Long positionMs;
@Schema(description = "时长(毫秒)")
private Long durationMs;
/** 是否已看完(进度到达片尾 1 秒内) */
@Schema(description = "是否已看完")
private Boolean finished;
@Schema(description = "来源")
private String source;
@Schema(description = "设备 ID")
private String deviceId;
@Schema(description = "观看时间")
private LocalDateTime watchedAt;
}
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more