feat(admin): 商品配置与系统公告管理接口,id 以字符串下发

- /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 条全过
This commit is contained in:
jianghaifeng committed 2026-10-16 16:33:15 +08:00
1 parent 7513ac06bc
commit 8d19a0cc0a
24 files changed
+1761 -48

No files matched your search

+78
View File
@@ -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`「尚未完成」。
+68
View File
@@ -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"处理)。
+244
View File
@@ -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` / `<!-- 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<String>
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 已列清单)。
@@ -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 = "对账任务与手动对账"),
@@ -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;
/**
* 后台系统公告管理。
*
* <p>发布后的可见性与 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<PageResult<NoticeRespVO>> page(@Validated NoticeQueryReqVO req) {
return Result.ok(opsNoticeService.pageForAdmin(req));
}
@Operation(summary = "公告详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('notice:list')")
public Result<NoticeRespVO> 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<String> 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<Void> 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<Void> 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<Void> delete(@PathVariable Long id) {
opsNoticeService.delete(id);
return Result.ok();
}
}
@@ -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;
/**
* 后台商品(金币包)配置。
*
* <p>商品是 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<PageResult<ProductRespVO>> page(@Validated ProductQueryReqVO req) {
return Result.ok(productService.page(req));
}
@Operation(summary = "商品详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('product:list')")
public Result<ProductRespVO> detail(@PathVariable Long id) {
return Result.ok(productService.detail(id));
}
@Operation(summary = "新建商品(默认下架,需显式上架;返回新商品 id,字符串)")
@PostMapping
@PreAuthorize("@ss.hasPermission('product:update')")
@OperationLog(module = "商品配置", action = "新建商品")
public Result<String> 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<Void> 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<Void> 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<Void> delete(@PathVariable Long id) {
productService.delete(id);
return Result.ok();
}
}
@@ -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`,且权限点与契约一致。
*
* <p>为什么用反射而不是起 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<String> permissionsOf(Class<?> controller) {
List<String> 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;
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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`,匿名,只读)。
*
* <p>口径:`status=1` + `target ∈ {ALL, USER}` + 已到发布时间 + 未过期;
* 按 `publish_at desc, id desc` 排序。**没有数据就是空列表**(不是 404)——
* 公告当前也没有后台产出方,客户端拿到空列表属正常状态。
* <p>筛选口径**不在本类实现**:统一走 `OpsNoticeService.pageForApp(...)` ——
* 后台公告管理(`/admin/notices`)用的是同一套条件,避免"后台发布后 C 端到底能不能看到"两处条件漂移。
*
* <p>没有数据返回空列表(不是 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<AppNoticeVO> page(int pageIndex, int pageSize) {
LocalDateTime now = LocalDateTime.now();
Page<OpsNotice> page = new Page<>(pageIndex < 1 ? 1 : pageIndex,
pageSize < 1 ? 20 : Math.min(pageSize, MAX_PAGE_SIZE));
IPage<OpsNotice> result = opsNoticeMapper.selectPage(page, Wrappers.<OpsNotice>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<OpsNotice> page = opsNoticeService.pageForApp(pageIndex, pageSize);
return new PageResult<>(page.getTotal(), page.getList().stream().map(this::toVO).toList());
}
private AppNoticeVO toVO(OpsNotice notice) {
@@ -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));
@@ -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<Product> products = productMapper.selectList(Wrappers.<Product>lambdaQuery()
.eq(Product::getStoreProductId, storeProductId)
.in(Product::getPlatform, List.of(platform, "ALL"))
.eq(Product::getStatus, 1));
return products.isEmpty() ? null : products.get(0);
}
/** 从校验结果建单:状态先记 PAID(商店已收款),交付由 deliver 推进到 DELIVERED。 */
private Orders buildOrder(Long userId, String platform, Product product,
StoreVerifier.StoreReceiptResult result, String receipt) {
@@ -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。
*
* <p>几条硬校验(对不上会直接影响发币口径,所以放在服务端而不是只靠前端):
* <ul>
* <li>`code` 全局唯一;</li>
* <li>`store_product_id` 在**同一 `platform` 下唯一** —— 否则同一商店商品被配置成两个商品,
* 客户端 verify 时"按 storeProductId 找到第一个"会随机发其中一个的金币;</li>
* <li>`type=COIN_PACK` 要求 `coin_amount > 0`;`type=VIP` 要求 `vip_days > 0`;</li>
* <li>**VIP 允许建档、不允许启用**(与 B1-1「VIP 订阅本轮不做」一致)。</li>
* </ul>
*/
@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<String> PLATFORMS = List.of(PLATFORM_ALL, "GOOGLE", "APPLE");
private final ProductMapper productMapper;
public PageResult<ProductRespVO> page(ProductQueryReqVO req) {
Page<Product> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Product> result = productMapper.selectPage(page, Wrappers.<Product>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<Product> products = productMapper.selectList(Wrappers.<Product>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.<Product>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<String> scope = PLATFORM_ALL.equals(platform)
? PLATFORMS
: List.of(platform, PLATFORM_ALL);
Long count = productMapper.selectCount(Wrappers.<Product>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;
}
}
@@ -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;
}
@@ -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`)。
*
* <p>文案由服务端给:前端不再写死中文,避免"同一状态两种叫法"。
*/
@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;
};
}
}
@@ -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;
/**
* 后台商品新增/编辑。
*
* <p>字段与 `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;
}
@@ -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);
}
@@ -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);
}
@@ -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;
/**
* 商品配置的业务校验。
*
* <p>这几条直接决定 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<Product> 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<Product> saved = captureInsert();
productService.create(coinPack("coin_999", "coin_999"));
// 新建默认下架:上架是显式动作,避免半配置商品立刻对客户端可见
assertThat(saved.get().getStatus()).isZero();
assertThat(saved.get().getCoinAmount()).isEqualTo(500L);
}
private AtomicReference<Product> captureInsert() {
AtomicReference<Product> 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;
}
}
@@ -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 端读取(**同一个筛选实现**)。
*
* <p>把 C 端的筛选条件收敛到 {@link #pageForApp(int, int)} 一处,后台与 App 共用 ——
* 否则"后台发布后 C 端能否看到"这种问题会变成两处条件对不对得上的猜谜。
*
* <p>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<String> TARGETS = List.of(TARGET_ALL, TARGET_USER, "CREATOR");
private static final List<String> TYPES = List.of("SYSTEM", "AUDIT", "PAYMENT", "ACTIVITY");
private static final int MAX_PAGE_SIZE = 50;
private final OpsNoticeMapper opsNoticeMapper;
// ─────────── 后台 ───────────
public PageResult<NoticeRespVO> pageForAdmin(NoticeQueryReqVO req) {
Page<OpsNotice> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<OpsNotice> result = opsNoticeMapper.selectPage(page, Wrappers.<OpsNotice>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<OpsNotice> pageForApp(int pageIndex, int pageSize) {
LocalDateTime now = LocalDateTime.now();
Page<OpsNotice> page = new Page<>(pageIndex < 1 ? 1 : pageIndex,
pageSize < 1 ? 20 : Math.min(pageSize, MAX_PAGE_SIZE));
IPage<OpsNotice> result = opsNoticeMapper.selectPage(page, Wrappers.<OpsNotice>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<String> 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;
}
}
@@ -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;
}
@@ -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;
};
}
}
@@ -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;
}
+40
View File
@@ -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;