diff --git a/docs/api-phase1.md b/docs/api-phase1.md index f4dad80..82124e0 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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 打到客户端接口会直接被拒。 客户端 `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`「尚未完成」。 diff --git a/docs/plans/2026-10-16-I1.md b/docs/plans/2026-10-16-I1.md new file mode 100644 index 0000000..f40346c --- /dev/null +++ b/docs/plans/2026-10-16-I1.md @@ -0,0 +1,68 @@ +# I1 后台管理端:商品(金币包)配置 + 系统公告管理 —— 计划(2026-10-16) + +> 依据:`FruitServer/docs/plans/INBOX.md` 17:30 条目。用户口径「现在做后台管理端,规范还是按照 specs」。 +> **本轮=后端接口 + ReelShortWeb 页面一起做**。指令通道已迁到本仓库(`FruitServer/docs/plans/`), +> 留痕写 `FruitServer/docs/plans/PROGRESS.md`(本轮新建);**specs 只读、不再写入**。 + +## 一、两个缺口(PM 已勘察,直接用) + +1. **商品/金币包配置后台无入口**:`product` 表 16 字段已建、3 行种子、C 端 verify 依赖 `store_product_id` 对齐商店商品,但 admin-api 没有任何商品 CRUD。 +2. **公告有读没写**:`ops_notice` 表 0 行,E1 交付了 C 端读,后台没有写入口。 + +## 二、勘察补充(影响做法,已实测) + +1. **前端挂载机制**(与条目描述略有出入,已核实):`src/utils/routerHelper.ts` 用 + `import.meta.glob('../views/**/*.{vue,tsx}')` + 后端菜单的 `component` 字符串**动态解析**; + `src/router/index.ts` 的 `asyncRouterMap` 只静态声明了 dashboard/content/user/system 等早期分组, + commerce/governance/board 这些都没有静态登记。→ 所以新增页面**只需**:① 新建 `.vue`; + ② `sql/data-phase1.sql` 里加菜单且 `component` 与视图路径一致。**静态路由不必改**(改了也无害)。 + 真正的坑是 `route_name` 唯一(曾因 `CreatorCenter` 重名导致 404),本轮命名会避开。 +2. **id 保留段**:权限点已用到 1155、菜单已用到 30 → 权限点用 **1160-1163**、菜单用 **31/32/33**。 +3. **前端既有约定**:api 封装在 `src/api/admin/*`(`import request from '@/axios'` + `PageResult`), + 页面用 `useRbac().can(code)` 控权、`dateCell` 格式化日期、`PageHeading` 标题;金额/金币这类大数值本项目 + 没有 `formatCount`,实际用的是 `src/utils/format.js`,**我会用其中的数值格式化工具或 `toLocaleString`**, + 并在回执说明(不新造 util)。 + +## 三、口径决定(需在回执与契约里写清) + +**VIP 商品**:**允许创建、不允许启用**(`PUT /{id}/status` 把 `type=VIP` 置为启用 → `400`)。 +理由:运营可以先把 VIP 商品与商店 product id 录好,等 B2 订阅上线后一键启用;直接拒绝创建会让上线日重新补数据。 +B1-1 已定「VIP 订阅本轮不做」,所以**启用**才是会产生误导的动作,拦在这里。 + +## 四、后端做法 + +1. **契约先行**:`docs/api-phase1.md` 新增一节(放在 §2.6 订单与权益之后的新编号),写清两个模块的 + 接口、字段、校验、错误码、权限点。 +2. **商品**(module-commerce 加 service/VO,admin-api 加 controller): + `GET /admin/products/page`(type/status/platform/keyword + 分页)、`GET /{id}`、`POST`、`PUT /{id}`、 + `PUT /{id}/status`、`DELETE /{id}`;字段按表结构;校验:`code` 全局唯一、`store_product_id` 同 `platform` 唯一、 + `type=COIN → coin_amount>0`、`type=VIP → vip_days>0`、**VIP 不可启用**。响应带 `statusText/typeText`(前端不写死中文)。 +3. **公告**(module-system 加 `OpsNoticeService` + VO,admin-api 加 controller): + `GET /admin/notices/page`、`GET /{id}`、`POST`、`PUT /{id}`、`PUT /{id}/status`、`DELETE /{id}`。 + **筛选逻辑收敛到 `OpsNoticeService.pageForApp()` 一处**,C 端 `AppNoticeService` 改为委托它 + (改我自己 E1 写的文件),这样"后台发布 → C 端可见 / 过期或下架 → 不可见"用的是同一段条件,不会漂移。 +4. **菜单与权限点**:`sql/data-phase1.sql` 幂等追加(权限点 1160-1163、菜单 31/32/33 与超管授权)。 + 商品挂「订单与权益」(id 22) 下;公告新建「运营管理」(id 32) 分组 + 子菜单 (33)。 +5. **Swagger**:两个新控制器按 S1 规范加 `@Tag`/`@Operation`,并把分组补进 admin-api 的 `@OpenAPIDefinition`。 + +## 五、前端做法(ReelShortWeb,develop 分支) + +- `src/api/admin/product.ts`、`src/api/admin/notice.ts`(按 `commerce.ts` 的写法) +- `src/views/commerce/ProductManagementView.vue`(列表/筛选/新建编辑弹窗/上下架/删除) +- `src/views/operation/NoticeManagementView.vue`(列表/新建编辑/发布下架/删除) +- `sql/data-phase1.sql` 的菜单 `component` 写 `views/commerce/ProductManagementView`、`views/operation/NoticeManagementView` +- **不改既有页面**;`route_name` 用 `commerce-products` / `operation-notices`(先 grep 确认不重名) + +## 六、门禁 + +- 后端:`mvn test` 存量 **91 条不得跑红** + 新增测试(商品三类校验、公告可见性委托、 + 两个控制器的接入层契约、权限点注解检查) +- 前端:`pnpm build:pro` 通过 + eslint 零错误 +- 端到端:后台登录 → 菜单出现两个新页面 → 建商品 → 用该商品在 C 端 `verify` 能对上 + (`store_product_id` 命中,不再是 `200002 该商品未在后台配置`);公告发布 → C 端 `/app/notices` 可见 → 下架/过期 → 不可见 + +## 七、风险 / 待确认 + +1. **不新增静态路由**是否与条目"两边都要加"冲突:我按实测的加载机制做,若你要求同时静态登记,我再补(不影响正确性)。 +2. `/app/orders/verify` 走真实 Google Play 才有完整链路;本机无商店凭据,端到端验证到"200002 消失、进入校验环节"为止。 +3. 公告"发布时间"语义:`publish_at` 为空视为立即生效(C 端条件里已按"为空或 <= now"处理)。 diff --git a/docs/plans/2026-10-16-J1.md b/docs/plans/2026-10-16-J1.md new file mode 100644 index 0000000..3f63e5d --- /dev/null +++ b/docs/plans/2026-10-16-J1.md @@ -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` 新建返回 + **回退成自然写法**(全局生效后它们就是冗余的特例),保持"所有模块一个口径"。 + +### 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` 的 **key** 不在本次范围(只要求 Map 值);当前也没有接口返回 `Map` 给前端。 +5. 大 id 只在"新建后立刻用 id 操作"时暴露;老页面用种子小 id,所以**回归要靠端到端实测**,不能只看单测。 diff --git a/docs/plans/INBOX.md b/docs/plans/INBOX.md new file mode 100644 index 0000000..3d20d07 --- /dev/null +++ b/docs/plans/INBOX.md @@ -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` +- 测试:新增 `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 "`, + 拿新用户的 `id`(预期是 `"2111…"` 这种**带引号的字符串**、`total` 是**不带引号的数字**),再 + `curl -s "http://127.0.0.1:8080/admin/app-users/" -H "Authorization: Bearer "` → 预期 `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=''"` **逐位相同**。 + 对照:`node -e 'console.log(String(JSON.parse("2111015748376858626")))'` → `2111015748376858620` 附近(会被改写),说明精度问题真实存在。 +4. **后台界面(:3005)**:`admin/admin123` 登录 → 商品配置新建一个商品 → **点它那一行的状态/编辑**, + 预期正常(toast 成功,不再"商品不存在");菜单管理编辑一个顶级菜单,预期父级下拉显示「顶级菜单」而不是 `0`。 +5. **短剧管理 → 剧集管理带参跳转**:从短剧管理点某剧的「剧集」,URL 带 `?drama=`, + 预期剧集页自动选中该剧(改成字符串比较前这里会选不中)。 +6. **客户端**:`cd ReelShort && flutter test`(预期 All tests passed);有设备时 `flutter run` 打开首页看播放量/详情 + 不报解析异常(后端 `viewCount` 现在下发字符串)。 +7. **异常路径**:请求体里 id 传**数字**(老客户端行为)也应被接受:`PUT /admin/products//status?status=1` 用数字 id 路径同样 200。 + +**禅道**:J1 在禅道里同样没有对应任务(项目 2 下仍是 S0/S1/S0-16、A1–A6、B1/B2、C1–C3),未动任何既有任务状态。 +**服务现状**:8080/8081 是我刚重建起回的新 jar(自测时在跑);:3005 的 dev server 也在。若被环境回收,按上面「准备」起回来即可。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md new file mode 100644 index 0000000..29ad3db --- /dev/null +++ b/docs/plans/PROGRESS.md @@ -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` / ``): + +``` +src/api/admin/product.ts src/api/admin/notice.ts +src/views/commerce/ProductManagementView.vue src/views/operation/NoticeManagementView.vue +``` + +**挂载机制实测**(与 INBOX 描述有出入,以实测为准):菜单树来自 DB `sys_menu`, +`src/utils/routerHelper.ts` 用 `import.meta.glob('../views/**/*.{vue,tsx}')` 按 `component` 字符串**动态解析**; +`src/router/index.ts` 的 `asyncRouterMap` 只静态声明 dashboard/content/user/system 这几个早期分组, +commerce/governance/board/creator-center 都不在里面 —— 所以新增页面**不需要**改静态路由表, +只要 ① 建 `.vue` ② 菜单 `component` 与视图路径一致。本轮据此实现,未动 `src/router/index.ts`。 + +前端门禁: + +``` +$ pnpm ts:check → 0 错误(无输出) +$ pnpm exec eslint --ext .js,.ts,.vue ./src + ✖ 1 problem (1 error, 0 warnings) # 第一遍:NoticeManagementView.vue prettier 折行 + (改完复跑)→ 无输出,0 error +$ pnpm build:pro → Build successful. Please see dist-pro directory +``` + +### 动作 8:`:3005` 真浏览器实测(Codex in-app browser) + +``` +登录 admin → 侧边栏出现「订单与权益 → 商品配置」「运营管理 → 系统公告」(其余菜单不变) +#/commerce/products → 标题「商品配置」,11 列与后端字段一一对应,状态列用 statusText(已上架/已下架) +#/operation/notices → 标题「系统公告」,空态「还没有发布公告」 +新建商品(UI)→ toast「商品已创建(默认下架,请确认后再上架)」,行内 totalCoin 550、状态「已下架」 +新建公告(UI)→ toast「公告已发布」,行内 发布时间 16:32:15、失效时间「-」、状态「已发布」 +C 端 GET /app/notices(匿名)→ 立刻能看到这条(total 1) +下架 → toast「公告已下架」→ C 端 total 0 +删除 → toast「商品已删除」「公告已删除」→ 列表回到种子数据 +溢出检查(1440/1280/1024,两页各一次) + 1440: docScroll=1440 clientWidth=1440 overflowX=false + 1280: docScroll=1280 clientWidth=1280 overflowX=false + 1024: docScroll=1024 clientWidth=1024 overflowX=false +截图:/tmp/i1-product-list.png、/tmp/i1-notice-list.png +``` + +### 动作 9:❌ 实测抓到的真缺陷 —— 雪花 id 在浏览器里被四舍五入 + +UI 上点「下架」时后端回: + +``` +{"code":200002,"message":"公告不存在"} # 明明列表里就有这条 +``` + +定位: + +``` +$ node -e 'console.log(2111011206851596290, Number.isSafeInteger(2111011206851596290))' +2111011206851596300 false +$ psql -c "select id, title from ops_notice;" + 2111011206851596290 | I1 自测公告(UI 创建) # DB 里是 …596290 +``` + +解读:S0 换雪花主键后 id 已超过 JS 安全整数范围(2^53-1 = 9007199254740991)。 +浏览器 `JSON.parse` 把 `2111011206851596290` 变成 `2111011206851596300`, +页面拿这个被改过的 id 回查 → `200002`。**这是 S0 之后所有"新建即操作"的通用风险**, +只是本轮新建的页面第一个撞上。 + +修复(**限定在本轮两个模块的契约内**,不动全局序列化、不动 app-api,避免影响 Flutter 客户端): + +``` +fruitshorts-module-commerce/.../ProductRespVO.java id 加 @JsonSerialize(using = ToStringSerializer.class) +fruitshorts-module-system/.../NoticeRespVO.java 同上 +ProductAdminController#create / NoticeAdminController#create 返回 Result +ReelShortWeb/src/api/admin/{product,notice}.ts id: string +docs/api-phase1.md §2.11/§2.12 写明"id 以字符串下发" +``` + +新增两条防回归测试(把真实雪花 id 喂进去断言是字符串): +`ProductAdminControllerContractTest#should_send_string_id_when_product_id_exceeds_js_safe_integer`、 +`NoticeAdminControllerContractTest#should_send_string_id_when_notice_id_exceeds_js_safe_integer`。 + +### 动作 10:修复后复跑门禁(最终数字) + +``` +$ mvn test +[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (module-commerce) +[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0 (admin-api) +[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (app-api) +[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (transcode-worker) +[INFO] BUILD SUCCESS # 共 110 条(存量 91 + 新增 19) +$ mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests → BUILD SUCCESS +``` + +修复后复测(同一浏览器,重新起服务后): + +``` +点「已发布」→ toast「公告已下架」,行内状态变「已下架」 # 修复前这里是「公告不存在」 +C 端 GET /app/notices → {"total":0} # 下架后客户端立即不可见 +接口返回类型核对:id= 2111012331625517057 type= str +``` + +### 动作 11:自测数据清理(收尾) + +``` +DELETE 1 # 商品 i1_ui_coin_880(第一轮 UI 建的) +DELETE 4 # 公告 4 条(含 UI 建的) +DELETE 1 # 商品 i1_ui2_coin_500(修复后 UI 建的) +DELETE 1 # 公告 I1 复测公告:印尼开服 + products | soft_deleted notices + 3 | 0 0 +``` + +解读:`product` 表回到 3 行种子(`coin_100 / coin_550 / coin_1200`),`ops_notice` 回 0 行。 +**没有留下任何自测数据。** + +### 本轮遗留(已写进 INBOX 回执的「待确认」) + +1. 雪花 id 的全局方案:admin-api 是否统一 `Long → String`(涉及所有既有页面的 `id` 类型), + app-api 是否要动(影响 Flutter 客户端解析)——属于平台级改动,等裁定。 +2. `/app/orders/verify` 的真发币链路需要 Google Play 凭据(B1-1 已列清单)。 + +--- + +## 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` 新建返回**已回退成自然写法** —— +全局生效后它们只是冗余特例(现在是"所有模块一个口径")。 + +### 动作 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 行"是假数字。真正的证据是后面的**全库残留扫描**(零命中)与现存数据核对,已按此结论写回执。 diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java index e5fe5ea..b79ad58 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java @@ -36,6 +36,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "后台-菜单管理", description = "菜单树维护"), @Tag(name = "后台-操作日志", description = "操作日志检索"), @Tag(name = "后台-订单管理", description = "订单检索、补发与退款"), + @Tag(name = "后台-商品配置", description = "商品(金币包)配置与上下架"), + @Tag(name = "后台-系统公告", description = "系统公告发布与管理(与 C 端口径一致)"), @Tag(name = "后台-权限点", description = "权限点查询"), @Tag(name = "后台-分集收费", description = "分集价格配置"), @Tag(name = "后台-对账", description = "对账任务与手动对账"), diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/NoticeAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/NoticeAdminController.java new file mode 100644 index 0000000..469beba --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/NoticeAdminController.java @@ -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; + +/** + * 后台系统公告管理。 + * + *

发布后的可见性与 C 端 `GET /app/notices` **共用同一套筛选实现** + * (`OpsNoticeService.pageForApp`):`status=1` + `target ∈ {ALL, USER}` + 已到发布时间 + 未过期。 + */ +@Tag(name = "后台-系统公告") +@RestController +@RequestMapping("/admin/notices") +@RequiredArgsConstructor +public class NoticeAdminController { + + private final OpsNoticeService opsNoticeService; + + @Operation(summary = "公告分页查询") + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('notice:list')") + public Result> page(@Validated NoticeQueryReqVO req) { + return Result.ok(opsNoticeService.pageForAdmin(req)); + } + + @Operation(summary = "公告详情") + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermission('notice:list')") + public Result detail(@PathVariable Long id) { + return Result.ok(opsNoticeService.detail(id)); + } + + @Operation(summary = "新建公告(默认发布,C 端立即可见;返回新公告 id,按 §0 以字符串下发)") + @PostMapping + @PreAuthorize("@ss.hasPermission('notice:update')") + @OperationLog(module = "系统公告", action = "新建公告") + public Result 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 update(@PathVariable Long id, @Valid @RequestBody NoticeSaveReqVO req) { + opsNoticeService.update(id, req); + return Result.ok(); + } + + @Operation(summary = "发布 / 下架(下架后 C 端立即不可见)") + @PutMapping("/{id}/status") + @PreAuthorize("@ss.hasPermission('notice:update')") + @OperationLog(module = "系统公告", action = "公告上下架") + public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + opsNoticeService.updateStatus(id, status); + return Result.ok(); + } + + @Operation(summary = "删除公告") + @DeleteMapping("/{id}") + @PreAuthorize("@ss.hasPermission('notice:update')") + @OperationLog(module = "系统公告", action = "删除公告") + public Result delete(@PathVariable Long id) { + opsNoticeService.delete(id); + return Result.ok(); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ProductAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ProductAdminController.java new file mode 100644 index 0000000..214258c --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ProductAdminController.java @@ -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; + +/** + * 后台商品(金币包)配置。 + * + *

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

为什么用反射而不是起 Spring 上下文:{@code @PreAuthorize} 靠方法代理生效, + * 切片测试(standalone MockMvc)不会走代理,跑出来是"永远通过"的假绿; + * 直接检查注解,能真正确认"忘写权限点"这类问题(无权限点被拒的前提是**有**权限点)。 + */ +class AdminPermissionAnnotationTest { + + @Test + void should_declare_expected_permissions_when_checking_new_admin_endpoints() { + assertThat(permissionsOf(ProductAdminController.class)) + .containsExactlyInAnyOrder("product:list", "product:list", + "product:update", "product:update", "product:update", "product:update"); + assertThat(permissionsOf(NoticeAdminController.class)) + .containsExactlyInAnyOrder("notice:list", "notice:list", + "notice:update", "notice:update", "notice:update", "notice:update"); + } + + /** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */ + private List permissionsOf(Class controller) { + List permissions = new ArrayList<>(); + for (Method method : controller.getDeclaredMethods()) { + if (!isEndpoint(method)) { + continue; + } + PreAuthorize annotation = method.getAnnotation(PreAuthorize.class); + permissions.add(annotation == null ? "MISSING" : extract(annotation.value())); + } + return permissions; + } + + private boolean isEndpoint(Method method) { + return method.isAnnotationPresent(GetMapping.class) || method.isAnnotationPresent(PostMapping.class) + || method.isAnnotationPresent(PutMapping.class) || method.isAnnotationPresent(DeleteMapping.class); + } + + /** 从 `@ss.hasPermission('product:list')` 里取出权限码。 */ + private String extract(String expression) { + int start = expression.indexOf('\''); + int end = expression.lastIndexOf('\''); + return start >= 0 && end > start ? expression.substring(start + 1, end) : expression; + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/NoticeAdminControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/NoticeAdminControllerContractTest.java new file mode 100644 index 0000000..3edd5fa --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/NoticeAdminControllerContractTest.java @@ -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)); + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ProductAdminControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ProductAdminControllerContractTest.java new file mode 100644 index 0000000..4ea7c62 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ProductAdminControllerContractTest.java @@ -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)); + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/support/ControllerContractSupport.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/support/ControllerContractSupport.java index 1a39d5c..40dffe1 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/support/ControllerContractSupport.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/support/ControllerContractSupport.java @@ -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(); diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/web/LongToStringSerializationTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/web/LongToStringSerializationTest.java new file mode 100644 index 0000000..8afbdfc --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/web/LongToStringSerializationTest.java @@ -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 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 list; + private Map map; + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java index 1e1ef0e..84382fc 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java @@ -2,48 +2,30 @@ package com.fruitshorts.app.service; -import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fruitshorts.app.vo.AppNoticeVO; import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.system.dal.entity.OpsNotice; -import com.fruitshorts.system.dal.mapper.OpsNoticeMapper; +import com.fruitshorts.system.service.OpsNoticeService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; -import java.util.List; - /** * C 端系统公告(`GET /app/notices`,匿名,只读)。 * - *

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

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

没有数据返回空列表(不是 404);本类只负责把实体转成 C 端字段(不暴露后台字段)。 */ @Service @RequiredArgsConstructor public class AppNoticeService { - private static final String TARGET_ALL = "ALL"; - private static final String TARGET_USER = "USER"; - private static final int MAX_PAGE_SIZE = 50; - - private final OpsNoticeMapper opsNoticeMapper; + private final OpsNoticeService opsNoticeService; public PageResult page(int pageIndex, int pageSize) { - LocalDateTime now = LocalDateTime.now(); - Page page = new Page<>(pageIndex < 1 ? 1 : pageIndex, - pageSize < 1 ? 20 : Math.min(pageSize, MAX_PAGE_SIZE)); - IPage result = opsNoticeMapper.selectPage(page, Wrappers.lambdaQuery() - .eq(OpsNotice::getStatus, 1) - .in(OpsNotice::getTarget, List.of(TARGET_ALL, TARGET_USER)) - .and(w -> w.isNull(OpsNotice::getPublishAt).or().le(OpsNotice::getPublishAt, now)) - .and(w -> w.isNull(OpsNotice::getExpireAt).or().gt(OpsNotice::getExpireAt, now)) - .orderByDesc(OpsNotice::getPublishAt) - .orderByDesc(OpsNotice::getId)); - return new PageResult<>(result.getTotal(), result.getRecords().stream().map(this::toVO).toList()); + PageResult page = opsNoticeService.pageForApp(pageIndex, pageSize); + return new PageResult<>(page.getTotal(), page.getList().stream().map(this::toVO).toList()); } private AppNoticeVO toVO(OpsNotice notice) { diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java index d306f8e..45430d0 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java @@ -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); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCreatorAndConfigContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCreatorAndConfigContractTest.java index 1c90550..8397c0e 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCreatorAndConfigContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCreatorAndConfigContractTest.java @@ -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)); } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java index 5098e68..8f4b0d3 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java @@ -12,6 +12,7 @@ import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper; import com.fruitshorts.commerce.service.DisabledStoreVerifier; import com.fruitshorts.commerce.service.EntitlementService; import com.fruitshorts.commerce.service.OrderService; +import com.fruitshorts.commerce.service.ProductService; import com.fruitshorts.commerce.service.StoreVerifier; import com.fruitshorts.commerce.service.UnlockService; import com.fruitshorts.commerce.service.WalletService; @@ -65,8 +66,8 @@ class AppOrdersVerifyContractTest { storeReceiptMapper = mock(StoreReceiptMapper.class); verifier = new StubStoreVerifier(); OrderService orderService = new OrderService(ordersMapper, mock(OrderEventMapper.class), - productMapper, storeReceiptMapper, verifier, mock(WalletService.class), - mock(EntitlementService.class)); + new ProductService(productMapper), storeReceiptMapper, verifier, + mock(WalletService.class), mock(EntitlementService.class)); mockMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController( mock(WalletService.class), mock(UnlockService.class), orderService)); SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( @@ -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)); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java index 3a9f24f..aa7b0ef 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java @@ -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")); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java index 7ab4da5..153d5c0 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java @@ -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(); diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/web/JacksonLongToStringConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/web/JacksonLongToStringConfig.java new file mode 100644 index 0000000..7d74da9 --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/web/JacksonLongToStringConfig.java @@ -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 字符串。 + * + *

为什么需要它:S0 把主键换成雪花 id 之后,id 是 19 位十进制数(例如 {@code 2111012753262120962}), + * 而 JS 的 {@code Number.MAX_SAFE_INTEGER} 只有 {@code 9007199254740991}。裸数字下发会被浏览器 + * (以及任何用 IEEE754 double 解析 JSON 的客户端)四舍五入,页面拿这个 id 回查就是"数据不存在" —— + * 后台"新建一条记录再点编辑/下架"必然踩中。 + * + *

所以:**所有 ID 字段一律以字符串下发**(见 `docs/api-phase1.md` §0)。注意两点: + *

    + *
  • 只动 {@code Long}/{@code long},**不动 {@code Integer}/{@code int}**(分页 pageIndex/pageSize、 + * {@code Result.code} 等保持数字);
  • + *
  • 反序列化方向不受影响:Jackson 默认允许把 {@code "123"} 标量强转成 {@code Long}, + * 路径变量与查询参数本来就走 String→Long 转换,所以前端把 id 当字符串回传没问题。
  • + *
+ * + *

放在 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} 的序列化钉成数字的混入声明。 + * + *

用 mixin 而不是给实体加注解,是为了让"例外"跟"规则"写在同一个文件里, + * 也避免给 fruitshorts-common 引入 jackson 依赖。 + */ + abstract static class PageResultTotalAsNumber { + @JsonSerialize(using = PlainLongSerializer.class) + abstract long getTotal(); + } + + /** 把 long 原样写成 JSON 数字(全局规则把它们都变成字符串了,这里显式还原)。 */ + public static final class PlainLongSerializer extends JsonSerializer { + @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()); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java index 2122b53..36fe7c8 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java @@ -11,7 +11,6 @@ import com.fruitshorts.commerce.dal.entity.Product; import com.fruitshorts.commerce.dal.entity.StoreReceipt; import com.fruitshorts.commerce.dal.mapper.OrderEventMapper; import com.fruitshorts.commerce.dal.mapper.OrdersMapper; -import com.fruitshorts.commerce.dal.mapper.ProductMapper; import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper; import com.fruitshorts.commerce.service.store.StorePlatforms; import com.fruitshorts.commerce.vo.OrderQueryReqVO; @@ -54,7 +53,7 @@ public class OrderService { private final OrdersMapper ordersMapper; private final OrderEventMapper orderEventMapper; - private final ProductMapper productMapper; + private final ProductService productService; private final StoreReceiptMapper storeReceiptMapper; private final StoreVerifier storeVerifier; private final WalletService walletService; @@ -130,7 +129,7 @@ public class OrderService { return deliver(existing, "STORE", null, "票据重复提交,返回既有订单"); } - Product product = findProduct(store, result.productId()); + Product product = productService.findForStore(store, result.productId()); if (product == null) { throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "该商品未在后台配置,请联系客服:" + result.productId()); @@ -180,18 +179,6 @@ public class OrderService { } } - /** 商品按 `(平台, 商店商品 id)` 找;`platform=ALL` 的通用商品也认。 */ - private Product findProduct(String platform, String storeProductId) { - if (!StringUtils.hasText(storeProductId)) { - return null; - } - List products = productMapper.selectList(Wrappers.lambdaQuery() - .eq(Product::getStoreProductId, storeProductId) - .in(Product::getPlatform, List.of(platform, "ALL")) - .eq(Product::getStatus, 1)); - return products.isEmpty() ? null : products.get(0); - } - /** 从校验结果建单:状态先记 PAID(商店已收款),交付由 deliver 推进到 DELIVERED。 */ private Orders buildOrder(Long userId, String platform, Product product, StoreVerifier.StoreReceiptResult result, String receipt) { diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java new file mode 100644 index 0000000..921b090 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ProductService.java @@ -0,0 +1,223 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.commerce.dal.entity.Product; +import com.fruitshorts.commerce.dal.mapper.ProductMapper; +import com.fruitshorts.commerce.vo.ProductQueryReqVO; +import com.fruitshorts.commerce.vo.ProductRespVO; +import com.fruitshorts.commerce.vo.ProductSaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 商品(金币包)配置:后台 CRUD。 + * + *

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

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

文案由服务端给:前端不再写死中文,避免"同一状态两种叫法"。 + */ +@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; + }; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductSaveReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductSaveReqVO.java new file mode 100644 index 0000000..f85751c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductSaveReqVO.java @@ -0,0 +1,49 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 后台商品新增/编辑。 + * + *

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

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

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

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