Author SHA1 Message Date
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
161 changed files with 6155 additions and 275 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)不入库,只保留目录
data/uploads/*
!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"]
+6
View File
@@ -44,6 +44,7 @@ FruitServer/
| JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` |
| Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` |
| PostgreSQL | 18.6 | `brew install postgresql@18`,`brew services start postgresql@18` 启动 |
| ffmpeg | 9.0.2(Homebrew 当前稳定版) | `brew install ffmpeg`;**只有转码 worker 需要**,两个在线入口(8080/8081)不依赖它 |
> `postgresql@18` 是 keg-only,命令行工具在 `/opt/homebrew/opt/postgresql@18/bin`,
> 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。
@@ -51,8 +52,13 @@ FruitServer/
```sh
java -version # openjdk 21.0.12.1
mvn -v # Maven 3.10.0 / Java 21.0.12.1
ffmpeg -version # 转码 worker 用;没装也能跑在线服务,只是转码任务会一直排队
```
> **ffmpeg 只被 `fruitshorts-transcode-worker` 使用**:`backend-design-phase1.md` §8.5 要求转码与在线服务隔离,
> 所以 app-api / admin-api 里 `fruitshorts.media.transcode.worker-enabled` 默认 false,转码要单独起 worker 进程
> (`java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar`)。
说明:
- 工程编译目标为 Java 17(`maven.compiler.release=17`),在 JDK 21 上构建与运行。
+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:
+76 -1
View File
@@ -74,6 +74,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
@@ -197,11 +221,45 @@ POST /app/auth/register
| GET | `/app/wallet` | 金币余额 + 最近流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
| GET | `/app/orders` | 我的订单 |
| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据。**本轮只留端口不接商店**,会返回 `200003 尚未配置` |
| POST | `/app/orders/verify` | 提交 Google Play 票据:服务端校验 → 建单 → 发权益(详见下方说明) |
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
**`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 创作者(下一批)
| 方法 | 路径 | 说明 |
@@ -363,6 +421,23 @@ POST /app/auth/register
| POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 |
| POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) |
**对账口径(B1-2 起生效,字段名不变,新增两个字段)**
| 字段 | 含义 |
| --- | --- |
| `totalCount` | 当期本地有效订单数(排除 `CREATED`/`CLOSED`) |
| `matchedCount` | 其中能在 `store_receipt` 找到同一 `(platform, transaction_id)` 的笔数 |
| `diffCount` | 差异笔数 = 本地缺票据 + 票据无本地订单 + **商店已作废但本地无单** |
| `amountSum` | 本地订单金额合计(**本地口径**,不是商店结算口径) |
| `storeAmountSum` | 商店侧结算金额。Developer API 不提供(要向 Play Console 财务报告取)→ **无数据源时为 `null`** |
| `notes` | 说明差异构成、本次退款回收情况、以及"缺哪些数据源"。**契约强制:取不到的指标用 `null` + `notes` 说明,不许用 0 冒充** |
| `status` | 商店侧调用失败时为 `FAILED`(不会写成 `SUCCESS` + 一堆 0) |
`POST /admin/reconcile/run` 同时会**拉取商店已作废交易并回收权益与金币**(退款/拒付回收),
回收是幂等的:同一笔作废交易重复出现在时间窗里,只回收一次。
口径细节:Google 一次性商品的作废是**整笔**,拿不到部分金额,因此按整单回收;
找不到本地订单的作废交易会计入 `diffCount` 并在 `notes` 里点名。
### 2.7 创作者与内容审核(已实现)
| 方法 | 路径 | 权限点 | 说明 |
@@ -5,7 +5,48 @@ package com.fruitshorts;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.tags.Tag;
@OpenAPIDefinition(
info = @Info(
title = "FruitShorts 运营后台接口(/admin/**)",
version = "1.0.0",
description = "接口契约以 FruitServer/docs/api-phase1.md 为准"
),
tags = {
@Tag(name = "后台-管理员账号", description = "后台管理员账号管理"),
@Tag(name = "后台-App 用户", description = "App 用户查询与处置"),
@Tag(name = "后台-登录与个人", description = "管理端登录、菜单、个人资料与改密"),
@Tag(name = "后台-轮播图管理", description = "轮播图维护与排序"),
@Tag(name = "后台-评论审核", description = "评论检索与审核"),
@Tag(name = "后台-系统配置", description = "系统参数配置"),
@Tag(name = "后台-创作者管理", description = "创作者入驻审核与账号开通"),
@Tag(name = "创作者中心", description = "创作者工作台(作品、通知、看板)"),
@Tag(name = "创作者中心-内容与收费", description = "创作者内容编辑、提交审核与分集收费"),
@Tag(name = "后台-数据看板", description = "运营大盘与转码看板"),
@Tag(name = "后台-字典", description = "分类与状态字典"),
@Tag(name = "后台-离线下载管理", description = "下载授权查询、吊销与清理"),
@Tag(name = "后台-短剧分类", description = "短剧分类维护"),
@Tag(name = "后台-短剧管理", description = "短剧 CRUD 与审核发布流转"),
@Tag(name = "后台-权益管理", description = "权益补发与回收"),
@Tag(name = "后台-剧集管理", description = "剧集 CRUD 与排序"),
@Tag(name = "后台-媒资管理", description = "分片上传与媒资详情"),
@Tag(name = "后台-菜单管理", description = "菜单树维护"),
@Tag(name = "后台-操作日志", description = "操作日志检索"),
@Tag(name = "后台-订单管理", description = "订单检索、补发与退款"),
@Tag(name = "后台-权限点", description = "权限点查询"),
@Tag(name = "后台-分集收费", description = "分集价格配置"),
@Tag(name = "后台-对账", description = "对账任务与手动对账"),
@Tag(name = "后台-举报工单", description = "举报工单处理"),
@Tag(name = "后台-角色分类", description = "角色分类维护"),
@Tag(name = "后台-角色管理", description = "角色与角色权限"),
@Tag(name = "后台-敏感词库", description = "敏感词维护"),
@Tag(name = "后台-内容提交审核", description = "创作者内容提交审核"),
@Tag(name = "后台-金币钱包", description = "金币流水与人工补币"),
}
)
@SpringBootApplication
@MapperScan("com.fruitshorts.**.dal.mapper")
public class FruitShortsApplication {
@@ -10,6 +10,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.AdminUserPageReqVO;
import com.fruitshorts.system.vo.AdminUserRespVO;
import com.fruitshorts.system.vo.AdminUserSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Tag(name = "后台-管理员账号")
@RestController
@RequestMapping("/admin/admin-users")
@RequiredArgsConstructor
@@ -32,18 +35,21 @@ public class AdminUserController {
private final AdminUserService adminUserService;
@Operation(summary = "管理员分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<PageResult<AdminUserRespVO>> page(AdminUserPageReqVO req) {
return Result.ok(adminUserService.page(req));
}
@Operation(summary = "管理员详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<AdminUserRespVO> get(@PathVariable Long id) {
return Result.ok(adminUserService.get(id));
}
@Operation(summary = "新建管理员")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "新增管理员")
@@ -51,6 +57,7 @@ public class AdminUserController {
return Result.ok(adminUserService.create(req));
}
@Operation(summary = "编辑管理员")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "编辑管理员")
@@ -59,6 +66,7 @@ public class AdminUserController {
return Result.ok();
}
@Operation(summary = "删除管理员")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "删除管理员")
@@ -67,6 +75,7 @@ public class AdminUserController {
return Result.ok();
}
@Operation(summary = "启用 / 停用管理员")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "修改管理员状态")
@@ -75,6 +84,7 @@ public class AdminUserController {
return Result.ok();
}
@Operation(summary = "重置管理员密码")
@PutMapping("/{id}/password")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "重置管理员密码")
@@ -18,6 +18,8 @@ import com.fruitshorts.user.dal.entity.AppUserBanRecord;
import com.fruitshorts.user.service.AppUserAdminService;
import com.fruitshorts.user.vo.AppUserQueryReqVO;
import com.fruitshorts.user.vo.AppUserAdminRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -32,6 +34,7 @@ import java.util.Map;
import java.util.stream.Collectors;
/** 后台用户管理。 */
@Tag(name = "后台-App 用户")
@RestController
@RequestMapping("/admin/app-users")
@RequiredArgsConstructor
@@ -42,18 +45,21 @@ public class AppUserAdminController {
private final DramaService dramaService;
private final EpisodeService episodeService;
@Operation(summary = "App 用户分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<PageResult<AppUserAdminRespVO>> page(AppUserQueryReqVO req) {
return Result.ok(appUserAdminService.page(req));
}
@Operation(summary = "App 用户详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<AppUserAdminRespVO> detail(@PathVariable Long id) {
return Result.ok(appUserAdminService.detail(id));
}
@Operation(summary = "封禁 / 处置记录")
@GetMapping("/{id}/ban-records")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<List<AppUserBanRecord>> banRecords(@PathVariable Long id) {
@@ -61,6 +67,7 @@ public class AppUserAdminController {
}
/** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */
@Operation(summary = "观看流水明细(客服核对用)")
@GetMapping("/{id}/watch-history")
@PreAuthorize("@ss.hasPermission('app-user:list')")
public Result<List<AppUserWatchHistoryVO>> watchHistory(@PathVariable Long id,
@@ -108,6 +115,7 @@ public class AppUserAdminController {
}
/** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */
@Operation(summary = "处置用户状态(封禁会立即吊销全部会话)")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('app-user:update')")
@OperationLog(module = "用户管理", action = "修改用户状态")
@@ -12,6 +12,8 @@ import com.fruitshorts.system.vo.LoginRespVO;
import com.fruitshorts.system.vo.MenuVO;
import com.fruitshorts.system.vo.PasswordChangeReqVO;
import com.fruitshorts.system.vo.ProfileRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -23,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-登录与个人")
@RestController
@RequestMapping("/admin/auth")
@RequiredArgsConstructor
@@ -30,6 +33,7 @@ public class AuthController {
private final AuthService authService;
@Operation(summary = "管理端登录")
@PostMapping("/login")
@OperationLog(module = "认证", action = "登录")
public Result<LoginRespVO> login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) {
@@ -37,23 +41,27 @@ public class AuthController {
}
/** 当前用户的菜单路由树,结构与前端 element-plus-admin 的动态路由一致。 */
@Operation(summary = "当前用户菜单路由树")
@GetMapping("/menus")
public Result<List<MenuVO>> menus() {
return Result.ok(authService.listMenus(SecurityUtils.getUserId()));
}
@Operation(summary = "当前用户资料")
@GetMapping("/profile")
public Result<ProfileRespVO> profile() {
return Result.ok(authService.profile(SecurityUtils.getUserId()));
}
/** JWT 无状态,登出由前端清除 token 即可。 */
@Operation(summary = "登出")
@PostMapping("/logout")
@OperationLog(module = "认证", action = "退出登录")
public Result<Void> logout() {
return Result.ok();
}
@Operation(summary = "修改自己的登录密码")
@PostMapping("/password")
@OperationLog(module = "认证", action = "修改密码")
public Result<Void> changePassword(@Valid @RequestBody PasswordChangeReqVO req) {
@@ -9,6 +9,8 @@ import com.fruitshorts.content.vo.BannerRespVO;
import com.fruitshorts.content.vo.BannerSaveReqVO;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-轮播图管理")
@RestController
@RequestMapping("/admin/banners")
@RequiredArgsConstructor
@@ -32,6 +35,7 @@ public class BannerController {
private final BannerService bannerService;
@Operation(summary = "轮播图分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('banner:list')")
public Result<PageResult<BannerRespVO>> page(@RequestParam(required = false) String position,
@@ -41,12 +45,14 @@ public class BannerController {
return Result.ok(bannerService.page(position, status, pageIndex, pageSize));
}
@Operation(summary = "轮播图详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:list')")
public Result<BannerRespVO> get(@PathVariable Long id) {
return Result.ok(bannerService.get(id));
}
@Operation(summary = "新建轮播图")
@PostMapping
@PreAuthorize("@ss.hasPermission('banner:create')")
@OperationLog(module = "内容管理", action = "新增轮播图")
@@ -54,6 +60,7 @@ public class BannerController {
return Result.ok(bannerService.create(req, SecurityUtils.getUserId()));
}
@Operation(summary = "编辑轮播图")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "编辑轮播图")
@@ -62,6 +69,7 @@ public class BannerController {
return Result.ok();
}
@Operation(summary = "删除轮播图")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:delete')")
@OperationLog(module = "内容管理", action = "删除轮播图")
@@ -70,6 +78,7 @@ public class BannerController {
return Result.ok();
}
@Operation(summary = "上架 / 下架轮播图")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "修改轮播图状态")
@@ -78,6 +87,7 @@ public class BannerController {
return Result.ok();
}
@Operation(summary = "调整轮播排序")
@PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('banner:update')")
public Result<Void> sort(@RequestBody Map<String, List<Long>> body) {
@@ -11,6 +11,8 @@ import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.interaction.vo.CommentAuditReqVO;
import com.fruitshorts.interaction.vo.CommentQueryReqVO;
import com.fruitshorts.interaction.vo.CommentRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台评论审核。 */
@Tag(name = "后台-评论审核")
@RestController
@RequestMapping("/admin/comments")
@RequiredArgsConstructor
@@ -33,6 +36,7 @@ public class CommentAdminController {
private final CommentService commentService;
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */
@Operation(summary = "评论检索(默认按状态过滤,待审队列)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('comment:list')")
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
@@ -40,12 +44,14 @@ public class CommentAdminController {
}
/** 审核留证:机审与人审的完整链路。 */
@Operation(summary = "评论审核留证(机审 + 人审链路)")
@GetMapping("/{id}/audit-records")
@PreAuthorize("@ss.hasPermission('comment:list')")
public Result<List<ContentAuditRecord>> auditRecords(@PathVariable Long id) {
return Result.ok(commentService.auditRecords(id));
}
@Operation(summary = "审核评论(通过 / 驳回)")
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('comment:audit')")
@OperationLog(module = "内容治理", action = "审核评论")
@@ -54,6 +60,7 @@ public class CommentAdminController {
return Result.ok();
}
@Operation(summary = "删除评论")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('comment:audit')")
@OperationLog(module = "内容治理", action = "删除评论")
@@ -6,6 +6,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.system.service.ConfigService;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.ConfigItemVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-系统配置")
@RestController
@RequestMapping("/admin/configs")
@RequiredArgsConstructor
@@ -24,12 +27,14 @@ public class ConfigController {
private final ConfigService configService;
@Operation(summary = "系统配置列表")
@GetMapping
@PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')")
public Result<List<ConfigItemVO>> list() {
return Result.ok(configService.list());
}
@Operation(summary = "保存系统配置")
@PutMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改系统设置")
@@ -15,6 +15,8 @@ import com.fruitshorts.creator.vo.CreatorAuditReqVO;
import com.fruitshorts.creator.vo.CreatorQueryReqVO;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台创作者:检索、详情、资质、入驻审核。 */
@Tag(name = "后台-创作者管理")
@RestController
@RequestMapping("/admin/creators")
@RequiredArgsConstructor
@@ -37,12 +40,14 @@ public class CreatorAdminController {
private final CreatorService creatorService;
private final CreatorAccountService creatorAccountService;
@Operation(summary = "创作者分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<PageResult<Creator>> page(CreatorQueryReqVO req) {
return Result.ok(creatorService.page(req));
}
@Operation(summary = "创作者详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<Creator> detail(@PathVariable Long id) {
@@ -50,12 +55,14 @@ public class CreatorAdminController {
}
/** 资质文件:审核前要看证件原件。 */
@Operation(summary = "创作者资质文件")
@GetMapping("/{id}/documents")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<List<CreatorDocument>> documents(@PathVariable Long id) {
return Result.ok(creatorService.documents(id));
}
@Operation(summary = "创作者入驻审核")
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "入驻审核")
@@ -64,6 +71,7 @@ public class CreatorAdminController {
}
/** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */
@Operation(summary = "手工录入创作者主体")
@PostMapping
@PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "新增创作者")
@@ -72,6 +80,7 @@ public class CreatorAdminController {
}
/** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */
@Operation(summary = "开通创作者账号(仅审核通过可开通,幂等)")
@PostMapping("/{id}/account")
@PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "开通创作者账号")
@@ -81,6 +90,7 @@ public class CreatorAdminController {
}
/** 重置创作者账号密码:一次性明文返回,旧密码立即失效。 */
@Operation(summary = "重置创作者账号密码(一次性明文返回)")
@PostMapping("/{id}/account/reset-password")
@PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "重置创作者密码")
@@ -11,6 +11,8 @@ import com.fruitshorts.creator.vo.CreatorCenterNoticeVO;
import com.fruitshorts.creator.vo.CreatorCenterProfileVO;
import com.fruitshorts.creator.vo.CreatorCenterTargetVO;
import com.fruitshorts.admin.service.CreatorDashboardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -28,6 +30,7 @@ import java.util.Map;
* 归属由 {@link CreatorCenterService#requireCurrentCreator()} 从登录态解析,
* 查询强制带 `where creator_id = 当前创作者`;访问他人作品统一 403。
*/
@Tag(name = "创作者中心")
@RestController
@RequestMapping("/admin/creator-center")
@RequiredArgsConstructor
@@ -37,6 +40,7 @@ public class CreatorCenterController {
private final CreatorDashboardService creatorDashboardService;
/** 当前创作者的主体信息与入驻状态。 */
@Operation(summary = "我的创作者资料与入驻状态")
@GetMapping("/profile")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterProfileVO> profile() {
@@ -44,6 +48,7 @@ public class CreatorCenterController {
}
/** 我的作品列表(含最近一次提交的审核进度)。 */
@Operation(summary = "我的作品列表(含最近一次审核进度)")
@GetMapping("/dramas/page")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PageResult<CreatorCenterDramaVO>> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
@@ -51,6 +56,7 @@ public class CreatorCenterController {
}
/** 我的作品详情:id 是别人的作品时返回 403,不区分"不存在"。 */
@Operation(summary = "我的作品详情")
@GetMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterDramaVO> myDrama(@PathVariable Long id) {
@@ -58,6 +64,7 @@ public class CreatorCenterController {
}
/** 我的通知:入驻审核结果 + 我提交内容的审核结果。 */
@Operation(summary = "我的通知(入驻与内容审核结果)")
@GetMapping("/notices")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<CreatorCenterNoticeVO>> notices() {
@@ -65,6 +72,7 @@ public class CreatorCenterController {
}
/** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */
@Operation(summary = "我的数据看板(播放 / 完播 / 收藏 / 评论 / 解锁收入)")
@GetMapping("/dashboard")
@PreAuthorize("@ss.hasPermission('creator-center:stat:view')")
public Result<Map<String, Object>> dashboard() {
@@ -75,6 +83,7 @@ public class CreatorCenterController {
* 代看目标下拉的数据源:只有带 `creator-center:impersonate` 的账号(第一阶段 = 超管)能调。
* 前端拿它渲染顶部的「当前创作者」下拉;没权限的账号不显示下拉,也不会请求这个接口。
*/
@Operation(summary = "代看可选创作者列表")
@GetMapping("/impersonation/targets")
@PreAuthorize("@ss.hasPermission('creator-center:impersonate')")
public Result<List<CreatorCenterTargetVO>> impersonationTargets() {
@@ -14,6 +14,8 @@ import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.creator.vo.CreatorSubmitReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -34,6 +36,7 @@ import java.util.List;
* 与 {@link CreatorCenterController} 一样**不接收 creatorId**:归属由登录态解析,
* 服务层强制 `where creator_id = 当前创作者`,改别人的剧或分集一律 403。
*/
@Tag(name = "创作者中心-内容与收费")
@RestController
@RequestMapping("/admin/creator-center")
@RequiredArgsConstructor
@@ -42,6 +45,7 @@ public class CreatorContentController {
private final CreatorContentService creatorContentService;
/** 新建草稿。 */
@Operation(summary = "新建作品草稿")
@PostMapping("/dramas")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "新建作品")
@@ -50,6 +54,7 @@ public class CreatorContentController {
}
/** 编辑作品信息(仅草稿 / 已下线可改)。 */
@Operation(summary = "编辑作品信息(仅草稿 / 已下线可改)")
@PutMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "编辑作品")
@@ -59,6 +64,7 @@ public class CreatorContentController {
}
/** 分集列表。 */
@Operation(summary = "分集列表")
@GetMapping("/dramas/{id}/episodes")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<EpisodeRespVO>> listEpisodes(@PathVariable Long id) {
@@ -66,6 +72,7 @@ public class CreatorContentController {
}
/** 新增分集(集号不传自动排到最后)。 */
@Operation(summary = "新增分集")
@PostMapping("/dramas/{id}/episodes")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "新增分集")
@@ -74,6 +81,7 @@ public class CreatorContentController {
}
/** 编辑分集(时长 / 封面 / 片源 / 排序)。 */
@Operation(summary = "编辑分集(时长 / 封面 / 片源)")
@PutMapping("/dramas/{id}/episodes/{episodeId}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "编辑分集")
@@ -84,6 +92,7 @@ public class CreatorContentController {
}
/** 删除分集。 */
@Operation(summary = "删除分集")
@DeleteMapping("/dramas/{id}/episodes/{episodeId}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "删除分集")
@@ -93,6 +102,7 @@ public class CreatorContentController {
}
/** 批量排序:body 里按新顺序给出分集 id。 */
@Operation(summary = "分集批量排序")
@PutMapping("/dramas/{id}/episodes/sort")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "调整分集顺序")
@@ -102,6 +112,7 @@ public class CreatorContentController {
}
/** 提交审核:CREATE / UPDATE / PUBLISH / OFFLINE。 */
@Operation(summary = "提交审核(CREATE / UPDATE / PUBLISH / OFFLINE)")
@PostMapping("/dramas/{id}/submit")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "提交审核")
@@ -110,6 +121,7 @@ public class CreatorContentController {
}
/** 撤回自己的提交(撤回后作品退回草稿,可继续编辑)。 */
@Operation(summary = "撤回自己的提交")
@PostMapping("/submissions/{id}/withdraw")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "撤回提交")
@@ -118,6 +130,7 @@ public class CreatorContentController {
}
/** 我的提交历史(审核进度)。 */
@Operation(summary = "我的提交历史")
@GetMapping("/dramas/{id}/submissions")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) {
@@ -125,6 +138,7 @@ public class CreatorContentController {
}
/** 把上传好的视频产物挂到自己的分集上(产物必须是本人上传的)。 */
@Operation(summary = "把上传产物挂到自己的分集上")
@PostMapping("/dramas/{id}/episodes/{episodeId}/attach")
@PreAuthorize("@ss.hasPermission('creator-center:upload')")
@OperationLog(module = "创作者中心", action = "挂载分集视频")
@@ -135,6 +149,7 @@ public class CreatorContentController {
}
/** 分集收费与试看秒数:读现状。 */
@Operation(summary = "分集收费与试看秒数(读)")
@GetMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PriceRespVO> priceDetail(@PathVariable Long id) {
@@ -142,6 +157,7 @@ public class CreatorContentController {
}
/** 分集收费与试看秒数:保存(复用后台同一套 PriceService,改完立即影响付费判定)。 */
@Operation(summary = "分集收费与试看秒数(保存)")
@PutMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:price:update')")
@OperationLog(module = "创作者中心", action = "配置分集价格")
@@ -4,6 +4,8 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.DashboardService;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -18,6 +20,7 @@ import java.util.Map;
* 指标口径写在 DashboardService 的类注释与 docs/api-phase1.md 里;
* 取不到数据源的指标返回 null 并在 notes 里说明,不用 0 冒充。
*/
@Tag(name = "后台-数据看板")
@RestController
@RequestMapping("/admin/dashboard")
@RequiredArgsConstructor
@@ -26,6 +29,7 @@ public class DashboardAdminController {
private final DashboardService dashboardService;
/** 总览:用户 / 内容 / 播放 / 收入 / 成本。 */
@Operation(summary = "运营大盘总览(用户 / 内容 / 播放 / 收入 / 成本)")
@GetMapping("/overview")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<Map<String, Object>> overview() {
@@ -33,6 +37,7 @@ public class DashboardAdminController {
}
/** 转码看板:队列积压与失败任务明细。 */
@Operation(summary = "转码看板(队列积压与失败任务)")
@GetMapping("/transcode")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
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.service.DramaCategoryService;
import com.fruitshorts.content.vo.DramaCategoryRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -19,6 +21,7 @@ import java.util.Map;
/**
* 字典接口:题材、内容状态等下拉数据。
*/
@Tag(name = "后台-字典")
@RestController
@RequestMapping("/admin/dicts")
@RequiredArgsConstructor
@@ -26,12 +29,14 @@ public class DictController {
private final DramaCategoryService dramaCategoryService;
@Operation(summary = "短剧分类字典")
@GetMapping("/drama-category")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> dramaCategories() {
return Result.ok(dramaCategoryService.list());
}
@Operation(summary = "内容状态字典")
@GetMapping("/content-status")
@PreAuthorize("@ss.hasPermission('drama:list')")
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.vo.DownloadLicenseRespVO;
import com.fruitshorts.playback.vo.DownloadQueryReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/** 后台下载授权管理。 */
@Tag(name = "后台-离线下载管理")
@RestController
@RequestMapping("/admin/downloads")
@RequiredArgsConstructor
@@ -27,12 +30,14 @@ public class DownloadAdminController {
private final DownloadService downloadService;
@Operation(summary = "下载授权分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('download:list')")
public Result<PageResult<DownloadLicenseRespVO>> page(DownloadQueryReqVO req) {
return Result.ok(downloadService.page(req));
}
@Operation(summary = "下载统计")
@GetMapping("/stats")
@PreAuthorize("@ss.hasPermission('download:list')")
public Result<Map<String, Object>> stats() {
@@ -40,6 +45,7 @@ public class DownloadAdminController {
}
/** 吊销单条授权:用户侧下次联网即失效。 */
@Operation(summary = "吊销单条下载授权")
@PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('download:revoke')")
@OperationLog(module = "下载管理", action = "吊销下载授权")
@@ -49,6 +55,7 @@ public class DownloadAdminController {
}
/** 手动触发过期清理,正常情况下由定时任务每天执行。 */
@Operation(summary = "手动触发过期授权清理")
@PostMapping("/cleanup")
@PreAuthorize("@ss.hasPermission('download:revoke')")
@OperationLog(module = "下载管理", action = "清理过期下载授权")
@@ -8,6 +8,8 @@ import com.fruitshorts.content.vo.DramaCategoryRespVO;
import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import java.util.List;
/**
* 短剧类型管理。类型属于短剧域,权限沿用 drama:*,不再单开权限点。
*/
@Tag(name = "后台-短剧分类")
@RestController
@RequestMapping("/admin/drama-categories")
@RequiredArgsConstructor
@@ -32,12 +35,14 @@ public class DramaCategoryController {
private final DramaCategoryService dramaCategoryService;
@Operation(summary = "分类列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> list() {
return Result.ok(dramaCategoryService.list());
}
@Operation(summary = "新建分类")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增短剧类型")
@@ -45,6 +50,7 @@ public class DramaCategoryController {
return Result.ok(dramaCategoryService.create(req));
}
@Operation(summary = "编辑分类")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧类型")
@@ -53,6 +59,7 @@ public class DramaCategoryController {
return Result.ok();
}
@Operation(summary = "删除分类")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧类型")
@@ -12,6 +12,8 @@ import com.fruitshorts.content.vo.DramaSaveReqVO;
import com.fruitshorts.content.vo.DramaStatusReqVO;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.log.OperationLog;
import 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;
@@ -26,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-短剧管理")
@RestController
@RequestMapping("/admin/dramas")
@RequiredArgsConstructor
@@ -33,18 +36,21 @@ public class DramaController {
private final DramaService dramaService;
@Operation(summary = "短剧分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
return Result.ok(dramaService.page(req));
}
@Operation(summary = "短剧详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<DramaRespVO> get(@PathVariable Long id) {
return Result.ok(dramaService.get(id));
}
@Operation(summary = "新建短剧")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新建短剧")
@@ -52,6 +58,7 @@ public class DramaController {
return Result.ok(dramaService.create(req, SecurityUtils.getUserId()));
}
@Operation(summary = "编辑短剧")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧")
@@ -60,6 +67,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "删除短剧")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧")
@@ -69,6 +77,7 @@ public class DramaController {
}
/** 提交审核:内容运营即可操作。 */
@Operation(summary = "提交审核")
@PostMapping("/{id}/submit")
@PreAuthorize("@ss.hasAnyPermission('drama:review','drama:update')")
@OperationLog(module = "内容管理", action = "提交审核")
@@ -77,6 +86,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "审核通过")
@PostMapping("/{id}/approve")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "审核通过")
@@ -85,6 +95,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "审核驳回")
@PostMapping("/{id}/reject")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "驳回短剧")
@@ -93,6 +104,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "上线")
@PostMapping("/{id}/publish")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧上线")
@@ -101,6 +113,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "下线")
@PostMapping("/{id}/offline")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧下线")
@@ -109,6 +122,7 @@ public class DramaController {
return Result.ok();
}
@Operation(summary = "审核记录")
@GetMapping("/{id}/review-records")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaReviewRecord>> reviewRecords(@PathVariable Long id) {
@@ -8,6 +8,8 @@ import com.fruitshorts.commerce.vo.EntitlementGrantReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台权益:检索、人工补发、回收。 */
@Tag(name = "后台-权益管理")
@RestController
@RequestMapping("/admin/entitlements")
@RequiredArgsConstructor
@@ -28,6 +31,7 @@ public class EntitlementAdminController {
private final EntitlementService entitlementService;
@Operation(summary = "权益分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('entitlement:list')")
public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId,
@@ -39,6 +43,7 @@ public class EntitlementAdminController {
}
/** 客服补偿:直接给用户某集/整剧的播放权益。 */
@Operation(summary = "补发权益(客服补偿)")
@PostMapping
@PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "人工补发权益")
@@ -50,6 +55,7 @@ public class EntitlementAdminController {
}
/** 回收权益:退款、违规处置后用。 */
@Operation(summary = "回收权益(退款 / 违规处置)")
@PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "回收权益")
@@ -8,6 +8,8 @@ import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import com.fruitshorts.content.vo.EpisodeSortReqVO;
import com.fruitshorts.framework.log.OperationLog;
import 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;
@@ -23,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-剧集管理")
@RestController
@RequestMapping("/admin/episodes")
@RequiredArgsConstructor
@@ -30,6 +33,7 @@ public class EpisodeController {
private final EpisodeService episodeService;
@Operation(summary = "剧集列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('episode:list')")
public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId,
@@ -37,6 +41,7 @@ public class EpisodeController {
return Result.ok(episodeService.listByDrama(dramaId, status));
}
@Operation(summary = "新建剧集")
@PostMapping
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "新增剧集")
@@ -44,6 +49,7 @@ public class EpisodeController {
return Result.ok(episodeService.create(req));
}
@Operation(summary = "编辑剧集")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "编辑剧集")
@@ -52,6 +58,7 @@ public class EpisodeController {
return Result.ok();
}
@Operation(summary = "删除剧集")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "删除剧集")
@@ -60,6 +67,7 @@ public class EpisodeController {
return Result.ok();
}
@Operation(summary = "剧集排序")
@PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('episode:sort')")
@OperationLog(module = "内容管理", action = "调整剧集顺序")
@@ -11,6 +11,8 @@ import com.fruitshorts.media.vo.UploadCompleteReqVO;
import com.fruitshorts.media.vo.UploadInitReqVO;
import com.fruitshorts.media.vo.UploadInitRespVO;
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 lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -26,6 +28,7 @@ import org.springframework.web.multipart.MultipartFile;
/**
* 媒资上传(本地存储版):init 建会话 → chunk 传分片 → complete 合并出地址。
*/
@Tag(name = "后台-媒资管理")
@RestController
@RequestMapping("/admin/media")
@RequiredArgsConstructor
@@ -33,6 +36,7 @@ public class MediaController {
private final MediaService mediaService;
@Operation(summary = "初始化分片上传")
@PostMapping("/upload/init")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<UploadInitRespVO> init(@Valid @RequestBody UploadInitReqVO req) {
@@ -41,6 +45,7 @@ public class MediaController {
return Result.ok(mediaService.init(req, SecurityUtils.getUserId()));
}
@Operation(summary = "上传分片")
@PostMapping("/upload/chunk")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<Integer> uploadChunk(@RequestParam String uploadId,
@@ -51,6 +56,7 @@ public class MediaController {
return Result.ok(index);
}
@Operation(summary = "完成上传")
@PostMapping("/upload/complete")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<MediaAssetRespVO> complete(@Valid @RequestBody UploadCompleteReqVO req) {
@@ -59,12 +65,14 @@ public class MediaController {
}
/** 断点续传:查上传会话进度(只能查自己发起的会话)。 */
@Operation(summary = "查询上传进度(断点续传)")
@GetMapping("/upload/{uploadId}")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<UploadProgressRespVO> progress(@PathVariable String uploadId) {
return Result.ok(mediaService.progress(uploadId, SecurityUtils.getUserId()));
}
@Operation(summary = "媒资详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
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.system.vo.MenuSaveReqVO;
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 lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-菜单管理")
@RestController
@RequestMapping("/admin/menus")
@RequiredArgsConstructor
@@ -28,12 +31,14 @@ public class MenuController {
private final MenuService menuService;
@Operation(summary = "菜单树")
@GetMapping("/tree")
@PreAuthorize("@ss.hasPermission('system:menu')")
public Result<List<MenuVO>> tree() {
return Result.ok(menuService.listAll());
}
@Operation(summary = "新建菜单")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "新增菜单")
@@ -41,6 +46,7 @@ public class MenuController {
return Result.ok(menuService.create(req));
}
@Operation(summary = "编辑菜单")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "编辑菜单")
@@ -49,6 +55,7 @@ public class MenuController {
return Result.ok();
}
@Operation(summary = "删除菜单")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "删除菜单")
@@ -7,6 +7,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.system.service.OperationLogService;
import com.fruitshorts.system.vo.OperationLogPageReqVO;
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 org.springframework.security.access.prepost.PreAuthorize;
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.RestController;
@Tag(name = "后台-操作日志")
@RestController
@RequestMapping("/admin/operation-logs")
@RequiredArgsConstructor
@@ -21,12 +24,14 @@ public class OperationLogController {
private final OperationLogService operationLogService;
@Operation(summary = "操作日志分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('log:list')")
public Result<PageResult<OperationLogRespVO>> page(OperationLogPageReqVO req) {
return Result.ok(operationLogService.page(req));
}
@Operation(summary = "操作日志详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('log:list')")
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.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -24,6 +26,7 @@ import java.util.List;
import java.util.Map;
/** 后台订单:检索、补发、退款回收。 */
@Tag(name = "后台-订单管理")
@RestController
@RequestMapping("/admin/orders")
@RequiredArgsConstructor
@@ -31,12 +34,14 @@ public class OrderAdminController {
private final OrderService orderService;
@Operation(summary = "订单分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<PageResult<Orders>> page(OrderQueryReqVO req) {
return Result.ok(orderService.page(req));
}
@Operation(summary = "订单详情")
@GetMapping("/{orderNo}")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<Orders> detail(@PathVariable String orderNo) {
@@ -44,6 +49,7 @@ public class OrderAdminController {
}
/** 订单状态流转流水:支付争议时的证据链。 */
@Operation(summary = "订单状态流转流水")
@GetMapping("/{orderNo}/events")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<List<OrderEvent>> events(@PathVariable String orderNo) {
@@ -51,6 +57,7 @@ public class OrderAdminController {
}
/** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */
@Operation(summary = "补发订单(已支付未交付重新交付,幂等)")
@PostMapping("/{orderNo}/compensate")
@PreAuthorize("@ss.hasPermission('order:update')")
@OperationLog(module = "订单管理", action = "补发订单")
@@ -59,6 +66,7 @@ public class OrderAdminController {
}
/** 退款/撤销:回收权益并扣回金币。 */
@Operation(summary = "退款 / 撤销(回收权益并扣回金币)")
@PostMapping("/{orderNo}/refund")
@PreAuthorize("@ss.hasPermission('order:update')")
@OperationLog(module = "订单管理", action = "订单退款")
@@ -5,6 +5,8 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.system.service.PermissionService;
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 org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -13,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-权限点")
@RestController
@RequestMapping("/admin/permissions")
@RequiredArgsConstructor
@@ -21,6 +24,7 @@ public class PermissionController {
private final PermissionService permissionService;
/** 按分组返回权限点,直接喂给前端的角色权限配置页。 */
@Operation(summary = "权限点列表(按分组返回)")
@GetMapping
@PreAuthorize("@ss.hasPermission('system:role')")
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.PriceSaveReqVO;
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 org.springframework.security.access.prepost.PreAuthorize;
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;
/** 后台分集价格与免费集配置。 */
@Tag(name = "后台-分集收费")
@RestController
@RequestMapping("/admin/prices")
@RequiredArgsConstructor
@@ -25,12 +28,14 @@ public class PriceAdminController {
private final PriceService priceService;
/** 当前价格配置:整剧默认 + 分集明细。 */
@Operation(summary = "分集价格配置(整剧默认 + 分集明细)")
@GetMapping
@PreAuthorize("@ss.hasPermission('price:update')")
public Result<PriceRespVO> detail(@RequestParam Long dramaId) {
return Result.ok(priceService.detail(dramaId));
}
@Operation(summary = "保存分集价格配置")
@PutMapping
@PreAuthorize("@ss.hasPermission('price:update')")
@OperationLog(module = "定价管理", action = "配置分集价格")
@@ -7,6 +7,8 @@ import com.fruitshorts.commerce.service.ReconcileService;
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 lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
/** 后台对账:手动触发与历史查看。 */
@Tag(name = "后台-对账")
@RestController
@RequestMapping("/admin/reconcile")
@RequiredArgsConstructor
@@ -25,6 +28,7 @@ public class ReconcileAdminController {
private final ReconcileService reconcileService;
@Operation(summary = "对账任务列表")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<PageResult<ReconcileTask>> page(@RequestParam(defaultValue = "1") Integer pageIndex,
@@ -33,6 +37,7 @@ public class ReconcileAdminController {
}
/** 手动对账:默认前一天,可指定日期与渠道。 */
@Operation(summary = "手动触发对账")
@PostMapping("/run")
@PreAuthorize("@ss.hasPermission('reconcile:run')")
@OperationLog(module = "订单管理", action = "手动对账")
@@ -10,6 +10,8 @@ import com.fruitshorts.interaction.dal.entity.ContentReport;
import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.ReportHandleReqVO;
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台举报工单。 */
@Tag(name = "后台-举报工单")
@RestController
@RequestMapping("/admin/reports")
@RequiredArgsConstructor
@@ -27,6 +30,7 @@ public class ReportAdminController {
private final ReportService reportService;
@Operation(summary = "举报工单分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('report:list')")
public Result<PageResult<ContentReport>> page(ReportQueryReqVO req) {
@@ -34,6 +38,7 @@ public class ReportAdminController {
}
/** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */
@Operation(summary = "处理举报工单(可联动驳回评论与封禁用户)")
@PutMapping("/{id}/handle")
@PreAuthorize("@ss.hasPermission('report:handle')")
@OperationLog(module = "内容治理", action = "处理举报")
@@ -7,6 +7,8 @@ import com.fruitshorts.system.service.RoleCategoryService;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.RoleCategoryRespVO;
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 lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-角色分类")
@RestController
@RequestMapping("/admin/role-categories")
@RequiredArgsConstructor
@@ -28,12 +31,14 @@ public class RoleCategoryController {
private final RoleCategoryService roleCategoryService;
@Operation(summary = "角色分类列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('system:role-category')")
public Result<List<RoleCategoryRespVO>> list() {
return Result.ok(roleCategoryService.list());
}
@Operation(summary = "新建角色分类")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "新增角色分类")
@@ -41,6 +46,7 @@ public class RoleCategoryController {
return Result.ok(roleCategoryService.create(req));
}
@Operation(summary = "编辑角色分类")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "编辑角色分类")
@@ -49,6 +55,7 @@ public class RoleCategoryController {
return Result.ok();
}
@Operation(summary = "删除角色分类")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "删除角色分类")
@@ -9,6 +9,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.vo.RolePermissionSaveReqVO;
import com.fruitshorts.system.vo.RoleRespVO;
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 lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "后台-角色管理")
@RestController
@RequestMapping("/admin/roles")
@RequiredArgsConstructor
@@ -31,6 +34,7 @@ public class RoleController {
private final RoleService roleService;
@Operation(summary = "角色分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:role')")
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));
}
@Operation(summary = "角色下拉列表")
@GetMapping("/list")
public Result<List<RoleRespVO>> list() {
return Result.ok(roleService.listAll());
}
@Operation(summary = "角色详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<RoleRespVO> get(@PathVariable Long id) {
return Result.ok(roleService.get(id));
}
@Operation(summary = "新建角色")
@PostMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "新增角色")
@@ -57,6 +64,7 @@ public class RoleController {
return Result.ok(roleService.create(req));
}
@Operation(summary = "编辑角色")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "编辑角色")
@@ -65,6 +73,7 @@ public class RoleController {
return Result.ok();
}
@Operation(summary = "删除角色")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "删除角色")
@@ -73,12 +82,14 @@ public class RoleController {
return Result.ok();
}
@Operation(summary = "角色已授权限")
@GetMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<List<String>> permissions(@PathVariable Long id) {
return Result.ok(roleService.listCodesByRoleId(id));
}
@Operation(summary = "保存角色权限")
@PutMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改角色权限")
@@ -8,6 +8,8 @@ import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.interaction.dal.entity.SensitiveWord;
import com.fruitshorts.interaction.service.SensitiveWordService;
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 lombok.RequiredArgsConstructor;
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;
/** 后台敏感词库。改动即时生效(服务端词库缓存会失效重建)。 */
@Tag(name = "后台-敏感词库")
@RestController
@RequestMapping("/admin/sensitive-words")
@RequiredArgsConstructor
@@ -29,6 +32,7 @@ public class SensitiveWordAdminController {
private final SensitiveWordService sensitiveWordService;
@Operation(summary = "敏感词分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('sensitive-word:list')")
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));
}
@Operation(summary = "新增敏感词")
@PostMapping
@PreAuthorize("@ss.hasPermission('sensitive-word:update')")
@OperationLog(module = "内容治理", action = "新增敏感词")
@@ -46,6 +51,7 @@ public class SensitiveWordAdminController {
return Result.ok(sensitiveWordService.create(req));
}
@Operation(summary = "编辑敏感词")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('sensitive-word:update')")
@OperationLog(module = "内容治理", action = "修改敏感词")
@@ -54,6 +60,7 @@ public class SensitiveWordAdminController {
return Result.ok();
}
@Operation(summary = "删除敏感词")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('sensitive-word:update')")
@OperationLog(module = "内容治理", action = "删除敏感词")
@@ -10,6 +10,8 @@ import com.fruitshorts.creator.vo.SubmissionAuditReqVO;
import com.fruitshorts.creator.vo.SubmissionQueryReqVO;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台内容提交审核:检索、详情、审核(通过 / 驳回 / 撤回)。 */
@Tag(name = "后台-内容提交审核")
@RestController
@RequestMapping("/admin/submissions")
@RequiredArgsConstructor
@@ -27,18 +30,21 @@ public class SubmissionAdminController {
private final ContentSubmissionService submissionService;
@Operation(summary = "内容提交分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('submission:list')")
public Result<PageResult<ContentSubmission>> page(SubmissionQueryReqVO req) {
return Result.ok(submissionService.page(req));
}
@Operation(summary = "内容提交详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('submission:list')")
public Result<ContentSubmission> detail(@PathVariable Long id) {
return Result.ok(submissionService.getExisting(id));
}
@Operation(summary = "内容提交审核")
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('submission:audit')")
@OperationLog(module = "内容审核", action = "提交审核")
@@ -11,6 +11,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
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 lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台钱包:人工补币/扣回,全部进流水且必须带幂等键。 */
@Tag(name = "后台-金币钱包")
@RestController
@RequestMapping("/admin/wallets")
@RequiredArgsConstructor
@@ -33,6 +36,7 @@ public class WalletAdminController {
private final WalletService walletService;
private final AppUserMapper appUserMapper;
@Operation(summary = "金币流水")
@GetMapping("/{userId}/ledger")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<List<CoinLedger>> ledger(@PathVariable Long userId,
@@ -40,6 +44,7 @@ public class WalletAdminController {
return Result.ok(walletService.recentLedger(userId, limit));
}
@Operation(summary = "人工补币")
@PostMapping("/coins")
@PreAuthorize("@ss.hasPermission('wallet:update')")
@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
datasource:
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}
password: ${DB_PASSWORD:fruitshorts}
jackson:
@@ -28,23 +29,35 @@ mybatis-plus:
global-config:
banner: false
db-config:
id-type: auto
# 主键由应用侧雪花算法生成(S0),数据库不再自增
id-type: assign_id
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
sequence:
# 单机开发用固定值;上多实例前必须改成按实例分配(环境变量注入),否则会撞 ID
worker-id: 1
datacenter-id: 1
fruitshorts:
media:
# 本地媒资目录(相对应用启动目录),通过 /files/** 访问
root-dir: ./data/uploads
# 媒资目录:容器里指向挂载卷;本地默认 ./data/uploads
root-dir: ${MEDIA_ROOT_DIR:./data/uploads}
public-prefix: /files
chunk-size: 5242880
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:
# 双密钥都要外置:只配 admin 而漏配 app,生产会静默用 dev 默认值(prod fail-fast 会拦住)
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
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:
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:
swagger-ui:
@@ -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,43 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.support;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
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())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build()))
.build();
}
}
+6
View File
@@ -41,6 +41,12 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId>
</dependency>
<!-- 契约测试用:MockMvc + Mockito + JUnit5,test scope,不进运行态依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -5,12 +5,34 @@ package com.fruitshorts.app;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* 客户端接口服务,只暴露 /app/** 与静态媒资 /files/**。
* 与 fruitshorts-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。
*/
/* 显式指定扫描根包:本类在 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 = "观看进度上报与续看位置"),
}
)
@SpringBootApplication(scanBasePackages = "com.fruitshorts")
@MapperScan("com.fruitshorts.**.dal.mapper")
public class AppApiApplication {
@@ -13,6 +13,8 @@ import com.fruitshorts.user.vo.OtpSendReqVO;
import com.fruitshorts.user.vo.RefreshReqVO;
import com.fruitshorts.user.vo.RegisterReqVO;
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.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 客户端账号:验证码、注册、登录、刷新、登出。 */
@Tag(name = "客户端-账号")
@RestController
@RequestMapping("/app/auth")
@RequiredArgsConstructor
@@ -31,30 +34,35 @@ public class AppAuthController {
private final AppAuthService authService;
/** 发送验证码。频控在服务端,客户端不需要自己限流。 */
@Operation(summary = "发送验证码(频控在服务端)")
@PostMapping("/otp")
public Result<Void> sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) {
otpService.send(req, WebUtils.getClientIp(request));
return Result.ok();
}
@Operation(summary = "注册账号(邮箱免验证码 / 手机号需验证码)")
@PostMapping("/register")
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
HttpServletRequest request) {
return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
}
@Operation(summary = "登录(验证码或密码)")
@PostMapping("/login")
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
HttpServletRequest request) {
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
}
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
@PostMapping("/refresh")
public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) {
return Result.ok(authService.refresh(req.getRefreshToken()));
}
/** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */
@Operation(summary = "登出(默认当前设备,allDevices 吊销全部)")
@PostMapping("/logout")
public Result<Void> logout(@RequestBody(required = false) 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.AppFeedVO;
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;
@@ -16,6 +18,7 @@ import java.util.List;
/**
* 客户端目录接口:首页与发现页各一次请求拿全量聚合数据。
*/
@Tag(name = "客户端-内容浏览")
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
@@ -23,16 +26,19 @@ public class AppCatalogController {
private final AppCatalogService appCatalogService;
@Operation(summary = "首页聚合:轮播 + 分区,一次请求渲染整页")
@GetMapping("/home/feed")
public Result<AppFeedVO> homeFeed() {
return Result.ok(appCatalogService.homeFeed());
}
@Operation(summary = "发现页聚合:分区列表")
@GetMapping("/discover/feed")
public Result<AppFeedVO> discoverFeed() {
return Result.ok(appCatalogService.discoverFeed());
}
@Operation(summary = "分类列表")
@GetMapping("/categories")
public Result<List<AppCategoryVO>> 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.CommentQueryReqVO;
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.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
* 不传 `rootId` 时返回顶层评论,传了则返回该楼层的回复。
* 列表允许匿名浏览;发布与删除需要登录。
*/
@Tag(name = "客户端-评论")
@RestController
@RequestMapping("/app/comments")
@RequiredArgsConstructor
@@ -36,6 +39,7 @@ public class AppCommentController {
private final CommentService commentService;
@Operation(summary = "发布评论")
@PostMapping
public Result<CommentRespVO> publish(@Valid @RequestBody CommentPublishReqVO req,
HttpServletRequest request) {
@@ -43,6 +47,7 @@ public class AppCommentController {
WebUtils.getClientIp(request), request.getHeader("X-Device-Id")));
}
@Operation(summary = "评论列表(游标翻页,可匿名浏览)")
@GetMapping
public Result<CursorResult<CommentRespVO>> page(@RequestParam Long dramaId,
@RequestParam(required = false) Long episodeId,
@@ -58,6 +63,7 @@ public class AppCommentController {
return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query));
}
@Operation(summary = "删除自己的评论")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long 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.common.pojo.Result;
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 org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -26,6 +28,7 @@ import java.util.Map;
* 金币解锁是第一阶段真实的收费方式;真实商店购买走 {@code /app/orders/verify},
* 本轮只预留接口(商店未接入时返回明确业务提示,不会产生假订单)。
*/
@Tag(name = "客户端-金币与订单")
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
@@ -36,6 +39,7 @@ public class AppCommerceController {
private final OrderService orderService;
/** 我的钱包:余额 + 最近流水,客户端一处渲染。 */
@Operation(summary = "我的钱包(余额 + 最近流水)")
@GetMapping("/wallet")
public Result<Map<String, Object>> wallet() {
Long userId = SecurityUtils.getAppUserId();
@@ -46,20 +50,31 @@ public class AppCommerceController {
}
/** 金币解锁分集:幂等,重复点击不会重复扣币。 */
@Operation(summary = "金币解锁分集(幂等,重复点击不重复扣币)")
@PostMapping("/episodes/{episodeId}/unlock")
public Result<Map<String, Object>> unlock(@PathVariable Long episodeId) {
return Result.ok(unlockService.unlock(SecurityUtils.getAppUserId(), episodeId));
}
@Operation(summary = "我的订单列表")
@GetMapping("/orders")
public Result<List<Orders>> orders() {
return Result.ok(orderService.listByUser(SecurityUtils.getAppUserId()));
}
/** 提交商店票据。第一阶段商店未接入,会返回明确的"尚未配置"。 */
/**
* 提交商店票据:服务端校验 → 建单 → 发权益。
*
* <p>body:`platform`(GOOGLE/GOOGLE_PLAY)、`productId`(商店商品 id)、`receipt`(purchaseToken)。
* 同一票据重复提交只交付一次;票据已绑定其他账号、或校验不通过时返回明确业务码且不产生订单。
* 商店未配置(默认)时返回 `200003 尚未配置`,与第一阶段行为一致。
*/
@Operation(summary = "提交商店票据(校验后建单并发权益,重复提交幂等)")
@PostMapping("/orders/verify")
public Result<Orders> verify(@RequestBody Map<String, String> body) {
return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(),
body.getOrDefault("platform", ""), body.getOrDefault("receipt", "")));
body.getOrDefault("platform", ""),
body.getOrDefault("productId", ""),
body.getOrDefault("receipt", "")));
}
}
@@ -10,6 +10,8 @@ import com.fruitshorts.playback.vo.DownloadLicenseRespVO;
import com.fruitshorts.playback.vo.DownloadPolicyRespVO;
import com.fruitshorts.playback.vo.DownloadProgressReqVO;
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.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -31,6 +33,7 @@ import java.util.List;
* <p>服务端只发授权与地址,分片由客户端向 CDN 拉取;进度上报用于断点续传与跨端展示。
* 设备标识优先取请求体,其次取请求头 X-Device-Id。
*/
@Tag(name = "客户端-离线下载")
@RestController
@RequestMapping("/app/downloads")
@RequiredArgsConstructor
@@ -39,6 +42,7 @@ public class AppDownloadController {
private final DownloadService downloadService;
/** 申请下载授权(同一剧集重复申请幂等,只刷新有效期)。 */
@Operation(summary = "申请下载授权(同一剧集幂等,只刷新有效期)")
@PostMapping
public Result<DownloadLicenseRespVO> grant(@Valid @RequestBody DownloadGrantReqVO req,
HttpServletRequest request) {
@@ -46,17 +50,20 @@ public class AppDownloadController {
request.getHeader("X-Device-Id")));
}
@Operation(summary = "我的下载授权列表")
@GetMapping
public Result<List<DownloadLicenseRespVO>> list(@RequestParam(required = false) String deviceId) {
return Result.ok(downloadService.list(SecurityUtils.getAppUserId(), deviceId));
}
@Operation(summary = "下载规则(设备上限 / 仅 Wi-Fi / 清晰度上限)")
@GetMapping("/policy")
public Result<DownloadPolicyRespVO> policy(@RequestParam(required = false) String deviceId) {
return Result.ok(downloadService.policy(SecurityUtils.getAppUserId(), deviceId));
}
/** 仅 Wi-Fi 下载、清晰度上限等偏好。 */
@Operation(summary = "更新下载偏好设置")
@PutMapping("/settings")
public Result<Void> updateSettings(@RequestBody DownloadSettingReqVO req) {
downloadService.updateSettings(SecurityUtils.getAppUserId(), req);
@@ -64,6 +71,7 @@ public class AppDownloadController {
}
/** 断点续传前重新取一次地址:授权不变,只有地址是短时效的。 */
@Operation(summary = "重新获取下载地址(断点续传前刷新短时效地址)")
@GetMapping("/{episodeId}/url")
public Result<DownloadLicenseRespVO> refreshUrl(@PathVariable Long episodeId,
HttpServletRequest request) {
@@ -71,6 +79,7 @@ public class AppDownloadController {
request.getHeader("X-Device-Id")));
}
@Operation(summary = "上报下载进度")
@PostMapping("/{episodeId}/progress")
public Result<DownloadLicenseRespVO> reportProgress(@PathVariable Long episodeId,
@RequestBody DownloadProgressReqVO req,
@@ -79,6 +88,7 @@ public class AppDownloadController {
request.getHeader("X-Device-Id")));
}
@Operation(summary = "吊销某集的下载授权")
@DeleteMapping("/{episodeId}")
public Result<Void> revoke(@PathVariable Long episodeId,
@RequestParam(required = false) String deviceId,
@@ -89,6 +99,7 @@ public class AppDownloadController {
}
/** 清空当前设备的全部下载授权。 */
@Operation(summary = "清空当前设备的全部下载授权")
@DeleteMapping
public Result<Integer> clear(@RequestParam(required = false) String deviceId,
HttpServletRequest request) {
@@ -9,6 +9,8 @@ import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.playback.service.PlaybackService;
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 lombok.RequiredArgsConstructor;
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.RestController;
@Tag(name = "客户端-短剧与播放")
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
@@ -25,6 +28,7 @@ public class AppDramaController {
private final PlaybackService playbackService;
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
@Operation(summary = "短剧详情(含剧集列表,进详情页一次请求即可渲染)")
@GetMapping("/dramas/{id}")
public Result<AppDramaDetailVO> detail(@PathVariable Long id) {
return Result.ok(appCatalogService.dramaDetail(id));
@@ -34,6 +38,7 @@ public class AppDramaController {
* 播放票据:服务端判定权益后签发短时效播放地址。
* 未登录也能播免费集;付费集未解锁时只返回需要多少金币。
*/
@Operation(summary = "播放票据:判定权益后签发短时效播放地址")
@GetMapping("/episodes/{id}/play")
public Result<PlayRespVO> play(@PathVariable Long id, HttpServletRequest request) {
return Result.ok(playbackService.play(
@@ -8,6 +8,8 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.FavoriteService;
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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 收藏。 */
@Tag(name = "客户端-收藏")
@RestController
@RequestMapping("/app/favorites")
@RequiredArgsConstructor
@@ -30,18 +33,21 @@ public class AppFavoriteController {
private final AppCatalogService appCatalogService;
/** 切换收藏,返回切换后的状态:true 为已收藏。 */
@Operation(summary = "切换收藏(返回切换后的状态)")
@PostMapping("/toggle")
public Result<Boolean> toggle(@Valid @RequestBody FavoriteToggleReqVO req) {
return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId()));
}
/** 收藏列表,卡片结构与首页一致。 */
@Operation(summary = "我的收藏列表")
@GetMapping
public Result<List<AppDramaCardVO>> list() {
return Result.ok(appCatalogService.toCards(
favoriteService.listDramas(SecurityUtils.getAppUserId())));
}
@Operation(summary = "取消收藏")
@DeleteMapping("/{dramaId}")
public Result<Void> remove(@PathVariable Long 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.LikeToggleRespVO;
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 lombok.RequiredArgsConstructor;
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;
/** 点赞与举报。 */
@Tag(name = "客户端-点赞与举报")
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
@@ -26,12 +29,14 @@ public class AppInteractionController {
private final ReportService reportService;
/** 点赞/取消点赞,返回切换后的状态与最新计数。 */
@Operation(summary = "点赞 / 取消点赞")
@PostMapping("/likes/toggle")
public Result<LikeToggleRespVO> toggleLike(@Valid @RequestBody LikeToggleReqVO req) {
return Result.ok(likeService.toggle(SecurityUtils.getAppUserId(), req));
}
/** 提交举报:同一用户对同一目标只保留一条待处理工单。 */
@Operation(summary = "提交举报(同一目标只保留一条待处理工单)")
@PostMapping("/reports")
public Result<Long> report(@Valid @RequestBody ReportCreateReqVO 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.DeactivateReqVO;
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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
@@ -23,6 +25,7 @@ import java.util.List;
import java.util.Map;
/** 客户端个人中心:资料、设备、注销、数据导出。 */
@Tag(name = "客户端-个人中心")
@RestController
@RequestMapping("/app/me")
@RequiredArgsConstructor
@@ -30,33 +33,39 @@ public class AppMeController {
private final AppUserService userService;
@Operation(summary = "我的资料")
@GetMapping
public Result<AppUserRespVO> me() {
return Result.ok(userService.me(SecurityUtils.getAppUserId()));
}
@Operation(summary = "修改资料(昵称 / 头像 / 语言等)")
@PutMapping
public Result<AppUserRespVO> update(@Valid @RequestBody ProfileUpdateReqVO req) {
return Result.ok(userService.updateProfile(SecurityUtils.getAppUserId(), req));
}
@Operation(summary = "我的登录设备列表")
@GetMapping("/devices")
public Result<List<AppUserDevice>> devices() {
return Result.ok(userService.devices(SecurityUtils.getAppUserId()));
}
/** 商店与隐私法规要求的数据导出,返回账号侧数据;内容侧数据由客户端自行下载。 */
@Operation(summary = "数据导出(合规要求)")
@GetMapping("/export")
public Result<Map<String, Object>> export() {
return Result.ok(userService.exportAccount(SecurityUtils.getAppUserId()));
}
/** 申请注销:立即登出全部设备,进入冷静期,期间可撤销。 */
@Operation(summary = "申请注销(进入冷静期并登出全部设备)")
@PostMapping("/deactivate")
public Result<LocalDateTime> deactivate(@RequestBody(required = false) DeactivateReqVO req) {
return Result.ok(userService.requestDeactivation(SecurityUtils.getAppUserId(), req));
}
@Operation(summary = "撤销注销申请")
@PostMapping("/deactivate/cancel")
public Result<Void> cancelDeactivate() {
userService.cancelDeactivation(SecurityUtils.getAppUserId());
@@ -7,6 +7,8 @@ import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.playback.vo.ProgressRespVO;
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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 观看进度与观看记录。 */
@Tag(name = "客户端-观看进度")
@RestController
@RequestMapping("/app/watch")
@RequiredArgsConstructor
@@ -29,28 +32,33 @@ public class AppWatchController {
private final WatchProgressService progressService;
/** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */
@Operation(summary = "上报观看进度(客户端每 10 秒一次)")
@PostMapping("/progress")
public Result<ProgressRespVO> save(@Valid @RequestBody ProgressSaveReqVO req) {
return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req));
}
/** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */
@Operation(summary = "某部剧的续看位置")
@GetMapping("/progress")
public Result<ProgressRespVO> ofDrama(@RequestParam Long dramaId) {
return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId));
}
@Operation(summary = "最近观看列表")
@GetMapping("/recent")
public Result<List<ProgressRespVO>> recent(@RequestParam(defaultValue = "20") int limit) {
return Result.ok(progressService.recent(SecurityUtils.getAppUserId(), limit));
}
@Operation(summary = "删除某部剧的观看记录")
@DeleteMapping("/progress/{dramaId}")
public Result<Void> remove(@PathVariable Long dramaId) {
progressService.remove(SecurityUtils.getAppUserId(), dramaId);
return Result.ok();
}
@Operation(summary = "清空观看记录")
@DeleteMapping("/progress")
public Result<Void> clear() {
progressService.clear(SecurityUtils.getAppUserId());
@@ -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
datasource:
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}
password: ${DB_PASSWORD:fruitshorts}
jackson:
@@ -23,11 +25,17 @@ mybatis-plus:
global-config:
banner: false
db-config:
id-type: auto
# 主键由应用侧雪花算法生成(S0),数据库不再自增
id-type: assign_id
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
sequence:
# 单机开发用固定值;上多实例前必须改成按实例分配(环境变量注入),否则会撞 ID
worker-id: 1
datacenter-id: 1
# 接口文档:/doc.html 打开 Swagger UI,/v3/api-docs 是机器可读契约(前端联调按它写)
springdoc:
swagger-ui:
path: /doc.html
@@ -47,16 +55,36 @@ fruitshorts:
license-days: 30
wifi-only-default: true
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:
root-dir: ./data/uploads
# 媒体根目录:容器里指向挂载卷;本地默认 ./data/uploads
root-dir: ${MEDIA_ROOT_DIR:./data/uploads}
public-prefix: /files
chunk-size: 5242880
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:
# 双密钥:管理端与客户端物理隔离,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-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:
allowed-origins: "*"
allowed-origins: ${CORS_ALLOWED_ORIGINS:*}
logging:
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,114 @@
// 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.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,177 @@
// 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.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),
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.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),
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,96 @@
// 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))
.andExpect(jsonPath("$.data.episodeId").value(11))
.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,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,43 @@
// Author: fruit.com.cn
package com.fruitshorts.app.support;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
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())
.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() + " 位)");
}
}
}
@@ -48,6 +48,8 @@ public class SecurityConfig {
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
/* 健康检查:给容器编排与冒烟脚本用(本项目没引 actuator,见 HealthController 注释) */
"/healthz",
"/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);
}
}
+6
View File
@@ -34,5 +34,11 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-playback</artifactId>
</dependency>
<!-- 票据校验与订单交付的单测用:JUnit5 + Mockito,test scope,不进运行态依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</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")
public class CoinLedger {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;
/** 正数入账、负数出账 */
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("entitlement")
public class Entitlement {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;
private Long dramaId;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("order_event")
public class OrderEvent {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String orderNo;
private String event;
@@ -18,7 +18,7 @@ import java.time.LocalDateTime;
@TableName("orders")
public class Orders {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String orderNo;
private Long userId;
@@ -18,7 +18,7 @@ import java.time.LocalDateTime;
@TableName("product")
public class Product {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String code;
private String name;
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("reconcile_task")
public class ReconcileTask {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDate bizDate;
private String platform;
@@ -24,6 +24,10 @@ public class ReconcileTask {
private Integer matchedCount;
private Integer diffCount;
private BigDecimal amountSum;
/** 商店侧结算金额;**无数据源时为 null**(绝不用 0 冒充)。 */
private BigDecimal storeAmountSum;
/** 对账说明:缺哪些数据源、差异构成、本次退款回收情况。 */
private String notes;
/** RUNNING/SUCCESS/FAILED */
private String status;
private String detail;
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("store_receipt")
public class StoreReceipt {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String platform;
private String transactionId;
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("wallet")
public class Wallet {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;
private Long coinBalance;
@@ -4,17 +4,23 @@ package com.fruitshorts.commerce.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* 未接入商店时的默认实现:明确报"未配置",而不是静默放行或假装成功。
* 这样客户端拿到的是可理解的业务提示,也不会产生假订单。
*
* <p>由 `fruitshorts.commerce.store.verifier` 选择:缺省(或写成 disabled)时用本实现,
* 保证没有凭据的环境下对外行为与第一阶段一致。
*/
@Component
@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "disabled",
matchIfMissing = true)
public class DisabledStoreVerifier implements StoreVerifier {
@Override
public StoreReceiptResult verify(String platform, String receipt) {
public StoreReceiptResult verify(String platform, String productId, String receipt) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"Apple / Google 商店校验尚未配置,第一阶段仅预留接口");
}
@@ -7,20 +7,30 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.commerce.dal.entity.OrderEvent;
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.OrdersMapper;
import com.fruitshorts.commerce.dal.mapper.ProductMapper;
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
import com.fruitshorts.commerce.service.store.StorePlatforms;
import com.fruitshorts.commerce.vo.OrderQueryReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* 订单:检索、补发、退款回收。
@@ -39,8 +49,13 @@ public class OrderService {
public static final String STATUS_REFUNDED = "REFUNDED";
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 OrderEventMapper orderEventMapper;
private final ProductMapper productMapper;
private final StoreReceiptMapper storeReceiptMapper;
private final StoreVerifier storeVerifier;
private final WalletService walletService;
private final EntitlementService entitlementService;
@@ -80,24 +95,173 @@ 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)
public Orders verifyAndDeliver(Long userId, String platform, String receipt) {
StoreVerifier.StoreReceiptResult result = storeVerifier.verify(platform, receipt);
public Orders verifyAndDeliver(Long userId, String platform, String productId, String 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()) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, result.message());
}
Orders order = ordersMapper.selectOne(Wrappers.<Orders>lambdaQuery()
.eq(Orders::getPlatform, platform)
.eq(Orders::getStoreTransactionId, result.transactionId())
.last("limit 1"));
if (order == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "找不到对应的订单");
Orders existing = findOrderByStoreTransaction(store, result.transactionId());
if (existing != null) {
requireSameUser(existing, userId);
if (STATUS_DELIVERED.equals(existing.getStatus())) {
// 已交付过:直接返回既有订单,连钱包幂等调用都不再发一次
return existing;
}
return deliver(existing, "STORE", null, "票据重复提交,返回既有订单");
}
return deliver(order, "STORE", null, "票据校验通过");
Product product = findProduct(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, "该票据已绑定其他账号,不能重复使用");
}
}
/** 商品按 `(平台, 商店商品 id)` 找;`platform=ALL` 的通用商品也认。 */
private Product findProduct(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(platform, "ALL"))
.eq(Product::getStatus, 1));
return products.isEmpty() ? null : products.get(0);
}
/** 从校验结果建单:状态先记 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 +301,38 @@ public class OrderService {
if (STATUS_REFUNDED.equals(order.getStatus())) {
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())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"只有已支付或已交付的订单可以退款,当前状态:" + order.getStatus());
}
String orderNo = order.getOrderNo();
String from = order.getStatus();
int revoked = entitlementService.revokeByOrderNo(orderNo, reason);
String clawback = "未涉及金币";
@@ -159,7 +351,7 @@ public class OrderService {
order.setRefundAt(LocalDateTime.now());
order.setRefundReason(reason == null ? "" : reason);
ordersMapper.updateById(order);
recordEvent(order, "REFUND", from, STATUS_REFUNDED, "ADMIN", operatorId,
recordEvent(order, "REFUND", from, STATUS_REFUNDED, source, operatorId,
"回收权益 " + revoked + " 条;" + clawback);
return order;
}
@@ -12,26 +12,35 @@ import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.dal.mapper.ReconcileTaskMapper;
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 对账:把本地订单与商店票据比对,差异进人工处理队列。
* 对账:把本地订单与商店侧回执比对,差异进人工处理队列,并顺带执行退款回收。
*
* 第一阶段商店未接入,票据表是空的,所以差异必然等于订单数 ——
* 这正是要暴露的:接入商店后这个数字必须归零,否则就是在错账。
* <p>口径(与 `docs/api-phase1.md` §2.6 一致,改口径必须同步契约):
* <ul>
* <li>`totalCount` = 当期本地有效订单数(排除 CREATED/CLOSED);`matchedCount` = 其中能在票据表找到对应交易的笔数;
* `diffCount` = 本地缺票据 + 票据无本地单 + 商店已作废但本地未回收;`amountSum` = 本地金额合计(本地口径);</li>
* <li>`storeAmountSum` = 商店侧结算金额。**Developer API 不提供**(要向 Play Console 财务报告要),
* 所以**恒为 null**,并把缺什么写进 `notes` —— 契约强制"取不到就用 null,不许用 0 冒充";</li>
* <li>商店侧调用失败 → `status=FAILED` + `notes` 写明原因,**不写 SUCCESS 加一堆 0**;</li>
* <li>未配置商店(默认 verifier=disabled)→ 商店相关指标为 null,notes 注明"仅有本地一致性核对"。</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ReconcileService {
public static final String STATUS_SUCCESS = "SUCCESS";
@@ -40,11 +49,22 @@ public class ReconcileService {
private final ReconcileTaskMapper reconcileTaskMapper;
private final OrdersMapper ordersMapper;
private final StoreReceiptMapper storeReceiptMapper;
/** 只有配了 Google 才有退款回收能力;用 ObjectProvider 让两种配置都能启动。 */
private final ObjectProvider<StoreRefundService> storeRefundServiceProvider;
public ReconcileService(ReconcileTaskMapper reconcileTaskMapper, OrdersMapper ordersMapper,
StoreReceiptMapper storeReceiptMapper,
ObjectProvider<StoreRefundService> storeRefundServiceProvider) {
this.reconcileTaskMapper = reconcileTaskMapper;
this.ordersMapper = ordersMapper;
this.storeReceiptMapper = storeReceiptMapper;
this.storeRefundServiceProvider = storeRefundServiceProvider;
}
@Transactional(rollbackFor = Exception.class)
public ReconcileTask run(LocalDate bizDate, String platform) {
LocalDate date = bizDate == null ? LocalDate.now().minusDays(1) : bizDate;
String scope = platform == null || platform.isBlank() ? "ALL" : platform;
String scope = StringUtils.hasText(platform) ? platform.trim().toUpperCase() : "ALL";
LocalDateTime from = date.atStartOfDay();
LocalDateTime to = date.plusDays(1).atStartOfDay();
@@ -57,34 +77,101 @@ public class ReconcileService {
.lt(StoreReceipt::getCreatedAt, to)
.eq(!"ALL".equals(scope), StoreReceipt::getPlatform, scope));
int effectiveCount = 0;
BigDecimal amountSum = BigDecimal.ZERO;
int paidCount = 0;
Set<String> orderTxns = new HashSet<>();
for (Orders order : orders) {
if (order.getAmount() != null && !OrderService.STATUS_CREATED.equals(order.getStatus())
&& !OrderService.STATUS_CLOSED.equals(order.getStatus())) {
boolean effective = !OrderService.STATUS_CREATED.equals(order.getStatus())
&& !OrderService.STATUS_CLOSED.equals(order.getStatus());
if (!effective) {
continue;
}
effectiveCount++;
if (order.getAmount() != null) {
amountSum = amountSum.add(order.getAmount());
paidCount++;
}
if (StringUtils.hasText(order.getStoreTransactionId())) {
orderTxns.add(order.getStoreTransactionId());
}
}
int diff = Math.abs(paidCount - receipts.size());
Set<String> receiptTxns = new HashSet<>();
for (StoreReceipt receipt : receipts) {
if (StringUtils.hasText(receipt.getTransactionId())) {
receiptTxns.add(receipt.getTransactionId());
}
}
Set<String> matched = new HashSet<>(orderTxns);
matched.retainAll(receiptTxns);
Set<String> orderOnly = new HashSet<>(orderTxns);
orderOnly.removeAll(receiptTxns);
Set<String> receiptOnly = new HashSet<>(receiptTxns);
receiptOnly.removeAll(orderTxns);
// 商店侧:拉已作废交易并回收权益(未配置商店时为 null,绝不写 0 冒充)
StoreRefundService refundService = storeRefundServiceProvider.getIfAvailable();
StoreRefundService.RecoverResult recover = null;
boolean storeConfigured = refundService != null;
String failure = "";
if (storeConfigured) {
try {
recover = refundService.recoverVoidedPurchases(scope, date);
} catch (Exception ex) {
failure = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
log.error("对账期间拉取商店退款失败:{}", failure);
}
}
int unmatchedVoided = recover == null ? 0 : Math.max(0, recover.unmatched());
int diff = orderOnly.size() + receiptOnly.size() + unmatchedVoided;
ReconcileTask task = new ReconcileTask();
task.setBizDate(date);
task.setPlatform(scope);
task.setTotalCount(paidCount);
task.setMatchedCount(Math.min(paidCount, receipts.size()));
task.setTotalCount(effectiveCount);
task.setMatchedCount(matched.size());
task.setDiffCount(diff);
task.setAmountSum(amountSum);
task.setStatus(STATUS_SUCCESS);
task.setDetail("本地有效订单 " + paidCount + " 笔 / 商店票据 " + receipts.size()
+ " 张,差异 " + diff + " 笔。"
+ (diff > 0 ? "商店尚未接入时差异属预期,接入后必须归零。" : ""));
// 商店结算金额要向 Play Console 财务报告取,Developer API 不提供 → null + notes
task.setStoreAmountSum(null);
task.setStatus(StringUtils.hasText(failure) ? STATUS_FAILED : STATUS_SUCCESS);
task.setDetail("本地有效订单 " + effectiveCount + " 笔、金额 " + amountSum
+ ";票据 " + receipts.size() + " 张;一致 " + matched.size() + " 笔;差异 " + diff + " 笔");
task.setNotes(buildNotes(storeConfigured, recover, orderOnly.size(), receiptOnly.size(),
unmatchedVoided, failure));
task.setStartedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
reconcileTaskMapper.insert(task);
log.info("对账完成 {} {}:订单 {} 票据 {} 差异 {}", scope, date, paidCount, receipts.size(), diff);
log.info("对账完成 {} {}:订单 {} 票据 {} 一致 {} 差异 {} 状态 {}",
scope, date, effectiveCount, receipts.size(), matched.size(), diff, task.getStatus());
return task;
}
/** 把"缺什么数据源"写清楚,这是契约要求的 notes(不是可选装饰)。 */
private String buildNotes(boolean storeConfigured, StoreRefundService.RecoverResult recover,
int orderOnly, int receiptOnly, int unmatchedVoided, String failure) {
StringBuilder notes = new StringBuilder();
notes.append("差异构成:本地缺票据 ").append(orderOnly)
.append(" 笔、票据无本地订单 ").append(receiptOnly).append(" 笔");
if (unmatchedVoided > 0) {
notes.append("、商店已作废但本地无单 ").append(unmatchedVoided).append(" 笔");
}
notes.append("。");
if (!storeConfigured) {
notes.append("商店侧数据源未配置(store.verifier=disabled),本次只做本地票据一致性核对;")
.append("商店结算金额与退款列表均无数据源。");
} else if (StringUtils.hasText(failure)) {
notes.append("商店侧调用失败:").append(failure).append(";本次商店相关指标不可信,请重跑。");
} else if (recover == null) {
notes.append("商店凭据不完整(缺 packageName),未拉取退款列表。");
} else {
notes.append("商店侧已作废交易 ").append(recover.voidedTotal()).append(" 笔:回收 ")
.append(recover.recovered()).append(" 笔、已退款跳过 ")
.append(recover.skipped()).append(" 笔。");
}
notes.append("商店结算金额/手续费/税需从 Play Console 财务报告(或 Cloud Storage 报表)获取,")
.append("Developer API 不提供,故 storeAmountSum 为 null。");
return notes.toString();
}
/** 每天 04:20 自动对前一天,避免人工忘记。 */
@Scheduled(cron = "${fruitshorts.commerce.reconcile-cron:0 20 4 * * *}")
public void scheduled() {
@@ -0,0 +1,119 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.commerce.config.StoreProperties;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.service.store.GooglePlayPurchaseClient;
import com.fruitshorts.commerce.service.store.StorePlatforms;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
/**
* 商店退款 / 拒付回收。
*
* <p>由对账(每日 04:20 定时 + 后台手动 run)驱动:拉取 Google 的"已作废交易",
* 按 `(platform, orderId)` 找回本地订单并**幂等回收**权益与金币。
*
* <p>口径说明(写进回执与契约文档):
* <ul>
* <li>一次性商品的作废是**整笔**,Google 不返回部分金额 → 本轮按整单回收;</li>
* <li>找不到本地订单的作废交易属于**差异**,会计入对账的 diffCount,不静默丢掉;</li>
* <li>未配置 Google 凭据时**不调用商店**,返回 null 交由对账写 notes 说明"缺数据源"。</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "google-play")
public class StoreRefundService {
private final StoreProperties properties;
private final GooglePlayPurchaseClient purchaseClient;
private final OrdersMapper ordersMapper;
private final OrderService orderService;
/** 回收结果:回收了几笔、跳过几笔(已退款)、几笔在本地找不到(差异)。 */
public record RecoverResult(int recovered, int skipped, int unmatched, int voidedTotal) {
public static RecoverResult unavailable() {
return new RecoverResult(-1, -1, -1, -1);
}
}
/**
* 拉取并回收指定日期(本地时区整天)的已作废交易。
*
* @return null 表示"没有可用数据源"(未配置凭据),调用方必须用 null + notes,不能当 0
*/
public RecoverResult recoverVoidedPurchases(String platform, LocalDate bizDate) {
if (!StorePlatforms.isGoogle(platform) && !"ALL".equalsIgnoreCase(platform)) {
// Apple 等其它渠道本阶段没有退款数据源
return null;
}
if (!StringUtils.hasText(properties.getGooglePlayPackageName())) {
log.info("未配置 Google Play packageName,跳过退款回收");
return null;
}
ZoneId zone = ZoneId.systemDefault();
long start = bizDate.atStartOfDay(zone).toInstant().toEpochMilli();
long end = bizDate.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - 1;
List<GooglePlayPurchaseClient.VoidedPurchase> voided =
purchaseClient.listVoidedPurchases(properties.getGooglePlayPackageName(), start, end);
int recovered = 0;
int skipped = 0;
int unmatched = 0;
for (GooglePlayPurchaseClient.VoidedPurchase item : voided) {
Orders order = findOrder(item.orderId());
if (order == null) {
unmatched++;
log.warn("商店已作废但本地没有对应订单:orderId={}", item.orderId());
continue;
}
String before = order.getStatus();
Orders after = orderService.refundByStoreCallback(order.getOrderNo(), reasonOf(item));
if (before.equals(after.getStatus())) {
skipped++;
} else {
recovered++;
}
}
log.info("退款回收 {} {}:作废 {} 笔,回收 {} 笔,已退款跳过 {} 笔,本地无单 {} 笔",
platform, bizDate, voided.size(), recovered, skipped, unmatched);
return new RecoverResult(recovered, skipped, unmatched, voided.size());
}
private Orders findOrder(String storeTransactionId) {
if (!StringUtils.hasText(storeTransactionId)) {
return null;
}
return ordersMapper.selectOne(Wrappers.<Orders>lambdaQuery()
.eq(Orders::getPlatform, StorePlatforms.GOOGLE)
.eq(Orders::getStoreTransactionId, storeTransactionId)
.last("limit 1"));
}
/** 把 Google 的原因码翻成可读的中文,写进订单的退款原因与流水。 */
private String reasonOf(GooglePlayPurchaseClient.VoidedPurchase item) {
String source = switch (item.voidedSource()) {
case 0 -> "用户退款";
case 1 -> "系统作废";
case 2 -> "开发者撤销";
default -> "商店作废";
};
LocalDateTime at = item.voidedTime();
return source + "(Google reason=" + item.voidedReason() + ",时间="
+ (at == null ? "未知" : at.toString()) + ")";
}
}
@@ -2,22 +2,39 @@
package com.fruitshorts.commerce.service;
import java.time.LocalDateTime;
/**
* 商店票据校验端口。
*
* 第一阶段**只预留端口,不接真实 Apple / Google 商店**:接入时新增一个实现
* (Apple App Store Server API / Google Play Developer API)并替换 DisabledStoreVerifier 即可,
* 订单与权益链路不需要改动。
* <p>实现由 `fruitshorts.commerce.store.verifier` 配置选择:默认 {@code disabled}(明确报"未配置"),
* 配成 {@code google-play} 时走 Google Play Developer API。订单与权益链路不感知具体商店。
*/
public interface StoreVerifier {
StoreReceiptResult verify(String platform, String receipt);
/**
* 校验票据。
*
* @param platform 商店平台,取值见 {@link StorePlatforms}
* @param productId 商店商品 id(Google Play 的 {@code purchases.products.get} 必需)
* @param receipt 购买凭证:Google Play 是 purchaseToken
*/
StoreReceiptResult verify(String platform, String productId, String receipt);
/** 校验结果。ok=false 时 message 说明原因,用于人工排查。 */
/**
* 校验结果。ok=false 时 message 说明原因,用于人工排查,也可直接下发给客户端。
*
* @param transactionId 商店交易号:Google Play 是 orderId,作为本地幂等键之一
* @param amountMicros 金额(微单位,Google Play 原样返回);null 表示商店未给
* @param purchaseTime 购买时间;null 表示商店未给
*/
record StoreReceiptResult(boolean ok, String transactionId, String originalTransactionId,
String productId, String message) {
String productId, Long amountMicros, String currency,
LocalDateTime purchaseTime, String message) {
/** 校验失败:只带原因,不带任何商品信息,避免调用方拿半截数据建单。 */
public static StoreReceiptResult failed(String message) {
return new StoreReceiptResult(false, "", "", "", message);
return new StoreReceiptResult(false, "", "", "", null, "", null, message);
}
}
}
@@ -0,0 +1,70 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import java.time.LocalDateTime;
import java.util.List;
/**
* Google Play Developer API 的取数端口。
*
* <p>抽成端口是为了可测:单测用替身直接喂"Google 的返回",不必联网、也不需要真实服务账号。
* 生产实现见 {@link HttpGooglePlayPurchaseClient}。
*/
public interface GooglePlayPurchaseClient {
/**
* 查询一次性商品(金币包)的购买状态。
*
* @return Google 返回的商品购买信息;凭证不存在/已失效时实现方抛异常
*/
ProductPurchase fetchProductPurchase(String packageName, String productId, String purchaseToken);
/**
* 拉取"已作废(退款/撤销/拒付)"的交易,用于退款回收与对账。
*
* <p>对应 Google 的 `purchases.voidedpurchases.list`:一次性商品的作废是**整笔**,
* 拿不到部分金额,所以本层只表达"这笔作废了"。
*
* @param startMillis 起始时间(含),epoch 毫秒
* @param endMillis 结束时间(含),epoch 毫秒
*/
List<VoidedPurchase> listVoidedPurchases(String packageName, long startMillis, long endMillis);
/**
* Google `purchases.voidedpurchases.list` 的单条记录。
*
* @param voidedSource 0=用户主动退款,1=系统/风控,2=开发者撤销
* @param voidedReason 具体原因码(Google 定义,原样保留便于排查)
*/
record VoidedPurchase(String orderId, String purchaseToken, long voidedTimeMillis,
int voidedSource, int voidedReason, String purchaseType) {
/** 作废时间(本地时区);时间戳为 0 时返回 null。 */
public LocalDateTime voidedTime() {
return voidedTimeMillis <= 0
? null
: LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(voidedTimeMillis),
java.time.ZoneId.systemDefault());
}
}
/**
* Google `purchases.products.get` 的返回里我们需要的字段。
*
* @param purchaseState 0=已购买,1=已取消,2=待处理
* @param consumptionState 0=未消费,1=已消费
*/
record ProductPurchase(String orderId, String productId, int purchaseState, int consumptionState,
long purchaseTimeMillis, Long priceAmountMicros, String priceCurrencyCode,
String purchaseType) {
/** 购买时间(本地时区);时间戳为 0 时返回 null,交给上层决定怎么处理。 */
public LocalDateTime purchaseTime() {
return purchaseTimeMillis <= 0
? null
: LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(purchaseTimeMillis),
java.time.ZoneId.systemDefault());
}
}
}
@@ -0,0 +1,74 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import com.fruitshorts.commerce.config.StoreProperties;
import com.fruitshorts.commerce.service.StoreVerifier;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* Google Play 票据校验实现。
*
* <p>只有 `fruitshorts.commerce.store.verifier=google-play` 时才装配(默认 disabled,保持原有对外行为)。
* 校验口径:
* <ul>
* <li>platform 必须是 Google(`GOOGLE` / `GOOGLE_PLAY` 都会归一化);</li>
* <li>Google 返回 `purchaseState == 0`(0=已购买,1=已取消,2=待处理);</li>
* <li>返回的 `productId` 必须与请求一致 —— 防止"拿 A 商品的票据冒充 B 商品"。</li>
* </ul>
* 结果里的 `transactionId` 用 Google 的 `orderId`,它是本地幂等键 `(platform, transactionId)` 的一半。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "google-play")
public class GooglePlayStoreVerifier implements StoreVerifier {
/** Google 的 purchaseState:0=已购买。 */
private static final int STATE_PURCHASED = 0;
private final StoreProperties properties;
private final GooglePlayPurchaseClient purchaseClient;
@Override
public StoreReceiptResult verify(String platform, String productId, String receipt) {
if (!StorePlatforms.isGoogle(platform)) {
String hint = StorePlatforms.APPLE.equals(StorePlatforms.normalize(platform))
? "Apple IAP 尚未实现(印尼 iOS 占比低,契约已预留端口,B2 再实现)"
: "当前校验实现只支持 Google Play,收到的平台是:" + platform;
return StoreReceiptResult.failed(hint);
}
String packageName = properties.getGooglePlayPackageName();
if (!StringUtils.hasText(packageName)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"未配置 Google Play packageName(fruitshorts.commerce.store.google-play-package-name)");
}
if (!StringUtils.hasText(productId) || !StringUtils.hasText(receipt)) {
return StoreReceiptResult.failed("缺少 productId 或 purchaseToken");
}
GooglePlayPurchaseClient.ProductPurchase purchase =
purchaseClient.fetchProductPurchase(packageName, productId, receipt);
if (purchase.purchaseState() != STATE_PURCHASED) {
return StoreReceiptResult.failed("Google Play 购买状态不是已购买(purchaseState="
+ purchase.purchaseState() + ")");
}
if (StringUtils.hasText(purchase.productId()) && !productId.equals(purchase.productId())) {
log.warn("票据商品与请求商品不一致:请求={}, Google={}", productId, purchase.productId());
return StoreReceiptResult.failed("票据对应的商品与请求不一致");
}
if (!StringUtils.hasText(purchase.orderId())) {
// 没有 orderId 就没有幂等键,宁可拒绝也不能建单(否则重复提交必然重复发货)
return StoreReceiptResult.failed("Google Play 未返回 orderId,无法作为幂等键");
}
return new StoreReceiptResult(true, purchase.orderId(), "", purchase.productId(),
purchase.priceAmountMicros(), purchase.priceCurrencyCode(), purchase.purchaseTime(),
"Google Play 校验通过");
}
}
@@ -0,0 +1,250 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.commerce.config.StoreProperties;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
* 用 JDK 自带 {@link HttpClient} 调 Google Play Developer API,**不引入 Google SDK 依赖**。
*
* <p>两步走:
* <ol>
* <li>服务账号私钥签 RS256 的 JWT,向 {@code oauth2.googleapis.com/token} 换 access_token;</li>
* <li>`GET .../purchases/products/{productId}/tokens/{token}` 查购买状态。</li>
* </ol>
* token 按过期时间缓存,避免每笔校验都换一次。
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "google-play")
public class HttpGooglePlayPurchaseClient implements GooglePlayPurchaseClient {
private static final String TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
private static final String PURCHASE_ENDPOINT =
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/%s/purchases/products/%s/tokens/%s";
private static final String VOIDED_ENDPOINT =
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/%s/purchases/voidedpurchases";
private static final String SCOPE = "https://www.googleapis.com/auth/androidpublisher";
private static final long TOKEN_SKEW_SECONDS = 60;
/** 单页最多 1000 条(Google 上限),超过就翻页,避免只对了一半还以为对完了。 */
private static final int VOIDED_PAGE_SIZE = 1000;
private final StoreProperties properties;
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient;
private String cachedToken = "";
private Instant cachedTokenExpireAt = Instant.EPOCH;
public HttpGooglePlayPurchaseClient(StoreProperties properties) {
this.properties = properties;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(properties.getGooglePlayTimeoutSeconds()))
.build();
}
@Override
public List<VoidedPurchase> listVoidedPurchases(String packageName, long startMillis,
long endMillis) {
List<VoidedPurchase> result = new ArrayList<>();
String pageToken = "";
do {
StringBuilder url = new StringBuilder(String.format(VOIDED_ENDPOINT, encode(packageName)))
.append("?startTime=").append(startMillis)
.append("&endTime=").append(endMillis)
.append("&maxResults=").append(VOIDED_PAGE_SIZE);
if (StringUtils.hasText(pageToken)) {
url.append("&token=").append(encode(pageToken));
}
HttpRequest request = HttpRequest.newBuilder(URI.create(url.toString()))
.timeout(Duration.ofSeconds(properties.getGooglePlayTimeoutSeconds()))
.header("Authorization", "Bearer " + accessToken())
.header("Accept", "application/json")
.GET()
.build();
JsonNode body = send(request, "查询 Google Play 已作废交易失败");
for (JsonNode item : body.path("voidedPurchases")) {
result.add(new VoidedPurchase(
text(item, "orderId"),
text(item, "purchaseToken"),
item.path("voidedTimeMillis").asLong(0L),
item.path("voidedSource").asInt(-1),
item.path("voidedReason").asInt(-1),
text(item, "purchaseType")));
}
pageToken = body.path("tokenPagination").path("nextPageToken").asText("");
} while (StringUtils.hasText(pageToken));
return result;
}
@Override
public ProductPurchase fetchProductPurchase(String packageName, String productId,
String purchaseToken) {
String url = String.format(PURCHASE_ENDPOINT, encode(packageName), encode(productId),
encode(purchaseToken));
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(properties.getGooglePlayTimeoutSeconds()))
.header("Authorization", "Bearer " + accessToken())
.header("Accept", "application/json")
.GET()
.build();
JsonNode body = send(request, "查询 Google Play 购买状态失败");
return new ProductPurchase(
text(body, "orderId"),
text(body, "productId"),
body.path("purchaseState").asInt(-1),
body.path("consumptionState").asInt(-1),
body.path("purchaseTimeMillis").asLong(0L),
body.hasNonNull("priceAmountMicros") ? body.path("priceAmountMicros").asLong() : null,
text(body, "priceCurrencyCode"),
text(body, "purchaseType"));
}
/** 拿 access_token:未过期就用缓存,否则用服务账号重新换一次。 */
private synchronized String accessToken() {
if (StringUtils.hasText(cachedToken) && Instant.now().isBefore(cachedTokenExpireAt)) {
return cachedToken;
}
JsonNode account = serviceAccount();
String clientEmail = account.path("client_email").asText("");
String privateKeyPem = account.path("private_key").asText("");
if (!StringUtils.hasText(clientEmail) || !StringUtils.hasText(privateKeyPem)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"Google Play 服务账号 JSON 缺少 client_email 或 private_key");
}
long now = Instant.now().getEpochSecond();
String assertion = jwt(clientEmail, now, now + 3600, privateKeyPem);
String form = "grant_type=" + encode("urn:ietf:params:oauth:grant-type:jwt-bearer")
+ "&assertion=" + encode(assertion);
HttpRequest request = HttpRequest.newBuilder(URI.create(TOKEN_ENDPOINT))
.timeout(Duration.ofSeconds(properties.getGooglePlayTimeoutSeconds()))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
JsonNode body = send(request, "换取 Google access_token 失败");
cachedToken = body.path("access_token").asText("");
long expiresIn = body.path("expires_in").asLong(3600L);
cachedTokenExpireAt = Instant.now().plusSeconds(Math.max(60L, expiresIn - TOKEN_SKEW_SECONDS));
if (!StringUtils.hasText(cachedToken)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google 未返回 access_token");
}
return cachedToken;
}
/** 服务账号 JSON 支持三种写法:文件路径 / JSON 内容 / base64 内容。 */
private JsonNode serviceAccount() {
String raw = properties.getGooglePlayServiceAccountJson();
if (!StringUtils.hasText(raw)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"未配置 Google Play 服务账号(fruitshorts.commerce.store.google-play-service-account-json)");
}
String json = raw.trim();
if (!json.startsWith("{")) {
Path path = Path.of(json);
if (Files.isReadable(path)) {
json = readFile(path);
} else {
json = new String(Base64.getDecoder().decode(json), StandardCharsets.UTF_8);
}
}
try {
return objectMapper.readTree(json);
} catch (IOException ex) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google Play 服务账号 JSON 解析失败");
}
}
private String readFile(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException ex) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "服务账号文件不可读:" + path);
}
}
/** RS256 签名的 service-account JWT。 */
private String jwt(String clientEmail, long issuedAt, long expiresAt, String privateKeyPem) {
String header = base64Url("{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
String claims = base64Url("{\"iss\":\"" + clientEmail + "\",\"scope\":\"" + SCOPE
+ "\",\"aud\":\"" + TOKEN_ENDPOINT + "\",\"iat\":" + issuedAt + ",\"exp\":" + expiresAt + "}");
String signingInput = header + "." + claims;
try {
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey(privateKeyPem));
signature.update(signingInput.getBytes(StandardCharsets.UTF_8));
return signingInput + "." + Base64.getUrlEncoder().withoutPadding()
.encodeToString(signature.sign());
} catch (Exception ex) {
log.warn("服务账号私钥签名失败:{}", ex.getMessage());
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "服务账号私钥不可用");
}
}
private PrivateKey privateKey(String pem) throws Exception {
String base64 = pem.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(base64);
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der));
}
private JsonNode send(HttpRequest request, String errorPrefix) {
try {
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
log.warn("{}:HTTP {} {}", errorPrefix, response.statusCode(), response.body());
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
errorPrefix + "(HTTP " + response.statusCode() + ")");
}
return objectMapper.readTree(response.body());
} catch (ServiceException ex) {
throw ex;
} catch (IOException ex) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, errorPrefix + ":网络异常");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, errorPrefix + ":调用被中断");
}
}
private static String base64Url(String value) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
private static String encode(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.path(field).asText("") : "";
}
}
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import java.util.Locale;
/**
* 商店平台取值。
*
* <p>入库口径按建表注释:`product.platform` / `orders.platform` 用 {@code ALL/APPLE/GOOGLE}。
* 客户端可能按商店名写成 `GOOGLE_PLAY`,所以这里同时接受别名并**归一化后入库**,
* 避免同一笔交易因为拼写不同被当成两个平台(那会绕过 `uk_orders_store_txn` 幂等键)。
*/
public final class StorePlatforms {
public static final String GOOGLE = "GOOGLE";
public static final String APPLE = "APPLE";
private StorePlatforms() {
}
/** 归一化平台名;无法识别时返回空串(由调用方决定怎么报错)。 */
public static String normalize(String raw) {
if (raw == null) {
return "";
}
String value = raw.trim().toUpperCase(Locale.ROOT);
return switch (value) {
case "GOOGLE", "GOOGLE_PLAY", "GOOGLEPLAY", "ANDROID" -> GOOGLE;
case "APPLE", "APP_STORE", "APPSTORE", "IOS" -> APPLE;
default -> "";
};
}
/** 是否 Google Play。 */
public static boolean isGoogle(String raw) {
return GOOGLE.equals(normalize(raw));
}
}
@@ -0,0 +1,133 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service;
import com.fruitshorts.commerce.dal.entity.OrderEvent;
import com.fruitshorts.commerce.dal.entity.Orders;
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.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.anyBoolean;
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;
/**
* 商店驱动的退款/拒付回收:回收一次、重复回调不二次回收,人工入口的既有契约不变。
*/
class OrderServiceRefundTest {
private static final String ORDER_NO = "FS202610161200000001";
private OrdersMapper ordersMapper;
private OrderEventMapper orderEventMapper;
private WalletService walletService;
private EntitlementService entitlementService;
private OrderService orderService;
@BeforeEach
void setUp() {
ordersMapper = mock(OrdersMapper.class);
orderEventMapper = mock(OrderEventMapper.class);
walletService = mock(WalletService.class);
entitlementService = mock(EntitlementService.class);
orderService = new OrderService(ordersMapper, orderEventMapper, mock(ProductMapper.class),
mock(StoreReceiptMapper.class), mock(StoreVerifier.class), walletService,
entitlementService);
given(entitlementService.revokeByOrderNo(anyString(), any())).willReturn(1);
}
@Test
void should_revoke_entitlement_and_claw_back_coins_when_store_reports_a_refund() {
given(ordersMapper.selectOne(any())).willReturn(deliveredOrder());
Orders order = orderService.refundByStoreCallback(ORDER_NO, "用户退款(Google reason=1)");
assertThat(order.getStatus()).isEqualTo(OrderService.STATUS_REFUNDED);
assertThat(order.getRefundAt()).isNotNull();
assertThat(order.getRefundReason()).contains("用户退款");
verify(entitlementService).revokeByOrderNo(eq(ORDER_NO), anyString());
verify(walletService).change(eq(1001L), eq(-550L), eq(WalletService.BIZ_REFUND),
eq(ORDER_NO), eq("order:" + ORDER_NO + ":refund"), anyString(), eq(false), isNull());
// 流水要写,而且来源标成 STORE,人工与商店回调在证据链上可区分
ArgumentCaptor<OrderEvent> event = ArgumentCaptor.forClass(OrderEvent.class);
verify(orderEventMapper).insert(event.capture());
assertThat(event.getValue().getEvent()).isEqualTo("REFUND");
assertThat(event.getValue().getSource()).isEqualTo("STORE");
assertThat(event.getValue().getPayload()).contains("回收权益 1 条");
}
@Test
void should_ignore_repeat_when_the_same_refund_callback_arrives_twice() {
Orders refunded = deliveredOrder();
refunded.setStatus(OrderService.STATUS_REFUNDED);
given(ordersMapper.selectOne(any())).willReturn(refunded);
Orders first = orderService.refundByStoreCallback(ORDER_NO, "用户退款");
Orders second = orderService.refundByStoreCallback(ORDER_NO, "用户退款");
assertThat(first.getStatus()).isEqualTo(OrderService.STATUS_REFUNDED);
assertThat(second.getStatus()).isEqualTo(OrderService.STATUS_REFUNDED);
// 幂等的关键:不二次回收权益、不二次扣币、不写第二条流水、不改订单
verify(entitlementService, never()).revokeByOrderNo(anyString(), any());
verify(walletService, never()).change(any(), anyLong(), anyString(), anyString(),
anyString(), anyString(), anyBoolean(), any());
verify(orderEventMapper, never()).insert(any(OrderEvent.class));
verify(ordersMapper, never()).updateById(any(Orders.class));
}
@Test
void should_keep_the_manual_refund_contract_when_order_is_already_refunded() {
Orders refunded = deliveredOrder();
refunded.setStatus(OrderService.STATUS_REFUNDED);
given(ordersMapper.selectOne(any())).willReturn(refunded);
// 人工入口保持原契约:已退款要报错(前端据此提示),不能被"幂等"改成静默
assertThatThrownBy(() -> orderService.refund(ORDER_NO, "重复操作", 1L))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.OPERATION_NOT_ALLOWED.getCode()))
.hasMessage("该订单已退款");
}
@Test
void should_do_nothing_when_a_voided_purchase_maps_to_an_unpaid_order() {
Orders created = deliveredOrder();
created.setStatus(OrderService.STATUS_CREATED);
created.setCoinAmount(0L);
given(ordersMapper.selectOne(any())).willReturn(created);
Orders order = orderService.refundByStoreCallback(ORDER_NO, "用户退款");
assertThat(order.getStatus()).isEqualTo(OrderService.STATUS_CREATED);
verify(entitlementService, never()).revokeByOrderNo(anyString(), any());
verify(orderEventMapper, never()).insert(any(OrderEvent.class));
}
private Orders deliveredOrder() {
Orders order = new Orders();
order.setId(1L);
order.setOrderNo(ORDER_NO);
order.setUserId(1001L);
order.setPlatform("GOOGLE");
order.setStoreTransactionId("GPA.3311-00001");
order.setCoinAmount(550L);
order.setStatus(OrderService.STATUS_DELIVERED);
return order;
}
}
@@ -0,0 +1,253 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service;
import com.fruitshorts.commerce.dal.entity.OrderEvent;
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.OrdersMapper;
import com.fruitshorts.commerce.dal.mapper.ProductMapper;
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
import com.fruitshorts.commerce.service.store.StorePlatforms;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
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.anyLong;
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;
/**
* 票据校验闭环:**校验 → 建单 → 发权益**,以及幂等与防重放。
*
* <p>用替身 verifier(`StubStoreVerifier`)喂"商店的判定结果",Mapper 与钱包/权益层用替身,
* 不连库、不联网。四类必须覆盖:通过→发权益、重复→幂等、无效→拒绝、跨用户→拒绝。
*/
class OrderServiceVerifyTest {
private static final Long USER = 1001L;
private static final String TOKEN = "purchase-token-1";
private static final String STORE_PRODUCT = "coin_550";
private static final String TXN = "GPA.3311-0000-0000-00001";
private OrdersMapper ordersMapper;
private OrderEventMapper orderEventMapper;
private ProductMapper productMapper;
private StoreReceiptMapper storeReceiptMapper;
private StubStoreVerifier verifier;
private WalletService walletService;
private EntitlementService entitlementService;
private OrderService orderService;
@BeforeEach
void setUp() {
ordersMapper = mock(OrdersMapper.class);
orderEventMapper = mock(OrderEventMapper.class);
productMapper = mock(ProductMapper.class);
storeReceiptMapper = mock(StoreReceiptMapper.class);
verifier = new StubStoreVerifier();
walletService = mock(WalletService.class);
entitlementService = mock(EntitlementService.class);
orderService = new OrderService(ordersMapper, orderEventMapper, productMapper,
storeReceiptMapper, verifier, walletService, entitlementService);
}
@Test
void should_deliver_coins_when_receipt_is_valid() {
given(ordersMapper.selectOne(any())).willReturn(null);
given(productMapper.selectList(any())).willReturn(List.of(coinPack()));
verifier.result = okResult();
AtomicReference<Orders> saved = captureInsertedOrder();
Orders order = orderService.verifyAndDeliver(USER, "GOOGLE_PLAY", STORE_PRODUCT, TOKEN);
assertThat(order.getStatus()).isEqualTo(OrderService.STATUS_DELIVERED);
// 500 基础 + 50 赠送
assertThat(order.getCoinAmount()).isEqualTo(550L);
assertThat(order.getPlatform()).isEqualTo(StorePlatforms.GOOGLE);
assertThat(order.getStoreTransactionId()).isEqualTo(TXN);
assertThat(order.getProductCode()).isEqualTo("coin_550");
// Google 给的微单位金额换算成元:990000 → 0.99
assertThat(order.getAmount()).isEqualByComparingTo(new BigDecimal("0.99"));
assertThat(order.getCurrency()).isEqualTo("IDR");
assertThat(order.getDeliveredAt()).isNotNull();
assertThat(saved.get().getOrderNo()).startsWith("FS");
// 发权益:钱包只调一次,幂等键按订单固定
verify(walletService).change(eq(USER), eq(550L), eq(WalletService.BIZ_RECHARGE),
eq(order.getOrderNo()), eq("order:" + order.getOrderNo() + ":coins"),
anyString(), eq(true), eq(null));
// 票据审计行 + 两条状态流水(VERIFY_OK / DELIVER)
verify(storeReceiptMapper).insert(any(StoreReceipt.class));
verify(orderEventMapper, org.mockito.Mockito.times(2)).insert(any(OrderEvent.class));
}
@Test
void should_return_existing_order_without_delivering_again_when_receipt_is_submitted_twice() {
Orders delivered = deliveredOrder(USER);
given(ordersMapper.selectOne(any())).willReturn(delivered);
verifier.result = okResult();
Orders first = orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN);
Orders second = orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN);
assertThat(first.getOrderNo()).isEqualTo(delivered.getOrderNo());
assertThat(second.getOrderNo()).isEqualTo(delivered.getOrderNo());
// 重复提交:不再发币、不再建单、不再写票据
verify(walletService, never()).change(any(), anyLong(), anyString(), anyString(),
anyString(), anyString(), org.mockito.ArgumentMatchers.anyBoolean(), any());
verify(ordersMapper, never()).insert(any(Orders.class));
verify(storeReceiptMapper, never()).insert(any(StoreReceipt.class));
}
@Test
void should_reject_and_create_nothing_when_receipt_is_invalid() {
verifier.result = StoreVerifier.StoreReceiptResult.failed("Google Play 购买状态不是已购买");
assertThatThrownBy(() -> orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.OPERATION_NOT_ALLOWED.getCode()))
.hasMessageContaining("购买状态不是已购买");
// 关键:不产生假订单、不写票据、不发币
verify(ordersMapper, never()).insert(any(Orders.class));
verify(storeReceiptMapper, never()).insert(any(StoreReceipt.class));
verify(walletService, never()).change(any(), anyLong(), anyString(), anyString(),
anyString(), anyString(), org.mockito.ArgumentMatchers.anyBoolean(), any());
}
@Test
void should_reject_when_receipt_belongs_to_another_user() {
given(ordersMapper.selectOne(any())).willReturn(deliveredOrder(2002L));
verifier.result = okResult();
assertThatThrownBy(() -> orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.FORBIDDEN.getCode()))
.hasMessageContaining("已绑定其他账号");
verify(walletService, never()).change(any(), anyLong(), anyString(), anyString(),
anyString(), anyString(), org.mockito.ArgumentMatchers.anyBoolean(), any());
}
@Test
void should_reject_when_product_is_not_configured() {
given(ordersMapper.selectOne(any())).willReturn(null);
given(productMapper.selectList(any())).willReturn(List.of());
verifier.result = okResult();
assertThatThrownBy(() -> orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.DATA_NOT_FOUND.getCode()));
verify(ordersMapper, never()).insert(any(Orders.class));
}
@Test
void should_fall_back_to_configured_price_when_store_omits_amount() {
given(ordersMapper.selectOne(any())).willReturn(null);
given(productMapper.selectList(any())).willReturn(List.of(coinPack()));
verifier.result = new StoreVerifier.StoreReceiptResult(true, TXN, "", STORE_PRODUCT,
null, "", LocalDateTime.now(), "Google Play 校验通过");
AtomicReference<Orders> saved = captureInsertedOrder();
orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN);
assertThat(saved.get().getAmount()).isEqualByComparingTo(new BigDecimal("4.99"));
}
@Test
void should_reject_when_platform_is_unknown() {
assertThatThrownBy(() -> orderService.verifyAndDeliver(USER, "NINTENDO", STORE_PRODUCT, TOKEN))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("不支持的商店平台");
}
@Test
void should_keep_the_original_delivery_when_the_purchase_is_restored_on_another_device() {
// 恢复购买:换设备重新提交同一票据(purchaseToken 不变 → 同一个 Google orderId)
Orders delivered = deliveredOrder(USER);
delivered.setDeliveredAt(LocalDateTime.of(2026, 10, 15, 9, 30));
given(ordersMapper.selectOne(any())).willReturn(delivered);
verifier.result = okResult();
Orders restored = orderService.verifyAndDeliver(USER, "GOOGLE_PLAY", STORE_PRODUCT, TOKEN);
assertThat(restored.getOrderNo()).isEqualTo(delivered.getOrderNo());
assertThat(restored.getStatus()).isEqualTo(OrderService.STATUS_DELIVERED);
// 原有交付时间不被改写,说明没有二次发货
assertThat(restored.getDeliveredAt()).isEqualTo(LocalDateTime.of(2026, 10, 15, 9, 30));
verify(ordersMapper, never()).updateById(any(Orders.class));
verify(storeReceiptMapper, never()).insert(any(StoreReceipt.class));
verify(orderEventMapper, never()).insert(any(OrderEvent.class));
}
private AtomicReference<Orders> captureInsertedOrder() {
AtomicReference<Orders> saved = new AtomicReference<>();
given(ordersMapper.insert(any(Orders.class))).willAnswer(invocation -> {
saved.set(invocation.getArgument(0));
return 1;
});
return saved;
}
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(STORE_PRODUCT);
product.setCoinAmount(500L);
product.setBonusCoin(50L);
product.setPriceAmount(new BigDecimal("4.99"));
product.setCurrency("IDR");
product.setVipDays(0);
product.setStatus(1);
return product;
}
private Orders deliveredOrder(Long userId) {
Orders order = new Orders();
order.setId(1L);
order.setOrderNo("FS202610161200000001");
order.setUserId(userId);
order.setPlatform(StorePlatforms.GOOGLE);
order.setStoreTransactionId(TXN);
order.setCoinAmount(550L);
order.setStatus(OrderService.STATUS_DELIVERED);
return order;
}
private StoreVerifier.StoreReceiptResult okResult() {
return new StoreVerifier.StoreReceiptResult(true, TXN, "", STORE_PRODUCT,
990000L, "IDR", LocalDateTime.now(), "Google Play 校验通过");
}
/** 替身 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,145 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.dal.entity.ReconcileTask;
import com.fruitshorts.commerce.dal.entity.StoreReceipt;
import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.dal.mapper.ReconcileTaskMapper;
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
import com.fruitshorts.common.exception.ServiceException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
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;
/**
* 对账口径:集合比对差异 + **取不到的指标用 null 并在 notes 说明**(契约强制,不许用 0 冒充)。
*/
class ReconcileServiceTest {
private static final LocalDate DATE = LocalDate.of(2026, 10, 15);
private OrdersMapper ordersMapper;
private StoreReceiptMapper storeReceiptMapper;
private ReconcileTaskMapper reconcileTaskMapper;
private ObjectProvider<StoreRefundService> provider;
private ReconcileService reconcileService;
private AtomicReference<ReconcileTask> saved;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ordersMapper = mock(OrdersMapper.class);
storeReceiptMapper = mock(StoreReceiptMapper.class);
reconcileTaskMapper = mock(ReconcileTaskMapper.class);
provider = mock(ObjectProvider.class);
reconcileService = new ReconcileService(reconcileTaskMapper, ordersMapper,
storeReceiptMapper, provider);
saved = new AtomicReference<>();
given(reconcileTaskMapper.insert(any(ReconcileTask.class))).willAnswer(invocation -> {
saved.set(invocation.getArgument(0));
return 1;
});
}
@Test
void should_return_null_store_amount_and_explain_the_missing_source_when_store_is_not_configured() {
given(provider.getIfAvailable()).willReturn(null);
given(ordersMapper.selectList(any())).willReturn(List.of(order("T1", "4.99")));
given(storeReceiptMapper.selectList(any())).willReturn(List.of(receipt("T1")));
ReconcileTask task = reconcileService.run(DATE, "ALL");
assertThat(task.getTotalCount()).isEqualTo(1);
assertThat(task.getMatchedCount()).isEqualTo(1);
assertThat(task.getDiffCount()).isZero();
assertThat(task.getAmountSum()).isEqualByComparingTo(new BigDecimal("4.99"));
assertThat(task.getStatus()).isEqualTo(ReconcileService.STATUS_SUCCESS);
// 契约强制:没有商店数据源 → null,不是 0
assertThat(task.getStoreAmountSum()).isNull();
assertThat(task.getNotes()).contains("数据源未配置");
assertThat(task.getNotes()).contains("财务报告");
}
@Test
void should_count_differences_on_both_sides_when_sets_do_not_line_up() {
given(provider.getIfAvailable()).willReturn(null);
given(ordersMapper.selectList(any())).willReturn(List.of(order("T1", "4.99")));
given(storeReceiptMapper.selectList(any())).willReturn(List.of(receipt("T2")));
ReconcileTask task = reconcileService.run(DATE, "ALL");
// 本地缺票据 1 笔 + 票据无本地订单 1 笔
assertThat(task.getDiffCount()).isEqualTo(2);
assertThat(task.getMatchedCount()).isZero();
assertThat(task.getNotes()).contains("本地缺票据 1 笔").contains("票据无本地订单 1 笔");
}
@Test
void should_recover_voided_purchases_and_report_them_in_notes_when_store_is_configured() {
StoreRefundService refundService = mock(StoreRefundService.class);
given(provider.getIfAvailable()).willReturn(refundService);
given(refundService.recoverVoidedPurchases("ALL", DATE))
.willReturn(new StoreRefundService.RecoverResult(1, 0, 1, 2));
given(ordersMapper.selectList(any())).willReturn(List.of(order("T1", "4.99")));
given(storeReceiptMapper.selectList(any())).willReturn(List.of(receipt("T1")));
ReconcileTask task = reconcileService.run(DATE, "ALL");
// 回收 1 笔;另有 1 笔商店说作废、本地无单 → 计入差异
assertThat(task.getDiffCount()).isEqualTo(1);
assertThat(task.getStoreAmountSum()).isNull();
assertThat(task.getNotes()).contains("已作废交易 2 笔").contains("回收 1 笔")
.contains("商店已作废但本地无单 1 笔");
}
@Test
void should_mark_task_failed_and_keep_notes_when_the_store_call_throws() {
StoreRefundService refundService = mock(StoreRefundService.class);
given(provider.getIfAvailable()).willReturn(refundService);
given(refundService.recoverVoidedPurchases("ALL", DATE))
.willThrow(new ServiceException("换取 Google access_token 失败"));
given(ordersMapper.selectList(any())).willReturn(List.of(order("T1", "4.99")));
given(storeReceiptMapper.selectList(any())).willReturn(List.of(receipt("T1")));
ReconcileTask task = reconcileService.run(DATE, "ALL");
// 调商店失败必须显性失败,不能写成 SUCCESS + 一堆 0
assertThat(task.getStatus()).isEqualTo(ReconcileService.STATUS_FAILED);
assertThat(task.getNotes()).contains("商店侧调用失败").contains("access_token");
assertThat(task.getStoreAmountSum()).isNull();
assertThat(task.getDiffCount()).isZero();
}
private Orders order(String transactionId, String amount) {
Orders order = new Orders();
order.setId(1L);
order.setOrderNo("FS202610151200000001");
order.setUserId(1001L);
order.setPlatform("GOOGLE");
order.setStoreTransactionId(transactionId);
order.setAmount(new BigDecimal(amount));
order.setStatus(OrderService.STATUS_DELIVERED);
return order;
}
private StoreReceipt receipt(String transactionId) {
StoreReceipt receipt = new StoreReceipt();
receipt.setId(1L);
receipt.setPlatform("GOOGLE");
receipt.setTransactionId(transactionId);
receipt.setOrderNo("FS202610151200000001");
return receipt;
}
}
@@ -0,0 +1,142 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service;
import com.fruitshorts.commerce.config.StoreProperties;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.service.store.GooglePlayPurchaseClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
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.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* 退款回收:拉商店作废列表 → 幂等回收本地订单;找不到本地单要计入差异;
* 未配置商店时**返回 null**(由对账写 notes),不能假装"回收了 0 笔"。
*/
class StoreRefundServiceTest {
private static final LocalDate DATE = LocalDate.of(2026, 10, 15);
private static final String PACKAGE_NAME = "com.stormg.fruitshorts";
private StoreProperties properties;
private StubPurchaseClient client;
private OrdersMapper ordersMapper;
private OrderService orderService;
private StoreRefundService refundService;
@BeforeEach
void setUp() {
properties = new StoreProperties();
properties.setGooglePlayPackageName(PACKAGE_NAME);
client = new StubPurchaseClient();
ordersMapper = mock(OrdersMapper.class);
orderService = mock(OrderService.class);
refundService = new StoreRefundService(properties, client, ordersMapper, orderService);
}
@Test
void should_recover_matched_order_and_report_unmatched_when_store_lists_voided_purchases() {
client.voided = List.of(
new GooglePlayPurchaseClient.VoidedPurchase("GPA.3311-00001", "token-1",
1760000000000L, 0, 1, "0"),
new GooglePlayPurchaseClient.VoidedPurchase("GPA.3311-99999", "token-9",
1760000000000L, 1, 2, "0"));
Orders matched = order("GPA.3311-00001");
given(ordersMapper.selectOne(any())).willReturn(matched, (Orders) null);
given(orderService.refundByStoreCallback(anyString(), anyString())).willAnswer(invocation -> {
matched.setStatus(OrderService.STATUS_REFUNDED);
return matched;
});
StoreRefundService.RecoverResult result = refundService.recoverVoidedPurchases("GOOGLE", DATE);
assertThat(result.voidedTotal()).isEqualTo(2);
assertThat(result.recovered()).isEqualTo(1);
assertThat(result.skipped()).isZero();
// 商店说有作废、本地没订单 → 必须计入差异,不能静默
assertThat(result.unmatched()).isEqualTo(1);
// 回收原因要带可读来源与 Google 原因码,方便客服解释
verify(orderService).refundByStoreCallback(eq(matched.getOrderNo()),
org.mockito.ArgumentMatchers.contains("用户退款"));
}
@Test
void should_count_skipped_when_the_order_was_already_refunded() {
client.voided = List.of(new GooglePlayPurchaseClient.VoidedPurchase("GPA.3311-00001",
"token-1", 1760000000000L, 0, 1, "0"));
Orders refunded = order("GPA.3311-00001");
refunded.setStatus(OrderService.STATUS_REFUNDED);
given(ordersMapper.selectOne(any())).willReturn(refunded);
// 已退款时 service 原样返回(状态不变)
given(orderService.refundByStoreCallback(anyString(), anyString())).willReturn(refunded);
StoreRefundService.RecoverResult result = refundService.recoverVoidedPurchases("GOOGLE", DATE);
assertThat(result.skipped()).isEqualTo(1);
assertThat(result.recovered()).isZero();
assertThat(result.unmatched()).isZero();
}
@Test
void should_return_null_when_google_play_is_not_configured() {
properties.setGooglePlayPackageName("");
StoreRefundService.RecoverResult result = refundService.recoverVoidedPurchases("GOOGLE", DATE);
assertThat(result).isNull();
// 没配凭据就不该发请求
assertThat(client.called).isFalse();
}
@Test
void should_return_null_when_platform_is_not_google() {
StoreRefundService.RecoverResult result = refundService.recoverVoidedPurchases("APPLE", DATE);
assertThat(result).isNull();
assertThat(client.called).isFalse();
verify(ordersMapper, never()).selectOne(any());
}
private Orders order(String transactionId) {
Orders order = new Orders();
order.setId(1L);
order.setOrderNo("FS202610151200000001");
order.setUserId(1001L);
order.setPlatform("GOOGLE");
order.setStoreTransactionId(transactionId);
order.setStatus(OrderService.STATUS_DELIVERED);
return order;
}
/** 替身商店客户端:只回预设的作废列表,并记录有没有被调用过。 */
private static final class StubPurchaseClient implements GooglePlayPurchaseClient {
private List<VoidedPurchase> voided = List.of();
private boolean called;
@Override
public List<VoidedPurchase> listVoidedPurchases(String packageName, long startMillis,
long endMillis) {
called = true;
return voided;
}
@Override
public ProductPurchase fetchProductPurchase(String packageName, String productId,
String purchaseToken) {
throw new UnsupportedOperationException("本测试不校验购买");
}
}
}
@@ -0,0 +1,131 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import com.fruitshorts.commerce.config.StoreProperties;
import com.fruitshorts.commerce.service.StoreVerifier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Google Play 校验分支:只测"拿到 Google 返回之后怎么判定",用替身客户端喂数据,不联网。
*
* <p>真调 Google API 需要 service account 凭据(属付费密钥,见回执里的清单),
* 所以这里把判定逻辑钉死,凭据到位后再做一次真实联调。
*/
class GooglePlayStoreVerifierTest {
private static final String PACKAGE_NAME = "com.stormg.fruitshorts";
private static final String PRODUCT_ID = "coin_550";
private static final String TOKEN = "purchase-token-1";
private StoreProperties properties;
private StubPurchaseClient client;
private GooglePlayStoreVerifier verifier;
@BeforeEach
void setUp() {
properties = new StoreProperties();
properties.setGooglePlayPackageName(PACKAGE_NAME);
client = new StubPurchaseClient();
verifier = new GooglePlayStoreVerifier(properties, client);
}
@Test
void should_return_transaction_when_google_confirms_the_purchase() {
client.purchase = new GooglePlayPurchaseClient.ProductPurchase("GPA.3311-00001", PRODUCT_ID,
0, 1, 1760000000000L, 990000L, "IDR", "0");
StoreVerifier.StoreReceiptResult result = verifier.verify("GOOGLE_PLAY", PRODUCT_ID, TOKEN);
assertThat(result.ok()).isTrue();
assertThat(result.transactionId()).isEqualTo("GPA.3311-00001");
assertThat(result.productId()).isEqualTo(PRODUCT_ID);
assertThat(result.amountMicros()).isEqualTo(990000L);
assertThat(result.currency()).isEqualTo("IDR");
assertThat(result.purchaseTime()).isNotNull();
assertThat(client.requestedPackageName).isEqualTo(PACKAGE_NAME);
assertThat(client.requestedProductId).isEqualTo(PRODUCT_ID);
}
@Test
void should_reject_when_purchase_state_is_not_purchased() {
client.purchase = new GooglePlayPurchaseClient.ProductPurchase("GPA.3311-00002", PRODUCT_ID,
1, 1, 1760000000000L, 990000L, "IDR", "0");
StoreVerifier.StoreReceiptResult result = verifier.verify("GOOGLE", PRODUCT_ID, TOKEN);
assertThat(result.ok()).isFalse();
assertThat(result.message()).contains("购买状态");
}
@Test
void should_reject_when_google_returns_a_different_product() {
client.purchase = new GooglePlayPurchaseClient.ProductPurchase("GPA.3311-00003", "coin_100",
0, 1, 1760000000000L, 100000L, "IDR", "0");
StoreVerifier.StoreReceiptResult result = verifier.verify("GOOGLE", PRODUCT_ID, TOKEN);
assertThat(result.ok()).isFalse();
assertThat(result.message()).contains("商品与请求不一致");
}
@Test
void should_reject_when_order_id_is_missing() {
// 没有 orderId 就没有幂等键:宁可不发权益,也不能冒重复发货的风险
client.purchase = new GooglePlayPurchaseClient.ProductPurchase("", PRODUCT_ID,
0, 1, 1760000000000L, 990000L, "IDR", "0");
StoreVerifier.StoreReceiptResult result = verifier.verify("GOOGLE", PRODUCT_ID, TOKEN);
assertThat(result.ok()).isFalse();
assertThat(result.message()).contains("orderId");
}
@Test
void should_reject_with_apple_hint_when_platform_is_apple() {
// Apple 只留端口:给客户端的提示要说清"尚未实现",而不是含糊地报参数错
StoreVerifier.StoreReceiptResult result = verifier.verify("APPLE", PRODUCT_ID, TOKEN);
assertThat(result.ok()).isFalse();
assertThat(result.message()).contains("Apple IAP 尚未实现");
}
@Test
void should_reject_when_platform_is_unknown() {
StoreVerifier.StoreReceiptResult result = verifier.verify("NINTENDO", PRODUCT_ID, TOKEN);
assertThat(result.ok()).isFalse();
assertThat(result.message()).contains("只支持 Google Play");
}
@Test
void should_reject_when_product_id_or_token_is_blank() {
assertThat(verifier.verify("GOOGLE", "", TOKEN).ok()).isFalse();
assertThat(verifier.verify("GOOGLE", PRODUCT_ID, "").ok()).isFalse();
}
/** 替身客户端:记录被请求的 packageName/productId,返回预设购买信息。 */
private static final class StubPurchaseClient implements GooglePlayPurchaseClient {
private ProductPurchase purchase;
private String requestedPackageName = "";
private String requestedProductId = "";
@Override
public ProductPurchase fetchProductPurchase(String packageName, String productId,
String purchaseToken) {
requestedPackageName = packageName;
requestedProductId = productId;
return purchase;
}
/** 本组测试只关心购买校验,作废列表用不到。 */
@Override
public java.util.List<VoidedPurchase> listVoidedPurchases(String packageName, long startMillis,
long endMillis) {
return java.util.List.of();
}
}
}
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("content_banner")
public class ContentBanner {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String imageUrl;
/** 展示位置,列名为 slot(position 在 PostgreSQL 里是关键字) */
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("drama")
public class Drama {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String code;
private String title;
@@ -11,7 +11,7 @@ import lombok.Data;
@TableName("drama_category")
public class DramaCategory {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
private Integer sort;
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("drama_episode")
public class DramaEpisode {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long dramaId;
private Integer epNo;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
@TableName("drama_review_record")
public class DramaReviewRecord {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String bizType;
private Long bizId;
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("content_submission")
public class ContentSubmission {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long creatorId;
private Long dramaId;
@@ -18,7 +18,7 @@ import java.time.LocalDateTime;
@TableName("creator")
public class Creator {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 关联 C 端账号 app_user.id */
private Long userId;
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("creator_document")
public class CreatorDocument {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long creatorId;
/** ID_CARD / BUSINESS_LICENSE / CONTRACT */
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more