From 8d19a0cc0a021f414ebf0dab45eb52e5e175e854 Mon Sep 17 00:00:00 2001 From: jianghaifeng Date: Fri, 16 Oct 2026 16:33:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=95=86=E5=93=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E7=B3=BB=E7=BB=9F=E5=85=AC=E5=91=8A=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=8Cid=20=E4=BB=A5=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E4=B8=B2=E4=B8=8B=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/products 六个接口 + 校验(code 唯一、store_product_id 同平台唯一、 COIN_PACK 需 coin_amount>0、VIP 需 vip_days>0、VIP 只建不启) - /admin/notices 六个接口,与 C 端 /app/notices 共用 OpsNoticeService.pageForApp 一套筛选口径 - 权限点 product:list/product:update/notice:list/notice:update + 菜单 31/32/33(sql/data-phase1.sql 幂等) - 雪花 id 超过 JS 安全整数范围:id 以字符串下发,避免浏览器四舍五入导致回查 200002 - C 端商品查询收敛到 ProductService.findForStore,OrderService 不再自带一份 - 测试:module-commerce 33 / admin-api 16 / app-api 49 / worker 12,共 110 条全过 --- docs/api-phase1.md | 78 ++++++ docs/plans/2026-10-16-I1.md | 68 +++++ docs/plans/PROGRESS.md | 244 ++++++++++++++++++ .../fruitshorts/FruitShortsApplication.java | 2 + .../controller/NoticeAdminController.java | 91 +++++++ .../controller/ProductAdminController.java | 91 +++++++ .../AdminPermissionAnnotationTest.java | 61 +++++ .../NoticeAdminControllerContractTest.java | 116 +++++++++ .../ProductAdminControllerContractTest.java | 122 +++++++++ .../app/service/AppNoticeService.java | 34 +-- .../AppOrdersVerifyContractTest.java | 7 +- .../commerce/service/OrderService.java | 17 +- .../commerce/service/ProductService.java | 223 ++++++++++++++++ .../commerce/vo/ProductQueryReqVO.java | 22 ++ .../commerce/vo/ProductRespVO.java | 92 +++++++ .../commerce/vo/ProductSaveReqVO.java | 49 ++++ .../service/OrderServiceRefundTest.java | 6 +- .../service/OrderServiceVerifyTest.java | 2 +- .../commerce/service/ProductServiceTest.java | 155 +++++++++++ .../system/service/OpsNoticeService.java | 159 ++++++++++++ .../system/vo/NoticeQueryReqVO.java | 22 ++ .../fruitshorts/system/vo/NoticeRespVO.java | 76 ++++++ .../system/vo/NoticeSaveReqVO.java | 32 +++ sql/data-phase1.sql | 40 +++ 24 files changed, 1761 insertions(+), 48 deletions(-) create mode 100644 docs/plans/2026-10-16-I1.md create mode 100644 docs/plans/PROGRESS.md create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/NoticeAdminController.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ProductAdminController.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/AdminPermissionAnnotationTest.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/NoticeAdminControllerContractTest.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ProductAdminControllerContractTest.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductQueryReqVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductRespVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductSaveReqVO.java create mode 100644 fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ProductServiceTest.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/OpsNoticeService.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeQueryReqVO.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeRespVO.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeSaveReqVO.java diff --git a/docs/api-phase1.md b/docs/api-phase1.md index f4dad80..0849787 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -742,6 +742,84 @@ GET /app/notices?pageIndex=1&pageSize=20 | --- | --- | --- | --- | | GET | `/admin/creator-center/impersonation/targets` | `creator-center:impersonate` | 代看目标下拉的数据源(id / 名称 / 状态) | +### 2.11 商品(金币包)配置(已实现) + +> 背景:`product` 表 16 个字段早就建好,但后台一直没有入口,运营只能改库;而 C 端 `/app/orders/verify` +> 依赖 `store_product_id` 对齐商店商品(对不上就是 `200002 该商品未在后台配置`)。本节补齐 CRUD。 +> **不改表结构。** + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| GET | `/admin/products/page` | `product:list` | 分页检索(`type` / `status` / `platform` / `keyword`,分页沿用 `{total,list}`) | +| GET | `/admin/products/{id}` | `product:list` | 商品详情 | +| POST | `/admin/products` | `product:update` | 新建 | +| PUT | `/admin/products/{id}` | `product:update` | 编辑 | +| PUT | `/admin/products/{id}/status` | `product:update` | 上架 / 下架(`status`:1 上架、0 下架) | +| DELETE | `/admin/products/{id}` | `product:update` | 删除(逻辑删除) | + +**字段**(与表一致,响应里额外带 `statusText` / `typeText`,前端不再写死中文): + +| 字段 | 说明 | +| --- | --- | +| `code` | 商品编码,**全局唯一** | +| `name` | 展示名 | +| `type` | `COIN_PACK`(金币包)/ `VIP`(会员,第二阶段) | +| `platform` | `ALL` / `GOOGLE` / `APPLE` | +| `storeProductId` | 商店商品 id,**同一 `platform` 下唯一**(空串表示未绑定,允许多个) | +| `coinAmount` / `bonusCoin` | 金币数与赠送(`type=COIN_PACK` 时 `coinAmount > 0`) | +| `priceAmount` / `currency` / `displayPrice` | 价格与展示价 | +| `regionCodes` | 生效区域,逗号分隔,空=全区域 | +| `vipDays` | 会员天数(`type=VIP` 时 `> 0`) | +| `sort` / `status` | 排序 / 上下架 | +| `effectiveAt` / `expireAt` | 生效与失效时间(可空) | + +> **⚠️ `id` 以字符串下发**(2026-10-16 实测补充):S0 换雪花主键后 id 已超过 JS 的安全整数范围 +> (2^53-1 = 9007199254740991)。直接下发数字会被浏览器四舍五入(实测 `2111011206851596290` +> 在 JS 里变成 `2111011206851596300`),页面拿这个 id 回查就是"商品不存在"。 +> 因此**本节所有响应里的 `id`(含新建返回的新 id)都是字符串**,前端原样拼进 URL 即可。 + +**校验与错误码**: + +| 场景 | 结果 | +| --- | --- | +| `code` 已存在 | `200001 商品编码已存在:{code}` | +| `storeProductId` 在同 `platform` 下已存在 | `200001 该平台下商店商品 id 已被占用:{id}` | +| `type=COIN_PACK` 但 `coinAmount <= 0` | `400 金币包的金币数量必须大于 0` | +| `type=VIP` 但 `vipDays <= 0` | `400 会员商品的会员天数必须大于 0` | +| **`type=VIP` 且要启用**(`status=1`) | `400 会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)` | +| 商品不存在 | `200002 商品不存在` | + +> **VIP 口径(2026-10-16 定,与 B1-1「VIP 订阅本轮不做」一致)**:**允许创建、不允许启用**。 +> 运营可以先录好商品与商店 product id,等 B2 上线后直接上下架;**启用**才是会误导客户端的动作,拦在这里。 + +### 2.12 系统公告管理(已实现) + +> 背景:`ops_notice` 表 0 行、契约原先写着"没有产出方";E1 已交付 C 端 `GET /app/notices`(匿名只读)。 +> 本节补齐后台写入。**不改表结构。** + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| GET | `/admin/notices/page` | `notice:list` | 分页检索(`status` / `target` / `type` / `keyword`) | +| GET | `/admin/notices/{id}` | `notice:list` | 公告详情 | +| POST | `/admin/notices` | `notice:update` | 新建 | +| PUT | `/admin/notices/{id}` | `notice:update` | 编辑 | +| PUT | `/admin/notices/{id}/status` | `notice:update` | 发布(`status=1`)/ 下架(`status=0`) | +| DELETE | `/admin/notices/{id}` | `notice:update` | 删除(逻辑删除) | + +**字段**:`title` / `content` / `target`(`ALL` / `USER` / `CREATOR`)/ `type`(`SYSTEM` / `AUDIT` / `PAYMENT` / `ACTIVITY`)/ +`status` / `publishAt` / `expireAt`;响应另带 `statusText` / `targetText` / `typeText`。 + +> **⚠️ `id` 以字符串下发**:同 §2.11 —— 雪花 id 超过 JS 安全整数范围,数字下发会被浏览器四舍五入, +> 导致"下架/编辑"回查不到记录。本节的 `id`(含新建返回的新 id)一律是字符串。 + +**与 C 端口径严格对齐(同一条查询实现)**:C 端 `GET /app/notices` 只返回 +`status=1` 且 `target ∈ {ALL, USER}`、`publish_at` 为空或 `<= now`、`expire_at` 为空或未过期的公告, +按 `publish_at desc, id desc` 排序。**后台发布即 C 端可见;下架或过期后 C 端不可见。** +(实现上收敛在 `OpsNoticeService.pageForApp(...)` 一处,避免两端条件漂移。) + +**筛选与错误码**:`title` 必填(1–128);`content` 最长 2000;公告不存在 → `200002 公告不存在`; +`status` 只接受 0/1,其他值 → `400`。 + ## 3. 契约与实现状态对照 > 最后更新:2026-10-16(`develop` 分支)。未完成项汇总见仓库根 `README.md`「尚未完成」。 diff --git a/docs/plans/2026-10-16-I1.md b/docs/plans/2026-10-16-I1.md new file mode 100644 index 0000000..f40346c --- /dev/null +++ b/docs/plans/2026-10-16-I1.md @@ -0,0 +1,68 @@ +# I1 后台管理端:商品(金币包)配置 + 系统公告管理 —— 计划(2026-10-16) + +> 依据:`FruitServer/docs/plans/INBOX.md` 17:30 条目。用户口径「现在做后台管理端,规范还是按照 specs」。 +> **本轮=后端接口 + ReelShortWeb 页面一起做**。指令通道已迁到本仓库(`FruitServer/docs/plans/`), +> 留痕写 `FruitServer/docs/plans/PROGRESS.md`(本轮新建);**specs 只读、不再写入**。 + +## 一、两个缺口(PM 已勘察,直接用) + +1. **商品/金币包配置后台无入口**:`product` 表 16 字段已建、3 行种子、C 端 verify 依赖 `store_product_id` 对齐商店商品,但 admin-api 没有任何商品 CRUD。 +2. **公告有读没写**:`ops_notice` 表 0 行,E1 交付了 C 端读,后台没有写入口。 + +## 二、勘察补充(影响做法,已实测) + +1. **前端挂载机制**(与条目描述略有出入,已核实):`src/utils/routerHelper.ts` 用 + `import.meta.glob('../views/**/*.{vue,tsx}')` + 后端菜单的 `component` 字符串**动态解析**; + `src/router/index.ts` 的 `asyncRouterMap` 只静态声明了 dashboard/content/user/system 等早期分组, + commerce/governance/board 这些都没有静态登记。→ 所以新增页面**只需**:① 新建 `.vue`; + ② `sql/data-phase1.sql` 里加菜单且 `component` 与视图路径一致。**静态路由不必改**(改了也无害)。 + 真正的坑是 `route_name` 唯一(曾因 `CreatorCenter` 重名导致 404),本轮命名会避开。 +2. **id 保留段**:权限点已用到 1155、菜单已用到 30 → 权限点用 **1160-1163**、菜单用 **31/32/33**。 +3. **前端既有约定**:api 封装在 `src/api/admin/*`(`import request from '@/axios'` + `PageResult`), + 页面用 `useRbac().can(code)` 控权、`dateCell` 格式化日期、`PageHeading` 标题;金额/金币这类大数值本项目 + 没有 `formatCount`,实际用的是 `src/utils/format.js`,**我会用其中的数值格式化工具或 `toLocaleString`**, + 并在回执说明(不新造 util)。 + +## 三、口径决定(需在回执与契约里写清) + +**VIP 商品**:**允许创建、不允许启用**(`PUT /{id}/status` 把 `type=VIP` 置为启用 → `400`)。 +理由:运营可以先把 VIP 商品与商店 product id 录好,等 B2 订阅上线后一键启用;直接拒绝创建会让上线日重新补数据。 +B1-1 已定「VIP 订阅本轮不做」,所以**启用**才是会产生误导的动作,拦在这里。 + +## 四、后端做法 + +1. **契约先行**:`docs/api-phase1.md` 新增一节(放在 §2.6 订单与权益之后的新编号),写清两个模块的 + 接口、字段、校验、错误码、权限点。 +2. **商品**(module-commerce 加 service/VO,admin-api 加 controller): + `GET /admin/products/page`(type/status/platform/keyword + 分页)、`GET /{id}`、`POST`、`PUT /{id}`、 + `PUT /{id}/status`、`DELETE /{id}`;字段按表结构;校验:`code` 全局唯一、`store_product_id` 同 `platform` 唯一、 + `type=COIN → coin_amount>0`、`type=VIP → vip_days>0`、**VIP 不可启用**。响应带 `statusText/typeText`(前端不写死中文)。 +3. **公告**(module-system 加 `OpsNoticeService` + VO,admin-api 加 controller): + `GET /admin/notices/page`、`GET /{id}`、`POST`、`PUT /{id}`、`PUT /{id}/status`、`DELETE /{id}`。 + **筛选逻辑收敛到 `OpsNoticeService.pageForApp()` 一处**,C 端 `AppNoticeService` 改为委托它 + (改我自己 E1 写的文件),这样"后台发布 → C 端可见 / 过期或下架 → 不可见"用的是同一段条件,不会漂移。 +4. **菜单与权限点**:`sql/data-phase1.sql` 幂等追加(权限点 1160-1163、菜单 31/32/33 与超管授权)。 + 商品挂「订单与权益」(id 22) 下;公告新建「运营管理」(id 32) 分组 + 子菜单 (33)。 +5. **Swagger**:两个新控制器按 S1 规范加 `@Tag`/`@Operation`,并把分组补进 admin-api 的 `@OpenAPIDefinition`。 + +## 五、前端做法(ReelShortWeb,develop 分支) + +- `src/api/admin/product.ts`、`src/api/admin/notice.ts`(按 `commerce.ts` 的写法) +- `src/views/commerce/ProductManagementView.vue`(列表/筛选/新建编辑弹窗/上下架/删除) +- `src/views/operation/NoticeManagementView.vue`(列表/新建编辑/发布下架/删除) +- `sql/data-phase1.sql` 的菜单 `component` 写 `views/commerce/ProductManagementView`、`views/operation/NoticeManagementView` +- **不改既有页面**;`route_name` 用 `commerce-products` / `operation-notices`(先 grep 确认不重名) + +## 六、门禁 + +- 后端:`mvn test` 存量 **91 条不得跑红** + 新增测试(商品三类校验、公告可见性委托、 + 两个控制器的接入层契约、权限点注解检查) +- 前端:`pnpm build:pro` 通过 + eslint 零错误 +- 端到端:后台登录 → 菜单出现两个新页面 → 建商品 → 用该商品在 C 端 `verify` 能对上 + (`store_product_id` 命中,不再是 `200002 该商品未在后台配置`);公告发布 → C 端 `/app/notices` 可见 → 下架/过期 → 不可见 + +## 七、风险 / 待确认 + +1. **不新增静态路由**是否与条目"两边都要加"冲突:我按实测的加载机制做,若你要求同时静态登记,我再补(不影响正确性)。 +2. `/app/orders/verify` 走真实 Google Play 才有完整链路;本机无商店凭据,端到端验证到"200002 消失、进入校验环节"为止。 +3. 公告"发布时间"语义:`publish_at` 为空视为立即生效(C 端条件里已按"为空或 <= now"处理)。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md new file mode 100644 index 0000000..52a360a --- /dev/null +++ b/docs/plans/PROGRESS.md @@ -0,0 +1,244 @@ +# PROGRESS — 工作留痕(FruitServer) + +> 红线第 2 条:谁干活谁写这里(命令 + 原始输出 + 解读,逐动作追加)。 +> 指令通道:`docs/plans/INBOX.md`(specs 仓库只拉取、由项目负责人更新)。 + +--- + +## 2026-10-16 I1:后台管理端 —— 商品(金币包)配置 + 系统公告管理 + +计划文件:`docs/plans/2026-10-16-I1.md`(先写计划再动手)。改了 `docs/api-phase1.md` §2.11/§2.12(契约先行)。 + +### 动作 1:开工前核对仓库状态 + +``` +$ cd FruitServer && git status --short && git log --oneline -1 + M docs/api-phase1.md + M fruitshorts-admin-api/.../FruitShortsApplication.java + M fruitshorts-app-api/.../AppNoticeService.java + M sql/data-phase1.sql +?? docs/plans/2026-10-16-I1.md +?? fruitshorts-admin-api/.../ProductAdminController.java(+ NoticeAdminController) +?? fruitshorts-admin-api/src/test/.../ProductAdminControllerContractTest.java(+ Notice / AdminPermissionAnnotation) +?? fruitshorts-module-commerce/.../ProductService.java(+ 3 个 VO) +?? fruitshorts-module-system/.../OpsNoticeService.java(+ 3 个 VO) +7513ac0 docs(plans): 指令通道迁入本仓库(specs 改为只拉取,由项目负责人更新) +``` + +解读:分支 `develop`,起点就是指令通道那笔 `7513ac0`;工作区只有本轮 I1 的改动。 + +### 动作 2:契约先行(先改文档再写代码) + +`docs/api-phase1.md` 新增 **§2.11 商品(金币包)配置(已实现)**、**§2.12 系统公告管理(已实现)**: +接口表 + 字段表 + 校验错误码表 + 权限点 + 与 C 端可见性口径对齐说明。VIP 口径在契约里定死: +**允许建档、不允许启用**。 + +### 动作 3:后端门禁 —— `mvn test` + +首轮(雪花 id 修复前): + +``` +$ mvn test +[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-module-commerce) +[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-admin-api) +[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-app-api) +[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-transcode-worker) +[INFO] BUILD SUCCESS +``` + +解读:存量 91 条(app-api 41 + commerce 27 + admin-api 3 + worker 12 + …)无一跑红,新增 17 条全过,共 108。 + +### 动作 4:SQL 幂等(连跑三遍) + +``` +$ psql -f sql/data-phase1.sql # 第一次 +INSERT 0 5 / UPDATE 5 / INSERT 0 4 / INSERT 0 4 / INSERT 0 3 / INSERT 0 3 +$ psql -f sql/data-phase1.sql # 第二次 +INSERT 0 5 / UPDATE 5 / INSERT 0 0 / INSERT 0 0 / INSERT 0 3 / INSERT 0 0 +$ psql -t -c "select 'role_menu='||count(*) from sys_role_menu union all ..." # 跑前 + role_menu=54 / permission=52 / menu=33 / role_perm=79 +$ psql -f sql/data-phase1.sql # 第三次 +RUN3 EXIT=0 + role_menu=54 / permission=52 / menu=33 / role_perm=79 # 跑后:完全一致 +``` + +解读:脚本可重复执行,**行数不增长、不重复插入**。新增权限点 `1160-1163`、菜单 `31/32/33`(商品挂 22「订单与权益」下、公告新建 32「运营管理」分组 + 33 子菜单),并给超管授了角色-菜单。 + +### 动作 5:打包 + 起回服务 + curl 自测(商品) + +``` +$ mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests → BUILD SUCCESS +(kill 8080/8081 → 重新起 jar → 健康检查) +admin=200 / app=200 +``` + +商品自测(完整脚本 `/tmp/i1-curl.sh`,原始输出 `/tmp/i1-curl.log`): + +``` +3) 新建商品 i1_test_coin_100(800+80,IDR 9900,platform=GOOGLE) + {"code":0,"message":"ok","data":2111010131922784257} +4) 新建后状态 → "status": 0, "statusText": "已下架" # 新建默认下架 +5) code 重复 → {"code":200001,"message":"商品编码已存在:i1_test_coin_100"} +6) 同平台 store id 重复 → {"code":200001,"message":"该平台下商店商品 id 已被占用:I1_TEST_COIN_100"} +7) 金币包 coin_amount=0 → {"code":400,"message":"金币包的金币数量必须大于 0"} +8) VIP vip_days=0 → {"code":400,"message":"会员商品的会员天数必须大于 0"} +9) VIP vip_days=30 → {"code":0} # 允许建档 +10) VIP 启用 → {"code":400,"message":"会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)"} +11) 上架 → {"code":0},详情 status=1/已上架 +12) C 端等价查询(store_product_id + platform in (GOOGLE,ALL) + status=1) + 2111010131922784257 | i1_test_coin_100 | GOOGLE | I1_TEST_COIN_100 | 800 | 80 | 1 (1 row) +``` + +公告自测(同一脚本,13–18 步): + +``` +13) 公告分页(新建前)→ total 0 +14) 新建 target=ALL → {"code":0,"data":2111010133357236226} +15) 新建 target=CREATOR → {"code":0,"data":2111010133537591298} +16) 新建 expire_at=2020-01-01(已过期) → {"code":0} +17) C 端 GET /app/notices(匿名)→ total 1,只返回第 14 步那条 +18) 后台下架第 14 步那条 → C 端 total 0 +``` + +越权自测(`content.editor01` / role=content_operator,无 `product:*` / `notice:*`): + +``` +GET /admin/products/page → {"code":403,"message":"无权限"} +GET /admin/notices/page → {"code":403,"message":"无权限"} +POST /admin/products → {"code":403,"message":"无权限"} +PUT /admin/notices/1/status → {"code":403,"message":"无权限"} +(无 token)GET /admin/products/page → {"code":401,"message":"未登录或登录已过期"} +``` + +解读:商品三类校验、VIP 只建不启、公告"C 端可见/不可见"三个分支、越权 401/403 全部实测通过。 +`logs` 里 `200002 该商品未在后台配置` 的触发点在商店校验**之后**(`/app/orders/verify` 当前默认 +`DisabledStoreVerifier` → 先返回 `200003 商店校验尚未配置`),所以 C 端链路验证到"进入商店校验环节"为止, +真正的发币链路等 Google Play 凭据到位(见回执「待确认」)。 + +### 动作 6:清理第一轮自测数据 + +``` +DELETE 3 # 3 个自测商品 +DELETE 4 # 4 条自测公告 + products | soft_deleted notices + 3 | 0 0 +``` + +### 动作 7:前端页面(ReelShortWeb,develop 分支) + +新增 4 个文件(首行署名 `// Author: fruit.com.cn` / ``): + +``` +src/api/admin/product.ts src/api/admin/notice.ts +src/views/commerce/ProductManagementView.vue src/views/operation/NoticeManagementView.vue +``` + +**挂载机制实测**(与 INBOX 描述有出入,以实测为准):菜单树来自 DB `sys_menu`, +`src/utils/routerHelper.ts` 用 `import.meta.glob('../views/**/*.{vue,tsx}')` 按 `component` 字符串**动态解析**; +`src/router/index.ts` 的 `asyncRouterMap` 只静态声明 dashboard/content/user/system 这几个早期分组, +commerce/governance/board/creator-center 都不在里面 —— 所以新增页面**不需要**改静态路由表, +只要 ① 建 `.vue` ② 菜单 `component` 与视图路径一致。本轮据此实现,未动 `src/router/index.ts`。 + +前端门禁: + +``` +$ pnpm ts:check → 0 错误(无输出) +$ pnpm exec eslint --ext .js,.ts,.vue ./src + ✖ 1 problem (1 error, 0 warnings) # 第一遍:NoticeManagementView.vue prettier 折行 + (改完复跑)→ 无输出,0 error +$ pnpm build:pro → Build successful. Please see dist-pro directory +``` + +### 动作 8:`:3005` 真浏览器实测(Codex in-app browser) + +``` +登录 admin → 侧边栏出现「订单与权益 → 商品配置」「运营管理 → 系统公告」(其余菜单不变) +#/commerce/products → 标题「商品配置」,11 列与后端字段一一对应,状态列用 statusText(已上架/已下架) +#/operation/notices → 标题「系统公告」,空态「还没有发布公告」 +新建商品(UI)→ toast「商品已创建(默认下架,请确认后再上架)」,行内 totalCoin 550、状态「已下架」 +新建公告(UI)→ toast「公告已发布」,行内 发布时间 16:32:15、失效时间「-」、状态「已发布」 +C 端 GET /app/notices(匿名)→ 立刻能看到这条(total 1) +下架 → toast「公告已下架」→ C 端 total 0 +删除 → toast「商品已删除」「公告已删除」→ 列表回到种子数据 +溢出检查(1440/1280/1024,两页各一次) + 1440: docScroll=1440 clientWidth=1440 overflowX=false + 1280: docScroll=1280 clientWidth=1280 overflowX=false + 1024: docScroll=1024 clientWidth=1024 overflowX=false +截图:/tmp/i1-product-list.png、/tmp/i1-notice-list.png +``` + +### 动作 9:❌ 实测抓到的真缺陷 —— 雪花 id 在浏览器里被四舍五入 + +UI 上点「下架」时后端回: + +``` +{"code":200002,"message":"公告不存在"} # 明明列表里就有这条 +``` + +定位: + +``` +$ node -e 'console.log(2111011206851596290, Number.isSafeInteger(2111011206851596290))' +2111011206851596300 false +$ psql -c "select id, title from ops_notice;" + 2111011206851596290 | I1 自测公告(UI 创建) # DB 里是 …596290 +``` + +解读:S0 换雪花主键后 id 已超过 JS 安全整数范围(2^53-1 = 9007199254740991)。 +浏览器 `JSON.parse` 把 `2111011206851596290` 变成 `2111011206851596300`, +页面拿这个被改过的 id 回查 → `200002`。**这是 S0 之后所有"新建即操作"的通用风险**, +只是本轮新建的页面第一个撞上。 + +修复(**限定在本轮两个模块的契约内**,不动全局序列化、不动 app-api,避免影响 Flutter 客户端): + +``` +fruitshorts-module-commerce/.../ProductRespVO.java id 加 @JsonSerialize(using = ToStringSerializer.class) +fruitshorts-module-system/.../NoticeRespVO.java 同上 +ProductAdminController#create / NoticeAdminController#create 返回 Result +ReelShortWeb/src/api/admin/{product,notice}.ts id: string +docs/api-phase1.md §2.11/§2.12 写明"id 以字符串下发" +``` + +新增两条防回归测试(把真实雪花 id 喂进去断言是字符串): +`ProductAdminControllerContractTest#should_send_string_id_when_product_id_exceeds_js_safe_integer`、 +`NoticeAdminControllerContractTest#should_send_string_id_when_notice_id_exceeds_js_safe_integer`。 + +### 动作 10:修复后复跑门禁(最终数字) + +``` +$ mvn test +[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (module-commerce) +[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0 (admin-api) +[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (app-api) +[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (transcode-worker) +[INFO] BUILD SUCCESS # 共 110 条(存量 91 + 新增 19) +$ mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests → BUILD SUCCESS +``` + +修复后复测(同一浏览器,重新起服务后): + +``` +点「已发布」→ toast「公告已下架」,行内状态变「已下架」 # 修复前这里是「公告不存在」 +C 端 GET /app/notices → {"total":0} # 下架后客户端立即不可见 +接口返回类型核对:id= 2111012331625517057 type= str +``` + +### 动作 11:自测数据清理(收尾) + +``` +DELETE 1 # 商品 i1_ui_coin_880(第一轮 UI 建的) +DELETE 4 # 公告 4 条(含 UI 建的) +DELETE 1 # 商品 i1_ui2_coin_500(修复后 UI 建的) +DELETE 1 # 公告 I1 复测公告:印尼开服 + products | soft_deleted notices + 3 | 0 0 +``` + +解读:`product` 表回到 3 行种子(`coin_100 / coin_550 / coin_1200`),`ops_notice` 回 0 行。 +**没有留下任何自测数据。** + +### 本轮遗留(已写进 INBOX 回执的「待确认」) + +1. 雪花 id 的全局方案:admin-api 是否统一 `Long → String`(涉及所有既有页面的 `id` 类型), + app-api 是否要动(影响 Flutter 客户端解析)——属于平台级改动,等裁定。 +2. `/app/orders/verify` 的真发币链路需要 Google Play 凭据(B1-1 已列清单)。 diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java index e5fe5ea..b79ad58 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java @@ -36,6 +36,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "后台-菜单管理", description = "菜单树维护"), @Tag(name = "后台-操作日志", description = "操作日志检索"), @Tag(name = "后台-订单管理", description = "订单检索、补发与退款"), + @Tag(name = "后台-商品配置", description = "商品(金币包)配置与上下架"), + @Tag(name = "后台-系统公告", description = "系统公告发布与管理(与 C 端口径一致)"), @Tag(name = "后台-权限点", description = "权限点查询"), @Tag(name = "后台-分集收费", description = "分集价格配置"), @Tag(name = "后台-对账", description = "对账任务与手动对账"), diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/NoticeAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/NoticeAdminController.java new file mode 100644 index 0000000..4eeb2e1 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/NoticeAdminController.java @@ -0,0 +1,91 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.system.service.OpsNoticeService; +import com.fruitshorts.system.vo.NoticeQueryReqVO; +import com.fruitshorts.system.vo.NoticeRespVO; +import com.fruitshorts.system.vo.NoticeSaveReqVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 后台系统公告管理。 + * + *

发布后的可见性与 C 端 `GET /app/notices` **共用同一套筛选实现** + * (`OpsNoticeService.pageForApp`):`status=1` + `target ∈ {ALL, USER}` + 已到发布时间 + 未过期。 + */ +@Tag(name = "后台-系统公告") +@RestController +@RequestMapping("/admin/notices") +@RequiredArgsConstructor +public class NoticeAdminController { + + private final OpsNoticeService opsNoticeService; + + @Operation(summary = "公告分页查询") + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('notice:list')") + public Result> page(@Validated NoticeQueryReqVO req) { + return Result.ok(opsNoticeService.pageForAdmin(req)); + } + + @Operation(summary = "公告详情") + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermission('notice:list')") + public Result detail(@PathVariable Long id) { + return Result.ok(opsNoticeService.detail(id)); + } + + @Operation(summary = "新建公告(默认发布,C 端立即可见;返回新公告 id,字符串)") + @PostMapping + @PreAuthorize("@ss.hasPermission('notice:update')") + @OperationLog(module = "系统公告", action = "新建公告") + public Result create(@Valid @RequestBody NoticeSaveReqVO req) { + // 雪花 id 用字符串回,避免前端拿到被四舍五入的 id + return Result.ok(String.valueOf(opsNoticeService.create(req))); + } + + @Operation(summary = "编辑公告") + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPermission('notice:update')") + @OperationLog(module = "系统公告", action = "编辑公告") + public Result update(@PathVariable Long id, @Valid @RequestBody NoticeSaveReqVO req) { + opsNoticeService.update(id, req); + return Result.ok(); + } + + @Operation(summary = "发布 / 下架(下架后 C 端立即不可见)") + @PutMapping("/{id}/status") + @PreAuthorize("@ss.hasPermission('notice:update')") + @OperationLog(module = "系统公告", action = "公告上下架") + public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + opsNoticeService.updateStatus(id, status); + return Result.ok(); + } + + @Operation(summary = "删除公告") + @DeleteMapping("/{id}") + @PreAuthorize("@ss.hasPermission('notice:update')") + @OperationLog(module = "系统公告", action = "删除公告") + public Result delete(@PathVariable Long id) { + opsNoticeService.delete(id); + return Result.ok(); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ProductAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ProductAdminController.java new file mode 100644 index 0000000..abc4ae0 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ProductAdminController.java @@ -0,0 +1,91 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.commerce.service.ProductService; +import com.fruitshorts.commerce.vo.ProductQueryReqVO; +import com.fruitshorts.commerce.vo.ProductRespVO; +import com.fruitshorts.commerce.vo.ProductSaveReqVO; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.OperationLog; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 后台商品(金币包)配置。 + * + *

商品是 C 端 `POST /app/orders/verify` 的发币依据(按 `store_product_id` 对齐商店商品), + * 所以 `code` 与"同平台 store_product_id"的唯一性、以及 type 与金额的匹配都在服务端强校验。 + */ +@Tag(name = "后台-商品配置") +@RestController +@RequestMapping("/admin/products") +@RequiredArgsConstructor +public class ProductAdminController { + + private final ProductService productService; + + @Operation(summary = "商品分页查询") + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('product:list')") + public Result> page(@Validated ProductQueryReqVO req) { + return Result.ok(productService.page(req)); + } + + @Operation(summary = "商品详情") + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermission('product:list')") + public Result detail(@PathVariable Long id) { + return Result.ok(productService.detail(id)); + } + + @Operation(summary = "新建商品(默认下架,需显式上架;返回新商品 id,字符串)") + @PostMapping + @PreAuthorize("@ss.hasPermission('product:update')") + @OperationLog(module = "商品配置", action = "新建商品") + public Result create(@Valid @RequestBody ProductSaveReqVO req) { + // 雪花 id 用字符串回,避免前端拿到被四舍五入的 id + return Result.ok(String.valueOf(productService.create(req))); + } + + @Operation(summary = "编辑商品") + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPermission('product:update')") + @OperationLog(module = "商品配置", action = "编辑商品") + public Result update(@PathVariable Long id, @Valid @RequestBody ProductSaveReqVO req) { + productService.update(id, req); + return Result.ok(); + } + + @Operation(summary = "上架 / 下架(会员商品在订阅上线前不能启用)") + @PutMapping("/{id}/status") + @PreAuthorize("@ss.hasPermission('product:update')") + @OperationLog(module = "商品配置", action = "商品上下架") + public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + productService.updateStatus(id, status); + return Result.ok(); + } + + @Operation(summary = "删除商品") + @DeleteMapping("/{id}") + @PreAuthorize("@ss.hasPermission('product:update')") + @OperationLog(module = "商品配置", action = "删除商品") + public Result delete(@PathVariable Long id) { + productService.delete(id); + return Result.ok(); + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/AdminPermissionAnnotationTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/AdminPermissionAnnotationTest.java new file mode 100644 index 0000000..ab3ba57 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/AdminPermissionAnnotationTest.java @@ -0,0 +1,61 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 权限点注解检查:新增的写接口必须带 `@PreAuthorize`,且权限点与契约一致。 + * + *

为什么用反射而不是起 Spring 上下文:{@code @PreAuthorize} 靠方法代理生效, + * 切片测试(standalone MockMvc)不会走代理,跑出来是"永远通过"的假绿; + * 直接检查注解,能真正确认"忘写权限点"这类问题(无权限点被拒的前提是**有**权限点)。 + */ +class AdminPermissionAnnotationTest { + + @Test + void should_declare_expected_permissions_when_checking_new_admin_endpoints() { + assertThat(permissionsOf(ProductAdminController.class)) + .containsExactlyInAnyOrder("product:list", "product:list", + "product:update", "product:update", "product:update", "product:update"); + assertThat(permissionsOf(NoticeAdminController.class)) + .containsExactlyInAnyOrder("notice:list", "notice:list", + "notice:update", "notice:update", "notice:update", "notice:update"); + } + + /** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */ + private List permissionsOf(Class controller) { + List permissions = new ArrayList<>(); + for (Method method : controller.getDeclaredMethods()) { + if (!isEndpoint(method)) { + continue; + } + PreAuthorize annotation = method.getAnnotation(PreAuthorize.class); + permissions.add(annotation == null ? "MISSING" : extract(annotation.value())); + } + return permissions; + } + + private boolean isEndpoint(Method method) { + return method.isAnnotationPresent(GetMapping.class) || method.isAnnotationPresent(PostMapping.class) + || method.isAnnotationPresent(PutMapping.class) || method.isAnnotationPresent(DeleteMapping.class); + } + + /** 从 `@ss.hasPermission('product:list')` 里取出权限码。 */ + private String extract(String expression) { + int start = expression.indexOf('\''); + int end = expression.lastIndexOf('\''); + return start >= 0 && end > start ? expression.substring(start + 1, end) : expression; + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/NoticeAdminControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/NoticeAdminControllerContractTest.java new file mode 100644 index 0000000..27a7337 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/NoticeAdminControllerContractTest.java @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.support.ControllerContractSupport; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.system.service.OpsNoticeService; +import com.fruitshorts.system.vo.NoticeRespVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** `/admin/notices` 接入层契约。 */ +class NoticeAdminControllerContractTest { + + private OpsNoticeService opsNoticeService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + opsNoticeService = mock(OpsNoticeService.class); + mockMvc = ControllerContractSupport.mockMvcOf(new NoticeAdminController(opsNoticeService)); + } + + @Test + void should_return_paged_notices_when_page_is_requested() throws Exception { + NoticeRespVO vo = new NoticeRespVO(); + vo.setId(1L); + vo.setTitle("开服公告"); + vo.setTarget("ALL"); + vo.setTargetText("全部用户"); + vo.setType("SYSTEM"); + vo.setTypeText("系统"); + vo.setStatus(1); + vo.setStatusText("已发布"); + given(opsNoticeService.pageForAdmin(any())).willReturn(new PageResult<>(1L, List.of(vo))); + + mockMvc.perform(get("/admin/notices/page").param("status", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list[0].title").value("开服公告")) + .andExpect(jsonPath("$.data.list[0].statusText").value("已发布")) + .andExpect(jsonPath("$.data.list[0].targetText").value("全部用户")); + } + + @Test + void should_return_new_id_when_creating_notice() throws Exception { + given(opsNoticeService.create(any())).willReturn(66L); + + mockMvc.perform(post("/admin/notices") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"开服公告\",\"content\":\"欢迎\",\"target\":\"ALL\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + // id 以字符串回:雪花 id 直接下发数字会被浏览器四舍五入 + .andExpect(jsonPath("$.data").value("66")); + } + + @Test + void should_send_string_id_when_notice_id_exceeds_js_safe_integer() throws Exception { + NoticeRespVO vo = new NoticeRespVO(); + vo.setId(2111011206851596290L); + vo.setTitle("开服公告"); + given(opsNoticeService.pageForAdmin(any())).willReturn(new PageResult<>(1L, List.of(vo))); + + mockMvc.perform(get("/admin/notices/page")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[0].id").value("2111011206851596290")) + .andExpect(jsonPath("$.data.list[0].id").isString()); + } + + @Test + void should_return_bad_request_when_title_is_missing() throws Exception { + mockMvc.perform(post("/admin/notices") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"没有标题\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); + } + + @Test + void should_map_not_found_when_notice_does_not_exist() throws Exception { + given(opsNoticeService.detail(any())).willThrow( + new ServiceException(ErrorCode.DATA_NOT_FOUND, "公告不存在")); + + mockMvc.perform(get("/admin/notices/404")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value("公告不存在")); + } + + @Test + void should_pass_status_when_publishing_or_taking_down_notice() throws Exception { + mockMvc.perform(put("/admin/notices/9/status").param("status", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(opsNoticeService).updateStatus(eq(9L), eq(0)); + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ProductAdminControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ProductAdminControllerContractTest.java new file mode 100644 index 0000000..0868cf6 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ProductAdminControllerContractTest.java @@ -0,0 +1,122 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.support.ControllerContractSupport; +import com.fruitshorts.commerce.service.ProductService; +import com.fruitshorts.commerce.vo.ProductRespVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** `/admin/products` 接入层契约:路由、参数绑定、统一响应体、错误码映射。 */ +class ProductAdminControllerContractTest { + + private ProductService productService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + productService = mock(ProductService.class); + mockMvc = ControllerContractSupport.mockMvcOf(new ProductAdminController(productService)); + } + + @Test + void should_return_paged_products_when_page_is_requested() throws Exception { + ProductRespVO vo = new ProductRespVO(); + vo.setId(1L); + vo.setCode("coin_100"); + vo.setName("100 金币"); + vo.setType("COIN_PACK"); + vo.setTypeText("金币包"); + vo.setStatus(1); + vo.setStatusText("已上架"); + vo.setTotalCoin(100L); + given(productService.page(any())).willReturn(new PageResult<>(1L, List.of(vo))); + + mockMvc.perform(get("/admin/products/page").param("type", "COIN_PACK")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.list[0].code").value("coin_100")) + // 状态中文由服务端给(前端不写死) + .andExpect(jsonPath("$.data.list[0].statusText").value("已上架")) + .andExpect(jsonPath("$.data.list[0].typeText").value("金币包")); + } + + @Test + void should_return_new_id_when_creating_product() throws Exception { + given(productService.create(any())).willReturn(88L); + + mockMvc.perform(post("/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"coin_100\",\"name\":\"100 金币\",\"coinAmount\":100}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + // id 以字符串回:雪花 id 直接下发数字会被浏览器四舍五入 + .andExpect(jsonPath("$.data").value("88")); + } + + @Test + void should_send_string_id_when_product_id_exceeds_js_safe_integer() throws Exception { + ProductRespVO vo = new ProductRespVO(); + // 一个真实量级的雪花 id(> 2^53-1),前端按字符串原样回传才不会被四舍五入 + vo.setId(2111010131922784257L); + vo.setCode("coin_big_id"); + given(productService.page(any())).willReturn(new PageResult<>(1L, List.of(vo))); + + mockMvc.perform(get("/admin/products/page")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[0].id").value("2111010131922784257")) + .andExpect(jsonPath("$.data.list[0].id").isString()); + } + + @Test + void should_return_bad_request_when_code_is_missing() throws Exception { + // @NotBlank 校验失败 → 全局异常处理器给出 400 信封 + mockMvc.perform(post("/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"没有编码\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); + } + + @Test + void should_map_data_exists_when_store_product_id_is_duplicated() throws Exception { + given(productService.create(any())).willThrow( + new ServiceException(ErrorCode.DATA_EXISTS, "该平台下商店商品 id 已被占用:coin_100")); + + mockMvc.perform(post("/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"coin_x\",\"name\":\"X\",\"storeProductId\":\"coin_100\",\"coinAmount\":100}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.DATA_EXISTS.getCode())) + .andExpect(jsonPath("$.message").value("该平台下商店商品 id 已被占用:coin_100")); + } + + @Test + void should_pass_status_when_updating_product_status() throws Exception { + mockMvc.perform(put("/admin/products/8/status").param("status", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(productService).updateStatus(eq(8L), eq(1)); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java index 1e1ef0e..84382fc 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java @@ -2,48 +2,30 @@ package com.fruitshorts.app.service; -import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fruitshorts.app.vo.AppNoticeVO; import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.system.dal.entity.OpsNotice; -import com.fruitshorts.system.dal.mapper.OpsNoticeMapper; +import com.fruitshorts.system.service.OpsNoticeService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; -import java.util.List; - /** * C 端系统公告(`GET /app/notices`,匿名,只读)。 * - *

口径:`status=1` + `target ∈ {ALL, USER}` + 已到发布时间 + 未过期; - * 按 `publish_at desc, id desc` 排序。**没有数据就是空列表**(不是 404)—— - * 公告当前也没有后台产出方,客户端拿到空列表属正常状态。 + *

筛选口径**不在本类实现**:统一走 `OpsNoticeService.pageForApp(...)` —— + * 后台公告管理(`/admin/notices`)用的是同一套条件,避免"后台发布后 C 端到底能不能看到"两处条件漂移。 + * + *

没有数据返回空列表(不是 404);本类只负责把实体转成 C 端字段(不暴露后台字段)。 */ @Service @RequiredArgsConstructor public class AppNoticeService { - private static final String TARGET_ALL = "ALL"; - private static final String TARGET_USER = "USER"; - private static final int MAX_PAGE_SIZE = 50; - - private final OpsNoticeMapper opsNoticeMapper; + private final OpsNoticeService opsNoticeService; public PageResult page(int pageIndex, int pageSize) { - LocalDateTime now = LocalDateTime.now(); - Page page = new Page<>(pageIndex < 1 ? 1 : pageIndex, - pageSize < 1 ? 20 : Math.min(pageSize, MAX_PAGE_SIZE)); - IPage result = opsNoticeMapper.selectPage(page, Wrappers.lambdaQuery() - .eq(OpsNotice::getStatus, 1) - .in(OpsNotice::getTarget, List.of(TARGET_ALL, TARGET_USER)) - .and(w -> w.isNull(OpsNotice::getPublishAt).or().le(OpsNotice::getPublishAt, now)) - .and(w -> w.isNull(OpsNotice::getExpireAt).or().gt(OpsNotice::getExpireAt, now)) - .orderByDesc(OpsNotice::getPublishAt) - .orderByDesc(OpsNotice::getId)); - return new PageResult<>(result.getTotal(), result.getRecords().stream().map(this::toVO).toList()); + PageResult page = opsNoticeService.pageForApp(pageIndex, pageSize); + return new PageResult<>(page.getTotal(), page.getList().stream().map(this::toVO).toList()); } private AppNoticeVO toVO(OpsNotice notice) { 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 5098e68..98146e7 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 @@ -12,6 +12,7 @@ import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper; import com.fruitshorts.commerce.service.DisabledStoreVerifier; import com.fruitshorts.commerce.service.EntitlementService; import com.fruitshorts.commerce.service.OrderService; +import com.fruitshorts.commerce.service.ProductService; import com.fruitshorts.commerce.service.StoreVerifier; import com.fruitshorts.commerce.service.UnlockService; import com.fruitshorts.commerce.service.WalletService; @@ -65,8 +66,8 @@ class AppOrdersVerifyContractTest { storeReceiptMapper = mock(StoreReceiptMapper.class); verifier = new StubStoreVerifier(); OrderService orderService = new OrderService(ordersMapper, mock(OrderEventMapper.class), - productMapper, storeReceiptMapper, verifier, mock(WalletService.class), - mock(EntitlementService.class)); + new ProductService(productMapper), storeReceiptMapper, verifier, + mock(WalletService.class), mock(EntitlementService.class)); mockMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController( mock(WalletService.class), mock(UnlockService.class), orderService)); SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( @@ -131,7 +132,7 @@ class AppOrdersVerifyContractTest { void should_keep_disabled_behaviour_when_store_is_not_configured() throws Exception { // 默认实现(未配凭据):对外仍是"尚未配置",不产生订单 OrderService disabled = new OrderService(ordersMapper, mock(OrderEventMapper.class), - productMapper, storeReceiptMapper, new DisabledStoreVerifier(), + 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)); 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 2122b53..36fe7c8 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 @@ -11,7 +11,6 @@ import com.fruitshorts.commerce.dal.entity.Product; import com.fruitshorts.commerce.dal.entity.StoreReceipt; import com.fruitshorts.commerce.dal.mapper.OrderEventMapper; import com.fruitshorts.commerce.dal.mapper.OrdersMapper; -import com.fruitshorts.commerce.dal.mapper.ProductMapper; import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper; import com.fruitshorts.commerce.service.store.StorePlatforms; import com.fruitshorts.commerce.vo.OrderQueryReqVO; @@ -54,7 +53,7 @@ public class OrderService { private final OrdersMapper ordersMapper; private final OrderEventMapper orderEventMapper; - private final ProductMapper productMapper; + private final ProductService productService; private final StoreReceiptMapper storeReceiptMapper; private final StoreVerifier storeVerifier; private final WalletService walletService; @@ -130,7 +129,7 @@ public class OrderService { return deliver(existing, "STORE", null, "票据重复提交,返回既有订单"); } - Product product = findProduct(store, result.productId()); + Product product = productService.findForStore(store, result.productId()); if (product == null) { throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "该商品未在后台配置,请联系客服:" + result.productId()); @@ -180,18 +179,6 @@ public class OrderService { } } - /** 商品按 `(平台, 商店商品 id)` 找;`platform=ALL` 的通用商品也认。 */ - private Product findProduct(String platform, String storeProductId) { - if (!StringUtils.hasText(storeProductId)) { - return null; - } - List products = productMapper.selectList(Wrappers.lambdaQuery() - .eq(Product::getStoreProductId, storeProductId) - .in(Product::getPlatform, List.of(platform, "ALL")) - .eq(Product::getStatus, 1)); - return products.isEmpty() ? null : products.get(0); - } - /** 从校验结果建单:状态先记 PAID(商店已收款),交付由 deliver 推进到 DELIVERED。 */ private Orders buildOrder(Long userId, String platform, Product product, StoreVerifier.StoreReceiptResult result, String receipt) { 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 new file mode 100644 index 0000000..921b090 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java @@ -0,0 +1,223 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.commerce.dal.entity.Product; +import com.fruitshorts.commerce.dal.mapper.ProductMapper; +import com.fruitshorts.commerce.vo.ProductQueryReqVO; +import com.fruitshorts.commerce.vo.ProductRespVO; +import com.fruitshorts.commerce.vo.ProductSaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 商品(金币包)配置:后台 CRUD。 + * + *

几条硬校验(对不上会直接影响发币口径,所以放在服务端而不是只靠前端): + *

+ */ +@Service +@RequiredArgsConstructor +public class ProductService { + + public static final String TYPE_COIN = "COIN_PACK"; + public static final String TYPE_VIP = "VIP"; + public static final String PLATFORM_ALL = "ALL"; + + private static final List PLATFORMS = List.of(PLATFORM_ALL, "GOOGLE", "APPLE"); + + private final ProductMapper productMapper; + + public PageResult page(ProductQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + IPage result = productMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(StringUtils.hasText(req.getType()), Product::getType, req.getType()) + .eq(req.getStatus() != null, Product::getStatus, req.getStatus()) + .eq(StringUtils.hasText(req.getPlatform()), Product::getPlatform, req.getPlatform()) + .and(StringUtils.hasText(req.getKeyword()), w -> w + .like(Product::getCode, req.getKeyword()) + .or().like(Product::getName, req.getKeyword())) + .orderByAsc(Product::getSort) + .orderByDesc(Product::getId)); + return new PageResult<>(result.getTotal(), + result.getRecords().stream().map(ProductRespVO::of).toList()); + } + + public ProductRespVO detail(Long id) { + return ProductRespVO.of(getExisting(id)); + } + + @Transactional(rollbackFor = Exception.class) + public Long create(ProductSaveReqVO req) { + String code = req.getCode().trim(); + String platform = normalizePlatform(req.getPlatform()); + validateShape(req); + requireCodeFree(code, null); + requireStoreProductFree(platform, req.getStoreProductId(), null); + + Product product = new Product(); + product.setCode(code); + applyFields(product, req, platform); + // 新建默认下架:上架是显式动作,避免半配置的商品立刻对客户端可见 + product.setStatus(req.getStatus() == null ? 0 : req.getStatus()); + requireVipNotEnabled(product); + productMapper.insert(product); + return product.getId(); + } + + @Transactional(rollbackFor = Exception.class) + public void update(Long id, ProductSaveReqVO req) { + Product product = getExisting(id); + String code = req.getCode().trim(); + String platform = normalizePlatform(req.getPlatform()); + validateShape(req); + requireCodeFree(code, id); + requireStoreProductFree(platform, req.getStoreProductId(), id); + + product.setCode(code); + applyFields(product, req, platform); + if (req.getStatus() != null) { + product.setStatus(req.getStatus()); + } + requireVipNotEnabled(product); + productMapper.updateById(product); + } + + /** 上架 / 下架;VIP 商品只允许下架(第二阶段订阅上线后才放开)。 */ + @Transactional(rollbackFor = Exception.class) + public void updateStatus(Long id, Integer status) { + if (status == null || (status != 0 && status != 1)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0(下架)或 1(上架)"); + } + Product product = getExisting(id); + product.setStatus(status); + requireVipNotEnabled(product); + productMapper.updateById(product); + } + + @Transactional(rollbackFor = Exception.class) + public void delete(Long id) { + getExisting(id); + productMapper.deleteById(id); + } + + /** 供 C 端校验链路复用(按 store_product_id + 平台找商品)。 */ + public Product findForStore(String platform, String storeProductId) { + if (!StringUtils.hasText(storeProductId)) { + return null; + } + List products = productMapper.selectList(Wrappers.lambdaQuery() + .eq(Product::getStoreProductId, storeProductId) + .in(Product::getPlatform, List.of(normalizePlatform(platform), PLATFORM_ALL)) + .eq(Product::getStatus, 1)); + return products.isEmpty() ? null : products.get(0); + } + + private void applyFields(Product product, ProductSaveReqVO req, String platform) { + product.setName(req.getName().trim()); + product.setType(normalizeType(req.getType())); + product.setPlatform(platform); + product.setStoreProductId(req.getStoreProductId() == null ? "" : req.getStoreProductId().trim()); + product.setCoinAmount(req.getCoinAmount() == null ? 0L : req.getCoinAmount()); + product.setBonusCoin(req.getBonusCoin() == null ? 0L : req.getBonusCoin()); + product.setPriceAmount(req.getPriceAmount() == null ? BigDecimal.ZERO : req.getPriceAmount()); + product.setCurrency(StringUtils.hasText(req.getCurrency()) ? req.getCurrency() : "IDR"); + product.setDisplayPrice(req.getDisplayPrice() == null ? "" : req.getDisplayPrice()); + product.setRegionCodes(req.getRegionCodes() == null ? "" : req.getRegionCodes()); + product.setVipDays(req.getVipDays() == null ? 0 : req.getVipDays()); + product.setSort(req.getSort() == null ? 0 : req.getSort()); + product.setEffectiveAt(req.getEffectiveAt()); + product.setExpireAt(req.getExpireAt()); + } + + /** type 与金额/天数的匹配校验。 */ + private void validateShape(ProductSaveReqVO req) { + String type = normalizeType(req.getType()); + if (TYPE_VIP.equals(type)) { + if (req.getVipDays() == null || req.getVipDays() <= 0) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "会员商品的会员天数必须大于 0"); + } + return; + } + if (req.getCoinAmount() == null || req.getCoinAmount() <= 0) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "金币包的金币数量必须大于 0"); + } + } + + /** VIP 商品在订阅功能上线前不能启用(允许建档)。 */ + private void requireVipNotEnabled(Product product) { + boolean enabled = product.getStatus() != null && product.getStatus() == 1; + if (enabled && TYPE_VIP.equals(product.getType())) { + throw new ServiceException(ErrorCode.BAD_REQUEST, + "会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)"); + } + } + + private void requireCodeFree(String code, Long excludeId) { + Long count = productMapper.selectCount(Wrappers.lambdaQuery() + .eq(Product::getCode, code) + .ne(excludeId != null, Product::getId, excludeId)); + if (count != null && count > 0) { + throw new ServiceException(ErrorCode.DATA_EXISTS, "商品编码已存在:" + code); + } + } + + /** 同一平台下 store_product_id 必须唯一(ALL 视作"所有平台的通用商品",一并纳入比较)。 */ + private void requireStoreProductFree(String platform, String storeProductId, Long excludeId) { + if (!StringUtils.hasText(storeProductId)) { + return; + } + String id = storeProductId.trim(); + List scope = PLATFORM_ALL.equals(platform) + ? PLATFORMS + : List.of(platform, PLATFORM_ALL); + Long count = productMapper.selectCount(Wrappers.lambdaQuery() + .eq(Product::getStoreProductId, id) + .in(Product::getPlatform, scope) + .ne(excludeId != null, Product::getId, excludeId)); + if (count != null && count > 0) { + throw new ServiceException(ErrorCode.DATA_EXISTS, + "该平台下商店商品 id 已被占用:" + id); + } + } + + private Product getExisting(Long id) { + Product product = id == null ? null : productMapper.selectById(id); + if (product == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "商品不存在"); + } + return product; + } + + private String normalizeType(String type) { + return StringUtils.hasText(type) ? type.trim().toUpperCase() : TYPE_COIN; + } + + private String normalizePlatform(String platform) { + if (!StringUtils.hasText(platform)) { + return PLATFORM_ALL; + } + String value = platform.trim().toUpperCase(); + if (!PLATFORMS.contains(value)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "平台取值只能是 ALL / GOOGLE / APPLE"); + } + return value; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductQueryReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductQueryReqVO.java new file mode 100644 index 0000000..09c8a23 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductQueryReqVO.java @@ -0,0 +1,22 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.vo; + +import com.fruitshorts.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 后台商品检索条件。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ProductQueryReqVO extends PageParam { + + /** COIN_PACK / VIP。 */ + private String type; + /** 1 上架 / 0 下架。 */ + private Integer status; + /** ALL / GOOGLE / APPLE。 */ + private String platform; + /** 模糊匹配 code 或 name。 */ + private String keyword; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductRespVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductRespVO.java new file mode 100644 index 0000000..7352752 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductRespVO.java @@ -0,0 +1,92 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.vo; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fruitshorts.commerce.dal.entity.Product; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 后台商品响应:实体字段 + **中文文案**(`statusText` / `typeText`)。 + * + *

文案由服务端给:前端不再写死中文,避免"同一状态两种叫法"。 + */ +@Data +public class ProductRespVO { + + /** + * 雪花 id 超过 JS 的安全整数范围(2^53-1),直接下发数字会被浏览器四舍五入 —— + * 后台页面再拿这个 id 回查就会"商品不存在"。所以 id 一律以**字符串**下发, + * 前端原样拼进 URL 即可(详见 api-phase1.md §2.11)。 + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + private String code; + private String name; + private String type; + private String typeText; + private String platform; + private String storeProductId; + private Long coinAmount; + private Long bonusCoin; + /** 实际到账金币 = coinAmount + bonusCoin(展示用,避免前端各算一遍)。 */ + private Long totalCoin; + private BigDecimal priceAmount; + private String currency; + private String displayPrice; + private String regionCodes; + private Integer vipDays; + private Integer sort; + private Integer status; + private String statusText; + private LocalDateTime effectiveAt; + private LocalDateTime expireAt; + private LocalDateTime createdAt; + + public static ProductRespVO of(Product product) { + ProductRespVO vo = new ProductRespVO(); + vo.setId(product.getId()); + vo.setCode(product.getCode()); + vo.setName(product.getName()); + vo.setType(product.getType()); + vo.setTypeText(typeText(product.getType())); + vo.setPlatform(product.getPlatform()); + vo.setStoreProductId(product.getStoreProductId()); + vo.setCoinAmount(product.getCoinAmount()); + vo.setBonusCoin(product.getBonusCoin()); + long coins = (product.getCoinAmount() == null ? 0L : product.getCoinAmount()) + + (product.getBonusCoin() == null ? 0L : product.getBonusCoin()); + vo.setTotalCoin(coins); + vo.setPriceAmount(product.getPriceAmount()); + vo.setCurrency(product.getCurrency()); + vo.setDisplayPrice(product.getDisplayPrice()); + vo.setRegionCodes(product.getRegionCodes()); + vo.setVipDays(product.getVipDays()); + vo.setSort(product.getSort()); + vo.setStatus(product.getStatus()); + vo.setStatusText(isEnabled(product.getStatus()) ? "已上架" : "已下架"); + vo.setEffectiveAt(product.getEffectiveAt()); + vo.setExpireAt(product.getExpireAt()); + vo.setCreatedAt(product.getCreatedAt()); + return vo; + } + + private static boolean isEnabled(Integer status) { + return status != null && status == 1; + } + + private static String typeText(String type) { + if (type == null) { + return "-"; + } + return switch (type) { + case "COIN_PACK" -> "金币包"; + case "VIP" -> "会员(第二阶段)"; + default -> type; + }; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductSaveReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductSaveReqVO.java new file mode 100644 index 0000000..f85751c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductSaveReqVO.java @@ -0,0 +1,49 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 后台商品新增/编辑。 + * + *

字段与 `product` 表一致(不改表结构);业务校验在 `ProductService` 里做 + * (code 唯一、store_product_id 同平台唯一、type 与金额/天数匹配、VIP 不允许启用)。 + */ +@Data +public class ProductSaveReqVO { + + @NotBlank(message = "请填写商品编码") + @Size(max = 64, message = "商品编码过长") + private String code; + + @NotBlank(message = "请填写商品名称") + @Size(max = 128, message = "商品名称过长") + private String name; + + /** COIN_PACK(默认)/ VIP。 */ + private String type; + + /** ALL(默认)/ GOOGLE / APPLE。 */ + private String platform; + + @Size(max = 128, message = "商店商品 id 过长") + private String storeProductId; + + private Long coinAmount; + private Long bonusCoin; + private BigDecimal priceAmount; + private String currency; + private String displayPrice; + private String regionCodes; + private Integer vipDays; + private Integer sort; + private Integer status; + private LocalDateTime effectiveAt; + private LocalDateTime expireAt; +} diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceRefundTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceRefundTest.java index 4629985..198324b 100644 --- a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceRefundTest.java +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceRefundTest.java @@ -46,9 +46,9 @@ class OrderServiceRefundTest { orderEventMapper = mock(OrderEventMapper.class); walletService = mock(WalletService.class); entitlementService = mock(EntitlementService.class); - orderService = new OrderService(ordersMapper, orderEventMapper, mock(ProductMapper.class), - mock(StoreReceiptMapper.class), mock(StoreVerifier.class), walletService, - entitlementService); + orderService = new OrderService(ordersMapper, orderEventMapper, + new ProductService(mock(ProductMapper.class)), mock(StoreReceiptMapper.class), + mock(StoreVerifier.class), walletService, entitlementService); given(entitlementService.revokeByOrderNo(anyString(), any())).willReturn(1); } diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceVerifyTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceVerifyTest.java index 61dd368..dd0b951 100644 --- a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceVerifyTest.java +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceVerifyTest.java @@ -64,7 +64,7 @@ class OrderServiceVerifyTest { verifier = new StubStoreVerifier(); walletService = mock(WalletService.class); entitlementService = mock(EntitlementService.class); - orderService = new OrderService(ordersMapper, orderEventMapper, productMapper, + orderService = new OrderService(ordersMapper, orderEventMapper, new ProductService(productMapper), storeReceiptMapper, verifier, walletService, entitlementService); } 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 new file mode 100644 index 0000000..19f2451 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ProductServiceTest.java @@ -0,0 +1,155 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fruitshorts.commerce.dal.entity.Product; +import com.fruitshorts.commerce.dal.mapper.ProductMapper; +import com.fruitshorts.commerce.vo.ProductSaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 商品配置的业务校验。 + * + *

这几条直接决定 C 端发币口径,必须在服务端拦住(前端校验只是体验): + * `code` 唯一、`store_product_id` 同平台唯一、type 与金额/天数匹配、VIP 不允许启用。 + */ +class ProductServiceTest { + + private ProductMapper productMapper; + private ProductService productService; + + @BeforeAll + static void initTableInfoCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), Product.class); + } + + @BeforeEach + void setUp() { + productMapper = mock(ProductMapper.class); + productService = new ProductService(productMapper); + } + + @Test + void should_reject_when_code_already_exists() { + given(productMapper.selectCount(any())).willReturn(1L); + + assertThatThrownBy(() -> productService.create(coinPack("coin_100", "coin_100"))) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.DATA_EXISTS.getCode())) + .hasMessageContaining("商品编码已存在"); + verify(productMapper, never()).insert(any(Product.class)); + } + + @Test + void should_reject_when_store_product_id_is_taken_on_the_same_platform() { + // 第一次 selectCount 是 code 检查(通过),第二次是 store_product_id 检查(命中) + given(productMapper.selectCount(any())).willReturn(0L, 1L); + + assertThatThrownBy(() -> productService.create(coinPack("coin_550_new", "coin_550"))) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.DATA_EXISTS.getCode())) + .hasMessageContaining("商店商品 id 已被占用"); + verify(productMapper, never()).insert(any(Product.class)); + } + + @Test + void should_reject_when_coin_pack_has_no_coins() { + ProductSaveReqVO req = coinPack("coin_zero", "coin_zero"); + req.setCoinAmount(0L); + + assertThatThrownBy(() -> productService.create(req)) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode())) + .hasMessageContaining("金币数量必须大于 0"); + } + + @Test + void should_reject_when_vip_has_no_days() { + ProductSaveReqVO req = coinPack("vip_month", "vip_month"); + req.setType("VIP"); + req.setVipDays(0); + + assertThatThrownBy(() -> productService.create(req)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("会员天数必须大于 0"); + } + + @Test + void should_allow_creating_vip_but_reject_enabling_it() { + given(productMapper.selectCount(any())).willReturn(0L); + AtomicReference saved = captureInsert(); + ProductSaveReqVO req = coinPack("vip_year", "vip_year"); + req.setType("VIP"); + req.setVipDays(365); + req.setStatus(0); + + // 允许建档(下架状态) + productService.create(req); + 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("会员商品在订阅功能上线前不能启用"); + } + + @Test + void should_create_coin_pack_as_offline_when_status_is_missing() { + given(productMapper.selectCount(any())).willReturn(0L); + AtomicReference saved = captureInsert(); + + productService.create(coinPack("coin_999", "coin_999")); + + // 新建默认下架:上架是显式动作,避免半配置商品立刻对客户端可见 + assertThat(saved.get().getStatus()).isZero(); + assertThat(saved.get().getCoinAmount()).isEqualTo(500L); + } + + private AtomicReference captureInsert() { + AtomicReference saved = new AtomicReference<>(); + given(productMapper.insert(any(Product.class))).willAnswer(invocation -> { + Product product = invocation.getArgument(0); + product.setId(9L); + saved.set(product); + return 1; + }); + return saved; + } + + private ProductSaveReqVO coinPack(String code, String storeProductId) { + ProductSaveReqVO req = new ProductSaveReqVO(); + req.setCode(code); + req.setName("500 金币"); + req.setType("COIN_PACK"); + req.setPlatform("GOOGLE"); + req.setStoreProductId(storeProductId); + req.setCoinAmount(500L); + req.setBonusCoin(50L); + req.setCurrency("IDR"); + return req; + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/OpsNoticeService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/OpsNoticeService.java new file mode 100644 index 0000000..62fce51 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/OpsNoticeService.java @@ -0,0 +1,159 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.system.dal.entity.OpsNotice; +import com.fruitshorts.system.dal.mapper.OpsNoticeMapper; +import com.fruitshorts.system.vo.NoticeQueryReqVO; +import com.fruitshorts.system.vo.NoticeRespVO; +import com.fruitshorts.system.vo.NoticeSaveReqVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 系统公告:后台写入 + C 端读取(**同一个筛选实现**)。 + * + *

把 C 端的筛选条件收敛到 {@link #pageForApp(int, int)} 一处,后台与 App 共用 —— + * 否则"后台发布后 C 端能否看到"这种问题会变成两处条件对不对得上的猜谜。 + * + *

C 端口径(契约 §1.9.2):`status=1` 且 `target ∈ {ALL, USER}`、`publish_at` 为空或 `<= now`、 + * `expire_at` 为空或未过期,按 `publish_at desc, id desc`。 + */ +@Service +@RequiredArgsConstructor +public class OpsNoticeService { + + private static final String TARGET_ALL = "ALL"; + private static final String TARGET_USER = "USER"; + private static final List TARGETS = List.of(TARGET_ALL, TARGET_USER, "CREATOR"); + private static final List TYPES = List.of("SYSTEM", "AUDIT", "PAYMENT", "ACTIVITY"); + private static final int MAX_PAGE_SIZE = 50; + + private final OpsNoticeMapper opsNoticeMapper; + + // ─────────── 后台 ─────────── + + public PageResult pageForAdmin(NoticeQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + IPage result = opsNoticeMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(req.getStatus() != null, OpsNotice::getStatus, req.getStatus()) + .eq(StringUtils.hasText(req.getTarget()), OpsNotice::getTarget, req.getTarget()) + .eq(StringUtils.hasText(req.getType()), OpsNotice::getType, req.getType()) + .like(StringUtils.hasText(req.getKeyword()), OpsNotice::getTitle, req.getKeyword()) + .orderByDesc(OpsNotice::getPublishAt) + .orderByDesc(OpsNotice::getId)); + return new PageResult<>(result.getTotal(), + result.getRecords().stream().map(NoticeRespVO::of).toList()); + } + + public NoticeRespVO detail(Long id) { + return NoticeRespVO.of(getExisting(id)); + } + + /** 新建即发布(`status=1`);不传 `publish_at` 视为立即生效。 */ + @Transactional(rollbackFor = Exception.class) + public Long create(NoticeSaveReqVO req) { + validate(req); + OpsNotice notice = new OpsNotice(); + applyFields(notice, req); + notice.setStatus(1); + if (notice.getPublishAt() == null) { + notice.setPublishAt(LocalDateTime.now()); + } + opsNoticeMapper.insert(notice); + return notice.getId(); + } + + @Transactional(rollbackFor = Exception.class) + public void update(Long id, NoticeSaveReqVO req) { + validate(req); + OpsNotice notice = getExisting(id); + applyFields(notice, req); + opsNoticeMapper.updateById(notice); + } + + /** 发布(1)/ 下架(0):下架后 C 端立即不可见(C 端条件里要求 status=1)。 */ + @Transactional(rollbackFor = Exception.class) + public void updateStatus(Long id, Integer status) { + if (status == null || (status != 0 && status != 1)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0(下架)或 1(发布)"); + } + OpsNotice notice = getExisting(id); + notice.setStatus(status); + if (status == 1 && notice.getPublishAt() == null) { + // 下架再发布时补上发布时间,否则 C 端排序里它会跑到最后 + notice.setPublishAt(LocalDateTime.now()); + } + opsNoticeMapper.updateById(notice); + } + + @Transactional(rollbackFor = Exception.class) + public void delete(Long id) { + getExisting(id); + opsNoticeMapper.deleteById(id); + } + + // ─────────── C 端(与后台共用同一套筛选定义) ─────────── + + /** `GET /app/notices` 的数据源:只返回"对普通用户可见且在有效期内"的公告。 */ + public PageResult pageForApp(int pageIndex, int pageSize) { + LocalDateTime now = LocalDateTime.now(); + Page page = new Page<>(pageIndex < 1 ? 1 : pageIndex, + pageSize < 1 ? 20 : Math.min(pageSize, MAX_PAGE_SIZE)); + IPage result = opsNoticeMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(OpsNotice::getStatus, 1) + .in(OpsNotice::getTarget, List.of(TARGET_ALL, TARGET_USER)) + .and(w -> w.isNull(OpsNotice::getPublishAt).or().le(OpsNotice::getPublishAt, now)) + .and(w -> w.isNull(OpsNotice::getExpireAt).or().gt(OpsNotice::getExpireAt, now)) + .orderByDesc(OpsNotice::getPublishAt) + .orderByDesc(OpsNotice::getId)); + return new PageResult<>(result.getTotal(), result.getRecords()); + } + + private void applyFields(OpsNotice notice, NoticeSaveReqVO req) { + notice.setTitle(req.getTitle().trim()); + notice.setContent(req.getContent() == null ? "" : req.getContent()); + notice.setTarget(normalize(req.getTarget(), TARGETS, TARGET_ALL)); + notice.setType(normalize(req.getType(), TYPES, "SYSTEM")); + notice.setPublishAt(req.getPublishAt()); + notice.setExpireAt(req.getExpireAt()); + } + + private void validate(NoticeSaveReqVO req) { + if (req.getExpireAt() != null && req.getPublishAt() != null + && !req.getExpireAt().isAfter(req.getPublishAt())) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "失效时间必须晚于发布时间"); + } + } + + private String normalize(String value, List allowed, String fallback) { + if (!StringUtils.hasText(value)) { + return fallback; + } + String normalized = value.trim().toUpperCase(); + if (!allowed.contains(normalized)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, + "取值不合法:" + value + ",可选 " + String.join("/", allowed)); + } + return normalized; + } + + private OpsNotice getExisting(Long id) { + OpsNotice notice = id == null ? null : opsNoticeMapper.selectById(id); + if (notice == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "公告不存在"); + } + return notice; + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeQueryReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeQueryReqVO.java new file mode 100644 index 0000000..e074a81 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeQueryReqVO.java @@ -0,0 +1,22 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.vo; + +import com.fruitshorts.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 后台公告检索条件。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class NoticeQueryReqVO extends PageParam { + + /** 1 已发布 / 0 已下架。 */ + private Integer status; + /** ALL / USER / CREATOR。 */ + private String target; + /** SYSTEM / AUDIT / PAYMENT / ACTIVITY。 */ + private String type; + /** 模糊匹配标题。 */ + private String keyword; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeRespVO.java new file mode 100644 index 0000000..cfcc196 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeRespVO.java @@ -0,0 +1,76 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.vo; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fruitshorts.system.dal.entity.OpsNotice; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 后台公告响应:实体字段 + 中文文案(前端不写死中文)。 */ +@Data +public class NoticeRespVO { + + /** 同样是雪花 id 的精度问题:id 以字符串下发,避免浏览器把 id 四舍五入(见 api-phase1.md §2.12)。 */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + private String title; + private String content; + private String target; + private String targetText; + private String type; + private String typeText; + private Integer status; + private String statusText; + private LocalDateTime publishAt; + private LocalDateTime expireAt; + private LocalDateTime createdAt; + + public static NoticeRespVO of(OpsNotice notice) { + NoticeRespVO vo = new NoticeRespVO(); + vo.setId(notice.getId()); + vo.setTitle(notice.getTitle()); + vo.setContent(notice.getContent()); + vo.setTarget(notice.getTarget()); + vo.setTargetText(targetText(notice.getTarget())); + vo.setType(notice.getType()); + vo.setTypeText(typeText(notice.getType())); + vo.setStatus(notice.getStatus()); + vo.setStatusText(statusText(notice.getStatus())); + vo.setPublishAt(notice.getPublishAt()); + vo.setExpireAt(notice.getExpireAt()); + vo.setCreatedAt(notice.getCreatedAt()); + return vo; + } + + private static String statusText(Integer status) { + return status != null && status == 1 ? "已发布" : "已下架"; + } + + private static String targetText(String target) { + if (target == null) { + return "-"; + } + return switch (target) { + case "ALL" -> "全部用户"; + case "USER" -> "普通用户"; + case "CREATOR" -> "创作者"; + default -> target; + }; + } + + private static String typeText(String type) { + if (type == null) { + return "-"; + } + return switch (type) { + case "SYSTEM" -> "系统"; + case "AUDIT" -> "审核"; + case "PAYMENT" -> "支付"; + case "ACTIVITY" -> "活动"; + default -> type; + }; + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeSaveReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeSaveReqVO.java new file mode 100644 index 0000000..919946d --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/NoticeSaveReqVO.java @@ -0,0 +1,32 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 后台公告新增/编辑(字段与 `ops_notice` 表一致,不改表)。 */ +@Data +public class NoticeSaveReqVO { + + @NotBlank(message = "请填写公告标题") + @Size(max = 128, message = "公告标题过长") + private String title; + + @Size(max = 2000, message = "公告正文过长") + private String content; + + /** ALL(默认)/ USER / CREATOR。 */ + private String target; + + /** SYSTEM(默认)/ AUDIT / PAYMENT / ACTIVITY。 */ + private String type; + + /** 不传按"立即发布"处理(C 端条件里 publish_at 为空视为立即生效)。 */ + private LocalDateTime publishAt; + + private LocalDateTime expireAt; +} diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 8b5f49f..8c980c4 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -284,3 +284,43 @@ WHERE menu_id IN (26, 27, 28, 29, 30) -- 光收 sys_role_menu 不够:超管的菜单是"全量兜底",不看角色-菜单表。 -- 这几条置 0,超管的菜单树里就不会再出现创作者中心(连带隐藏的作品编辑路由)。 UPDATE sys_menu SET super_admin_visible = 0 WHERE id IN (26, 27, 28, 29, 30); + +-- ============================================================ +-- I1:商品(金币包)配置 + 系统公告管理 +-- ============================================================ +-- 权限点用 1160-1163 段(此前已用到 1155);菜单用 31/32/33(此前已用到 30)。 +-- 商品挂「订单与权益」(22) 下;公告新建「运营管理」分组(32) + 子菜单(33)。 +INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES + (1160, 'product:list', '查看商品配置', '订单与权益', 'BUTTON', 161), + (1161, 'product:update', '维护商品配置', '订单与权益', 'BUTTON', 162), + (1162, 'notice:list', '查看系统公告', '运营管理', 'BUTTON', 163), + (1163, 'notice:update', '维护系统公告', '运营管理', 'BUTTON', 164) +ON CONFLICT (code) DO NOTHING; + +-- 超管默认拥有这两个模块的全部权限点 +INSERT INTO sys_role_permission (id, role_id, permission_id) +SELECT r.id * 1000000 + p.id, r.id, p.id +FROM sys_role r +CROSS JOIN sys_permission p +WHERE r.code = 'super_admin' + AND p.code IN ('product:list', 'product:update', 'notice:list', 'notice:update') +ON CONFLICT DO NOTHING; + +-- 菜单:component 必须与前端视图路径一致(前端用 import.meta.glob 按字符串动态解析) +INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, + permission_code, type, always_show, affix, sort) VALUES + (31, 22, '商品配置', 'products', 'commerce-products', 'views/commerce/ProductManagementView', '', '', 'product:list', 'MENU', 0, 0, 2), + (32, 0, '运营管理', '/operation', 'OperationCenter', '#', '/operation/notices', 'ep:bell', 'notice:list', 'CATALOG', 1, 0, 9), + (33, 32, '系统公告', 'notices', 'operation-notices', 'views/operation/NoticeManagementView', '', '', 'notice:list', 'MENU', 0, 0, 1) +ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, + route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component, + redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, + type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; + +INSERT INTO sys_role_menu (id, role_id, menu_id) +SELECT r.id * 1000000 + m.id, r.id, m.id +FROM sys_role r +CROSS JOIN sys_menu m +WHERE r.code = 'super_admin' + AND m.id IN (31, 32, 33) +ON CONFLICT DO NOTHING;