Author SHA1 Message Date
jianghaifeng 4118efd0ca docs(inbox): 按 specs 补全 K2 执行规范与门禁 2026-10-17 14:17:17 +08:00
jianghaifeng 096e90c4c6 docs(inbox): 记录用户裁定——K2 先按现状契约执行,契约缺口后期再补 2026-10-17 14:12:37 +08:00
jianghaifeng 582453f0bd docs(inbox): 下发 K2 客户端第二批(分类/记录/收藏/下载授权 + UI) 2026-10-17 14:11:22 +08:00
jianghaifeng bad3bf4e79 docs: 记录 specs 只同步 main / specs 权威 / 执行前必读 INBOX 的裁定 2026-10-17 14:06:50 +08:00
jianghaifeng 7637e322ec docs: 记录 2026-10-17 三库全量拉取与 main→develop 同步结果 2026-10-17 13:50:01 +08:00
jianghaifeng 2d70858887 docs(plans): K1-verify 回执与留痕(真机验证交回用户,标注未验证) 2026-10-16 18:06:02 +08:00
jianghaifeng 53ac5d60d1 docs(plans): 新增 K1-verify 条目 + 红线第 10 条(设备验证必须设备侧取证)
背景:1-3/1-4 的真机验证用了错地址 10.157.242.1(开发机 VPN 网卡、手机不可达),
且当时的证据是本机 curl 本机网卡地址返回 200 —— 假阳性,已导致真机首页加载失败。
本条目要求用正确地址(ipconfig getifaddr en0 取)重做,且证据必须产自设备侧
(首选:integration_test 在手机进程里真请求并断言),拿不到就如实标「未验证」。
红线新增第 10 条,防止再犯。
2026-10-16 17:54:36 +08:00
jianghaifeng 40e2812677 docs(plans): K1 回执(客户端详情+播放+续看+收藏,含真数据/假数据清单) 2026-10-16 17:39:36 +08:00
jianghaifeng 59d064f8c6 docs(plans): 新增 K1 条目(客户端对接:详情+播放+进度续看+收藏,含 UI 三态)
PM 勘察:客户端只调了 6 个 C 端接口(契约已实现 44 个);无 playback/watch/library
api 类;WatchHistoryRepository 仍是 SharedPreferences 本地存储、mine_logic 用 demoProfileId
假数据;EngagementRepository 亦为本地。K1 打通首页→详情→播放→续看→收藏一条链路。
2026-10-16 17:25:00 +08:00
jianghaifeng 0cab094194 docs(plans): J1 回执(全局 ID 字符串化 + 契约统一),标为已处理·待用户验收 2026-10-16 16:48:07 +08:00
jianghaifeng f35db2cd3b feat(framework): 全局 Long 字符串化,ID 一律以字符串下发
用户裁定方案 (a):雪花 id 精度问题全局修,统一契约。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# Author: fruit.com.cn
#
# 构建上下文里不需要的东西:产物、IDE、本地上传的媒资、日志。
# 注意别把 pom.xml / src / sql 排掉,镜像构建要用。
**/target/
.git/
.gitignore
.idea/
*.iml
*.log
data/
docs/
node_modules/
+36
View File
@@ -0,0 +1,36 @@
# 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
# ── 可选:Google Play 商店票据校验(不配则保持 disabled)──
STORE_VERIFIER=disabled
GOOGLE_PLAY_PACKAGE_NAME=
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=
+3
View File
@@ -16,3 +16,6 @@ logs/
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录 # 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
data/uploads/* data/uploads/*
!data/uploads/.gitkeep !data/uploads/.gitkeep
# 部署用的真实环境变量(含密钥),只提交 .env.example
.env
+256
View File
@@ -0,0 +1,256 @@
# Author: fruit.com.cn
#
# DEPLOY-CHECKLIST —— 部署合规视角(specs 约束逐条结论 + 上线检查清单对照 + 风险/待确认)
#
# 这份文档回答两个问题:
# 1) `docs/04-部署架构`、`docs/08-备份恢复方案`、`docs/10-安全方案`、`docs/11-发布与回滚方案`、
# `docs/12-上线检查清单`、`docs/07-监控告警方案`、`GIT_WORKFLOW.md`、`CODING_STANDARDS.md`
# 这些 specs 里跟部署有关的要求,**我们现在满足到哪一步**;
# 2) 上线前**必须补什么**、谁来补。
#
# 操作层面的"怎么部署/怎么回滚命令"在 `DEPLOY.md`(本文件只做结论与对照,避免两份口径分叉)。
# 结论用三态:✅ 已满足 | ⚠️ 部分满足(能用但有差距)| ❌ 未满足(上线前必补)
## 一、环境映射(对应 `04-部署架构`§一)
`04-部署架构`§一把环境分成四级:
| 环境 | 用途 | 数据 | 访问 |
| --- | --- | --- | --- |
| dev | 本地开发 | 本地 PostgreSQL | 内网 |
| test | 测试/联调 | 独立测试库 | 内网/VPN |
| pre | 预发(生产镜像演练) | 接近生产 | 受限 |
| prod | 生产 | 生产库 | 公网 |
**当前这台办公室主机 = `test` 环境**,依据三条:
1. 用途是"给前端联调 + 内部验收",不是对外生产(`04-部署架构`§一 test = 测试/联调);
2. 访问方式计划是内网/办公网(`admin-api` 只绑 `127.0.0.1`),而不是公网;
3. 用的是独立的开发库(`fruitshorts`,含种子数据),不是生产库。
规范要求与我们的对应:
- `docker-compose.yml` 用 **Docker Compose 单机编排** → 对应 §五「第一阶段(dev/test):Docker Compose 单机编排」,✅。
- 一台机器上合并 app/api/DB/Redis + worker 独立容器 → 对应 §三「精简方案:test 环境可将 app/api/DB/Redis 合并到 1 台 8C32G,转码 worker 独立 1 台」,✅(worker 是独立容器,但**与在线服务同机**:资源隔离靠容器限额,真正的分机留到生产)。
- **差距**:`pre` 环境(生产镜像演练)**尚未建立** —— 上线前建议在目标机用同一份 compose 跑一次"预发演练",即用生产镜像 + 接近生产的数据走一遍发布流程。状态:❌(流程未建立,不阻塞 test 联调)。
## 二、域名 / HTTPS / 反向代理(对应 `04-部署架构`§四 + `10-安全方案`§二)
**specs 原文**:`04-部署架构`§四「域名/SSL:管理端、客户端 API、媒资**分别域名**;**HTTPS 强制**」;
`10-安全方案`§二「传输安全:HTTPS 强制;密钥环境变量注入」。
**当前状态:❌ 未满足**。现在的形态是 `IP:端口 + HTTP`:
| 需要的域名 | 现状 | 上线前必须做 |
| --- | --- | --- |
| 管理端(后台 Web + `/admin/**`) | 无域名;`admin-api` 绑 `127.0.0.1:8080`,靠内网/SSH 隧道访问 | 申请域名(如 `admin.<domain>`)+ 证书;nginx 反代 → `127.0.0.1:8080`;**并保持只允许内网/VPN 来源**(见 §三) |
| 客户端 API(`/app/**`) | 无域名;`app-api` 监听 `8081`(HTTP) | 申请域名(如 `api.<domain>`)+ 证书;nginx 反代 → `8081`,只暴露 `/app/**`;**强制 HTTP→HTTPS 跳转** |
| 媒资(`/files/**`) | 无域名;与 app-api 同一端口 | 申请域名(如 `media.<domain>`)或直接走 CDN 域名;**播放地址已带签名参数**,CDN 需按签名做缓存策略(`04-部署架构`§四) |
**测试环境(本机)可以先用内网 HTTP 联调**(`04-部署架构`§一 test 是内网/VPN 访问),
但**上线(pre/prod)前必须满足上述三条 HTTPS + 独立域名**,否则不满足 `12-上线检查清单`§三「传输安全」。
### nginx + 证书的建议做法(上线前照做)
```nginx
# /etc/nginx/conf.d/fruitshorts.conf(示意;域名与证书路径按实际替换)
server { # 客户端 API
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
client_max_body_size 20m; # 分片上传,与 admin-api 的 multipart 上限一致
location / { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; }
}
server { # 管理端:只允许内网/VPN 网段
listen 443 ssl http2;
server_name admin.example.com;
allow 10.0.0.0/8; allow 192.168.0.0/16; deny all; # 按实际内网段调整
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; }
}
server { # HTTP 强制跳 HTTPS
listen 80;
server_name api.example.com admin.example.com;
return 301 https://$host$request_uri;
}
```
证书建议用 **Let's Encrypt(certbot)** 自动续期(`certbot --nginx -d api.example.com -d admin.example.com`),
或公司自有证书;无论哪种,**到期前必须能自动续**,否则接口会整体不可用。
反代配置文件需与本仓库一起纳入版本管理(放目标机的 `/etc/nginx/conf.d/`,另存一份到运维仓库)。
## 三、网络分区(对应 `04-部署架构`§四)
**specs 原文**:「网络分区:管理后台仅内网/VPN 可访问;客户端 API 公网。」
| 要求 | 现状 | 上线前必须做 |
| --- | --- | --- |
| 管理后台仅内网/VPN | ✅ 已满足:`docker-compose.yml` 里 admin-api 端口映射为 `127.0.0.1:8080:8080`(只绑本机,公网访问不到) | 上公网后仍要保证:只经 nginx + `allow/deny` 内网段暴露(见 §二示例) |
| 客户端 API 公网 | ⚠️ 部分满足:`app-api` 监听 `0.0.0.0:8081`,同网段可访问;**但还没有反代/域名,也没限制只暴露 `/app/**`** | 反代只放行 `/app/**`(`/files/**` 走媒资域名/CDN);不要把 `/v3/api-docs`、`/healthz` 直接暴露到公网(`/healthz` 可留给内网监控) |
**注意**:`/doc.html` 与 `/v3/api-docs` 由 `SWAGGER_ENABLED` 控制(见 `DEPLOY.md`)。
**联调环境建议 `true`**(前端要看),**公网生产建议 `false`**(不暴露接口清单)。
## 四、发布与回滚规范(对应 `11-发布与回滚方案`§一/§四 + `GIT_WORKFLOW.md`§四)
**specs 原文**:
- `11-发布与回滚方案`§一:「语义化版本 `v<major>.<minor>.<patch>`;分支 `main`(生产)← `release/*` ← `develop` ← `feature/*`;每次 `release`/`hotfix` 合并 `main` 必须打 tag,tag 不可移动/删除。」
- `GIT_WORKFLOW.md`§四:「`major` 破坏性变更、`minor` 新功能、`patch` 修复;发布后 tag 不可移动、不可删除(错误版本发新 tag 修正,不覆盖旧 tag)。」
- `11-发布与回滚方案`§四:「回滚演练:**上线前演练回滚**,确保可执行。」「数据库迁移脚本必须可回滚(向前兼容,避免破坏性变更)。」
| 要求 | 现状 | 上线前必须做 |
| --- | --- | --- |
| 语义化 tag `v<x.y.z>` | ⚠️ 部分满足:`DEPLOY.md` 里只是示例式地提到"每次发版打 tag",**没有命名规范** | 按 §四 规则打:首个上线版本 `v1.0.0`;之后功能 `v1.1.0`、修复 `v1.0.1`;**错误版本发新 tag,绝不移动/删除旧 tag** |
| release/hotfix 合并 main | ⚠️ 流程未走过:目前工作都在 `develop`,`main` 未动 | 上线时:`develop` → 建 `release/v1.0.0` → 在 test 环境验证 → 合并 `main` + 打 tag `v1.0.0`(**由用户操作,Codex 不碰 main**) |
| 回滚可执行 | ✅ 技术路径已写全(`DEPLOY.md`§七:切回上一个镜像 tag / git 回上一个 release tag + `up -d --build` + 重跑冒烟) | **上线前演练一次**(在 test 环境故意回滚到上一版本并跑 `tools/smoke.sh`),把演练记录留档 |
| 数据库迁移可回滚 | ⚠️ 部分满足:`sql/*.sql` 全是幂等增量(`ADD COLUMN IF NOT EXISTS`、`CREATE TABLE IF NOT EXISTS`),**没有破坏性变更**;`upsert` 语义的种子脚本也在 | 上线前确认:本次发布**不包含** `DROP/ALTER TYPE` 类破坏性语句(当前没有);如将来有,必须先写"回滚脚本 + 备份"再发 |
| 灰度/放量 | ❌ 未满足(单机 Compose 无灰度能力) | 上线首日按"先内测 → 再小流量(如有网关可切)";正式灰度需要 K8s/网关,属后续阶段(`04-部署架构`§五:生产上 K8s) |
## 五、备份与恢复(对应 `08-备份恢复方案`§二/§三)
**specs 目标**:全量每日 × 保持 30 天;**WAL 归档(实时)**;可选增量;**备份加密 + 异地存储**;
**RPO ≤ 5 分钟、RTO ≤ 30 分钟**;**每季度至少一次恢复演练**。
| 项 | specs 要求 | 现状 | 差距 / 上线前必须补 |
| --- | --- | --- | --- |
| 全量备份 | 每日 1 次,保留 30 天 | ⚠️ `DEPLOY.md`§七 给了**手动** `pg_dump` 命令;**没有定时任务、没有保留策略** | 目标机加 cron(或 compose 侧容器)每日 `pg_dump`,按日期命名保留 30 天;脚本可放 `tools/backup.sh`(本轮未写,属运维脚本) |
| WAL 归档 | 实时,用于 PITR | ❌ 未配置(PG 默认 `archive_mode=off`) | `postgresql.conf`:`wal_level=replica`、`archive_mode=on`、`archive_command` 推到异地;这是达成 **RPO≤5min** 的唯一途径 |
| 增量 | 可选 | ❌ 未做 | 有了 WAL 归档即可满足 RPO,增量备份可后补 |
| 加密 | 备份加密 | ❌ 未做 | `pg_dump` 管道给 `age`/`gpg`,或落到加密卷;密钥单独保管(与备份分开) |
| 异地 | 与生产不同可用区/账号 | ❌ 未做(本机 dump 还在同一台机器上) | 推到对象存储(另一账号/另一区);**"备份和数据库在同一台机器上"等于没备份** |
| 恢复演练 | 每季度 ≥ 1 次,记录归档 | ❌ 从未做过 | 首次上线前做一次"恢复到新库 + 起服务 + 冒烟",记录耗时(验 RTO)与丢失窗口(验 RPO) |
**结论:备份恢复这一项与 specs 差距最大,是上线前必须补的硬缺口(❌)。**
当前只有"能手动 dump",距离「RPO≤5min / RTO≤30min / 加密 / 异地 / 季度演练」还差整套机制。
建议排期:**上线前**至少做到「每日全量 + 异机保存 + 一次恢复演练」;**上线后两周内**补 WAL 归档与加密。
## 六、监控与告警现状(对应 `07-监控告警方案`§七)
**specs 原文**:`07-监控告警`§七「Prometheus/Loki/Grafana 尚未部署,**第一阶段先用日志 + `SlowRequestLogger` 兜底**」;
§六「告警渠道:飞书/钉钉/邮件 + 值班轮转」;§六「值班表与 Runbook 见 `docs/plans/`(上线前补)」。
| 项 | 现状 | 上线前必须补 |
| --- | --- | --- |
| 指标(Prometheus) | ❌ 未部署;**项目也没引 actuator**(`/actuator/health` 一直是未映射,见 `DEPLOY.md`§五说明) | 按 §七 的既定路线部署;要暴露指标需先给两个入口加 `spring-boot-starter-actuator` + `micrometer-registry-prometheus`(**属代码改动,需立项**) |
| 日志聚合(Loki) | ❌ 未部署;现在日志在容器 stdout(`docker compose logs`) | 部署 Loki + Promtail 采集容器日志 |
| 大盘(Grafana) | ❌ 未部署 | 按 §五 大盘定义建面板 |
| 第一阶段兜底 | ✅ 已具备:应用结构化日志(转码耗时/失败原因、业务异常、`SlowRequestLogger` 慢请求告警都在代码里)+ 容器级 `healthcheck`(`/healthz`) | 至少把"容器 unhealthy"接到人的手机上(可用 `docker compose events` + 简单脚本/飞书机器人) |
| 告警渠道 + 值班 + Runbook | ❌ 未定 | 定渠道(飞书/钉钉/邮件)、排值班表、把 `DEPLOY.md`§九「故障速查」扩成 Runbook |
**结论:监控告警 ❌ 未满足**,但按 specs §七 的既定节奏,**第一阶段允许先用日志兜底** ——
前提是"告警能到达人"。上线前最低要求:**容器 unhealthy / 冒烟失败能被发现**(哪怕先是人工每天跑一次 `tools/smoke.sh`)。
## 七、密钥托管与轮换(对应 `10-安全方案`§五)
**specs 原文**:§五「密钥托管:生产密钥**集中托管**,**最小权限**」;`12-上线检查清单`§三「JWT/签名密钥走环境变量,未硬编码/入库」「生产密钥集中托管」。
| 要求 | 现状 | 上线前必须补 |
| --- | --- | --- |
| 密钥走环境变量、未硬编码/入库 | ✅ 已满足:三个可运行件的密钥全部 `${ENV:...}` 注入;`.env` 已 gitignore;`.env.example` 只有占位;镜像里不含密钥 | 继续保持;**代码审查时禁止把密钥写回 yml** |
| 缺失即启动失败 | ✅ 已满足:`ProdSecretGuard` 在 `prod` profile 下校验三个密钥 + 签名开关 + DB_URL + CORS,缺失/仍是 dev 默认值/不足 32 位 → 退出码 1 | — |
| **集中托管** | ⚠️ 部分满足:现在是"目标机的 `.env` 文件"(文件权限 600),**没有密钥管理系统** | 短期:`.env` 权限 600、只有运维账号可读、**不进任何 git 仓库**、单独备份(加密);中期:迁到 K8s Secret / 云 KMS / Vault(`04-部署架构`§五 生产路线也是 K8s Secret) |
| 最小权限 | ⚠️ 部分满足:DB 用单一 `fruitshorts` 账号(读写全库) | 拆账号:应用用 `fruitshorts_app`(DML 权限、无 DDL);备份用只读账号;DDL 只留给运维 |
| 轮换 | ❌ 未做(也没有流程) | 定义轮换周期(建议 90 天或按事件):**先加新密钥 → 双密钥并行验证期 → 再下线旧密钥**;JWT 轮换会导致存量 token 失效,需选低峰并公告(`10-安全方案`§一 token 有效期 120min,影响窗口可控) |
## 八、上线检查清单对照表(逐条映射 `docs/12-上线检查清单.md`)
> 三态:✅ 已满足 | ⚠️ 部分满足 / 有条件满足 | ❌ 未满足(上线前必补)
> 「责任方」列区分:**Codex**(我能做的)/ **用户或运维**(需要账号、域名、外部资源)。
### 一、代码与门禁
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 三端门禁全绿 | ⚠️ | **后端 ✅**:`mvn test` 83 条全过;**前端(Flutter/Web)不在本工程范围**,由另一同事的 AI 负责,需其提供门禁结果 | 用户/前端 |
| 接口契约 `api-phase1.md` 已同步 | ✅ | 本轮 B1-1/B1-2 的 verify / reconcile 改动都已同步进契约 | Codex |
| 作者标识齐全 | ✅ | 远端已做全量补齐(`35be37c`),本轮新增文件也都带 `// Author: fruit.com.cn` | Codex |
| 代码规模红线、无关键路径 TODO | ⚠️ | 未做静态检查工具(后端无 checkstyle/pmd),**存量未逐文件核对**;无 TODO 扫描证据 | Codex(需立项) |
### 二、功能与测试
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 核心用例通过、P0 清零 | ⚠️ | 后端 83 条(含真实 ffmpeg smoke)全过;**前端与端到端用例不在本工程** | Codex/用户 |
| 支付闭环端到端 | ⚠️ | 票据校验/幂等/退款回收/对账**已实现并有单测**;**真实 Google Play 未联调**(缺 packageName + service account JSON,属付费凭据) | 用户(凭据)+ Codex(联调) |
| 播放长稳 + 弱网测试 | ❌ | 未做(需真机 + 网络模拟) | 前端/用户 |
| 多端(iOS/Android)回归 | ❌ | 未做(Android 无可用模拟器,iOS 有真机) | 前端/用户 |
### 三、安全
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 播放签名 `signature-required=true`(生产必开) | ✅ | `application-prod.yml` 写死 `true`,`ProdSecretGuard` 再校验一次,缺了直接拒启 | Codex |
| JWT/签名密钥走环境变量,未硬编码/入库 | ✅ | 三件全 `${ENV:...}`;`.env` gitignore;镜像不含密钥 | Codex |
| 越权/注入/敏感数据专项测试 | ⚠️ | **越权**:创作者中心有专项用例(B 批);**注入/SQL 注入**:全部走 MyBatis-Plus 参数绑定,无字符串拼接 SQL(人工核对),**无自动化用例**;敏感数据脱敏有实现(`AppIdentifier.mask`)但无专项测试 | Codex(需立项) |
| 生产密钥集中托管 | ⚠️ | 见 §七:现为 `.env` 文件权限隔离,未接 KMS/Vault | 用户/运维 |
### 四、基础设施
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 数据库分区已生效 | ✅ | `watch_history`/`playback_ticket`/`order_event` 均为声明式月分区,`partition-maintenance` 启动时+每日补齐。**实测证据**:`select tableoid::regclass, count(*) from playback_ticket group by 1` → `playback_ticket_202610 \| 1`(落月分区,不是 `_default`);`select count(*) from watch_history_default` → `0` | Codex |
| Redis/缓存(如接入)配置正确 | ⚠️ | compose 已备 Redis 容器,**代码尚未接入(C2 待做)**;当前不依赖 Redis | Codex(C2) |
| 媒资已迁 OSS/S3 + CDN(生产) | ❌ | **仍是本地磁盘 `./data/uploads`,与在线服务同机**;`MediaStorageService` 已把存储收敛到一处,切换 OSS 只需改它 + 转码产物上传路径 | 用户(账号)+ Codex |
| 备份恢复已配置并演练 | ❌ | 见 §五,差距最大 | 用户/运维 + Codex(脚本) |
| 监控告警已接入、告警可达 | ❌ | 见 §六 | 用户/运维 |
### 五、商店与合规
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 应用名称/截图/文案/隐私标签一致 | ❌ | 未办(客户端上架材料) | 用户 |
| App Privacy / Data Safety / SDK 清单 | ❌ | 未办 | 用户 |
| 隐私政策、用户协议已上线 | ❌ | 未办(后端有 `app_user_consent` 落库能力,文案与页面需法务/前端) | 用户 |
| 版权/素材/商标授权台账 | ❌ | 未办 | 用户 |
| 商店账号/证书/包名有效 | ❌ | 未办;**并且包名仍不一致**(Android `com.stormg.lumadrama` / iOS `com.stormg.lumadramv`,见 C 端回执) | 用户(建议上架前统一) |
### 六、发布与回滚
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 版本号 + tag 冻结(`v1.0.0`) | ❌ | 尚未打 tag(规范见 §四) | 用户(合并 main 时打) |
| 回滚方案已演练 | ❌ | 方案已写(`DEPLOY.md`§七),**未演练** | 用户/运维 |
| 值班表 + 告警 Runbook 就绪 | ⚠️ | `DEPLOY.md`§九「故障速查」可作为 Runbook 初稿;**值班表未定** | 用户 |
| 灰度/放量计划明确 | ❌ | 单机 Compose 无灰度能力;生产上 K8s 后再做 | 用户 |
### 七、观察窗口(上线后)
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
| --- | --- | --- | --- |
| 核心链路监控 | ❌ | 监控未接;**上线首日建议人工每 30 分钟跑一次 `tools/smoke.sh` + 看 `docker compose logs`** 作为过渡 | 用户/运维 |
| 支付/订单对账数据回看 | ⚠️ | 对账能力已在(`/admin/reconcile/run`,`storeAmountSum` 无数据源时返回 null + notes),**真实商店数据要等凭据** | Codex/用户 |
| 崩溃率/错误率观察 | ❌ | 未接监控;容器日志可人工查 | 用户/运维 |
| 问题复盘归档 | ⚠️ | 本工程有 PROGRESS/回执留痕习惯,可沿用 | Codex/用户 |
## 九、风险 / 待确认(`CODING_STANDARDS`§7 要求:技术决策必须留痕)
| # | 风险 / 待确认 | 影响 | 建议与责任方 |
| --- | --- | --- | --- |
| R1 | **媒资仍在本机磁盘**(`./data/uploads`),未迁 OSS/CDN | 单机磁盘满/机器损坏即媒资全丢;播放无法借 CDN 扛量 | `12-上线检查清单`§四把"迁 OSS/CDN"列为上线项,**属上线前必补**;`MediaStorageService` 已是唯一替换点,切换工作量可控(Codex 可做,需 OSS 账号) |
| R2 | **单点无高可用**:一台办公室主机跑全栈 | 机器/网络故障 = 服务全停;无灰度能力 | test 阶段可接受;**生产必须按 `04-部署架构`§三 生产规格(应用 2+、DB 主备)+ §五 上 K8s** |
| R3 | **备份未演练、无 WAL 归档、备份与库同机** | 真出事时可能既恢复不了也丢得多(RPO 远大于 5 分钟) | 上线前至少"每日全量 + 异机保存 + 一次恢复演练";两周内补 WAL 归档与加密(见 §五) |
| R4 | **监控与告警未接** | 故障靠人发现;上线首日风险最高的窗口无自动告警 | 短期用"人工冒烟 + 容器 healthcheck";随后按 `07-监控告警方案`部署(需先给服务加 actuator,属代码改动) |
| R5 | **商店合规未办**(隐私政策/Data Safety/SDK 清单/版权台账/包名) | 无法上架;印尼还涉及 Kominfo PSE 注册(未注册可能被整体封锁,见 `docs/08`/PRD 合规节) | 全部为用户侧工作;其中"包名统一"建议在上架前完成(一旦上架不可改) |
| R6 | **真实 Google Play 联调未做**(缺 packageName + service account JSON) | 支付闭环在真机/商店侧未验证;`STORE_VERIFIER=disabled` 时对外明确返回"尚未配置",不会产生假订单 | 用户提供凭据后,Codex 用沙盒测试购买联调(`STORE_VERIFIER=google-play`) |
| R7 | **HTTPS/域名未就绪**,当前 IP+HTTP | 不满足 `04-部署架构`§四与 `10-安全方案`§二;公网明文传输不可接受 | 上线前按 §二 配域名 + 证书 + nginx(用户/运维) |
| R8 | **DB 账号未做最小权限**(单账号读写全库) | 应用被攻破即拥有 DDL 能力 | 拆 `fruitshorts_app`(仅 DML)与备份只读账号(见 §七) |
| R9 | **`/swagger-ui/**` 静态资源在 `SWAGGER_ENABLED=false` 时仍可访问** | 不泄露接口信息(spec 与 config 都已 404),但严格合规环境下应彻底关闭 | 可选加固:`spring.web.resources` 排除 webjar;需你点头再改(Codex) |
| R10 | 转码 worker 与在线服务**同机**(虽为独立容器) | 大批量转码会抢 CPU,影响播放稳定性 | `04-部署架构`§三 的完整方案是 worker 独立 1 台;上线前如有第二台机器就拆开,否则给 worker 容器加 CPU 限额(Codex 可改 compose) |
## 十、上线前必补清单(按优先级)
| 优先级 | 事项 | 依赖 | 责任方 |
| --- | --- | --- | --- |
| P0 | HTTPS + 独立域名 + nginx 反代(§二) | 域名、证书 | 用户/运维 |
| P0 | 每日全量备份 + 异机保存 + 一次恢复演练(§五) | 目标机 + 异地存储 | 用户/运维(Codex 可提供脚本) |
| P0 | 媒资迁 OSS/CDN(R1) | 云账号 | 用户 + Codex |
| P1 | tag 规范落地 + 回滚演练(§四) | — | 用户(合并 main 时)/ Codex 配合 |
| P1 | 监控告警最低可用(§六:容器 unhealthy 能到人) | 通知渠道 | 用户/运维 |
| P1 | 真实 Google Play 联调(R6) | 付费凭据 | 用户 → Codex |
| P2 | DB 最小权限账号(R8)、密钥集中托管(§七) | 运维 | 用户/运维 |
| P2 | K8s 生产化(R2)、灰度能力、WAL 归档与加密 | 生产资源 | 后续阶段 |
+208
View File
@@ -0,0 +1,208 @@
# FruitShorts 后端部署与回滚(DEPLOY)
> 依据:`docs/04-部署架构.md`(第一阶段 **Docker Compose 单机**、镜像即交付物;生产才上 K8s)、
> `docs/11-发布与回滚方案.md`、`docs/12-上线检查清单.md`(**播放签名生产必开**、密钥走环境变量)。
> 目标机形态:**办公室一台主机**(§三 精简方案:app/api/DB/Redis 合并 1 台 8C32G,转码 worker 独立进程)。
> **本文只讲"怎么部署/怎么回滚"**。要回答"我们离 specs 还差什么、上线前必补什么",
> 看 **[DEPLOY-CHECKLIST.md](./DEPLOY-CHECKLIST.md)**(环境映射、域名/HTTPS、备份恢复、监控、发布与回滚规范、
> `docs/12-上线检查清单` 逐条对照表、风险/待确认)。
**这台办公室主机属于 `test` 环境**(`04-部署架构`§一:测试/联调、独立测试库、内网/VPN 访问);
`pre`/`prod` 的规格与前置条件见 `DEPLOY-CHECKLIST.md` §一。
## 一、两条部署路径(先选一条)
| 路径 | 适用 | 命令主线 |
| --- | --- | --- |
| **A. Docker Compose(推荐,文档口径)** | 目标主机装了 Docker + Compose;一条命令起全栈(含 PG 18 与 Redis) | 见 §三 |
| **B. 裸 jar(兜底)** | 主机没有 Docker,或只想起单个进程调试 | 见 §四 |
> 两条路径的**配置完全相同**(同一批环境变量),只是"谁来跑进程"不同。
## 二、环境变量清单
复制 `.env.example` 为 `.env` 填值(`.env` 已在 `.gitignore`,**不要提交**)。
使用 `--spring.profiles.active=prod`(Compose 里已默认)时,下表标 🔴 的项**缺失/仍是 dev 默认值/长度不足 32 位 → 启动直接失败**(fail-fast,见 §五)。
| 变量 | 必填 | 缺失后果 | 说明 |
| --- | --- | --- | --- |
| `DB_URL` | ✅ | 用默认 `jdbc:postgresql://127.0.0.1:5432/fruitshorts`,连不上就启动失败 | Compose 里默认指向 `db` 服务 |
| `DB_USER` / `DB_PASSWORD` | ✅ | 连库失败 | Compose 的 db 容器用同一组值创建库 |
| `DB_NAME` | Compose 必填 | 默认 `fruitshorts` | 只在 compose 里用到 |
| `JWT_ADMIN_SECRET` | 🔴 | **prod 拒绝启动** | 管理端令牌密钥,≥32 位,生成:`openssl rand -base64 48` |
| `JWT_APP_SECRET` | 🔴 | **prod 拒绝启动** | 客户端令牌密钥。**过去这个键没外置,一直用代码里的 dev 默认值**,务必注入 |
| `MEDIA_SIGNATURE_SECRET` | 🔴 | **prod 拒绝启动** | 播放地址签名密钥(`docs/12-上线检查清单`) |
| `MEDIA_SIGNATURE_REQUIRED` | 🔴 | prod 下不是 `true` 就拒绝启动 | 生产必须开;本地调试保持 false |
| `CORS_ALLOWED_ORIGINS` | 🔴 | prod 下为空则拒绝启动 | 逗号分隔的正式来源,**生产不许用 `*`** |
| `MEDIA_ROOT_DIR` | 建议 | 默认 `./data/uploads`,容器里必须为 `/data/uploads` | 媒资根目录(挂载卷) |
| `SERVER_PORT` | Compose 已设 | 默认 8081/8080 | app-api 8081 / admin-api 8080 |
| `SWAGGER_ENABLED` | 可选 | 默认 false(**测试/联调请在 `.env` 里设 `true`**,前端要看 `/doc.html`) | **一次开关两个**:`true` = `/doc.html` + `/v3/api-docs` 都开;`false` = 两个都关(生产不想暴露 UI 就设 false,机器可读契约也一并关闭) |
| `STORE_VERIFIER` / `GOOGLE_PLAY_*` | 可选 | 默认 `disabled` | 商店票据校验,没凭据就保持 disabled(对外返回"尚未配置") |
**密钥怎么生成**:`openssl rand -base64 48`(每个密钥单独生成,别复用)。
## 三、路径 A:办公室单机 + Docker Compose(主线)
目标主机前置:**Docker + Docker Compose**(`docker compose version` 有输出即可),建议 8C32G。
```bash
# 0) 取代码(或拿打包好的目录)并进入 FruitServer
cd FruitServer
# 1) 配置环境变量
cp .env.example .env
openssl rand -base64 48 # 生成三个密钥,分别填进 JWT_ADMIN_SECRET / JWT_APP_SECRET / MEDIA_SIGNATURE_SECRET
vi .env # 还要填 DB_PASSWORD、CORS_ALLOWED_ORIGINS
# 2) 构建并启动(首次会自动建表 + 造种子数据)
docker compose up -d --build
# 3) 看状态与日志
docker compose ps # 预期:db/redis healthy,app-api/admin-api healthy,transcode-worker running
docker compose logs -f app-api # 预期:出现 "生产配置校验通过" 与 "Started AppApiApplication"
# 4) 冒烟
tools/smoke.sh # 预期:全部 PASS,退出码 0
```
**端口与访问**:app-api `8081`(对客户端开放)、admin-api **只绑 `127.0.0.1:8080`**(管理端仅内网,公网通过 SSH 隧道或反代访问)、PG/Redis 不对宿主暴露。
**数据库初始化**:`sql/*.sql` 已挂进 `docker-entrypoint-initdb.d`(schema → schema-phase1 → partition → data → data-phase1),
**只在数据卷为空时执行**。改 SQL 后要重来:`docker compose down -v`(**会删数据**)再 `up`。
**日常操作**
```bash
docker compose ps # 状态
docker compose logs -f --tail=200 # 全部日志
docker compose restart app-api # 重启单个服务
docker compose up -d --build app-api # 只重建单个镜像
docker compose down # 停(保留数据卷)
```
## 四、路径 B:裸 jar(兜底)
目标主机前置:**JDK 21**;转码进程额外需要 **ffmpeg/ffprobe**(`brew install ffmpeg` 或 `apt install ffmpeg`)。
数据库:自己装 PostgreSQL 18 并导入 `sql/*.sql`(顺序同上)。
```bash
cd FruitServer
mvn -pl fruitshorts-app-api,fruitshorts-admin-api,fruitshorts-transcode-worker -am package -DskipTests
# 导出环境变量(示例;密钥务必换真实值)
export DB_URL=jdbc:postgresql://127.0.0.1:5432/fruitshorts DB_USER=fruitshorts DB_PASSWORD='***'
export JWT_ADMIN_SECRET="$(openssl rand -base64 48)" # 生产请固定保存,不要每次重新生成
export JWT_APP_SECRET="$(openssl rand -base64 48)"
export MEDIA_SIGNATURE_SECRET="$(openssl rand -base64 48)"
export MEDIA_SIGNATURE_REQUIRED=true CORS_ALLOWED_ORIGINS='https://admin.example.com'
export MEDIA_ROOT_DIR="$PWD/data/uploads"
# 起三个进程(生产用 prod profile;本地调试去掉 --spring.profiles.active=prod 即可)
nohup java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/admin.log 2>&1 &
nohup java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/app.log 2>&1 &
nohup java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/worker.log 2>&1 &
```
> ⚠️ **重新打包必须重启进程**:`spring-boot:repackage` 会原地替换 jar,运行中的进程再去加载"没加载过的类"
> 会抛 `NoClassDefFoundError`,表现为**请求挂住**(S1 记录过这个坑)。顺序永远是"改完 → 打包 → 重启 → 验证"。
## 五、prod fail-fast 是什么、长什么样
`prod` profile 下 `ProdSecretGuard` 会校验:JWT 双密钥 + 媒体签名密钥(非空、不是 dev 默认值、≥32 位)、
`signature-required=true`、`DB_URL` 非空、CORS 非空。任一不满足 → **打印全部问题并终止启动**:
```
ERROR c.f.framework.config.ProdSecretGuard : 生产配置校验未通过,拒绝启动(prod profile):
- fruitshorts.jwt.admin-secret(环境变量 JWT_ADMIN_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.jwt.app-secret(环境变量 JWT_APP_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.media.signature-secret(环境变量 MEDIA_SIGNATURE_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.cors.allowed-origins 为空(环境变量 CORS_ALLOWED_ORIGINS)—— 生产不允许用通配 *,必须显式列出来源
请按 FruitServer/DEPLOY.md 的环境变量清单补齐后重启。
```
exit code = **1**(部署脚本可以直接判失败)。**没有 prod profile 时这套校验不生效**,本地开发照旧开箱即用。
## 六、发布后验证(每次发版都做)
1. `docker compose ps`:三个应用容器 `Up (healthy)`(worker 是 `Up`)。
2. `tools/smoke.sh`:全 PASS、退出码 0。它检查 `/healthz`、`/app/categories`、`/app/home/feed`、
两个 `/v3/api-docs`、`/doc.html`,以及未授权路径是否按统一信封拒绝。
**关于在线文档那三项**:脚本会按 `SWAGGER_ENABLED` 自适应 —— `true` 时断言 200;
`false` 时打印 **SKIP**(不算失败),因为 `/doc.html` 与 `/v3/api-docs` 都被配置关掉了。
没设这个变量时脚本自动探测("200 且含 openapi"算开,"统一信封 code=404"算关,其它响应→FAIL)。
联调环境想强制要求文档在线:`SWAGGER_REQUIRED=true tools/smoke.sh`(不满足直接失败)。
3. `curl -s http://127.0.0.1:8081/healthz`:(从容器里或宿主机)应返回 `{"code":0,...,"status":"UP"}`。
4. 业务抽查:`curl -s http://127.0.0.1:8081/app/home/feed | head -c 200`(有内容);
后台 `http://127.0.0.1:8080/doc.html`(**需要 `.env` 里 `SWAGGER_ENABLED=true`**,联调环境请打开)。
5. 登录一个后台账号,确认能进后台首页(连通数据库与权限体系)。
## 七、回滚
**镜像/代码回滚**
```bash
# 用上一个版本的 tag(每次发版都打 tag:fruitshorts/app-api:2026-10-16-1)
docker compose stop app-api admin-api transcode-worker
docker tag fruitshorts/app-api:2026-10-15-2 fruitshorts/app-api:latest
docker compose up -d app-api admin-api transcode-worker
tools/smoke.sh
```
或用 git 回到上一个 tag 重新 `up -d --build`:
```bash
git fetch --tags && git checkout <上一个 release tag>
docker compose up -d --build
```
**数据库回滚(谨慎)**:schema 脚本是幂等增量(`ADD COLUMN IF NOT EXISTS` 等),**一般不需要回滚表结构**;
真要回滚数据用备份:`docker compose exec -T db pg_dump -U fruitshorts fruitshorts > backup.sql`。
**发布前先备份**是硬要求:
```bash
docker compose exec -T db pg_dump -U "${DB_USER:-fruitshorts}" "${DB_NAME:-fruitshorts}" > backup-$(date +%F-%H%M).sql
```
**回滚后必须重跑** `tools/smoke.sh`,并确认转码队列状态正常(后台「数据看板 → 转码看板」)。
## 八、未在本机验证的部分(如实说明)
本机**没有 Docker / colima / podman**(`docker` 命令不存在),所以:
- `Dockerfile`、`.dockerignore`、`docker-compose.yml` **未经本机构建与运行验证**(只做了语法与配置项静态检查)。
- 本机真实验证过的是:**裸 jar 路径**(两个入口 + worker 起停、`/healthz`、冒烟脚本)、
**prod fail-fast**(缺密钥时退出码 1 + 明确报错)、配置外置(`DB_URL` 等改动后仍能起)。
- 目标主机上有 Docker 之后,请**先跑 §三 的 1)–4) 步**确认;如报错把 `docker compose logs` 贴回来。
## 八之二、上线前必须补的(治理类,详见 DEPLOY-CHECKLIST.md)
以下四条**不是**"部署命令"层面的问题,但 `docs/12-上线检查清单` 把它们列为上线条件,此处给出醒目提示:
1. **HTTPS + 独立域名 + 反向代理**(管理端/客户端 API/媒资分域名,`04-部署架构`§四 + `10-安全方案`§二)—— 现在只有 IP:HTTP;
2. **备份恢复**:specs 要求每日全量×30 天 + WAL 归档 + 加密 + 异地 + **RPO≤5min / RTO≤30min** + 季度演练,
现在只有手动 `pg_dump`,且**备份与数据库在同一台机器上**(等于没备份);
3. **监控告警**:Prometheus/Grafana/Loki 未部署;第一阶段按 specs 允许用日志兜底,但"容器 unhealthy 要能通知到人";
4. **商店与合规**:隐私政策/Data Safety/SDK 清单/版权台账/包名统一,均未办(印尼还涉及 Kominfo PSE 注册)。
**逐条状态与"上线前必补清单(按优先级)"见 [DEPLOY-CHECKLIST.md](./DEPLOY-CHECKLIST.md)。**
## 九、故障速查
| 现象 | 先看 | 处理 |
| --- | --- | --- |
| 容器起不来,日志有"生产配置校验未通过" | §五 | 补 `.env` 里对应变量;密钥用 `openssl rand -base64 48` |
| `/healthz` 超时(不是快速 404/200) | 是不是刚重新打包没重启? | 重启进程(§四 的注意事项) |
| `app-api` healthy 但 `/app/*` 报连库失败 | `docker compose logs db` | 确认 `DB_URL` 指向 `db:5432`,`DB_PASSWORD` 与 db 容器一致 |
| 转码任务一直 `QUEUED` | worker 日志 | 确认 `transcode-worker` 在跑、`MEDIA_ROOT_DIR` 与 API 相同、镜像里装了 ffmpeg |
## 十、本地开发(不上 Docker)
```bash
mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests
java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar # 不带 prod profile,开箱即用
tools/smoke.sh # 本机一键冒烟
```
+72
View File
@@ -0,0 +1,72 @@
# 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"]
+87 -17
View File
@@ -7,7 +7,7 @@
| --- | --- | | --- | --- |
| [docs/prd-phase1.md](docs/prd-phase1.md) | 第一阶段 PRD:范围、验收口径、里程碑 | | [docs/prd-phase1.md](docs/prd-phase1.md) | 第一阶段 PRD:范围、验收口径、里程碑 |
| [docs/backend-design-phase1.md](docs/backend-design-phase1.md) | 后端设计:1 万并发观看的容量推导、成本、部署 | | [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) | 早期架构设计文档 | | [docs/architecture-design.md](docs/architecture-design.md) | 早期架构设计文档 |
| 项 | 版本 | | 项 | 版本 |
@@ -44,6 +44,7 @@ FruitServer/
| JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` | | JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` |
| Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` | | Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` |
| PostgreSQL | 18.6 | `brew install postgresql@18`,`brew services start postgresql@18` 启动 | | 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`, > `postgresql@18` 是 keg-only,命令行工具在 `/opt/homebrew/opt/postgresql@18/bin`,
> 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。 > 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。
@@ -51,8 +52,13 @@ FruitServer/
```sh ```sh
java -version # openjdk 21.0.12.1 java -version # openjdk 21.0.12.1
mvn -v # Maven 3.10.0 / Java 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 上构建与运行。 - 工程编译目标为 Java 17(`maven.compiler.release=17`),在 JDK 21 上构建与运行。
@@ -185,18 +191,43 @@ java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar
## 第一阶段实现进度 ## 第一阶段实现进度
> 最后更新:2026-10-16(后端 `develop` 分支 `4418a84`)。**未完成清单见文末「尚未完成」。**
| 模块 | 状态 | 说明 | | 模块 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护 | | 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护;**雪花主键(S0)**、**Swagger 在线文档 `/doc.html`(S1)** |
| 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 | | 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 |
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;补内容分级、可播区域、版权窗口、标签字段 | | 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;内容分级、可播区域、版权窗口、标签字段 |
| 04 播放器与播放控制 | 部分 | 播放票据 + 签名地址 + 进度已实现;多码率片源待转码 worker | | 04 播放器与播放控制 | 已实现 | 播放票据 + 签名地址 + 进度;多码率 HLS 由转码 worker 产出(见 10) |
| 05 记录、收藏与下载 | 部分 | 进度、观看流水、收藏已实现;离线下载授权待做 | | 05 记录、收藏与下载 | 已实现 | 进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 待做 | 表已建,接口与实现待补 | | 06 评论、点赞与分享 | 已实现 | 评论(游标翻页/审核/删除)、点赞、举报、敏感词;**分享埋点待 C3** |
| 07 创作者中心 | 待做 | 表已建;转码任务与多码率产物记账已实现 | | 07 创作者中心 | 已实现 | **在后台管理(Web 端 `ReelShortWeb`)**:工作台、内容编辑、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;不含提现/结算。**App 客户端不提供创作者中心**(口径 2026-10-16) |
| 08 平台运营后台 | 部分 | App 用户管理已实现;内容治理、订单、看板待做 | | 08 平台运营后台 | 已实现 | 用户管理、内容治理、订单与权益、创作者与内容审核、数据看板 + 四个后台页面 |
| 09 分集收费与支付 | 待做 | 表已建;播放入口的权益判定端口已预留,接上即生效 | | 09 分集收费与支付 | 部分 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,文档口径为"预留") |
| 10 质量、数据与上线 | 待做 | 压测、埋点、对账待做 | | 10 质量、数据与上线 | 部分 | **转码 worker(C1)已实现**;**Redis 缓存与限流(C2)、埋点(C3)、压测未做** |
### 已完成批次(按提交顺序)
| 批次 | 内容 | 提交 |
| --- | --- | --- |
| S0 | 主键改雪花(58 表 / 52 实体 / 存量库幂等迁移脚本) | `37c9a14` `84aa0e1` |
| S1 | 接口文档在线化(39 个 Controller / 172 个接口中文注解 + `/doc.html`) | `c6fcfcc` |
| 契约补遗 | 补全接口契约遗漏(系统管理 / 内容 / watch-history / creators 账号 / media 详情) | `efe8af6` |
| M2 | 后端契约测试补齐(**0 → 37 条**) | `fafadfc` |
| B1-1 | Google Play 票据校验闭环(校验→建单→发权益) | `db897d5` |
| B1-2 | 恢复购买 / 退款拒付回收 / 对账真实比对 + Apple 端口预留 | `1dc97b4` |
| C1 | 转码 worker(独立进程消费 `transcode_task`,四档 HLS + 封面) | `9f76302` |
| D1 | 部署就绪(配置外置 + prod fail-fast + 自包含 Compose + 冒烟/回滚) | `b567365` |
| D1-fix | 冒烟脚本与 prod Swagger 开关解耦 | `cbb9961` |
| D2 | 部署文档按 specs 约束补全(新增 `DEPLOY-CHECKLIST.md`) | `7dd4b15` |
| 部署修复 | PG18 数据卷挂载路径 + worker 的 prod 密钥/CORS 映射(PR #1) | `8b46e53` `4c4f643` |
| E1 | 补 5 个 C 端接口(`/app/creator/apply`、`/app/config`、`/app/notices`) | `14b5e41` |
| 契约修正 | 移除 `/app/creator/works`、`/app/creator/stats` 的 C 端定义 | `bb21920` |
| E1-rollback | 撤除上述两条接口对应代码(创作者中心归 Web 端) | `4418a84` |
**门禁现状**:`mvn test` **91 条全过**(app-api 49 / commerce 27 / worker 12 / admin-api 3)。
> 客户端仓库 `ReelShort`(Flutter)另有已完成批次:网络层换 `dio`(`1853e18`)、客户端改名 `nontonki → FruitShorts` + 印尼语 `id-ID` 文案(`61454e3`)。
## 默认账号 ## 默认账号
@@ -393,15 +424,54 @@ public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) { ...
6. 表格状态列改用接口返回的 `status` / `statusText`, 6. 表格状态列改用接口返回的 `status` / `statusText`,
大数值用 `src/utils/format.js` 的 `formatCount` 格式化。 大数值用 `src/utils/format.js` 的 `formatCount` 格式化。
## 尚未实现 ## 尚未完成
评论点赞分享、订单与钱包权益(IAP / Google Play Billing 票据校验、对账、退款回收)、 > 最后更新:2026-10-16。完成后请同步本表与 [docs/api-phase1.md](docs/api-phase1.md)。
创作者中心、内容治理与经营看板、转码 worker(消费 `transcode_task`,产出多码率 HLS)、
Redis 缓存与限流、离线下载授权、埋点与数据看板。
这些模块的表结构、接口契约、状态机与权限点都已经冻结,见 ### 一、后端研发(剩余)
[docs/api-phase1.md](docs/api-phase1.md) 里标注「下一批」的部分 —— 接下来是照契约填代码,
不会再有结构性改动。 | # | 项 | 内容 | 依据 |
| --- | --- | --- | --- |
| 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`,
> **两份看板问题已从根上消失,该方案不再需要**。
## 常见问题 ## 常见问题
+148
View File
@@ -0,0 +1,148 @@
# 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"
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
ports:
# docs/12-上线检查清单「管理端仅内网」:只绑本机,公网不直接暴露
- "127.0.0.1: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
volumes:
# 命名卷:跟着 compose 项目走,`docker compose down -v` 才会删
db-data:
redis-data:
+280 -17
View File
@@ -16,6 +16,29 @@
`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。 `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>`。 **鉴权**:`Authorization: Bearer <token>`。
管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。 管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。
客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。 客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。
@@ -74,6 +97,30 @@ 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** 个、同设备 24h ≤ **3** 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 |
频控口径落库在 `app_user.register_ip` / `register_device_id` 上(按 24h 计数,不新建计数表)。
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。
响应(登录、刷新同结构): 响应(登录、刷新同结构):
```json ```json
@@ -197,23 +244,146 @@ POST /app/auth/register
| GET | `/app/wallet` | 金币余额 + 最近流水 | | GET | `/app/wallet` | 金币余额 + 最近流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 | | POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
| GET | `/app/orders` | 我的订单 | | GET | `/app/orders` | 我的订单 |
| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据。**本轮只留端口不接商店**,会返回 `200003 尚未配置` | | POST | `/app/orders/verify` | 提交 Google Play 票据:服务端校验 → 建单 → 发权益(详见下方说明) |
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供, 解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。 播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
### 1.9 创作者(下一批) **`POST /app/orders/verify`(B1-1:Google Play 票据校验闭环)**
| 方法 | 路径 | 说明 | 请求体:
```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"
}
```
| 字段 | 必填 | 校验 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/app/orders` | 我的订单 | | `name` | ✅ | 1–128 字 |
| GET | `/app/wallet` | 金币余额与流水 | | `type` | 否 | `INDIVIDUAL`(默认)/ `COMPANY` |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集 | | `contactName` / `contactMobile` / `contactEmail` | 否 | 只是联系方式,不参与鉴权 |
| POST | `/app/creator/apply` | 创作者入驻 | | `countryCode` | 否 | 默认 `ID` |
| GET | `/app/creator/works` | 我的作品与审核状态 | | `agreementVersion` | ✅ | **必须等于 `/app/config` 的 `agreementVersion`**,否则 `400` |
| GET | `/app/creator/stats` | 创作者数据看板 |
| GET | `/app/config` | 启动配置(协议版本、开关、最低版本) | **幂等口径(硬要求)**:以「**C 端账号唯一**」防重(DB 唯一索引 `uk_creator_user(user_id) where user_id > 0`):
| GET | `/app/notices` | 系统公告 |
| 情况 | 行为 |
| --- | --- |
| 首次申请 | 建档,`status=pending`,返回 `{id, code, status, statusText, auditNote, appliedAt}` |
| 已申请(pending) | **返回当前状态**,不重复建档、不改动已有记录 |
| 已通过(approved) | 返回 `status=approved`(**创作者工作台在 Web 端**,见 §2.10;App 端只提示"已通过,请到创作者后台使用") |
| 被驳回 / 冻结 | 返回当前状态与 `auditNote`(是否允许重新申请由运营在后台处理,本期不做二次申请) |
| 并发重复提交 | 唯一索引兜底:捕获冲突后**读回已有记录返回**,不报 500 |
## 2. 后台管理接口(/admin/**) ## 2. 后台管理接口(/admin/**)
@@ -363,6 +533,23 @@ POST /app/auth/register
| POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 | | POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 |
| POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) | | 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 创作者与内容审核(已实现) ### 2.7 创作者与内容审核(已实现)
| 方法 | 路径 | 权限点 | 说明 | | 方法 | 路径 | 权限点 | 说明 |
@@ -578,17 +765,93 @@ POST /app/auth/register
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET | `/admin/creator-center/impersonation/targets` | `creator-center:impersonate` | 代看目标下拉的数据源(id / 名称 / 状态) | | 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. 契约与实现状态对照 ## 3. 契约与实现状态对照
> 最后更新:2026-10-16(`develop` 分支)。未完成项汇总见仓库根 `README.md`「尚未完成」。
| 模块 | 表结构 | 接口 | 代码 | | 模块 | 表结构 | 接口 | 代码 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现 | | 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现;**S0 雪花主键**、**S1 Swagger 在线文档** 已完成 |
| 02 用户账号与登录 | 已建 | 已冻结 | **已实现**(手机号/邮箱,第三方登录走同一凭证表) | | 02 用户账号与登录 | 已建 | 已冻结 | **已实现**(手机号/邮箱,第三方登录走同一凭证表) |
| 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) | | 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) |
| 04 播放器与播放控制 | 已建 | 已冻结 | 票据 + 签名 + 进度 已实现;多码率依赖转码 worker | | 04 播放器与播放控制 | 已建 | 已冻结 | **已实现**:票据 + 签名 + 进度;多码率 HLS 由转码 worker(C1)产出 |
| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 | | 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;分享埋点在下一批 | | 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;**分享埋点待 C3** |
| 07 创作者中心 | 已建 | 已冻结 | **已实现**:创作者工作台(我的作品 / 审核进度 / 通知)、内容编辑与分集管理、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;Web 端页面见 2.10,不含提现 / 结算 | | 07 创作者中心 | 已建 | 已冻结 | **已实现**(**在后台管理 Web 端**):创作者工作台(我的作品 / 审核进度 / 通知)、内容编辑与分集管理、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;Web 端页面见 2.10,不含提现 / 结算。**App 客户端不提供创作者中心**(口径 2026-10-16) |
| 08 平台运营后台 | 已建 | 已冻结 | **已实现**:后台接口(用户管理、内容治理、订单与权益、创作者与内容审核、数据看板)与后台页面(内容治理 / 订单与权益 / 创作者审核 / 数据看板,见 2.9)全部落地 | | 08 平台运营后台 | 已建 | 已冻结 | **已实现**:后台接口(用户管理、内容治理、订单与权益、创作者与内容审核、数据看板)与后台页面(内容治理 / 订单与权益 / 创作者审核 / 数据看板,见 2.9)全部落地 |
| 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) | | 09 分集收费与支付 | 已建 | 已冻结 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,口径为"预留") |
| 10 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 | | 10 质量、数据与上线 | 已建 | 已冻结 | **转码 worker(C1)已实现**;**Redis 缓存与限流(C2)、埋点(C3)、压测 未做** |
+68
View File
@@ -0,0 +1,68 @@
# I1 后台管理端:商品(金币包)配置 + 系统公告管理 —— 计划(2026-10-16)
> 依据:`FruitServer/docs/plans/INBOX.md` 17:30 条目。用户口径「现在做后台管理端,规范还是按照 specs」。
> **本轮=后端接口 + ReelShortWeb 页面一起做**。指令通道已迁到本仓库(`FruitServer/docs/plans/`),
> 留痕写 `FruitServer/docs/plans/PROGRESS.md`(本轮新建);**specs 只读、不再写入**。
## 一、两个缺口(PM 已勘察,直接用)
1. **商品/金币包配置后台无入口**:`product` 表 16 字段已建、3 行种子、C 端 verify 依赖 `store_product_id` 对齐商店商品,但 admin-api 没有任何商品 CRUD。
2. **公告有读没写**:`ops_notice` 表 0 行,E1 交付了 C 端读,后台没有写入口。
## 二、勘察补充(影响做法,已实测)
1. **前端挂载机制**(与条目描述略有出入,已核实):`src/utils/routerHelper.ts` 用
`import.meta.glob('../views/**/*.{vue,tsx}')` + 后端菜单的 `component` 字符串**动态解析**;
`src/router/index.ts` 的 `asyncRouterMap` 只静态声明了 dashboard/content/user/system 等早期分组,
commerce/governance/board 这些都没有静态登记。→ 所以新增页面**只需**:① 新建 `.vue`;
② `sql/data-phase1.sql` 里加菜单且 `component` 与视图路径一致。**静态路由不必改**(改了也无害)。
真正的坑是 `route_name` 唯一(曾因 `CreatorCenter` 重名导致 404),本轮命名会避开。
2. **id 保留段**:权限点已用到 1155、菜单已用到 30 → 权限点用 **1160-1163**、菜单用 **31/32/33**。
3. **前端既有约定**:api 封装在 `src/api/admin/*`(`import request from '@/axios'` + `PageResult`),
页面用 `useRbac().can(code)` 控权、`dateCell` 格式化日期、`PageHeading` 标题;金额/金币这类大数值本项目
没有 `formatCount`,实际用的是 `src/utils/format.js`,**我会用其中的数值格式化工具或 `toLocaleString`**,
并在回执说明(不新造 util)。
## 三、口径决定(需在回执与契约里写清)
**VIP 商品**:**允许创建、不允许启用**(`PUT /{id}/status` 把 `type=VIP` 置为启用 → `400`)。
理由:运营可以先把 VIP 商品与商店 product id 录好,等 B2 订阅上线后一键启用;直接拒绝创建会让上线日重新补数据。
B1-1 已定「VIP 订阅本轮不做」,所以**启用**才是会产生误导的动作,拦在这里。
## 四、后端做法
1. **契约先行**:`docs/api-phase1.md` 新增一节(放在 §2.6 订单与权益之后的新编号),写清两个模块的
接口、字段、校验、错误码、权限点。
2. **商品**(module-commerce 加 service/VO,admin-api 加 controller):
`GET /admin/products/page`(type/status/platform/keyword + 分页)、`GET /{id}`、`POST`、`PUT /{id}`、
`PUT /{id}/status`、`DELETE /{id}`;字段按表结构;校验:`code` 全局唯一、`store_product_id` 同 `platform` 唯一、
`type=COIN → coin_amount>0`、`type=VIP → vip_days>0`、**VIP 不可启用**。响应带 `statusText/typeText`(前端不写死中文)。
3. **公告**(module-system 加 `OpsNoticeService` + VO,admin-api 加 controller):
`GET /admin/notices/page`、`GET /{id}`、`POST`、`PUT /{id}`、`PUT /{id}/status`、`DELETE /{id}`。
**筛选逻辑收敛到 `OpsNoticeService.pageForApp()` 一处**,C 端 `AppNoticeService` 改为委托它
(改我自己 E1 写的文件),这样"后台发布 → C 端可见 / 过期或下架 → 不可见"用的是同一段条件,不会漂移。
4. **菜单与权限点**:`sql/data-phase1.sql` 幂等追加(权限点 1160-1163、菜单 31/32/33 与超管授权)。
商品挂「订单与权益」(id 22) 下;公告新建「运营管理」(id 32) 分组 + 子菜单 (33)。
5. **Swagger**:两个新控制器按 S1 规范加 `@Tag`/`@Operation`,并把分组补进 admin-api 的 `@OpenAPIDefinition`。
## 五、前端做法(ReelShortWeb,develop 分支)
- `src/api/admin/product.ts`、`src/api/admin/notice.ts`(按 `commerce.ts` 的写法)
- `src/views/commerce/ProductManagementView.vue`(列表/筛选/新建编辑弹窗/上下架/删除)
- `src/views/operation/NoticeManagementView.vue`(列表/新建编辑/发布下架/删除)
- `sql/data-phase1.sql` 的菜单 `component` 写 `views/commerce/ProductManagementView`、`views/operation/NoticeManagementView`
- **不改既有页面**;`route_name` 用 `commerce-products` / `operation-notices`(先 grep 确认不重名)
## 六、门禁
- 后端:`mvn test` 存量 **91 条不得跑红** + 新增测试(商品三类校验、公告可见性委托、
两个控制器的接入层契约、权限点注解检查)
- 前端:`pnpm build:pro` 通过 + eslint 零错误
- 端到端:后台登录 → 菜单出现两个新页面 → 建商品 → 用该商品在 C 端 `verify` 能对上
(`store_product_id` 命中,不再是 `200002 该商品未在后台配置`);公告发布 → C 端 `/app/notices` 可见 → 下架/过期 → 不可见
## 七、风险 / 待确认
1. **不新增静态路由**是否与条目"两边都要加"冲突:我按实测的加载机制做,若你要求同时静态登记,我再补(不影响正确性)。
2. `/app/orders/verify` 走真实 Google Play 才有完整链路;本机无商店凭据,端到端验证到"200002 消失、进入校验环节"为止。
3. 公告"发布时间"语义:`publish_at` 为空视为立即生效(C 端条件里已按"为空或 <= now"处理)。
+91
View File
@@ -0,0 +1,91 @@
# J1 全局 ID 字符串化(Long → String)+ 契约统一 —— 计划(2026-10-16)
> 依据:`docs/plans/INBOX.md` 18:30 条目。用户裁定:雪花 id 精度问题**按方案 (a) 全局修改、统一契约**。
> 起因:I1 实测「UI 点下架 → `200002 公告不存在`」,根因是 19 位雪花 id 超出 JS `Number.MAX_SAFE_INTEGER`
> (9007199254740991),浏览器 `JSON.parse` 会把它四舍五入。
## 一、方案
### 1. 后端:fruitshorts-framework 里注册全局序列化模块
- 新增 `com.fruitshorts.framework.web.JacksonLongToStringConfig`:
- `SimpleModule` 把 **`Long.class` 与 `Long.TYPE`(`long`)→ `ToStringSerializer`**;
- 通过 `Jackson2ObjectMapperBuilderCustomizer` 挂进 Spring Boot 自动配置的 `ObjectMapper`。
- 两个入口(admin-api / app-api)都是 `scanBasePackages = "com.fruitshorts"`,framework 在扫描范围内,
一处生效即两端生效;**不改任何 yml**。
- **不动 `Integer` / `int`**:`pageIndex` / `pageSize` / `Result.code` / `quantity` / `durationSec` …
仍是数字。
- **序列化方向**:`Long` 值一律输出成 JSON 字符串(含普通字段、嵌套对象、`List`、`Map` 值、分页 list 元素)。
- **反序列化方向**:Jackson 默认允许 `"123"` → `Long` 的标量强转,所以**请求体里 id 传字符串可以直接绑定**;
路径变量 `/…/{id}` 与查询参数走 Spring 的 `ConversionService`,本来就是 String → Long,不受影响。
这两条都要有测试兜住。
### 2. 一个刻意的例外:`PageResult.total` 保持数字
`PageResult.total` 是 `long` 计数,不是 id。放行它会让后台 ~13 处 `el-pagination :total=` 与 C 端分页全部变成
字符串,收益为零、风险不小。所以给这个字段加 `@JsonSerialize(using = NumberSerializer.class)` 显式保持数字,
并在契约 §0 里写明「**id 一律字符串;分页 total 是计数,仍是数字**」。
⚠️ 这是我自己拍的口径,已单独列进回执,若要求"Long 一律字符串"我再把这一行去掉。
### 3. 契约统一(`docs/api-phase1.md`)
- §0 通用约定新增「**所有 ID 字段(主键与外键)一律以字符串下发**」+ 示例 + `total` 例外说明。
- §2.11 / §2.12(I1 商品/公告)里逐模块写的 "id 以字符串下发" 改成引用 §0。
- **代码侧**:I1 给 `ProductRespVO` / `NoticeRespVO` 加的 `@JsonSerialize` 与 `Result<String>` 新建返回
**回退成自然写法**(全局生效后它们就是冗余的特例),保持"所有模块一个口径"。
### 4. ReelShortWeb 适配(审计结果见 §二)
把 id 当数字用的地方逐处改成字符串语义。**不建议**的写法(`Number(id)` / `id === 数字`)全部清掉。
### 5. ReelShort(Flutter)适配(最小改动)
**先说结论:客户端 model 里的 id 已经是 `(json['id'] ?? '').toString()` —— id 方向本来就是字符串,不会崩。**
会崩的是被全局规则扫到的 **Long 计数**(`as num?` 在收到字符串时直接抛异常)。逐个 Java 字段核过类型后,
只有两个字段真正会崩:
| Dart 位置 | 字段 | Java 类型 | 处理 |
| --- | --- | --- | --- |
| `lib/core/data/models/auth_session.dart:41` | `expiresIn` | `TokenRespVO.expiresIn` = `long` | 改成兼容解析 |
| `lib/core/data/models/drama.dart:101,117` | `viewCount` | `AppDramaCardVO/AppDramaDetailVO.viewCount` = `Long` | 改成兼容解析 |
| 其余 8 处 `as num?`(`durationSec`/`unlockCoin`/`number`/`totalEpisodes`/`freeEpisodes`/`code`…) | — | `Integer`/`int` | 不动(Java 侧不会被字符串化) |
做法:加一个兼容解析小工具(接受 `num` 或数字字符串),替换上面 3 处。**不做模型改造、不碰业务逻辑**,
避免和正在推进客户端的另一位同事冲突。
## 二、影响面清单(审计结果)
### ReelShortWeb(id 当数字用 → 必须改)
| 文件 | 问题 | 改法 |
| --- | --- | --- |
| `src/utils/creatorImpersonation.ts` | `Number(raw)` 读 localStorage 里的代看创作者 id → 19 位 id 被四舍五入,`X-Creator-Id` 头会带错值 | 存/读都按字符串 |
| `src/views/contentManagement/EpisodeManagementView.vue:58` | `Number(route.query.drama)` 与 `item.id ===` 比较 → 从短剧管理带 `?drama=` 跳过来选不中 | 按字符串比较 |
| `src/views/creator-center/composables/useDramaEdit.js:45` | `Number(route.params.id)` 与 `item.id ===` 比较 → 作品编辑找不到自己那条 | 按字符串比较 |
| `src/views/permission/composables/useMenuManagement.js:50,76,97,117` | 顶级菜单哨兵值是数字 `0`,而 `raw.parentId` 会变成字符串 `"0"` → 下拉回显错 | 哨兵统一成字符串 `'0'` |
### ReelShortWeb(核过不需要改)
- `total.value = res.data.total`(13 处)→ `total` 保持数字,无需改。
- `role.id === selectedRoleId`、`detail.id === row.id`、`useDramaEdit#findIndex`、`useCreatorPrice#find`
→ 两侧都来自接口,同为字符串,仍然相等。
- `formatCount(row.views)` → 内部 `Number(value)`,吃到字符串也正常。
- 全局 `parseInt`/`Number(`/id 算术/id 当数组下标/`a.id - b.id`:审计后**没有命中**。
### FruitServer(会跟着变化、需确认消费方)
- admin-api:所有 `Long` id、`viewCount`、`likeCount`、`memberCount` 等计数器 → 字符串。
- app-api:C 端 `id/dramaId/episodeId/userId`、`viewCount`、`positionMs/durationMs`、
`downloadedBytes/totalBytes`、`TokenRespVO.expiresIn` → 字符串;`Result.code`、`PageResult.total`、
`Integer` 字段 → 仍是数字。
## 三、风险 / 待确认
1. **`PageResult.total` 的例外**(见 §一.2)—— 我自己拍的,一句话可回退。
2. **契约测试的 standalone MockMvc 自带 mapper**:它本来是手搓的 `JsonMapper`,不含全局模块,
会让"字符串化"在测试里**假绿**。所以本轮把模块也挂进 `ControllerContractSupport`,让测试与生产一致;
代价是几条老断言(id 断数字)要改成断字符串。
3. **Flutter 仓库当前只有 `main`**(无 develop)。按条目要求在 develop 提交推送,会新建分支;
客户端由另一位同事推进,若他在 main 上继续,需要把这笔改动带过去 —— 回执里会写清。
4. `Map<Long, X>` 的 **key** 不在本次范围(只要求 Map 值);当前也没有接口返回 `Map<Long, ?>` 给前端。
5. 大 id 只在"新建后立刻用 id 操作"时暴露;老页面用种子小 id,所以**回归要靠端到端实测**,不能只看单测。
+95
View File
@@ -0,0 +1,95 @@
# K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整 —— 计划(2026-10-16)
> 依据:`docs/plans/INBOX.md` 19:40 条目。用户指令:「现在你要做的事对接客户端接口及 UI 调整,
> 仍然严格按照 specs 执行」→ 本轮做客户端 `ReelShort`(Flutter),目标是一条完整看剧闭环:
> **首页点进剧 → 详情正确 → 能起播 → 进度能续看 → 能收藏**,全链路走真接口。
> 契约以 `FruitServer/docs/api-phase1.md` §1.4 / §1.5 为准。
## 一、勘察更正(先说清,免得后面对不上)
条目里写「客户端只调了 6 个接口、没有详情和票据」—— 这条**不准确**,实测:
| 接口 | 现状 |
| --- | --- |
| `GET /app/dramas/{id}` | **已接**:`CatalogApi.dramaDetail` + `CatalogController.requireDetail`,详情页在用 |
| `GET /app/episodes/{id}/play` | **半接**:`CatalogApi.playTicket` + `CatalogController.resolvePlay` 存在,但**没有任何页面调用**;详情下发的 `playUrl`(免费集已带签名)被直接使用 |
| `/app/watch/*` | **没接**:`WatchHistoryRepository` 是 `SharedPreferences` 本地实现,`demoProfileId` 假身份 |
| `/app/favorites/*` | **没接**:收藏走本地 `EngagementController`(SharedPreferences),点赞也是本地(点赞属 K3,不动) |
所以本轮真正要做的是:**播放票据接上并带过期重取**、**进度从本地换远程**、**收藏换服务端**、
**三态 UI**,以及把详情页已有的加载/失败态补齐(空态、续看进度条)。
## 二、方案(分层:api → repository → logic → view)
### 1. 新增 api 类(`lib/core/data/remote/`)
| 文件 | 方法 | 对应接口 |
| --- | --- | --- |
| `playback_api.dart`(新增) | `ticket(episodeId)` | `GET /app/episodes/{id}/play` |
| `watch_api.dart`(新增) | `saveProgress` / `progressOfDrama` / `recent` | `POST /app/watch/progress`、`GET /app/watch/progress?dramaId=`、`GET /app/watch/recent?limit=` |
| `library_api.dart`(新增) | `toggleFavorite` / `favorites` / `removeFavorite` | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` |
票据调用从 `CatalogApi` **搬**到 `PlaybackApi`(条目要求新增 playback_api),
`CatalogRepository.resolvePlay` 随之移到新的 `PlaybackRepository`——这是本轮唯一"动到既有目录层"的地方,最小接线。
### 2. repository(保留抽象,测试注入假实现)
- `playback_repository.dart`(新增):`ticketFor(episodeId, {forceRefresh})`。
`HttpPlaybackRepository` 内含**短时缓存**:默认 TTL 10 分钟(签名有效期契约是 15 分钟,留余量),
**时钟与 TTL 可注入** → 过期自动重取,测试用假时钟钉死。
- `watch_history_repository.dart`(**改签名**):服务端按 `(user, drama)` 存一条续看记录,
所以接口从 `(profileId, dramaId, episodeId)` 变成
`progressFor(dramaId)` / `save({episodeId, position, duration, finished})` / `recent(limit)`;
`HttpWatchHistoryRepository` + `InMemoryWatchHistoryRepository`(测试用);
`demoProfileId` 删除;`clampResumePosition` 保留(续看钳位规则不变)。
- `favorite_repository.dart`(新增):`toggle` / `list` / `remove`;HTTP + 内存实现。
### 3. controller / logic
- `favorite_controller.dart`(新增):`savedIds` + `cards` + `loading/error`,
`toggle(dramaId)` **乐观更新 → 失败回滚**(`DELETE`/`toggle` 任一失败都回到原状态)。
- `DramaDetailLogic` / `MineLogic` / `PlayerLogic`:改用新的 `WatchHistoryRepository`,
续看点从 `WatchProgress(episodeId, positionMs)` 来(不再是本地 episodeId 字符串)。
- `PlayerLogic`:
- 起播前 `ticketFor(episode)`,把签名地址写回 session 的剧集对象;**地址原样交给播放器**
(`AppConfig.mediaUrl` 只补 host,不动 path/query —— 新增测试钉住 `?e=&s=` 原样保留);
- 票据拿不到 / `accessType=COIN_REQUIRED|NO_MEDIA` → 由**后端返回值**驱动 UI(锁定/无片源提示 + 重试);
- 进度上报**节流 10 秒**(原实现是 5 秒),切集/退出/切后台各补一次,`finished` 在播完时上报。
### 4. UI
- 详情页:加载中 / 失败(可重试)/ 空剧集三态;续看位置在"继续观看"按钮与对应剧集行上给进度条。
- 播放页:票据加载中 / 失败(可重试)/ 锁定(`accessType` 驱动)三态;收藏按钮真实状态 + 回滚 +
失败提示(未登录先引导登录,沿用现有 `ensureSignedIn`)。
- 我的页:最近观看卡片三态(服务端续看位置驱动)、收藏列表三态,去掉 `demoProfileId` 假身份。
- 文案:新增键写进 `app_en.arb` / `app_zh.arb` / `app_id.arb`,`flutter gen-l10n` 重新生成。
## 三、影响面清单
新增:`playback_api.dart`、`watch_api.dart`、`library_api.dart`、`watch_progress.dart`(模型)、
`playback_repository.dart`、`favorite_repository.dart`、`favorite_controller.dart`。
改:`watch_history_repository.dart`(签名)、`catalog_api.dart`(去掉 playTicket)、
`catalog_repository.dart`(去掉 resolvePlay)、`catalog_controller.dart`(同步)、
`play_ticket.dart`(补 dramaId/number/previewSeconds)、`app.dart`(注入三个仓库 + FavoriteController)、
`drama_detail_{logic,view,binding}.dart`、`mine_{logic,view,binding}.dart`、
`player_{logic,view,binding}.dart`、`discover_{logic,binding}.dart`(续看点来源)、
`app_{en,zh,id}.arb` + 生成的 4 个 l10n 文件。
测试:改 `test/support/fake_watch_history.dart`、`test/playback/watch_history_test.dart`、
`test/catalog/catalog_controller_test.dart`、`test/support/fake_catalog.dart`、
`test/{discover,mine,drama_detail,player}/…`(续看点改签名);新增
`playback_ticket_expiry_test.dart`、`progress_throttle_test.dart`、`watch_history_remote_test.dart`、
`favorite_controller_test.dart`、`player_ticket_gate_test.dart`、`integration/live_watch_flow_test.dart`。
## 四、风险 / 待确认
1. **改动面比"只加接口"大**:`WatchHistoryRepository` 的签名必须改(服务端是"每剧一条续看记录",
没有 profile 概念),这会连带改 4 个 logic + 6 个测试文件。这是接口模型决定的,不是顺手重构。
2. **未登录的边界**:`/app/watch/*` 与 `/app/favorites/*` 需要登录态。客户端策略:
未登录时**不发**这些请求(浏览/试看仍可匿名),收藏按钮沿用"先引导登录"。
3. **票据过期无法从响应判断**(契约里没有 expiresAt 字段),所以用**客户端 TTL(10 分钟)**保守失效重取;
若后端后续补 `expiresAt`,直接换成按字段判断即可(口径已写进计划)。
4. **`/app/watch/recent` 只给 dramaId/episodeId/位置**,没有剧名封面 —— K1 里"我的"页用
已加载的首页/发现卡片按 id 映射显示;映射不到就不显示(K2 的记录页再做成独立请求)。
5. 联调测试需要登录态:新增的 live 测试会**注册一个一次性邮箱账号**,跑完在库里清掉(回执写明)。
+730
View File
@@ -0,0 +1,730 @@
# INBOX — 给 Codex 的指令队列(FruitServer 仓库内)
> **Codex 每次开始任务前必须先读本文件**(2026-10-16 起指令通道从 specs 仓库迁到本仓库)。
> 按条目顺序执行,处理完的条目标记 `[已处理]`。指令来源:项目经理(Trae)。有异议在条目下留言,不要静默跳过。
>
> **规范以 specs 仓库为准**(`AGENTS.md`、`CODING_STANDARDS.md`、`GIT_WORKFLOW.md`、`docs/01~20`、`docs/plans/codex-研发任务清单.md`)——
> specs 现在**只拉取、由项目负责人更新**,我们不再往它提交。工作留痕写在本仓库 `docs/plans/PROGRESS.md`。
> ⚠️ **双 Agent 协作红线(Trae + Codex 共守)**
> 1. **单一写者**:同一时刻只有一个 agent 在改代码/写 PROGRESS/跑门禁。
> 2. **留痕**:谁干活谁写 `docs/plans/PROGRESS.md`(命令 + 原始输出 + 解读,逐动作追加)。Trae 接手时用「【Trae 接手】」标记。
> 3. **git 不冲突**:提交前先 `git status`/`git log`;只 `git add` 自己的文件,禁止 `git add -A`/`.`;不夹带对方改动。
> 4. **文件不覆盖**:写 INBOX/PROGRESS 前先读最新,追加不覆盖。
> 5. **求助通道**:遇到阻塞/不确定,在本文件顶部写「【待 Trae 协助】+ 问题描述」。
> 6. **禅道同步**:任务「开始/完成」时同步更新禅道任务状态。
> 7. **验收归用户**:每批做完**停在干净交接点**,由用户亲自验证;回执必须含「用户可照做的验证步骤 + 每步预期结果」。
> 8. **门禁(不达标不提交)**:后端 = `mvn test` 全过(核心接口须有契约测试);Web = `pnpm build:pro` 通过 + eslint 零错误;Flutter = `dart analyze lib test` 零告警 + `flutter test` 全过。
> 9. **分支(按 `GIT_WORKFLOW.md`)**:从 `develop` 拉 `feature/<模块>-<简述>`,小步提交;完成后 `git checkout develop && git merge --no-ff feature/...` 再 `git push origin develop`;**绝不动 `main`**(dev→main 走 PR,由用户操作)。
> 10. **设备验证必须「设备侧取证」(2026-10-16 新增,血的教训)**:真机 / 模拟器 / 小程序等目标设备的验证,**证据必须产自设备侧**(设备侧集成测试、设备日志、设备截图、设备侧抓到的连接)。**禁止**用"在开发机上 `curl` 某地址返回 200"证明设备可达 —— 本机 curl 本机网卡地址必然 200,这是**假阳性**(已实际误导过一次:`10.157.242.1` 是开发机 VPN 网卡地址,手机根本不可达,导致真机「剧目列表加载失败」)。**拿不到设备侧证据的,一律标「未验证」**,不得写成"已验证"。
---
## [待处理] 2026-10-17 | K2 客户端第二批:分类 + 观看记录 + 收藏列表 + 下载授权(A3/A5 剩余)+ UI
**用户指令(2026-10-17)**:「现在先做客户端 对接相关接口 及ui 严格执行specs 内的要求」。
> **本批只做 K2,做完停下写回执,不要连做 K3/K4。**
> 规范以 specs 为准(`docs/plans/codex-研发任务清单.md` 阶段 A3/A5、`docs/16-测试用例库.md` CONTENT/RECORD、`docs/09-测试计划.md`);
> 接口只认 `FruitServer/docs/api-phase1.md` §1.3 / §1.5 / §1.6,**契约里没有的接口一律不许自造**。
### 〇、开工前必读(按 specs 顺序,缺一不可)
1. `AGENTS.md`(项目上下文、强制约束、自主执行与沟通节奏)
2. `CODING_STANDARDS.md`(三端红线、Dart/Flutter 专章、门禁总表)
3. `GIT_WORKFLOW.md`(Git Flow、提交格式、审查门槛)
4. `FruitServer/docs/api-phase1.md` §0 / §1.3 / §1.5 / §1.6(接口唯一权威)
5. `docs/plans/codex-研发任务清单.md`(A3/A5)、`docs/16-测试用例库.md`(CONTENT/RECORD)、`docs/09-测试计划.md`
6. 本 INBOX 条目
**硬性执行口径(摘自上述规范,违反即打回)**
- **Git**:从 `develop` 拉 `feature/flutter-k2-library` 开发;提交信息 `type(scope): 描述`(`type` ∈ feat/fix/docs/test/refactor/style/perf/chore,≤50 字符,一个 commit 只做一件事);完成后 `git merge --no-ff` 回 `develop` 再 push;**禁止直接改 `main`**;只 `git add` 自己的文件,禁止 `git add -A`/`.`。
- **TDD**:先写测试(红灯)→ 实现(绿灯)→ 重构;测试命名 `should_<期望>_when_<条件>`;核心类(repository/service/logic)必配 `*_test.dart`。
- **Dart**:`provider` binding/logic/view 三层;`dio`;全程 `async/await`,**禁裸 `then()`**;IO 失败用结果对象/控制器错误字段承接,**不把异常直接抛到 UI**;禁止空 catch。
- **代码规模**:函数 ≤50 行、参数 ≤5、类 ≤300 行、单文件 ≤500 行、嵌套 ≤4 层、圈复杂度 ≤10;除 `0/1/-1/null/空串/布尔` 外禁止魔法值。
- **注释**:每个 public 类/方法必须有文档注释;文件头写一行中文职责;新文件首行 `// Author: fruit.com.cn`;代码/注释/提交信息**不得出现任何 AI 痕迹**。
- **留痕**:每步「叙述 + 工具命令 + 原始输出 + 解读」,追加到 `docs/plans/PROGRESS.md`;做前先写 `docs/plans/2026-10-17-K2.md`(步骤/影响文件/风险/待确认)。
- **l10n 生成物**(`lib/l10n/app_localizations*.dart`)豁免作者头,但 `.arb` 必须手写三份。
### 一、PM 已勘察的现状(实测,直接用)
- `ReelShort`(`develop` `00280ca`)已接:`/app/home/feed`、`/app/discover/feed`、`/app/dramas/{id}`、`/app/episodes/{id}/play`、`/app/watch/progress`(POST/GET)、`/app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`/app/favorites`(toggle/list/delete)、账号 4 个接口。
- **未接**:`GET /app/categories`、观看记录删除/清空、下载全部 8 个接口、`/app/me*`(属 K4)、互动(属 K3)。
- 现状缺陷:
- 发现页分类**是本地按 sections 下标硬编码**([discover_view.dart](file:///Users/jianghaifeng/Desktop/ReelShort/ReelShort/lib/pages/discover/discover_view.dart#L363)),不是 `GET /app/categories`;
- "我的-记录"只渲染**能在已加载首页/发现目录里匹配到**的剧([mine_view.dart](file:///Users/jianghaifeng/Desktop/ReelShort/ReelShort/lib/pages/mine/mine_view.dart#L634-L716)),匹配不到就消失;
- 收藏列表嵌在"我的"页签里,不能单独刷新/取消收藏;
- 下载**完全没有**。
### 二、本批范围(4 块,按顺序做)
#### K2-A 分类(A3 剩余)
- 接口:`GET /app/categories` → `[{ id: string, name: string }]`(J1:**id 是字符串**)。
- UI:发现页分类条改为**服务端分类**;点分类展示该分类的剧。
- 内容来源只允许 `GET /app/discover/feed` 的 `sections[]` 中 `code == "category-{id}"` 的分区(后端已按分类生成,见 `AppCatalogService#discoverFeed`)。
- 某分类在 discover 里没有对应分区 → 显示空态,**不要自己拼请求/自造接口**。
- 保留现有 `hot` / `new` 分区的入口与样式。
#### K2-B 观看记录页(A5 剩余)
- 接口:
- `GET /app/watch/recent?limit=20` → `[{ dramaId, episodeId, positionMs, durationMs, finished, playCount, updatedAt }]`(全部 ID 为字符串)
- `DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress`(清空)
- **契约缺口(必须按下面方式处理,不许改后端)**:`recent` **不返回剧名/封面**。做法:
- 先去重 `dramaId`,再对每个 id 调 `GET /app/dramas/{id}` 补卡片;
- **并发上限 4**,结果按 dramaId 缓存(同页内不重复请求);
- 单条详情失败**不能拖垮整页**:该条显示占位(剧名"加载失败"+重试),其余正常渲染;
- 在计划文档里记为「契约缺口:建议后续在 `ProgressRespVO` 补卡片字段(需 specs 负责人改契约)」。
- UI:独立"观看记录"页(入口:我的 → 观看记录)
- 每条:封面、剧名、第 N 集、`positionMs/durationMs` 进度、更新时间;点击进详情并续看;
- 单条删除、清空(二次确认);删除**乐观更新**,失败回滚并提示;
- 未登录 → 复用现有 `AuthGate` 登录引导(接口需要登录,见 `SecurityConfig`);
- 三态:加载中 / 空("还没有观看记录")/ 失败(可重试)。
#### K2-C 收藏列表页(A5 剩余)
- 接口:`GET /app/favorites`(返回 `AppDramaCardVO[]`,**与首页卡片同结构**)、`DELETE /app/favorites/{dramaId}`、`POST /app/favorites/toggle`。
- UI:独立"我的收藏"页(我的 → 收藏),复用首页卡片渲染:
- 取消收藏(按钮或长按/左滑,二选一但要能发现)→ **乐观更新 + 失败回滚**;
- 点击进详情;空/错/载三态;未登录 → `AuthGate`。
- **契约缺口**:TC-RECORD-04 写"分页正确",但 `GET /app/favorites` **没有分页参数**、返回全量数组 → 本批按全量实现,计划文档登记该缺口,**不许自造 `page/size`**。
#### K2-D 下载授权与管理(A5 最后一块,**不做实际落盘**)
- 接口(§1.6,全部需登录;设备号优先请求体、其次 `X-Device-Id`,现有 `ApiClient` 已带):
| 方法 | 路径 | 本批用途 |
|---|---|---|
| GET | `/app/downloads/policy` | 配额/授权天数/wifiOnly/清晰度/已用/剩余/`expirePolicyText` |
| PUT | `/app/downloads/settings` | 仅 Wi-Fi、最高清晰度 |
| POST | `/app/downloads` | 申请授权(body `episodeId`,`quality` 可省=AUTO) |
| GET | `/app/downloads` | 我的下载授权列表 |
| GET | `/app/downloads/{episodeId}/url` | 续传前刷新短时效地址(本批只提供 API 方法) |
| POST | `/app/downloads/{episodeId}/progress` | 进度上报(本批只提供 API 方法) |
| DELETE | `/app/downloads/{episodeId}` | 删除单条 |
| DELETE | `/app/downloads` | 清空,返回条数 |
- UI:
- 详情页/播放页加"下载"入口:调 `POST /app/downloads`;**付费/未解锁/配额满时直接展示后端 `message`**,不要自造判断(判定归服务端);
- 我的 → 下载:策略卡(剩余配额、授权天数、`expirePolicyText`)、授权列表(剧名、集号、`status`、`taskStatus`、`progressPercent`、到期时间)、单条删除、清空、仅 Wi-Fi/清晰度设置;
- 状态枚举全部**后端返回值驱动**(`ACTIVE/EXPIRED/REVOKED`、`QUEUED/DOWNLOADING/PAUSED/COMPLETED/FAILED/CANCELED`),前端不新增枚举;
- 空/错/载三态 + 未登录 `AuthGate`。
- **明确不做**(写进计划文档,避免误判"没做完"):文件分片落盘、断点续传、离线播放、`licenseKey` 本地校验 —— 需要存储/权限/加密播放方案,另立批次。本批只交付"授权与状态管理"。
### 三、UI 与规范约束(严格执行 specs)
- **语言**:所有新增文案必须进 `lib/l10n/app_{en,zh,id}.arb` **三份齐全**并重新生成;禁止硬编码中文/英文。印尼语为首发语言。
- **视觉**:沿用现有深色主题与 `AppColors`/组件风格(specs 里未提供 Stitch 出稿;若用户后续给出稿,再单独做视觉替换,本批不自行改主题)。
- **三态**:每个新页面/列表都要有加载、空、失败(可重试);失败文案走 `errorText` + l10n,禁止白屏/假装成功。
- **分层**:新增 `category_api / download_api`(放 `lib/core/data/remote/`),页面只依赖 repository/controller 抽象;沿用 K1 的分层与依赖注入方式。
- **ID**:19 位雪花 ID 一律按字符串解析(`json_numbers.dart`),**禁止 `as int`**;计数/时长同理按契约类型处理。
- **不要动**:首页/发现已接 feed 逻辑(分类来源替换除外)、登录注册、播放/进度/收藏已有逻辑(除必要接线)、`ReelShortWeb`、`FruitServer` 后端。
- 新文件首行 `// Author: fruit.com.cn`;测试命名 `should_<期望>_when_<条件>`。
### 四、门禁(不达标不提交)
1. `dart analyze lib test` **零告警**;`flutter test` **全过**(存量 33 个测试文件不得跑红)。
2. `flutter build apk --debug` 通过(CODING_STANDARDS §6 门禁总表含构建);若本机缺 Android SDK,回执里贴失败原始输出并标「未验证」,不得静默跳过。
3. 新增测试至少覆盖:分类→section 映射(含无分区空态)、记录页 N+1 详情请求去重/并发上限/单条失败降级、删除与清空回滚、收藏取消回滚、下载策略/授权列表渲染、未登录 AuthGate;核心类覆盖率 ≥80%。
4. **真机/设备侧验证由用户执行**(红线 7、10):回执里给"用户可照做的步骤 + 每步预期结果",**禁止用开发机 `curl` 冒充设备可达**;拿不到设备证据就标「未验证」。
5. Git:`feature/flutter-k2-library` 开发 → `git merge --no-ff` 回 `develop` → `git push origin develop`;**不动 `main`**;合并/推送前再跑一次门禁。
### 五、节奏与留痕
- 先写 `FruitServer/docs/plans/2026-10-17-K2.md`:范围、影响文件清单、上述 3 个契约缺口、风险、待确认。
- **TDD 节奏**:每块先写测试(红灯)→ 实现(绿灯)→ 重构;每完成一个模块跑一次门禁,零告警才继续。
- 每步在公司 `PROGRESS.md` 追加「叙述 + 命令 + 原始输出 + 解读」(PM 验收口径,缺一即打回)。
- 做完**停下写回执**(追加到 `docs/plans/PROGRESS.md` + 本条下),回执必须含:
- 哪些页面已走真数据、哪些仍是本地/未做;
- 用户可照做的验证步骤与每步预期;
- 测试与 analyze 的原始输出;
- 提交号。
- 禅道:开工/完成各同步一次任务状态。
### 六、PM 登记:specs 契约缺口(Codex 不要动,反馈给项目负责人)
1. **搜索**:任务清单 A3 写了"搜索"、TC-CONTENT-05 有搜索用例,但 `api-phase1.md` **没有 `/app/search` 接口** → 本批**不做搜索**。
2. `GET /app/favorites` 无分页参数,与 TC-RECORD-04"分页正确"不一致。
3. `GET /app/watch/recent` 不返回剧名/封面,记录页需 N+1 详情请求(建议后端补卡片字段)。
> **用户裁定(2026-10-17)**:**先按现状契约执行,上述缺口后期再补**。本批不得因缺口停工,也不得临时改后端或自造接口;按第二节的降级方案实现,缺口留到后续批次。
---
## [已处理] 2026-10-17 | 三库全量拉取 + `main` → `develop` 同步(Trae 执行,无代码改动)
**用户指令(2026-10-17)**:「先拉最新全量代码,三个库全拉,相关规范已经备齐、接口文档完备、INBOX更新、环境地址清单在docs下21-环境地址清单.md 同步main 分支到开发分支」。
### 一、执行结果(实测)
| 仓库 | 分支 | HEAD | 落后 origin/develop | 领先 | `origin/main` 未并入 | 工作区 |
|---|---|---|---|---|---|---|
| FruitServer | develop | `2d70858` | 0 | 0 | 0 | 干净 |
| ReelShort | develop | `00280ca` | 0 | 0 | 0 | 干净 |
| ReelShortWeb | develop | `3ec9ea6` | 0 | 0 | 0 | 干净 |
- 三库 `git fetch --all --prune` 后均无新提交,无需 `pull`。
- **ReelShort** 是唯一有 `main` 未同步的库:`eaf8fb4 style: 全量补充作者标识` 与 develop 改名(`nontonki`→`fruitshorts`)冲突 15 个文件。
解法:保留 develop 口径(`package:fruitshorts`)+ 按文件类型补回署名头(`.dart` → `//`,`.yaml` → `#`,`.xml` → `<!-- -->`);
`main` 版复活了 develop 已删除的 5 个文件(`test/catalog/demo_media_test.dart`、`test/catalog/home_feed_category_test.dart`、
`lib/pages/home/home_feed_sections.dart`、`lib/pages/home/widgets/home_feed_header.dart`、`lib/pages/home/widgets/category_swipe_region.dart`)→ 已 `git rm`;
`lib/l10n/app_localizations*.dart`(生成文件)不加署名头,已还原。
门禁:`dart analyze lib test` → `No issues found!`;提交 `00280ca`(作者 `jianghaifeng <haifeng_hello@163.com>`),已推 `origin/develop`。
### 二、⚠️ 待 PM / 用户确认
1. **`docs/21-环境地址清单.md` 在 specs 仓库中不存在**。已核对:specs `docs/`(仅 01~20 + README + plans + 参考规范材料清单 + 代码现状盘点)、三个代码仓 `git ls-files`、specs 全部远端分支(只有 `origin/main`)—— 均无此文件。请确认是尚未推送,还是在别的路径/仓库。
2. specs 本地仍有 22 笔提交未推送到远端(无写权限,`origin/main` 停在 `c528345`)。若以远端为准,需要决定是否把本地回退到 `origin/main`。
### 三、用户裁定(2026-10-17)
1. **specs 只有 `main`,只需同步** → 已将本地 `specs` 强制对齐到 `origin/main`(`c528345`),本地领先的 22 笔未推送提交已丢弃;此后 specs **只拉取,不提交**。
2. **specs 是唯一权威** → 规范、接口契约、任务清单一律以 specs 为准;与本仓库文档冲突时以 specs 为准。
3. **执行前置动作** → Codex 每次开始任务前**必须先读本文件(INBOX)**,按条目顺序执行;新条目由 PM 追加,不覆盖历史。
---
## [已交接 · 未验证(真机验证改由用户执行)] 2026-10-16 20:30 | K1-verify:用**正确地址**重做真机验证(设备侧取证)
**用户指令(2026-10-16 20:20)**:「让 Codex 用正确地址重做一次真机验证」。
**背景(为什么必须重做)**:你 1-3/1-4 那批的真机验证用的是
`--dart-define=API_BASE_URL=http://10.157.242.1:8081` —— 该地址是**开发机某块 VPN 网卡(utun)的地址**,
现已不存在,**手机侧没有这块网卡**,所以按它构建、安装到手机上的 App **在真机上必然请求失败**
(用户实测提示「**剧目列表加载失败**」,报错里就显示着这个地址)。
更关键的是:当时那条"证据"是**在开发机上 `curl` 本机网卡地址拿到 200** —— 本机 curl 本机必然 200,
**不构成真机可达的证据(假阳性)**。PM 已把 specs 里 4 处记录修正并作废该结论(`481d4bb`)。
### 一、重做验证(**必须设备侧取证**,见红线第 10 条)
1. **先取真实地址,不要硬编码**:`ipconfig getifaddr en0` 拿开发机局域网 IP(PM 实测当前 `192.168.31.187`;**换网络会变**)。
2. **前置条件**:手机与开发机在**同一局域网**;iPhone 已授权「**本地网络**」权限(设置 → 隐私与安全性 → 本地网络)。
3. 真机跑:`flutter run -d 00008101-000875813E42001E --dart-define=API_BASE_URL=http://<步骤1得到的IP>:8081`
4. **证据至少一条来自设备侧**(优先级从高到低):
- **首选:加设备侧集成测试**(`integration_test` 依赖 + `integration_test/` 目录),
用 `flutter test integration_test -d <device> --dart-define=API_BASE_URL=http://<IP>:8081` 在**手机进程里**
真实请求 `/app/home/feed` 与 `/app/dramas/{id}`,断言 `code=0` 且列表非空。
—— 这是可重复、可入库的最硬证据,也顺带把"设备验证"变成可回归的。
- 或:App 运行时抓**从手机 IP 到 8081 的已建立连接**(`lsof -nP -iTCP:8081 | grep <手机IP>`),
并**同时**给出后端侧确实收到该请求的证据。
- 或:手机侧截图(`device` 截图不支持无线 iPhone —— 那就用设备侧测试或连接证据替代)。
5. **若都拿不到:如实标「未验证」**,并说明卡在哪一步(同网段?权限未授权?),**不许含糊**。
6. 顺带确认原故障已消失:首页「剧目列表」能出内容(或由步骤 4 的测试断言覆盖)。
### 二、留痕要求(**只写本仓库,别动 specs**)
- `docs/plans/PROGRESS.md`(本仓库 FruitServer 这份)**追加**逐动作留痕:命令 + 原始输出 + 解读。
- 本条下写回执,含**用户可照做的验证步骤 + 每步预期结果**,并**明确标注"证据来自设备侧"**。
- **不要去改 specs 仓库**(它只拉取;那 4 处 PM 已修正,别重复改)。
### 三、门禁与 git
- `dart analyze lib test` 零告警 + `flutter test` 全过(新增的 integration_test 一起跑;若它依赖真机、无法在纯本机跑,就在回执里说明并给出真机执行命令与输出)。
- 在 `ReelShort` **`develop`** 提交并 `git push origin develop`;**不动 `main`**;新文件首行 `// Author: fruit.com.cn`。
### 四、节奏
做完**停下写回执**,不要连做下一批。
---
## [已处理·待用户验收] 2026-10-16 19:40 | K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整
**用户指令(2026-10-16 19:30)**:「现在你要做的事对接客户端接口及 UI 调整,仍然严格按照 specs 执行」→ 转到 **客户端 `ReelShort`(Flutter)**,按 specs 阶段 A 推进。
### 一、PM 已勘察的现状(实测,直接用)
**客户端目前只调了 6 个 C 端接口**(契约里已实现 **44 个**):
```
/app/home/feed /app/discover/feed
/app/auth/register /app/auth/login /app/auth/refresh /app/auth/logout
```
- `lib/core/data/remote/` 下**只有** `api_client / api_exception / api_interceptors / auth_api / catalog_api` —— **没有** playback / watch / library / interaction / me 的 api 类。
- `WatchHistoryRepository` 是 **`SharedPreferencesWatchHistoryRepository`(本地存储)**,`mine_logic` 还在用 **`demoProfileId`** 假数据 —— 观看进度/续看位置**全在本机**,没接 `/app/watch/*`。
- `EngagementRepository` 是 `SharedPreferences` / `InMemory` —— 点赞/收藏状态**也是本地假数据**。
- 已有:`dio` 网络层 + 拦截器(A1 ✅)、改名 + 印尼语(✅)、`json_numbers.dart` 兼容字符串 id(J1 ✅)。
### 二、本批范围(K1:一条完整看剧闭环)
> 目标:**从首页点进一部剧 → 详情正确 → 能起播 → 进度能续看 → 能收藏**,全链路走真接口。
1. **详情(A3 的一块)**:新增 `catalog_api` 方法对接 `GET /app/dramas/{id}`(含剧集列表;**免费集带已签名播放地址**)。
2. **播放(A4)**:新增 `lib/core/data/remote/playback_api.dart`
- `GET /app/episodes/{id}/play` → 票据 + 签名地址。**签名地址原样使用,不要拼接/改写**(文档硬要求)。
- 起播前取票据;票据/签名地址有有效期,**过期要重新取**,不要缓存到失效后还用。
3. **进度与续看(A5 的一块)**:
- `POST /app/watch/progress`(**每 10s 一次**,切集/退出/切后台各补一次)
- `GET /app/watch/progress?dramaId=`(进入详情/播放时定位续看位置)
- `GET /app/watch/recent?limit=20`("我的"页最近观看)
- **把 `WatchHistoryRepository` 的本地实现换成远程实现**(保留抽象接口,测试注入内存假实现);`mine_logic` **去掉 `demoProfileId`**。
4. **收藏(A5 的一块)**:`POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}`;收藏状态**以服务端为准**(不要再用本地 `EngagementRepository` 存收藏)。
5. **UI 调整**(本批必须一起做):
- 详情页 / 播放页 / "我的"页:**加载中 / 空 / 失败**三态齐备(失败要能重试,不要白屏或假装成功)
- 进度条与**续看定位**、收藏按钮的真实状态与乐观更新回滚
- 断网/超时的提示文案走 l10n(en / zh / id 三份都要有)
### 三、口径与约束(照 specs)
- **ID 是字符串**(J1 刚统一):解析走 `json_numbers.dart`,不要直接 `as int`。**计数类**(`viewCount`/`expiresIn`)也是字符串。
- `accessType` 等枚举**用后端返回值驱动 UI**(免费/付费/试看),**不要在前端写死**;付费集解锁不在本批(属 B2)。
- 接口层与业务层分离:**新增 api 类**、controller/logic 只依赖 repository 抽象(沿用现有 `catalog_api` + `catalog_controller` 的分层做法)。
- **不要动**已对接的首页/发现/登录注册逻辑(除必要的最小接线)。
- 新文件首行 `// Author: fruit.com.cn`;测试命名 `should_<期望>_when_<条件>`。
### 四、门禁
- `dart analyze lib test` **零告警** + `flutter test` **全过**(存量 27 个测试文件不得跑红;新增测试要覆盖:票据过期重取、进度上报节流、续看定位、收藏切换回滚、失败态)
- **真实后端联调**(8081):跑一次端到端并留证据 —— 首页 → 详情 → 播放 → 上报进度 → 重新进入看到续看位置 → 收藏出现在收藏列表
- 在 `ReelShort` **`develop`** 提交并 `git push origin develop`;**不动 `main`**
### 五、节奏
先写 `docs/plans/2026-10-16-K1.md`(计划 + 影响面 + 风险)→ 做 → 停下写回执(含**用户可照做的验证步骤 + 每步预期结果**,以及"哪几个页面现在走真数据、哪几个还是假的"的清单)。**不要连做 K2。**
> **后续批次(预告,不用现在做)**:**K2** = 记录/最近观看页 + 收藏列表页 + 分类/搜索 + 下载授权(A5/A3 剩余);**K3** = 互动(评论/点赞/举报/分享,A6);**K4** = 账号剩余(资料/设备/注销;**Google 登录需 Google OAuth 凭据**,属待你提供的付费/密钥类)。
---
## [已处理 · 待用户验收] 2026-10-16 18:30 | J1 全局 ID 字符串化(Long → String)+ 契约统一
> **PM 独立复核(Trae 2026-10-16 19:05)—— 交付 `FruitServer f35db2c` / `ReelShortWeb 3ec9ea6` / `ReelShort 94ff9f1`(三仓均在 `develop` 且与远端同步),验证通过。**
> - **门禁复跑**:`mvn test` **110 → 115 条全过**(commerce 33 / admin-api **16→21** / app-api 49 / worker 12)+ BUILD SUCCESS ✅
> - **全局配置落位核实**:`fruitshorts-framework/.../web/JacksonLongToStringConfig.java` 存在(一处生效两端,未改 yml)✅
> - **端到端真实验证(PM 亲自跑,非采信回执)**:新注册用户雪花 id `2111018722574602241` →
> ① `/admin/app-users/page` 返回 **`"id":"2111018722574602241"`(字符串)**、**无裸数字大 id**、`"total":2` **仍是数字**(符合约定);
> ② 用该 id 调 `/admin/app-users/{id}` → **`code=0`**(修复前此场景即 `200002`);
> ③ **真 node 逐位比对**:`typeof parsed.id === 'string'`、`String(parsed.id)` 与库值**逐位相等 = true**;测试数据已清理(残留 0)。
> - **按红线第 7 条,最终验收权仍归用户。**
>
> ⚠️ **三处口径待用户点头(PM 意见附后)**:
> 1. **唯一例外 `PageResult.total` 保持数字** —— PM 意见:**接受**(它是条数不是 id,前端分页组件要数字);但需在契约 §0「ID 一律字符串」处**显式写出这个例外**,否则前端会困惑。待 Codex 补一句。
> 2. **客户端改动比"只改 id 解析"略宽(物理上必须)** —— Codex 查明 Flutter 的 id 本来就是 `toString()` 不会崩,真正会崩的是被全局规则扫到的 **Long 计数**,故只改了 `expiresIn`、`viewCount` 等 3 处 + 新增 `json_numbers.dart`。PM 认可这个判断与最小改动口径。⚠️ **另需用户知晓**:`ReelShort` 原先**只有 `main`**,Codex 新建了 `develop` 并推送;若客户端同事仍在 `main` 上开发,这笔需要带过去。
> 3. **Codex 自我纠正了一处统计错误** —— 它第一版清理脚本用 `psql ... returning 1 | wc -l` 数行数,数到的是 psql 命令标签(删 0 行也输出 1),那轮"删除 1 行"是假数字;随后用**全库扫描**复核(uid/邮箱/雪花 id 零命中)确认无残留。PM 认可其如实披露。
>
> 📌 **仍挂着**:禅道里同样**没有 J1 任务**(项目 2 下只有 S0/S1、A1–A6、B1/B2、C1–C3),无可同步状态。
>
> ⚠️ **PM 顺手说明**:为做端到端验证,8080(PID 80375)/ 8081(80383)/ 3005 均为 Codex 重启后的新 jar,接口与页面正常。
**用户裁定(2026-10-16 18:10)**:雪花 id 精度问题 **「按方案 (a) 全局修改,统一契约」**。
**背景(两方独立复现同一根因,故现在铺全局)**:
- 你在 I1 里实测到:UI 点「下架」后端回 `200002 公告不存在` —— 库里 `2111011206851596290`,浏览器 `JSON.parse` 后变 `2111011206851596300`,回查自然找不到。你把修复**限定在本轮两个模块**并留了待裁定。
- PM 独立复核同样确认:新注册用户 id `2111012753262120962` 由 `/admin/app-users/page` **以裸 JSON 数字**下发;用真 JS 引擎验证 `JSON.parse('{"id":2111012753262120962}').id` → `2111012753262121000`(**不相等**)。根因是 JS `Number.MAX_SAFE_INTEGER = 9007199254740991`,19 位雪花 id 必然超出;而服务端**没有全局 `Long→String` 序列化配置**。
- 种子数据都是小 id(4/5/25)所以老页面一直正常 —— 这是**只要"新建后立刻拿 id 操作"就必然踩**的坑。
### 一、后端:全局序列化(放 `fruitshorts-framework`,两个入口都生效)
1. `Long.class` 与 `Long.TYPE` 一律用 `ToStringSerializer`。
2. ⚠️ **不要动 `Integer` / `int`**(分页 `pageIndex`/`pageSize` 等保持数字)。
3. 必须覆盖:普通字段、**嵌套对象**、`List`/`Map` 的值、分页 `list`。
4. **反序列化方向也要验**:请求体 / 路径变量 / 查询参数里 id 传**字符串**要能正确绑定成 `Long`(前端回传的就是字符串)。
### 二、契约统一(`docs/api-phase1.md` §0 通用约定)
- 写明「**所有 ID 字段一律以字符串下发**」(含主键与外键 id),并给示例。
- 把 I1 里逐模块的描述(商品/公告 VO 各自加 `ToStringSerializer`)**收拢到这条总口径**,不要各写一套。
- 顺手把 §2.11/§2.12 里与总口径重复的表述改成引用 §0。
### 三、两个前端适配
1. **ReelShortWeb**:审计现有页面有没有把 id 当**数字**用 —— `parseInt(id)` / `Number(id)` / `id + 1` / 拿 id 当数组下标 / `===` 与数字比较 / `sort((a,b)=>a.id-b.id)`,逐处改成字符串语义。本轮新加的两个页面已按 `string` 写,**别回退**。
2. **ReelShort(Flutter)**:`fromJson` 里 `json['id'] as int` 这类写法在字符串下发时会**直接抛异常**,必须审计并修。策略二选一(推荐前者,改动小且向前兼容):
- **兼容解析**:统一按"接受字符串或数字"的方式解析(如 `int.tryParse(v.toString())`),或把模型里的 id 保留为 `String`;
- 或全量改成 `String`(改动更大,要与客户端同事对齐)。
- ⚠️ 客户端现由另一同事在推进:**只做"ID 解析"这一类最小改动**,不要顺手改别的;有分歧或改不动就写「【待 Trae 协助】」,并在回执里给出**逐文件清单**。
### 四、验证(必须有证据,不接受"应该没问题")
1. 后端契约测试:`Long` 字段确实以**字符串**输出(含嵌套 / 分页 list / Map)。
2. **端到端复现 I1 那个 bug 并证明已消失**:admin 新建一条记录 → 用响应里的 id 调详情 / 编辑 / 上下架 → **成功**(全局范围内,不再只限商品/公告)。
3. **JS 侧一致性证据**:拿真实响应喂 `node -e 'const d=JSON.parse(...); console.log(String(d.id))'`,证明与库里的值**逐位相等**。
4. C 端:注册 / 播放票据 / 订单等返回的 id 也是字符串,且**客户端解析不崩**(Flutter 侧跑测试)。
### 五、门禁与 git
- `mvn test` 存量 **110 条不得跑红** + 新增测试;Web `pnpm ts:check` + eslint 零错误 + `pnpm build:pro` 通过;若改了客户端则 `dart analyze lib test` 零告警 + `flutter test` 全过。
- FruitServer / ReelShortWeb / ReelShort **各自在 `develop` 提交并推送**;**绝不动 `main`**。
- 新写文件首行 `// Author: fruit.com.cn`(Vue/TS 同;Dart 用 `// Author: fruit.com.cn`)。
### 六、节奏
先写 `FruitServer/docs/plans/2026-10-16-J1.md`(方案 + 影响面清单 + 风险)→ 做 → 跑门禁 → **停在干净交接点写回执**,回执必须含:
- 每个仓库改了哪些文件(**客户端逐文件清单**要单独列)
- 「用户可照做的验证步骤 + 每步预期结果」
- 若客户端有未改完/有分歧的部分,**如实列出**(不许含糊)
---
## [已处理·待用户验收] 2026-10-16 17:30 | I1 后台管理端:商品(金币包)配置 + 系统公告管理(后端接口 + Web 页面)
**用户指令(2026-10-16 17:10)**:「现在做后台管理端,规范还是按照 specs」+「ReelShortWeb 就是这个项目」→ **后台管理端 = `ReelShortWeb`(Vue3 + Element Plus),本批后端接口与页面一起做**。
### 一、PM 已勘察的两个缺口(实测证据,直接用)
| # | 缺口 | 证据 |
| --- | --- | --- |
| 1 | **商品/金币包配置:后台完全没有入口** | `product` 表 **16 个字段**已建(`code/name/type/platform/store_product_id/coin_amount/bonus_coin/price_amount/currency/display_price/region_codes/vip_days/sort/status/effective_at/expire_at`)、**已有 3 行种子数据**、C 端 `/app/orders/verify` 依赖 `store_product_id` 对齐商店商品(错误码 `200002 该商品未在后台配置` 就是为它留的);但 admin-api 29 个 Controller 里**没有任何商品 CRUD 接口**(`grep` 只命中 `WalletAdminController /coins` 的发币补偿)→ 运营改商品只能改库。**PRD §09 明确要求「商品配置与价格分层」** |
| 2 | **系统公告:有读没写** | `ops_notice` 表 **0 行**;E1 已交付 C 端 `GET /app/notices`(匿名只读);契约 §2.7 原话「**系统公告 `ops_notice` 目前没有产出方,等有公告功能再接**」→ 后台没有公告管理接口,运营发不了公告 |
> 除这两项外,admin-api 对照契约 §2.1–§2.10 全部已实现,**不要重做**。
### 二、后端(FruitServer / admin-api)
1. **契约先行**(依据 `12-上线检查清单` §一):先在 `docs/api-phase1.md` 写清两个模块的接口、字段、错误码、权限点(放在 §2.6 订单与权益之后,或新增一节),**再动代码**。
2. **商品配置 `/admin/products`**:
- `GET /admin/products/page`(筛选:`type` / `status` / `platform` / `keyword`,分页沿用既有口径)、`GET /admin/products/{id}`
- `POST /admin/products`、`PUT /admin/products/{id}`、`PUT /admin/products/{id}/status`(上下架)、`DELETE /admin/products/{id}`
- 字段按 `product` 表现有结构,**不要改表**
- **必须有校验**:`code` 在全局唯一、`store_product_id` 在**同一 `platform` 下唯一**(否则同一商店商品被重复配置 → 发币口径混乱);`type=COIN` 时 `coin_amount > 0`;`type=VIP` 时 `vip_days > 0`
- ⚠️ **VIP 商品口径**:B1-1 已定「VIP 订阅本轮不做」→ 允许建但**不允许启用**(启用报明确错误)或直接拒绝创建,**二选一由你定,写进契约并在回执里说明**;拿不准就写「【待 Trae 协助】」
- 权限点命名沿用现有习惯(参考 `commerce:*`),并落 `sys_permission` + `sys_menu`
3. **系统公告 `/admin/notices`**:
- `GET /admin/notices/page`、`GET /admin/notices/{id}`、`POST`、`PUT /admin/notices/{id}`、`PUT /admin/notices/{id}/status`(发布/下架)、`DELETE /admin/notices/{id}`
- 字段按 `ops_notice` 表现有结构,**不要改表**
- **与 C 端 `GET /app/notices` 的筛选口径必须严格对齐**(E1 已定:`status=1` 且 `target ∈ {ALL, USER}`、`publish_at <= now`、`expire_at` 为空或未过期、按 `publish_at desc, id desc`)—— 后台发布后 C 端能查到、过期/下架后查不到
- 权限点 + 菜单同上
4. **菜单与权限点落库**:写进 `sql/data-phase1.sql`(**幂等**),注意**沿用既有保留段**、不要与现有 id 冲突(现有菜单到 id 30)。建议:商品配置挂 `订单与权益`(id 22) 下;系统公告可挂新的运营分组,具体由你按现有约定定。**超管必须可见且可授权**。
5. Swagger 中文注解按 S1 规范补齐(`@Tag` / `@Operation`)。
6. **门禁**:`mvn test` 存量 **91 条不得跑红** + 新增契约测试,至少覆盖:`store_product_id` 同平台唯一、`code` 唯一、`type` 校验、公告发布后 C 端可见 / 过期或下架后不可见、越权(无权限点被拒)。
### 三、后台页面(ReelShortWeb)
> **挂载机制(PM 已勘察)**:路由是**静态注册**在 `src/router/index.ts`(`component: () => import('@/views/...')`),而菜单树来自 DB `sys_menu`,其中 `component` 字段必须**与视图路径一致**(例:`views/contentManagement/DramaManagementView`)。两边都要加。
1. 新增两个页面(放在与现有页面同级的目录,命名沿用 `XxxManagementView.vue` 风格):
- **商品配置**:列表(分页/筛选)+ 新建/编辑弹窗 + 上下架 + 删除;表单字段按 `product` 表;金额/金币用数字输入并做前端校验
- **系统公告**:列表 + 新建/编辑(标题/正文/类型/目标/发布时间/失效时间)+ 发布/下架 + 删除
2. **按现有约定写**:API 封装放 `src/api/admin/`(新增如 `product.ts` / `notice.ts`);权限码走现有的 `can(code)`;**表格状态列用接口返回的 `status` / `statusText`,不要在前端写死中文**;大数值用 `formatCount`。
3. `src/router/index.ts` 静态注册路由(`route_name` 与 DB `sys_menu.route_name` 一致,**注意不要与既有 route_name 重名** —— 之前踩过 `CreatorCenter` 重名导致白屏/404 的坑)。
4. **不要改动已对接的既有页面**(避免与其他人正在做的改动冲突)。
5. **门禁**:`pnpm build:pro` 通过 + eslint 零错误。
### 四、git
- **FruitServer**:在 `develop` 提交并推送;**不动 `main`**。
- **ReelShortWeb**:现在是 `main` 分支且**与远端分叉**(本地 `4564aa8` 领先 1、远端 `1fd0b8b 全量补充作者标识` 领先 1)。请先 `git fetch` 并把远端合进来,**然后按 specs 的 `GIT_WORKFLOW` 新建 `develop` 分支,在 `develop` 上提交并 `git push origin develop`**;**不动 `main`**(合并走 PR、由用户操作)。远端是 `jianghaifeng/ReelShortWeb.git`(有写权限)。
- 作者的署名规范:`// Author: fruit.com.cn`(Vue/TS 文件首行)。
### 五、节奏(红线第 7 条)
1. 先写计划文件 `docs/plans/2026-10-16-I1.md`(后端 + 前端两部分、步骤、风险、待确认)。
2. 做,跑门禁。
3. **停在干净交接点写回执**:逐项列出改了什么;**给你和用户可照做的验证步骤 + 每步预期结果**(含「后台登录 → 菜单里能看到这两个新页面 → 建一个商品 → 用该商品在 C 端 verify 能对上」这样的端到端链路)。
4. **不要连着抢做下一批。**
---
### 回执(Codex,2026-10-16):I1 完成,停在干净交接点
**提交**:FruitServer `8d19a0c`(develop,已推) + ReelShortWeb `db313f4`(develop,已推,含 husky 门禁)。
**留痕**:`docs/plans/PROGRESS.md`(本轮新建,逐动作命令 + 原始输出 + 解读);计划 `docs/plans/2026-10-16-I1.md`。
**没碰 `main`**;01-08 冻结模块未改(改动只在 module-commerce / module-system / admin-api / app-api 的 `AppNoticeService`)。
**门禁数字(原始输出见 PROGRESS 动作 3/6/10)**
| 门禁 | 结果 |
| --- | --- |
| `mvn test` | **BUILD SUCCESS**,commerce 33 / admin-api 16 / app-api 49 / worker 12 = **110 条全过**(存量 91 + 新增 19,无跑红) |
| `mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests` | BUILD SUCCESS |
| `sql/data-phase1.sql` 幂等 | 连跑三遍,`sys_role_menu/permission/menu/role_permission` 行数 54/52/33/79 前后一致 |
| `pnpm ts:check` | 0 错误 |
| `eslint --ext .js,.ts,.vue ./src` | 0 error(首遍 1 个 prettier 折行,改后复跑为空) |
| `pnpm build:pro` | Build successful |
| `:3005` 真浏览器 | 两页渲染正常;1440/1280/1024 三档 `overflowX=false`;截图见下面步骤 |
**交付内容**
1. 后端:`/admin/products` 6 个接口(page/{id}/POST/PUT/{id}/status/DELETE)、`/admin/notices` 6 个接口;字段严格按 `product` / `ops_notice` 表现有结构,**未改表**。
2. 校验:`code` 全局唯一、`storeProductId` **同 platform 唯一**、`COIN_PACK` 需 `coinAmount>0`、`VIP` 需 `vipDays>0`、新建默认下架。
3. 公告可见性与 C 端**共用一份实现**:`OpsNoticeService.pageForApp(...)`,后台与 `/app/notices` 不再各写一套条件。
4. 权限点 `product:list/product:update/notice:list/notice:update` + 菜单 31(商品配置,挂 22「订单与权益」)/ 32(运营管理)/ 33(系统公告),超管已授权,幂等。
5. 前端:`src/api/admin/product.ts`、`src/api/admin/notice.ts`、`ProductManagementView.vue`、`NoticeManagementView.vue`;状态列只用接口 `statusText`,不写死中文。
**两个自己拍的口径(请复核)**
1. **VIP 商品:允许建档、不允许启用** —— `status=1` 时回 `400 会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)`。理由:上线日可以直接上下架,不必临时补商品数据;已写进契约 §2.11。
2. **⚠️ 雪花 id 以字符串下发(本轮实测抓到的真缺陷)** —— UI 上点「下架」后端回 `200002 公告不存在`,根因是 S0 换雪花主键后 id 超过 JS 安全整数范围:DB 里 `2111011206851596290`,浏览器 `JSON.parse` 后变成 `2111011206851596300`,回查自然找不到。
修复限定在本轮两个模块(`ProductRespVO` / `NoticeRespVO` 的 `id` 加 `ToStringSerializer`,新建接口返回 `String`,前端 `id: string`),**没动全局序列化、没动 app-api**,避免影响 Flutter 客户端;并补了两条防回归测试。
- 遗留:admin-api 其它模块(订单/权益/短剧…)只要"新建后立刻用 id 操作",同样会中招。**是否统一做"admin-api 的 Long → String"、app-api 要不要一起动(Flutter 解析 id 的地方会受影响)——这是平台级改动,请裁定**,我没自行铺开。
3. **菜单挂载机制与条目描述有出入(如实报出)**:实测菜单来自 DB `sys_menu`,前端用 `import.meta.glob` 按 `component` 字符串动态解析;`src/router/index.ts` 的 `asyncRouterMap` 只静态声明 dashboard/content/user/system,commerce/governance/board 都不在里面。所以本轮**没有改 `src/router/index.ts`**,只建页面 + 落菜单(`component` 与视图路径一致)。若你要求"两边都登记",我补一版。
**用户可照做的验证步骤(每步都有预期结果)**
准备(后端):`cd FruitServer && mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests`,然后
```
java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar # 8080
java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar # 8081
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/v3/api-docs # 预期 200
```
数据库若在干净机器上:`psql -f sql/data-phase1.sql`(可重复执行)。
1. **菜单**:`pnpm dev` → 打开 `http://127.0.0.1:3005/#/login` → `admin / admin123` 登录。
预期:左侧出现「订单与权益 → 商品配置」与「运营管理 → 系统公告」,其余菜单不变。
2. **商品页**:进 `#/commerce/products`。预期:3 行种子(coin_100/550/1200),列 = 商品编码/名称/类型/平台/商店商品 id/到账金币/价格/排序/状态/创建时间/操作;状态显示「已上架」。
3. **新建商品(正常路径)**:点「新建商品」→ 编码 `uitest_coin_500`、名称随意、平台选 Google Play、商店商品 id `UITEST_COIN_500`、金币 500、赠送 50、价格 9900 → 保存。
预期:toast「商品已创建(默认下架,请确认后再上架)」,列表新增一行且状态「已下架」(**id 是雪花 id,注意这是本轮修过的点**)。
4. **异常路径**:再新建一个同编码商品 → 预期 toast「商品编码已存在:uitest_coin_500」;把类型改成「会员(第二阶段)」、天数 30 → 保存成功(**允许建档**),再点它的状态想上架 → 预期 toast「会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)」,状态不变。
5. **C 端对齐(命令)**:把第 3 步的商品点上架,然后
```
psql -c "select id, code, platform, store_product_id, status from product where store_product_id='UITEST_COIN_500';"
```
预期:查到 1 行、`status=1` —— 这正是 `/app/orders/verify` 找商品的同一条件。
(`POST /app/orders/verify` 本机默认 `STORE_VERIFIER=disabled`,会先回 `200003 商店校验尚未配置`,这是 B1-1 的既定行为,不是商品没配上。)
6. **公告页**:进 `#/operation/notices` → 「新建公告」填标题/正文(默认「全部用户/系统」、不填时间=立即发布)→ 保存。
预期:toast「公告已发布」,行内状态「已发布」、失效时间显示「-」。
```
curl -s "http://127.0.0.1:8081/app/notices?pageIndex=1&pageSize=5" # 预期 total 1,能看到刚发的标题
```
7. **下架(异常路径的对照组)**:点该行状态「已发布」→ 预期 toast「公告已下架」、行内变「已下架」;再跑上面的 curl → 预期 `total 0`。
8. **越权**:用 `content.editor01 / admin123`(内容运营,无 `product:*`/`notice:*`)登录后直接敲 `#/commerce/products`。
预期:页面 403/无权限(后端 `{"code":403,"message":"无权限"}`);不带 token 请求 `curl -s "http://127.0.0.1:8080/admin/products/page"` → `{"code":401,...}`。
9. **清理**:把第 3/4 步的商品、第 6 步的公告删掉;核对 `select count(*) from product;` = 3、`select count(*) from ops_notice;` = 0。
(本轮自测数据我已清干净:`product` 3 行种子、`ops_notice` 0 行。)
**门禁/流程说明**:本轮所有子步骤都在同一批内完成,未抢做下一批;`mvn test` 与 `package` 都是先停服务再打包、打包后重新起服务、**对着活着的服务**做的 curl 自测(不是对停掉的服务打)。
服务当前状态:8080/8081 我这边是**已起的**(自测完成时仍在监听);若你这边连不上就是被环境回收了,按上面「准备」两条命令起回来即可。
**禅道同步(红线第 6 条,如实说明)**:我把 `水果短剧APP`(项目 2)的 4 个执行下的任务都拉了一遍,
任务只有 S0/S1/S0-16、A1–A6、B1/B2、C1–C3 —— **没有 I1(后台管理端 商品配置 + 系统公告)这一条**,
所以本轮没有可同步的任务号,我没有动任何既有任务的状态(避免误改 B1/C1/A* 这些别的批次)。
下一步请给 I1 建任务并告知任务号,我补一次状态同步(`codex01` 的 token 已可用)。
---
### 回执(Codex,2026-10-16):J1 完成,停在干净交接点
**提交**(三个仓库各自 `develop`,`main` 未动)
- FruitServer `f35db2c`(develop,已推):全局 Long→String + 契约 §0 + 测试
- ReelShortWeb `3ec9ea6`(develop,已推):4 处 id 数字语义适配
- ReelShort `94ff9f1`(**新建 develop**,已推):客户端 Long 计数兼容解析
**留痕**:`docs/plans/PROGRESS.md`(本轮追加 J1 段,含原始输出);计划 `docs/plans/2026-10-16-J1.md`。
**门禁数字**
| 门禁 | 结果 |
| --- | --- |
| `mvn test` | **BUILD SUCCESS** — commerce 33 / admin-api 21 / app-api 49 / worker 12 = **115 条全过**(存量 110 未跑红,新增 5) |
| `mvn … package -DskipTests` | BUILD SUCCESS |
| `pnpm ts:check` / eslint / `pnpm build:pro` | 0 错误 / 0 error / Build successful |
| `dart analyze lib test` / `flutter test` | No issues found! / All tests passed!(+167) |
**每个仓库改了什么**
FruitServer(后端)
- 新增 `fruitshorts-framework/.../web/JacksonLongToStringConfig.java`(`Long`/`long` → 字符串;
`PageResult.total` 用 mixin 保持数字;`PlainLongSerializer` 供 mixin 使用)
- `docs/api-phase1.md`:§0 新增「ID 字段一律以字符串下发」总口径(含示例、边界、`total` 例外),
§2.11/§2.12 改为引用 §0
- I1 的特例回退:`ProductRespVO` / `NoticeRespVO` 去掉 `@JsonSerialize`,
`ProductAdminController` / `NoticeAdminController` 的 `create` 回到 `Result<Long>`
- 测试:新增 `admin-api/.../web/LongToStringSerializationTest.java`(5 条);
`ControllerContractSupport`(admin + app 两处)挂上模块,避免契约测试假绿;
C 端 4 条 id 断言改成 `value("…") + isString()`
ReelShortWeb(4 个文件)
- `src/utils/creatorImpersonation.ts` — 代看创作者 id 全程字符串(原来 `Number(raw)`,19 位会被四舍五入)
- `src/views/contentManagement/EpisodeManagementView.vue` — `Number(route.query.drama)` → 字符串比较
- `src/views/creator-center/composables/useDramaEdit.js` — `Number(route.params.id)` → 字符串比较
- `src/views/permission/composables/useMenuManagement.js` — 顶级菜单哨兵 `0` → `'0'`(与接口的 `"0"` 对齐)
ReelShort(Flutter)逐文件清单(**只有这些,没夹带别的**)
- `lib/core/data/models/json_numbers.dart`(新增)— `jsonToInt`:接受数字或数字字符串
- `lib/core/data/models/auth_session.dart` — `expiresIn`(后端 `long`)改用 `jsonToInt`
- `lib/core/data/models/drama.dart` — `viewCount`(后端 `Long`)两处改用 `jsonToInt`
- `test/core/json_id_parsing_test.dart`(新增)— 8 条回归用例
**三个需要你确认的口径**
1. **`PageResult.total` 保持数字(唯一例外)** —— 它是条数不是 id,前端分页组件(el-pagination / Flutter 分页)
都要数字。实测 `total = 2 int`。若你要求"Long 一律字符串",删掉 `JacksonLongToStringConfig` 里那一行 mixin 即可。
2. **反向(请求方向)未做限制**:请求体 `"id":"…"`、路径变量、查询参数都接受字符串并转 `Long`(有测试)。
`Integer`/`int`(`pageIndex`/`pageSize`/`Result.code`/`totalEpisodes`/`durationSec`…)保持数字 —— 实测已确认。
3. **客户端适配范围比"只改 id 解析"宽了一点点,原因是物理上必须**:客户端 id 本来就是
`(json['id'] ?? '').toString()`,不会崩;真正会崩的是被全局规则扫到的 **Long 计数**,
所以改了 `expiresIn` 与 `viewCount` 共 3 处(`json_numbers.dart` 是新增工具)。
其余 8 处 `as num?` 对应的 Java 字段都是 `Integer`/`int`,**按最小改动原则没动**。
⚠️ **ReelShort 仓库原来只有 `main`**(无 develop),我按条目要求新建了 `develop` 并在上面提交。
客户端由另一位同事推进,若他继续在 `main` 上走,需要把这笔 `94ff9f1` 带过去。
**用户可照做的验证步骤(每步都有预期结果)**
准备:`cd FruitServer && mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests`,起两个 jar;
`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/v3/api-docs` → 200。
1. **复现 PM 那条(最直观)**:注册一个 C 端账号
```
curl -s -X POST http://127.0.0.1:8081/app/auth/register -H 'Content-Type: application/json' \
-d '{"targetType":"EMAIL","target":"你的邮箱@example.com","password":"Passw0rd!23","nickname":"手测","deviceId":"manual-1"}'
```
预期:`"expiresIn": "7200"`(字符串)。
2. **用响应里的 user id 调后台**:`curl -s "http://127.0.0.1:8080/admin/app-users/page?pageSize=50" -H "Authorization: Bearer <admin token>"`,
拿新用户的 `id`(预期是 `"2111…"` 这种**带引号的字符串**、`total` 是**不带引号的数字**),再
`curl -s "http://127.0.0.1:8080/admin/app-users/<id>" -H "Authorization: Bearer <token>"` → 预期 `code=0`、`data.id` 与列表一致
(**修复前这里返回 `200002 数据不存在`**)。
3. **JS 逐位一致**:把第 2 步的响应存成 `r.json`,然后
```
node -e 'const d=JSON.parse(require("fs").readFileSync("r.json","utf8"));console.log(String(d.data.id))'
```
预期与 `psql -c "select id::text from app_user where uid='<uid>'"` **逐位相同**。
对照:`node -e 'console.log(String(JSON.parse("2111015748376858626")))'` → `2111015748376858620` 附近(会被改写),说明精度问题真实存在。
4. **后台界面(:3005)**:`admin/admin123` 登录 → 商品配置新建一个商品 → **点它那一行的状态/编辑**,
预期正常(toast 成功,不再"商品不存在");菜单管理编辑一个顶级菜单,预期父级下拉显示「顶级菜单」而不是 `0`。
5. **短剧管理 → 剧集管理带参跳转**:从短剧管理点某剧的「剧集」,URL 带 `?drama=<id>`,
预期剧集页自动选中该剧(改成字符串比较前这里会选不中)。
6. **客户端**:`cd ReelShort && flutter test`(预期 All tests passed);有设备时 `flutter run` 打开首页看播放量/详情
不报解析异常(后端 `viewCount` 现在下发字符串)。
7. **异常路径**:请求体里 id 传**数字**(老客户端行为)也应被接受:`PUT /admin/products/<id>/status?status=1` 用数字 id 路径同样 200。
**禅道**:J1 在禅道里同样没有对应任务(项目 2 下仍是 S0/S1/S0-16、A1–A6、B1/B2、C1–C3),未动任何既有任务状态。
**服务现状**:8080/8081 是我刚重建起回的新 jar(自测时在跑);:3005 的 dev server 也在。若被环境回收,按上面「准备」起回来即可。
---
### 回执(Codex,2026-10-16):K1 完成,停在干净交接点
**提交**:`ReelShort d7eff4c`(develop,已推;`main` 未动)。留痕:`docs/plans/PROGRESS.md`;计划:`docs/plans/2026-10-16-K1.md`。
**门禁**:`dart analyze lib test` → No issues found!;`flutter test` → **All tests passed!(188 条,存量 27 个测试文件未跑红)**;真实后端联调(8081)见下方原始输出。
**先纠正条目里的一处勘察**:客户端**详情接口早已接上**(`CatalogApi.dramaDetail` + `CatalogController.requireDetail`,详情页在用),
票据调用 `CatalogApi.playTicket` 也存在但**没有任何页面调用**。所以本轮真正做的是:把票据接进播放链路、进度从本地换服务端、
收藏换服务端、补三态 UI。
**改了什么(ReelShort,只列这一批)**
新增:`remote/playback_api.dart`、`remote/watch_api.dart`、`remote/library_api.dart`、`models/watch_progress.dart`、
`repositories/playback_repository.dart`、`repositories/favorite_repository.dart`、`services/favorite_controller.dart`、
`formatting/error_text.dart`。
改:`app.dart`(注入三个仓库 + 收藏控制器 + 登录/登出联动)、`remote/api_client.dart`(补 `delete`)、
`remote/catalog_api.dart` 与 `repositories/catalog_repository.dart`、`services/catalog_controller.dart`(票据职责搬走)、
`models/play_ticket.dart`(补 dramaId/number/title/previewSeconds)、`repositories/watch_history_repository.dart`(换服务端模型)、
`pages/{discover,drama_detail,mine,player}/*`、`l10n/app_{en,zh,id}.arb` + 4 个生成文件。
测试:新增 5 个(含 live 联调),改 8 个(含 fake 与 widget_test 的离线仓库注入)。
**怎么改的(要点)**
1. **起播前取票**:`PlaybackRepository.ticketFor()` 带 TTL 缓存(默认 10 分钟 < 后端 15 分钟签名),过期或 `forceRefresh` 时重取;
回前台发现票据过期会重新取。**签名地址原样交给播放器**(`AppConfig.mediaUrl` 只补 host,path 与 `?e=&s=` 不动,有测试钉住)。
2. **accessType 驱动 UI**:`COIN_REQUIRED/VIP_REQUIRED` → 锁定提示 + "金币解锁即将开放";`NO_MEDIA` → 暂无片源;
前端不推断能不能播(契约 §1.4 的硬要求)。付费解锁仍属 B2。
3. **进度**:服务端每剧一条记录,`POST /app/watch/progress` 节流从 5 秒改成**10 秒**,切集/退出/切后台各补一次,播完带 `finished`;
续看位置只在"记录里的那一集"生效(其它集从头播);`demoProfileId` 与本地 SharedPreferences 实现**已删除**。
4. **收藏**:`FavoriteController` 以服务端返回为准,乐观更新 + 失败回滚 + 失败提示;退出登录清空(不跨账号串联)。
5. **三态**:详情页(加载 / 失败可重试 / 空剧集);播放页(票据加载中 / 失败可重试 / 后端判定不可播);
我的页(续看读取失败可重试 / 收藏列表加载中·失败·空 / 最近观看加载中·失败·空)。断网与超时文案走 l10n(en/zh/id 各 8 个新键)。
**原始联调输出(8081)**
```
① /app/home/feed → dramaId=5
② /app/dramas/5 → episodes=3;第一集 id='7'
playUrl=/files/episode/202610/cec39d8e….mp4?e=1792144372&s=8791826a…
③ /app/episodes/7/play → episodeId='7' dramaId='5' accessType=FREE accessible=True
④ POST /app/watch/progress (42s) → positionMs="42000" durationMs="228000" playCount=1
⑤ GET /app/watch/progress?dramaId=5 → episodeId='7' positionMs='42000' ← 续看位置读得回来
⑥ GET /app/watch/recent?limit=20 → 条数=1(dramaId='5' episodeId='7')
⑦ POST /app/favorites/toggle → true;GET /app/favorites → 1 条;DELETE → 0 条
$ flutter test test/integration/live_watch_flow_test.dart → All tests passed!
```
自测数据已清干净:`app_user` 只剩 PM 那条(id=28)、`watch_progress` 0、`user_favorite` 0。
**哪些页面现在走真数据、哪些还是假的**
| 页面/能力 | 数据来源 | 状态 |
| --- | --- | --- |
| 首页 / 发现页 | `/app/home/feed`、`/app/discover/feed` | ✅ 真数据(本批未动) |
| 登录 / 注册 / 刷新 / 登出 | `/app/auth/*` | ✅ 真数据(本批未动) |
| **详情页(剧集列表、可播地址)** | `/app/dramas/{id}` | ✅ 真数据 |
| **详情页"继续观看"与剧集进度条** | `/app/watch/progress?dramaId=` | ✅ 本批接上(原来读本机) |
| **播放页(票据/签名地址/起播判定)** | `/app/episodes/{id}/play` + accessType | ✅ 本批接上 |
| **播放页进度上报(10s 节流)** | `POST /app/watch/progress` | ✅ 本批接上(原来存本机) |
| **我的页:收藏列表 + 收藏按钮** | `/app/favorites*` | ✅ 本批接上(原来存本机) |
| **我的页:最近观看** | `/app/watch/recent` | ✅ 接上;**只渲染能在已加载目录里按 id 匹配到的剧**,匹配不到先不显示(见待确认 2) |
| 我的页:点赞 | 本地 `EngagementController` | ❌ 仍是本地假状态(属 **K3** 互动) |
| 播放页:评论 | 本地 `DramaCommentsSheet` | ❌ 仍是本地(属 **K3**) |
| 播放页:解锁付费集 | — | ❌ 本批不做(属 **B2**,票据已按 `accessType` 提示) |
| 记录页 / 收藏列表页 / 分类搜索 / 下载 | — | ❌ 属 **K2** |
**用户可照做的验证步骤(每步预期结果)**
准备:后端两个 jar 起着(`admin=200`、`app=200`);`cd ReelShort && flutter run`(模拟器用
`--dart-define=API_BASE_URL=http://10.0.2.2:8081`;iOS/桌面用默认 127.0.0.1)。
1. **匿名可看**:不登录进首页 → 点任一剧 → 详情正常出剧集列表;点某集能起播(免费集)。
预期:起播前有一瞬"加载票据",无白屏;付费集显示"第 N 集已锁定 + 金币解锁即将开放"。
2. **登录后进度**:用「我的 → 登录」注册/登录一个邮箱账号 → 进一部剧 → 看 20~30 秒 → 返回详情。
预期:详情页的"继续观看"按钮下方出现进度条与"续看至 mm:ss / mm:ss",剧集行上也有一条进度条。
3. **跨进程续看(真数据验证)**:杀进程重开 → 进同一部剧 → 直接点"继续观看"。
预期:从上次的位置继续播(不是从头)。也可以直接查库:
`psql -c "select drama_id, episode_id, position_ms from watch_progress order by updated_at desc limit 3;"` 预期有该剧记录。
4. **收藏**:播放页右侧点收藏(或详情页右上角)→ 打开「我的 → Watchlist」。
预期:该剧出现在收藏列表;再点一次取消 → 从列表消失。断网时点收藏 → 提示"收藏操作失败,请重试"且图标回到原状态。
5. **异常路径**:把后端停掉(`lsof -tiTCP:8081 -sTCP:LSLISTEN | xargs kill`)后进详情/播放。
预期:详情页给"网络异常,请检查网络后重试"+ 重试按钮;播放页给"此视频暂时无法播放"+ 重试;
我的页收藏区给失败文案 + 重试;**都不白屏、也不假装成功**。恢复后端后点重试即可继续。
6. **一套命令验证(不用模拟器)**:
`cd ReelShort && flutter test test/integration/live_watch_flow_test.dart` → 预期 All tests passed(服务没起会 skip)。
**待确认 / 如实说明**
1. **票据过期用的是客户端 TTL(10 分钟)**,因为契约里 `play` 响应没有 `expiresAt` 字段。后端若补上过期时间,改成按字段判断即可(一行)。
2. **`/app/watch/recent` 只返回 dramaId/episodeId/位置,没有剧名封面**,所以"我的页最近观看"目前按 id 去已加载的目录卡片里匹配;
匹配不到的条目先不显示。**K2 的记录页**建议要么后端补卡片字段,要么做成独立请求(已在计划里记为风险 4)。
3. **`WatchHistoryRepository` 的接口签名被改了**(服务端是"每剧一条续看记录",没有 profile 概念),连带改了 4 个 logic 与 6 个测试文件——
这是接口模型决定的,不是顺手重构;`clampResumePosition` 等本地工具函数保留。
4. **未登录时**进度与收藏接口一律不发(匿名浏览/试看不受影响),收藏按钮沿用"先引导登录"。
5. 禅道里仍**没有 K1 任务**(项目 2 下只有 S0/S1、A1–A6、B1/B2、C1–C3),未动任何既有任务状态。
---
### 回执(Codex,2026-10-16):K1-verify —— **未验证(真机验证由用户执行,Codex 未执行)**
**结论先说**:按用户 20:45 指令「codex 不用跑测试 我来执行」,本批的**真机验证状态 = 未验证**,
由用户执行后以其结果为准。我**没有**执行任何最终真机验证,也**没有**用任何"开发机 curl"当作设备可达证据。
**交付物(ReelShort `develop`,已推)**
- `pubspec.yaml`:dev_dependencies 加 `integration_test`(sdk)—— 唯一的新依赖
- `integration_test/app_backend_test.dart`(新增,首行 `// Author: fruit.com.cn`):两个设备侧用例
1. 在手机进程里请求 `/app/home/feed` 与 `/app/dramas/{id}`,断言列表非空、详情 id 与卡片一致;
2. 用**真 App**(真网络、真目录控制器)启动,轮询到首页底部标签栏出现 —— 即"剧目列表加载成功",
也就是用户在真机上遇到的「剧目列表加载失败」是否消失。
- 该文件**按用户要求保持原样,不再改动**。
**当前机器状态(交接前已清理)**
- 我起的 `flutter run` / `xcodebuild` / lsof 轮询进程**已全部终止**(`pgrep -fl "flutter_tools|xcodebuild"` 只剩 IDE 常驻 daemon,非构建)。
- `build/` 里留着我被叫停那次构建的半成品 → **用户从 `flutter clean && flutter pub get` 起跑即可清掉**。
- 后端 8080 / 8081 仍在跑(`admin=200` / `app=200`)。
**用户可直接复制执行的命令(⚠️ 本项目 iOS 用 SPM,没有 Podfile,不要跑 `pod install`)**
```bash
# 0) 起点:清掉残留构建产物(我被打断的那次留下的)
cd /Users/jianghaifeng/Desktop/ReelShort/ReelShort
flutter clean && flutter pub get
# 1) 取开发机真实局域网地址(不要硬编码;换网络会变)
IP=$(ipconfig getifaddr en0) && echo "API_BASE_URL=http://$IP:8081"
# 2) 设备侧集成测试(最硬的证据,跑在手机进程里)
flutter test integration_test -d 00008101-000875813E42001E --dart-define=API_BASE_URL=http://$IP:8081
# 2b) 只想跑网络那一条
flutter test integration_test/app_backend_test.dart -d 00008101-000875813E42001E \
--plain-name "should_fetch_home_feed_and_drama_detail_when_device_reaches_backend" \
--dart-define=API_BASE_URL=http://$IP:8081
# 3) 真机手动看 App(可选,验证首页列表与交互)
flutter run -d 00008101-000875813E42001E --dart-define=API_BASE_URL=http://$IP:8081
```
(本次实测 `ipconfig getifaddr en0` = **192.168.31.187**;若换网络请以命令输出为准。)
**前置条件**
1. 手机与开发机在**同一局域网**(例如都在 192.168.31.x)。
2. iPhone 已授权「**本地网络**」权限:设置 → 隐私与安全性 → 本地网络 → 打开本项目(App 名 **FruitShorts**)。
没授权时 App 请求局域网会失败;`Info.plist` 里 `NSLocalNetworkUsageDescription` 与
`NSAllowsLocalNetworking` 都已配好(上一批加的),所以系统应当会弹权限询问。
3. 同一时刻**只跑一个** flutter 构建(并发写同一个 `build/` 会互相破坏产物 —— 这正是本次
`video_player_avfoundation…bundle: No such file or directory` 的疑似成因)。
**设备侧证据看什么(红线第 10 条)**
- 测试输出里必须出现**由手机进程打印**的:
- `[device] API_BASE_URL=http://192.168.31.187:8081`(证明手机上用的是局域网地址,不是开发机环回/VPN 地址)
- `[device] 首页分区=4 剧目=3` / `[device] 详情 id=5 剧集=3`(证明设备真的拿到了数据)
- `[device] 首页剧目列表已渲染:API_BASE_URL=http://192.168.31.187:8081`(证明真 App 首页列表出内容)
- 最后一行 `All tests passed!`
- 若失败:`reason` 里会带上 `API_BASE_URL=…`,据此区分"地址不对"还是"设备连不上/权限未授权"。
**如实说明(失败与历史记录,详见 PROGRESS 的 K1-verify 段)**
- 我在 17:56 跑过一次,**那次是设备侧输出且全过**(上面那几行 `[device]` 就是它的原始输出)。
- 随后两次执行失败:17:59 那次 `The Dart VM Service was not discovered after 60 seconds`(app 没 attach,我也没抓到任何手机→8081 的连接);
18:03 那次 `flutter run` 构建完成后 PM 报产物缺失(`video_player_avfoundation…bundle: No such file or directory`)并叫停。
- 因此**在用户重跑之前,本批一律按「未验证」计**;上面那次的历史输出只作参考,不作为结论。
+491
View File
@@ -0,0 +1,491 @@
# PROGRESS — 工作留痕(FruitServer)
> 红线第 2 条:谁干活谁写这里(命令 + 原始输出 + 解读,逐动作追加)。
> 指令通道:`docs/plans/INBOX.md`(specs 仓库只拉取、由项目负责人更新)。
---
## 2026-10-16 I1:后台管理端 —— 商品(金币包)配置 + 系统公告管理
计划文件:`docs/plans/2026-10-16-I1.md`(先写计划再动手)。改了 `docs/api-phase1.md` §2.11/§2.12(契约先行)。
### 动作 1:开工前核对仓库状态
```
$ cd FruitServer && git status --short && git log --oneline -1
M docs/api-phase1.md
M fruitshorts-admin-api/.../FruitShortsApplication.java
M fruitshorts-app-api/.../AppNoticeService.java
M sql/data-phase1.sql
?? docs/plans/2026-10-16-I1.md
?? fruitshorts-admin-api/.../ProductAdminController.java(+ NoticeAdminController)
?? fruitshorts-admin-api/src/test/.../ProductAdminControllerContractTest.java(+ Notice / AdminPermissionAnnotation)
?? fruitshorts-module-commerce/.../ProductService.java(+ 3 个 VO)
?? fruitshorts-module-system/.../OpsNoticeService.java(+ 3 个 VO)
7513ac0 docs(plans): 指令通道迁入本仓库(specs 改为只拉取,由项目负责人更新)
```
解读:分支 `develop`,起点就是指令通道那笔 `7513ac0`;工作区只有本轮 I1 的改动。
### 动作 2:契约先行(先改文档再写代码)
`docs/api-phase1.md` 新增 **§2.11 商品(金币包)配置(已实现)**、**§2.12 系统公告管理(已实现)**:
接口表 + 字段表 + 校验错误码表 + 权限点 + 与 C 端可见性口径对齐说明。VIP 口径在契约里定死:
**允许建档、不允许启用**。
### 动作 3:后端门禁 —— `mvn test`
首轮(雪花 id 修复前):
```
$ mvn test
[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-module-commerce)
[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-admin-api)
[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-app-api)
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-transcode-worker)
[INFO] BUILD SUCCESS
```
解读:存量 91 条(app-api 41 + commerce 27 + admin-api 3 + worker 12 + …)无一跑红,新增 17 条全过,共 108。
### 动作 4:SQL 幂等(连跑三遍)
```
$ psql -f sql/data-phase1.sql # 第一次
INSERT 0 5 / UPDATE 5 / INSERT 0 4 / INSERT 0 4 / INSERT 0 3 / INSERT 0 3
$ psql -f sql/data-phase1.sql # 第二次
INSERT 0 5 / UPDATE 5 / INSERT 0 0 / INSERT 0 0 / INSERT 0 3 / INSERT 0 0
$ psql -t -c "select 'role_menu='||count(*) from sys_role_menu union all ..." # 跑前
role_menu=54 / permission=52 / menu=33 / role_perm=79
$ psql -f sql/data-phase1.sql # 第三次
RUN3 EXIT=0
role_menu=54 / permission=52 / menu=33 / role_perm=79 # 跑后:完全一致
```
解读:脚本可重复执行,**行数不增长、不重复插入**。新增权限点 `1160-1163`、菜单 `31/32/33`(商品挂 22「订单与权益」下、公告新建 32「运营管理」分组 + 33 子菜单),并给超管授了角色-菜单。
### 动作 5:打包 + 起回服务 + curl 自测(商品)
```
$ mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests → BUILD SUCCESS
(kill 8080/8081 → 重新起 jar → 健康检查)
admin=200 / app=200
```
商品自测(完整脚本 `/tmp/i1-curl.sh`,原始输出 `/tmp/i1-curl.log`):
```
3) 新建商品 i1_test_coin_100(800+80,IDR 9900,platform=GOOGLE)
{"code":0,"message":"ok","data":2111010131922784257}
4) 新建后状态 → "status": 0, "statusText": "已下架" # 新建默认下架
5) code 重复 → {"code":200001,"message":"商品编码已存在:i1_test_coin_100"}
6) 同平台 store id 重复 → {"code":200001,"message":"该平台下商店商品 id 已被占用:I1_TEST_COIN_100"}
7) 金币包 coin_amount=0 → {"code":400,"message":"金币包的金币数量必须大于 0"}
8) VIP vip_days=0 → {"code":400,"message":"会员商品的会员天数必须大于 0"}
9) VIP vip_days=30 → {"code":0} # 允许建档
10) VIP 启用 → {"code":400,"message":"会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)"}
11) 上架 → {"code":0},详情 status=1/已上架
12) C 端等价查询(store_product_id + platform in (GOOGLE,ALL) + status=1)
2111010131922784257 | i1_test_coin_100 | GOOGLE | I1_TEST_COIN_100 | 800 | 80 | 1 (1 row)
```
公告自测(同一脚本,13–18 步):
```
13) 公告分页(新建前)→ total 0
14) 新建 target=ALL → {"code":0,"data":2111010133357236226}
15) 新建 target=CREATOR → {"code":0,"data":2111010133537591298}
16) 新建 expire_at=2020-01-01(已过期) → {"code":0}
17) C 端 GET /app/notices(匿名)→ total 1,只返回第 14 步那条
18) 后台下架第 14 步那条 → C 端 total 0
```
越权自测(`content.editor01` / role=content_operator,无 `product:*` / `notice:*`):
```
GET /admin/products/page → {"code":403,"message":"无权限"}
GET /admin/notices/page → {"code":403,"message":"无权限"}
POST /admin/products → {"code":403,"message":"无权限"}
PUT /admin/notices/1/status → {"code":403,"message":"无权限"}
(无 token)GET /admin/products/page → {"code":401,"message":"未登录或登录已过期"}
```
解读:商品三类校验、VIP 只建不启、公告"C 端可见/不可见"三个分支、越权 401/403 全部实测通过。
`logs` 里 `200002 该商品未在后台配置` 的触发点在商店校验**之后**(`/app/orders/verify` 当前默认
`DisabledStoreVerifier` → 先返回 `200003 商店校验尚未配置`),所以 C 端链路验证到"进入商店校验环节"为止,
真正的发币链路等 Google Play 凭据到位(见回执「待确认」)。
### 动作 6:清理第一轮自测数据
```
DELETE 3 # 3 个自测商品
DELETE 4 # 4 条自测公告
products | soft_deleted notices
3 | 0 0
```
### 动作 7:前端页面(ReelShortWeb,develop 分支)
新增 4 个文件(首行署名 `// Author: fruit.com.cn` / `<!-- Author: fruit.com.cn -->`):
```
src/api/admin/product.ts src/api/admin/notice.ts
src/views/commerce/ProductManagementView.vue src/views/operation/NoticeManagementView.vue
```
**挂载机制实测**(与 INBOX 描述有出入,以实测为准):菜单树来自 DB `sys_menu`,
`src/utils/routerHelper.ts` 用 `import.meta.glob('../views/**/*.{vue,tsx}')` 按 `component` 字符串**动态解析**;
`src/router/index.ts` 的 `asyncRouterMap` 只静态声明 dashboard/content/user/system 这几个早期分组,
commerce/governance/board/creator-center 都不在里面 —— 所以新增页面**不需要**改静态路由表,
只要 ① 建 `.vue` ② 菜单 `component` 与视图路径一致。本轮据此实现,未动 `src/router/index.ts`。
前端门禁:
```
$ pnpm ts:check → 0 错误(无输出)
$ pnpm exec eslint --ext .js,.ts,.vue ./src
✖ 1 problem (1 error, 0 warnings) # 第一遍:NoticeManagementView.vue prettier 折行
(改完复跑)→ 无输出,0 error
$ pnpm build:pro → Build successful. Please see dist-pro directory
```
### 动作 8:`:3005` 真浏览器实测(Codex in-app browser)
```
登录 admin → 侧边栏出现「订单与权益 → 商品配置」「运营管理 → 系统公告」(其余菜单不变)
#/commerce/products → 标题「商品配置」,11 列与后端字段一一对应,状态列用 statusText(已上架/已下架)
#/operation/notices → 标题「系统公告」,空态「还没有发布公告」
新建商品(UI)→ toast「商品已创建(默认下架,请确认后再上架)」,行内 totalCoin 550、状态「已下架」
新建公告(UI)→ toast「公告已发布」,行内 发布时间 16:32:15、失效时间「-」、状态「已发布」
C 端 GET /app/notices(匿名)→ 立刻能看到这条(total 1)
下架 → toast「公告已下架」→ C 端 total 0
删除 → toast「商品已删除」「公告已删除」→ 列表回到种子数据
溢出检查(1440/1280/1024,两页各一次)
1440: docScroll=1440 clientWidth=1440 overflowX=false
1280: docScroll=1280 clientWidth=1280 overflowX=false
1024: docScroll=1024 clientWidth=1024 overflowX=false
截图:/tmp/i1-product-list.png、/tmp/i1-notice-list.png
```
### 动作 9:❌ 实测抓到的真缺陷 —— 雪花 id 在浏览器里被四舍五入
UI 上点「下架」时后端回:
```
{"code":200002,"message":"公告不存在"} # 明明列表里就有这条
```
定位:
```
$ node -e 'console.log(2111011206851596290, Number.isSafeInteger(2111011206851596290))'
2111011206851596300 false
$ psql -c "select id, title from ops_notice;"
2111011206851596290 | I1 自测公告(UI 创建) # DB 里是 …596290
```
解读:S0 换雪花主键后 id 已超过 JS 安全整数范围(2^53-1 = 9007199254740991)。
浏览器 `JSON.parse` 把 `2111011206851596290` 变成 `2111011206851596300`,
页面拿这个被改过的 id 回查 → `200002`。**这是 S0 之后所有"新建即操作"的通用风险**,
只是本轮新建的页面第一个撞上。
修复(**限定在本轮两个模块的契约内**,不动全局序列化、不动 app-api,避免影响 Flutter 客户端):
```
fruitshorts-module-commerce/.../ProductRespVO.java id 加 @JsonSerialize(using = ToStringSerializer.class)
fruitshorts-module-system/.../NoticeRespVO.java 同上
ProductAdminController#create / NoticeAdminController#create 返回 Result<String>
ReelShortWeb/src/api/admin/{product,notice}.ts id: string
docs/api-phase1.md §2.11/§2.12 写明"id 以字符串下发"
```
新增两条防回归测试(把真实雪花 id 喂进去断言是字符串):
`ProductAdminControllerContractTest#should_send_string_id_when_product_id_exceeds_js_safe_integer`、
`NoticeAdminControllerContractTest#should_send_string_id_when_notice_id_exceeds_js_safe_integer`。
### 动作 10:修复后复跑门禁(最终数字)
```
$ mvn test
[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (module-commerce)
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0 (admin-api)
[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (app-api)
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (transcode-worker)
[INFO] BUILD SUCCESS # 共 110 条(存量 91 + 新增 19)
$ mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests → BUILD SUCCESS
```
修复后复测(同一浏览器,重新起服务后):
```
点「已发布」→ toast「公告已下架」,行内状态变「已下架」 # 修复前这里是「公告不存在」
C 端 GET /app/notices → {"total":0} # 下架后客户端立即不可见
接口返回类型核对:id= 2111012331625517057 type= str
```
### 动作 11:自测数据清理(收尾)
```
DELETE 1 # 商品 i1_ui_coin_880(第一轮 UI 建的)
DELETE 4 # 公告 4 条(含 UI 建的)
DELETE 1 # 商品 i1_ui2_coin_500(修复后 UI 建的)
DELETE 1 # 公告 I1 复测公告:印尼开服
products | soft_deleted notices
3 | 0 0
```
解读:`product` 表回到 3 行种子(`coin_100 / coin_550 / coin_1200`),`ops_notice` 回 0 行。
**没有留下任何自测数据。**
### 本轮遗留(已写进 INBOX 回执的「待确认」)
1. 雪花 id 的全局方案:admin-api 是否统一 `Long → String`(涉及所有既有页面的 `id` 类型),
app-api 是否要动(影响 Flutter 客户端解析)——属于平台级改动,等裁定。
2. `/app/orders/verify` 的真发币链路需要 Google Play 凭据(B1-1 已列清单)。
---
## 2026-10-16 J1:全局 ID 字符串化(Long → String)+ 契约统一
计划文件:`docs/plans/2026-10-16-J1.md`(先写计划再动手)。用户裁定「方案 (a) 全局修改,统一契约」。
### 动作 1:后端全局序列化(fruitshorts-framework)
新增 `JacksonLongToStringConfig`:`SimpleModule` 把 `Long.class` / `Long.TYPE` → `ToStringSerializer`,
经 `Jackson2ObjectMapperBuilderCustomizer` 挂进 Spring Boot 的 `ObjectMapper`。
两个入口都是 `scanBasePackages = "com.fruitshorts"`,一处生效两端一致,**没改 yml**。
一个刻意的例外:`PageResult.total` 是条数不是 id,用 mixin 显式保持数字(前端分页组件要数字)。
为此给 mixin 加了个 `PlainLongSerializer`(`NumberSerializer` 没有无参构造,`using=` 装不上)。
I1 里给商品/公告 VO 逐个加的 `@JsonSerialize` 与 `Result<String>` 新建返回**已回退成自然写法** ——
全局生效后它们只是冗余特例(现在是"所有模块一个口径")。
### 动作 2:契约 §0 总口径
`docs/api-phase1.md` §0 新增「**ID 字段一律以字符串下发**」+ 示例 + 边界(`total` 与 `Integer` 保持数字、
id 回传按字符串传),§2.11/§2.12 的逐模块表述改成引用 §0。
### 动作 3:契约测试的 standalone mapper 也挂上模块(否则假绿)
`ControllerContractSupport`(app-api / admin-api 两处)自带手搓 `JsonMapper`,不含全局模块 ——
不改它的话,测试断言数字 id 也会过,而生产早就变成字符串了。挂上模块后:
```
$ mvn test
[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-module-commerce)
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-admin-api)
[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-app-api)
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-transcode-worker)
[INFO] BUILD SUCCESS # 共 115 条(存量 110 + 新增 5)
```
新增 `LongToStringSerializationTest` 5 条:普通字段 / 嵌套对象 / List / Map 值 / 分页(total 数字 + list id 字符串)
/ 请求体 `"id":"…"` 反序列化成 Long / 19 位路径变量绑定成 Long。
另外把 C 端 4 条 id 断言从数字改成 `value("…") + isString()`(原来"数字也能过"的宽松比较不算证据)。
### 动作 4:Web 与客户端适配
见回执的逐文件清单。要点:Web 审计出 4 处把 id 当数字用的地方(代看 id 过 `Number`、
两处 `Number(route.…)` 与 id 比较、菜单顶级哨兵 0 与 "0" 不一致);Flutter 侧 id 本来就是
`(json['id'] ?? '').toString()`,会崩的是被全局规则扫到的 **Long 计数**,只改了 `expiresIn` 与 `viewCount`。
```
$ pnpm ts:check → 0 错误
$ pnpm exec eslint … ./src → 0 error
$ pnpm build:pro → Build successful
$ dart analyze lib test → No issues found!
$ flutter test → All tests passed! (+167)
```
### 动作 5:端到端复现"修复前后"(真实服务)
```
① 复现 PM 那个场景:C 端注册 → admin 列表/详情
$ curl -X POST :8081/app/auth/register -d '{"targetType":"EMAIL","target":"j1.selftest@example.com",…}'
→ "expiresIn": "7200" # long 字段,已是字符串
$ curl -H "Authorization: …" :8080/admin/app-users/page?pageSize=50
total = 2 int # 分页 total 仍是数字(例外生效)
命中用户: uid=U79DG5IOM id='2111015748376858626' id类型=str
$ curl -H "Authorization: …" :8080/admin/app-users/2111015748376858626
code= 0 message= ok # 修复前这里就是 200002
data.id= '2111015748376858626' uid= U79DG5IOM
```
```
② JS 侧逐位一致(真实响应喂 node)
$ node -e '…'
响应原文里的 id(JSON 字符串): "2111015748376858626"
typeof 解析后 : string
String(parsed.id) : 2111015748376858626
库里 app_user.id : 2111015748376858626
逐位相等? : true
---- 反面对照:同一串数字当裸数字解析 ----
裸数字 -> String(parsed.id) : 2111015748376858600
逐位相等? : false
```
```
③ 全局范围:换两个模块(商品 / 创作者)走"新建 → 用响应 id 查详情 → 改状态"
新建商品响应 : {"code":0,"data":"2111015841427492865"}
商品详情 : code=0 id='2111015841427492865'
商品上架 : {"code":0}
新建创作者响应 : {"code":0,"data":"2111015841771425794"}
创作者详情 : code=0 id='2111015841771425794' name=J1 探针工作室
```
```
④ C 端字段类型(匿名可读的链路)
/app/home/feed : drama card id='5'(str) viewCount='3100000'(str)
/app/dramas/5 : id='5'(str) viewCount='3100000'(str) totalEpisodes=72(int) episode id='7'(str)
/app/episodes/7/play : episodeId='7'(str) dramaId='5'(str) durationSec=228(int) unlockCoin=0(int)
```
解读:**Long → 字符串**(id、viewCount、expiresIn、coinAmount),**Integer/int 与分页 total 仍是数字**,
与 §0 的规则和例外完全对上。
### 动作 6:自测数据清理(含一次口径纠正)
```
DELETE 1 # 自测用户 app_user(含 credential 2 / session 2 / device 2 / consent 3 / wallet 1)
DELETE 1 # 商品 j1_probe_coin
DELETE 1 # 创作者 J1PROBE
全库扫描 uid / 邮箱 / 雪花 id → 无任何残留(NOTICE: 扫描完成)
现存:creator 只剩 PM 那条 id=25(CR20261015001)、app_user 只剩 id=28(UHR-DLTH8)、
product 3 行种子、ops_notice 0 行 —— 都不是本轮数据。
```
⚠️ 口径纠正:我第一版清理脚本用 `psql -t -A -c "delete … returning 1" | wc -l` 数"行数",
**数到的其实是 psql 的命令标签**(实测删 0 行时输出 `DELETE 0`,也是 1 行),所以那轮打印的
"删除 1 行"是假数字。真正的证据是后面的**全库残留扫描**(零命中)与现存数据核对,已按此结论写回执。
---
## 2026-10-16 K1:客户端对接(详情 + 播放 + 进度续看 + 收藏)+ 三态 UI
计划文件:`docs/plans/2026-10-16-K1.md`(先写计划再动手)。仓库:`ReelShort`(Flutter,develop)。
### 动作 1:勘察更正
条目说"客户端只调了 6 个接口、没有详情和票据"——实测**详情已接**(`CatalogApi.dramaDetail`),
**票据半接**(`CatalogApi.playTicket` 存在但没页面调用)。这一点写进计划与回执。
### 动作 2:分层实现(api → repository → logic → view)
- 新增 `playback_api.dart` / `watch_api.dart` / `library_api.dart` + `watch_progress.dart` 模型
- 新增 `PlaybackRepository`(票据 TTL 缓存,默认 10 分钟;时钟/TTL 可注入)、`FavoriteRepository`
- `WatchHistoryRepository` 换成服务端模型(每剧一条续看记录),删掉 `demoProfileId` 与 SharedPreferences 实现
- `FavoriteController` 乐观更新 + 失败回滚;`app.dart` 注入三个仓库与收藏控制器
### 动作 3:门禁(最终数字)
```
$ dart analyze lib test
No issues found!
$ flutter test
00:09 +188: All tests passed!
```
新增测试:`playback_ticket_expiry_test`(票据过期重取 + 签名 query 原样保留)、
`progress_throttle_test`(10 秒节流 + 切集补报 + 播完 finished + 续看定位)、
`watch_history_test`(重写为服务端模型)、`library/favorite_controller_test`(回滚/错误态/退出清空)、
`integration/live_watch_flow_test`(真实后端端到端)。
### 动作 4:真实后端联调(8081,原始输出)
```
① 首页 → dramaId=5
② 详情 → episodes=3,第一集 id='7' playUrl=/files/episode/202610/cec39d8e….mp4?e=1792144372&s=8791826a…
③ 票据 → episodeId='7' dramaId='5' accessType=FREE accessible=True
playUrl 与详情里的签名地址一致(客户端只补 host,不动 path/query)
④ 上报进度(42s)→ {"dramaId":"5","episodeId":"7","positionMs":"42000","durationMs":"228000","playCount":1}
⑤ 读回续看位置 → dramaId='5' episodeId='7' positionMs='42000' durationMs='228000'
⑥ 最近观看 → 条数=1,dramaId='5' episodeId='7' positionMs='42000'
⑦ 收藏 → toggle 返回 true → 收藏列表条数=1(首个 '5')→ DELETE → 取消后条数=0
```
`flutter test test/integration/live_watch_flow_test.dart` → All tests passed(同一链路在客户端代码里跑通)。
解读:`positionMs/durationMs/dramaId` 都是**字符串**(J1 的新口径),客户端用 `jsonToInt` 解析。
### 动作 5:自测数据清理
```
已清理 2 个自测账号(k1.selftest.* / k1.receipt.*)+ 依赖行
残留核对:app_user 1 行(PM 的 id=28)、app_user_credential 1 行、watch_progress 0、user_favorite 0
```
(第一次清理漏了 `app_user` 本体——它的主键列叫 `id` 不是 `user_id`,随后单独删掉并复核。)
---
## 2026-10-16 K1-verify:用正确地址重做真机验证(设备侧取证)—— 结果:**未验证(已按用户要求交回用户执行)**
红线第 10 条新增后,本轮目标是"用正确地址 + 设备侧证据"重做真机验证。以下是逐动作留痕。
### 动作 1:取真实地址(不硬编码)
```
$ ipconfig getifaddr en0
192.168.31.187
$ for i in en0 en1 en2 utun0..3; do ipconfig getifaddr $i; done
en0 = 192.168.31.187 # 只有 en0 有地址;旧的 10.157.242.1 已不存在
$ lsof -nP -iTCP:8081 -sTCP:LISTEN
java 80383 ... TCP *:8081 (LISTEN)
```
### 动作 2:加设备侧集成测试(可重复、可入库的证据)
- `pubspec.yaml` dev_dependencies 加 `integration_test`(sdk)
- 新增 `integration_test/app_backend_test.dart`:在**手机进程里**请求 `/app/home/feed` 与 `/app/dramas/{id}`,
断言列表非空、详情 id 一致;再用真 App 跑一遍,断言首页底部标签栏出现(= 剧目列表加载成功)。
两个用例都把 `API_BASE_URL` 打进设备日志,便于区分"地址不对"还是"设备连不上"。
- `dart analyze lib test integration_test` → No issues found!
### 动作 3:第一次真机执行(**设备侧输出,17:56**)
```
$ IP=$(ipconfig getifaddr en0) && flutter test integration_test -d 00008101-000875813E42001E \
--dart-define=API_BASE_URL=http://$IP:8081
Running Xcode build... Xcode build done. 31.3s
Installing and launching... 20.0s
00:00 +0: should_fetch_home_feed_and_drama_detail_when_device_reaches_backend
[device] API_BASE_URL=http://192.168.31.187:8081
[device] 首页分区=4 剧目=3
[device] 详情 id=5 剧集=3
00:01 +1: should_render_home_drama_list_when_app_boots_on_device
[device] 首页剧目列表已渲染:API_BASE_URL=http://192.168.31.187:8081
00:05 +2: (tearDownAll)
00:06 +2: All tests passed!
```
解读:`[device]` 行是**手机进程里**打印的(不是开发机 curl),断言也在手机进程里跑;这一次是全过的。
### 动作 4:第二次执行(只跑第一个用例,17:59)—— 失败
```
Running Xcode build... Xcode build done. 17.8s
Installing and launching...
The Dart VM Service was not discovered after 60 seconds. This is taking much longer than expected...
```
我同时起了 lsof 轮询想抓"手机 IP → 8081"的已建立连接,**一个连接都没抓到**(原始文件只有 lsof 表头)——
因为 app 那次根本没 attach 上,也就没有发请求。这一步没有产出任何设备侧证据。
### 动作 5:第三次(`flutter run` 真机跑,18:03–18:04)—— 被叫停
```
Launching lib/main.dart on iPhone in debug mode...
Running Xcode build... Xcode build done. 30.4s
Installing and launching...
You may be prompted to give access to control Xcode...
```
PM 报告:该轮后续撞上 iOS 构建报错
`build/ios/iphoneos/video_player_avfoundation_video_player_avfoundation.bundle: No such file or directory`,
并把进程停掉、执行了 `flutter clean`(我自己的日志到 "Installing and launching" 为止,上面这条报错文本来自 PM 报告,不是我抓到的)。
**推测成因(如实写,未定论)**:我这轮 flutter run 与另一次执行**并发写同一个 build/ 目录**,
产物被互相破坏(PM 也把"并发写 build 目录"列为成因之一)。
### 动作 6:按用户指令停止执行,转为交接
用户 20:45 指令:「codex 不用跑测试 我来执行」。我随即:
```
$ kill -TERM 76461 76458 76675 77472 76250 # 我的 flutter run / xcodebuild / frontend_server / lsof 轮询
$ pgrep -fl "flutter_tools|xcodebuild|k1v_poller"
4173 ... flutter_tools.snapshot daemon # 只剩 IDE 常驻 daemon(非构建)
```
`build/` 目录里留着我被 kill 的那次构建的半成品,用户按 `flutter clean && flutter pub get` 起跑即可清掉。
### 结论口径
- 按**红线第 10 条**与用户要求:本批真机验证**标注「未验证」**,由用户执行后以用户的结果为准。
- 动作 3 那次是**真实的设备侧输出且全过**,但随后两次执行失败(一次 VM service 未 attach、一次构建产物缺失),
所以在用户重跑之前**不把它当作最终结论**;它作为历史记录保留在这里。
- 我**没有**用任何"开发机 curl"作为设备可达证据。
@@ -5,7 +5,50 @@ package com.fruitshorts;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; 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 @SpringBootApplication
@MapperScan("com.fruitshorts.**.dal.mapper") @MapperScan("com.fruitshorts.**.dal.mapper")
public class FruitShortsApplication { public class FruitShortsApplication {
@@ -10,6 +10,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.AdminUserPageReqVO; import com.fruitshorts.system.vo.AdminUserPageReqVO;
import com.fruitshorts.system.vo.AdminUserRespVO; import com.fruitshorts.system.vo.AdminUserRespVO;
import com.fruitshorts.system.vo.AdminUserSaveReqVO; import com.fruitshorts.system.vo.AdminUserSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
@Tag(name = "后台-管理员账号")
@RestController @RestController
@RequestMapping("/admin/admin-users") @RequestMapping("/admin/admin-users")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -32,18 +35,21 @@ public class AdminUserController {
private final AdminUserService adminUserService; private final AdminUserService adminUserService;
@Operation(summary = "管理员分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:admin-user')") @PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<PageResult<AdminUserRespVO>> page(AdminUserPageReqVO req) { public Result<PageResult<AdminUserRespVO>> page(AdminUserPageReqVO req) {
return Result.ok(adminUserService.page(req)); return Result.ok(adminUserService.page(req));
} }
@Operation(summary = "管理员详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')") @PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<AdminUserRespVO> get(@PathVariable Long id) { public Result<AdminUserRespVO> get(@PathVariable Long id) {
return Result.ok(adminUserService.get(id)); return Result.ok(adminUserService.get(id));
} }
@Operation(summary = "新建管理员")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('system:admin-user')") @PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "新增管理员") @OperationLog(module = "后台管理", action = "新增管理员")
@@ -51,6 +57,7 @@ public class AdminUserController {
return Result.ok(adminUserService.create(req)); return Result.ok(adminUserService.create(req));
} }
@Operation(summary = "编辑管理员")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')") @PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "编辑管理员") @OperationLog(module = "后台管理", action = "编辑管理员")
@@ -59,6 +66,7 @@ public class AdminUserController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除管理员")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')") @PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "删除管理员") @OperationLog(module = "后台管理", action = "删除管理员")
@@ -67,6 +75,7 @@ public class AdminUserController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "启用 / 停用管理员")
@PutMapping("/{id}/status") @PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('system:admin-user')") @PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "修改管理员状态") @OperationLog(module = "后台管理", action = "修改管理员状态")
@@ -75,6 +84,7 @@ public class AdminUserController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "重置管理员密码")
@PutMapping("/{id}/password") @PutMapping("/{id}/password")
@PreAuthorize("@ss.hasPermission('system:admin-user')") @PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "重置管理员密码") @OperationLog(module = "后台管理", action = "重置管理员密码")
@@ -18,6 +18,8 @@ import com.fruitshorts.user.dal.entity.AppUserBanRecord;
import com.fruitshorts.user.service.AppUserAdminService; import com.fruitshorts.user.service.AppUserAdminService;
import com.fruitshorts.user.vo.AppUserQueryReqVO; import com.fruitshorts.user.vo.AppUserQueryReqVO;
import com.fruitshorts.user.vo.AppUserAdminRespVO; import com.fruitshorts.user.vo.AppUserAdminRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -32,6 +34,7 @@ import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** 后台用户管理。 */ /** 后台用户管理。 */
@Tag(name = "后台-App 用户")
@RestController @RestController
@RequestMapping("/admin/app-users") @RequestMapping("/admin/app-users")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -42,18 +45,21 @@ public class AppUserAdminController {
private final DramaService dramaService; private final DramaService dramaService;
private final EpisodeService episodeService; private final EpisodeService episodeService;
@Operation(summary = "App 用户分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('app-user:list')") @PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<PageResult<AppUserAdminRespVO>> page(AppUserQueryReqVO req) { public Result<PageResult<AppUserAdminRespVO>> page(AppUserQueryReqVO req) {
return Result.ok(appUserAdminService.page(req)); return Result.ok(appUserAdminService.page(req));
} }
@Operation(summary = "App 用户详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-user:list')") @PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<AppUserAdminRespVO> detail(@PathVariable Long id) { public Result<AppUserAdminRespVO> detail(@PathVariable Long id) {
return Result.ok(appUserAdminService.detail(id)); return Result.ok(appUserAdminService.detail(id));
} }
@Operation(summary = "封禁 / 处置记录")
@GetMapping("/{id}/ban-records") @GetMapping("/{id}/ban-records")
@PreAuthorize("@ss.hasPermission('app-user:list')") @PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<List<AppUserBanRecord>> banRecords(@PathVariable Long id) { public Result<List<AppUserBanRecord>> banRecords(@PathVariable Long id) {
@@ -61,6 +67,7 @@ public class AppUserAdminController {
} }
/** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */ /** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */
@Operation(summary = "观看流水明细(客服核对用)")
@GetMapping("/{id}/watch-history") @GetMapping("/{id}/watch-history")
@PreAuthorize("@ss.hasPermission('app-user:list')") @PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<List<AppUserWatchHistoryVO>> watchHistory(@PathVariable Long id, public Result<List<AppUserWatchHistoryVO>> watchHistory(@PathVariable Long id,
@@ -108,6 +115,7 @@ public class AppUserAdminController {
} }
/** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */ /** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */
@Operation(summary = "处置用户状态(封禁会立即吊销全部会话)")
@PutMapping("/{id}/status") @PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('app-user:update')") @PreAuthorize("@ss.hasPermission('app-user:update')")
@OperationLog(module = "用户管理", action = "修改用户状态") @OperationLog(module = "用户管理", action = "修改用户状态")
@@ -12,6 +12,8 @@ import com.fruitshorts.system.vo.LoginRespVO;
import com.fruitshorts.system.vo.MenuVO; import com.fruitshorts.system.vo.MenuVO;
import com.fruitshorts.system.vo.PasswordChangeReqVO; import com.fruitshorts.system.vo.PasswordChangeReqVO;
import com.fruitshorts.system.vo.ProfileRespVO; import com.fruitshorts.system.vo.ProfileRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -23,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@Tag(name = "后台-登录与个人")
@RestController @RestController
@RequestMapping("/admin/auth") @RequestMapping("/admin/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -30,6 +33,7 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
@Operation(summary = "管理端登录")
@PostMapping("/login") @PostMapping("/login")
@OperationLog(module = "认证", action = "登录") @OperationLog(module = "认证", action = "登录")
public Result<LoginRespVO> login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) { public Result<LoginRespVO> login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) {
@@ -37,23 +41,27 @@ public class AuthController {
} }
/** 当前用户的菜单路由树,结构与前端 element-plus-admin 的动态路由一致。 */ /** 当前用户的菜单路由树,结构与前端 element-plus-admin 的动态路由一致。 */
@Operation(summary = "当前用户菜单路由树")
@GetMapping("/menus") @GetMapping("/menus")
public Result<List<MenuVO>> menus() { public Result<List<MenuVO>> menus() {
return Result.ok(authService.listMenus(SecurityUtils.getUserId())); return Result.ok(authService.listMenus(SecurityUtils.getUserId()));
} }
@Operation(summary = "当前用户资料")
@GetMapping("/profile") @GetMapping("/profile")
public Result<ProfileRespVO> profile() { public Result<ProfileRespVO> profile() {
return Result.ok(authService.profile(SecurityUtils.getUserId())); return Result.ok(authService.profile(SecurityUtils.getUserId()));
} }
/** JWT 无状态,登出由前端清除 token 即可。 */ /** JWT 无状态,登出由前端清除 token 即可。 */
@Operation(summary = "登出")
@PostMapping("/logout") @PostMapping("/logout")
@OperationLog(module = "认证", action = "退出登录") @OperationLog(module = "认证", action = "退出登录")
public Result<Void> logout() { public Result<Void> logout() {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "修改自己的登录密码")
@PostMapping("/password") @PostMapping("/password")
@OperationLog(module = "认证", action = "修改密码") @OperationLog(module = "认证", action = "修改密码")
public Result<Void> changePassword(@Valid @RequestBody PasswordChangeReqVO req) { public Result<Void> changePassword(@Valid @RequestBody PasswordChangeReqVO req) {
@@ -9,6 +9,8 @@ import com.fruitshorts.content.vo.BannerRespVO;
import com.fruitshorts.content.vo.BannerSaveReqVO; import com.fruitshorts.content.vo.BannerSaveReqVO;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog; 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@Tag(name = "后台-轮播图管理")
@RestController @RestController
@RequestMapping("/admin/banners") @RequestMapping("/admin/banners")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -32,6 +35,7 @@ public class BannerController {
private final BannerService bannerService; private final BannerService bannerService;
@Operation(summary = "轮播图分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('banner:list')") @PreAuthorize("@ss.hasPermission('banner:list')")
public Result<PageResult<BannerRespVO>> page(@RequestParam(required = false) String position, public Result<PageResult<BannerRespVO>> page(@RequestParam(required = false) String position,
@@ -41,12 +45,14 @@ public class BannerController {
return Result.ok(bannerService.page(position, status, pageIndex, pageSize)); return Result.ok(bannerService.page(position, status, pageIndex, pageSize));
} }
@Operation(summary = "轮播图详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:list')") @PreAuthorize("@ss.hasPermission('banner:list')")
public Result<BannerRespVO> get(@PathVariable Long id) { public Result<BannerRespVO> get(@PathVariable Long id) {
return Result.ok(bannerService.get(id)); return Result.ok(bannerService.get(id));
} }
@Operation(summary = "新建轮播图")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('banner:create')") @PreAuthorize("@ss.hasPermission('banner:create')")
@OperationLog(module = "内容管理", action = "新增轮播图") @OperationLog(module = "内容管理", action = "新增轮播图")
@@ -54,6 +60,7 @@ public class BannerController {
return Result.ok(bannerService.create(req, SecurityUtils.getUserId())); return Result.ok(bannerService.create(req, SecurityUtils.getUserId()));
} }
@Operation(summary = "编辑轮播图")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:update')") @PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "编辑轮播图") @OperationLog(module = "内容管理", action = "编辑轮播图")
@@ -62,6 +69,7 @@ public class BannerController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除轮播图")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:delete')") @PreAuthorize("@ss.hasPermission('banner:delete')")
@OperationLog(module = "内容管理", action = "删除轮播图") @OperationLog(module = "内容管理", action = "删除轮播图")
@@ -70,6 +78,7 @@ public class BannerController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "上架 / 下架轮播图")
@PutMapping("/{id}/status") @PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('banner:update')") @PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "修改轮播图状态") @OperationLog(module = "内容管理", action = "修改轮播图状态")
@@ -78,6 +87,7 @@ public class BannerController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "调整轮播排序")
@PutMapping("/sort") @PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('banner:update')") @PreAuthorize("@ss.hasPermission('banner:update')")
public Result<Void> sort(@RequestBody Map<String, List<Long>> body) { public Result<Void> sort(@RequestBody Map<String, List<Long>> body) {
@@ -11,6 +11,8 @@ import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.interaction.vo.CommentAuditReqVO; import com.fruitshorts.interaction.vo.CommentAuditReqVO;
import com.fruitshorts.interaction.vo.CommentQueryReqVO; import com.fruitshorts.interaction.vo.CommentQueryReqVO;
import com.fruitshorts.interaction.vo.CommentRespVO; import com.fruitshorts.interaction.vo.CommentRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** 后台评论审核。 */ /** 后台评论审核。 */
@Tag(name = "后台-评论审核")
@RestController @RestController
@RequestMapping("/admin/comments") @RequestMapping("/admin/comments")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -33,6 +36,7 @@ public class CommentAdminController {
private final CommentService commentService; private final CommentService commentService;
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */ /** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */
@Operation(summary = "评论检索(默认按状态过滤,待审队列)")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('comment:list')") @PreAuthorize("@ss.hasPermission('comment:list')")
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) { public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
@@ -40,12 +44,14 @@ public class CommentAdminController {
} }
/** 审核留证:机审与人审的完整链路。 */ /** 审核留证:机审与人审的完整链路。 */
@Operation(summary = "评论审核留证(机审 + 人审链路)")
@GetMapping("/{id}/audit-records") @GetMapping("/{id}/audit-records")
@PreAuthorize("@ss.hasPermission('comment:list')") @PreAuthorize("@ss.hasPermission('comment:list')")
public Result<List<ContentAuditRecord>> auditRecords(@PathVariable Long id) { public Result<List<ContentAuditRecord>> auditRecords(@PathVariable Long id) {
return Result.ok(commentService.auditRecords(id)); return Result.ok(commentService.auditRecords(id));
} }
@Operation(summary = "审核评论(通过 / 驳回)")
@PutMapping("/{id}/audit") @PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('comment:audit')") @PreAuthorize("@ss.hasPermission('comment:audit')")
@OperationLog(module = "内容治理", action = "审核评论") @OperationLog(module = "内容治理", action = "审核评论")
@@ -54,6 +60,7 @@ public class CommentAdminController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除评论")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('comment:audit')") @PreAuthorize("@ss.hasPermission('comment:audit')")
@OperationLog(module = "内容治理", action = "删除评论") @OperationLog(module = "内容治理", action = "删除评论")
@@ -6,6 +6,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.system.service.ConfigService; import com.fruitshorts.system.service.ConfigService;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.ConfigItemVO; import com.fruitshorts.system.vo.ConfigItemVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@Tag(name = "后台-系统配置")
@RestController @RestController
@RequestMapping("/admin/configs") @RequestMapping("/admin/configs")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -24,12 +27,14 @@ public class ConfigController {
private final ConfigService configService; private final ConfigService configService;
@Operation(summary = "系统配置列表")
@GetMapping @GetMapping
@PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')") @PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')")
public Result<List<ConfigItemVO>> list() { public Result<List<ConfigItemVO>> list() {
return Result.ok(configService.list()); return Result.ok(configService.list());
} }
@Operation(summary = "保存系统配置")
@PutMapping @PutMapping
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改系统设置") @OperationLog(module = "后台管理", action = "修改系统设置")
@@ -15,6 +15,8 @@ import com.fruitshorts.creator.vo.CreatorAuditReqVO;
import com.fruitshorts.creator.vo.CreatorQueryReqVO; import com.fruitshorts.creator.vo.CreatorQueryReqVO;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** 后台创作者:检索、详情、资质、入驻审核。 */ /** 后台创作者:检索、详情、资质、入驻审核。 */
@Tag(name = "后台-创作者管理")
@RestController @RestController
@RequestMapping("/admin/creators") @RequestMapping("/admin/creators")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -37,12 +40,14 @@ public class CreatorAdminController {
private final CreatorService creatorService; private final CreatorService creatorService;
private final CreatorAccountService creatorAccountService; private final CreatorAccountService creatorAccountService;
@Operation(summary = "创作者分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('creator:list')") @PreAuthorize("@ss.hasPermission('creator:list')")
public Result<PageResult<Creator>> page(CreatorQueryReqVO req) { public Result<PageResult<Creator>> page(CreatorQueryReqVO req) {
return Result.ok(creatorService.page(req)); return Result.ok(creatorService.page(req));
} }
@Operation(summary = "创作者详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('creator:list')") @PreAuthorize("@ss.hasPermission('creator:list')")
public Result<Creator> detail(@PathVariable Long id) { public Result<Creator> detail(@PathVariable Long id) {
@@ -50,12 +55,14 @@ public class CreatorAdminController {
} }
/** 资质文件:审核前要看证件原件。 */ /** 资质文件:审核前要看证件原件。 */
@Operation(summary = "创作者资质文件")
@GetMapping("/{id}/documents") @GetMapping("/{id}/documents")
@PreAuthorize("@ss.hasPermission('creator:list')") @PreAuthorize("@ss.hasPermission('creator:list')")
public Result<List<CreatorDocument>> documents(@PathVariable Long id) { public Result<List<CreatorDocument>> documents(@PathVariable Long id) {
return Result.ok(creatorService.documents(id)); return Result.ok(creatorService.documents(id));
} }
@Operation(summary = "创作者入驻审核")
@PutMapping("/{id}/audit") @PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('creator:audit')") @PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "入驻审核") @OperationLog(module = "创作者管理", action = "入驻审核")
@@ -64,6 +71,7 @@ public class CreatorAdminController {
} }
/** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */ /** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */
@Operation(summary = "手工录入创作者主体")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('creator:audit')") @PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "新增创作者") @OperationLog(module = "创作者管理", action = "新增创作者")
@@ -72,6 +80,7 @@ public class CreatorAdminController {
} }
/** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */ /** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */
@Operation(summary = "开通创作者账号(仅审核通过可开通,幂等)")
@PostMapping("/{id}/account") @PostMapping("/{id}/account")
@PreAuthorize("@ss.hasPermission('creator:account')") @PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "开通创作者账号") @OperationLog(module = "创作者管理", action = "开通创作者账号")
@@ -81,6 +90,7 @@ public class CreatorAdminController {
} }
/** 重置创作者账号密码:一次性明文返回,旧密码立即失效。 */ /** 重置创作者账号密码:一次性明文返回,旧密码立即失效。 */
@Operation(summary = "重置创作者账号密码(一次性明文返回)")
@PostMapping("/{id}/account/reset-password") @PostMapping("/{id}/account/reset-password")
@PreAuthorize("@ss.hasPermission('creator:account')") @PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "重置创作者密码") @OperationLog(module = "创作者管理", action = "重置创作者密码")
@@ -11,6 +11,8 @@ import com.fruitshorts.creator.vo.CreatorCenterNoticeVO;
import com.fruitshorts.creator.vo.CreatorCenterProfileVO; import com.fruitshorts.creator.vo.CreatorCenterProfileVO;
import com.fruitshorts.creator.vo.CreatorCenterTargetVO; import com.fruitshorts.creator.vo.CreatorCenterTargetVO;
import com.fruitshorts.admin.service.CreatorDashboardService; import com.fruitshorts.admin.service.CreatorDashboardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -28,6 +30,7 @@ import java.util.Map;
* 归属由 {@link CreatorCenterService#requireCurrentCreator()} 从登录态解析, * 归属由 {@link CreatorCenterService#requireCurrentCreator()} 从登录态解析,
* 查询强制带 `where creator_id = 当前创作者`;访问他人作品统一 403。 * 查询强制带 `where creator_id = 当前创作者`;访问他人作品统一 403。
*/ */
@Tag(name = "创作者中心")
@RestController @RestController
@RequestMapping("/admin/creator-center") @RequestMapping("/admin/creator-center")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -37,6 +40,7 @@ public class CreatorCenterController {
private final CreatorDashboardService creatorDashboardService; private final CreatorDashboardService creatorDashboardService;
/** 当前创作者的主体信息与入驻状态。 */ /** 当前创作者的主体信息与入驻状态。 */
@Operation(summary = "我的创作者资料与入驻状态")
@GetMapping("/profile") @GetMapping("/profile")
@PreAuthorize("@ss.hasPermission('creator-center:view')") @PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterProfileVO> profile() { public Result<CreatorCenterProfileVO> profile() {
@@ -44,6 +48,7 @@ public class CreatorCenterController {
} }
/** 我的作品列表(含最近一次提交的审核进度)。 */ /** 我的作品列表(含最近一次提交的审核进度)。 */
@Operation(summary = "我的作品列表(含最近一次审核进度)")
@GetMapping("/dramas/page") @GetMapping("/dramas/page")
@PreAuthorize("@ss.hasPermission('creator-center:view')") @PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PageResult<CreatorCenterDramaVO>> pageMyDramas(CreatorCenterDramaQueryReqVO req) { public Result<PageResult<CreatorCenterDramaVO>> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
@@ -51,6 +56,7 @@ public class CreatorCenterController {
} }
/** 我的作品详情:id 是别人的作品时返回 403,不区分"不存在"。 */ /** 我的作品详情:id 是别人的作品时返回 403,不区分"不存在"。 */
@Operation(summary = "我的作品详情")
@GetMapping("/dramas/{id}") @GetMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:view')") @PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterDramaVO> myDrama(@PathVariable Long id) { public Result<CreatorCenterDramaVO> myDrama(@PathVariable Long id) {
@@ -58,6 +64,7 @@ public class CreatorCenterController {
} }
/** 我的通知:入驻审核结果 + 我提交内容的审核结果。 */ /** 我的通知:入驻审核结果 + 我提交内容的审核结果。 */
@Operation(summary = "我的通知(入驻与内容审核结果)")
@GetMapping("/notices") @GetMapping("/notices")
@PreAuthorize("@ss.hasPermission('creator-center:view')") @PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<CreatorCenterNoticeVO>> notices() { public Result<List<CreatorCenterNoticeVO>> notices() {
@@ -65,6 +72,7 @@ public class CreatorCenterController {
} }
/** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */ /** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */
@Operation(summary = "我的数据看板(播放 / 完播 / 收藏 / 评论 / 解锁收入)")
@GetMapping("/dashboard") @GetMapping("/dashboard")
@PreAuthorize("@ss.hasPermission('creator-center:stat:view')") @PreAuthorize("@ss.hasPermission('creator-center:stat:view')")
public Result<Map<String, Object>> dashboard() { public Result<Map<String, Object>> dashboard() {
@@ -75,6 +83,7 @@ public class CreatorCenterController {
* 代看目标下拉的数据源:只有带 `creator-center:impersonate` 的账号(第一阶段 = 超管)能调。 * 代看目标下拉的数据源:只有带 `creator-center:impersonate` 的账号(第一阶段 = 超管)能调。
* 前端拿它渲染顶部的「当前创作者」下拉;没权限的账号不显示下拉,也不会请求这个接口。 * 前端拿它渲染顶部的「当前创作者」下拉;没权限的账号不显示下拉,也不会请求这个接口。
*/ */
@Operation(summary = "代看可选创作者列表")
@GetMapping("/impersonation/targets") @GetMapping("/impersonation/targets")
@PreAuthorize("@ss.hasPermission('creator-center:impersonate')") @PreAuthorize("@ss.hasPermission('creator-center:impersonate')")
public Result<List<CreatorCenterTargetVO>> impersonationTargets() { public Result<List<CreatorCenterTargetVO>> impersonationTargets() {
@@ -14,6 +14,8 @@ import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO; import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.creator.vo.CreatorSubmitReqVO; import com.fruitshorts.creator.vo.CreatorSubmitReqVO;
import com.fruitshorts.framework.log.OperationLog; 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -34,6 +36,7 @@ import java.util.List;
* 与 {@link CreatorCenterController} 一样**不接收 creatorId**:归属由登录态解析, * 与 {@link CreatorCenterController} 一样**不接收 creatorId**:归属由登录态解析,
* 服务层强制 `where creator_id = 当前创作者`,改别人的剧或分集一律 403。 * 服务层强制 `where creator_id = 当前创作者`,改别人的剧或分集一律 403。
*/ */
@Tag(name = "创作者中心-内容与收费")
@RestController @RestController
@RequestMapping("/admin/creator-center") @RequestMapping("/admin/creator-center")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -42,6 +45,7 @@ public class CreatorContentController {
private final CreatorContentService creatorContentService; private final CreatorContentService creatorContentService;
/** 新建草稿。 */ /** 新建草稿。 */
@Operation(summary = "新建作品草稿")
@PostMapping("/dramas") @PostMapping("/dramas")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')") @PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "新建作品") @OperationLog(module = "创作者中心", action = "新建作品")
@@ -50,6 +54,7 @@ public class CreatorContentController {
} }
/** 编辑作品信息(仅草稿 / 已下线可改)。 */ /** 编辑作品信息(仅草稿 / 已下线可改)。 */
@Operation(summary = "编辑作品信息(仅草稿 / 已下线可改)")
@PutMapping("/dramas/{id}") @PutMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')") @PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "编辑作品") @OperationLog(module = "创作者中心", action = "编辑作品")
@@ -59,6 +64,7 @@ public class CreatorContentController {
} }
/** 分集列表。 */ /** 分集列表。 */
@Operation(summary = "分集列表")
@GetMapping("/dramas/{id}/episodes") @GetMapping("/dramas/{id}/episodes")
@PreAuthorize("@ss.hasPermission('creator-center:view')") @PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<EpisodeRespVO>> listEpisodes(@PathVariable Long id) { public Result<List<EpisodeRespVO>> listEpisodes(@PathVariable Long id) {
@@ -66,6 +72,7 @@ public class CreatorContentController {
} }
/** 新增分集(集号不传自动排到最后)。 */ /** 新增分集(集号不传自动排到最后)。 */
@Operation(summary = "新增分集")
@PostMapping("/dramas/{id}/episodes") @PostMapping("/dramas/{id}/episodes")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')") @PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "新增分集") @OperationLog(module = "创作者中心", action = "新增分集")
@@ -74,6 +81,7 @@ public class CreatorContentController {
} }
/** 编辑分集(时长 / 封面 / 片源 / 排序)。 */ /** 编辑分集(时长 / 封面 / 片源 / 排序)。 */
@Operation(summary = "编辑分集(时长 / 封面 / 片源)")
@PutMapping("/dramas/{id}/episodes/{episodeId}") @PutMapping("/dramas/{id}/episodes/{episodeId}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')") @PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "编辑分集") @OperationLog(module = "创作者中心", action = "编辑分集")
@@ -84,6 +92,7 @@ public class CreatorContentController {
} }
/** 删除分集。 */ /** 删除分集。 */
@Operation(summary = "删除分集")
@DeleteMapping("/dramas/{id}/episodes/{episodeId}") @DeleteMapping("/dramas/{id}/episodes/{episodeId}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')") @PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "删除分集") @OperationLog(module = "创作者中心", action = "删除分集")
@@ -93,6 +102,7 @@ public class CreatorContentController {
} }
/** 批量排序:body 里按新顺序给出分集 id。 */ /** 批量排序:body 里按新顺序给出分集 id。 */
@Operation(summary = "分集批量排序")
@PutMapping("/dramas/{id}/episodes/sort") @PutMapping("/dramas/{id}/episodes/sort")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')") @PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "调整分集顺序") @OperationLog(module = "创作者中心", action = "调整分集顺序")
@@ -102,6 +112,7 @@ public class CreatorContentController {
} }
/** 提交审核:CREATE / UPDATE / PUBLISH / OFFLINE。 */ /** 提交审核:CREATE / UPDATE / PUBLISH / OFFLINE。 */
@Operation(summary = "提交审核(CREATE / UPDATE / PUBLISH / OFFLINE)")
@PostMapping("/dramas/{id}/submit") @PostMapping("/dramas/{id}/submit")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')") @PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "提交审核") @OperationLog(module = "创作者中心", action = "提交审核")
@@ -110,6 +121,7 @@ public class CreatorContentController {
} }
/** 撤回自己的提交(撤回后作品退回草稿,可继续编辑)。 */ /** 撤回自己的提交(撤回后作品退回草稿,可继续编辑)。 */
@Operation(summary = "撤回自己的提交")
@PostMapping("/submissions/{id}/withdraw") @PostMapping("/submissions/{id}/withdraw")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')") @PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "撤回提交") @OperationLog(module = "创作者中心", action = "撤回提交")
@@ -118,6 +130,7 @@ public class CreatorContentController {
} }
/** 我的提交历史(审核进度)。 */ /** 我的提交历史(审核进度)。 */
@Operation(summary = "我的提交历史")
@GetMapping("/dramas/{id}/submissions") @GetMapping("/dramas/{id}/submissions")
@PreAuthorize("@ss.hasPermission('creator-center:view')") @PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) { public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) {
@@ -125,6 +138,7 @@ public class CreatorContentController {
} }
/** 把上传好的视频产物挂到自己的分集上(产物必须是本人上传的)。 */ /** 把上传好的视频产物挂到自己的分集上(产物必须是本人上传的)。 */
@Operation(summary = "把上传产物挂到自己的分集上")
@PostMapping("/dramas/{id}/episodes/{episodeId}/attach") @PostMapping("/dramas/{id}/episodes/{episodeId}/attach")
@PreAuthorize("@ss.hasPermission('creator-center:upload')") @PreAuthorize("@ss.hasPermission('creator-center:upload')")
@OperationLog(module = "创作者中心", action = "挂载分集视频") @OperationLog(module = "创作者中心", action = "挂载分集视频")
@@ -135,6 +149,7 @@ public class CreatorContentController {
} }
/** 分集收费与试看秒数:读现状。 */ /** 分集收费与试看秒数:读现状。 */
@Operation(summary = "分集收费与试看秒数(读)")
@GetMapping("/dramas/{id}/prices") @GetMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:view')") @PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PriceRespVO> priceDetail(@PathVariable Long id) { public Result<PriceRespVO> priceDetail(@PathVariable Long id) {
@@ -142,6 +157,7 @@ public class CreatorContentController {
} }
/** 分集收费与试看秒数:保存(复用后台同一套 PriceService,改完立即影响付费判定)。 */ /** 分集收费与试看秒数:保存(复用后台同一套 PriceService,改完立即影响付费判定)。 */
@Operation(summary = "分集收费与试看秒数(保存)")
@PutMapping("/dramas/{id}/prices") @PutMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:price:update')") @PreAuthorize("@ss.hasPermission('creator-center:price:update')")
@OperationLog(module = "创作者中心", action = "配置分集价格") @OperationLog(module = "创作者中心", action = "配置分集价格")
@@ -4,6 +4,8 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.DashboardService; import com.fruitshorts.admin.service.DashboardService;
import com.fruitshorts.common.pojo.Result; 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 lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -18,6 +20,7 @@ import java.util.Map;
* 指标口径写在 DashboardService 的类注释与 docs/api-phase1.md 里; * 指标口径写在 DashboardService 的类注释与 docs/api-phase1.md 里;
* 取不到数据源的指标返回 null 并在 notes 里说明,不用 0 冒充。 * 取不到数据源的指标返回 null 并在 notes 里说明,不用 0 冒充。
*/ */
@Tag(name = "后台-数据看板")
@RestController @RestController
@RequestMapping("/admin/dashboard") @RequestMapping("/admin/dashboard")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -26,6 +29,7 @@ public class DashboardAdminController {
private final DashboardService dashboardService; private final DashboardService dashboardService;
/** 总览:用户 / 内容 / 播放 / 收入 / 成本。 */ /** 总览:用户 / 内容 / 播放 / 收入 / 成本。 */
@Operation(summary = "运营大盘总览(用户 / 内容 / 播放 / 收入 / 成本)")
@GetMapping("/overview") @GetMapping("/overview")
@PreAuthorize("@ss.hasPermission('dashboard:view')") @PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<Map<String, Object>> overview() { public Result<Map<String, Object>> overview() {
@@ -33,6 +37,7 @@ public class DashboardAdminController {
} }
/** 转码看板:队列积压与失败任务明细。 */ /** 转码看板:队列积压与失败任务明细。 */
@Operation(summary = "转码看板(队列积压与失败任务)")
@GetMapping("/transcode") @GetMapping("/transcode")
@PreAuthorize("@ss.hasPermission('dashboard:view')") @PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<Map<String, Object>> transcode() { public Result<Map<String, Object>> transcode() {
@@ -6,6 +6,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.enums.ContentStatus; import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.service.DramaCategoryService; import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.vo.DramaCategoryRespVO; import com.fruitshorts.content.vo.DramaCategoryRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -19,6 +21,7 @@ import java.util.Map;
/** /**
* 字典接口:题材、内容状态等下拉数据。 * 字典接口:题材、内容状态等下拉数据。
*/ */
@Tag(name = "后台-字典")
@RestController @RestController
@RequestMapping("/admin/dicts") @RequestMapping("/admin/dicts")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -26,12 +29,14 @@ public class DictController {
private final DramaCategoryService dramaCategoryService; private final DramaCategoryService dramaCategoryService;
@Operation(summary = "短剧分类字典")
@GetMapping("/drama-category") @GetMapping("/drama-category")
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> dramaCategories() { public Result<List<DramaCategoryRespVO>> dramaCategories() {
return Result.ok(dramaCategoryService.list()); return Result.ok(dramaCategoryService.list());
} }
@Operation(summary = "内容状态字典")
@GetMapping("/content-status") @GetMapping("/content-status")
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<Map<String, String>>> contentStatus() { public Result<List<Map<String, String>>> contentStatus() {
@@ -8,6 +8,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.playback.service.DownloadService; import com.fruitshorts.playback.service.DownloadService;
import com.fruitshorts.playback.vo.DownloadLicenseRespVO; import com.fruitshorts.playback.vo.DownloadLicenseRespVO;
import com.fruitshorts.playback.vo.DownloadQueryReqVO; import com.fruitshorts.playback.vo.DownloadQueryReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
/** 后台下载授权管理。 */ /** 后台下载授权管理。 */
@Tag(name = "后台-离线下载管理")
@RestController @RestController
@RequestMapping("/admin/downloads") @RequestMapping("/admin/downloads")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -27,12 +30,14 @@ public class DownloadAdminController {
private final DownloadService downloadService; private final DownloadService downloadService;
@Operation(summary = "下载授权分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('download:list')") @PreAuthorize("@ss.hasPermission('download:list')")
public Result<PageResult<DownloadLicenseRespVO>> page(DownloadQueryReqVO req) { public Result<PageResult<DownloadLicenseRespVO>> page(DownloadQueryReqVO req) {
return Result.ok(downloadService.page(req)); return Result.ok(downloadService.page(req));
} }
@Operation(summary = "下载统计")
@GetMapping("/stats") @GetMapping("/stats")
@PreAuthorize("@ss.hasPermission('download:list')") @PreAuthorize("@ss.hasPermission('download:list')")
public Result<Map<String, Object>> stats() { public Result<Map<String, Object>> stats() {
@@ -40,6 +45,7 @@ public class DownloadAdminController {
} }
/** 吊销单条授权:用户侧下次联网即失效。 */ /** 吊销单条授权:用户侧下次联网即失效。 */
@Operation(summary = "吊销单条下载授权")
@PutMapping("/{id}/revoke") @PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('download:revoke')") @PreAuthorize("@ss.hasPermission('download:revoke')")
@OperationLog(module = "下载管理", action = "吊销下载授权") @OperationLog(module = "下载管理", action = "吊销下载授权")
@@ -49,6 +55,7 @@ public class DownloadAdminController {
} }
/** 手动触发过期清理,正常情况下由定时任务每天执行。 */ /** 手动触发过期清理,正常情况下由定时任务每天执行。 */
@Operation(summary = "手动触发过期授权清理")
@PostMapping("/cleanup") @PostMapping("/cleanup")
@PreAuthorize("@ss.hasPermission('download:revoke')") @PreAuthorize("@ss.hasPermission('download:revoke')")
@OperationLog(module = "下载管理", action = "清理过期下载授权") @OperationLog(module = "下载管理", action = "清理过期下载授权")
@@ -8,6 +8,8 @@ import com.fruitshorts.content.vo.DramaCategoryRespVO;
import com.fruitshorts.content.vo.DramaCategorySaveReqVO; import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog; 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import java.util.List;
/** /**
* 短剧类型管理。类型属于短剧域,权限沿用 drama:*,不再单开权限点。 * 短剧类型管理。类型属于短剧域,权限沿用 drama:*,不再单开权限点。
*/ */
@Tag(name = "后台-短剧分类")
@RestController @RestController
@RequestMapping("/admin/drama-categories") @RequestMapping("/admin/drama-categories")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -32,12 +35,14 @@ public class DramaCategoryController {
private final DramaCategoryService dramaCategoryService; private final DramaCategoryService dramaCategoryService;
@Operation(summary = "分类列表")
@GetMapping @GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> list() { public Result<List<DramaCategoryRespVO>> list() {
return Result.ok(dramaCategoryService.list()); return Result.ok(dramaCategoryService.list());
} }
@Operation(summary = "新建分类")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')") @PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增短剧类型") @OperationLog(module = "内容管理", action = "新增短剧类型")
@@ -45,6 +50,7 @@ public class DramaCategoryController {
return Result.ok(dramaCategoryService.create(req)); return Result.ok(dramaCategoryService.create(req));
} }
@Operation(summary = "编辑分类")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')") @PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧类型") @OperationLog(module = "内容管理", action = "编辑短剧类型")
@@ -53,6 +59,7 @@ public class DramaCategoryController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除分类")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')") @PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧类型") @OperationLog(module = "内容管理", action = "删除短剧类型")
@@ -12,6 +12,8 @@ import com.fruitshorts.content.vo.DramaSaveReqVO;
import com.fruitshorts.content.vo.DramaStatusReqVO; import com.fruitshorts.content.vo.DramaStatusReqVO;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog; 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -26,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@Tag(name = "后台-短剧管理")
@RestController @RestController
@RequestMapping("/admin/dramas") @RequestMapping("/admin/dramas")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -33,18 +36,21 @@ public class DramaController {
private final DramaService dramaService; private final DramaService dramaService;
@Operation(summary = "短剧分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) { public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
return Result.ok(dramaService.page(req)); return Result.ok(dramaService.page(req));
} }
@Operation(summary = "短剧详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasPermission('drama:list')")
public Result<DramaRespVO> get(@PathVariable Long id) { public Result<DramaRespVO> get(@PathVariable Long id) {
return Result.ok(dramaService.get(id)); return Result.ok(dramaService.get(id));
} }
@Operation(summary = "新建短剧")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')") @PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新建短剧") @OperationLog(module = "内容管理", action = "新建短剧")
@@ -52,6 +58,7 @@ public class DramaController {
return Result.ok(dramaService.create(req, SecurityUtils.getUserId())); return Result.ok(dramaService.create(req, SecurityUtils.getUserId()));
} }
@Operation(summary = "编辑短剧")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')") @PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧") @OperationLog(module = "内容管理", action = "编辑短剧")
@@ -60,6 +67,7 @@ public class DramaController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除短剧")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')") @PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧") @OperationLog(module = "内容管理", action = "删除短剧")
@@ -69,6 +77,7 @@ public class DramaController {
} }
/** 提交审核:内容运营即可操作。 */ /** 提交审核:内容运营即可操作。 */
@Operation(summary = "提交审核")
@PostMapping("/{id}/submit") @PostMapping("/{id}/submit")
@PreAuthorize("@ss.hasAnyPermission('drama:review','drama:update')") @PreAuthorize("@ss.hasAnyPermission('drama:review','drama:update')")
@OperationLog(module = "内容管理", action = "提交审核") @OperationLog(module = "内容管理", action = "提交审核")
@@ -77,6 +86,7 @@ public class DramaController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "审核通过")
@PostMapping("/{id}/approve") @PostMapping("/{id}/approve")
@PreAuthorize("@ss.hasPermission('drama:review')") @PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "审核通过") @OperationLog(module = "内容管理", action = "审核通过")
@@ -85,6 +95,7 @@ public class DramaController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "审核驳回")
@PostMapping("/{id}/reject") @PostMapping("/{id}/reject")
@PreAuthorize("@ss.hasPermission('drama:review')") @PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "驳回短剧") @OperationLog(module = "内容管理", action = "驳回短剧")
@@ -93,6 +104,7 @@ public class DramaController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "上线")
@PostMapping("/{id}/publish") @PostMapping("/{id}/publish")
@PreAuthorize("@ss.hasPermission('drama:review')") @PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧上线") @OperationLog(module = "内容管理", action = "短剧上线")
@@ -101,6 +113,7 @@ public class DramaController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "下线")
@PostMapping("/{id}/offline") @PostMapping("/{id}/offline")
@PreAuthorize("@ss.hasPermission('drama:review')") @PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧下线") @OperationLog(module = "内容管理", action = "短剧下线")
@@ -109,6 +122,7 @@ public class DramaController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "审核记录")
@GetMapping("/{id}/review-records") @GetMapping("/{id}/review-records")
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaReviewRecord>> reviewRecords(@PathVariable Long id) { public Result<List<DramaReviewRecord>> reviewRecords(@PathVariable Long id) {
@@ -8,6 +8,8 @@ import com.fruitshorts.commerce.vo.EntitlementGrantReqVO;
import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog; 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 后台权益:检索、人工补发、回收。 */ /** 后台权益:检索、人工补发、回收。 */
@Tag(name = "后台-权益管理")
@RestController @RestController
@RequestMapping("/admin/entitlements") @RequestMapping("/admin/entitlements")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -28,6 +31,7 @@ public class EntitlementAdminController {
private final EntitlementService entitlementService; private final EntitlementService entitlementService;
@Operation(summary = "权益分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('entitlement:list')") @PreAuthorize("@ss.hasPermission('entitlement:list')")
public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId, public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId,
@@ -39,6 +43,7 @@ public class EntitlementAdminController {
} }
/** 客服补偿:直接给用户某集/整剧的播放权益。 */ /** 客服补偿:直接给用户某集/整剧的播放权益。 */
@Operation(summary = "补发权益(客服补偿)")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('entitlement:update')") @PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "人工补发权益") @OperationLog(module = "订单管理", action = "人工补发权益")
@@ -50,6 +55,7 @@ public class EntitlementAdminController {
} }
/** 回收权益:退款、违规处置后用。 */ /** 回收权益:退款、违规处置后用。 */
@Operation(summary = "回收权益(退款 / 违规处置)")
@PutMapping("/{id}/revoke") @PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('entitlement:update')") @PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "回收权益") @OperationLog(module = "订单管理", action = "回收权益")
@@ -8,6 +8,8 @@ import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO; import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import com.fruitshorts.content.vo.EpisodeSortReqVO; import com.fruitshorts.content.vo.EpisodeSortReqVO;
import com.fruitshorts.framework.log.OperationLog; 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -23,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@Tag(name = "后台-剧集管理")
@RestController @RestController
@RequestMapping("/admin/episodes") @RequestMapping("/admin/episodes")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -30,6 +33,7 @@ public class EpisodeController {
private final EpisodeService episodeService; private final EpisodeService episodeService;
@Operation(summary = "剧集列表")
@GetMapping @GetMapping
@PreAuthorize("@ss.hasPermission('episode:list')") @PreAuthorize("@ss.hasPermission('episode:list')")
public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId, public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId,
@@ -37,6 +41,7 @@ public class EpisodeController {
return Result.ok(episodeService.listByDrama(dramaId, status)); return Result.ok(episodeService.listByDrama(dramaId, status));
} }
@Operation(summary = "新建剧集")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('episode:upload')") @PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "新增剧集") @OperationLog(module = "内容管理", action = "新增剧集")
@@ -44,6 +49,7 @@ public class EpisodeController {
return Result.ok(episodeService.create(req)); return Result.ok(episodeService.create(req));
} }
@Operation(summary = "编辑剧集")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')") @PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "编辑剧集") @OperationLog(module = "内容管理", action = "编辑剧集")
@@ -52,6 +58,7 @@ public class EpisodeController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除剧集")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')") @PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "删除剧集") @OperationLog(module = "内容管理", action = "删除剧集")
@@ -60,6 +67,7 @@ public class EpisodeController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "剧集排序")
@PutMapping("/sort") @PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('episode:sort')") @PreAuthorize("@ss.hasPermission('episode:sort')")
@OperationLog(module = "内容管理", action = "调整剧集顺序") @OperationLog(module = "内容管理", action = "调整剧集顺序")
@@ -11,6 +11,8 @@ import com.fruitshorts.media.vo.UploadCompleteReqVO;
import com.fruitshorts.media.vo.UploadInitReqVO; import com.fruitshorts.media.vo.UploadInitReqVO;
import com.fruitshorts.media.vo.UploadInitRespVO; import com.fruitshorts.media.vo.UploadInitRespVO;
import com.fruitshorts.media.vo.UploadProgressRespVO; import com.fruitshorts.media.vo.UploadProgressRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -26,6 +28,7 @@ import org.springframework.web.multipart.MultipartFile;
/** /**
* 媒资上传(本地存储版):init 建会话 → chunk 传分片 → complete 合并出地址。 * 媒资上传(本地存储版):init 建会话 → chunk 传分片 → complete 合并出地址。
*/ */
@Tag(name = "后台-媒资管理")
@RestController @RestController
@RequestMapping("/admin/media") @RequestMapping("/admin/media")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -33,6 +36,7 @@ public class MediaController {
private final MediaService mediaService; private final MediaService mediaService;
@Operation(summary = "初始化分片上传")
@PostMapping("/upload/init") @PostMapping("/upload/init")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')") @PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<UploadInitRespVO> init(@Valid @RequestBody UploadInitReqVO req) { public Result<UploadInitRespVO> init(@Valid @RequestBody UploadInitReqVO req) {
@@ -41,6 +45,7 @@ public class MediaController {
return Result.ok(mediaService.init(req, SecurityUtils.getUserId())); return Result.ok(mediaService.init(req, SecurityUtils.getUserId()));
} }
@Operation(summary = "上传分片")
@PostMapping("/upload/chunk") @PostMapping("/upload/chunk")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')") @PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<Integer> uploadChunk(@RequestParam String uploadId, public Result<Integer> uploadChunk(@RequestParam String uploadId,
@@ -51,6 +56,7 @@ public class MediaController {
return Result.ok(index); return Result.ok(index);
} }
@Operation(summary = "完成上传")
@PostMapping("/upload/complete") @PostMapping("/upload/complete")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')") @PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<MediaAssetRespVO> complete(@Valid @RequestBody UploadCompleteReqVO req) { public Result<MediaAssetRespVO> complete(@Valid @RequestBody UploadCompleteReqVO req) {
@@ -59,12 +65,14 @@ public class MediaController {
} }
/** 断点续传:查上传会话进度(只能查自己发起的会话)。 */ /** 断点续传:查上传会话进度(只能查自己发起的会话)。 */
@Operation(summary = "查询上传进度(断点续传)")
@GetMapping("/upload/{uploadId}") @GetMapping("/upload/{uploadId}")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')") @PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<UploadProgressRespVO> progress(@PathVariable String uploadId) { public Result<UploadProgressRespVO> progress(@PathVariable String uploadId) {
return Result.ok(mediaService.progress(uploadId, SecurityUtils.getUserId())); return Result.ok(mediaService.progress(uploadId, SecurityUtils.getUserId()));
} }
@Operation(summary = "媒资详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')") @PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<MediaAssetRespVO> get(@PathVariable Long id) { public Result<MediaAssetRespVO> get(@PathVariable Long id) {
@@ -7,6 +7,8 @@ import com.fruitshorts.system.service.MenuService;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.MenuSaveReqVO; import com.fruitshorts.system.vo.MenuSaveReqVO;
import com.fruitshorts.system.vo.MenuVO; import com.fruitshorts.system.vo.MenuVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@Tag(name = "后台-菜单管理")
@RestController @RestController
@RequestMapping("/admin/menus") @RequestMapping("/admin/menus")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -28,12 +31,14 @@ public class MenuController {
private final MenuService menuService; private final MenuService menuService;
@Operation(summary = "菜单树")
@GetMapping("/tree") @GetMapping("/tree")
@PreAuthorize("@ss.hasPermission('system:menu')") @PreAuthorize("@ss.hasPermission('system:menu')")
public Result<List<MenuVO>> tree() { public Result<List<MenuVO>> tree() {
return Result.ok(menuService.listAll()); return Result.ok(menuService.listAll());
} }
@Operation(summary = "新建菜单")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('system:menu')") @PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "新增菜单") @OperationLog(module = "后台管理", action = "新增菜单")
@@ -41,6 +46,7 @@ public class MenuController {
return Result.ok(menuService.create(req)); return Result.ok(menuService.create(req));
} }
@Operation(summary = "编辑菜单")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:menu')") @PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "编辑菜单") @OperationLog(module = "后台管理", action = "编辑菜单")
@@ -49,6 +55,7 @@ public class MenuController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除菜单")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:menu')") @PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "删除菜单") @OperationLog(module = "后台管理", action = "删除菜单")
@@ -0,0 +1,90 @@
// 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();
}
}
@@ -7,6 +7,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.system.service.OperationLogService; import com.fruitshorts.system.service.OperationLogService;
import com.fruitshorts.system.vo.OperationLogPageReqVO; import com.fruitshorts.system.vo.OperationLogPageReqVO;
import com.fruitshorts.system.vo.OperationLogRespVO; import com.fruitshorts.system.vo.OperationLogRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Tag(name = "后台-操作日志")
@RestController @RestController
@RequestMapping("/admin/operation-logs") @RequestMapping("/admin/operation-logs")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -21,12 +24,14 @@ public class OperationLogController {
private final OperationLogService operationLogService; private final OperationLogService operationLogService;
@Operation(summary = "操作日志分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('log:list')") @PreAuthorize("@ss.hasPermission('log:list')")
public Result<PageResult<OperationLogRespVO>> page(OperationLogPageReqVO req) { public Result<PageResult<OperationLogRespVO>> page(OperationLogPageReqVO req) {
return Result.ok(operationLogService.page(req)); return Result.ok(operationLogService.page(req));
} }
@Operation(summary = "操作日志详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('log:list')") @PreAuthorize("@ss.hasPermission('log:list')")
public Result<OperationLogRespVO> get(@PathVariable Long id) { public Result<OperationLogRespVO> get(@PathVariable Long id) {
@@ -10,6 +10,8 @@ import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -24,6 +26,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** 后台订单:检索、补发、退款回收。 */ /** 后台订单:检索、补发、退款回收。 */
@Tag(name = "后台-订单管理")
@RestController @RestController
@RequestMapping("/admin/orders") @RequestMapping("/admin/orders")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -31,12 +34,14 @@ public class OrderAdminController {
private final OrderService orderService; private final OrderService orderService;
@Operation(summary = "订单分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('order:list')") @PreAuthorize("@ss.hasPermission('order:list')")
public Result<PageResult<Orders>> page(OrderQueryReqVO req) { public Result<PageResult<Orders>> page(OrderQueryReqVO req) {
return Result.ok(orderService.page(req)); return Result.ok(orderService.page(req));
} }
@Operation(summary = "订单详情")
@GetMapping("/{orderNo}") @GetMapping("/{orderNo}")
@PreAuthorize("@ss.hasPermission('order:list')") @PreAuthorize("@ss.hasPermission('order:list')")
public Result<Orders> detail(@PathVariable String orderNo) { public Result<Orders> detail(@PathVariable String orderNo) {
@@ -44,6 +49,7 @@ public class OrderAdminController {
} }
/** 订单状态流转流水:支付争议时的证据链。 */ /** 订单状态流转流水:支付争议时的证据链。 */
@Operation(summary = "订单状态流转流水")
@GetMapping("/{orderNo}/events") @GetMapping("/{orderNo}/events")
@PreAuthorize("@ss.hasPermission('order:list')") @PreAuthorize("@ss.hasPermission('order:list')")
public Result<List<OrderEvent>> events(@PathVariable String orderNo) { public Result<List<OrderEvent>> events(@PathVariable String orderNo) {
@@ -51,6 +57,7 @@ public class OrderAdminController {
} }
/** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */ /** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */
@Operation(summary = "补发订单(已支付未交付重新交付,幂等)")
@PostMapping("/{orderNo}/compensate") @PostMapping("/{orderNo}/compensate")
@PreAuthorize("@ss.hasPermission('order:update')") @PreAuthorize("@ss.hasPermission('order:update')")
@OperationLog(module = "订单管理", action = "补发订单") @OperationLog(module = "订单管理", action = "补发订单")
@@ -59,6 +66,7 @@ public class OrderAdminController {
} }
/** 退款/撤销:回收权益并扣回金币。 */ /** 退款/撤销:回收权益并扣回金币。 */
@Operation(summary = "退款 / 撤销(回收权益并扣回金币)")
@PostMapping("/{orderNo}/refund") @PostMapping("/{orderNo}/refund")
@PreAuthorize("@ss.hasPermission('order:update')") @PreAuthorize("@ss.hasPermission('order:update')")
@OperationLog(module = "订单管理", action = "订单退款") @OperationLog(module = "订单管理", action = "订单退款")
@@ -5,6 +5,8 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.system.service.PermissionService; import com.fruitshorts.system.service.PermissionService;
import com.fruitshorts.system.vo.PermissionGroupVO; import com.fruitshorts.system.vo.PermissionGroupVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -13,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@Tag(name = "后台-权限点")
@RestController @RestController
@RequestMapping("/admin/permissions") @RequestMapping("/admin/permissions")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -21,6 +24,7 @@ public class PermissionController {
private final PermissionService permissionService; private final PermissionService permissionService;
/** 按分组返回权限点,直接喂给前端的角色权限配置页。 */ /** 按分组返回权限点,直接喂给前端的角色权限配置页。 */
@Operation(summary = "权限点列表(按分组返回)")
@GetMapping @GetMapping
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
public Result<List<PermissionGroupVO>> groups() { public Result<List<PermissionGroupVO>> groups() {
@@ -7,6 +7,8 @@ import com.fruitshorts.content.service.PriceService;
import com.fruitshorts.content.vo.PriceRespVO; import com.fruitshorts.content.vo.PriceRespVO;
import com.fruitshorts.content.vo.PriceSaveReqVO; import com.fruitshorts.content.vo.PriceSaveReqVO;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 后台分集价格与免费集配置。 */ /** 后台分集价格与免费集配置。 */
@Tag(name = "后台-分集收费")
@RestController @RestController
@RequestMapping("/admin/prices") @RequestMapping("/admin/prices")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -25,12 +28,14 @@ public class PriceAdminController {
private final PriceService priceService; private final PriceService priceService;
/** 当前价格配置:整剧默认 + 分集明细。 */ /** 当前价格配置:整剧默认 + 分集明细。 */
@Operation(summary = "分集价格配置(整剧默认 + 分集明细)")
@GetMapping @GetMapping
@PreAuthorize("@ss.hasPermission('price:update')") @PreAuthorize("@ss.hasPermission('price:update')")
public Result<PriceRespVO> detail(@RequestParam Long dramaId) { public Result<PriceRespVO> detail(@RequestParam Long dramaId) {
return Result.ok(priceService.detail(dramaId)); return Result.ok(priceService.detail(dramaId));
} }
@Operation(summary = "保存分集价格配置")
@PutMapping @PutMapping
@PreAuthorize("@ss.hasPermission('price:update')") @PreAuthorize("@ss.hasPermission('price:update')")
@OperationLog(module = "定价管理", action = "配置分集价格") @OperationLog(module = "定价管理", action = "配置分集价格")
@@ -0,0 +1,90 @@
// 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();
}
}
@@ -7,6 +7,8 @@ import com.fruitshorts.commerce.service.ReconcileService;
import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate; import java.time.LocalDate;
/** 后台对账:手动触发与历史查看。 */ /** 后台对账:手动触发与历史查看。 */
@Tag(name = "后台-对账")
@RestController @RestController
@RequestMapping("/admin/reconcile") @RequestMapping("/admin/reconcile")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -25,6 +28,7 @@ public class ReconcileAdminController {
private final ReconcileService reconcileService; private final ReconcileService reconcileService;
@Operation(summary = "对账任务列表")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('order:list')") @PreAuthorize("@ss.hasPermission('order:list')")
public Result<PageResult<ReconcileTask>> page(@RequestParam(defaultValue = "1") Integer pageIndex, public Result<PageResult<ReconcileTask>> page(@RequestParam(defaultValue = "1") Integer pageIndex,
@@ -33,6 +37,7 @@ public class ReconcileAdminController {
} }
/** 手动对账:默认前一天,可指定日期与渠道。 */ /** 手动对账:默认前一天,可指定日期与渠道。 */
@Operation(summary = "手动触发对账")
@PostMapping("/run") @PostMapping("/run")
@PreAuthorize("@ss.hasPermission('reconcile:run')") @PreAuthorize("@ss.hasPermission('reconcile:run')")
@OperationLog(module = "订单管理", action = "手动对账") @OperationLog(module = "订单管理", action = "手动对账")
@@ -10,6 +10,8 @@ import com.fruitshorts.interaction.dal.entity.ContentReport;
import com.fruitshorts.interaction.service.ReportService; import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.ReportHandleReqVO; import com.fruitshorts.interaction.vo.ReportHandleReqVO;
import com.fruitshorts.interaction.vo.ReportQueryReqVO; import com.fruitshorts.interaction.vo.ReportQueryReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 后台举报工单。 */ /** 后台举报工单。 */
@Tag(name = "后台-举报工单")
@RestController @RestController
@RequestMapping("/admin/reports") @RequestMapping("/admin/reports")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -27,6 +30,7 @@ public class ReportAdminController {
private final ReportService reportService; private final ReportService reportService;
@Operation(summary = "举报工单分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('report:list')") @PreAuthorize("@ss.hasPermission('report:list')")
public Result<PageResult<ContentReport>> page(ReportQueryReqVO req) { public Result<PageResult<ContentReport>> page(ReportQueryReqVO req) {
@@ -34,6 +38,7 @@ public class ReportAdminController {
} }
/** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */ /** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */
@Operation(summary = "处理举报工单(可联动驳回评论与封禁用户)")
@PutMapping("/{id}/handle") @PutMapping("/{id}/handle")
@PreAuthorize("@ss.hasPermission('report:handle')") @PreAuthorize("@ss.hasPermission('report:handle')")
@OperationLog(module = "内容治理", action = "处理举报") @OperationLog(module = "内容治理", action = "处理举报")
@@ -7,6 +7,8 @@ import com.fruitshorts.system.service.RoleCategoryService;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.RoleCategoryRespVO; import com.fruitshorts.system.vo.RoleCategoryRespVO;
import com.fruitshorts.system.vo.RoleCategorySaveReqVO; import com.fruitshorts.system.vo.RoleCategorySaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@Tag(name = "后台-角色分类")
@RestController @RestController
@RequestMapping("/admin/role-categories") @RequestMapping("/admin/role-categories")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -28,12 +31,14 @@ public class RoleCategoryController {
private final RoleCategoryService roleCategoryService; private final RoleCategoryService roleCategoryService;
@Operation(summary = "角色分类列表")
@GetMapping @GetMapping
@PreAuthorize("@ss.hasPermission('system:role-category')") @PreAuthorize("@ss.hasPermission('system:role-category')")
public Result<List<RoleCategoryRespVO>> list() { public Result<List<RoleCategoryRespVO>> list() {
return Result.ok(roleCategoryService.list()); return Result.ok(roleCategoryService.list());
} }
@Operation(summary = "新建角色分类")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('system:role-category')") @PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "新增角色分类") @OperationLog(module = "后台管理", action = "新增角色分类")
@@ -41,6 +46,7 @@ public class RoleCategoryController {
return Result.ok(roleCategoryService.create(req)); return Result.ok(roleCategoryService.create(req));
} }
@Operation(summary = "编辑角色分类")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role-category')") @PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "编辑角色分类") @OperationLog(module = "后台管理", action = "编辑角色分类")
@@ -49,6 +55,7 @@ public class RoleCategoryController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除角色分类")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role-category')") @PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "删除角色分类") @OperationLog(module = "后台管理", action = "删除角色分类")
@@ -9,6 +9,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.RolePermissionSaveReqVO; import com.fruitshorts.system.vo.RolePermissionSaveReqVO;
import com.fruitshorts.system.vo.RoleRespVO; import com.fruitshorts.system.vo.RoleRespVO;
import com.fruitshorts.system.vo.RoleSaveReqVO; import com.fruitshorts.system.vo.RoleSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@Tag(name = "后台-角色管理")
@RestController @RestController
@RequestMapping("/admin/roles") @RequestMapping("/admin/roles")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -31,6 +34,7 @@ public class RoleController {
private final RoleService roleService; private final RoleService roleService;
@Operation(summary = "角色分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
public Result<PageResult<RoleRespVO>> page(@RequestParam(required = false) String keyword, public Result<PageResult<RoleRespVO>> page(@RequestParam(required = false) String keyword,
@@ -39,17 +43,20 @@ public class RoleController {
return Result.ok(roleService.page(keyword, pageIndex, pageSize)); return Result.ok(roleService.page(keyword, pageIndex, pageSize));
} }
@Operation(summary = "角色下拉列表")
@GetMapping("/list") @GetMapping("/list")
public Result<List<RoleRespVO>> list() { public Result<List<RoleRespVO>> list() {
return Result.ok(roleService.listAll()); return Result.ok(roleService.listAll());
} }
@Operation(summary = "角色详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
public Result<RoleRespVO> get(@PathVariable Long id) { public Result<RoleRespVO> get(@PathVariable Long id) {
return Result.ok(roleService.get(id)); return Result.ok(roleService.get(id));
} }
@Operation(summary = "新建角色")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "新增角色") @OperationLog(module = "后台管理", action = "新增角色")
@@ -57,6 +64,7 @@ public class RoleController {
return Result.ok(roleService.create(req)); return Result.ok(roleService.create(req));
} }
@Operation(summary = "编辑角色")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "编辑角色") @OperationLog(module = "后台管理", action = "编辑角色")
@@ -65,6 +73,7 @@ public class RoleController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除角色")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "删除角色") @OperationLog(module = "后台管理", action = "删除角色")
@@ -73,12 +82,14 @@ public class RoleController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "角色已授权限")
@GetMapping("/{id}/permissions") @GetMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
public Result<List<String>> permissions(@PathVariable Long id) { public Result<List<String>> permissions(@PathVariable Long id) {
return Result.ok(roleService.listCodesByRoleId(id)); return Result.ok(roleService.listCodesByRoleId(id));
} }
@Operation(summary = "保存角色权限")
@PutMapping("/{id}/permissions") @PutMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermission('system:role')") @PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改角色权限") @OperationLog(module = "后台管理", action = "修改角色权限")
@@ -8,6 +8,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.interaction.dal.entity.SensitiveWord; import com.fruitshorts.interaction.dal.entity.SensitiveWord;
import com.fruitshorts.interaction.service.SensitiveWordService; import com.fruitshorts.interaction.service.SensitiveWordService;
import com.fruitshorts.interaction.vo.SensitiveWordReqVO; import com.fruitshorts.interaction.vo.SensitiveWordReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 后台敏感词库。改动即时生效(服务端词库缓存会失效重建)。 */ /** 后台敏感词库。改动即时生效(服务端词库缓存会失效重建)。 */
@Tag(name = "后台-敏感词库")
@RestController @RestController
@RequestMapping("/admin/sensitive-words") @RequestMapping("/admin/sensitive-words")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -29,6 +32,7 @@ public class SensitiveWordAdminController {
private final SensitiveWordService sensitiveWordService; private final SensitiveWordService sensitiveWordService;
@Operation(summary = "敏感词分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('sensitive-word:list')") @PreAuthorize("@ss.hasPermission('sensitive-word:list')")
public Result<PageResult<SensitiveWord>> page(@RequestParam(required = false) String keyword, public Result<PageResult<SensitiveWord>> page(@RequestParam(required = false) String keyword,
@@ -39,6 +43,7 @@ public class SensitiveWordAdminController {
return Result.ok(sensitiveWordService.page(keyword, category, level, pageIndex, pageSize)); return Result.ok(sensitiveWordService.page(keyword, category, level, pageIndex, pageSize));
} }
@Operation(summary = "新增敏感词")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('sensitive-word:update')") @PreAuthorize("@ss.hasPermission('sensitive-word:update')")
@OperationLog(module = "内容治理", action = "新增敏感词") @OperationLog(module = "内容治理", action = "新增敏感词")
@@ -46,6 +51,7 @@ public class SensitiveWordAdminController {
return Result.ok(sensitiveWordService.create(req)); return Result.ok(sensitiveWordService.create(req));
} }
@Operation(summary = "编辑敏感词")
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('sensitive-word:update')") @PreAuthorize("@ss.hasPermission('sensitive-word:update')")
@OperationLog(module = "内容治理", action = "修改敏感词") @OperationLog(module = "内容治理", action = "修改敏感词")
@@ -54,6 +60,7 @@ public class SensitiveWordAdminController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除敏感词")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('sensitive-word:update')") @PreAuthorize("@ss.hasPermission('sensitive-word:update')")
@OperationLog(module = "内容治理", action = "删除敏感词") @OperationLog(module = "内容治理", action = "删除敏感词")
@@ -10,6 +10,8 @@ import com.fruitshorts.creator.vo.SubmissionAuditReqVO;
import com.fruitshorts.creator.vo.SubmissionQueryReqVO; import com.fruitshorts.creator.vo.SubmissionQueryReqVO;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 后台内容提交审核:检索、详情、审核(通过 / 驳回 / 撤回)。 */ /** 后台内容提交审核:检索、详情、审核(通过 / 驳回 / 撤回)。 */
@Tag(name = "后台-内容提交审核")
@RestController @RestController
@RequestMapping("/admin/submissions") @RequestMapping("/admin/submissions")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -27,18 +30,21 @@ public class SubmissionAdminController {
private final ContentSubmissionService submissionService; private final ContentSubmissionService submissionService;
@Operation(summary = "内容提交分页查询")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@ss.hasPermission('submission:list')") @PreAuthorize("@ss.hasPermission('submission:list')")
public Result<PageResult<ContentSubmission>> page(SubmissionQueryReqVO req) { public Result<PageResult<ContentSubmission>> page(SubmissionQueryReqVO req) {
return Result.ok(submissionService.page(req)); return Result.ok(submissionService.page(req));
} }
@Operation(summary = "内容提交详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('submission:list')") @PreAuthorize("@ss.hasPermission('submission:list')")
public Result<ContentSubmission> detail(@PathVariable Long id) { public Result<ContentSubmission> detail(@PathVariable Long id) {
return Result.ok(submissionService.getExisting(id)); return Result.ok(submissionService.getExisting(id));
} }
@Operation(summary = "内容提交审核")
@PutMapping("/{id}/audit") @PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('submission:audit')") @PreAuthorize("@ss.hasPermission('submission:audit')")
@OperationLog(module = "内容审核", action = "提交审核") @OperationLog(module = "内容审核", action = "提交审核")
@@ -11,6 +11,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.user.dal.mapper.AppUserMapper; import com.fruitshorts.user.dal.mapper.AppUserMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** 后台钱包:人工补币/扣回,全部进流水且必须带幂等键。 */ /** 后台钱包:人工补币/扣回,全部进流水且必须带幂等键。 */
@Tag(name = "后台-金币钱包")
@RestController @RestController
@RequestMapping("/admin/wallets") @RequestMapping("/admin/wallets")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -33,6 +36,7 @@ public class WalletAdminController {
private final WalletService walletService; private final WalletService walletService;
private final AppUserMapper appUserMapper; private final AppUserMapper appUserMapper;
@Operation(summary = "金币流水")
@GetMapping("/{userId}/ledger") @GetMapping("/{userId}/ledger")
@PreAuthorize("@ss.hasPermission('order:list')") @PreAuthorize("@ss.hasPermission('order:list')")
public Result<List<CoinLedger>> ledger(@PathVariable Long userId, public Result<List<CoinLedger>> ledger(@PathVariable Long userId,
@@ -40,6 +44,7 @@ public class WalletAdminController {
return Result.ok(walletService.recentLedger(userId, limit)); return Result.ok(walletService.recentLedger(userId, limit));
} }
@Operation(summary = "人工补币")
@PostMapping("/coins") @PostMapping("/coins")
@PreAuthorize("@ss.hasPermission('wallet:update')") @PreAuthorize("@ss.hasPermission('wallet:update')")
@OperationLog(module = "订单管理", action = "人工补币") @OperationLog(module = "订单管理", action = "人工补币")
@@ -0,0 +1,29 @@
# Author: fruit.com.cn
#
# 生产 profile(--spring.profiles.active=prod):
# 1) 集中生产开关,不在这里写任何密钥 —— 密钥一律环境变量注入;
# 2) 密钥缺失/还是 dev 默认值/签名没开 → ProdSecretGuard 直接让启动失败(fail-fast)。
server:
port: ${SERVER_PORT:8080}
tomcat:
# 生产前置反代,真实 IP 从 X-Forwarded-* 取
remoteip:
remote-ip-header: X-Forwarded-For
protocol-header: X-Forwarded-Proto
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
fruitshorts:
media:
# 生产必须开启播放地址签名(docs/12-上线检查清单)
signature-required: true
cors:
# 后台只允许显式配置的来源(本地开发用 base 里的 localhost:3005)
allowed-origins: ${CORS_ALLOWED_ORIGINS:}
logging:
level:
root: info
com.fruitshorts: info
@@ -10,7 +10,8 @@ spring:
exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts # 地址外置:生产用 DB_URL 注入(prod profile 会校验非空)
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/fruitshorts}
username: ${DB_USER:fruitshorts} username: ${DB_USER:fruitshorts}
password: ${DB_PASSWORD:fruitshorts} password: ${DB_PASSWORD:fruitshorts}
jackson: jackson:
@@ -28,23 +29,35 @@ mybatis-plus:
global-config: global-config:
banner: false banner: false
db-config: db-config:
id-type: auto # 主键由应用侧雪花算法生成(S0),数据库不再自增
id-type: assign_id
logic-delete-field: deleted logic-delete-field: deleted
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
sequence:
# 单机开发用固定值;上多实例前必须改成按实例分配(环境变量注入),否则会撞 ID
worker-id: 1
datacenter-id: 1
fruitshorts: fruitshorts:
media: media:
# 本地媒资目录(相对应用启动目录),通过 /files/** 访问 # 媒资目录:容器里指向挂载卷;本地默认 ./data/uploads
root-dir: ./data/uploads root-dir: ${MEDIA_ROOT_DIR:./data/uploads}
public-prefix: /files public-prefix: /files
chunk-size: 5242880 chunk-size: 5242880
max-file-size: 2147483648 max-file-size: 2147483648
signature-secret: ${MEDIA_SIGNATURE_SECRET:fruitshorts-media-dev-secret-change-me-in-production-32b}
signature-required: ${MEDIA_SIGNATURE_REQUIRED:false}
signature-ttl-minutes: 15
jwt: jwt:
# 双密钥都要外置:只配 admin 而漏配 app,生产会静默用 dev 默认值(prod fail-fast 会拦住)
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b} admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
admin-ttl-hours: 12 admin-ttl-hours: 12
app-secret: ${JWT_APP_SECRET:fruitshorts-app-dev-secret-change-me-in-production-32bytes}
app-access-ttl-minutes: 120
app-refresh-ttl-days: 30
cors: cors:
allowed-origins: http://localhost:3005,http://127.0.0.1:3005 allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3005,http://127.0.0.1:3005}
springdoc: springdoc:
swagger-ui: swagger-ui:
@@ -0,0 +1,61 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 权限点注解检查:新增的写接口必须带 `@PreAuthorize`,且权限点与契约一致。
*
* <p>为什么用反射而不是起 Spring 上下文:{@code @PreAuthorize} 靠方法代理生效,
* 切片测试(standalone MockMvc)不会走代理,跑出来是"永远通过"的假绿;
* 直接检查注解,能真正确认"忘写权限点"这类问题(无权限点被拒的前提是**有**权限点)。
*/
class AdminPermissionAnnotationTest {
@Test
void should_declare_expected_permissions_when_checking_new_admin_endpoints() {
assertThat(permissionsOf(ProductAdminController.class))
.containsExactlyInAnyOrder("product:list", "product:list",
"product:update", "product:update", "product:update", "product:update");
assertThat(permissionsOf(NoticeAdminController.class))
.containsExactlyInAnyOrder("notice:list", "notice:list",
"notice:update", "notice:update", "notice:update", "notice:update");
}
/** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */
private List<String> permissionsOf(Class<?> controller) {
List<String> permissions = new ArrayList<>();
for (Method method : controller.getDeclaredMethods()) {
if (!isEndpoint(method)) {
continue;
}
PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
permissions.add(annotation == null ? "MISSING" : extract(annotation.value()));
}
return permissions;
}
private boolean isEndpoint(Method method) {
return method.isAnnotationPresent(GetMapping.class) || method.isAnnotationPresent(PostMapping.class)
|| method.isAnnotationPresent(PutMapping.class) || method.isAnnotationPresent(DeleteMapping.class);
}
/** 从 `@ss.hasPermission('product:list')` 里取出权限码。 */
private String extract(String expression) {
int start = expression.indexOf('\'');
int end = expression.lastIndexOf('\'');
return start >= 0 && end > start ? expression.substring(start + 1, end) : expression;
}
}
@@ -0,0 +1,117 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.system.service.OpsNoticeService;
import com.fruitshorts.system.vo.NoticeRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** `/admin/notices` 接入层契约。 */
class NoticeAdminControllerContractTest {
private OpsNoticeService opsNoticeService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
opsNoticeService = mock(OpsNoticeService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new NoticeAdminController(opsNoticeService));
}
@Test
void should_return_paged_notices_when_page_is_requested() throws Exception {
NoticeRespVO vo = new NoticeRespVO();
vo.setId(1L);
vo.setTitle("开服公告");
vo.setTarget("ALL");
vo.setTargetText("全部用户");
vo.setType("SYSTEM");
vo.setTypeText("系统");
vo.setStatus(1);
vo.setStatusText("已发布");
given(opsNoticeService.pageForAdmin(any())).willReturn(new PageResult<>(1L, List.of(vo)));
mockMvc.perform(get("/admin/notices/page").param("status", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].title").value("开服公告"))
.andExpect(jsonPath("$.data.list[0].statusText").value("已发布"))
.andExpect(jsonPath("$.data.list[0].targetText").value("全部用户"));
}
@Test
void should_return_new_id_when_creating_notice() throws Exception {
given(opsNoticeService.create(any())).willReturn(66L);
mockMvc.perform(post("/admin/notices")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"开服公告\",\"content\":\"欢迎\",\"target\":\"ALL\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
// id 以字符串回:雪花 id 直接下发数字会被浏览器四舍五入
.andExpect(jsonPath("$.data").value("66"))
.andExpect(jsonPath("$.data").isString());
}
@Test
void should_send_string_id_when_notice_id_exceeds_js_safe_integer() throws Exception {
NoticeRespVO vo = new NoticeRespVO();
vo.setId(2111011206851596290L);
vo.setTitle("开服公告");
given(opsNoticeService.pageForAdmin(any())).willReturn(new PageResult<>(1L, List.of(vo)));
mockMvc.perform(get("/admin/notices/page"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.list[0].id").value("2111011206851596290"))
.andExpect(jsonPath("$.data.list[0].id").isString());
}
@Test
void should_return_bad_request_when_title_is_missing() throws Exception {
mockMvc.perform(post("/admin/notices")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"content\":\"没有标题\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()));
}
@Test
void should_map_not_found_when_notice_does_not_exist() throws Exception {
given(opsNoticeService.detail(any())).willThrow(
new ServiceException(ErrorCode.DATA_NOT_FOUND, "公告不存在"));
mockMvc.perform(get("/admin/notices/404"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.message").value("公告不存在"));
}
@Test
void should_pass_status_when_publishing_or_taking_down_notice() throws Exception {
mockMvc.perform(put("/admin/notices/9/status").param("status", "0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(opsNoticeService).updateStatus(eq(9L), eq(0));
}
}
@@ -0,0 +1,123 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.commerce.service.ProductService;
import com.fruitshorts.commerce.vo.ProductRespVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** `/admin/products` 接入层契约:路由、参数绑定、统一响应体、错误码映射。 */
class ProductAdminControllerContractTest {
private ProductService productService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
productService = mock(ProductService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new ProductAdminController(productService));
}
@Test
void should_return_paged_products_when_page_is_requested() throws Exception {
ProductRespVO vo = new ProductRespVO();
vo.setId(1L);
vo.setCode("coin_100");
vo.setName("100 金币");
vo.setType("COIN_PACK");
vo.setTypeText("金币包");
vo.setStatus(1);
vo.setStatusText("已上架");
vo.setTotalCoin(100L);
given(productService.page(any())).willReturn(new PageResult<>(1L, List.of(vo)));
mockMvc.perform(get("/admin/products/page").param("type", "COIN_PACK"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.list[0].code").value("coin_100"))
// 状态中文由服务端给(前端不写死)
.andExpect(jsonPath("$.data.list[0].statusText").value("已上架"))
.andExpect(jsonPath("$.data.list[0].typeText").value("金币包"));
}
@Test
void should_return_new_id_when_creating_product() throws Exception {
given(productService.create(any())).willReturn(88L);
mockMvc.perform(post("/admin/products")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"coin_100\",\"name\":\"100 金币\",\"coinAmount\":100}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
// id 以字符串回:雪花 id 直接下发数字会被浏览器四舍五入
.andExpect(jsonPath("$.data").value("88"))
.andExpect(jsonPath("$.data").isString());
}
@Test
void should_send_string_id_when_product_id_exceeds_js_safe_integer() throws Exception {
ProductRespVO vo = new ProductRespVO();
// 一个真实量级的雪花 id(> 2^53-1),前端按字符串原样回传才不会被四舍五入
vo.setId(2111010131922784257L);
vo.setCode("coin_big_id");
given(productService.page(any())).willReturn(new PageResult<>(1L, List.of(vo)));
mockMvc.perform(get("/admin/products/page"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.list[0].id").value("2111010131922784257"))
.andExpect(jsonPath("$.data.list[0].id").isString());
}
@Test
void should_return_bad_request_when_code_is_missing() throws Exception {
// @NotBlank 校验失败 → 全局异常处理器给出 400 信封
mockMvc.perform(post("/admin/products")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"没有编码\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()));
}
@Test
void should_map_data_exists_when_store_product_id_is_duplicated() throws Exception {
given(productService.create(any())).willThrow(
new ServiceException(ErrorCode.DATA_EXISTS, "该平台下商店商品 id 已被占用:coin_100"));
mockMvc.perform(post("/admin/products")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"coin_x\",\"name\":\"X\",\"storeProductId\":\"coin_100\",\"coinAmount\":100}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_EXISTS.getCode()))
.andExpect(jsonPath("$.message").value("该平台下商店商品 id 已被占用:coin_100"));
}
@Test
void should_pass_status_when_updating_product_status() throws Exception {
mockMvc.perform(put("/admin/products/8/status").param("status", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(productService).updateStatus(eq(8L), eq(1));
}
}
@@ -0,0 +1,99 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.commerce.dal.entity.ReconcileTask;
import com.fruitshorts.commerce.service.ReconcileService;
import com.fruitshorts.common.pojo.PageResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* `/admin/reconcile/*` 接入层契约(api-phase1.md §2.6 已冻结):路由、参数绑定、统一响应体,
* 以及"取不到的指标是 null 而不是 0"这条硬口径在 JSON 里确实体现出来。
*/
class ReconcileAdminControllerContractTest {
private ReconcileService reconcileService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
reconcileService = mock(ReconcileService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new ReconcileAdminController(reconcileService));
}
@Test
void should_pass_date_and_platform_when_manual_reconcile_is_triggered() throws Exception {
given(reconcileService.run(eq(LocalDate.of(2026, 10, 15)), eq("GOOGLE")))
.willReturn(task());
mockMvc.perform(post("/admin/reconcile/run")
.param("bizDate", "2026-10-15")
.param("platform", "GOOGLE"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.bizDate").value("2026-10-15"))
.andExpect(jsonPath("$.data.platform").value("GOOGLE"))
.andExpect(jsonPath("$.data.diffCount").value(2))
// 契约强制:商店侧结算金额无数据源 → null(不是 0)
.andExpect(jsonPath("$.data.storeAmountSum").value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$.data.notes").value(org.hamcrest.Matchers.containsString("财务报告")));
verify(reconcileService).run(LocalDate.of(2026, 10, 15), "GOOGLE");
}
@Test
void should_default_to_yesterday_when_biz_date_is_absent() throws Exception {
given(reconcileService.run(any(), any())).willReturn(task());
mockMvc.perform(post("/admin/reconcile/run"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 不传 bizDate → 传 null 给 service(由 service 决定"默认前一天")
verify(reconcileService).run(eq(null), eq(null));
}
@Test
void should_return_paged_history_when_page_is_requested() throws Exception {
given(reconcileService.page(1, 10)).willReturn(new PageResult<>(1L, List.of(task())));
mockMvc.perform(get("/admin/reconcile/page").param("pageIndex", "1").param("pageSize", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.list[0].status").value("SUCCESS"));
}
private ReconcileTask task() {
ReconcileTask task = new ReconcileTask();
task.setId(1L);
task.setBizDate(LocalDate.of(2026, 10, 15));
task.setPlatform("GOOGLE");
task.setTotalCount(3);
task.setMatchedCount(1);
task.setDiffCount(2);
task.setAmountSum(new BigDecimal("14.97"));
task.setStoreAmountSum(null);
task.setStatus(ReconcileService.STATUS_SUCCESS);
task.setNotes("商店结算金额/手续费/税需从 Play Console 财务报告获取,故 storeAmountSum 为 null。");
return task;
}
}
@@ -0,0 +1,46 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.support;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.web.JacksonLongToStringConfig;
import com.fruitshorts.framework.web.GlobalExceptionHandler;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.mockito.Mockito;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* 控制器契约测试的公共装配:真控制器 + 真全局异常处理器 + 真校验器,**不启 Spring 容器**。
*
* <p>为什么不用 {@code @WebMvcTest}:{@code AppApiApplication} 上挂着
* {@code @MapperScan("com.fruitshorts.**.dal.mapper")},而切片测试不装配 DataSource / MyBatis,
* 扫描出的 Mapper 工厂会因为缺少 {@code SqlSessionFactory} 直接报错;要绕开就得再加一套测试专用的
* 引导配置。这里的 standaloneSetup 同样只装 Web 层(路由、参数绑定、消息转换、异常处理),
* 覆盖面不变而更轻、更稳。
*/
public final class ControllerContractSupport {
private ControllerContractSupport() {
}
/** 组装一个只含指定控制器与全局异常处理的 MockMvc。 */
@SuppressWarnings("unchecked")
public static MockMvc mockMvcOf(Object controller) {
AccessDeniedRecorder recorder = new AccessDeniedRecorder(Mockito.mock(ObjectProvider.class));
return MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler(recorder))
// 与 Spring Boot 的 Jackson 配置对齐:时间按 ISO 字符串输出而不是数组,
// 否则契约测试断言 LocalDate/LocalDateTime 时会看到 [2026,10,15] 这种形态。
.setMessageConverters(new MappingJackson2HttpMessageConverter(JsonMapper.builder()
.addModule(new JavaTimeModule())
// 与生产一致:Long/long 一律输出成字符串(见 JacksonLongToStringConfig)
.addModule(JacksonLongToStringConfig.longToStringModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build()))
.build();
}
}
@@ -0,0 +1,132 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.admin.controller.ProductAdminController;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.commerce.service.ProductService;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.framework.web.JacksonLongToStringConfig;
import lombok.Data;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* J1 全局 ID 字符串化:Long/long 一律以 JSON 字符串下发,覆盖普通字段 / 嵌套对象 / List / Map 值 / 分页 list;
* 同时保证 Integer/int 与分页 total 仍是数字,且**字符串形式的 id 能反序列化回 Long**。
*/
class LongToStringSerializationTest {
/** 雪花量级的 id(> 2^53-1),裸数字下发会被 JS 四舍五入 */
private static final long SNOWFLAKE_ID = 2111012753262120962L;
private ObjectMapper mapper;
private ProductService productService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
// 用生产同一套装配(Jackson2ObjectMapperBuilder + 我们的 customizer),而不是手搓一个 mapper,
// 否则这条测试可能"测的是测试自己"
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
new JacksonLongToStringConfig().longToStringCustomizer().customize(builder);
mapper = builder.build();
productService = mock(ProductService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new ProductAdminController(productService));
}
@Test
void should_render_long_as_string_when_field_is_long() throws Exception {
JsonNode json = mapper.readTree(mapper.writeValueAsString(new Sample(SNOWFLAKE_ID, 7)));
assertThat(json.get("id").isTextual()).isTrue();
assertThat(json.get("id").asText()).isEqualTo("2111012753262120962");
// Integer 字段不受影响:分页参数这类必须还是数字
assertThat(json.get("sort").isNumber()).isTrue();
}
@Test
void should_render_long_as_string_when_it_is_nested_list_or_map_value() throws Exception {
Nested nested = new Nested();
nested.setInner(new Sample(SNOWFLAKE_ID, 1));
nested.setList(List.of(new Sample(SNOWFLAKE_ID + 1, 1)));
nested.setMap(Map.of("drama", new Sample(SNOWFLAKE_ID + 2, 1)));
JsonNode json = mapper.readTree(mapper.writeValueAsString(nested));
assertThat(json.get("inner").get("id").isTextual()).isTrue();
assertThat(json.get("list").get(0).get("id").isTextual()).isTrue();
assertThat(json.get("map").get("drama").get("id").isTextual()).isTrue();
assertThat(json.get("list").get(0).get("id").asText()).isEqualTo("2111012753262120963");
assertThat(json.get("map").get("drama").get("id").asText()).isEqualTo("2111012753262120964");
}
@Test
void should_keep_total_as_number_and_list_ids_as_string_when_serializing_page() throws Exception {
PageResult<Sample> page = new PageResult<>(SNOWFLAKE_ID, List.of(new Sample(SNOWFLAKE_ID, 1)));
JsonNode json = mapper.readTree(mapper.writeValueAsString(page));
// total 是计数不是 id:保持数字,否则前端分页组件全部要改
assertThat(json.get("total").isNumber()).isTrue();
assertThat(json.get("total").asLong()).isEqualTo(SNOWFLAKE_ID);
assertThat(json.get("list").get(0).get("id").isTextual()).isTrue();
}
@Test
void should_read_long_from_string_when_request_body_sends_string_id() throws Exception {
Sample sample = mapper.readValue("{\"id\":\"2111012753262120962\",\"sort\":3}", Sample.class);
assertThat(sample.getId()).isEqualTo(SNOWFLAKE_ID);
assertThat(sample.getSort()).isEqualTo(3);
}
@Test
void should_bind_long_when_path_variable_is_a_19_digit_string() throws Exception {
mockMvc.perform(put("/admin/products/2111012753262120962/status").param("status", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 路径变量按字符串传来,必须原样绑定成 Long(不能被截断)
verify(productService).updateStatus(eq(SNOWFLAKE_ID), eq(1));
}
/** 普通字段 */
@Data
public static class Sample {
private Long id;
private Integer sort;
public Sample() {
}
public Sample(Long id, Integer sort) {
this.id = id;
this.sort = sort;
}
}
/** 嵌套容器 */
@Data
public static class Nested {
private Sample inner;
private List<Sample> list;
private Map<String, Sample> map;
}
}
+16
View File
@@ -41,6 +41,22 @@
<groupId>com.fruitshorts</groupId> <groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId> <artifactId>fruitshorts-module-commerce</artifactId>
</dependency> </dependency>
<!-- C 端创作者入驻/我的作品(module-creator 只读复用,未改其既有行为) -->
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-creator</artifactId>
</dependency>
<!-- 启动配置白名单读 sys_config、系统公告读 ops_notice -->
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-system</artifactId>
</dependency>
<!-- 契约测试用:MockMvc + Mockito + JUnit5,test scope,不进运行态依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -5,12 +5,36 @@ package com.fruitshorts.app;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; 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;
/** /**
* 客户端接口服务,只暴露 /app/** 与静态媒资 /files/**。 * 客户端接口服务,只暴露 /app/** 与静态媒资 /files/**。
* 与 fruitshorts-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。 * 与 fruitshorts-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。
*/ */
/* 显式指定扫描根包:本类在 com.fruitshorts.app 下,默认扫不到 content/system/framework 模块的 Bean */ /* 显式指定扫描根包:本类在 com.fruitshorts.app 下,默认扫不到 content/system/framework 模块的 Bean */
@OpenAPIDefinition(
info = @Info(
title = "FruitShorts 客户端接口(/app/**)",
version = "1.0.0",
description = "接口契约以 FruitServer/docs/api-phase1.md 为准"
),
tags = {
@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 = "创作者入驻申请、我的作品与数据看板(C 端账号)"),
@Tag(name = "客户端-启动配置与公告", description = "启动配置(白名单键)与系统公告,均匿名可访问"),
@Tag(name = "客户端-观看进度", description = "观看进度上报与续看位置"),
}
)
@SpringBootApplication(scanBasePackages = "com.fruitshorts") @SpringBootApplication(scanBasePackages = "com.fruitshorts")
@MapperScan("com.fruitshorts.**.dal.mapper") @MapperScan("com.fruitshorts.**.dal.mapper")
public class AppApiApplication { public class AppApiApplication {
@@ -13,6 +13,8 @@ import com.fruitshorts.user.vo.OtpSendReqVO;
import com.fruitshorts.user.vo.RefreshReqVO; import com.fruitshorts.user.vo.RefreshReqVO;
import com.fruitshorts.user.vo.RegisterReqVO; import com.fruitshorts.user.vo.RegisterReqVO;
import com.fruitshorts.user.vo.TokenRespVO; import com.fruitshorts.user.vo.TokenRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 客户端账号:验证码、注册、登录、刷新、登出。 */ /** 客户端账号:验证码、注册、登录、刷新、登出。 */
@Tag(name = "客户端-账号")
@RestController @RestController
@RequestMapping("/app/auth") @RequestMapping("/app/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -31,30 +34,35 @@ public class AppAuthController {
private final AppAuthService authService; private final AppAuthService authService;
/** 发送验证码。频控在服务端,客户端不需要自己限流。 */ /** 发送验证码。频控在服务端,客户端不需要自己限流。 */
@Operation(summary = "发送验证码(频控在服务端)")
@PostMapping("/otp") @PostMapping("/otp")
public Result<Void> sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) { public Result<Void> sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) {
otpService.send(req, WebUtils.getClientIp(request)); otpService.send(req, WebUtils.getClientIp(request));
return Result.ok(); return Result.ok();
} }
@Operation(summary = "注册账号(邮箱免验证码 / 手机号需验证码)")
@PostMapping("/register") @PostMapping("/register")
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req, public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
HttpServletRequest request) { HttpServletRequest request) {
return Result.ok(authService.register(req, WebUtils.getClientIp(request))); return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
} }
@Operation(summary = "登录(验证码或密码)")
@PostMapping("/login") @PostMapping("/login")
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req, public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
HttpServletRequest request) { HttpServletRequest request) {
return Result.ok(authService.login(req, WebUtils.getClientIp(request))); return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
} }
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
@PostMapping("/refresh") @PostMapping("/refresh")
public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) { public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) {
return Result.ok(authService.refresh(req.getRefreshToken())); return Result.ok(authService.refresh(req.getRefreshToken()));
} }
/** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */ /** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */
@Operation(summary = "登出(默认当前设备,allDevices 吊销全部)")
@PostMapping("/logout") @PostMapping("/logout")
public Result<Void> logout(@RequestBody(required = false) LogoutReqVO req) { public Result<Void> logout(@RequestBody(required = false) LogoutReqVO req) {
authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req); authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req);
@@ -6,6 +6,8 @@ import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppCategoryVO; import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppFeedVO; import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.common.pojo.Result; 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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -16,6 +18,7 @@ import java.util.List;
/** /**
* 客户端目录接口:首页与发现页各一次请求拿全量聚合数据。 * 客户端目录接口:首页与发现页各一次请求拿全量聚合数据。
*/ */
@Tag(name = "客户端-内容浏览")
@RestController @RestController
@RequestMapping("/app") @RequestMapping("/app")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -23,16 +26,19 @@ public class AppCatalogController {
private final AppCatalogService appCatalogService; private final AppCatalogService appCatalogService;
@Operation(summary = "首页聚合:轮播 + 分区,一次请求渲染整页")
@GetMapping("/home/feed") @GetMapping("/home/feed")
public Result<AppFeedVO> homeFeed() { public Result<AppFeedVO> homeFeed() {
return Result.ok(appCatalogService.homeFeed()); return Result.ok(appCatalogService.homeFeed());
} }
@Operation(summary = "发现页聚合:分区列表")
@GetMapping("/discover/feed") @GetMapping("/discover/feed")
public Result<AppFeedVO> discoverFeed() { public Result<AppFeedVO> discoverFeed() {
return Result.ok(appCatalogService.discoverFeed()); return Result.ok(appCatalogService.discoverFeed());
} }
@Operation(summary = "分类列表")
@GetMapping("/categories") @GetMapping("/categories")
public Result<List<AppCategoryVO>> categories() { public Result<List<AppCategoryVO>> categories() {
return Result.ok(appCatalogService.categories()); return Result.ok(appCatalogService.categories());
@@ -10,6 +10,8 @@ import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.interaction.vo.CommentPublishReqVO; import com.fruitshorts.interaction.vo.CommentPublishReqVO;
import com.fruitshorts.interaction.vo.CommentQueryReqVO; import com.fruitshorts.interaction.vo.CommentQueryReqVO;
import com.fruitshorts.interaction.vo.CommentRespVO; import com.fruitshorts.interaction.vo.CommentRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
* 不传 `rootId` 时返回顶层评论,传了则返回该楼层的回复。 * 不传 `rootId` 时返回顶层评论,传了则返回该楼层的回复。
* 列表允许匿名浏览;发布与删除需要登录。 * 列表允许匿名浏览;发布与删除需要登录。
*/ */
@Tag(name = "客户端-评论")
@RestController @RestController
@RequestMapping("/app/comments") @RequestMapping("/app/comments")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -36,6 +39,7 @@ public class AppCommentController {
private final CommentService commentService; private final CommentService commentService;
@Operation(summary = "发布评论")
@PostMapping @PostMapping
public Result<CommentRespVO> publish(@Valid @RequestBody CommentPublishReqVO req, public Result<CommentRespVO> publish(@Valid @RequestBody CommentPublishReqVO req,
HttpServletRequest request) { HttpServletRequest request) {
@@ -43,6 +47,7 @@ public class AppCommentController {
WebUtils.getClientIp(request), request.getHeader("X-Device-Id"))); WebUtils.getClientIp(request), request.getHeader("X-Device-Id")));
} }
@Operation(summary = "评论列表(游标翻页,可匿名浏览)")
@GetMapping @GetMapping
public Result<CursorResult<CommentRespVO>> page(@RequestParam Long dramaId, public Result<CursorResult<CommentRespVO>> page(@RequestParam Long dramaId,
@RequestParam(required = false) Long episodeId, @RequestParam(required = false) Long episodeId,
@@ -58,6 +63,7 @@ public class AppCommentController {
return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query)); return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query));
} }
@Operation(summary = "删除自己的评论")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) { public Result<Void> delete(@PathVariable Long id) {
commentService.delete(SecurityUtils.getAppUserId(), id); commentService.delete(SecurityUtils.getAppUserId(), id);
@@ -8,6 +8,8 @@ import com.fruitshorts.commerce.service.UnlockService;
import com.fruitshorts.commerce.service.WalletService; import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -26,6 +28,7 @@ import java.util.Map;
* 金币解锁是第一阶段真实的收费方式;真实商店购买走 {@code /app/orders/verify}, * 金币解锁是第一阶段真实的收费方式;真实商店购买走 {@code /app/orders/verify},
* 本轮只预留接口(商店未接入时返回明确业务提示,不会产生假订单)。 * 本轮只预留接口(商店未接入时返回明确业务提示,不会产生假订单)。
*/ */
@Tag(name = "客户端-金币与订单")
@RestController @RestController
@RequestMapping("/app") @RequestMapping("/app")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -36,6 +39,7 @@ public class AppCommerceController {
private final OrderService orderService; private final OrderService orderService;
/** 我的钱包:余额 + 最近流水,客户端一处渲染。 */ /** 我的钱包:余额 + 最近流水,客户端一处渲染。 */
@Operation(summary = "我的钱包(余额 + 最近流水)")
@GetMapping("/wallet") @GetMapping("/wallet")
public Result<Map<String, Object>> wallet() { public Result<Map<String, Object>> wallet() {
Long userId = SecurityUtils.getAppUserId(); Long userId = SecurityUtils.getAppUserId();
@@ -46,20 +50,31 @@ public class AppCommerceController {
} }
/** 金币解锁分集:幂等,重复点击不会重复扣币。 */ /** 金币解锁分集:幂等,重复点击不会重复扣币。 */
@Operation(summary = "金币解锁分集(幂等,重复点击不重复扣币)")
@PostMapping("/episodes/{episodeId}/unlock") @PostMapping("/episodes/{episodeId}/unlock")
public Result<Map<String, Object>> unlock(@PathVariable Long episodeId) { public Result<Map<String, Object>> unlock(@PathVariable Long episodeId) {
return Result.ok(unlockService.unlock(SecurityUtils.getAppUserId(), episodeId)); return Result.ok(unlockService.unlock(SecurityUtils.getAppUserId(), episodeId));
} }
@Operation(summary = "我的订单列表")
@GetMapping("/orders") @GetMapping("/orders")
public Result<List<Orders>> orders() { public Result<List<Orders>> orders() {
return Result.ok(orderService.listByUser(SecurityUtils.getAppUserId())); return Result.ok(orderService.listByUser(SecurityUtils.getAppUserId()));
} }
/** 提交商店票据。第一阶段商店未接入,会返回明确的"尚未配置"。 */ /**
* 提交商店票据:服务端校验 → 建单 → 发权益。
*
* <p>body:`platform`(GOOGLE/GOOGLE_PLAY)、`productId`(商店商品 id)、`receipt`(purchaseToken)。
* 同一票据重复提交只交付一次;票据已绑定其他账号、或校验不通过时返回明确业务码且不产生订单。
* 商店未配置(默认)时返回 `200003 尚未配置`,与第一阶段行为一致。
*/
@Operation(summary = "提交商店票据(校验后建单并发权益,重复提交幂等)")
@PostMapping("/orders/verify") @PostMapping("/orders/verify")
public Result<Orders> verify(@RequestBody Map<String, String> body) { public Result<Orders> verify(@RequestBody Map<String, String> body) {
return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(), return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(),
body.getOrDefault("platform", ""), body.getOrDefault("receipt", ""))); body.getOrDefault("platform", ""),
body.getOrDefault("productId", ""),
body.getOrDefault("receipt", "")));
} }
} }
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppConfigService;
import com.fruitshorts.app.service.AppNoticeService;
import com.fruitshorts.app.vo.AppNoticeVO;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 启动配置与系统公告:**两个匿名接口**(客户端登录前就要拿到)。
*
* <p>放在一起是因为它们同属"进入 App 前的公共数据",且都不依赖登录态。
*/
@Tag(name = "客户端-启动配置与公告")
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppConfigController {
private final AppConfigService appConfigService;
private final AppNoticeService appNoticeService;
/**
* 启动配置:协议版本 / 最低版本 / 功能开关。
* **只下发白名单键**,库里缺键用契约默认值兜底。
*/
@Operation(summary = "启动配置(匿名,只下发白名单键)")
@GetMapping("/config")
public Result<Map<String, Object>> config() {
return Result.ok(appConfigService.appConfig());
}
/** 系统公告:无数据返回空列表(不是 404)。 */
@Operation(summary = "系统公告(匿名,无数据返回空列表)")
@GetMapping("/notices")
public Result<PageResult<AppNoticeVO>> notices(
@RequestParam(defaultValue = "1") Integer pageIndex,
@RequestParam(defaultValue = "20") Integer pageSize) {
return Result.ok(appNoticeService.page(pageIndex == null ? 1 : pageIndex,
pageSize == null ? 20 : pageSize));
}
}
@@ -0,0 +1,42 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppConfigService;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.creator.service.CreatorAppService;
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* C 端创作者:**只有入驻申请一条**。
*
* <p>口径(2026-10-16 变更):**创作者中心(工作台 / 作品管理 / 数据看板)由 Web 端承载**(后端契约 §2.10),
* App 客户端不再提供 `works` / `stats`。归属一律取当前登录的 C 端账号,接口**不接收 creatorId**。
*/
@Tag(name = "客户端-创作者")
@RestController
@RequestMapping("/app/creator")
@RequiredArgsConstructor
public class AppCreatorController {
private final CreatorAppService creatorAppService;
private final AppConfigService appConfigService;
/** 入驻申请:幂等,重复提交返回当前状态(不重复建档)。 */
@Operation(summary = "创作者入驻申请(幂等,重复提交返回当前状态)")
@PostMapping("/apply")
public Result<CreatorApplyRespVO> apply(@Valid @RequestBody CreatorApplyReqVO req) {
return Result.ok(creatorAppService.apply(SecurityUtils.getAppUserId(), req,
appConfigService.agreementVersion()));
}
}
@@ -10,6 +10,8 @@ import com.fruitshorts.playback.vo.DownloadLicenseRespVO;
import com.fruitshorts.playback.vo.DownloadPolicyRespVO; import com.fruitshorts.playback.vo.DownloadPolicyRespVO;
import com.fruitshorts.playback.vo.DownloadProgressReqVO; import com.fruitshorts.playback.vo.DownloadProgressReqVO;
import com.fruitshorts.playback.vo.DownloadSettingReqVO; import com.fruitshorts.playback.vo.DownloadSettingReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -31,6 +33,7 @@ import java.util.List;
* <p>服务端只发授权与地址,分片由客户端向 CDN 拉取;进度上报用于断点续传与跨端展示。 * <p>服务端只发授权与地址,分片由客户端向 CDN 拉取;进度上报用于断点续传与跨端展示。
* 设备标识优先取请求体,其次取请求头 X-Device-Id。 * 设备标识优先取请求体,其次取请求头 X-Device-Id。
*/ */
@Tag(name = "客户端-离线下载")
@RestController @RestController
@RequestMapping("/app/downloads") @RequestMapping("/app/downloads")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -39,6 +42,7 @@ public class AppDownloadController {
private final DownloadService downloadService; private final DownloadService downloadService;
/** 申请下载授权(同一剧集重复申请幂等,只刷新有效期)。 */ /** 申请下载授权(同一剧集重复申请幂等,只刷新有效期)。 */
@Operation(summary = "申请下载授权(同一剧集幂等,只刷新有效期)")
@PostMapping @PostMapping
public Result<DownloadLicenseRespVO> grant(@Valid @RequestBody DownloadGrantReqVO req, public Result<DownloadLicenseRespVO> grant(@Valid @RequestBody DownloadGrantReqVO req,
HttpServletRequest request) { HttpServletRequest request) {
@@ -46,17 +50,20 @@ public class AppDownloadController {
request.getHeader("X-Device-Id"))); request.getHeader("X-Device-Id")));
} }
@Operation(summary = "我的下载授权列表")
@GetMapping @GetMapping
public Result<List<DownloadLicenseRespVO>> list(@RequestParam(required = false) String deviceId) { public Result<List<DownloadLicenseRespVO>> list(@RequestParam(required = false) String deviceId) {
return Result.ok(downloadService.list(SecurityUtils.getAppUserId(), deviceId)); return Result.ok(downloadService.list(SecurityUtils.getAppUserId(), deviceId));
} }
@Operation(summary = "下载规则(设备上限 / 仅 Wi-Fi / 清晰度上限)")
@GetMapping("/policy") @GetMapping("/policy")
public Result<DownloadPolicyRespVO> policy(@RequestParam(required = false) String deviceId) { public Result<DownloadPolicyRespVO> policy(@RequestParam(required = false) String deviceId) {
return Result.ok(downloadService.policy(SecurityUtils.getAppUserId(), deviceId)); return Result.ok(downloadService.policy(SecurityUtils.getAppUserId(), deviceId));
} }
/** 仅 Wi-Fi 下载、清晰度上限等偏好。 */ /** 仅 Wi-Fi 下载、清晰度上限等偏好。 */
@Operation(summary = "更新下载偏好设置")
@PutMapping("/settings") @PutMapping("/settings")
public Result<Void> updateSettings(@RequestBody DownloadSettingReqVO req) { public Result<Void> updateSettings(@RequestBody DownloadSettingReqVO req) {
downloadService.updateSettings(SecurityUtils.getAppUserId(), req); downloadService.updateSettings(SecurityUtils.getAppUserId(), req);
@@ -64,6 +71,7 @@ public class AppDownloadController {
} }
/** 断点续传前重新取一次地址:授权不变,只有地址是短时效的。 */ /** 断点续传前重新取一次地址:授权不变,只有地址是短时效的。 */
@Operation(summary = "重新获取下载地址(断点续传前刷新短时效地址)")
@GetMapping("/{episodeId}/url") @GetMapping("/{episodeId}/url")
public Result<DownloadLicenseRespVO> refreshUrl(@PathVariable Long episodeId, public Result<DownloadLicenseRespVO> refreshUrl(@PathVariable Long episodeId,
HttpServletRequest request) { HttpServletRequest request) {
@@ -71,6 +79,7 @@ public class AppDownloadController {
request.getHeader("X-Device-Id"))); request.getHeader("X-Device-Id")));
} }
@Operation(summary = "上报下载进度")
@PostMapping("/{episodeId}/progress") @PostMapping("/{episodeId}/progress")
public Result<DownloadLicenseRespVO> reportProgress(@PathVariable Long episodeId, public Result<DownloadLicenseRespVO> reportProgress(@PathVariable Long episodeId,
@RequestBody DownloadProgressReqVO req, @RequestBody DownloadProgressReqVO req,
@@ -79,6 +88,7 @@ public class AppDownloadController {
request.getHeader("X-Device-Id"))); request.getHeader("X-Device-Id")));
} }
@Operation(summary = "吊销某集的下载授权")
@DeleteMapping("/{episodeId}") @DeleteMapping("/{episodeId}")
public Result<Void> revoke(@PathVariable Long episodeId, public Result<Void> revoke(@PathVariable Long episodeId,
@RequestParam(required = false) String deviceId, @RequestParam(required = false) String deviceId,
@@ -89,6 +99,7 @@ public class AppDownloadController {
} }
/** 清空当前设备的全部下载授权。 */ /** 清空当前设备的全部下载授权。 */
@Operation(summary = "清空当前设备的全部下载授权")
@DeleteMapping @DeleteMapping
public Result<Integer> clear(@RequestParam(required = false) String deviceId, public Result<Integer> clear(@RequestParam(required = false) String deviceId,
HttpServletRequest request) { HttpServletRequest request) {
@@ -9,6 +9,8 @@ import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils; import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.playback.service.PlaybackService; import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO; import com.fruitshorts.playback.vo.PlayRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Tag(name = "客户端-短剧与播放")
@RestController @RestController
@RequestMapping("/app") @RequestMapping("/app")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -25,6 +28,7 @@ public class AppDramaController {
private final PlaybackService playbackService; private final PlaybackService playbackService;
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */ /** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
@Operation(summary = "短剧详情(含剧集列表,进详情页一次请求即可渲染)")
@GetMapping("/dramas/{id}") @GetMapping("/dramas/{id}")
public Result<AppDramaDetailVO> detail(@PathVariable Long id) { public Result<AppDramaDetailVO> detail(@PathVariable Long id) {
return Result.ok(appCatalogService.dramaDetail(id)); return Result.ok(appCatalogService.dramaDetail(id));
@@ -34,6 +38,7 @@ public class AppDramaController {
* 播放票据:服务端判定权益后签发短时效播放地址。 * 播放票据:服务端判定权益后签发短时效播放地址。
* 未登录也能播免费集;付费集未解锁时只返回需要多少金币。 * 未登录也能播免费集;付费集未解锁时只返回需要多少金币。
*/ */
@Operation(summary = "播放票据:判定权益后签发短时效播放地址")
@GetMapping("/episodes/{id}/play") @GetMapping("/episodes/{id}/play")
public Result<PlayRespVO> play(@PathVariable Long id, HttpServletRequest request) { public Result<PlayRespVO> play(@PathVariable Long id, HttpServletRequest request) {
return Result.ok(playbackService.play( return Result.ok(playbackService.play(
@@ -8,6 +8,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.FavoriteService; import com.fruitshorts.playback.service.FavoriteService;
import com.fruitshorts.playback.vo.FavoriteToggleReqVO; import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** 收藏。 */ /** 收藏。 */
@Tag(name = "客户端-收藏")
@RestController @RestController
@RequestMapping("/app/favorites") @RequestMapping("/app/favorites")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -30,18 +33,21 @@ public class AppFavoriteController {
private final AppCatalogService appCatalogService; private final AppCatalogService appCatalogService;
/** 切换收藏,返回切换后的状态:true 为已收藏。 */ /** 切换收藏,返回切换后的状态:true 为已收藏。 */
@Operation(summary = "切换收藏(返回切换后的状态)")
@PostMapping("/toggle") @PostMapping("/toggle")
public Result<Boolean> toggle(@Valid @RequestBody FavoriteToggleReqVO req) { public Result<Boolean> toggle(@Valid @RequestBody FavoriteToggleReqVO req) {
return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId())); return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId()));
} }
/** 收藏列表,卡片结构与首页一致。 */ /** 收藏列表,卡片结构与首页一致。 */
@Operation(summary = "我的收藏列表")
@GetMapping @GetMapping
public Result<List<AppDramaCardVO>> list() { public Result<List<AppDramaCardVO>> list() {
return Result.ok(appCatalogService.toCards( return Result.ok(appCatalogService.toCards(
favoriteService.listDramas(SecurityUtils.getAppUserId()))); favoriteService.listDramas(SecurityUtils.getAppUserId())));
} }
@Operation(summary = "取消收藏")
@DeleteMapping("/{dramaId}") @DeleteMapping("/{dramaId}")
public Result<Void> remove(@PathVariable Long dramaId) { public Result<Void> remove(@PathVariable Long dramaId) {
favoriteService.remove(SecurityUtils.getAppUserId(), dramaId); favoriteService.remove(SecurityUtils.getAppUserId(), dramaId);
@@ -9,6 +9,8 @@ import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.LikeToggleReqVO; import com.fruitshorts.interaction.vo.LikeToggleReqVO;
import com.fruitshorts.interaction.vo.LikeToggleRespVO; import com.fruitshorts.interaction.vo.LikeToggleRespVO;
import com.fruitshorts.interaction.vo.ReportCreateReqVO; import com.fruitshorts.interaction.vo.ReportCreateReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 点赞与举报。 */ /** 点赞与举报。 */
@Tag(name = "客户端-点赞与举报")
@RestController @RestController
@RequestMapping("/app") @RequestMapping("/app")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -26,12 +29,14 @@ public class AppInteractionController {
private final ReportService reportService; private final ReportService reportService;
/** 点赞/取消点赞,返回切换后的状态与最新计数。 */ /** 点赞/取消点赞,返回切换后的状态与最新计数。 */
@Operation(summary = "点赞 / 取消点赞")
@PostMapping("/likes/toggle") @PostMapping("/likes/toggle")
public Result<LikeToggleRespVO> toggleLike(@Valid @RequestBody LikeToggleReqVO req) { public Result<LikeToggleRespVO> toggleLike(@Valid @RequestBody LikeToggleReqVO req) {
return Result.ok(likeService.toggle(SecurityUtils.getAppUserId(), req)); return Result.ok(likeService.toggle(SecurityUtils.getAppUserId(), req));
} }
/** 提交举报:同一用户对同一目标只保留一条待处理工单。 */ /** 提交举报:同一用户对同一目标只保留一条待处理工单。 */
@Operation(summary = "提交举报(同一目标只保留一条待处理工单)")
@PostMapping("/reports") @PostMapping("/reports")
public Result<Long> report(@Valid @RequestBody ReportCreateReqVO req) { public Result<Long> report(@Valid @RequestBody ReportCreateReqVO req) {
return Result.ok(reportService.create(SecurityUtils.getAppUserId(), req)); return Result.ok(reportService.create(SecurityUtils.getAppUserId(), req));
@@ -9,6 +9,8 @@ import com.fruitshorts.user.service.AppUserService;
import com.fruitshorts.user.vo.AppUserRespVO; import com.fruitshorts.user.vo.AppUserRespVO;
import com.fruitshorts.user.vo.DeactivateReqVO; import com.fruitshorts.user.vo.DeactivateReqVO;
import com.fruitshorts.user.vo.ProfileUpdateReqVO; import com.fruitshorts.user.vo.ProfileUpdateReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -23,6 +25,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** 客户端个人中心:资料、设备、注销、数据导出。 */ /** 客户端个人中心:资料、设备、注销、数据导出。 */
@Tag(name = "客户端-个人中心")
@RestController @RestController
@RequestMapping("/app/me") @RequestMapping("/app/me")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -30,33 +33,39 @@ public class AppMeController {
private final AppUserService userService; private final AppUserService userService;
@Operation(summary = "我的资料")
@GetMapping @GetMapping
public Result<AppUserRespVO> me() { public Result<AppUserRespVO> me() {
return Result.ok(userService.me(SecurityUtils.getAppUserId())); return Result.ok(userService.me(SecurityUtils.getAppUserId()));
} }
@Operation(summary = "修改资料(昵称 / 头像 / 语言等)")
@PutMapping @PutMapping
public Result<AppUserRespVO> update(@Valid @RequestBody ProfileUpdateReqVO req) { public Result<AppUserRespVO> update(@Valid @RequestBody ProfileUpdateReqVO req) {
return Result.ok(userService.updateProfile(SecurityUtils.getAppUserId(), req)); return Result.ok(userService.updateProfile(SecurityUtils.getAppUserId(), req));
} }
@Operation(summary = "我的登录设备列表")
@GetMapping("/devices") @GetMapping("/devices")
public Result<List<AppUserDevice>> devices() { public Result<List<AppUserDevice>> devices() {
return Result.ok(userService.devices(SecurityUtils.getAppUserId())); return Result.ok(userService.devices(SecurityUtils.getAppUserId()));
} }
/** 商店与隐私法规要求的数据导出,返回账号侧数据;内容侧数据由客户端自行下载。 */ /** 商店与隐私法规要求的数据导出,返回账号侧数据;内容侧数据由客户端自行下载。 */
@Operation(summary = "数据导出(合规要求)")
@GetMapping("/export") @GetMapping("/export")
public Result<Map<String, Object>> export() { public Result<Map<String, Object>> export() {
return Result.ok(userService.exportAccount(SecurityUtils.getAppUserId())); return Result.ok(userService.exportAccount(SecurityUtils.getAppUserId()));
} }
/** 申请注销:立即登出全部设备,进入冷静期,期间可撤销。 */ /** 申请注销:立即登出全部设备,进入冷静期,期间可撤销。 */
@Operation(summary = "申请注销(进入冷静期并登出全部设备)")
@PostMapping("/deactivate") @PostMapping("/deactivate")
public Result<LocalDateTime> deactivate(@RequestBody(required = false) DeactivateReqVO req) { public Result<LocalDateTime> deactivate(@RequestBody(required = false) DeactivateReqVO req) {
return Result.ok(userService.requestDeactivation(SecurityUtils.getAppUserId(), req)); return Result.ok(userService.requestDeactivation(SecurityUtils.getAppUserId(), req));
} }
@Operation(summary = "撤销注销申请")
@PostMapping("/deactivate/cancel") @PostMapping("/deactivate/cancel")
public Result<Void> cancelDeactivate() { public Result<Void> cancelDeactivate() {
userService.cancelDeactivation(SecurityUtils.getAppUserId()); userService.cancelDeactivation(SecurityUtils.getAppUserId());
@@ -7,6 +7,8 @@ import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.WatchProgressService; import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.playback.vo.ProgressRespVO; import com.fruitshorts.playback.vo.ProgressRespVO;
import com.fruitshorts.playback.vo.ProgressSaveReqVO; import com.fruitshorts.playback.vo.ProgressSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** 观看进度与观看记录。 */ /** 观看进度与观看记录。 */
@Tag(name = "客户端-观看进度")
@RestController @RestController
@RequestMapping("/app/watch") @RequestMapping("/app/watch")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -29,28 +32,33 @@ public class AppWatchController {
private final WatchProgressService progressService; private final WatchProgressService progressService;
/** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */ /** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */
@Operation(summary = "上报观看进度(客户端每 10 秒一次)")
@PostMapping("/progress") @PostMapping("/progress")
public Result<ProgressRespVO> save(@Valid @RequestBody ProgressSaveReqVO req) { public Result<ProgressRespVO> save(@Valid @RequestBody ProgressSaveReqVO req) {
return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req)); return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req));
} }
/** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */ /** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */
@Operation(summary = "某部剧的续看位置")
@GetMapping("/progress") @GetMapping("/progress")
public Result<ProgressRespVO> ofDrama(@RequestParam Long dramaId) { public Result<ProgressRespVO> ofDrama(@RequestParam Long dramaId) {
return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId)); return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId));
} }
@Operation(summary = "最近观看列表")
@GetMapping("/recent") @GetMapping("/recent")
public Result<List<ProgressRespVO>> recent(@RequestParam(defaultValue = "20") int limit) { public Result<List<ProgressRespVO>> recent(@RequestParam(defaultValue = "20") int limit) {
return Result.ok(progressService.recent(SecurityUtils.getAppUserId(), limit)); return Result.ok(progressService.recent(SecurityUtils.getAppUserId(), limit));
} }
@Operation(summary = "删除某部剧的观看记录")
@DeleteMapping("/progress/{dramaId}") @DeleteMapping("/progress/{dramaId}")
public Result<Void> remove(@PathVariable Long dramaId) { public Result<Void> remove(@PathVariable Long dramaId) {
progressService.remove(SecurityUtils.getAppUserId(), dramaId); progressService.remove(SecurityUtils.getAppUserId(), dramaId);
return Result.ok(); return Result.ok();
} }
@Operation(summary = "清空观看记录")
@DeleteMapping("/progress") @DeleteMapping("/progress")
public Result<Void> clear() { public Result<Void> clear() {
progressService.clear(SecurityUtils.getAppUserId()); progressService.clear(SecurityUtils.getAppUserId());
@@ -0,0 +1,76 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.system.dal.entity.SysConfig;
import com.fruitshorts.system.dal.mapper.SysConfigMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* C 端启动配置(`GET /app/config`,匿名)。
*
* <p><b>只下发白名单键</b>:`sys_config` 是后台配置表,里面可能有内部开关,
* 整表下发等于把内部配置暴露给客户端 —— 所以这里用**硬编码白名单**({@link #KEYS}),
* 且库里缺键时用**契约默认值**兜底(契约见 `docs/api-phase1.md` §1.9.1),
* 不返回 null 让客户端自己猜默认值。
*/
@Service
@RequiredArgsConstructor
public class AppConfigService {
/** 白名单:契约里的 6 个键 → 后台配置键 + 默认值。顺序即响应字段顺序。 */
private static final Map<String, KeySpec> KEYS = new LinkedHashMap<>();
static {
KEYS.put("agreementVersion", new KeySpec("app.agreement.version", "2026-10-01"));
KEYS.put("minAppVersion", new KeySpec("app.min.version", "1.0.0"));
KEYS.put("forceUpdate", new KeySpec("app.force.update", "false"));
KEYS.put("noticeEnabled", new KeySpec("app.notice.enabled", "true"));
KEYS.put("coinUnlockEnabled", new KeySpec("app.coin.unlock.enabled", "true"));
KEYS.put("downloadEnabled", new KeySpec("app.download.enabled", "true"));
}
private final SysConfigMapper sysConfigMapper;
/** 当前协议版本(`/app/creator/apply` 用它校验客户端提交的版本)。 */
public String agreementVersion() {
return valueOf("app.agreement.version", KEYS.get("agreementVersion").defaultValue());
}
/** 只含白名单键的配置;布尔项按布尔返回,避免客户端到处写字符串比较。 */
public Map<String, Object> appConfig() {
Map<String, Object> data = new LinkedHashMap<>();
KEYS.forEach((field, spec) -> {
String raw = valueOf(spec.configKey(), spec.defaultValue());
data.put(field, isBooleanField(field) ? Boolean.parseBoolean(raw) : raw);
});
return data;
}
/** 只查这一个键(不 select *):即使表里有别的键也不会被读出来。 */
private String valueOf(String configKey, String fallback) {
List<SysConfig> rows = sysConfigMapper.selectList(Wrappers.<SysConfig>lambdaQuery()
.eq(SysConfig::getConfigKey, configKey)
.last("limit 1"));
if (rows.isEmpty() || !StringUtils.hasText(rows.get(0).getConfigValue())) {
return fallback;
}
return rows.get(0).getConfigValue().trim();
}
private boolean isBooleanField(String field) {
return "forceUpdate".equals(field) || "noticeEnabled".equals(field)
|| "coinUnlockEnabled".equals(field) || "downloadEnabled".equals(field);
}
/** 一个白名单项:后台键 + 契约默认值。 */
private record KeySpec(String configKey, String defaultValue) {
}
}
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.app.vo.AppNoticeVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.system.dal.entity.OpsNotice;
import com.fruitshorts.system.service.OpsNoticeService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* C 端系统公告(`GET /app/notices`,匿名,只读)。
*
* <p>筛选口径**不在本类实现**:统一走 `OpsNoticeService.pageForApp(...)` ——
* 后台公告管理(`/admin/notices`)用的是同一套条件,避免"后台发布后 C 端到底能不能看到"两处条件漂移。
*
* <p>没有数据返回空列表(不是 404);本类只负责把实体转成 C 端字段(不暴露后台字段)。
*/
@Service
@RequiredArgsConstructor
public class AppNoticeService {
private final OpsNoticeService opsNoticeService;
public PageResult<AppNoticeVO> page(int pageIndex, int pageSize) {
PageResult<OpsNotice> page = opsNoticeService.pageForApp(pageIndex, pageSize);
return new PageResult<>(page.getTotal(), page.getList().stream().map(this::toVO).toList());
}
private AppNoticeVO toVO(OpsNotice notice) {
AppNoticeVO vo = new AppNoticeVO();
vo.setId(notice.getId());
vo.setTitle(notice.getTitle());
vo.setContent(notice.getContent());
vo.setType(notice.getType());
vo.setPublishAt(notice.getPublishAt());
vo.setExpireAt(notice.getExpireAt());
return vo;
}
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** C 端系统公告(`GET /app/notices`)。 */
@Data
public class AppNoticeVO {
private Long id;
private String title;
private String content;
/** SYSTEM / AUDIT / PAYMENT / ACTIVITY。 */
private String type;
private LocalDateTime publishAt;
private LocalDateTime expireAt;
}
@@ -0,0 +1,37 @@
# Author: fruit.com.cn
#
# 生产 profile(--spring.profiles.active=prod):
# 1) 集中生产开关,不在这里写任何密钥 —— 密钥一律环境变量注入;
# 2) 密钥缺失/还是 dev 默认值/签名没开 → ProdSecretGuard 直接让启动失败(fail-fast)。
server:
# 容器里由编排再映射;保持 8081 便于与 runbook 一致
port: ${SERVER_PORT:8081}
tomcat:
# 生产前置反代,真实 IP 从 X-Forwarded-* 取
remoteip:
remote-ip-header: X-Forwarded-For
protocol-header: X-Forwarded-Proto
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
fruitshorts:
media:
# 生产必须开启播放地址签名(docs/12-上线检查清单)
signature-required: true
cors:
# 生产只允许显式配置的来源;没配就等于空,浏览器侧会被拦住(比"*"安全)
allowed-origins: ${CORS_ALLOWED_ORIGINS:}
springdoc:
# 生产只留机器可读契约,关掉 UI(doc.html 不对外开放)
swagger-ui:
enabled: ${SWAGGER_ENABLED:false}
api-docs:
enabled: ${SWAGGER_ENABLED:false}
logging:
level:
root: info
com.fruitshorts: info
@@ -10,7 +10,9 @@ spring:
exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts # 地址必须外置:不同环境(本机 / 测试 / 生产)连的库不一样,
# 生产请用 DB_URL 注入(prod profile 会校验它非空)
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/fruitshorts}
username: ${DB_USER:fruitshorts} username: ${DB_USER:fruitshorts}
password: ${DB_PASSWORD:fruitshorts} password: ${DB_PASSWORD:fruitshorts}
jackson: jackson:
@@ -23,11 +25,17 @@ mybatis-plus:
global-config: global-config:
banner: false banner: false
db-config: db-config:
id-type: auto # 主键由应用侧雪花算法生成(S0),数据库不再自增
id-type: assign_id
logic-delete-field: deleted logic-delete-field: deleted
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
sequence:
# 单机开发用固定值;上多实例前必须改成按实例分配(环境变量注入),否则会撞 ID
worker-id: 1
datacenter-id: 1
# 接口文档:/doc.html 打开 Swagger UI,/v3/api-docs 是机器可读契约(前端联调按它写)
springdoc: springdoc:
swagger-ui: swagger-ui:
path: /doc.html path: /doc.html
@@ -47,16 +55,36 @@ fruitshorts:
license-days: 30 license-days: 30
wifi-only-default: true wifi-only-default: true
max-quality: AUTO max-quality: AUTO
commerce:
store:
# 商店票据校验实现:disabled = 明确报"未配置"(默认,无凭据时对外行为不变)
# google-play = 走 Google Play Developer API
verifier: ${STORE_VERIFIER:disabled}
# 下面两项属付费凭据:只从环境变量/配置注入,不入库、不提交、不打进镜像
google-play-package-name: ${GOOGLE_PLAY_PACKAGE_NAME:}
# 服务账号 JSON:文件路径 \| JSON 内容 \| base64(建议用环境变量给 base64)
google-play-service-account-json: ${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON:}
google-play-timeout-seconds: 10
media: media:
root-dir: ./data/uploads # 媒体根目录:容器里指向挂载卷;本地默认 ./data/uploads
root-dir: ${MEDIA_ROOT_DIR:./data/uploads}
public-prefix: /files public-prefix: /files
chunk-size: 5242880 chunk-size: 5242880
max-file-size: 2147483648 max-file-size: 2147483648
# 播放地址签名:生产必开(docs/12-上线检查清单),prod profile 里强制为 true
signature-secret: ${MEDIA_SIGNATURE_SECRET:fruitshorts-media-dev-secret-change-me-in-production-32b}
signature-required: ${MEDIA_SIGNATURE_REQUIRED:false}
signature-ttl-minutes: 15
jwt: jwt:
# 双密钥:管理端与客户端物理隔离,token 不能互换(AGENTS.md 强制约束 2)
# 注意 app-secret 以前没写在这里,一直用代码里的 dev 默认值 —— 生产必须注入 JWT_APP_SECRET
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b} admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
admin-ttl-hours: 12 admin-ttl-hours: 12
app-secret: ${JWT_APP_SECRET:fruitshorts-app-dev-secret-change-me-in-production-32bytes}
app-access-ttl-minutes: 120
app-refresh-ttl-days: 30
cors: cors:
allowed-origins: "*" allowed-origins: ${CORS_ALLOWED_ORIGINS:*}
logging: logging:
level: level:
@@ -0,0 +1,140 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.user.service.AppAuthService;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.vo.TokenRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* `/app/auth/**` 的接入层契约:路由、请求体绑定、统一响应包 `{code,message,data}`、错误码映射。
*
* <p>业务规则(免码、密码强度、频控)在 {@code AppAuthServiceTest} / {@code AppOtpServiceTest} 里用真服务验证,
* 这里只钉"接口长什么样"。
*/
class AppAuthControllerContractTest {
private AppOtpService otpService;
private AppAuthService authService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
otpService = mock(AppOtpService.class);
authService = mock(AppAuthService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new AppAuthController(otpService, authService));
}
@Test
void should_return_access_token_when_email_register_succeeds() throws Exception {
given(authService.register(any(), any())).willReturn(tokens("access-1", "refresh-1"));
mockMvc.perform(post("/app/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.message").value("ok"))
.andExpect(jsonPath("$.data.accessToken").value("access-1"))
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
.andExpect(jsonPath("$.data.newUser").value(true));
}
@Test
void should_return_data_exists_code_when_email_is_already_registered() throws Exception {
given(authService.register(any(), any())).willThrow(
new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录"));
mockMvc.perform(post("/app/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_EXISTS.getCode()))
.andExpect(jsonPath("$.message").value("该账号已注册,请直接登录"))
.andExpect(jsonPath("$.data").value(nullValue()));
}
@Test
void should_map_otp_required_error_when_phone_register_has_no_code() throws Exception {
given(authService.register(any(), any())).willThrow(
new ServiceException(ErrorCode.BAD_REQUEST, "请先获取验证码"));
mockMvc.perform(post("/app/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"PHONE\",\"target\":\"08123456789\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.message").value("请先获取验证码"));
}
@Test
void should_return_bad_request_code_when_register_body_has_no_target() throws Exception {
mockMvc.perform(post("/app/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.message").value(containsString("请输入手机号或邮箱")));
}
@Test
void should_return_access_token_when_password_login_succeeds() throws Exception {
given(authService.login(any(), any())).willReturn(tokens("access-2", "refresh-2"));
mockMvc.perform(post("/app/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.accessToken").value("access-2"));
}
@Test
void should_return_login_failed_code_when_password_is_wrong() throws Exception {
given(authService.login(any(), any())).willThrow(new ServiceException(ErrorCode.LOGIN_FAILED));
mockMvc.perform(post("/app/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"wrongpass1\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.LOGIN_FAILED.getCode()))
.andExpect(jsonPath("$.message").value("账号或密码错误"));
}
@Test
void should_return_ok_envelope_when_otp_send_succeeds() throws Exception {
mockMvc.perform(post("/app/auth/otp")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"scene\":\"REGISTER\",\"targetType\":\"PHONE\",\"target\":\"08123456789\",\"deviceId\":\"d-1\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(nullValue()));
verify(otpService).send(any(), any());
}
private TokenRespVO tokens(String accessToken, String refreshToken) {
TokenRespVO vo = new TokenRespVO();
vo.setAccessToken(accessToken);
vo.setRefreshToken(refreshToken);
vo.setExpiresIn(7200);
vo.setNewUser(true);
return vo;
}
}
@@ -0,0 +1,115 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.app.vo.AppBannerVO;
import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.playback.service.PlaybackService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 目录接口的接入层契约:`/app/home/feed`、`/app/discover/feed`、`/app/categories`、`/app/dramas/{id}`。
*
* <p>关注三件事:路由通不通、路径参数有没有绑对、响应壳是不是 `{code,message,data}`。
*/
class AppCatalogControllerContractTest {
private AppCatalogService catalogService;
private MockMvc catalogMvc;
private MockMvc dramaMvc;
@BeforeEach
void setUp() {
catalogService = mock(AppCatalogService.class);
catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService));
dramaMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(catalogService, mock(PlaybackService.class)));
}
@Test
void should_return_banners_and_sections_when_home_feed_is_requested() throws Exception {
AppBannerVO banner = new AppBannerVO();
banner.setId(9L);
banner.setImageUrl("/files/banner/9.png");
banner.setLinkType("DRAMA");
banner.setLinkValue("12");
AppFeedVO feed = new AppFeedVO(List.of(banner), List.of());
given(catalogService.homeFeed()).willReturn(feed);
catalogMvc.perform(get("/app/home/feed"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.banners[0].id").value(9))
.andExpect(jsonPath("$.data.banners[0].imageUrl").value("/files/banner/9.png"))
.andExpect(jsonPath("$.data.banners[0].linkType").value("DRAMA"));
}
@Test
void should_return_sections_when_discover_feed_is_requested() throws Exception {
AppFeedVO feed = new AppFeedVO(List.of(), List.of());
given(catalogService.discoverFeed()).willReturn(feed);
catalogMvc.perform(get("/app/discover/feed"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.sections").isArray());
}
@Test
void should_return_category_list_when_categories_are_requested() throws Exception {
AppCategoryVO category = new AppCategoryVO(3L, "都市");
given(catalogService.categories()).willReturn(List.of(category));
catalogMvc.perform(get("/app/categories"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].id").value(3))
.andExpect(jsonPath("$.data[0].name").value("都市"));
}
@Test
void should_bind_path_variable_when_drama_detail_is_requested() throws Exception {
AppDramaDetailVO detail = new AppDramaDetailVO();
detail.setId(42L);
detail.setTitle("命运的相遇");
detail.setEpisodes(List.of());
given(catalogService.dramaDetail(42L)).willReturn(detail);
dramaMvc.perform(get("/app/dramas/42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value("42"))
.andExpect(jsonPath("$.data.id").isString())
.andExpect(jsonPath("$.data.title").value("命运的相遇"));
verify(catalogService).dramaDetail(42L);
}
@Test
void should_return_data_not_found_code_when_drama_does_not_exist() throws Exception {
given(catalogService.dramaDetail(404L))
.willThrow(new ServiceException(ErrorCode.DATA_NOT_FOUND, "短剧不存在"));
dramaMvc.perform(get("/app/dramas/404"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.data").value(nullValue()));
}
}
@@ -0,0 +1,171 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppConfigService;
import com.fruitshorts.app.service.AppNoticeService;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.service.CreatorAppService;
import com.fruitshorts.creator.service.CreatorCenterService;
import com.fruitshorts.creator.service.CreatorService;
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
import com.fruitshorts.framework.security.LoginUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* E1 五个 C 端接口的接入层契约。
*
* <p>覆盖(对应条目门禁):匿名可达 config/notices、未登录访问 creator 被拒、
* 重复 apply 幂等、非创作者看 works 被拒(200007)、`/app/config` 只回白名单键。
*/
class AppCreatorAndConfigContractTest {
private static final Long USER = 1001L;
private AppConfigService appConfigService;
private AppNoticeService appNoticeService;
private CreatorAppService creatorAppService;
private MockMvc configMvc;
private MockMvc creatorMvc;
@BeforeEach
void setUp() {
appConfigService = mock(AppConfigService.class);
appNoticeService = mock(AppNoticeService.class);
creatorAppService = mock(CreatorAppService.class);
configMvc = ControllerContractSupport.mockMvcOf(
new AppConfigController(appConfigService, appNoticeService));
creatorMvc = ControllerContractSupport.mockMvcOf(new AppCreatorController(
creatorAppService, appConfigService));
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
private void login() {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new LoginUser(USER, "app-user", "APP_USER", List.of()), null, List.of()));
}
// ─────────── /app/config(匿名 + 白名单) ───────────
@Test
void should_return_only_whitelisted_keys_when_config_is_requested_anonymously() throws Exception {
// 服务层按白名单组装;这里再钉一遍"响应体里只有白名单键"
Map<String, Object> whitelist = new LinkedHashMap<>();
whitelist.put("agreementVersion", "2026-10-01");
whitelist.put("minAppVersion", "1.0.0");
whitelist.put("forceUpdate", false);
whitelist.put("noticeEnabled", true);
whitelist.put("coinUnlockEnabled", true);
whitelist.put("downloadEnabled", true);
given(appConfigService.appConfig()).willReturn(whitelist);
String body = configMvc.perform(get("/app/config"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.agreementVersion").value("2026-10-01"))
.andExpect(jsonPath("$.data.forceUpdate").value(false))
.andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
// 没登录也能拿到(匿名)
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
assertThat(body).doesNotContain("password").doesNotContain("secret").doesNotContain("token");
}
@Test
void should_return_empty_list_when_no_notice_exists() throws Exception {
given(appNoticeService.page(1, 20)).willReturn(new PageResult<>(0L, List.of()));
configMvc.perform(get("/app/notices"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(0))
.andExpect(jsonPath("$.data.list").isArray());
}
// ─────────── /app/creator/**(需登录) ───────────
@Test
void should_reject_creator_apply_when_not_logged_in() throws Exception {
// 未设置 SecurityContext:控制器取不到 C 端用户 → 统一 401 信封
creatorMvc.perform(post("/app/creator/apply")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"星野工作室\",\"agreementVersion\":\"2026-10-01\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
verify(creatorAppService, never()).apply(any(), any(), anyString());
}
@Test
void should_return_current_status_when_applying_twice() throws Exception {
login();
given(appConfigService.agreementVersion()).willReturn("2026-10-01");
CreatorApplyRespVO first = applyResp(7L, CreatorService.STATUS_PENDING, true);
CreatorApplyRespVO second = applyResp(7L, CreatorService.STATUS_PENDING, false);
given(creatorAppService.apply(eq(USER), any(CreatorApplyReqVO.class), eq("2026-10-01")))
.willReturn(first, second);
String payload = "{\"name\":\"星野工作室\",\"type\":\"COMPANY\",\"agreementVersion\":\"2026-10-01\"}";
creatorMvc.perform(post("/app/creator/apply").contentType(MediaType.APPLICATION_JSON).content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value("7"))
.andExpect(jsonPath("$.data.id").isString())
.andExpect(jsonPath("$.data.status").value(CreatorService.STATUS_PENDING))
.andExpect(jsonPath("$.data.created").value(true));
// 第二次提交:同一条记录、created=false(幂等)
creatorMvc.perform(post("/app/creator/apply").contentType(MediaType.APPLICATION_JSON).content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value("7"))
.andExpect(jsonPath("$.data.id").isString())
.andExpect(jsonPath("$.data.created").value(false));
}
private CreatorApplyRespVO applyResp(Long id, String status, boolean created) {
CreatorApplyRespVO vo = new CreatorApplyRespVO();
vo.setId(id);
vo.setCode("CR20261016001");
vo.setName("星野工作室");
vo.setStatus(status);
vo.setStatusText(CreatorService.statusText(status));
vo.setCreated(created);
return vo;
}
}
@@ -0,0 +1,179 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.dal.entity.Product;
import com.fruitshorts.commerce.dal.mapper.OrderEventMapper;
import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.dal.mapper.ProductMapper;
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
import com.fruitshorts.commerce.service.DisabledStoreVerifier;
import com.fruitshorts.commerce.service.EntitlementService;
import com.fruitshorts.commerce.service.OrderService;
import com.fruitshorts.commerce.service.ProductService;
import com.fruitshorts.commerce.service.StoreVerifier;
import com.fruitshorts.commerce.service.UnlockService;
import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.framework.security.LoginUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* `/app/orders/verify` 的接入层契约:统一响应体 + 错误码 + "默认 disabled 行为不变"。
*
* <p>业务规则在 `fruitshorts-module-commerce` 的 `OrderServiceVerifyTest` 里测;
* 这里只钉"客户端会看到的 HTTP 表现",用替身 verifier,不连库、不联网。
*/
class AppOrdersVerifyContractTest {
private static final Long USER = 1001L;
private static final String BODY = """
{"platform":"GOOGLE_PLAY","productId":"coin_550","receipt":"purchase-token-1"}""";
private OrdersMapper ordersMapper;
private ProductMapper productMapper;
private StoreReceiptMapper storeReceiptMapper;
private StubStoreVerifier verifier;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
ordersMapper = mock(OrdersMapper.class);
productMapper = mock(ProductMapper.class);
storeReceiptMapper = mock(StoreReceiptMapper.class);
verifier = new StubStoreVerifier();
OrderService orderService = new OrderService(ordersMapper, mock(OrderEventMapper.class),
new ProductService(productMapper), storeReceiptMapper, verifier,
mock(WalletService.class), mock(EntitlementService.class));
mockMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController(
mock(WalletService.class), mock(UnlockService.class), orderService));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new LoginUser(USER, "app-user", "APP_USER", List.of()), null, List.of()));
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void should_return_delivered_order_when_receipt_is_valid() throws Exception {
given(ordersMapper.selectOne(any())).willReturn(null);
given(productMapper.selectList(any())).willReturn(List.of(coinPack()));
verifier.result = new StoreVerifier.StoreReceiptResult(true, "GPA.3311-00001", "", "coin_550",
990000L, "IDR", LocalDateTime.now(), "Google Play 校验通过");
mockMvc.perform(post("/app/orders/verify").contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value(OrderService.STATUS_DELIVERED))
.andExpect(jsonPath("$.data.coinAmount").value("550"))
.andExpect(jsonPath("$.data.coinAmount").isString())
.andExpect(jsonPath("$.data.platform").value("GOOGLE"))
.andExpect(jsonPath("$.data.storeTransactionId").value("GPA.3311-00001"));
verify(ordersMapper).insert(any(Orders.class));
}
@Test
void should_reject_with_operation_not_allowed_when_receipt_is_invalid() throws Exception {
verifier.result = StoreVerifier.StoreReceiptResult.failed("Google Play 购买状态不是已购买");
mockMvc.perform(post("/app/orders/verify").contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.OPERATION_NOT_ALLOWED.getCode()))
.andExpect(jsonPath("$.message").value("Google Play 购买状态不是已购买"));
verify(ordersMapper, never()).insert(any(Orders.class));
}
@Test
void should_reject_with_forbidden_when_receipt_belongs_to_another_user() throws Exception {
Orders others = new Orders();
others.setId(1L);
others.setOrderNo("FS202610161200000001");
others.setUserId(2002L);
others.setPlatform("GOOGLE");
others.setStoreTransactionId("GPA.3311-00001");
others.setStatus(OrderService.STATUS_DELIVERED);
given(ordersMapper.selectOne(any())).willReturn(others);
verifier.result = new StoreVerifier.StoreReceiptResult(true, "GPA.3311-00001", "", "coin_550",
990000L, "IDR", LocalDateTime.now(), "Google Play 校验通过");
mockMvc.perform(post("/app/orders/verify").contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()))
.andExpect(jsonPath("$.message").value("该票据已绑定其他账号,不能重复使用"));
}
@Test
void should_keep_disabled_behaviour_when_store_is_not_configured() throws Exception {
// 默认实现(未配凭据):对外仍是"尚未配置",不产生订单
OrderService disabled = new OrderService(ordersMapper, mock(OrderEventMapper.class),
new ProductService(productMapper), storeReceiptMapper, new DisabledStoreVerifier(),
mock(WalletService.class), mock(EntitlementService.class));
MockMvc disabledMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController(
mock(WalletService.class), mock(UnlockService.class), disabled));
String body = disabledMvc.perform(post("/app/orders/verify")
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.OPERATION_NOT_ALLOWED.getCode()))
// MockMvc 的 getContentAsString() 在无 charset 时按 ISO-8859-1 解,中文会变乱码,
// 这里显式按 UTF-8 解码,断言才是真的在断言中文文案
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(body).contains("尚未配置");
verify(ordersMapper, never()).insert(any(Orders.class));
}
private Product coinPack() {
Product product = new Product();
product.setId(9L);
product.setCode("coin_550");
product.setName("550 金币");
product.setType("COIN_PACK");
product.setPlatform("ALL");
product.setStoreProductId("coin_550");
product.setCoinAmount(500L);
product.setBonusCoin(50L);
product.setPriceAmount(new BigDecimal("4.99"));
product.setCurrency("IDR");
product.setVipDays(0);
product.setStatus(1);
return product;
}
/** 替身 verifier:喂什么判定就返回什么。 */
private static final class StubStoreVerifier implements StoreVerifier {
private StoreReceiptResult result = StoreReceiptResult.failed("未设置");
@Override
public StoreReceiptResult verify(String platform, String productId, String receipt) {
return result;
}
}
}
@@ -0,0 +1,98 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 播放票据接口的接入层契约:`/app/episodes/{id}/play` 的免费/付费两种形态与参数透传。
*
* <p>免费 / 付费的**判定规则**在 {@code PlaybackServiceTest} 里用真服务验证;
* 这里钉的是"下发给客户端的字段长什么样"(客户端 Flutter 依赖这些字段名)。
*/
class AppPlaybackContractTest {
private PlaybackService playbackService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
playbackService = mock(PlaybackService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(mock(AppCatalogService.class), playbackService));
}
@Test
void should_return_free_access_when_episode_is_free() throws Exception {
given(playbackService.play(isNull(), eq(11L), any(), any()))
.willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE, "/files/ep.mp4?s=1"));
mockMvc.perform(get("/app/episodes/11/play").header("X-Device-Id", "device-9"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
// §0:「ID 一律以字符串下发」——用 isString 钉住,避免"数字也能过"的宽松比较
.andExpect(jsonPath("$.data.episodeId").value("11"))
.andExpect(jsonPath("$.data.episodeId").isString())
.andExpect(jsonPath("$.data.accessible").value(true))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"));
}
@Test
void should_return_coin_required_when_episode_is_locked_and_not_entitled() throws Exception {
PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, "");
locked.setUnlockCoin(60);
given(playbackService.play(isNull(), eq(12L), any(), any())).willReturn(locked);
mockMvc.perform(get("/app/episodes/12/play"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessible").value(false))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_COIN_REQUIRED))
.andExpect(jsonPath("$.data.playUrl").value(""))
.andExpect(jsonPath("$.data.unlockCoin").value(60));
}
@Test
void should_pass_episode_id_and_device_header_when_play_is_requested() throws Exception {
given(playbackService.play(isNull(), eq(13L), eq("device-9"), any()))
.willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE, "/files/ep13.mp4?s=1"));
mockMvc.perform(get("/app/episodes/13/play").header("X-Device-Id", "device-9"))
.andExpect(status().isOk());
verify(playbackService).play(isNull(), eq(13L), eq("device-9"), any());
}
private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType, String playUrl) {
PlayRespVO vo = new PlayRespVO();
vo.setEpisodeId(episodeId);
vo.setDramaId(7L);
vo.setNumber(1);
vo.setTitle("第 1 集");
vo.setDurationSec(120);
vo.setAccessible(accessible);
vo.setAccessType(accessType);
vo.setPlayUrl(playUrl);
vo.setQualities(List.of("AUTO"));
vo.setUnlockCoin(0);
vo.setPreviewSeconds(0);
return vo;
}
}
@@ -0,0 +1,320 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.framework.security.JwtTokenProvider;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserCredential;
import com.fruitshorts.user.dal.entity.AppUserOtp;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppUserConsentMapper;
import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper;
import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import com.fruitshorts.user.enums.AppUserStatus;
import com.fruitshorts.user.service.AppAuthService;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.service.CredentialHasher;
import com.fruitshorts.user.service.OtpSender;
import com.fruitshorts.user.vo.LoginReqVO;
import com.fruitshorts.user.vo.RegisterReqVO;
import com.fruitshorts.user.vo.TokenRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* C 端注册 / 登录的**业务规则**测试:真 `AppAuthService` + 替身 Mapper,不连库。
*
* <p>覆盖几件接入层测不到的事:EMAIL 免码到底走没走验证码、密码强度、注册频控、
* 账号重复、密码比对与封禁判断。验证码本身的校验规则在 `AppOtpServiceTest`。
*/
class AppAuthServiceTest {
private AppUserMapper userMapper;
private AppUserCredentialMapper credentialMapper;
private AppUserSessionMapper sessionMapper;
private AppUserConsentMapper consentMapper;
private AppUserDeviceMapper deviceMapper;
private AppOtpService otpService;
private CredentialHasher hasher;
private JwtTokenProvider tokenProvider;
private PasswordEncoder passwordEncoder;
private AppAuthService authService;
@BeforeEach
void setUp() {
userMapper = mock(AppUserMapper.class);
credentialMapper = mock(AppUserCredentialMapper.class);
sessionMapper = mock(AppUserSessionMapper.class);
consentMapper = mock(AppUserConsentMapper.class);
deviceMapper = mock(AppUserDeviceMapper.class);
otpService = mock(AppOtpService.class);
hasher = mock(CredentialHasher.class);
tokenProvider = mock(JwtTokenProvider.class);
passwordEncoder = mock(PasswordEncoder.class);
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder);
}
@Test
void should_register_without_otp_when_target_type_is_email() {
stubHappyRegister();
AtomicReference<AppUserCredential> saved = captureCredential();
TokenRespVO resp = authService.register(emailRegister("abcd1234", null), "203.0.113.8");
assertThat(resp.getAccessToken()).isEqualTo("access-1");
assertThat(resp.getRefreshToken()).isEqualTo("refresh-1");
assertThat(resp.isNewUser()).isTrue();
// 免码注册不等于"已验证":邮箱可达性没验证过,所以先记 0,等日后点验证链接再置 1
assertThat(saved.get().getVerified()).isZero();
verify(otpService, never()).consume(anyString(), anyString(), anyString(), any());
}
@Test
void should_consume_otp_when_email_register_carries_a_code() {
stubHappyRegister();
authService.register(emailRegister("abcd1234", "654321"), "203.0.113.8");
verify(otpService).consume(eq("REGISTER"), eq("EMAIL"), eq("a@b.com"), eq("654321"));
}
@Test
void should_consume_otp_when_target_type_is_phone() {
stubHappyRegister();
authService.register(phoneRegister("+628123456789", "654321"), "203.0.113.8");
verify(otpService).consume(eq("REGISTER"), eq("PHONE"), eq("+628123456789"), eq("654321"));
}
@Test
void should_reject_phone_register_when_no_code_was_requested() {
AppAuthService serviceWithRealOtp = serviceWithRealOtpService(null);
assertThatThrownBy(() -> serviceWithRealOtp.register(phoneRegister("+628123456789", ""), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.hasMessage("请先获取验证码");
}
@Test
void should_reject_phone_register_when_code_is_wrong() {
AppUserOtp issued = new AppUserOtp();
issued.setCodeHash("hash-of-654321");
issued.setExpireAt(LocalDateTime.now().plusMinutes(5));
issued.setFailCount(0);
AppAuthService serviceWithRealOtp = serviceWithRealOtpService(issued);
assertThatThrownBy(() -> serviceWithRealOtp.register(phoneRegister("+628123456789", "000000"), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.hasMessage("验证码不正确");
}
@Test
void should_reject_email_register_when_password_is_weak() {
RegisterReqVO req = emailRegister("abcdefgh", null);
assertThatThrownBy(() -> authService.register(req, "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("密码需同时包含字母和数字");
}
@Test
void should_reject_email_register_when_format_is_invalid() {
RegisterReqVO req = emailRegister("abcd1234", null);
req.setTarget("not-an-email");
assertThatThrownBy(() -> authService.register(req, "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.hasMessage("邮箱格式不正确");
}
@Test
void should_return_data_exists_when_email_is_already_registered() {
given(userMapper.selectCount(any())).willReturn(0L);
AppUserCredential existing = new AppUserCredential();
existing.setUserId(1001L);
given(credentialMapper.selectOne(any())).willReturn(existing);
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.DATA_EXISTS.getCode()))
.hasMessage("该账号已注册,请直接登录");
}
@Test
void should_reject_register_when_ip_reaches_the_daily_limit() {
given(userMapper.selectCount(any())).willReturn(5L);
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()))
.hasMessageContaining("当前网络注册过于频繁");
}
@Test
void should_return_tokens_when_password_matches() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
given(passwordEncoder.matches("abcd1234", "bcrypt-hash")).willReturn(true);
TokenRespVO resp = authService.login(emailLogin("abcd1234"), "203.0.113.8");
assertThat(resp.getAccessToken()).isEqualTo("access-1");
assertThat(resp.getUser().getUid()).isEqualTo("UAB12CD");
verify(sessionMapper).insert(any(AppUserSession.class));
}
@Test
void should_reject_login_when_password_does_not_match() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
given(passwordEncoder.matches("wrongpass1", "bcrypt-hash")).willReturn(false);
assertThatThrownBy(() -> authService.login(emailLogin("wrongpass1"), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.LOGIN_FAILED.getCode()))
.hasMessage("账号或密码错误");
}
@Test
void should_reject_login_when_account_does_not_exist() {
given(credentialMapper.selectOne(any())).willReturn(null);
assertThatThrownBy(() -> authService.login(emailLogin("abcd1234"), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.LOGIN_FAILED.getCode()))
.hasMessage("账号不存在,请先注册");
}
@Test
void should_reject_login_when_account_is_banned() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.BANNED));
given(passwordEncoder.matches("abcd1234", "bcrypt-hash")).willReturn(true);
assertThatThrownBy(() -> authService.login(emailLogin("abcd1234"), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.ACCOUNT_DISABLED.getCode()));
}
private void stubHappyRegister() {
given(userMapper.selectCount(any())).willReturn(0L);
given(credentialMapper.selectOne(any())).willReturn(null);
given(hasher.newUid()).willReturn("UAB12CD");
given(hasher.newRefreshToken()).willReturn("refresh-1");
given(hasher.sha256(anyString())).willReturn("hash-1");
given(passwordEncoder.encode(anyString())).willReturn("bcrypt-hash");
given(tokenProvider.createAppToken(any(), anyString(), any())).willReturn("access-1");
given(tokenProvider.accessTtlSeconds()).willReturn(7200L);
given(tokenProvider.appRefreshTtl()).willReturn(Duration.ofDays(30));
AtomicReference<AppUser> inserted = new AtomicReference<>();
given(userMapper.insert(any(AppUser.class))).willAnswer(invocation -> {
AppUser saved = invocation.getArgument(0);
saved.setId(1001L);
inserted.set(saved);
return 1;
});
given(userMapper.selectById(any())).willAnswer(invocation -> inserted.get());
}
private AtomicReference<AppUserCredential> captureCredential() {
AtomicReference<AppUserCredential> saved = new AtomicReference<>();
given(credentialMapper.insert(any(AppUserCredential.class))).willAnswer(invocation -> {
saved.set(invocation.getArgument(0));
return 1;
});
return saved;
}
private void stubLogin(AppUserCredential credential, AppUser user) {
given(credentialMapper.selectOne(any())).willReturn(credential);
given(userMapper.selectById(any())).willReturn(user);
given(hasher.newRefreshToken()).willReturn("refresh-1");
given(hasher.sha256(anyString())).willReturn("hash-1");
given(tokenProvider.createAppToken(any(), anyString(), any())).willReturn("access-1");
given(tokenProvider.accessTtlSeconds()).willReturn(7200L);
given(tokenProvider.appRefreshTtl()).willReturn(Duration.ofDays(30));
}
private AppAuthService serviceWithRealOtpService(AppUserOtp issued) {
AppUserOtpMapper otpMapper = mock(AppUserOtpMapper.class);
CredentialHasher otpHasher = mock(CredentialHasher.class);
given(otpMapper.selectOne(any())).willReturn(issued);
given(otpHasher.hashOtp(any())).willReturn("hash-of-something-else");
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder);
}
private RegisterReqVO emailRegister(String password, String code) {
RegisterReqVO req = new RegisterReqVO();
req.setTargetType("EMAIL");
req.setTarget("a@b.com");
req.setPassword(password);
req.setCode(code);
req.setDeviceId("device-9");
req.setAgreementVersion("2026-10-01");
return req;
}
private RegisterReqVO phoneRegister(String target, String code) {
RegisterReqVO req = new RegisterReqVO();
req.setTargetType("PHONE");
req.setTarget(target);
req.setCode(code);
req.setDeviceId("device-9");
req.setAgreementVersion("2026-10-01");
return req;
}
private LoginReqVO emailLogin(String password) {
LoginReqVO req = new LoginReqVO();
req.setTargetType("EMAIL");
req.setTarget("a@b.com");
req.setPassword(password);
req.setDeviceId("device-9");
return req;
}
private AppUserCredential credential(String passwordHash) {
AppUserCredential credential = new AppUserCredential();
credential.setId(1L);
credential.setUserId(1001L);
credential.setType("EMAIL");
credential.setIdentifier("a@b.com");
credential.setPasswordHash(passwordHash);
return credential;
}
private AppUser user(String status) {
AppUser user = new AppUser();
user.setId(1001L);
user.setUid("UAB12CD");
user.setNickname("UserAB12CD");
user.setStatus(status);
user.setCountryCode("ID");
return user;
}
}
@@ -0,0 +1,104 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.user.dal.entity.AppUserOtp;
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.service.CredentialHasher;
import com.fruitshorts.user.service.OtpSender;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* 验证码**校验规则**测试:真 `AppOtpService` + 替身 Mapper。
*
* <p>注册/登录链路"PHONE 必须带验证码"的拒绝对外表现,靠这里的真规则 + `AppAuthServiceTest`
* 里的"必须调用 consume"两条一起钉住,避免两边都用替身导致互相假装。
*/
class AppOtpServiceTest {
private AppUserOtpMapper otpMapper;
private CredentialHasher hasher;
private AppOtpService otpService;
@BeforeEach
void setUp() {
otpMapper = mock(AppUserOtpMapper.class);
hasher = mock(CredentialHasher.class);
otpService = new AppOtpService(otpMapper, mock(OtpSender.class), hasher);
}
@Test
void should_reject_consume_when_no_code_was_requested() {
given(otpMapper.selectOne(any())).willReturn(null);
assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "123456"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()))
.hasMessage("请先获取验证码");
}
@Test
void should_reject_consume_when_code_is_wrong() {
AppUserOtp issued = issuedOtp("hash-of-123456");
given(otpMapper.selectOne(any())).willReturn(issued);
given(hasher.hashOtp("000000")).willReturn("hash-of-000000");
assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "000000"))
.isInstanceOf(ServiceException.class)
.hasMessage("验证码不正确");
// 失败要计数,连续错 5 次就作废(防爆破)
assertThat(issued.getFailCount()).isEqualTo(1);
}
@Test
void should_reject_consume_when_code_is_expired() {
AppUserOtp issued = issuedOtp("hash-of-123456");
issued.setExpireAt(LocalDateTime.now().minusMinutes(1));
given(otpMapper.selectOne(any())).willReturn(issued);
assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "123456"))
.isInstanceOf(ServiceException.class)
.hasMessage("验证码已过期");
}
@Test
void should_mark_code_used_when_code_matches() {
AppUserOtp issued = issuedOtp("hash-of-123456");
AtomicReference<AppUserOtp> updated = new AtomicReference<>();
given(otpMapper.selectOne(any())).willReturn(issued);
given(hasher.hashOtp("123456")).willReturn("hash-of-123456");
given(otpMapper.updateById(any(AppUserOtp.class))).willAnswer(invocation -> {
updated.set(invocation.getArgument(0));
return 1;
});
otpService.consume("REGISTER", "PHONE", "+628123456789", "123456");
assertThat(updated.get().getUsedAt()).isNotNull();
}
private AppUserOtp issuedOtp(String codeHash) {
AppUserOtp otp = new AppUserOtp();
otp.setId(1L);
otp.setScene("REGISTER");
otp.setTargetType("PHONE");
otp.setTarget("+628123456789");
otp.setCodeHash(codeHash);
otp.setExpireAt(LocalDateTime.now().plusMinutes(5));
otp.setFailCount(0);
return otp;
}
}
@@ -0,0 +1,145 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
import com.fruitshorts.creator.service.CreatorAppService;
import com.fruitshorts.creator.service.CreatorService;
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* C 端创作者入驻的**业务规则**(真 service + 替身 Mapper):幂等防重靠 DB、状态机报错口径。
*
* <p>契约层的 HTTP 表现见 `AppCreatorAndConfigContractTest`;这里钉的是"服务端真的只建一次"。
*/
class CreatorAppServiceTest {
private static final Long USER = 1001L;
private static final String AGREEMENT = "2026-10-01";
private CreatorMapper creatorMapper;
private CreatorAppService creatorAppService;
@BeforeAll
static void initTableInfoCache() {
// MyBatis-Plus 的 lambda 条件构造器需要实体的 TableInfo 缓存(纯单测没有 MP 上下文)
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Creator.class);
}
@BeforeEach
void setUp() {
creatorMapper = mock(CreatorMapper.class);
creatorAppService = new CreatorAppService(creatorMapper);
}
@Test
void should_insert_pending_creator_when_applying_for_the_first_time() {
given(creatorMapper.selectOne(any())).willReturn(null);
given(creatorMapper.selectCount(any())).willReturn(0L);
AtomicReference<Creator> saved = captureInsert();
CreatorApplyRespVO resp = creatorAppService.apply(USER, req(AGREEMENT), AGREEMENT);
assertThat(resp.isCreated()).isTrue();
assertThat(resp.getStatus()).isEqualTo(CreatorService.STATUS_PENDING);
assertThat(saved.get().getUserId()).isEqualTo(USER);
assertThat(saved.get().getCode()).startsWith("CR");
assertThat(saved.get().getStatus()).isEqualTo(CreatorService.STATUS_PENDING);
}
@Test
void should_return_existing_without_inserting_when_applying_again() {
Creator existing = creator(CreatorService.STATUS_PENDING);
given(creatorMapper.selectOne(any())).willReturn(existing);
CreatorApplyRespVO resp = creatorAppService.apply(USER, req(AGREEMENT), AGREEMENT);
assertThat(resp.isCreated()).isFalse();
assertThat(resp.getId()).isEqualTo(existing.getId());
// 关键的幂等保证:第二次提交不产生新的插入
verify(creatorMapper, never()).insert(any(Creator.class));
}
@Test
void should_return_existing_when_concurrent_insert_hits_the_unique_index() {
// 第一次查不到(并发对手还没提交完),插入时被唯一索引挡住 → 必须读回既有记录,不能报 500
given(creatorMapper.selectOne(any()))
.willReturn(null, creator(CreatorService.STATUS_PENDING));
given(creatorMapper.selectCount(any())).willReturn(0L);
given(creatorMapper.insert(any(Creator.class)))
.willThrow(new DuplicateKeyException("uk_creator_user"));
CreatorApplyRespVO resp = creatorAppService.apply(USER, req(AGREEMENT), AGREEMENT);
assertThat(resp.isCreated()).isFalse();
assertThat(resp.getStatus()).isEqualTo(CreatorService.STATUS_PENDING);
}
private AtomicReference<Creator> captureInsert() {
AtomicReference<Creator> saved = new AtomicReference<>();
given(creatorMapper.insert(any(Creator.class))).willAnswer(invocation -> {
Creator creator = invocation.getArgument(0);
creator.setId(7L);
saved.set(creator);
return 1;
});
return saved;
}
@Test
void should_reject_when_agreement_version_is_not_the_current_one() {
// 协议版本必须等于 /app/config 下发的当前版本(保留用例:apply 的协议校验)
assertThatThrownBy(() -> creatorAppService.apply(USER, req("2026-09-01"), AGREEMENT))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()))
.hasMessageContaining("请先同意最新版协议");
verify(creatorMapper, never()).insert(any(Creator.class));
}
private CreatorApplyReqVO req(String agreementVersion) {
CreatorApplyReqVO req = new CreatorApplyReqVO();
req.setName("星野工作室");
req.setType("COMPANY");
req.setContactEmail("creator@example.com");
req.setAgreementVersion(agreementVersion);
return req;
}
private Creator creator(String status) {
Creator creator = new Creator();
creator.setId(7L);
creator.setCode("CR20261016001");
creator.setName("星野工作室");
creator.setUserId(USER);
creator.setStatus(status);
creator.setAuditNote("");
return creator;
}
}
@@ -0,0 +1,146 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
import com.fruitshorts.playback.service.EntitlementPort;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* 播放票据的**判定规则**测试:真 `PlaybackService` + 替身依赖,不连库。
*
* <p>两条关键分支(接入层只验证字段形态,判定在这里):免费集一律给签名地址;
* 付费集在没权益时**不给地址**,只回 `COIN_REQUIRED` 与所需金币。
*/
class PlaybackServiceTest {
private EpisodeService episodeService;
private DramaService dramaService;
private PlaybackTicketMapper ticketMapper;
private PlaybackSigner signer;
private EntitlementPort entitlementPort;
private TranscodeService transcodeService;
private PlaybackService playbackService;
@BeforeEach
void setUp() {
episodeService = mock(EpisodeService.class);
dramaService = mock(DramaService.class);
ticketMapper = mock(PlaybackTicketMapper.class);
signer = mock(PlaybackSigner.class);
entitlementPort = mock(EntitlementPort.class);
transcodeService = mock(TranscodeService.class);
playbackService = new PlaybackService(episodeService, dramaService, ticketMapper, signer,
entitlementPort, transcodeService);
given(dramaService.getPublished(anyLong())).willReturn(drama());
given(transcodeService.listRenditions(anyLong())).willReturn(List.of());
given(signer.sign(anyString())).willReturn("/files/episode/1.mp4?e=1&s=sig");
}
@Test
void should_return_free_access_when_episode_is_free() {
given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4"));
PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8");
assertThat(resp.getAccessible()).isTrue();
assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_FREE);
assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig");
// 未登录不记票据(票据是"已登录用户"的签发审计)
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
}
@Test
void should_return_coin_required_when_episode_is_locked_and_not_entitled() {
given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4"));
given(entitlementPort.isUnlocked(anyLong(), anyLong(), anyLong())).willReturn(false);
PlayRespVO resp = playbackService.play(1001L, 12L, "device-9", "203.0.113.8");
assertThat(resp.getAccessible()).isFalse();
assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_COIN_REQUIRED);
assertThat(resp.getPlayUrl()).isEmpty();
assertThat(resp.getUnlockCoin()).isEqualTo(60);
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
}
@Test
void should_return_entitled_access_when_entitlement_port_confirms_unlock() {
given(episodeService.getPublished(13L)).willReturn(episode(13L, 0, 60, "/files/episode/3.mp4"));
given(entitlementPort.isUnlocked(eq(1001L), anyLong(), eq(13L))).willReturn(true);
PlayRespVO resp = playbackService.play(1001L, 13L, "device-9", "203.0.113.8");
assertThat(resp.getAccessible()).isTrue();
assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_ENTITLED);
assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig");
verify(ticketMapper).insert(any(PlaybackTicket.class));
}
@Test
void should_return_no_media_when_episode_has_no_source() {
given(episodeService.getPublished(14L)).willReturn(episode(14L, 1, 0, ""));
PlayRespVO resp = playbackService.play(isNull(), 14L, "device-9", "203.0.113.8");
assertThat(resp.getAccessible()).isFalse();
assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_NO_MEDIA);
assertThat(resp.getPlayUrl()).isEmpty();
}
@Test
void should_prefer_hls_url_when_the_episode_has_multi_bitrate_renditions() {
DramaEpisode episode = episode(15L, 1, 0, "/files/episode/origin.mp4");
episode.setHlsUrl("/files/episode/15/master.m3u8");
given(episodeService.getPublished(15L)).willReturn(episode);
PlayRespVO resp = playbackService.play(null, 15L, "device-9", "203.0.113.8");
assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig");
verify(signer).sign("/files/episode/15/master.m3u8");
}
private DramaEpisode episode(Long id, int isFree, int unlockCoin, String videoUrl) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setDramaId(7L);
episode.setEpNo(1);
episode.setTitle("第 1 集");
episode.setDurationSec(120);
episode.setIsFree(isFree);
episode.setUnlockCoin(unlockCoin);
episode.setPreviewSeconds(0);
episode.setVideoUrl(videoUrl);
episode.setStatus("published");
return episode;
}
private Drama drama() {
Drama drama = new Drama();
drama.setId(7L);
drama.setTitle("命运的相遇");
return drama;
}
}
@@ -0,0 +1,46 @@
// Author: fruit.com.cn
package com.fruitshorts.app.support;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.web.JacksonLongToStringConfig;
import com.fruitshorts.framework.web.GlobalExceptionHandler;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.mockito.Mockito;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* 控制器契约测试的公共装配:真控制器 + 真全局异常处理器 + 真校验器,**不启 Spring 容器**。
*
* <p>为什么不用 {@code @WebMvcTest}:{@code AppApiApplication} 上挂着
* {@code @MapperScan("com.fruitshorts.**.dal.mapper")},而切片测试不装配 DataSource / MyBatis,
* 扫描出的 Mapper 工厂会因为缺少 {@code SqlSessionFactory} 直接报错;要绕开就得再加一套测试专用的
* 引导配置。这里的 standaloneSetup 同样只装 Web 层(路由、参数绑定、消息转换、异常处理),
* 覆盖面不变而更轻、更稳。
*/
public final class ControllerContractSupport {
private ControllerContractSupport() {
}
/** 组装一个只含指定控制器与全局异常处理的 MockMvc。 */
@SuppressWarnings("unchecked")
public static MockMvc mockMvcOf(Object controller) {
AccessDeniedRecorder recorder = new AccessDeniedRecorder(Mockito.mock(ObjectProvider.class));
return MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler(recorder))
// 与 Spring Boot 的 Jackson 配置对齐:时间按 ISO 字符串输出而不是数组,
// 否则契约测试断言 LocalDate/LocalDateTime 时会看到 [2026,10,15] 这种形态。
.setMessageConverters(new MappingJackson2HttpMessageConverter(JsonMapper.builder()
.addModule(new JavaTimeModule())
// 与生产一致:Long/long 一律输出成字符串(见 JacksonLongToStringConfig)
.addModule(JacksonLongToStringConfig.longToStringModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build()))
.build();
}
}
@@ -0,0 +1,88 @@
// Author: fruit.com.cn
package com.fruitshorts.framework.config;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
/**
* 生产环境密钥把关:`prod` profile 下,密钥缺失、仍等于代码里的 dev 默认值、
* 或长度不足 32 位(以及"播放签名没开")→ **启动直接失败**。
*
* <p>为什么要有它:`docs/12-上线检查清单` 要求密钥一律走环境变量、播放签名生产必开,
* 但属性类里为了本地开箱即用带了 dev 默认值 —— 一旦生产漏配,服务会**安静地**用 dev 密钥跑起来,
* 后果是令牌/播放地址可被伪造。这里把"安静失败"改成"启动即报错"。
*
* <p>用 {@link Environment} 读**解析后**的属性(含 yml 里的默认值),而不是注入各模块的属性类:
* framework 不该反向依赖 media 模块,这样三个可运行件都能复用同一份校验。
* 只校验"配置是否可上线",不碰任何业务逻辑;非 prod profile 完全不受影响。
*/
@Slf4j
@Component
@Profile("prod")
@RequiredArgsConstructor
public class ProdSecretGuard {
/** 代码里为本地开发准备的默认值,生产出现即视为未配置。 */
private static final List<String> DEV_DEFAULTS = List.of(
"fruitshorts-admin-dev-secret-change-me-in-production-32b",
"fruitshorts-app-dev-secret-change-me-in-production-32bytes",
"fruitshorts-media-dev-secret-change-me-in-production-32b");
private static final int MIN_SECRET_LENGTH = 32;
private final Environment environment;
@PostConstruct
public void verify() {
List<String> problems = new ArrayList<>();
check(problems, "fruitshorts.jwt.admin-secret(环境变量 JWT_ADMIN_SECRET)",
environment.getProperty("fruitshorts.jwt.admin-secret"));
check(problems, "fruitshorts.jwt.app-secret(环境变量 JWT_APP_SECRET)",
environment.getProperty("fruitshorts.jwt.app-secret"));
check(problems, "fruitshorts.media.signature-secret(环境变量 MEDIA_SIGNATURE_SECRET)",
environment.getProperty("fruitshorts.media.signature-secret"));
if (!environment.getProperty("fruitshorts.media.signature-required", Boolean.class, false)) {
problems.add("fruitshorts.media.signature-required 必须为 true"
+ "(docs/12-上线检查清单:生产必开播放地址签名)");
}
if (!StringUtils.hasText(environment.getProperty("spring.datasource.url"))) {
problems.add("spring.datasource.url 为空(环境变量 DB_URL)");
}
if (environment.getProperty("fruitshorts.cors.allowed-origins", "").isBlank()) {
problems.add("fruitshorts.cors.allowed-origins 为空(环境变量 CORS_ALLOWED_ORIGINS)"
+ "—— 生产不允许用通配 *,必须显式列出来源");
}
if (!problems.isEmpty()) {
String message = "生产配置校验未通过,拒绝启动(prod profile):\n - "
+ String.join("\n - ", problems)
+ "\n请按 FruitServer/DEPLOY.md 的环境变量清单补齐后重启。";
log.error(message);
throw new IllegalStateException(message);
}
log.info("生产配置校验通过:JWT 双密钥 + 媒体签名密钥均已注入,播放签名已强制开启");
}
private void check(List<String> problems, String name, String value) {
if (!StringUtils.hasText(value)) {
problems.add(name + " 未配置(空)");
return;
}
if (DEV_DEFAULTS.contains(value.trim())) {
problems.add(name + " 仍是代码里的 dev 默认值,必须换成生产密钥");
return;
}
if (value.trim().length() < MIN_SECRET_LENGTH) {
problems.add(name + " 长度不足 " + MIN_SECRET_LENGTH + " 位(当前 "
+ value.trim().length() + " 位)");
}
}
}
@@ -41,6 +41,9 @@ public class SecurityConfig {
"/app/dramas/**", "/app/dramas/**",
"/app/episodes/*/play", "/app/episodes/*/play",
"/app/config/**", "/app/config/**",
/* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */
"/app/config",
"/app/notices",
/* 媒资:封面与视频用 <img>/<video> 直接访问,无法带请求头。 /* 媒资:封面与视频用 <img>/<video> 直接访问,无法带请求头。
片源的防盗链由 fruitshorts.media.signature-required 控制。 */ 片源的防盗链由 fruitshorts.media.signature-required 控制。 */
"/files/**", "/files/**",
@@ -48,6 +51,8 @@ public class SecurityConfig {
"/swagger-ui/**", "/swagger-ui/**",
"/swagger-ui.html", "/swagger-ui.html",
"/v3/api-docs/**", "/v3/api-docs/**",
/* 健康检查:给容器编排与冒烟脚本用(本项目没引 actuator,见 HealthController 注释) */
"/healthz",
"/actuator/health" "/actuator/health"
}; };
@@ -0,0 +1,45 @@
// Author: fruit.com.cn
package com.fruitshorts.framework.web;
import com.fruitshorts.common.pojo.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 轻量健康检查端点:`GET /healthz`。
*
* <p>为什么不直接用 `/actuator/health`:本项目**没有引入 actuator 依赖**(jar 里 0 个 actuator 类),
* 那个路径一直是"未映射"状态 —— 早期观察到的"超时"是另一回事(旧 jar 未重启,见 `docs/plans/INBOX.md` S1 条目)。
* 部署需要的只是一个"进程活着、能响应 HTTP"的信号,这个端点足够,且不引新依赖。
*
* <p>要换成 actuator(带 DB/Redis 健康指示器)时:加依赖 + 把 actuator 路径加进
* {@code SecurityConfig.PUBLIC_PATHS}(那里已经预留了 `/actuator/health`)。
*/
@RestController
@RequiredArgsConstructor
public class HealthController {
private final Environment environment;
@Value("${spring.application.name:unknown}")
private String applicationName;
/** 返回统一响应体,便于与其它接口共用同一套客户端解包逻辑。 */
@GetMapping("/healthz")
public Result<Map<String, Object>> health() {
Map<String, Object> data = new LinkedHashMap<>();
data.put("status", "UP");
data.put("app", applicationName);
data.put("profiles", String.join(",", environment.getActiveProfiles()));
data.put("time", LocalDateTime.now().toString());
return Result.ok(data);
}
}
@@ -0,0 +1,78 @@
// Author: fruit.com.cn
package com.fruitshorts.framework.web;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fruitshorts.common.pojo.PageResult;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
/**
* 全局把 {@link Long} / {@code long} 序列化成 JSON 字符串。
*
* <p>为什么需要它:S0 把主键换成雪花 id 之后,id 是 19 位十进制数(例如 {@code 2111012753262120962}),
* 而 JS 的 {@code Number.MAX_SAFE_INTEGER} 只有 {@code 9007199254740991}。裸数字下发会被浏览器
* (以及任何用 IEEE754 double 解析 JSON 的客户端)四舍五入,页面拿这个 id 回查就是"数据不存在" ——
* 后台"新建一条记录再点编辑/下架"必然踩中。
*
* <p>所以:**所有 ID 字段一律以字符串下发**(见 `docs/api-phase1.md` §0)。注意两点:
* <ul>
* <li>只动 {@code Long}/{@code long},**不动 {@code Integer}/{@code int}**(分页 pageIndex/pageSize、
* {@code Result.code} 等保持数字);</li>
* <li>反序列化方向不受影响:Jackson 默认允许把 {@code "123"} 标量强转成 {@code Long},
* 路径变量与查询参数本来就走 String→Long 转换,所以前端把 id 当字符串回传没问题。</li>
* </ul>
*
* <p>放在 framework 模块,admin-api 与 app-api 都扫得到(两个入口都是
* {@code scanBasePackages = "com.fruitshorts"}),一处生效两端一致。
*/
@Configuration
public class JacksonLongToStringConfig {
/** 供测试直接复用:拿到挂了 Long→String 的模块。 */
public static SimpleModule longToStringModule() {
SimpleModule module = new SimpleModule("LongToStringModule");
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
// 唯一的例外:分页 total 是计数不是 id,保持数字(前端分页组件要数字)
module.setMixInAnnotation(PageResult.class, PageResultTotalAsNumber.class);
return module;
}
/**
* 把 {@code PageResult.total} 的序列化钉成数字的混入声明。
*
* <p>用 mixin 而不是给实体加注解,是为了让"例外"跟"规则"写在同一个文件里,
* 也避免给 fruitshorts-common 引入 jackson 依赖。
*/
abstract static class PageResultTotalAsNumber {
@JsonSerialize(using = PlainLongSerializer.class)
abstract long getTotal();
}
/** 把 long 原样写成 JSON 数字(全局规则把它们都变成字符串了,这里显式还原)。 */
public static final class PlainLongSerializer extends JsonSerializer<Long> {
@Override
public void serialize(Long value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
if (value == null) {
gen.writeNull();
} else {
gen.writeNumber(value.longValue());
}
}
}
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
return builder -> builder.modulesToInstall(longToStringModule());
}
}
+6
View File
@@ -34,5 +34,11 @@
<groupId>com.fruitshorts</groupId> <groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-playback</artifactId> <artifactId>fruitshorts-module-playback</artifactId>
</dependency> </dependency>
<!-- 票据校验与订单交付的单测用:JUnit5 + Mockito,test scope,不进运行态依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 商店票据校验配置。
*
* <p>凭据只从配置/环境变量读,**不入库、不提交到仓库、不打进镜像**:
* <pre>
* fruitshorts.commerce.store.verifier: disabled # disabled(默认)| google-play
* fruitshorts.commerce.store.google-play-package-name: com.stormg.xxx
* fruitshorts.commerce.store.google-play-service-account-json: /etc/fruitshorts/play-sa.json
* </pre>
* `google-play-service-account-json` 支持两种写法:文件路径,或直接给 JSON 内容/base64(便于放环境变量)。
*/
@Data
@Component
@ConfigurationProperties(prefix = "fruitshorts.commerce.store")
public class StoreProperties {
/** 校验实现:disabled = 明确报"未配置"(默认);google-play = 接 Google Play Developer API。 */
private String verifier = "disabled";
/** Google Play 应用的 packageName(例如 com.stormg.fruitshorts)。 */
private String googlePlayPackageName = "";
/** 服务账号 JSON:文件路径,或 JSON 内容,或 base64 编码的 JSON。 */
private String googlePlayServiceAccountJson = "";
/** 调用 Google 接口的超时(秒)。 */
private int googlePlayTimeoutSeconds = 10;
}
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("coin_ledger") @TableName("coin_ledger")
public class CoinLedger { public class CoinLedger {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
/** 正数入账、负数出账 */ /** 正数入账、负数出账 */
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("entitlement") @TableName("entitlement")
public class Entitlement { public class Entitlement {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long dramaId; private Long dramaId;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("order_event") @TableName("order_event")
public class OrderEvent { public class OrderEvent {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String orderNo; private String orderNo;
private String event; private String event;
@@ -18,7 +18,7 @@ import java.time.LocalDateTime;
@TableName("orders") @TableName("orders")
public class Orders { public class Orders {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String orderNo; private String orderNo;
private Long userId; private Long userId;
@@ -18,7 +18,7 @@ import java.time.LocalDateTime;
@TableName("product") @TableName("product")
public class Product { public class Product {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String code; private String code;
private String name; private String name;
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("reconcile_task") @TableName("reconcile_task")
public class ReconcileTask { public class ReconcileTask {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private LocalDate bizDate; private LocalDate bizDate;
private String platform; private String platform;
@@ -24,6 +24,10 @@ public class ReconcileTask {
private Integer matchedCount; private Integer matchedCount;
private Integer diffCount; private Integer diffCount;
private BigDecimal amountSum; private BigDecimal amountSum;
/** 商店侧结算金额;**无数据源时为 null**(绝不用 0 冒充)。 */
private BigDecimal storeAmountSum;
/** 对账说明:缺哪些数据源、差异构成、本次退款回收情况。 */
private String notes;
/** RUNNING/SUCCESS/FAILED */ /** RUNNING/SUCCESS/FAILED */
private String status; private String status;
private String detail; private String detail;
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("store_receipt") @TableName("store_receipt")
public class StoreReceipt { public class StoreReceipt {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String platform; private String platform;
private String transactionId; private String transactionId;
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("wallet") @TableName("wallet")
public class Wallet { public class Wallet {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long coinBalance; private Long coinBalance;
@@ -4,17 +4,23 @@ package com.fruitshorts.commerce.service;
import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.common.exception.ServiceException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* 未接入商店时的默认实现:明确报"未配置",而不是静默放行或假装成功。 * 未接入商店时的默认实现:明确报"未配置",而不是静默放行或假装成功。
* 这样客户端拿到的是可理解的业务提示,也不会产生假订单。 * 这样客户端拿到的是可理解的业务提示,也不会产生假订单。
*
* <p>由 `fruitshorts.commerce.store.verifier` 选择:缺省(或写成 disabled)时用本实现,
* 保证没有凭据的环境下对外行为与第一阶段一致。
*/ */
@Component @Component
@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "disabled",
matchIfMissing = true)
public class DisabledStoreVerifier implements StoreVerifier { public class DisabledStoreVerifier implements StoreVerifier {
@Override @Override
public StoreReceiptResult verify(String platform, String receipt) { public StoreReceiptResult verify(String platform, String productId, String receipt) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"Apple / Google 商店校验尚未配置,第一阶段仅预留接口"); "Apple / Google 商店校验尚未配置,第一阶段仅预留接口");
} }
@@ -7,20 +7,29 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.commerce.dal.entity.OrderEvent; import com.fruitshorts.commerce.dal.entity.OrderEvent;
import com.fruitshorts.commerce.dal.entity.Orders; import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.dal.entity.Product;
import com.fruitshorts.commerce.dal.entity.StoreReceipt;
import com.fruitshorts.commerce.dal.mapper.OrderEventMapper; import com.fruitshorts.commerce.dal.mapper.OrderEventMapper;
import com.fruitshorts.commerce.dal.mapper.OrdersMapper; import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
import com.fruitshorts.commerce.service.store.StorePlatforms;
import com.fruitshorts.commerce.vo.OrderQueryReqVO; import com.fruitshorts.commerce.vo.OrderQueryReqVO;
import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/** /**
* 订单:检索、补发、退款回收。 * 订单:检索、补发、退款回收。
@@ -39,8 +48,13 @@ public class OrderService {
public static final String STATUS_REFUNDED = "REFUNDED"; public static final String STATUS_REFUNDED = "REFUNDED";
public static final String STATUS_CLOSED = "CLOSED"; public static final String STATUS_CLOSED = "CLOSED";
private static final String RECEIPT_OK = "OK";
private static final DateTimeFormatter ORDER_NO_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final OrdersMapper ordersMapper; private final OrdersMapper ordersMapper;
private final OrderEventMapper orderEventMapper; private final OrderEventMapper orderEventMapper;
private final ProductService productService;
private final StoreReceiptMapper storeReceiptMapper;
private final StoreVerifier storeVerifier; private final StoreVerifier storeVerifier;
private final WalletService walletService; private final WalletService walletService;
private final EntitlementService entitlementService; private final EntitlementService entitlementService;
@@ -80,24 +94,161 @@ public class OrderService {
} }
/** /**
* 客户端提交商店票据。 * 客户端提交商店票据:**校验 → 建单 → 发权益**,一步都不能省。
* 商店未接入时 StoreVerifier 会抛出明确异常,不会产生假订单 —— 这是刻意的: *
* 宁可不收钱,也不能在支付链路上给出"看起来成功"的假象。 * <p>幂等与防重放:
* <ul>
* <li>幂等键 `(platform, storeTransactionId)`(DB 唯一索引 `uk_orders_store_txn`);
* 同一票据重复提交直接返回既有订单,不会二次发币;</li>
* <li>票据已被**别人**用掉 → 拒绝(跨用户重放);</li>
* <li>校验不通过 → 抛业务码,**不写订单、不写票据**(宁可不收钱,也不能给"看起来成功"的假象);</li>
* <li>并发提交同一票据 → 唯一键兜底:读回既有订单走幂等分支,不会重复发货。</li>
* </ul>
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Orders verifyAndDeliver(Long userId, String platform, String receipt) { public Orders verifyAndDeliver(Long userId, String platform, String productId, String receipt) {
StoreVerifier.StoreReceiptResult result = storeVerifier.verify(platform, receipt); if (userId == null) {
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
String store = StorePlatforms.normalize(platform);
if (!StringUtils.hasText(store)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的商店平台:" + platform);
}
StoreVerifier.StoreReceiptResult result = storeVerifier.verify(store, productId, receipt);
if (!result.ok()) { if (!result.ok()) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, result.message()); throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, result.message());
} }
Orders order = ordersMapper.selectOne(Wrappers.<Orders>lambdaQuery()
.eq(Orders::getPlatform, platform) Orders existing = findOrderByStoreTransaction(store, result.transactionId());
.eq(Orders::getStoreTransactionId, result.transactionId()) if (existing != null) {
.last("limit 1")); requireSameUser(existing, userId);
if (order == null) { if (STATUS_DELIVERED.equals(existing.getStatus())) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "找不到对应的订单"); // 已交付过:直接返回既有订单,连钱包幂等调用都不再发一次
return existing;
}
return deliver(existing, "STORE", null, "票据重复提交,返回既有订单");
} }
return deliver(order, "STORE", null, "票据校验通过");
Product product = productService.findForStore(store, result.productId());
if (product == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND,
"该商品未在后台配置,请联系客服:" + result.productId());
}
if (product.getVipDays() != null && product.getVipDays() > 0) {
// 会员商品的发放链路属于 B1-2,先明确拒绝,避免"付了钱没发东西"
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "会员商品暂未开放购买");
}
Orders order = buildOrder(userId, store, product, result, receipt);
try {
ordersMapper.insert(order);
} catch (DuplicateKeyException ex) {
Orders raced = findOrderByStoreTransaction(store, result.transactionId());
if (raced == null) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "该票据正在处理,请稍后重试");
}
requireSameUser(raced, userId);
if (STATUS_DELIVERED.equals(raced.getStatus())) {
return raced;
}
return deliver(raced, "STORE", null, "票据并发提交,返回既有订单");
}
recordReceipt(order, store, result, receipt);
recordEvent(order, "VERIFY_OK", "", STATUS_PAID, "STORE", null,
"票据校验通过:" + result.message());
return deliver(order, "STORE", null, "商店票据校验通过");
}
/** 按商店交易号查订单:这是幂等与防重放的唯一入口。 */
private Orders findOrderByStoreTransaction(String platform, String transactionId) {
if (!StringUtils.hasText(platform) || !StringUtils.hasText(transactionId)) {
return null;
}
return ordersMapper.selectOne(Wrappers.<Orders>lambdaQuery()
.eq(Orders::getPlatform, platform)
.eq(Orders::getStoreTransactionId, transactionId)
.last("limit 1"));
}
/** 票据已被别的账号用过 → 拒绝,防止"一张票据刷两个号"。 */
private void requireSameUser(Orders order, Long userId) {
if (!userId.equals(order.getUserId())) {
log.warn("票据重放被拒绝:订单 {} 属于用户 {},当前用户 {}",
order.getOrderNo(), order.getUserId(), userId);
throw new ServiceException(ErrorCode.FORBIDDEN, "该票据已绑定其他账号,不能重复使用");
}
}
/** 从校验结果建单:状态先记 PAID(商店已收款),交付由 deliver 推进到 DELIVERED。 */
private Orders buildOrder(Long userId, String platform, Product product,
StoreVerifier.StoreReceiptResult result, String receipt) {
LocalDateTime now = LocalDateTime.now();
Orders order = new Orders();
order.setOrderNo(newOrderNo());
order.setUserId(userId);
order.setProductId(product.getId());
order.setProductCode(product.getCode());
order.setProductName(product.getName());
order.setType(StringUtils.hasText(product.getType()) ? product.getType() : "COIN_PACK");
order.setPlatform(platform);
order.setStoreProductId(product.getStoreProductId());
order.setStoreTransactionId(result.transactionId());
order.setOriginalTransactionId(result.originalTransactionId() == null
? "" : result.originalTransactionId());
long coins = (product.getCoinAmount() == null ? 0L : product.getCoinAmount())
+ (product.getBonusCoin() == null ? 0L : product.getBonusCoin());
order.setCoinAmount(coins);
order.setQuantity(1);
order.setAmount(resolveAmount(product, result));
order.setCurrency(StringUtils.hasText(result.currency())
? result.currency() : product.getCurrency());
order.setRegionCode("ID");
order.setChannel("IAP");
order.setExperimentId("");
// 只留票据指纹,不落完整 purchaseToken(复验由 store_receipt 存)
order.setReceipt(receipt == null ? "" : receipt);
order.setStatus(STATUS_PAID);
order.setVerifyAt(now);
order.setPaidAt(now);
order.setFailReason("");
order.setRefundReason("");
return order;
}
/**
* 金额:商店返回微单位整数(1 元 = 1_000_000),落库是 NUMERIC(12,2) → 换算成元保留 2 位。
* 商店没给就用后台商品配置的价格。
*/
private BigDecimal resolveAmount(Product product, StoreVerifier.StoreReceiptResult result) {
if (result.amountMicros() != null && result.amountMicros() > 0) {
return BigDecimal.valueOf(result.amountMicros(), 6).setScale(2, RoundingMode.HALF_UP);
}
return product.getPriceAmount() == null ? BigDecimal.ZERO : product.getPriceAmount();
}
/** 票据审计行:退款/拒付(B1-2)要靠它复验,所以原始 token 必须留档。 */
private void recordReceipt(Orders order, String platform,
StoreVerifier.StoreReceiptResult result, String receipt) {
StoreReceipt record = new StoreReceipt();
record.setPlatform(platform);
record.setTransactionId(result.transactionId());
record.setOriginalTransactionId(result.originalTransactionId() == null
? "" : result.originalTransactionId());
record.setOrderNo(order.getOrderNo());
record.setUserId(order.getUserId());
record.setProductId(result.productId());
record.setPurchaseAt(result.purchaseTime());
record.setRawPayload(receipt == null ? "" : receipt);
record.setVerifyStatus(RECEIPT_OK);
record.setVerifyResponse(result.message());
record.setVerifyAt(LocalDateTime.now());
storeReceiptMapper.insert(record);
}
/** 订单号:FS + 时间 + 4 位随机;唯一索引 uk_orders_no 兜底。 */
private String newOrderNo() {
return "FS" + LocalDateTime.now().format(ORDER_NO_TIME)
+ String.format("%04d", ThreadLocalRandom.current().nextInt(10000));
} }
/** 后台补发:把已支付未交付的订单重新交付;已交付时幂等返回。 */ /** 后台补发:把已支付未交付的订单重新交付;已交付时幂等返回。 */
@@ -137,10 +288,38 @@ public class OrderService {
if (STATUS_REFUNDED.equals(order.getStatus())) { if (STATUS_REFUNDED.equals(order.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该订单已退款"); throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该订单已退款");
} }
return refundInternal(order, reason, operatorId, "ADMIN");
}
/**
* 商店回调(退款 / 拒付 / 撤销)触发的回收,**幂等**:
* 订单已退款就直接返回,不二次回收权益、不二次扣币、不写第二条流水。
*
* <p>与人工 {@link #refund} 的区别只在"已退款"的处理:人工入口继续报错(保持既有契约与前端提示),
* 商店回调必须容忍重复(Google 的作废列表会反复出现在时间窗里)。
*/
@Transactional(rollbackFor = Exception.class)
public Orders refundByStoreCallback(String orderNo, String reason) {
Orders order = getExisting(orderNo);
if (STATUS_REFUNDED.equals(order.getStatus())) {
log.info("订单 {} 已退款,忽略重复的商店回调", orderNo);
return order;
}
if (!STATUS_PAID.equals(order.getStatus()) && !STATUS_DELIVERED.equals(order.getStatus())) {
// 未支付/已关闭的订单没什么可回收的,记录下来即可,不当成异常
log.warn("订单 {} 状态为 {},商店回调无可回收内容", orderNo, order.getStatus());
return order;
}
return refundInternal(order, reason, null, "STORE");
}
/** 退款主体:人工与商店回调共用,只有 source 不同。 */
private Orders refundInternal(Orders order, String reason, Long operatorId, String source) {
if (!STATUS_PAID.equals(order.getStatus()) && !STATUS_DELIVERED.equals(order.getStatus())) { if (!STATUS_PAID.equals(order.getStatus()) && !STATUS_DELIVERED.equals(order.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"只有已支付或已交付的订单可以退款,当前状态:" + order.getStatus()); "只有已支付或已交付的订单可以退款,当前状态:" + order.getStatus());
} }
String orderNo = order.getOrderNo();
String from = order.getStatus(); String from = order.getStatus();
int revoked = entitlementService.revokeByOrderNo(orderNo, reason); int revoked = entitlementService.revokeByOrderNo(orderNo, reason);
String clawback = "未涉及金币"; String clawback = "未涉及金币";
@@ -159,7 +338,7 @@ public class OrderService {
order.setRefundAt(LocalDateTime.now()); order.setRefundAt(LocalDateTime.now());
order.setRefundReason(reason == null ? "" : reason); order.setRefundReason(reason == null ? "" : reason);
ordersMapper.updateById(order); ordersMapper.updateById(order);
recordEvent(order, "REFUND", from, STATUS_REFUNDED, "ADMIN", operatorId, recordEvent(order, "REFUND", from, STATUS_REFUNDED, source, operatorId,
"回收权益 " + revoked + " 条;" + clawback); "回收权益 " + revoked + " 条;" + clawback);
return order; return order;
} }
@@ -0,0 +1,223 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.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.commerce.dal.entity.Product;
import com.fruitshorts.commerce.dal.mapper.ProductMapper;
import com.fruitshorts.commerce.vo.ProductQueryReqVO;
import com.fruitshorts.commerce.vo.ProductRespVO;
import com.fruitshorts.commerce.vo.ProductSaveReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
/**
* 商品(金币包)配置:后台 CRUD。
*
* <p>几条硬校验(对不上会直接影响发币口径,所以放在服务端而不是只靠前端):
* <ul>
* <li>`code` 全局唯一;</li>
* <li>`store_product_id` 在**同一 `platform` 下唯一** —— 否则同一商店商品被配置成两个商品,
* 客户端 verify 时"按 storeProductId 找到第一个"会随机发其中一个的金币;</li>
* <li>`type=COIN_PACK` 要求 `coin_amount > 0`;`type=VIP` 要求 `vip_days > 0`;</li>
* <li>**VIP 允许建档、不允许启用**(与 B1-1「VIP 订阅本轮不做」一致)。</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class ProductService {
public static final String TYPE_COIN = "COIN_PACK";
public static final String TYPE_VIP = "VIP";
public static final String PLATFORM_ALL = "ALL";
private static final List<String> PLATFORMS = List.of(PLATFORM_ALL, "GOOGLE", "APPLE");
private final ProductMapper productMapper;
public PageResult<ProductRespVO> page(ProductQueryReqVO req) {
Page<Product> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Product> result = productMapper.selectPage(page, Wrappers.<Product>lambdaQuery()
.eq(StringUtils.hasText(req.getType()), Product::getType, req.getType())
.eq(req.getStatus() != null, Product::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getPlatform()), Product::getPlatform, req.getPlatform())
.and(StringUtils.hasText(req.getKeyword()), w -> w
.like(Product::getCode, req.getKeyword())
.or().like(Product::getName, req.getKeyword()))
.orderByAsc(Product::getSort)
.orderByDesc(Product::getId));
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(ProductRespVO::of).toList());
}
public ProductRespVO detail(Long id) {
return ProductRespVO.of(getExisting(id));
}
@Transactional(rollbackFor = Exception.class)
public Long create(ProductSaveReqVO req) {
String code = req.getCode().trim();
String platform = normalizePlatform(req.getPlatform());
validateShape(req);
requireCodeFree(code, null);
requireStoreProductFree(platform, req.getStoreProductId(), null);
Product product = new Product();
product.setCode(code);
applyFields(product, req, platform);
// 新建默认下架:上架是显式动作,避免半配置的商品立刻对客户端可见
product.setStatus(req.getStatus() == null ? 0 : req.getStatus());
requireVipNotEnabled(product);
productMapper.insert(product);
return product.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, ProductSaveReqVO req) {
Product product = getExisting(id);
String code = req.getCode().trim();
String platform = normalizePlatform(req.getPlatform());
validateShape(req);
requireCodeFree(code, id);
requireStoreProductFree(platform, req.getStoreProductId(), id);
product.setCode(code);
applyFields(product, req, platform);
if (req.getStatus() != null) {
product.setStatus(req.getStatus());
}
requireVipNotEnabled(product);
productMapper.updateById(product);
}
/** 上架 / 下架;VIP 商品只允许下架(第二阶段订阅上线后才放开)。 */
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
if (status == null || (status != 0 && status != 1)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0(下架)或 1(上架)");
}
Product product = getExisting(id);
product.setStatus(status);
requireVipNotEnabled(product);
productMapper.updateById(product);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
productMapper.deleteById(id);
}
/** 供 C 端校验链路复用(按 store_product_id + 平台找商品)。 */
public Product findForStore(String platform, String storeProductId) {
if (!StringUtils.hasText(storeProductId)) {
return null;
}
List<Product> products = productMapper.selectList(Wrappers.<Product>lambdaQuery()
.eq(Product::getStoreProductId, storeProductId)
.in(Product::getPlatform, List.of(normalizePlatform(platform), PLATFORM_ALL))
.eq(Product::getStatus, 1));
return products.isEmpty() ? null : products.get(0);
}
private void applyFields(Product product, ProductSaveReqVO req, String platform) {
product.setName(req.getName().trim());
product.setType(normalizeType(req.getType()));
product.setPlatform(platform);
product.setStoreProductId(req.getStoreProductId() == null ? "" : req.getStoreProductId().trim());
product.setCoinAmount(req.getCoinAmount() == null ? 0L : req.getCoinAmount());
product.setBonusCoin(req.getBonusCoin() == null ? 0L : req.getBonusCoin());
product.setPriceAmount(req.getPriceAmount() == null ? BigDecimal.ZERO : req.getPriceAmount());
product.setCurrency(StringUtils.hasText(req.getCurrency()) ? req.getCurrency() : "IDR");
product.setDisplayPrice(req.getDisplayPrice() == null ? "" : req.getDisplayPrice());
product.setRegionCodes(req.getRegionCodes() == null ? "" : req.getRegionCodes());
product.setVipDays(req.getVipDays() == null ? 0 : req.getVipDays());
product.setSort(req.getSort() == null ? 0 : req.getSort());
product.setEffectiveAt(req.getEffectiveAt());
product.setExpireAt(req.getExpireAt());
}
/** type 与金额/天数的匹配校验。 */
private void validateShape(ProductSaveReqVO req) {
String type = normalizeType(req.getType());
if (TYPE_VIP.equals(type)) {
if (req.getVipDays() == null || req.getVipDays() <= 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "会员商品的会员天数必须大于 0");
}
return;
}
if (req.getCoinAmount() == null || req.getCoinAmount() <= 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "金币包的金币数量必须大于 0");
}
}
/** VIP 商品在订阅功能上线前不能启用(允许建档)。 */
private void requireVipNotEnabled(Product product) {
boolean enabled = product.getStatus() != null && product.getStatus() == 1;
if (enabled && TYPE_VIP.equals(product.getType())) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)");
}
}
private void requireCodeFree(String code, Long excludeId) {
Long count = productMapper.selectCount(Wrappers.<Product>lambdaQuery()
.eq(Product::getCode, code)
.ne(excludeId != null, Product::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "商品编码已存在:" + code);
}
}
/** 同一平台下 store_product_id 必须唯一(ALL 视作"所有平台的通用商品",一并纳入比较)。 */
private void requireStoreProductFree(String platform, String storeProductId, Long excludeId) {
if (!StringUtils.hasText(storeProductId)) {
return;
}
String id = storeProductId.trim();
List<String> scope = PLATFORM_ALL.equals(platform)
? PLATFORMS
: List.of(platform, PLATFORM_ALL);
Long count = productMapper.selectCount(Wrappers.<Product>lambdaQuery()
.eq(Product::getStoreProductId, id)
.in(Product::getPlatform, scope)
.ne(excludeId != null, Product::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS,
"该平台下商店商品 id 已被占用:" + id);
}
}
private Product getExisting(Long id) {
Product product = id == null ? null : productMapper.selectById(id);
if (product == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "商品不存在");
}
return product;
}
private String normalizeType(String type) {
return StringUtils.hasText(type) ? type.trim().toUpperCase() : TYPE_COIN;
}
private String normalizePlatform(String platform) {
if (!StringUtils.hasText(platform)) {
return PLATFORM_ALL;
}
String value = platform.trim().toUpperCase();
if (!PLATFORMS.contains(value)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "平台取值只能是 ALL / GOOGLE / APPLE");
}
return value;
}
}
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more