feat(backend): 补齐 C 端商业化与账号缺口(金币充值/会员/搜索/语言筛选/Google登录/短信/商店可配置接入)
This commit is contained in:
1 parent
212834d59d
commit
63b086998e
25 files changed
+859
-29
No files matched your search
@@ -101,6 +101,7 @@ services:
|
||||
SERVER_PORT: "8080"
|
||||
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB"
|
||||
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB"
|
||||
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005"
|
||||
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
|
||||
ports:
|
||||
# docs/12-上线检查清单「管理端仅内网」:只对局域网开放;不在路由器做端口转发,公网访问不到
|
||||
|
||||
+13
-1
@@ -165,10 +165,14 @@ app.register.max-per-device-per-day
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集 |
|
||||
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
|
||||
| GET | `/app/discover/feed` | 发现页分区 |
|
||||
| GET | `/app/categories` | 分类列表 |
|
||||
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
|
||||
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
|
||||
|
||||
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
|
||||
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
|
||||
|
||||
### 1.4 播放(已实现,链路已重做)
|
||||
|
||||
@@ -257,9 +261,17 @@ app.register.max-per-device-per-day
|
||||
| --- | --- | --- |
|
||||
| GET | `/app/wallet` | 金币余额 + 最近流水 |
|
||||
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
|
||||
| GET | `/app/products` | 充值商品列表(只返回上架商品,`type` 可选 `COIN_PACK`/`VIP`) |
|
||||
| POST | `/app/orders` | 创建充值订单(`body` 传 `productId`,返回 `CREATED` 订单,等待支付) |
|
||||
| GET | `/app/orders` | 我的订单 |
|
||||
| POST | `/app/orders/verify` | 提交 Google Play 票据:服务端校验 → 建单 → 发权益(详见下方说明) |
|
||||
|
||||
> `GET /app/products` 只返回 `status=1`(上架)的商品,按 `sort` 升序、`id` 降序。
|
||||
> `POST /app/orders` 请求体 `{ "productId": 123 }`,商品下架时返回 `400 商品已下架`;
|
||||
> 订单 `coinAmount = coinAmount + bonusCoin`(与商店票据口径一致),状态为 `CREATED`。
|
||||
> VIP 商品下单后交付时发放 `TYPE_VIP` 权益(`dramaId=0`、`episodeId=0`,永久有效),
|
||||
> 播放/下载的权益判定在 `EntitlementPort` 中增加「存在有效 VIP 权益即放行」的判定。
|
||||
|
||||
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
|
||||
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
|
||||
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
## 【待 Trae 协助】2026-10-19 | 客户端 P0→P2 契约缺口(不阻塞其余项)
|
||||
|
||||
> 以下能力当前后端契约/配置不具备,客户端不做假按钮、不造接口;已落地的同类入口保持真实可用。请确认后端排期或补充凭据。
|
||||
|
||||
1. **看广告解锁**:`/app/**` 无激励广告相关接口,Flutter 工程也未集成广告 SDK;当前只实现金币解锁。需提供广告平台账号、广告位 ID 与发奖校验接口。
|
||||
2. **Google Play 充值**:`POST /app/orders/verify` 需要真实 `purchaseToken` 与商店商品配置;服务端默认 `verifier=disabled`,客户端无 Google Play Billing 商品/凭据。已按「暂不接则隐藏入口」处理,钱包页不放假充值按钮。
|
||||
3. **短信验证码通道**:`POST /app/auth/otp` 已对接,但服务端当前是 `LogOtpSender`,只写日志不发短信;UI 可发起请求,真机收码需替换短信供应商后才能验收。
|
||||
4. **创作 tab**:契约明确「创作者中心由 Web 端承载,App 不提供创作者中心」,C 端只有 `POST /app/creator/apply`,没有 AI 制作、上传、发布接口。无法做真实创作 tab,需产品确认是否改为「入驻申请」或开放 C 端接口。
|
||||
5. **会员购买**:无 C 端会员商品/订阅接口,后台商品契约也禁止启用 VIP 商品;无法做真实购买流程。
|
||||
6. **搜索接后端**:`/app/**` 无搜索接口;当前只能对已加载目录本地过滤,无法做服务端搜索。
|
||||
7. **剧场语言筛选**:剧目有 `language` 字段,但 `/app/home/feed`、`/app/discover/feed`、卡片 VO、详情 VO 均未下发语言;客户端无真实数据可筛。
|
||||
8. **播放清晰度切换**:`/app/episodes/{id}/play` 当前只下发 `qualities: ["AUTO"]`,没有多清晰度地址;仅保留 AUTO,不伪造切换。
|
||||
9. **Google 登录**:需要 Google OAuth 客户端 ID/服务器配置;当前登录页已移除该假入口。
|
||||
|
||||
---
|
||||
|
||||
## 补充指令(2026-10-19 23:30 周卫东)
|
||||
|
||||
> 1. **UI/UX 参考 Google 设计体系**:遵循 Material Design 3 规范 + Google Stitch 设计理念,界面现代简洁、层次分明,做成精品,不要粗糙界面。
|
||||
> 2. **收尾提交 develop**:所有工作做完、门禁全过后,把 feature 分支合并回 develop 并 `git push origin develop`。用户明天拉 develop 打包验证。
|
||||
|
||||
---
|
||||
# INBOX — 给 Codex 的指令队列(FruitServer 仓库内)
|
||||
|
||||
> **Codex 每次开始任务前必须先读本文件**(2026-10-16 起指令通道从 specs 仓库迁到本仓库)。
|
||||
@@ -20,6 +42,46 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚨🚨 立即开工(最高优先 · 不要等巡检 · 不要做任何收口动作)—— 2026-10-19 周卫东拍板
|
||||
|
||||
> **读到这里就直接开始做下面 P0 → P1 → P2,逐条落地并跑门禁,每条完成写 PROGRESS 留痕。**
|
||||
>
|
||||
> **背景**:周卫东对照「漫剧 App 功能规划图」盘点 ReelShort(Flutter)现状,结论:「看剧」链路真实可用,但「商业化」和「创作」大面积是**假功能/缺失**(金币写死 0、充值弹「示例数据」、解锁弹「即将开放」、下载不落盘、Google/手机登录弹「即将开放」、创作 tab 整个没有)。
|
||||
>
|
||||
> **拍板**:全量补齐 P0→P1→P2。原则——**不贪多,但每个都要能用、要精品**。后端接口已就绪,**全部是 App 端对接活,不重做后端**。
|
||||
>
|
||||
> **工作目录**:`C:\work\ReelShort`(Flutter,分支 `develop`)。禅道任务:29/30/31。
|
||||
|
||||
### P0 — 消灭假功能(做出来的必须能用,最优先)
|
||||
1. 内容解锁闭环:对接 `POST /app/episodes/{id}/unlock`,金币解锁 + 看广告解锁跑通,不再「即将开放」
|
||||
2. 金币余额:对接 `GET /app/wallet`,不再写死 0
|
||||
3. 充值流程:对接订单接口;若支付暂不接则**先隐藏入口**,不给假按钮
|
||||
4. 本地下载落盘:对接 `GET /app/downloads/{episodeId}/url`,真下载 + 离线播放
|
||||
5. 手机号登录:对接 `POST /app/auth/otp`,不再「即将开放」
|
||||
|
||||
### P1 — 补齐规划核心功能
|
||||
6. 「创作」tab(底部导航 3→4):AI 制作入口 + 上传发布入口,对接 creator-center 接口
|
||||
7. 开通会员购买流程
|
||||
8. 首页「榜单」tab
|
||||
9. 剧场「语言筛选」
|
||||
|
||||
### P2 — UX 打磨(精品感)
|
||||
10. 搜索接后端搜索接口(不再本地过滤)
|
||||
11. 点赞/评论显示计数
|
||||
12. 设置页完善(移除 debug「服务器地址」)
|
||||
13. 首页轮播 banner 真正用起来
|
||||
14. 播放清晰度切换
|
||||
|
||||
**门禁**:每条做完 `dart analyze lib test` 零告警 + `flutter test` 全过,再下一条。测试命名 `should_<期望>_when_<条件>`。
|
||||
### 执行状态(2026-10-19 收尾)
|
||||
- P0:金币解锁、钱包余额、本地下载落盘、手机号登录已完成;充值入口按契约隐藏;看广告解锁缺广告接口,见顶部第 1 条。
|
||||
- P1:榜单 tab 已完成;创作 tab、会员购买、剧场语言筛选缺契约,见顶部第 4/5/7 条。
|
||||
- P2:点赞/评论计数、设置页清理、首页轮播 banner 已完成;服务端搜索、播放清晰度切换缺契约,见顶部第 6/8 条。
|
||||
- 门禁:`dart analyze lib test` 零告警;`flutter test` 全过(`+385 ~1`);`flutter build apk --debug` 成功。
|
||||
- 交付:feature/flutter-p0-commerce 已以 --no-ff 合并 develop(ec2db68)并推送 origin/develop。
|
||||
|
||||
---
|
||||
|
||||
## [已处理·待用户验收] 2026-10-17 | K16 注册限流改为可配置(支持不限制)
|
||||
|
||||
**用户指令(2026-10-17)**:「注册 429 限流做成可配置的,不限制或限制数量」。
|
||||
|
||||
@@ -3756,3 +3756,162 @@ cf03178 feat(flutter): 首页改为两列全部剧目网格
|
||||
预期:标题最多 2 行、类型最多 1 行,不横向溢出;空态和失败重试文案保留。
|
||||
|
||||
设备侧截图、日志与最终验收结论由用户执行。
|
||||
|
||||
## 2026-10-19 | P0 商业化与登录闭环(Flutter)
|
||||
|
||||
### 1. 金币余额 + 金币解锁
|
||||
- 新增 `WalletController`、`CommerceApi`、`CommerceRepository`、`WalletSnapshot`/`UnlockResult`;钱包页读取 `GET /app/wallet`,展示服务端余额、累计充消、流水、空态与重试。
|
||||
- 播放器 `COIN_REQUIRED` 时调用 `POST /app/episodes/{id}/unlock`,成功后强制刷新播放票据并起播;余额不足保留后端 message。
|
||||
- 个人页金币卡改为真实余额;未登录先走登录。
|
||||
- 命令与结果:
|
||||
```text
|
||||
flutter test test/commerce test/wallet
|
||||
+6: All tests passed!
|
||||
```
|
||||
- 解读:余额、流水、解锁扣币与错误回传均由服务端数据驱动,没有本地写死余额。
|
||||
|
||||
### 2. 充值入口
|
||||
- 契约要求 Google Play `purchaseToken`,开发环境 `verifier=disabled` 且无商品凭据;按指令「暂不接则隐藏入口」处理,钱包页与个人页均不保留假充值按钮。
|
||||
- 阻塞已写入 `INBOX.md` 顶部「【待 Trae 协助】」。
|
||||
|
||||
### 3. 本地下载落盘 + 离线播放
|
||||
- 新增 `DownloadStorage` / `FileDownloadStorage`:`GET /app/downloads/{episodeId}/url` 取签名地址,流式写 `.part`,完成后原子改名;冷启动扫描本地目录恢复索引。
|
||||
- `DownloadController.startDownload` 上报 `DOWNLOADING` / `COMPLETED` / `FAILED`,下载页展示实时进度;下载完成可离线播放,删除同时清本地文件和服务端授权。
|
||||
- 播放器优先使用已同步本地文件,命中时不请求播放票据。
|
||||
- 命令与结果:
|
||||
```text
|
||||
flutter test test/download/download_storage_test.dart test/download/download_controller_local_test.dart
|
||||
+3: All tests passed!
|
||||
|
||||
flutter test test/download
|
||||
+36: All tests passed!
|
||||
```
|
||||
- 解读:MP4 分集真实落盘,下载页可离线起播;HLS 分片场景由后端下载接口当前返回 MP4 地址覆盖。
|
||||
|
||||
### 4. 手机号 OTP 登录
|
||||
- `AuthApi` 新增 `POST /app/auth/otp`、手机号验证码登录/注册;登录页新增 Material 3 底部表单(手机号、验证码、60 秒重发倒计时、登录/注册切换)。
|
||||
- 移除了 Google 登录假入口;短信通道仍是后端 `LogOtpSender`,已在 INBOX 标注。
|
||||
- 命令与结果:
|
||||
```text
|
||||
flutter test test/auth
|
||||
+26: All tests passed!
|
||||
```
|
||||
- 解读:客户端能真实发起 OTP、携带 `deviceId` 登录/注册并落盘会话;真机收码依赖后端短信供应商。
|
||||
|
||||
### 5. 门禁
|
||||
```text
|
||||
dart analyze lib test
|
||||
No issues found!
|
||||
|
||||
flutter test
|
||||
00:19 +374 ~1: All tests passed!
|
||||
```
|
||||
- 解读:静态检查零告警,全部测试通过;`~1` 为联调测试在无数据时主动跳过。
|
||||
## 2026-10-19 | P1 首页榜单 tab(Flutter)
|
||||
|
||||
### 1. 数据来源与交互
|
||||
- `RankingLogic` 只消费 `/app/home/feed` 已返回的 `hot` / `new` 分区,保持服务端顺序并按 id 去重;分区缺失时回退到完整目录,不生成虚构分数或假数据。
|
||||
- 新增 `RankingPage`:Material 3 分段切换、榜首大图卡、第 2 名起紧凑榜单、真实集数/类型文案、空态;条目点击复用现有详情页。
|
||||
- 底部导航 3→4:首页 / 发现 / 榜单 / 我的,榜单返回逻辑复用根 tab 的状态保留。
|
||||
|
||||
### 2. 测试(先红后绿)
|
||||
```text
|
||||
flutter test test/ranking/ranking_logic_test.dart test/ranking/ranking_view_test.dart test/home/home_nav_ui_test.dart
|
||||
+8: All tests passed!
|
||||
```
|
||||
- 解读:热门顺序、新剧切换、去重回退、榜单渲染与详情跳转、四 tab 导航均已覆盖。
|
||||
|
||||
### 3. 门禁
|
||||
```text
|
||||
dart analyze lib test
|
||||
No issues found!
|
||||
|
||||
flutter test
|
||||
00:20 +379 ~1: All tests passed!
|
||||
```
|
||||
- 解读:静态检查零告警,全量测试通过;`~1` 为联调测试在无数据时主动跳过。
|
||||
## 2026-10-19 | P2 移除 debug 服务器地址入口(Flutter)
|
||||
|
||||
- 设置 sheet 只保留界面语言(English / 简体中文 / Bahasa Indonesia),删除 `server-endpoint-setting` 编辑入口与 `debugShowSetting` 分支。
|
||||
- 首页目录失败态只保留错误说明与重试,删除 debug 服务器按钮及仅用于运行时代理编辑的 `server_endpoint_dialog.dart`。
|
||||
- 运行时地址仍可由 `--dart-define=API_BASE_URL` 注入,不影响真机联调或发布配置。
|
||||
|
||||
### 测试(先红后绿)
|
||||
```text
|
||||
flutter test test/mine/server_endpoint_setting_test.dart test/home/home_server_setting_test.dart test/mine/mine_logic_test.dart test/mine/mine_preview_layout_test.dart
|
||||
+12: All tests passed!
|
||||
```
|
||||
- 解读:设置 sheet 与首页失败态均断言不再出现服务器入口,三种界面语言仍完整可选。
|
||||
|
||||
### 门禁
|
||||
```text
|
||||
dart analyze lib test
|
||||
No issues found!
|
||||
|
||||
flutter test
|
||||
00:19 +377 ~1: All tests passed!
|
||||
```
|
||||
- 解读:静态检查零告警,全量测试通过;`~1` 为联调测试在无数据时主动跳过。
|
||||
## 2026-10-19 | P2 首页轮播 banner 接入(Flutter)
|
||||
|
||||
- 发现页顶部接入 `/app/home/feed` 的真实 `banners`;`DRAMA` 类型点击后按 `linkValue` 拉取详情并进入详情页,不会把 banner 链接当成假数据。
|
||||
- 新增 `FeedBannerCarousel`:Material 3 圆角卡片、4.5s 自动轮播、单张不启动定时器、按住暂停/松手恢复、TickerMode 与 tab active 双重暂停、圆点指示器带三语语义标签。
|
||||
- `banners` 为空时完全不占位;图片加载失败显示统一占位图标。
|
||||
- 使用现有 `carouselSemantics` 文案,没有新增无效入口。
|
||||
|
||||
### 测试(先红后绿)
|
||||
```text
|
||||
flutter test test/home/feed_banner_carousel_test.dart test/discover/discover_banner_test.dart
|
||||
+5: All tests passed!
|
||||
```
|
||||
- 解读:空列表、点击跳详情、自动翻页、按住暂停,以及发现页真实对接链路均通过。
|
||||
|
||||
### 门禁
|
||||
```text
|
||||
dart analyze lib test
|
||||
No issues found!
|
||||
|
||||
flutter test
|
||||
00:20 +382 ~1: All tests passed!
|
||||
```
|
||||
- 解读:静态检查零告警,全量测试通过;`~1` 为联调测试在无数据时主动跳过。
|
||||
## 2026-10-19 | P2 点赞与评论计数展示(Flutter)
|
||||
|
||||
- 点赞计数:`InteractionController` 记录点赞接口返回的 `count`,仅在服务端成功后展示;剧集契约没有初始计数,因此未点赞前不伪造数字。右栏 `rail-like-count` 与播放器更多菜单 `player-more-like-count` 均展示真实值。
|
||||
- 评论计数:评论接口无总数字段,`CommentController.exactCount` 仅在全部顶层评论分页加载完成且无更多页时返回精确数量;仍有多页时标题保持 `Comments`,不把当前页数量冒充总数。
|
||||
- 切换账号时 `clear()` 同步清理点赞计数,避免跨账号串联。
|
||||
|
||||
### 测试(先红后绿)
|
||||
```text
|
||||
flutter test test/home/home_preview_layout_test.dart test/interaction/interaction_controller_test.dart test/comment/comment_controller_test.dart test/comment/drama_comments_sheet_test.dart
|
||||
00:01 +27: All tests passed!
|
||||
```
|
||||
- 解读:覆盖服务端计数刷新、清空会话、分页完成前不展示总数、分页完成后显示 `Comments · N`,以及右栏真实数字渲染。
|
||||
|
||||
### 门禁
|
||||
```text
|
||||
dart analyze lib test
|
||||
No issues found!
|
||||
|
||||
flutter test
|
||||
00:20 +385 ~1: All tests passed!
|
||||
```
|
||||
- 解读:静态检查零告警,全量测试通过;`~1` 为联调测试在无数据时主动跳过。
|
||||
### 打包验证
|
||||
```text
|
||||
flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 93.9s
|
||||
√ Built build\app\outputs\flutter-apk\app-debug.apk
|
||||
```
|
||||
- 解读:debug APK 构建成功,develop 合并后可直接拉取打包验证。
|
||||
### develop 合并与推送
|
||||
```text
|
||||
git checkout develop
|
||||
git merge --no-ff feature/flutter-p0-commerce -m "merge: Flutter P0-P2 App 对接完成"
|
||||
Merge made by the 'ort' strategy.
|
||||
69 files changed, 4733 insertions(+), 387 deletions(-)
|
||||
|
||||
git push origin develop
|
||||
de941c8..ec2db68 develop -> develop
|
||||
```
|
||||
- 解读:合并提交 `ec2db68`,远端 `origin/develop` 已更新;合并后 `dart analyze lib test` 零告警、`flutter test` 全过(`+385 ~1`)、`flutter build apk --debug` 成功,工作区干净。
|
||||
+7
@@ -2,6 +2,7 @@
|
||||
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.app.vo.GoogleLoginReqVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
@@ -55,6 +56,12 @@ public class AppAuthController {
|
||||
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Google 登录(未配置 clientId 时返回明确错误)")
|
||||
@PostMapping("/google")
|
||||
public Result<TokenRespVO> google(@Valid @RequestBody GoogleLoginReqVO req) {
|
||||
return Result.ok(authService.googleLogin(req.getIdToken()));
|
||||
}
|
||||
|
||||
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
|
||||
@PostMapping("/refresh")
|
||||
public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) {
|
||||
|
||||
+20
@@ -4,8 +4,10 @@ package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.commerce.dal.entity.Orders;
|
||||
import com.fruitshorts.commerce.service.OrderService;
|
||||
import com.fruitshorts.commerce.service.ProductService;
|
||||
import com.fruitshorts.commerce.service.UnlockService;
|
||||
import com.fruitshorts.commerce.service.WalletService;
|
||||
import com.fruitshorts.commerce.vo.ProductRespVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -37,6 +40,7 @@ public class AppCommerceController {
|
||||
private final WalletService walletService;
|
||||
private final UnlockService unlockService;
|
||||
private final OrderService orderService;
|
||||
private final ProductService productService;
|
||||
|
||||
/** 我的钱包:余额 + 最近流水,客户端一处渲染。 */
|
||||
@Operation(summary = "我的钱包(余额 + 最近流水)")
|
||||
@@ -49,6 +53,22 @@ public class AppCommerceController {
|
||||
return Result.ok(data);
|
||||
}
|
||||
|
||||
/** 充值商品列表:只返回上架商品,type 可选(COIN_PACK/VIP)。 */
|
||||
@Operation(summary = "充值商品列表(只返回上架商品,type 可选 COIN_PACK/VIP)")
|
||||
@GetMapping("/products")
|
||||
public Result<List<ProductRespVO>> products(@RequestParam(required = false) String type) {
|
||||
return Result.ok(productService.listForApp(type));
|
||||
}
|
||||
|
||||
/** 创建充值订单:body 传 productId,返回 CREATED 状态的订单,等待后续支付。 */
|
||||
@Operation(summary = "创建充值订单(body 传 productId)")
|
||||
@PostMapping("/orders")
|
||||
public Result<Orders> createOrder(@RequestBody Map<String, Object> body) {
|
||||
Object productId = body.get("productId");
|
||||
Long pid = productId == null ? null : Long.valueOf(productId.toString());
|
||||
return Result.ok(orderService.createOrder(SecurityUtils.getAppUserId(), pid));
|
||||
}
|
||||
|
||||
/** 金币解锁分集:幂等,重复点击不会重复扣币。 */
|
||||
@Operation(summary = "金币解锁分集(幂等,重复点击不重复扣币)")
|
||||
@PostMapping("/episodes/{episodeId}/unlock")
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.app.service.AppSearchService;
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 客户端搜索:按标题或简介模糊匹配已上线短剧。 */
|
||||
@Tag(name = "客户端-搜索")
|
||||
@RestController
|
||||
@RequestMapping("/app")
|
||||
@RequiredArgsConstructor
|
||||
public class AppSearchController {
|
||||
|
||||
private final AppSearchService appSearchService;
|
||||
|
||||
@Operation(summary = "搜索短剧(按标题/简介模糊匹配,只返回已上线)")
|
||||
@GetMapping("/search")
|
||||
public Result<PageResult<AppDramaCardVO>> search(@RequestParam String keyword,
|
||||
@RequestParam(defaultValue = "1") int pageIndex,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
return Result.ok(appSearchService.search(keyword, pageIndex, pageSize));
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,7 @@ public class AppCatalogService {
|
||||
card.setTotalEpisodes(drama.getTotalEpisodes());
|
||||
card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
|
||||
card.setViewCount(drama.getViewCount());
|
||||
card.setLanguage(drama.getLanguage());
|
||||
card.setPreview(preview == null ? null : toEpisode(preview));
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
||||
import com.fruitshorts.content.enums.ContentStatus;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 客户端搜索:只搜已上线短剧,命中标题或简介。
|
||||
* 结果复用 {@link AppCatalogService#toCards} 的卡片转换,保证与首页卡片字段一致。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppSearchService {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
private final DramaMapper dramaMapper;
|
||||
private final AppCatalogService appCatalogService;
|
||||
|
||||
public PageResult<AppDramaCardVO> search(String keyword, int pageIndex, int pageSize) {
|
||||
if (!StringUtils.hasText(keyword)) {
|
||||
return PageResult.empty();
|
||||
}
|
||||
int safeIndex = Math.max(pageIndex, 1);
|
||||
int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||
Page<Drama> page = new Page<>(safeIndex, safeSize);
|
||||
Page<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
|
||||
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode())
|
||||
.and(w -> w.like(Drama::getTitle, keyword.trim())
|
||||
.or().like(Drama::getSynopsis, keyword.trim()))
|
||||
.orderByDesc(Drama::getSort)
|
||||
.orderByDesc(Drama::getId));
|
||||
return new PageResult<>(result.getTotal(), appCatalogService.toCards(result.getRecords()));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/** 列表卡片,用于首页/发现页/分类分区 */
|
||||
@@ -17,6 +18,9 @@ public class AppDramaCardVO {
|
||||
private Integer totalEpisodes;
|
||||
private Boolean isHot;
|
||||
private Long viewCount;
|
||||
/** 语种,供客户端按语言筛选(如 zh-CN / id)。 */
|
||||
@Schema(description = "短剧语种,如 zh-CN / id")
|
||||
private String language;
|
||||
/**
|
||||
* 首个可播免费集。首页竖屏播放需要在列表阶段就能起播,
|
||||
* 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/** Google 登录请求。 */
|
||||
@Data
|
||||
public class GoogleLoginReqVO {
|
||||
|
||||
@NotBlank(message = "缺少 Google idToken")
|
||||
private String idToken;
|
||||
}
|
||||
+4
-2
@@ -69,7 +69,8 @@ class AppOrdersVerifyContractTest {
|
||||
new ProductService(productMapper), storeReceiptMapper, verifier,
|
||||
mock(WalletService.class), mock(EntitlementService.class));
|
||||
mockMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController(
|
||||
mock(WalletService.class), mock(UnlockService.class), orderService));
|
||||
mock(WalletService.class), mock(UnlockService.class), orderService,
|
||||
mock(ProductService.class)));
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
new LoginUser(USER, "app-user", "APP_USER", List.of()), null, List.of()));
|
||||
}
|
||||
@@ -136,7 +137,8 @@ class AppOrdersVerifyContractTest {
|
||||
new ProductService(productMapper), storeReceiptMapper, new DisabledStoreVerifier(),
|
||||
mock(WalletService.class), mock(EntitlementService.class));
|
||||
MockMvc disabledMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController(
|
||||
mock(WalletService.class), mock(UnlockService.class), disabled));
|
||||
mock(WalletService.class), mock(UnlockService.class), disabled,
|
||||
mock(ProductService.class)));
|
||||
|
||||
String body = disabledMvc.perform(post("/app/orders/verify")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(BODY))
|
||||
|
||||
+45
-2
@@ -19,6 +19,8 @@ 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.GoogleIdTokenVerifier;
|
||||
import com.fruitshorts.user.service.GoogleUser;
|
||||
import com.fruitshorts.user.service.OtpSender;
|
||||
import com.fruitshorts.user.vo.LoginReqVO;
|
||||
import com.fruitshorts.user.vo.RegisterReqVO;
|
||||
@@ -60,6 +62,7 @@ class AppAuthServiceTest {
|
||||
private JwtTokenProvider tokenProvider;
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private ConfigService configService;
|
||||
private GoogleIdTokenVerifier googleVerifier;
|
||||
private AppAuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -74,10 +77,12 @@ class AppAuthServiceTest {
|
||||
tokenProvider = mock(JwtTokenProvider.class);
|
||||
passwordEncoder = mock(PasswordEncoder.class);
|
||||
configService = mock(ConfigService.class);
|
||||
googleVerifier = mock(GoogleIdTokenVerifier.class);
|
||||
given(configService.get(anyString(), anyString()))
|
||||
.willAnswer(invocation -> invocation.getArgument(1));
|
||||
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService);
|
||||
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService,
|
||||
googleVerifier);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -282,6 +287,43 @@ class AppAuthServiceTest {
|
||||
.isEqualTo(ErrorCode.ACCOUNT_DISABLED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_auto_register_and_issue_tokens_when_google_login_first_time() {
|
||||
given(googleVerifier.verify(anyString()))
|
||||
.willReturn(new GoogleUser("sub-1", "Alice@Example.com", "Alice", "http://pic/a.png"));
|
||||
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(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());
|
||||
|
||||
TokenRespVO resp = authService.googleLogin("google-id-token");
|
||||
|
||||
assertThat(resp.getAccessToken()).isEqualTo("access-1");
|
||||
assertThat(resp.isNewUser()).isTrue();
|
||||
verify(sessionMapper).insert(any(AppUserSession.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_google_login_when_email_is_missing() {
|
||||
given(googleVerifier.verify(anyString()))
|
||||
.willReturn(new GoogleUser("sub-1", "", "Alice", ""));
|
||||
|
||||
assertThatThrownBy(() -> authService.googleLogin("google-id-token"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("Google 账号未返回邮箱,无法登录");
|
||||
}
|
||||
|
||||
private void stubHappyRegister() {
|
||||
given(userMapper.selectCount(any())).willReturn(0L);
|
||||
given(credentialMapper.selectOne(any())).willReturn(null);
|
||||
@@ -328,7 +370,8 @@ class AppAuthServiceTest {
|
||||
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, configService);
|
||||
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService,
|
||||
googleVerifier);
|
||||
}
|
||||
|
||||
private void setRegisterLimits(int perIp, int perDevice) {
|
||||
|
||||
+10
@@ -37,6 +37,7 @@ public class EntitlementService implements EntitlementPort {
|
||||
|
||||
public static final String TYPE_EPISODE = "EPISODE";
|
||||
public static final String TYPE_DRAMA = "DRAMA";
|
||||
public static final String TYPE_VIP = "VIP";
|
||||
|
||||
private final EntitlementMapper entitlementMapper;
|
||||
|
||||
@@ -46,6 +47,15 @@ public class EntitlementService implements EntitlementPort {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Long vipCount = entitlementMapper.selectCount(Wrappers.<Entitlement>lambdaQuery()
|
||||
.eq(Entitlement::getUserId, userId)
|
||||
.eq(Entitlement::getStatus, STATUS_ACTIVE)
|
||||
.eq(Entitlement::getType, TYPE_VIP)
|
||||
.and(w -> w.isNull(Entitlement::getExpireAt)
|
||||
.or().gt(Entitlement::getExpireAt, now)));
|
||||
if (vipCount != null && vipCount > 0) {
|
||||
return true;
|
||||
}
|
||||
Long count = entitlementMapper.selectCount(Wrappers.<Entitlement>lambdaQuery()
|
||||
.eq(Entitlement::getUserId, userId)
|
||||
.eq(Entitlement::getStatus, STATUS_ACTIVE)
|
||||
|
||||
+47
@@ -93,6 +93,49 @@ public class OrderService {
|
||||
.orderByDesc(OrderEvent::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端创建充值订单:按商品配置生成一笔 CREATED 订单,等待后续支付。
|
||||
*
|
||||
* <p>商品必须处于上架状态;金币数量按 coinAmount + bonusCoin 计算,
|
||||
* 与商店票据链路 {@link #buildOrder} 的口径一致,交付时一次性到账。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Orders createOrder(Long userId, Long productId) {
|
||||
if (userId == null) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
Product product = productService.getExisting(productId);
|
||||
if (product.getStatus() == null || product.getStatus() != 1) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "商品已下架");
|
||||
}
|
||||
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() : ProductService.TYPE_COIN);
|
||||
order.setPlatform(product.getPlatform());
|
||||
order.setStoreProductId(product.getStoreProductId());
|
||||
long coins = (product.getCoinAmount() == null ? 0L : product.getCoinAmount())
|
||||
+ (product.getBonusCoin() == null ? 0L : product.getBonusCoin());
|
||||
order.setCoinAmount(coins);
|
||||
order.setAmount(product.getPriceAmount() == null ? BigDecimal.ZERO : product.getPriceAmount());
|
||||
order.setCurrency(StringUtils.hasText(product.getCurrency()) ? product.getCurrency() : "IDR");
|
||||
order.setQuantity(1);
|
||||
order.setStatus(STATUS_CREATED);
|
||||
order.setRegionCode("ID");
|
||||
order.setChannel("IAP");
|
||||
order.setExperimentId("");
|
||||
order.setReceipt("");
|
||||
order.setFailReason("");
|
||||
order.setRefundReason("");
|
||||
order.setCreatedAt(now);
|
||||
ordersMapper.insert(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端提交商店票据:**校验 → 建单 → 发权益**,一步都不能省。
|
||||
*
|
||||
@@ -273,6 +316,10 @@ public class OrderService {
|
||||
order.getOrderNo(), "order:" + order.getOrderNo() + ":coins",
|
||||
remark + ":" + order.getProductName(), true, operatorId);
|
||||
}
|
||||
if (ProductService.TYPE_VIP.equals(order.getType())) {
|
||||
entitlementService.grant(order.getUserId(), 0L, 0L,
|
||||
EntitlementService.TYPE_VIP, "ORDER", order.getOrderNo());
|
||||
}
|
||||
order.setStatus(STATUS_DELIVERED);
|
||||
order.setDeliveredAt(LocalDateTime.now());
|
||||
order.setFailReason("");
|
||||
|
||||
+15
-15
@@ -30,7 +30,7 @@ import java.util.List;
|
||||
* <li>`store_product_id` 在**同一 `platform` 下唯一** —— 否则同一商店商品被配置成两个商品,
|
||||
* 客户端 verify 时"按 storeProductId 找到第一个"会随机发其中一个的金币;</li>
|
||||
* <li>`type=COIN_PACK` 要求 `coin_amount > 0`;`type=VIP` 要求 `vip_days > 0`;</li>
|
||||
* <li>**VIP 允许建档、不允许启用**(与 B1-1「VIP 订阅本轮不做」一致)。</li>
|
||||
* <li>**VIP 允许建档、允许启用**(会员商品随金币包一起上架售卖)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@@ -64,6 +64,17 @@ public class ProductService {
|
||||
return ProductRespVO.of(getExisting(id));
|
||||
}
|
||||
|
||||
/** C 端商品列表:只返回上架商品,type 可选过滤(自动归一化为大写)。 */
|
||||
public List<ProductRespVO> listForApp(String type) {
|
||||
String normalized = StringUtils.hasText(type) ? type.trim().toUpperCase() : null;
|
||||
List<Product> records = productMapper.selectList(Wrappers.<Product>lambdaQuery()
|
||||
.eq(Product::getStatus, 1)
|
||||
.eq(normalized != null, Product::getType, normalized)
|
||||
.orderByAsc(Product::getSort)
|
||||
.orderByDesc(Product::getId));
|
||||
return records.stream().map(ProductRespVO::of).toList();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(ProductSaveReqVO req) {
|
||||
String code = req.getCode().trim();
|
||||
@@ -77,7 +88,6 @@ public class ProductService {
|
||||
applyFields(product, req, platform);
|
||||
// 新建默认下架:上架是显式动作,避免半配置的商品立刻对客户端可见
|
||||
product.setStatus(req.getStatus() == null ? 0 : req.getStatus());
|
||||
requireVipNotEnabled(product);
|
||||
productMapper.insert(product);
|
||||
return product.getId();
|
||||
}
|
||||
@@ -96,11 +106,10 @@ public class ProductService {
|
||||
if (req.getStatus() != null) {
|
||||
product.setStatus(req.getStatus());
|
||||
}
|
||||
requireVipNotEnabled(product);
|
||||
productMapper.updateById(product);
|
||||
}
|
||||
|
||||
/** 上架 / 下架;VIP 商品只允许下架(第二阶段订阅上线后才放开)。 */
|
||||
/** 上架 / 下架。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateStatus(Long id, Integer status) {
|
||||
if (status == null || (status != 0 && status != 1)) {
|
||||
@@ -108,7 +117,6 @@ public class ProductService {
|
||||
}
|
||||
Product product = getExisting(id);
|
||||
product.setStatus(status);
|
||||
requireVipNotEnabled(product);
|
||||
productMapper.updateById(product);
|
||||
}
|
||||
|
||||
@@ -161,15 +169,6 @@ public class ProductService {
|
||||
}
|
||||
}
|
||||
|
||||
/** VIP 商品在订阅功能上线前不能启用(允许建档)。 */
|
||||
private void requireVipNotEnabled(Product product) {
|
||||
boolean enabled = product.getStatus() != null && product.getStatus() == 1;
|
||||
if (enabled && TYPE_VIP.equals(product.getType())) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||
"会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCodeFree(String code, Long excludeId) {
|
||||
Long count = productMapper.selectCount(Wrappers.<Product>lambdaQuery()
|
||||
.eq(Product::getCode, code)
|
||||
@@ -198,7 +197,8 @@ public class ProductService {
|
||||
}
|
||||
}
|
||||
|
||||
private Product getExisting(Long id) {
|
||||
/** 按 id 取商品,不存在抛业务异常(供订单创建链路复用)。 */
|
||||
public Product getExisting(Long id) {
|
||||
Product product = id == null ? null : productMapper.selectById(id);
|
||||
if (product == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "商品不存在");
|
||||
|
||||
+6
-6
@@ -28,7 +28,7 @@ import static org.mockito.Mockito.verify;
|
||||
* 商品配置的业务校验。
|
||||
*
|
||||
* <p>这几条直接决定 C 端发币口径,必须在服务端拦住(前端校验只是体验):
|
||||
* `code` 唯一、`store_product_id` 同平台唯一、type 与金额/天数匹配、VIP 不允许启用。
|
||||
* `code` 唯一、`store_product_id` 同平台唯一、type 与金额/天数匹配、VIP 允许启用。
|
||||
*/
|
||||
class ProductServiceTest {
|
||||
|
||||
@@ -96,7 +96,7 @@ class ProductServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_allow_creating_vip_but_reject_enabling_it() {
|
||||
void should_allow_creating_and_enabling_vip() {
|
||||
given(productMapper.selectCount(any())).willReturn(0L);
|
||||
AtomicReference<Product> saved = captureInsert();
|
||||
ProductSaveReqVO req = coinPack("vip_year", "vip_year");
|
||||
@@ -109,12 +109,12 @@ class ProductServiceTest {
|
||||
assertThat(saved.get().getType()).isEqualTo("VIP");
|
||||
assertThat(saved.get().getStatus()).isZero();
|
||||
|
||||
// 但不允许启用
|
||||
// 也允许上架(会员商品随金币包一起售卖)
|
||||
Product existing = saved.get();
|
||||
given(productMapper.selectById(any())).willReturn(existing);
|
||||
assertThatThrownBy(() -> productService.updateStatus(existing.getId(), 1))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessageContaining("会员商品在订阅功能上线前不能启用");
|
||||
productService.updateStatus(existing.getId(), 1);
|
||||
assertThat(existing.getStatus()).isEqualTo(1);
|
||||
verify(productMapper).updateById(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Google 登录配置。
|
||||
*
|
||||
* <p>凭据只从配置/环境变量读,不入库、不提交到仓库:
|
||||
* <pre>
|
||||
* fruitshorts.auth.google.client-id: 1234567890-xxx.apps.googleusercontent.com
|
||||
* </pre>
|
||||
* 未配置 clientId 时,Google 登录接口明确返回"未配置"错误,不会静默放行。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.auth.google")
|
||||
public class GoogleAuthProperties {
|
||||
|
||||
/** Google OAuth 客户端 id,用于校验 idToken 的 audience。 */
|
||||
private String clientId = "";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 短信发送配置。
|
||||
*
|
||||
* <p>默认关闭:未配置时验证码走日志通道({@code LogOtpSender})。开启后按通用 webhook 发送:
|
||||
* <pre>
|
||||
* fruitshorts.auth.sms.enabled: true
|
||||
* fruitshorts.auth.sms.api-url: https://sms.example.com/send
|
||||
* fruitshorts.auth.sms.api-key: xxx
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.auth.sms")
|
||||
public class SmsProperties {
|
||||
|
||||
/** 是否启用短信通道;false 时继续走日志。 */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** 短信发送 webhook 地址;为空时降级为只写日志。 */
|
||||
private String apiUrl = "";
|
||||
|
||||
/** 短信接口鉴权 key,随 Authorization 头下发;可为空。 */
|
||||
private String apiKey = "";
|
||||
}
|
||||
+112
@@ -35,6 +35,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,7 @@ public class AppAuthService {
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final ConfigService configService;
|
||||
private final GoogleIdTokenVerifier googleVerifier;
|
||||
|
||||
/** 后台 sys_config 缺失或非法时使用;小于等于 0 表示不限制。 */
|
||||
@Value("${fruitshorts.auth.register-max-per-ip-per-day:5}")
|
||||
@@ -179,6 +181,116 @@ public class AppAuthService {
|
||||
req.getAppVersion(), req.getPlatform(), clientIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 登录:校验 idToken,按邮箱找到或自动创建账号,签发令牌。
|
||||
*
|
||||
* <p>复用邮箱/密码体系:首次登录先落一条 EMAIL 凭证(verified=1,Google 已验证邮箱),
|
||||
* 再落一条 GOOGLE 凭证用于下次直接登录;已有邮箱账号时只补 GOOGLE 凭证,不重复建号。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TokenRespVO googleLogin(String idToken) {
|
||||
GoogleUser googleUser = googleVerifier.verify(idToken);
|
||||
String email = requireEmail(googleUser);
|
||||
AppUserCredential googleCredential = findCredential(CredentialType.GOOGLE.name(), email);
|
||||
boolean newUser = false;
|
||||
Long userId;
|
||||
if (googleCredential == null) {
|
||||
AppUserCredential emailCredential = findCredential(CredentialType.EMAIL.name(), email);
|
||||
if (emailCredential == null) {
|
||||
AppUser user = createGoogleUser(googleUser);
|
||||
insertCredential(user.getId(), CredentialType.EMAIL.name(), email, "");
|
||||
userId = user.getId();
|
||||
newUser = true;
|
||||
} else {
|
||||
userId = emailCredential.getUserId();
|
||||
}
|
||||
googleCredential = insertCredential(userId, CredentialType.GOOGLE.name(), email,
|
||||
googleUser.sub());
|
||||
} else {
|
||||
userId = googleCredential.getUserId();
|
||||
}
|
||||
AppUser user = requireActiveUser(userId);
|
||||
googleCredential.setLastUsedAt(LocalDateTime.now());
|
||||
credentialMapper.updateById(googleCredential);
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
user.setLastLoginIp("");
|
||||
appUserMapper.updateById(user);
|
||||
TokenRespVO resp = issueTokens(user, googleCredential, null, null, null, null, null, null);
|
||||
resp.setNewUser(newUser);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** Google 账号必须带邮箱,否则没有可绑定的登录标识。 */
|
||||
private String requireEmail(GoogleUser googleUser) {
|
||||
String email = googleUser.email() == null ? "" : googleUser.email().trim().toLowerCase(Locale.ROOT);
|
||||
if (!StringUtils.hasText(email)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "Google 账号未返回邮箱,无法登录");
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
/** 创建 Google 用户:邮箱已验证,昵称与头像直接取 Google 返回。 */
|
||||
private AppUser createGoogleUser(GoogleUser googleUser) {
|
||||
AppUser user = new AppUser();
|
||||
user.setUid(hasher.newUid());
|
||||
String nickname = StringUtils.hasText(googleUser.name())
|
||||
? googleUser.name() : "User" + user.getUid().substring(1, 7);
|
||||
user.setNickname(nickname);
|
||||
user.setAvatarUrl(AppIdentifier.nullSafe(googleUser.picture()));
|
||||
user.setGender("");
|
||||
user.setCountryCode("ID");
|
||||
user.setRegionCode("ID");
|
||||
user.setLanguage("id-ID");
|
||||
user.setTimezone("Asia/Jakarta");
|
||||
user.setCurrency("IDR");
|
||||
user.setStatus(AppUserStatus.NORMAL);
|
||||
user.setRegisterSource("APP");
|
||||
user.setRegisterChannel("");
|
||||
user.setRegisterDeviceId("");
|
||||
user.setRegisterIp("");
|
||||
user.setAgeVerified(0);
|
||||
user.setAgreementVersion("");
|
||||
user.setAgreementAt(LocalDateTime.now());
|
||||
user.setVipLevel(0);
|
||||
user.setRiskLevel(0);
|
||||
user.setInviteCode("");
|
||||
user.setReferrerUserId(0L);
|
||||
appUserMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 插入一条已验证凭证;并发重复由唯一索引兜底。 */
|
||||
private AppUserCredential insertCredential(Long userId, String type, String identifier,
|
||||
String federationId) {
|
||||
AppUserCredential credential = new AppUserCredential();
|
||||
credential.setUserId(userId);
|
||||
credential.setType(type);
|
||||
credential.setIdentifier(identifier);
|
||||
credential.setFederationId(AppIdentifier.nullSafe(federationId));
|
||||
credential.setAppId("");
|
||||
credential.setPasswordHash("");
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
credential.setLastUsedAt(LocalDateTime.now());
|
||||
try {
|
||||
credentialMapper.insert(credential);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "账号已存在,请直接登录");
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
private AppUser requireActiveUser(Long userId) {
|
||||
AppUser user = appUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new ServiceException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (AppUserStatus.isBlocked(user.getStatus())) {
|
||||
throw new ServiceException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 刷新令牌:轮换 refresh token,旧的立即失效。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TokenRespVO refresh(String refreshToken) {
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.user.config.GoogleAuthProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.time.Duration;
|
||||
|
||||
/**
|
||||
* Google idToken 校验器。
|
||||
*
|
||||
* <p>用 Google 官方 tokeninfo 接口离线校验,不引入 Google SDK:
|
||||
* 拿到返回后比对 audience(aud)是否等于配置的 clientId,防止拿别人应用的 token 登录。
|
||||
* 未配置 clientId 时直接报"未配置",避免无凭据环境下静默失败。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GoogleIdTokenVerifier {
|
||||
|
||||
/** Google tokeninfo 校验地址。 */
|
||||
private static final String TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo?id_token=";
|
||||
/** 调用 Google 接口的超时(秒)。 */
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
|
||||
private final GoogleAuthProperties properties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* 校验 idToken 并返回 Google 用户信息。
|
||||
*
|
||||
* @param idToken 客户端从 Google 拿到的 idToken
|
||||
*/
|
||||
public GoogleUser verify(String idToken) {
|
||||
String clientId = properties.getClientId();
|
||||
if (!StringUtils.hasText(clientId)) {
|
||||
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google 登录未配置");
|
||||
}
|
||||
if (!StringUtils.hasText(idToken)) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
}
|
||||
JsonNode body = fetchTokenInfo(idToken);
|
||||
String aud = body.path("aud").asText("");
|
||||
String sub = body.path("sub").asText("");
|
||||
if (!clientId.equals(aud) || !StringUtils.hasText(sub)) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
}
|
||||
return new GoogleUser(sub, body.path("email").asText(""),
|
||||
body.path("name").asText(""), body.path("picture").asText(""));
|
||||
}
|
||||
|
||||
/** 调 tokeninfo 拿原始返回;网络异常或非 2xx 一律按"令牌无效"处理。 */
|
||||
private JsonNode fetchTokenInfo(String idToken) {
|
||||
String url = TOKENINFO_URL + URLEncoder.encode(idToken, StandardCharsets.UTF_8);
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request,
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
}
|
||||
return objectMapper.readTree(response.body());
|
||||
} catch (ServiceException ex) {
|
||||
throw ex;
|
||||
} catch (IOException ex) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
/**
|
||||
* Google 令牌校验通过后取到的用户信息。
|
||||
*
|
||||
* @param sub Google 用户唯一标识(subject)
|
||||
* @param email 邮箱,可能为空(个别 Google 账号未绑定邮箱)
|
||||
* @param name 显示名,可能为空
|
||||
* @param picture 头像地址,可能为空
|
||||
*/
|
||||
public record GoogleUser(String sub, String email, String name, String picture) {
|
||||
}
|
||||
+3
-2
@@ -3,16 +3,17 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 开发用发送器:只打日志,不真正发短信。
|
||||
* 生产环境把 fruitshorts.otp.sender 配成 sms / email 并实现对应的 OtpSender。
|
||||
* 同时要求短信通道未启用(fruitshorts.auth.sms.enabled != true),避免与 {@link SmsOtpSender} 同时装配。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "fruitshorts.otp.sender", havingValue = "log", matchIfMissing = true)
|
||||
@ConditionalOnExpression("'${fruitshorts.otp.sender:log}' == 'log' && '${fruitshorts.auth.sms.enabled:false}' != 'true'")
|
||||
public class LogOtpSender implements OtpSender {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.user.config.SmsProperties;
|
||||
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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 短信验证码发送器:只有 {@code fruitshorts.auth.sms.enabled=true} 时才装配,默认由 {@link LogOtpSender} 生效。
|
||||
*
|
||||
* <p>为通用起见,走一个 webhook 风格接口:POST JSON {phone, code, scene},带 Authorization 头。
|
||||
* 只处理 PHONE 类型,EMAIL 仍走日志;未配置 api-url 时降级为只写日志,方便开发调试。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "fruitshorts.auth.sms.enabled", havingValue = "true")
|
||||
public class SmsOtpSender implements OtpSender {
|
||||
|
||||
/** 只处理手机号渠道。 */
|
||||
private static final String PHONE_TYPE = "PHONE";
|
||||
/** 短信接口超时(秒)。 */
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
|
||||
private final SmsProperties properties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public void send(String targetType, String target, String scene, String code) {
|
||||
if (!PHONE_TYPE.equals(targetType)) {
|
||||
log.info("[OTP] 场景={} 渠道={} 接收方={} 验证码={}(邮箱走日志通道)",
|
||||
scene, targetType, mask(target), code);
|
||||
return;
|
||||
}
|
||||
if (!StringUtils.hasText(properties.getApiUrl())) {
|
||||
log.info("[OTP] 场景={} 接收方={} 验证码={}(未配置短信接口,仅日志)",
|
||||
scene, mask(target), code);
|
||||
return;
|
||||
}
|
||||
postSms(target, scene, code);
|
||||
}
|
||||
|
||||
/** 调短信 webhook;失败只记日志,不打断验证码流程(验证码已入库,可重试)。 */
|
||||
private void postSms(String phone, String scene, String code) {
|
||||
Map<String, String> payload = new LinkedHashMap<>();
|
||||
payload.put("phone", phone);
|
||||
payload.put("code", code);
|
||||
payload.put("scene", scene);
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(payload);
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(properties.getApiUrl()))
|
||||
.timeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.header("Content-Type", "application/json");
|
||||
if (StringUtils.hasText(properties.getApiKey())) {
|
||||
builder.header("Authorization", "Bearer " + properties.getApiKey());
|
||||
}
|
||||
HttpResponse<String> response = httpClient.send(
|
||||
builder.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
|
||||
.build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
log.warn("[OTP] 短信接口返回 HTTP {}:{}", response.statusCode(), response.body());
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
log.warn("[OTP] 短信发送失败:{}", ex.getMessage());
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("[OTP] 短信发送被中断");
|
||||
}
|
||||
}
|
||||
|
||||
private String mask(String target) {
|
||||
if (target == null || target.length() <= 6) {
|
||||
return "****";
|
||||
}
|
||||
return target.substring(0, 3) + "****" + target.substring(target.length() - 3);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -100,7 +100,7 @@ class TranscodeClaimAndStorageTest {
|
||||
Path path = storageService.fileFor("episode/202610/1.mp4");
|
||||
|
||||
assertThat(path.startsWith(storageService.rootDir())).isTrue();
|
||||
assertThat(path.toString()).endsWith("episode/202610/1.mp4");
|
||||
assertThat(path.endsWith(Path.of("episode", "202610", "1.mp4"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user