Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c4f64323a | ||
|
|
8b46e535c5 | ||
|
|
7dd4b15804 | ||
|
|
cbb9961627 | ||
|
|
b567365460 | ||
|
|
9f76302e53 | ||
|
|
1dc97b4bf6 | ||
|
|
db897d50d6 | ||
|
|
27e689c771 | ||
|
|
5ce6de1e3b | ||
|
|
c6fcfcc6ea | ||
|
|
fafadfc6ec | ||
|
|
84aa0e17dd | ||
|
|
37c9a141d3 | ||
|
|
6833e01e1f |
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 构建上下文里不需要的东西:产物、IDE、本地上传的媒资、日志。
|
||||
# 注意别把 pom.xml / src / sql 排掉,镜像构建要用。
|
||||
**/target/
|
||||
.git/
|
||||
.gitignore
|
||||
.idea/
|
||||
*.iml
|
||||
*.log
|
||||
data/
|
||||
docs/
|
||||
node_modules/
|
||||
@@ -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=
|
||||
@@ -16,3 +16,6 @@ logs/
|
||||
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
|
||||
data/uploads/*
|
||||
!data/uploads/.gitkeep
|
||||
|
||||
# 部署用的真实环境变量(含密钥),只提交 .env.example
|
||||
.env
|
||||
@@ -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 归档与加密 | 生产资源 | 后续阶段 |
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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 上构建与运行。
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = "重置管理员密码")
|
||||
|
||||
+8
@@ -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 = "修改用户状态")
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+10
@@ -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) {
|
||||
|
||||
+7
@@ -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 = "删除评论")
|
||||
|
||||
+5
@@ -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 = "修改系统设置")
|
||||
|
||||
+10
@@ -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 = "重置创作者密码")
|
||||
|
||||
+9
@@ -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() {
|
||||
|
||||
+16
@@ -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 = "配置分集价格")
|
||||
|
||||
+5
@@ -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() {
|
||||
|
||||
+5
@@ -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() {
|
||||
|
||||
+7
@@ -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 = "清理过期下载授权")
|
||||
|
||||
+7
@@ -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 = "删除短剧类型")
|
||||
|
||||
+14
@@ -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) {
|
||||
|
||||
+6
@@ -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
@@ -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 = "调整剧集顺序")
|
||||
|
||||
+8
@@ -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
@@ -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 = "删除菜单")
|
||||
|
||||
+5
@@ -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) {
|
||||
|
||||
+8
@@ -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 = "订单退款")
|
||||
|
||||
+4
@@ -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() {
|
||||
|
||||
+5
@@ -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 = "配置分集价格")
|
||||
|
||||
+5
@@ -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 = "手动对账")
|
||||
|
||||
+5
@@ -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
@@ -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 = "删除角色分类")
|
||||
|
||||
+11
@@ -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 = "修改角色权限")
|
||||
|
||||
+7
@@ -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 = "删除敏感词")
|
||||
|
||||
+6
@@ -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 = "提交审核")
|
||||
|
||||
+5
@@ -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:
|
||||
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+8
@@ -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 +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());
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+17
-2
@@ -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", "")));
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {
|
||||
|
||||
+5
@@ -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(
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+5
@@ -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());
|
||||
|
||||
+8
@@ -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:
|
||||
|
||||
+140
@@ -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;
|
||||
}
|
||||
}
|
||||
+114
@@ -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()));
|
||||
}
|
||||
}
|
||||
+177
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -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;
|
||||
}
|
||||
}
|
||||
+320
@@ -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;
|
||||
}
|
||||
}
|
||||
+146
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
+88
@@ -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() + " 位)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -48,6 +48,8 @@ public class SecurityConfig {
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
"/v3/api-docs/**",
|
||||
/* 健康检查:给容器编排与冒烟脚本用(本项目没引 actuator,见 HealthController 注释) */
|
||||
"/healthz",
|
||||
"/actuator/health"
|
||||
};
|
||||
|
||||
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+36
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
/** 正数入账、负数出账 */
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+7
-1
@@ -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 商店校验尚未配置,第一阶段仅预留接口");
|
||||
}
|
||||
|
||||
+205
-13
@@ -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;
|
||||
}
|
||||
|
||||
+105
-18
@@ -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() {
|
||||
|
||||
+119
@@ -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()) + ")";
|
||||
}
|
||||
}
|
||||
+24
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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 校验通过");
|
||||
}
|
||||
}
|
||||
+250
@@ -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("") : "";
|
||||
}
|
||||
}
|
||||
+39
@@ -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));
|
||||
}
|
||||
}
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
+253
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -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;
|
||||
}
|
||||
}
|
||||
+142
@@ -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("本测试不校验购买");
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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 里是关键字) */
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
Reference in new issue
Block a user