Merge remote-tracking branch 'origin/develop' into feature/deploy-admin-api-lan-access
This commit is contained in:
commit
9267a9026f
33 files changed
+2495
-53
No files matched your search
@@ -16,6 +16,29 @@
|
||||
|
||||
`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。
|
||||
|
||||
**ID 字段一律以字符串下发(2026-10-16 J1 起,全局生效)**
|
||||
|
||||
所有 ID 字段(主键与外键,例如 `id` / `dramaId` / `episodeId` / `userId` / `orderId` …)
|
||||
在 JSON 里都是**字符串**,不是数字:
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "ok", "data": { "id": "2111012753262120962", "dramaId": "5" } }
|
||||
```
|
||||
|
||||
原因:S0 之后主键是雪花 id,19 位十进制数已超过 JS 的 `Number.MAX_SAFE_INTEGER`
|
||||
(`9007199254740991`)。裸数字下发会被浏览器/IEEE754 客户端四舍五入
|
||||
(实测库里的 `2111012753262120962` 在浏览器里变成 `2111012753262121000`),
|
||||
页面再拿它回查就是 `200002 数据不存在`。
|
||||
|
||||
约定与边界:
|
||||
|
||||
- **id 回传时按字符串传**(路径变量 / 查询参数 / 请求体都接受字符串,服务端自己转 `Long`);
|
||||
- 实现方式:服务端**全局**把 `Long` / `long` 序列化成字符串(`fruitshorts-framework` 的
|
||||
`JacksonLongToStringConfig`),**各模块不再逐字段加注解**;
|
||||
- **例外:分页 `total` 仍是数字** —— 它是条数不是 id,前端分页组件要数字;
|
||||
`Integer` / `int` 字段(`pageIndex` / `pageSize` / 各类计数如 `totalEpisodes`、状态码 `code`)
|
||||
也**保持数字**。
|
||||
|
||||
**鉴权**:`Authorization: Bearer <token>`。
|
||||
管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。
|
||||
客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。
|
||||
@@ -742,6 +765,80 @@ 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` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**(本节不再单独规定)。
|
||||
|
||||
**校验与错误码**:
|
||||
|
||||
| 场景 | 结果 |
|
||||
| --- | --- |
|
||||
| `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` 以字符串下发:见 **§0「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,91 @@
|
||||
# J1 全局 ID 字符串化(Long → String)+ 契约统一 —— 计划(2026-10-16)
|
||||
|
||||
> 依据:`docs/plans/INBOX.md` 18:30 条目。用户裁定:雪花 id 精度问题**按方案 (a) 全局修改、统一契约**。
|
||||
> 起因:I1 实测「UI 点下架 → `200002 公告不存在`」,根因是 19 位雪花 id 超出 JS `Number.MAX_SAFE_INTEGER`
|
||||
> (9007199254740991),浏览器 `JSON.parse` 会把它四舍五入。
|
||||
|
||||
## 一、方案
|
||||
|
||||
### 1. 后端:fruitshorts-framework 里注册全局序列化模块
|
||||
|
||||
- 新增 `com.fruitshorts.framework.web.JacksonLongToStringConfig`:
|
||||
- `SimpleModule` 把 **`Long.class` 与 `Long.TYPE`(`long`)→ `ToStringSerializer`**;
|
||||
- 通过 `Jackson2ObjectMapperBuilderCustomizer` 挂进 Spring Boot 自动配置的 `ObjectMapper`。
|
||||
- 两个入口(admin-api / app-api)都是 `scanBasePackages = "com.fruitshorts"`,framework 在扫描范围内,
|
||||
一处生效即两端生效;**不改任何 yml**。
|
||||
- **不动 `Integer` / `int`**:`pageIndex` / `pageSize` / `Result.code` / `quantity` / `durationSec` …
|
||||
仍是数字。
|
||||
- **序列化方向**:`Long` 值一律输出成 JSON 字符串(含普通字段、嵌套对象、`List`、`Map` 值、分页 list 元素)。
|
||||
- **反序列化方向**:Jackson 默认允许 `"123"` → `Long` 的标量强转,所以**请求体里 id 传字符串可以直接绑定**;
|
||||
路径变量 `/…/{id}` 与查询参数走 Spring 的 `ConversionService`,本来就是 String → Long,不受影响。
|
||||
这两条都要有测试兜住。
|
||||
|
||||
### 2. 一个刻意的例外:`PageResult.total` 保持数字
|
||||
|
||||
`PageResult.total` 是 `long` 计数,不是 id。放行它会让后台 ~13 处 `el-pagination :total=` 与 C 端分页全部变成
|
||||
字符串,收益为零、风险不小。所以给这个字段加 `@JsonSerialize(using = NumberSerializer.class)` 显式保持数字,
|
||||
并在契约 §0 里写明「**id 一律字符串;分页 total 是计数,仍是数字**」。
|
||||
⚠️ 这是我自己拍的口径,已单独列进回执,若要求"Long 一律字符串"我再把这一行去掉。
|
||||
|
||||
### 3. 契约统一(`docs/api-phase1.md`)
|
||||
|
||||
- §0 通用约定新增「**所有 ID 字段(主键与外键)一律以字符串下发**」+ 示例 + `total` 例外说明。
|
||||
- §2.11 / §2.12(I1 商品/公告)里逐模块写的 "id 以字符串下发" 改成引用 §0。
|
||||
- **代码侧**:I1 给 `ProductRespVO` / `NoticeRespVO` 加的 `@JsonSerialize` 与 `Result<String>` 新建返回
|
||||
**回退成自然写法**(全局生效后它们就是冗余的特例),保持"所有模块一个口径"。
|
||||
|
||||
### 4. ReelShortWeb 适配(审计结果见 §二)
|
||||
|
||||
把 id 当数字用的地方逐处改成字符串语义。**不建议**的写法(`Number(id)` / `id === 数字`)全部清掉。
|
||||
|
||||
### 5. ReelShort(Flutter)适配(最小改动)
|
||||
|
||||
**先说结论:客户端 model 里的 id 已经是 `(json['id'] ?? '').toString()` —— id 方向本来就是字符串,不会崩。**
|
||||
会崩的是被全局规则扫到的 **Long 计数**(`as num?` 在收到字符串时直接抛异常)。逐个 Java 字段核过类型后,
|
||||
只有两个字段真正会崩:
|
||||
|
||||
| Dart 位置 | 字段 | Java 类型 | 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| `lib/core/data/models/auth_session.dart:41` | `expiresIn` | `TokenRespVO.expiresIn` = `long` | 改成兼容解析 |
|
||||
| `lib/core/data/models/drama.dart:101,117` | `viewCount` | `AppDramaCardVO/AppDramaDetailVO.viewCount` = `Long` | 改成兼容解析 |
|
||||
| 其余 8 处 `as num?`(`durationSec`/`unlockCoin`/`number`/`totalEpisodes`/`freeEpisodes`/`code`…) | — | `Integer`/`int` | 不动(Java 侧不会被字符串化) |
|
||||
|
||||
做法:加一个兼容解析小工具(接受 `num` 或数字字符串),替换上面 3 处。**不做模型改造、不碰业务逻辑**,
|
||||
避免和正在推进客户端的另一位同事冲突。
|
||||
|
||||
## 二、影响面清单(审计结果)
|
||||
|
||||
### ReelShortWeb(id 当数字用 → 必须改)
|
||||
|
||||
| 文件 | 问题 | 改法 |
|
||||
| --- | --- | --- |
|
||||
| `src/utils/creatorImpersonation.ts` | `Number(raw)` 读 localStorage 里的代看创作者 id → 19 位 id 被四舍五入,`X-Creator-Id` 头会带错值 | 存/读都按字符串 |
|
||||
| `src/views/contentManagement/EpisodeManagementView.vue:58` | `Number(route.query.drama)` 与 `item.id ===` 比较 → 从短剧管理带 `?drama=` 跳过来选不中 | 按字符串比较 |
|
||||
| `src/views/creator-center/composables/useDramaEdit.js:45` | `Number(route.params.id)` 与 `item.id ===` 比较 → 作品编辑找不到自己那条 | 按字符串比较 |
|
||||
| `src/views/permission/composables/useMenuManagement.js:50,76,97,117` | 顶级菜单哨兵值是数字 `0`,而 `raw.parentId` 会变成字符串 `"0"` → 下拉回显错 | 哨兵统一成字符串 `'0'` |
|
||||
|
||||
### ReelShortWeb(核过不需要改)
|
||||
|
||||
- `total.value = res.data.total`(13 处)→ `total` 保持数字,无需改。
|
||||
- `role.id === selectedRoleId`、`detail.id === row.id`、`useDramaEdit#findIndex`、`useCreatorPrice#find`
|
||||
→ 两侧都来自接口,同为字符串,仍然相等。
|
||||
- `formatCount(row.views)` → 内部 `Number(value)`,吃到字符串也正常。
|
||||
- 全局 `parseInt`/`Number(`/id 算术/id 当数组下标/`a.id - b.id`:审计后**没有命中**。
|
||||
|
||||
### FruitServer(会跟着变化、需确认消费方)
|
||||
|
||||
- admin-api:所有 `Long` id、`viewCount`、`likeCount`、`memberCount` 等计数器 → 字符串。
|
||||
- app-api:C 端 `id/dramaId/episodeId/userId`、`viewCount`、`positionMs/durationMs`、
|
||||
`downloadedBytes/totalBytes`、`TokenRespVO.expiresIn` → 字符串;`Result.code`、`PageResult.total`、
|
||||
`Integer` 字段 → 仍是数字。
|
||||
|
||||
## 三、风险 / 待确认
|
||||
|
||||
1. **`PageResult.total` 的例外**(见 §一.2)—— 我自己拍的,一句话可回退。
|
||||
2. **契约测试的 standalone MockMvc 自带 mapper**:它本来是手搓的 `JsonMapper`,不含全局模块,
|
||||
会让"字符串化"在测试里**假绿**。所以本轮把模块也挂进 `ControllerContractSupport`,让测试与生产一致;
|
||||
代价是几条老断言(id 断数字)要改成断字符串。
|
||||
3. **Flutter 仓库当前只有 `main`**(无 develop)。按条目要求在 develop 提交推送,会新建分支;
|
||||
客户端由另一位同事推进,若他在 main 上继续,需要把这笔改动带过去 —— 回执里会写清。
|
||||
4. `Map<Long, X>` 的 **key** 不在本次范围(只要求 Map 值);当前也没有接口返回 `Map<Long, ?>` 给前端。
|
||||
5. 大 id 只在"新建后立刻用 id 操作"时暴露;老页面用种子小 id,所以**回归要靠端到端实测**,不能只看单测。
|
||||
@@ -0,0 +1,291 @@
|
||||
# INBOX — 给 Codex 的指令队列(FruitServer 仓库内)
|
||||
|
||||
> **Codex 每次开始任务前必须先读本文件**(2026-10-16 起指令通道从 specs 仓库迁到本仓库)。
|
||||
> 按条目顺序执行,处理完的条目标记 `[已处理]`。指令来源:项目经理(Trae)。有异议在条目下留言,不要静默跳过。
|
||||
>
|
||||
> **规范以 specs 仓库为准**(`AGENTS.md`、`CODING_STANDARDS.md`、`GIT_WORKFLOW.md`、`docs/01~20`、`docs/plans/codex-研发任务清单.md`)——
|
||||
> specs 现在**只拉取、由项目负责人更新**,我们不再往它提交。工作留痕写在本仓库 `docs/plans/PROGRESS.md`。
|
||||
|
||||
> ⚠️ **双 Agent 协作红线(Trae + Codex 共守)**
|
||||
> 1. **单一写者**:同一时刻只有一个 agent 在改代码/写 PROGRESS/跑门禁。
|
||||
> 2. **留痕**:谁干活谁写 `docs/plans/PROGRESS.md`(命令 + 原始输出 + 解读,逐动作追加)。Trae 接手时用「【Trae 接手】」标记。
|
||||
> 3. **git 不冲突**:提交前先 `git status`/`git log`;只 `git add` 自己的文件,禁止 `git add -A`/`.`;不夹带对方改动。
|
||||
> 4. **文件不覆盖**:写 INBOX/PROGRESS 前先读最新,追加不覆盖。
|
||||
> 5. **求助通道**:遇到阻塞/不确定,在本文件顶部写「【待 Trae 协助】+ 问题描述」。
|
||||
> 6. **禅道同步**:任务「开始/完成」时同步更新禅道任务状态。
|
||||
> 7. **验收归用户**:每批做完**停在干净交接点**,由用户亲自验证;回执必须含「用户可照做的验证步骤 + 每步预期结果」。
|
||||
> 8. **门禁(不达标不提交)**:后端 = `mvn test` 全过(核心接口须有契约测试);Web = `pnpm build:pro` 通过 + eslint 零错误;Flutter = `dart analyze lib test` 零告警 + `flutter test` 全过。
|
||||
> 9. **分支**:在 `develop` 提交并 `git push origin develop`;**绝不动 `main`**(dev→main 走 PR,由用户操作)。
|
||||
|
||||
---
|
||||
|
||||
## [已处理·待用户验收] 2026-10-16 18:30 | J1 全局 ID 字符串化(Long → String)+ 契约统一
|
||||
|
||||
**用户裁定(2026-10-16 18:10)**:雪花 id 精度问题 **「按方案 (a) 全局修改,统一契约」**。
|
||||
|
||||
**背景(两方独立复现同一根因,故现在铺全局)**:
|
||||
- 你在 I1 里实测到:UI 点「下架」后端回 `200002 公告不存在` —— 库里 `2111011206851596290`,浏览器 `JSON.parse` 后变 `2111011206851596300`,回查自然找不到。你把修复**限定在本轮两个模块**并留了待裁定。
|
||||
- PM 独立复核同样确认:新注册用户 id `2111012753262120962` 由 `/admin/app-users/page` **以裸 JSON 数字**下发;用真 JS 引擎验证 `JSON.parse('{"id":2111012753262120962}').id` → `2111012753262121000`(**不相等**)。根因是 JS `Number.MAX_SAFE_INTEGER = 9007199254740991`,19 位雪花 id 必然超出;而服务端**没有全局 `Long→String` 序列化配置**。
|
||||
- 种子数据都是小 id(4/5/25)所以老页面一直正常 —— 这是**只要"新建后立刻拿 id 操作"就必然踩**的坑。
|
||||
|
||||
### 一、后端:全局序列化(放 `fruitshorts-framework`,两个入口都生效)
|
||||
|
||||
1. `Long.class` 与 `Long.TYPE` 一律用 `ToStringSerializer`。
|
||||
2. ⚠️ **不要动 `Integer` / `int`**(分页 `pageIndex`/`pageSize` 等保持数字)。
|
||||
3. 必须覆盖:普通字段、**嵌套对象**、`List`/`Map` 的值、分页 `list`。
|
||||
4. **反序列化方向也要验**:请求体 / 路径变量 / 查询参数里 id 传**字符串**要能正确绑定成 `Long`(前端回传的就是字符串)。
|
||||
|
||||
### 二、契约统一(`docs/api-phase1.md` §0 通用约定)
|
||||
|
||||
- 写明「**所有 ID 字段一律以字符串下发**」(含主键与外键 id),并给示例。
|
||||
- 把 I1 里逐模块的描述(商品/公告 VO 各自加 `ToStringSerializer`)**收拢到这条总口径**,不要各写一套。
|
||||
- 顺手把 §2.11/§2.12 里与总口径重复的表述改成引用 §0。
|
||||
|
||||
### 三、两个前端适配
|
||||
|
||||
1. **ReelShortWeb**:审计现有页面有没有把 id 当**数字**用 —— `parseInt(id)` / `Number(id)` / `id + 1` / 拿 id 当数组下标 / `===` 与数字比较 / `sort((a,b)=>a.id-b.id)`,逐处改成字符串语义。本轮新加的两个页面已按 `string` 写,**别回退**。
|
||||
2. **ReelShort(Flutter)**:`fromJson` 里 `json['id'] as int` 这类写法在字符串下发时会**直接抛异常**,必须审计并修。策略二选一(推荐前者,改动小且向前兼容):
|
||||
- **兼容解析**:统一按"接受字符串或数字"的方式解析(如 `int.tryParse(v.toString())`),或把模型里的 id 保留为 `String`;
|
||||
- 或全量改成 `String`(改动更大,要与客户端同事对齐)。
|
||||
- ⚠️ 客户端现由另一同事在推进:**只做"ID 解析"这一类最小改动**,不要顺手改别的;有分歧或改不动就写「【待 Trae 协助】」,并在回执里给出**逐文件清单**。
|
||||
|
||||
### 四、验证(必须有证据,不接受"应该没问题")
|
||||
|
||||
1. 后端契约测试:`Long` 字段确实以**字符串**输出(含嵌套 / 分页 list / Map)。
|
||||
2. **端到端复现 I1 那个 bug 并证明已消失**:admin 新建一条记录 → 用响应里的 id 调详情 / 编辑 / 上下架 → **成功**(全局范围内,不再只限商品/公告)。
|
||||
3. **JS 侧一致性证据**:拿真实响应喂 `node -e 'const d=JSON.parse(...); console.log(String(d.id))'`,证明与库里的值**逐位相等**。
|
||||
4. C 端:注册 / 播放票据 / 订单等返回的 id 也是字符串,且**客户端解析不崩**(Flutter 侧跑测试)。
|
||||
|
||||
### 五、门禁与 git
|
||||
|
||||
- `mvn test` 存量 **110 条不得跑红** + 新增测试;Web `pnpm ts:check` + eslint 零错误 + `pnpm build:pro` 通过;若改了客户端则 `dart analyze lib test` 零告警 + `flutter test` 全过。
|
||||
- FruitServer / ReelShortWeb / ReelShort **各自在 `develop` 提交并推送**;**绝不动 `main`**。
|
||||
- 新写文件首行 `// Author: fruit.com.cn`(Vue/TS 同;Dart 用 `// Author: fruit.com.cn`)。
|
||||
|
||||
### 六、节奏
|
||||
|
||||
先写 `FruitServer/docs/plans/2026-10-16-J1.md`(方案 + 影响面清单 + 风险)→ 做 → 跑门禁 → **停在干净交接点写回执**,回执必须含:
|
||||
- 每个仓库改了哪些文件(**客户端逐文件清单**要单独列)
|
||||
- 「用户可照做的验证步骤 + 每步预期结果」
|
||||
- 若客户端有未改完/有分歧的部分,**如实列出**(不许含糊)
|
||||
|
||||
---
|
||||
|
||||
## [已处理·待用户验收] 2026-10-16 17:30 | I1 后台管理端:商品(金币包)配置 + 系统公告管理(后端接口 + Web 页面)
|
||||
|
||||
**用户指令(2026-10-16 17:10)**:「现在做后台管理端,规范还是按照 specs」+「ReelShortWeb 就是这个项目」→ **后台管理端 = `ReelShortWeb`(Vue3 + Element Plus),本批后端接口与页面一起做**。
|
||||
|
||||
### 一、PM 已勘察的两个缺口(实测证据,直接用)
|
||||
|
||||
| # | 缺口 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **商品/金币包配置:后台完全没有入口** | `product` 表 **16 个字段**已建(`code/name/type/platform/store_product_id/coin_amount/bonus_coin/price_amount/currency/display_price/region_codes/vip_days/sort/status/effective_at/expire_at`)、**已有 3 行种子数据**、C 端 `/app/orders/verify` 依赖 `store_product_id` 对齐商店商品(错误码 `200002 该商品未在后台配置` 就是为它留的);但 admin-api 29 个 Controller 里**没有任何商品 CRUD 接口**(`grep` 只命中 `WalletAdminController /coins` 的发币补偿)→ 运营改商品只能改库。**PRD §09 明确要求「商品配置与价格分层」** |
|
||||
| 2 | **系统公告:有读没写** | `ops_notice` 表 **0 行**;E1 已交付 C 端 `GET /app/notices`(匿名只读);契约 §2.7 原话「**系统公告 `ops_notice` 目前没有产出方,等有公告功能再接**」→ 后台没有公告管理接口,运营发不了公告 |
|
||||
|
||||
> 除这两项外,admin-api 对照契约 §2.1–§2.10 全部已实现,**不要重做**。
|
||||
|
||||
### 二、后端(FruitServer / admin-api)
|
||||
|
||||
1. **契约先行**(依据 `12-上线检查清单` §一):先在 `docs/api-phase1.md` 写清两个模块的接口、字段、错误码、权限点(放在 §2.6 订单与权益之后,或新增一节),**再动代码**。
|
||||
2. **商品配置 `/admin/products`**:
|
||||
- `GET /admin/products/page`(筛选:`type` / `status` / `platform` / `keyword`,分页沿用既有口径)、`GET /admin/products/{id}`
|
||||
- `POST /admin/products`、`PUT /admin/products/{id}`、`PUT /admin/products/{id}/status`(上下架)、`DELETE /admin/products/{id}`
|
||||
- 字段按 `product` 表现有结构,**不要改表**
|
||||
- **必须有校验**:`code` 在全局唯一、`store_product_id` 在**同一 `platform` 下唯一**(否则同一商店商品被重复配置 → 发币口径混乱);`type=COIN` 时 `coin_amount > 0`;`type=VIP` 时 `vip_days > 0`
|
||||
- ⚠️ **VIP 商品口径**:B1-1 已定「VIP 订阅本轮不做」→ 允许建但**不允许启用**(启用报明确错误)或直接拒绝创建,**二选一由你定,写进契约并在回执里说明**;拿不准就写「【待 Trae 协助】」
|
||||
- 权限点命名沿用现有习惯(参考 `commerce:*`),并落 `sys_permission` + `sys_menu`
|
||||
3. **系统公告 `/admin/notices`**:
|
||||
- `GET /admin/notices/page`、`GET /admin/notices/{id}`、`POST`、`PUT /admin/notices/{id}`、`PUT /admin/notices/{id}/status`(发布/下架)、`DELETE /admin/notices/{id}`
|
||||
- 字段按 `ops_notice` 表现有结构,**不要改表**
|
||||
- **与 C 端 `GET /app/notices` 的筛选口径必须严格对齐**(E1 已定:`status=1` 且 `target ∈ {ALL, USER}`、`publish_at <= now`、`expire_at` 为空或未过期、按 `publish_at desc, id desc`)—— 后台发布后 C 端能查到、过期/下架后查不到
|
||||
- 权限点 + 菜单同上
|
||||
4. **菜单与权限点落库**:写进 `sql/data-phase1.sql`(**幂等**),注意**沿用既有保留段**、不要与现有 id 冲突(现有菜单到 id 30)。建议:商品配置挂 `订单与权益`(id 22) 下;系统公告可挂新的运营分组,具体由你按现有约定定。**超管必须可见且可授权**。
|
||||
5. Swagger 中文注解按 S1 规范补齐(`@Tag` / `@Operation`)。
|
||||
6. **门禁**:`mvn test` 存量 **91 条不得跑红** + 新增契约测试,至少覆盖:`store_product_id` 同平台唯一、`code` 唯一、`type` 校验、公告发布后 C 端可见 / 过期或下架后不可见、越权(无权限点被拒)。
|
||||
|
||||
### 三、后台页面(ReelShortWeb)
|
||||
|
||||
> **挂载机制(PM 已勘察)**:路由是**静态注册**在 `src/router/index.ts`(`component: () => import('@/views/...')`),而菜单树来自 DB `sys_menu`,其中 `component` 字段必须**与视图路径一致**(例:`views/contentManagement/DramaManagementView`)。两边都要加。
|
||||
|
||||
1. 新增两个页面(放在与现有页面同级的目录,命名沿用 `XxxManagementView.vue` 风格):
|
||||
- **商品配置**:列表(分页/筛选)+ 新建/编辑弹窗 + 上下架 + 删除;表单字段按 `product` 表;金额/金币用数字输入并做前端校验
|
||||
- **系统公告**:列表 + 新建/编辑(标题/正文/类型/目标/发布时间/失效时间)+ 发布/下架 + 删除
|
||||
2. **按现有约定写**:API 封装放 `src/api/admin/`(新增如 `product.ts` / `notice.ts`);权限码走现有的 `can(code)`;**表格状态列用接口返回的 `status` / `statusText`,不要在前端写死中文**;大数值用 `formatCount`。
|
||||
3. `src/router/index.ts` 静态注册路由(`route_name` 与 DB `sys_menu.route_name` 一致,**注意不要与既有 route_name 重名** —— 之前踩过 `CreatorCenter` 重名导致白屏/404 的坑)。
|
||||
4. **不要改动已对接的既有页面**(避免与其他人正在做的改动冲突)。
|
||||
5. **门禁**:`pnpm build:pro` 通过 + eslint 零错误。
|
||||
|
||||
### 四、git
|
||||
|
||||
- **FruitServer**:在 `develop` 提交并推送;**不动 `main`**。
|
||||
- **ReelShortWeb**:现在是 `main` 分支且**与远端分叉**(本地 `4564aa8` 领先 1、远端 `1fd0b8b 全量补充作者标识` 领先 1)。请先 `git fetch` 并把远端合进来,**然后按 specs 的 `GIT_WORKFLOW` 新建 `develop` 分支,在 `develop` 上提交并 `git push origin develop`**;**不动 `main`**(合并走 PR、由用户操作)。远端是 `jianghaifeng/ReelShortWeb.git`(有写权限)。
|
||||
- 作者的署名规范:`// Author: fruit.com.cn`(Vue/TS 文件首行)。
|
||||
|
||||
### 五、节奏(红线第 7 条)
|
||||
|
||||
1. 先写计划文件 `docs/plans/2026-10-16-I1.md`(后端 + 前端两部分、步骤、风险、待确认)。
|
||||
2. 做,跑门禁。
|
||||
3. **停在干净交接点写回执**:逐项列出改了什么;**给你和用户可照做的验证步骤 + 每步预期结果**(含「后台登录 → 菜单里能看到这两个新页面 → 建一个商品 → 用该商品在 C 端 verify 能对上」这样的端到端链路)。
|
||||
4. **不要连着抢做下一批。**
|
||||
|
||||
---
|
||||
|
||||
### 回执(Codex,2026-10-16):I1 完成,停在干净交接点
|
||||
|
||||
**提交**:FruitServer `8d19a0c`(develop,已推) + ReelShortWeb `db313f4`(develop,已推,含 husky 门禁)。
|
||||
**留痕**:`docs/plans/PROGRESS.md`(本轮新建,逐动作命令 + 原始输出 + 解读);计划 `docs/plans/2026-10-16-I1.md`。
|
||||
**没碰 `main`**;01-08 冻结模块未改(改动只在 module-commerce / module-system / admin-api / app-api 的 `AppNoticeService`)。
|
||||
|
||||
**门禁数字(原始输出见 PROGRESS 动作 3/6/10)**
|
||||
| 门禁 | 结果 |
|
||||
| --- | --- |
|
||||
| `mvn test` | **BUILD SUCCESS**,commerce 33 / admin-api 16 / app-api 49 / worker 12 = **110 条全过**(存量 91 + 新增 19,无跑红) |
|
||||
| `mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests` | BUILD SUCCESS |
|
||||
| `sql/data-phase1.sql` 幂等 | 连跑三遍,`sys_role_menu/permission/menu/role_permission` 行数 54/52/33/79 前后一致 |
|
||||
| `pnpm ts:check` | 0 错误 |
|
||||
| `eslint --ext .js,.ts,.vue ./src` | 0 error(首遍 1 个 prettier 折行,改后复跑为空) |
|
||||
| `pnpm build:pro` | Build successful |
|
||||
| `:3005` 真浏览器 | 两页渲染正常;1440/1280/1024 三档 `overflowX=false`;截图见下面步骤 |
|
||||
|
||||
**交付内容**
|
||||
1. 后端:`/admin/products` 6 个接口(page/{id}/POST/PUT/{id}/status/DELETE)、`/admin/notices` 6 个接口;字段严格按 `product` / `ops_notice` 表现有结构,**未改表**。
|
||||
2. 校验:`code` 全局唯一、`storeProductId` **同 platform 唯一**、`COIN_PACK` 需 `coinAmount>0`、`VIP` 需 `vipDays>0`、新建默认下架。
|
||||
3. 公告可见性与 C 端**共用一份实现**:`OpsNoticeService.pageForApp(...)`,后台与 `/app/notices` 不再各写一套条件。
|
||||
4. 权限点 `product:list/product:update/notice:list/notice:update` + 菜单 31(商品配置,挂 22「订单与权益」)/ 32(运营管理)/ 33(系统公告),超管已授权,幂等。
|
||||
5. 前端:`src/api/admin/product.ts`、`src/api/admin/notice.ts`、`ProductManagementView.vue`、`NoticeManagementView.vue`;状态列只用接口 `statusText`,不写死中文。
|
||||
|
||||
**两个自己拍的口径(请复核)**
|
||||
1. **VIP 商品:允许建档、不允许启用** —— `status=1` 时回 `400 会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)`。理由:上线日可以直接上下架,不必临时补商品数据;已写进契约 §2.11。
|
||||
2. **⚠️ 雪花 id 以字符串下发(本轮实测抓到的真缺陷)** —— UI 上点「下架」后端回 `200002 公告不存在`,根因是 S0 换雪花主键后 id 超过 JS 安全整数范围:DB 里 `2111011206851596290`,浏览器 `JSON.parse` 后变成 `2111011206851596300`,回查自然找不到。
|
||||
修复限定在本轮两个模块(`ProductRespVO` / `NoticeRespVO` 的 `id` 加 `ToStringSerializer`,新建接口返回 `String`,前端 `id: string`),**没动全局序列化、没动 app-api**,避免影响 Flutter 客户端;并补了两条防回归测试。
|
||||
- 遗留:admin-api 其它模块(订单/权益/短剧…)只要"新建后立刻用 id 操作",同样会中招。**是否统一做"admin-api 的 Long → String"、app-api 要不要一起动(Flutter 解析 id 的地方会受影响)——这是平台级改动,请裁定**,我没自行铺开。
|
||||
3. **菜单挂载机制与条目描述有出入(如实报出)**:实测菜单来自 DB `sys_menu`,前端用 `import.meta.glob` 按 `component` 字符串动态解析;`src/router/index.ts` 的 `asyncRouterMap` 只静态声明 dashboard/content/user/system,commerce/governance/board 都不在里面。所以本轮**没有改 `src/router/index.ts`**,只建页面 + 落菜单(`component` 与视图路径一致)。若你要求"两边都登记",我补一版。
|
||||
|
||||
**用户可照做的验证步骤(每步都有预期结果)**
|
||||
|
||||
准备(后端):`cd FruitServer && mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests`,然后
|
||||
```
|
||||
java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar # 8080
|
||||
java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar # 8081
|
||||
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/v3/api-docs # 预期 200
|
||||
```
|
||||
数据库若在干净机器上:`psql -f sql/data-phase1.sql`(可重复执行)。
|
||||
|
||||
1. **菜单**:`pnpm dev` → 打开 `http://127.0.0.1:3005/#/login` → `admin / admin123` 登录。
|
||||
预期:左侧出现「订单与权益 → 商品配置」与「运营管理 → 系统公告」,其余菜单不变。
|
||||
2. **商品页**:进 `#/commerce/products`。预期:3 行种子(coin_100/550/1200),列 = 商品编码/名称/类型/平台/商店商品 id/到账金币/价格/排序/状态/创建时间/操作;状态显示「已上架」。
|
||||
3. **新建商品(正常路径)**:点「新建商品」→ 编码 `uitest_coin_500`、名称随意、平台选 Google Play、商店商品 id `UITEST_COIN_500`、金币 500、赠送 50、价格 9900 → 保存。
|
||||
预期:toast「商品已创建(默认下架,请确认后再上架)」,列表新增一行且状态「已下架」(**id 是雪花 id,注意这是本轮修过的点**)。
|
||||
4. **异常路径**:再新建一个同编码商品 → 预期 toast「商品编码已存在:uitest_coin_500」;把类型改成「会员(第二阶段)」、天数 30 → 保存成功(**允许建档**),再点它的状态想上架 → 预期 toast「会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)」,状态不变。
|
||||
5. **C 端对齐(命令)**:把第 3 步的商品点上架,然后
|
||||
```
|
||||
psql -c "select id, code, platform, store_product_id, status from product where store_product_id='UITEST_COIN_500';"
|
||||
```
|
||||
预期:查到 1 行、`status=1` —— 这正是 `/app/orders/verify` 找商品的同一条件。
|
||||
(`POST /app/orders/verify` 本机默认 `STORE_VERIFIER=disabled`,会先回 `200003 商店校验尚未配置`,这是 B1-1 的既定行为,不是商品没配上。)
|
||||
6. **公告页**:进 `#/operation/notices` → 「新建公告」填标题/正文(默认「全部用户/系统」、不填时间=立即发布)→ 保存。
|
||||
预期:toast「公告已发布」,行内状态「已发布」、失效时间显示「-」。
|
||||
```
|
||||
curl -s "http://127.0.0.1:8081/app/notices?pageIndex=1&pageSize=5" # 预期 total 1,能看到刚发的标题
|
||||
```
|
||||
7. **下架(异常路径的对照组)**:点该行状态「已发布」→ 预期 toast「公告已下架」、行内变「已下架」;再跑上面的 curl → 预期 `total 0`。
|
||||
8. **越权**:用 `content.editor01 / admin123`(内容运营,无 `product:*`/`notice:*`)登录后直接敲 `#/commerce/products`。
|
||||
预期:页面 403/无权限(后端 `{"code":403,"message":"无权限"}`);不带 token 请求 `curl -s "http://127.0.0.1:8080/admin/products/page"` → `{"code":401,...}`。
|
||||
9. **清理**:把第 3/4 步的商品、第 6 步的公告删掉;核对 `select count(*) from product;` = 3、`select count(*) from ops_notice;` = 0。
|
||||
(本轮自测数据我已清干净:`product` 3 行种子、`ops_notice` 0 行。)
|
||||
|
||||
**门禁/流程说明**:本轮所有子步骤都在同一批内完成,未抢做下一批;`mvn test` 与 `package` 都是先停服务再打包、打包后重新起服务、**对着活着的服务**做的 curl 自测(不是对停掉的服务打)。
|
||||
服务当前状态:8080/8081 我这边是**已起的**(自测完成时仍在监听);若你这边连不上就是被环境回收了,按上面「准备」两条命令起回来即可。
|
||||
|
||||
**禅道同步(红线第 6 条,如实说明)**:我把 `水果短剧APP`(项目 2)的 4 个执行下的任务都拉了一遍,
|
||||
任务只有 S0/S1/S0-16、A1–A6、B1/B2、C1–C3 —— **没有 I1(后台管理端 商品配置 + 系统公告)这一条**,
|
||||
所以本轮没有可同步的任务号,我没有动任何既有任务的状态(避免误改 B1/C1/A* 这些别的批次)。
|
||||
下一步请给 I1 建任务并告知任务号,我补一次状态同步(`codex01` 的 token 已可用)。
|
||||
|
||||
---
|
||||
|
||||
### 回执(Codex,2026-10-16):J1 完成,停在干净交接点
|
||||
|
||||
**提交**(三个仓库各自 `develop`,`main` 未动)
|
||||
- FruitServer `f35db2c`(develop,已推):全局 Long→String + 契约 §0 + 测试
|
||||
- ReelShortWeb `3ec9ea6`(develop,已推):4 处 id 数字语义适配
|
||||
- ReelShort `94ff9f1`(**新建 develop**,已推):客户端 Long 计数兼容解析
|
||||
**留痕**:`docs/plans/PROGRESS.md`(本轮追加 J1 段,含原始输出);计划 `docs/plans/2026-10-16-J1.md`。
|
||||
|
||||
**门禁数字**
|
||||
| 门禁 | 结果 |
|
||||
| --- | --- |
|
||||
| `mvn test` | **BUILD SUCCESS** — commerce 33 / admin-api 21 / app-api 49 / worker 12 = **115 条全过**(存量 110 未跑红,新增 5) |
|
||||
| `mvn … package -DskipTests` | BUILD SUCCESS |
|
||||
| `pnpm ts:check` / eslint / `pnpm build:pro` | 0 错误 / 0 error / Build successful |
|
||||
| `dart analyze lib test` / `flutter test` | No issues found! / All tests passed!(+167) |
|
||||
|
||||
**每个仓库改了什么**
|
||||
|
||||
FruitServer(后端)
|
||||
- 新增 `fruitshorts-framework/.../web/JacksonLongToStringConfig.java`(`Long`/`long` → 字符串;
|
||||
`PageResult.total` 用 mixin 保持数字;`PlainLongSerializer` 供 mixin 使用)
|
||||
- `docs/api-phase1.md`:§0 新增「ID 字段一律以字符串下发」总口径(含示例、边界、`total` 例外),
|
||||
§2.11/§2.12 改为引用 §0
|
||||
- I1 的特例回退:`ProductRespVO` / `NoticeRespVO` 去掉 `@JsonSerialize`,
|
||||
`ProductAdminController` / `NoticeAdminController` 的 `create` 回到 `Result<Long>`
|
||||
- 测试:新增 `admin-api/.../web/LongToStringSerializationTest.java`(5 条);
|
||||
`ControllerContractSupport`(admin + app 两处)挂上模块,避免契约测试假绿;
|
||||
C 端 4 条 id 断言改成 `value("…") + isString()`
|
||||
|
||||
ReelShortWeb(4 个文件)
|
||||
- `src/utils/creatorImpersonation.ts` — 代看创作者 id 全程字符串(原来 `Number(raw)`,19 位会被四舍五入)
|
||||
- `src/views/contentManagement/EpisodeManagementView.vue` — `Number(route.query.drama)` → 字符串比较
|
||||
- `src/views/creator-center/composables/useDramaEdit.js` — `Number(route.params.id)` → 字符串比较
|
||||
- `src/views/permission/composables/useMenuManagement.js` — 顶级菜单哨兵 `0` → `'0'`(与接口的 `"0"` 对齐)
|
||||
|
||||
ReelShort(Flutter)逐文件清单(**只有这些,没夹带别的**)
|
||||
- `lib/core/data/models/json_numbers.dart`(新增)— `jsonToInt`:接受数字或数字字符串
|
||||
- `lib/core/data/models/auth_session.dart` — `expiresIn`(后端 `long`)改用 `jsonToInt`
|
||||
- `lib/core/data/models/drama.dart` — `viewCount`(后端 `Long`)两处改用 `jsonToInt`
|
||||
- `test/core/json_id_parsing_test.dart`(新增)— 8 条回归用例
|
||||
|
||||
**三个需要你确认的口径**
|
||||
|
||||
1. **`PageResult.total` 保持数字(唯一例外)** —— 它是条数不是 id,前端分页组件(el-pagination / Flutter 分页)
|
||||
都要数字。实测 `total = 2 int`。若你要求"Long 一律字符串",删掉 `JacksonLongToStringConfig` 里那一行 mixin 即可。
|
||||
2. **反向(请求方向)未做限制**:请求体 `"id":"…"`、路径变量、查询参数都接受字符串并转 `Long`(有测试)。
|
||||
`Integer`/`int`(`pageIndex`/`pageSize`/`Result.code`/`totalEpisodes`/`durationSec`…)保持数字 —— 实测已确认。
|
||||
3. **客户端适配范围比"只改 id 解析"宽了一点点,原因是物理上必须**:客户端 id 本来就是
|
||||
`(json['id'] ?? '').toString()`,不会崩;真正会崩的是被全局规则扫到的 **Long 计数**,
|
||||
所以改了 `expiresIn` 与 `viewCount` 共 3 处(`json_numbers.dart` 是新增工具)。
|
||||
其余 8 处 `as num?` 对应的 Java 字段都是 `Integer`/`int`,**按最小改动原则没动**。
|
||||
⚠️ **ReelShort 仓库原来只有 `main`**(无 develop),我按条目要求新建了 `develop` 并在上面提交。
|
||||
客户端由另一位同事推进,若他继续在 `main` 上走,需要把这笔 `94ff9f1` 带过去。
|
||||
|
||||
**用户可照做的验证步骤(每步都有预期结果)**
|
||||
|
||||
准备:`cd FruitServer && mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests`,起两个 jar;
|
||||
`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/v3/api-docs` → 200。
|
||||
|
||||
1. **复现 PM 那条(最直观)**:注册一个 C 端账号
|
||||
```
|
||||
curl -s -X POST http://127.0.0.1:8081/app/auth/register -H 'Content-Type: application/json' \
|
||||
-d '{"targetType":"EMAIL","target":"你的邮箱@example.com","password":"Passw0rd!23","nickname":"手测","deviceId":"manual-1"}'
|
||||
```
|
||||
预期:`"expiresIn": "7200"`(字符串)。
|
||||
2. **用响应里的 user id 调后台**:`curl -s "http://127.0.0.1:8080/admin/app-users/page?pageSize=50" -H "Authorization: Bearer <admin token>"`,
|
||||
拿新用户的 `id`(预期是 `"2111…"` 这种**带引号的字符串**、`total` 是**不带引号的数字**),再
|
||||
`curl -s "http://127.0.0.1:8080/admin/app-users/<id>" -H "Authorization: Bearer <token>"` → 预期 `code=0`、`data.id` 与列表一致
|
||||
(**修复前这里返回 `200002 数据不存在`**)。
|
||||
3. **JS 逐位一致**:把第 2 步的响应存成 `r.json`,然后
|
||||
```
|
||||
node -e 'const d=JSON.parse(require("fs").readFileSync("r.json","utf8"));console.log(String(d.data.id))'
|
||||
```
|
||||
预期与 `psql -c "select id::text from app_user where uid='<uid>'"` **逐位相同**。
|
||||
对照:`node -e 'console.log(String(JSON.parse("2111015748376858626")))'` → `2111015748376858620` 附近(会被改写),说明精度问题真实存在。
|
||||
4. **后台界面(:3005)**:`admin/admin123` 登录 → 商品配置新建一个商品 → **点它那一行的状态/编辑**,
|
||||
预期正常(toast 成功,不再"商品不存在");菜单管理编辑一个顶级菜单,预期父级下拉显示「顶级菜单」而不是 `0`。
|
||||
5. **短剧管理 → 剧集管理带参跳转**:从短剧管理点某剧的「剧集」,URL 带 `?drama=<id>`,
|
||||
预期剧集页自动选中该剧(改成字符串比较前这里会选不中)。
|
||||
6. **客户端**:`cd ReelShort && flutter test`(预期 All tests passed);有设备时 `flutter run` 打开首页看播放量/详情
|
||||
不报解析异常(后端 `viewCount` 现在下发字符串)。
|
||||
7. **异常路径**:请求体里 id 传**数字**(老客户端行为)也应被接受:`PUT /admin/products/<id>/status?status=1` 用数字 id 路径同样 200。
|
||||
|
||||
**禅道**:J1 在禅道里同样没有对应任务(项目 2 下仍是 S0/S1/S0-16、A1–A6、B1/B2、C1–C3),未动任何既有任务状态。
|
||||
**服务现状**:8080/8081 是我刚重建起回的新 jar(自测时在跑);:3005 的 dev server 也在。若被环境回收,按上面「准备」起回来即可。
|
||||
@@ -0,0 +1,360 @@
|
||||
# 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 已列清单)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-16 J1:全局 ID 字符串化(Long → String)+ 契约统一
|
||||
|
||||
计划文件:`docs/plans/2026-10-16-J1.md`(先写计划再动手)。用户裁定「方案 (a) 全局修改,统一契约」。
|
||||
|
||||
### 动作 1:后端全局序列化(fruitshorts-framework)
|
||||
|
||||
新增 `JacksonLongToStringConfig`:`SimpleModule` 把 `Long.class` / `Long.TYPE` → `ToStringSerializer`,
|
||||
经 `Jackson2ObjectMapperBuilderCustomizer` 挂进 Spring Boot 的 `ObjectMapper`。
|
||||
两个入口都是 `scanBasePackages = "com.fruitshorts"`,一处生效两端一致,**没改 yml**。
|
||||
|
||||
一个刻意的例外:`PageResult.total` 是条数不是 id,用 mixin 显式保持数字(前端分页组件要数字)。
|
||||
为此给 mixin 加了个 `PlainLongSerializer`(`NumberSerializer` 没有无参构造,`using=` 装不上)。
|
||||
|
||||
I1 里给商品/公告 VO 逐个加的 `@JsonSerialize` 与 `Result<String>` 新建返回**已回退成自然写法** ——
|
||||
全局生效后它们只是冗余特例(现在是"所有模块一个口径")。
|
||||
|
||||
### 动作 2:契约 §0 总口径
|
||||
|
||||
`docs/api-phase1.md` §0 新增「**ID 字段一律以字符串下发**」+ 示例 + 边界(`total` 与 `Integer` 保持数字、
|
||||
id 回传按字符串传),§2.11/§2.12 的逐模块表述改成引用 §0。
|
||||
|
||||
### 动作 3:契约测试的 standalone mapper 也挂上模块(否则假绿)
|
||||
|
||||
`ControllerContractSupport`(app-api / admin-api 两处)自带手搓 `JsonMapper`,不含全局模块 ——
|
||||
不改它的话,测试断言数字 id 也会过,而生产早就变成字符串了。挂上模块后:
|
||||
|
||||
```
|
||||
$ mvn test
|
||||
[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-module-commerce)
|
||||
[INFO] Tests run: 21, 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 # 共 115 条(存量 110 + 新增 5)
|
||||
```
|
||||
|
||||
新增 `LongToStringSerializationTest` 5 条:普通字段 / 嵌套对象 / List / Map 值 / 分页(total 数字 + list id 字符串)
|
||||
/ 请求体 `"id":"…"` 反序列化成 Long / 19 位路径变量绑定成 Long。
|
||||
另外把 C 端 4 条 id 断言从数字改成 `value("…") + isString()`(原来"数字也能过"的宽松比较不算证据)。
|
||||
|
||||
### 动作 4:Web 与客户端适配
|
||||
|
||||
见回执的逐文件清单。要点:Web 审计出 4 处把 id 当数字用的地方(代看 id 过 `Number`、
|
||||
两处 `Number(route.…)` 与 id 比较、菜单顶级哨兵 0 与 "0" 不一致);Flutter 侧 id 本来就是
|
||||
`(json['id'] ?? '').toString()`,会崩的是被全局规则扫到的 **Long 计数**,只改了 `expiresIn` 与 `viewCount`。
|
||||
|
||||
```
|
||||
$ pnpm ts:check → 0 错误
|
||||
$ pnpm exec eslint … ./src → 0 error
|
||||
$ pnpm build:pro → Build successful
|
||||
$ dart analyze lib test → No issues found!
|
||||
$ flutter test → All tests passed! (+167)
|
||||
```
|
||||
|
||||
### 动作 5:端到端复现"修复前后"(真实服务)
|
||||
|
||||
```
|
||||
① 复现 PM 那个场景:C 端注册 → admin 列表/详情
|
||||
$ curl -X POST :8081/app/auth/register -d '{"targetType":"EMAIL","target":"j1.selftest@example.com",…}'
|
||||
→ "expiresIn": "7200" # long 字段,已是字符串
|
||||
$ curl -H "Authorization: …" :8080/admin/app-users/page?pageSize=50
|
||||
total = 2 int # 分页 total 仍是数字(例外生效)
|
||||
命中用户: uid=U79DG5IOM id='2111015748376858626' id类型=str
|
||||
$ curl -H "Authorization: …" :8080/admin/app-users/2111015748376858626
|
||||
code= 0 message= ok # 修复前这里就是 200002
|
||||
data.id= '2111015748376858626' uid= U79DG5IOM
|
||||
```
|
||||
|
||||
```
|
||||
② JS 侧逐位一致(真实响应喂 node)
|
||||
$ node -e '…'
|
||||
响应原文里的 id(JSON 字符串): "2111015748376858626"
|
||||
typeof 解析后 : string
|
||||
String(parsed.id) : 2111015748376858626
|
||||
库里 app_user.id : 2111015748376858626
|
||||
逐位相等? : true
|
||||
---- 反面对照:同一串数字当裸数字解析 ----
|
||||
裸数字 -> String(parsed.id) : 2111015748376858600
|
||||
逐位相等? : false
|
||||
```
|
||||
|
||||
```
|
||||
③ 全局范围:换两个模块(商品 / 创作者)走"新建 → 用响应 id 查详情 → 改状态"
|
||||
新建商品响应 : {"code":0,"data":"2111015841427492865"}
|
||||
商品详情 : code=0 id='2111015841427492865'
|
||||
商品上架 : {"code":0}
|
||||
新建创作者响应 : {"code":0,"data":"2111015841771425794"}
|
||||
创作者详情 : code=0 id='2111015841771425794' name=J1 探针工作室
|
||||
```
|
||||
|
||||
```
|
||||
④ C 端字段类型(匿名可读的链路)
|
||||
/app/home/feed : drama card id='5'(str) viewCount='3100000'(str)
|
||||
/app/dramas/5 : id='5'(str) viewCount='3100000'(str) totalEpisodes=72(int) episode id='7'(str)
|
||||
/app/episodes/7/play : episodeId='7'(str) dramaId='5'(str) durationSec=228(int) unlockCoin=0(int)
|
||||
```
|
||||
|
||||
解读:**Long → 字符串**(id、viewCount、expiresIn、coinAmount),**Integer/int 与分页 total 仍是数字**,
|
||||
与 §0 的规则和例外完全对上。
|
||||
|
||||
### 动作 6:自测数据清理(含一次口径纠正)
|
||||
|
||||
```
|
||||
DELETE 1 # 自测用户 app_user(含 credential 2 / session 2 / device 2 / consent 3 / wallet 1)
|
||||
DELETE 1 # 商品 j1_probe_coin
|
||||
DELETE 1 # 创作者 J1PROBE
|
||||
全库扫描 uid / 邮箱 / 雪花 id → 无任何残留(NOTICE: 扫描完成)
|
||||
现存:creator 只剩 PM 那条 id=25(CR20261015001)、app_user 只剩 id=28(UHR-DLTH8)、
|
||||
product 3 行种子、ops_notice 0 行 —— 都不是本轮数据。
|
||||
```
|
||||
|
||||
⚠️ 口径纠正:我第一版清理脚本用 `psql -t -A -c "delete … returning 1" | wc -l` 数"行数",
|
||||
**数到的其实是 psql 的命令标签**(实测删 0 行时输出 `DELETE 0`,也是 1 行),所以那轮打印的
|
||||
"删除 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 = "对账任务与手动对账"),
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// 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,按 §0 以字符串下发)")
|
||||
@PostMapping
|
||||
@PreAuthorize("@ss.hasPermission('notice:update')")
|
||||
@OperationLog(module = "系统公告", action = "新建公告")
|
||||
public Result<Long> create(@Valid @RequestBody NoticeSaveReqVO req) {
|
||||
return Result.ok(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();
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// 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,按 §0 以字符串下发)")
|
||||
@PostMapping
|
||||
@PreAuthorize("@ss.hasPermission('product:update')")
|
||||
@OperationLog(module = "商品配置", action = "新建商品")
|
||||
public Result<Long> create(@Valid @RequestBody ProductSaveReqVO req) {
|
||||
return Result.ok(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;
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// 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"))
|
||||
.andExpect(jsonPath("$.data").isString());
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
// 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"))
|
||||
.andExpect(jsonPath("$.data").isString());
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
+3
@@ -2,6 +2,7 @@
|
||||
package com.fruitshorts.admin.support;
|
||||
|
||||
import com.fruitshorts.framework.log.AccessDeniedRecorder;
|
||||
import com.fruitshorts.framework.web.JacksonLongToStringConfig;
|
||||
import com.fruitshorts.framework.web.GlobalExceptionHandler;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
@@ -36,6 +37,8 @@ public final class ControllerContractSupport {
|
||||
// 否则契约测试断言 LocalDate/LocalDateTime 时会看到 [2026,10,15] 这种形态。
|
||||
.setMessageConverters(new MappingJackson2HttpMessageConverter(JsonMapper.builder()
|
||||
.addModule(new JavaTimeModule())
|
||||
// 与生产一致:Long/long 一律输出成字符串(见 JacksonLongToStringConfig)
|
||||
.addModule(JacksonLongToStringConfig.longToStringModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.admin.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.admin.controller.ProductAdminController;
|
||||
import com.fruitshorts.admin.support.ControllerContractSupport;
|
||||
import com.fruitshorts.commerce.service.ProductService;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.framework.web.JacksonLongToStringConfig;
|
||||
import lombok.Data;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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;
|
||||
|
||||
/**
|
||||
* J1 全局 ID 字符串化:Long/long 一律以 JSON 字符串下发,覆盖普通字段 / 嵌套对象 / List / Map 值 / 分页 list;
|
||||
* 同时保证 Integer/int 与分页 total 仍是数字,且**字符串形式的 id 能反序列化回 Long**。
|
||||
*/
|
||||
class LongToStringSerializationTest {
|
||||
|
||||
/** 雪花量级的 id(> 2^53-1),裸数字下发会被 JS 四舍五入 */
|
||||
private static final long SNOWFLAKE_ID = 2111012753262120962L;
|
||||
|
||||
private ObjectMapper mapper;
|
||||
private ProductService productService;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 用生产同一套装配(Jackson2ObjectMapperBuilder + 我们的 customizer),而不是手搓一个 mapper,
|
||||
// 否则这条测试可能"测的是测试自己"
|
||||
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
|
||||
new JacksonLongToStringConfig().longToStringCustomizer().customize(builder);
|
||||
mapper = builder.build();
|
||||
|
||||
productService = mock(ProductService.class);
|
||||
mockMvc = ControllerContractSupport.mockMvcOf(new ProductAdminController(productService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_render_long_as_string_when_field_is_long() throws Exception {
|
||||
JsonNode json = mapper.readTree(mapper.writeValueAsString(new Sample(SNOWFLAKE_ID, 7)));
|
||||
|
||||
assertThat(json.get("id").isTextual()).isTrue();
|
||||
assertThat(json.get("id").asText()).isEqualTo("2111012753262120962");
|
||||
// Integer 字段不受影响:分页参数这类必须还是数字
|
||||
assertThat(json.get("sort").isNumber()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_render_long_as_string_when_it_is_nested_list_or_map_value() throws Exception {
|
||||
Nested nested = new Nested();
|
||||
nested.setInner(new Sample(SNOWFLAKE_ID, 1));
|
||||
nested.setList(List.of(new Sample(SNOWFLAKE_ID + 1, 1)));
|
||||
nested.setMap(Map.of("drama", new Sample(SNOWFLAKE_ID + 2, 1)));
|
||||
|
||||
JsonNode json = mapper.readTree(mapper.writeValueAsString(nested));
|
||||
|
||||
assertThat(json.get("inner").get("id").isTextual()).isTrue();
|
||||
assertThat(json.get("list").get(0).get("id").isTextual()).isTrue();
|
||||
assertThat(json.get("map").get("drama").get("id").isTextual()).isTrue();
|
||||
assertThat(json.get("list").get(0).get("id").asText()).isEqualTo("2111012753262120963");
|
||||
assertThat(json.get("map").get("drama").get("id").asText()).isEqualTo("2111012753262120964");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_keep_total_as_number_and_list_ids_as_string_when_serializing_page() throws Exception {
|
||||
PageResult<Sample> page = new PageResult<>(SNOWFLAKE_ID, List.of(new Sample(SNOWFLAKE_ID, 1)));
|
||||
|
||||
JsonNode json = mapper.readTree(mapper.writeValueAsString(page));
|
||||
|
||||
// total 是计数不是 id:保持数字,否则前端分页组件全部要改
|
||||
assertThat(json.get("total").isNumber()).isTrue();
|
||||
assertThat(json.get("total").asLong()).isEqualTo(SNOWFLAKE_ID);
|
||||
assertThat(json.get("list").get(0).get("id").isTextual()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_read_long_from_string_when_request_body_sends_string_id() throws Exception {
|
||||
Sample sample = mapper.readValue("{\"id\":\"2111012753262120962\",\"sort\":3}", Sample.class);
|
||||
|
||||
assertThat(sample.getId()).isEqualTo(SNOWFLAKE_ID);
|
||||
assertThat(sample.getSort()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_bind_long_when_path_variable_is_a_19_digit_string() throws Exception {
|
||||
mockMvc.perform(put("/admin/products/2111012753262120962/status").param("status", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
// 路径变量按字符串传来,必须原样绑定成 Long(不能被截断)
|
||||
verify(productService).updateStatus(eq(SNOWFLAKE_ID), eq(1));
|
||||
}
|
||||
|
||||
/** 普通字段 */
|
||||
@Data
|
||||
public static class Sample {
|
||||
private Long id;
|
||||
private Integer sort;
|
||||
|
||||
public Sample() {
|
||||
}
|
||||
|
||||
public Sample(Long id, Integer sort) {
|
||||
this.id = id;
|
||||
this.sort = sort;
|
||||
}
|
||||
}
|
||||
|
||||
/** 嵌套容器 */
|
||||
@Data
|
||||
public static class Nested {
|
||||
private Sample inner;
|
||||
private List<Sample> list;
|
||||
private Map<String, Sample> map;
|
||||
}
|
||||
}
|
||||
+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) {
|
||||
|
||||
+2
-1
@@ -95,7 +95,8 @@ class AppCatalogControllerContractTest {
|
||||
dramaMvc.perform(get("/app/dramas/42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.id").value(42))
|
||||
.andExpect(jsonPath("$.data.id").value("42"))
|
||||
.andExpect(jsonPath("$.data.id").isString())
|
||||
.andExpect(jsonPath("$.data.title").value("命运的相遇"));
|
||||
|
||||
verify(catalogService).dramaDetail(42L);
|
||||
|
||||
+4
-2
@@ -141,14 +141,16 @@ class AppCreatorAndConfigContractTest {
|
||||
String payload = "{\"name\":\"星野工作室\",\"type\":\"COMPANY\",\"agreementVersion\":\"2026-10-01\"}";
|
||||
creatorMvc.perform(post("/app/creator/apply").contentType(MediaType.APPLICATION_JSON).content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(7))
|
||||
.andExpect(jsonPath("$.data.id").value("7"))
|
||||
.andExpect(jsonPath("$.data.id").isString())
|
||||
.andExpect(jsonPath("$.data.status").value(CreatorService.STATUS_PENDING))
|
||||
.andExpect(jsonPath("$.data.created").value(true));
|
||||
|
||||
// 第二次提交:同一条记录、created=false(幂等)
|
||||
creatorMvc.perform(post("/app/creator/apply").contentType(MediaType.APPLICATION_JSON).content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(7))
|
||||
.andExpect(jsonPath("$.data.id").value("7"))
|
||||
.andExpect(jsonPath("$.data.id").isString())
|
||||
.andExpect(jsonPath("$.data.created").value(false));
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -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(
|
||||
@@ -89,7 +90,8 @@ class AppOrdersVerifyContractTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value(OrderService.STATUS_DELIVERED))
|
||||
.andExpect(jsonPath("$.data.coinAmount").value(550))
|
||||
.andExpect(jsonPath("$.data.coinAmount").value("550"))
|
||||
.andExpect(jsonPath("$.data.coinAmount").isString())
|
||||
.andExpect(jsonPath("$.data.platform").value("GOOGLE"))
|
||||
.andExpect(jsonPath("$.data.storeTransactionId").value("GPA.3311-00001"));
|
||||
|
||||
@@ -131,7 +133,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));
|
||||
|
||||
+3
-1
@@ -47,7 +47,9 @@ class AppPlaybackContractTest {
|
||||
mockMvc.perform(get("/app/episodes/11/play").header("X-Device-Id", "device-9"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.episodeId").value(11))
|
||||
// §0:「ID 一律以字符串下发」——用 isString 钉住,避免"数字也能过"的宽松比较
|
||||
.andExpect(jsonPath("$.data.episodeId").value("11"))
|
||||
.andExpect(jsonPath("$.data.episodeId").isString())
|
||||
.andExpect(jsonPath("$.data.accessible").value(true))
|
||||
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
|
||||
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"));
|
||||
|
||||
+3
@@ -2,6 +2,7 @@
|
||||
package com.fruitshorts.app.support;
|
||||
|
||||
import com.fruitshorts.framework.log.AccessDeniedRecorder;
|
||||
import com.fruitshorts.framework.web.JacksonLongToStringConfig;
|
||||
import com.fruitshorts.framework.web.GlobalExceptionHandler;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
@@ -36,6 +37,8 @@ public final class ControllerContractSupport {
|
||||
// 否则契约测试断言 LocalDate/LocalDateTime 时会看到 [2026,10,15] 这种形态。
|
||||
.setMessageConverters(new MappingJackson2HttpMessageConverter(JsonMapper.builder()
|
||||
.addModule(new JavaTimeModule())
|
||||
// 与生产一致:Long/long 一律输出成字符串(见 JacksonLongToStringConfig)
|
||||
.addModule(JacksonLongToStringConfig.longToStringModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.framework.web;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 全局把 {@link Long} / {@code long} 序列化成 JSON 字符串。
|
||||
*
|
||||
* <p>为什么需要它:S0 把主键换成雪花 id 之后,id 是 19 位十进制数(例如 {@code 2111012753262120962}),
|
||||
* 而 JS 的 {@code Number.MAX_SAFE_INTEGER} 只有 {@code 9007199254740991}。裸数字下发会被浏览器
|
||||
* (以及任何用 IEEE754 double 解析 JSON 的客户端)四舍五入,页面拿这个 id 回查就是"数据不存在" ——
|
||||
* 后台"新建一条记录再点编辑/下架"必然踩中。
|
||||
*
|
||||
* <p>所以:**所有 ID 字段一律以字符串下发**(见 `docs/api-phase1.md` §0)。注意两点:
|
||||
* <ul>
|
||||
* <li>只动 {@code Long}/{@code long},**不动 {@code Integer}/{@code int}**(分页 pageIndex/pageSize、
|
||||
* {@code Result.code} 等保持数字);</li>
|
||||
* <li>反序列化方向不受影响:Jackson 默认允许把 {@code "123"} 标量强转成 {@code Long},
|
||||
* 路径变量与查询参数本来就走 String→Long 转换,所以前端把 id 当字符串回传没问题。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>放在 framework 模块,admin-api 与 app-api 都扫得到(两个入口都是
|
||||
* {@code scanBasePackages = "com.fruitshorts"}),一处生效两端一致。
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonLongToStringConfig {
|
||||
|
||||
/** 供测试直接复用:拿到挂了 Long→String 的模块。 */
|
||||
public static SimpleModule longToStringModule() {
|
||||
SimpleModule module = new SimpleModule("LongToStringModule");
|
||||
module.addSerializer(Long.class, ToStringSerializer.instance);
|
||||
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
|
||||
// 唯一的例外:分页 total 是计数不是 id,保持数字(前端分页组件要数字)
|
||||
module.setMixInAnnotation(PageResult.class, PageResultTotalAsNumber.class);
|
||||
return module;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 {@code PageResult.total} 的序列化钉成数字的混入声明。
|
||||
*
|
||||
* <p>用 mixin 而不是给实体加注解,是为了让"例外"跟"规则"写在同一个文件里,
|
||||
* 也避免给 fruitshorts-common 引入 jackson 依赖。
|
||||
*/
|
||||
abstract static class PageResultTotalAsNumber {
|
||||
@JsonSerialize(using = PlainLongSerializer.class)
|
||||
abstract long getTotal();
|
||||
}
|
||||
|
||||
/** 把 long 原样写成 JSON 数字(全局规则把它们都变成字符串了,这里显式还原)。 */
|
||||
public static final class PlainLongSerializer extends JsonSerializer<Long> {
|
||||
@Override
|
||||
public void serialize(Long value, JsonGenerator gen, SerializerProvider serializers)
|
||||
throws IOException {
|
||||
if (value == null) {
|
||||
gen.writeNull();
|
||||
} else {
|
||||
gen.writeNumber(value.longValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
|
||||
return builder -> builder.modulesToInstall(longToStringModule());
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.commerce.vo;
|
||||
|
||||
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 由全局规则以字符串下发(见 api-phase1.md §0「ID 一律字符串」),这里不需要逐字段注解。 */
|
||||
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,73 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.vo;
|
||||
|
||||
import com.fruitshorts.system.dal.entity.OpsNotice;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 后台公告响应:实体字段 + 中文文案(前端不写死中文)。 */
|
||||
@Data
|
||||
public class NoticeRespVO {
|
||||
|
||||
/** id 由全局规则以字符串下发(见 api-phase1.md §0「ID 一律字符串」)。 */
|
||||
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