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:
1 parent
7513ac06bc
commit
8d19a0cc0a
24 files changed
+1761
-48
No files matched your search
@@ -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`「尚未完成」。
|
||||
|
||||
@@ -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"处理)。
|
||||
@@ -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 = "对账任务与手动对账"),
|
||||
|
||||
+91
@@ -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();
|
||||
}
|
||||
}
|
||||
+91
@@ -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();
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
+116
@@ -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));
|
||||
}
|
||||
}
|
||||
+122
@@ -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));
|
||||
}
|
||||
}
|
||||
+8
-26
@@ -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) {
|
||||
|
||||
+4
-3
@@ -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));
|
||||
|
||||
+2
-15
@@ -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) {
|
||||
|
||||
+223
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+92
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-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);
|
||||
}
|
||||
|
||||
|
||||
+155
@@ -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;
|
||||
}
|
||||
}
|
||||
+159
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user