From 63b086998e8b91b2757124c58faf4a130a649470 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 04:20:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E8=A1=A5=E9=BD=90=20C=20?= =?UTF-8?q?=E7=AB=AF=E5=95=86=E4=B8=9A=E5=8C=96=E4=B8=8E=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=EF=BC=88=E9=87=91=E5=B8=81=E5=85=85=E5=80=BC?= =?UTF-8?q?/=E4=BC=9A=E5=91=98/=E6=90=9C=E7=B4=A2/=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E7=AD=9B=E9=80=89/Google=E7=99=BB=E5=BD=95/=E7=9F=AD=E4=BF=A1/?= =?UTF-8?q?=E5=95=86=E5=BA=97=E5=8F=AF=E9=85=8D=E7=BD=AE=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 1 + docs/api-phase1.md | 14 +- docs/plans/INBOX.md | 62 +++++++ docs/plans/PROGRESS.md | 159 ++++++++++++++++++ .../app/controller/AppAuthController.java | 7 + .../app/controller/AppCommerceController.java | 20 +++ .../app/controller/AppSearchController.java | 33 ++++ .../app/service/AppCatalogService.java | 1 + .../app/service/AppSearchService.java | 44 +++++ .../fruitshorts/app/vo/AppDramaCardVO.java | 4 + .../fruitshorts/app/vo/GoogleLoginReqVO.java | 14 ++ .../AppOrdersVerifyContractTest.java | 6 +- .../app/service/AppAuthServiceTest.java | 47 +++++- .../commerce/service/EntitlementService.java | 10 ++ .../commerce/service/OrderService.java | 47 ++++++ .../commerce/service/ProductService.java | 30 ++-- .../commerce/service/ProductServiceTest.java | 12 +- .../user/config/GoogleAuthProperties.java | 25 +++ .../user/config/SmsProperties.java | 32 ++++ .../user/service/AppAuthService.java | 112 ++++++++++++ .../user/service/GoogleIdTokenVerifier.java | 91 ++++++++++ .../fruitshorts/user/service/GoogleUser.java | 14 ++ .../user/service/LogOtpSender.java | 5 +- .../user/service/SmsOtpSender.java | 96 +++++++++++ .../worker/TranscodeClaimAndStorageTest.java | 2 +- 25 files changed, 859 insertions(+), 29 deletions(-) create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppSearchController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppSearchService.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/GoogleLoginReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/config/GoogleAuthProperties.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/config/SmsProperties.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/GoogleIdTokenVerifier.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/GoogleUser.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/SmsOtpSender.java diff --git a/docker-compose.yml b/docker-compose.yml index 38a2cff..3b0b1e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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-上线检查清单「管理端仅内网」:只对局域网开放;不在路由器做端口转发,公网访问不到 diff --git a/docs/api-phase1.md b/docs/api-phase1.md index e3c9e2b..d0c509a 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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`,钱包余额与流水同事务。 diff --git a/docs/plans/INBOX.md b/docs/plans/INBOX.md index 1e42f43..5437a2b 100644 --- a/docs/plans/INBOX.md +++ b/docs/plans/INBOX.md @@ -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 限流做成可配置的,不限制或限制数量」。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 173c106..5f2c75e 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -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` 成功,工作区干净。 \ No newline at end of file diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java index a868e79..05036b8 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java @@ -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 google(@Valid @RequestBody GoogleLoginReqVO req) { + return Result.ok(authService.googleLogin(req.getIdToken())); + } + @Operation(summary = "刷新令牌(旧 refresh token 立即失效)") @PostMapping("/refresh") public Result refresh(@Valid @RequestBody RefreshReqVO req) { diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java index c5254b9..279b242 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java @@ -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> products(@RequestParam(required = false) String type) { + return Result.ok(productService.listForApp(type)); + } + + /** 创建充值订单:body 传 productId,返回 CREATED 状态的订单,等待后续支付。 */ + @Operation(summary = "创建充值订单(body 传 productId)") + @PostMapping("/orders") + public Result createOrder(@RequestBody Map 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") diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppSearchController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppSearchController.java new file mode 100644 index 0000000..ebb9a93 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppSearchController.java @@ -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> search(@RequestParam String keyword, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "20") int pageSize) { + return Result.ok(appSearchService.search(keyword, pageIndex, pageSize)); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java index 7013217..e225812 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java @@ -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; } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppSearchService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppSearchService.java new file mode 100644 index 0000000..557b737 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppSearchService.java @@ -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 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 page = new Page<>(safeIndex, safeSize); + Page result = dramaMapper.selectPage(page, Wrappers.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())); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java index 4384d94..b71d800 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java @@ -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; /** * 首个可播免费集。首页竖屏播放需要在列表阶段就能起播, * 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。 diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/GoogleLoginReqVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/GoogleLoginReqVO.java new file mode 100644 index 0000000..69f77dc --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/GoogleLoginReqVO.java @@ -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; +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java index 8f4b0d3..a4361a8 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java @@ -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)) diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java index f3f3e4d..403a685 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java @@ -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 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) { diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/EntitlementService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/EntitlementService.java index 93a7d1e..f88e4db 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/EntitlementService.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/EntitlementService.java @@ -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.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.lambdaQuery() .eq(Entitlement::getUserId, userId) .eq(Entitlement::getStatus, STATUS_ACTIVE) diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java index 36fe7c8..927743d 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java @@ -93,6 +93,49 @@ public class OrderService { .orderByDesc(OrderEvent::getId)); } + /** + * 客户端创建充值订单:按商品配置生成一笔 CREATED 订单,等待后续支付。 + * + *

商品必须处于上架状态;金币数量按 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(""); diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java index 921b090..7206db1 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java @@ -30,7 +30,7 @@ import java.util.List; *

  • `store_product_id` 在**同一 `platform` 下唯一** —— 否则同一商店商品被配置成两个商品, * 客户端 verify 时"按 storeProductId 找到第一个"会随机发其中一个的金币;
  • *
  • `type=COIN_PACK` 要求 `coin_amount > 0`;`type=VIP` 要求 `vip_days > 0`;
  • - *
  • **VIP 允许建档、不允许启用**(与 B1-1「VIP 订阅本轮不做」一致)。
  • + *
  • **VIP 允许建档、允许启用**(会员商品随金币包一起上架售卖)。
  • * */ @Service @@ -64,6 +64,17 @@ public class ProductService { return ProductRespVO.of(getExisting(id)); } + /** C 端商品列表:只返回上架商品,type 可选过滤(自动归一化为大写)。 */ + public List listForApp(String type) { + String normalized = StringUtils.hasText(type) ? type.trim().toUpperCase() : null; + List records = productMapper.selectList(Wrappers.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.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, "商品不存在"); diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ProductServiceTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ProductServiceTest.java index 19f2451..7973f60 100644 --- a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ProductServiceTest.java +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ProductServiceTest.java @@ -28,7 +28,7 @@ import static org.mockito.Mockito.verify; * 商品配置的业务校验。 * *

    这几条直接决定 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 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 diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/config/GoogleAuthProperties.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/config/GoogleAuthProperties.java new file mode 100644 index 0000000..9025582 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/config/GoogleAuthProperties.java @@ -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 登录配置。 + * + *

    凭据只从配置/环境变量读,不入库、不提交到仓库: + *

    + * fruitshorts.auth.google.client-id: 1234567890-xxx.apps.googleusercontent.com
    + * 
    + * 未配置 clientId 时,Google 登录接口明确返回"未配置"错误,不会静默放行。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "fruitshorts.auth.google") +public class GoogleAuthProperties { + + /** Google OAuth 客户端 id,用于校验 idToken 的 audience。 */ + private String clientId = ""; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/config/SmsProperties.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/config/SmsProperties.java new file mode 100644 index 0000000..442851d --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/config/SmsProperties.java @@ -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; + +/** + * 短信发送配置。 + * + *

    默认关闭:未配置时验证码走日志通道({@code LogOtpSender})。开启后按通用 webhook 发送: + *

    + * fruitshorts.auth.sms.enabled: true
    + * fruitshorts.auth.sms.api-url: https://sms.example.com/send
    + * fruitshorts.auth.sms.api-key: xxx
    + * 
    + */ +@Data +@Component +@ConfigurationProperties(prefix = "fruitshorts.auth.sms") +public class SmsProperties { + + /** 是否启用短信通道;false 时继续走日志。 */ + private boolean enabled = false; + + /** 短信发送 webhook 地址;为空时降级为只写日志。 */ + private String apiUrl = ""; + + /** 短信接口鉴权 key,随 Authorization 头下发;可为空。 */ + private String apiKey = ""; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java index e0f8d56..c6ccaf9 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java @@ -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,按邮箱找到或自动创建账号,签发令牌。 + * + *

    复用邮箱/密码体系:首次登录先落一条 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) { diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/GoogleIdTokenVerifier.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/GoogleIdTokenVerifier.java new file mode 100644 index 0000000..f10d6d6 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/GoogleIdTokenVerifier.java @@ -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 校验器。 + * + *

    用 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 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 令牌无效"); + } + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/GoogleUser.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/GoogleUser.java new file mode 100644 index 0000000..77deca7 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/GoogleUser.java @@ -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) { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/LogOtpSender.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/LogOtpSender.java index 69ee586..234abb8 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/LogOtpSender.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/LogOtpSender.java @@ -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 diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/SmsOtpSender.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/SmsOtpSender.java new file mode 100644 index 0000000..9962051 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/SmsOtpSender.java @@ -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} 生效。 + * + *

    为通用起见,走一个 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 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 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); + } +} diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java index 90145d6..68cc564 100644 --- a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java @@ -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