feat(framework): 全局 Long 字符串化,ID 一律以字符串下发

用户裁定方案 (a):雪花 id 精度问题全局修,统一契约。

- 新增 JacksonLongToStringConfig(fruitshorts-framework):Long/Long.TYPE → ToStringSerializer,
  经 Jackson2ObjectMapperBuilderCustomizer 挂进 Spring Boot 的 ObjectMapper;admin-api 与
  app-api 都是 scanBasePackages=com.fruitshorts,一处生效两端一致
- 例外:PageResult.total 是计数不是 id,用 mixin 显式保持数字(前端分页组件要数字);
  Integer/int(pageIndex/pageSize/Result.code 等)不受影响
- 反序列化方向不变:请求体 'id:123'、路径变量、查询参数都接受字符串
- 契约 api-phase1.md §0 新增「ID 字段一律以字符串下发」总口径,§2.11/§2.12 改为引用 §0;
  I1 里逐字段加的 @JsonSerialize 与 Result<String> 回退成自然写法,避免多套口径
- 测试:新增 LongToStringSerializationTest(普通/嵌套/List/Map/分页 + 反序列化 + 19 位路径变量),
  契约测试的 standalone mapper 也挂上模块(否则测试假绿);C 端 4 条 id 断言改成 isString
- mvn test:commerce 33 / admin-api 21 / app-api 49 / worker 12 = 115 条全过
This commit is contained in:
jianghaifeng committed 2026-10-16 16:44:13 +08:00
1 parent c503629bf9
commit f35db2cd3b
16 files changed
+355 -33

No files matched your search

+25 -6
View File
@@ -16,6 +16,29 @@
`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。
**ID 字段一律以字符串下发(2026-10-16 J1 起,全局生效)**
所有 ID 字段(主键与外键,例如 `id` / `dramaId` / `episodeId` / `userId` / `orderId` …)
在 JSON 里都是**字符串**,不是数字:
```json
{ "code": 0, "message": "ok", "data": { "id": "2111012753262120962", "dramaId": "5" } }
```
原因:S0 之后主键是雪花 id,19 位十进制数已超过 JS 的 `Number.MAX_SAFE_INTEGER`
(`9007199254740991`)。裸数字下发会被浏览器/IEEE754 客户端四舍五入
(实测库里的 `2111012753262120962` 在浏览器里变成 `2111012753262121000`),
页面再拿它回查就是 `200002 数据不存在`。
约定与边界:
- **id 回传时按字符串传**(路径变量 / 查询参数 / 请求体都接受字符串,服务端自己转 `Long`);
- 实现方式:服务端**全局**把 `Long` / `long` 序列化成字符串(`fruitshorts-framework` 的
`JacksonLongToStringConfig`),**各模块不再逐字段加注解**;
- **例外:分页 `total` 仍是数字** —— 它是条数不是 id,前端分页组件要数字;
`Integer` / `int` 字段(`pageIndex` / `pageSize` / 各类计数如 `totalEpisodes`、状态码 `code`)
也**保持数字**。
**鉴权**:`Authorization: Bearer <token>`。
管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。
客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。
@@ -773,10 +796,7 @@ GET /app/notices?pageIndex=1&pageSize=20
| `sort` / `status` | 排序 / 上下架 |
| `effectiveAt` / `expireAt` | 生效与失效时间(可空) |
> **⚠️ `id` 以字符串下发**(2026-10-16 实测补充):S0 换雪花主键后 id 已超过 JS 的安全整数范围
> (2^53-1 = 9007199254740991)。直接下发数字会被浏览器四舍五入(实测 `2111011206851596290`
> 在 JS 里变成 `2111011206851596300`),页面拿这个 id 回查就是"商品不存在"。
> 因此**本节所有响应里的 `id`(含新建返回的新 id)都是字符串**,前端原样拼进 URL 即可。
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**(本节不再单独规定)。
**校验与错误码**:
@@ -809,8 +829,7 @@ GET /app/notices?pageIndex=1&pageSize=20
**字段**:`title` / `content` / `target`(`ALL` / `USER` / `CREATOR`)/ `type`(`SYSTEM` / `AUDIT` / `PAYMENT` / `ACTIVITY`)/
`status` / `publishAt` / `expireAt`;响应另带 `statusText` / `targetText` / `typeText`。
> **⚠️ `id` 以字符串下发**:同 §2.11 —— 雪花 id 超过 JS 安全整数范围,数字下发会被浏览器四舍五入,
> 导致"下架/编辑"回查不到记录。本节的 `id`(含新建返回的新 id)一律是字符串。
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**。
**与 C 端口径严格对齐(同一条查询实现)**:C 端 `GET /app/notices` 只返回
`status=1` 且 `target ∈ {ALL, USER}`、`publish_at` 为空或 `<= now`、`expire_at` 为空或未过期的公告,
+91
View File
@@ -0,0 +1,91 @@
# J1 全局 ID 字符串化(Long → String)+ 契约统一 —— 计划(2026-10-16)
> 依据:`docs/plans/INBOX.md` 18:30 条目。用户裁定:雪花 id 精度问题**按方案 (a) 全局修改、统一契约**。
> 起因:I1 实测「UI 点下架 → `200002 公告不存在`」,根因是 19 位雪花 id 超出 JS `Number.MAX_SAFE_INTEGER`
> (9007199254740991),浏览器 `JSON.parse` 会把它四舍五入。
## 一、方案
### 1. 后端:fruitshorts-framework 里注册全局序列化模块
- 新增 `com.fruitshorts.framework.web.JacksonLongToStringConfig`:
- `SimpleModule` 把 **`Long.class` 与 `Long.TYPE`(`long`)→ `ToStringSerializer`**;
- 通过 `Jackson2ObjectMapperBuilderCustomizer` 挂进 Spring Boot 自动配置的 `ObjectMapper`。
- 两个入口(admin-api / app-api)都是 `scanBasePackages = "com.fruitshorts"`,framework 在扫描范围内,
一处生效即两端生效;**不改任何 yml**。
- **不动 `Integer` / `int`**:`pageIndex` / `pageSize` / `Result.code` / `quantity` / `durationSec` …
仍是数字。
- **序列化方向**:`Long` 值一律输出成 JSON 字符串(含普通字段、嵌套对象、`List`、`Map` 值、分页 list 元素)。
- **反序列化方向**:Jackson 默认允许 `"123"` → `Long` 的标量强转,所以**请求体里 id 传字符串可以直接绑定**;
路径变量 `/…/{id}` 与查询参数走 Spring 的 `ConversionService`,本来就是 String → Long,不受影响。
这两条都要有测试兜住。
### 2. 一个刻意的例外:`PageResult.total` 保持数字
`PageResult.total` 是 `long` 计数,不是 id。放行它会让后台 ~13 处 `el-pagination :total=` 与 C 端分页全部变成
字符串,收益为零、风险不小。所以给这个字段加 `@JsonSerialize(using = NumberSerializer.class)` 显式保持数字,
并在契约 §0 里写明「**id 一律字符串;分页 total 是计数,仍是数字**」。
⚠️ 这是我自己拍的口径,已单独列进回执,若要求"Long 一律字符串"我再把这一行去掉。
### 3. 契约统一(`docs/api-phase1.md`)
- §0 通用约定新增「**所有 ID 字段(主键与外键)一律以字符串下发**」+ 示例 + `total` 例外说明。
- §2.11 / §2.12(I1 商品/公告)里逐模块写的 "id 以字符串下发" 改成引用 §0。
- **代码侧**:I1 给 `ProductRespVO` / `NoticeRespVO` 加的 `@JsonSerialize` 与 `Result<String>` 新建返回
**回退成自然写法**(全局生效后它们就是冗余的特例),保持"所有模块一个口径"。
### 4. ReelShortWeb 适配(审计结果见 §二)
把 id 当数字用的地方逐处改成字符串语义。**不建议**的写法(`Number(id)` / `id === 数字`)全部清掉。
### 5. ReelShort(Flutter)适配(最小改动)
**先说结论:客户端 model 里的 id 已经是 `(json['id'] ?? '').toString()` —— id 方向本来就是字符串,不会崩。**
会崩的是被全局规则扫到的 **Long 计数**(`as num?` 在收到字符串时直接抛异常)。逐个 Java 字段核过类型后,
只有两个字段真正会崩:
| Dart 位置 | 字段 | Java 类型 | 处理 |
| --- | --- | --- | --- |
| `lib/core/data/models/auth_session.dart:41` | `expiresIn` | `TokenRespVO.expiresIn` = `long` | 改成兼容解析 |
| `lib/core/data/models/drama.dart:101,117` | `viewCount` | `AppDramaCardVO/AppDramaDetailVO.viewCount` = `Long` | 改成兼容解析 |
| 其余 8 处 `as num?`(`durationSec`/`unlockCoin`/`number`/`totalEpisodes`/`freeEpisodes`/`code`…) | — | `Integer`/`int` | 不动(Java 侧不会被字符串化) |
做法:加一个兼容解析小工具(接受 `num` 或数字字符串),替换上面 3 处。**不做模型改造、不碰业务逻辑**,
避免和正在推进客户端的另一位同事冲突。
## 二、影响面清单(审计结果)
### ReelShortWeb(id 当数字用 → 必须改)
| 文件 | 问题 | 改法 |
| --- | --- | --- |
| `src/utils/creatorImpersonation.ts` | `Number(raw)` 读 localStorage 里的代看创作者 id → 19 位 id 被四舍五入,`X-Creator-Id` 头会带错值 | 存/读都按字符串 |
| `src/views/contentManagement/EpisodeManagementView.vue:58` | `Number(route.query.drama)` 与 `item.id ===` 比较 → 从短剧管理带 `?drama=` 跳过来选不中 | 按字符串比较 |
| `src/views/creator-center/composables/useDramaEdit.js:45` | `Number(route.params.id)` 与 `item.id ===` 比较 → 作品编辑找不到自己那条 | 按字符串比较 |
| `src/views/permission/composables/useMenuManagement.js:50,76,97,117` | 顶级菜单哨兵值是数字 `0`,而 `raw.parentId` 会变成字符串 `"0"` → 下拉回显错 | 哨兵统一成字符串 `'0'` |
### ReelShortWeb(核过不需要改)
- `total.value = res.data.total`(13 处)→ `total` 保持数字,无需改。
- `role.id === selectedRoleId`、`detail.id === row.id`、`useDramaEdit#findIndex`、`useCreatorPrice#find`
→ 两侧都来自接口,同为字符串,仍然相等。
- `formatCount(row.views)` → 内部 `Number(value)`,吃到字符串也正常。
- 全局 `parseInt`/`Number(`/id 算术/id 当数组下标/`a.id - b.id`:审计后**没有命中**。
### FruitServer(会跟着变化、需确认消费方)
- admin-api:所有 `Long` id、`viewCount`、`likeCount`、`memberCount` 等计数器 → 字符串。
- app-api:C 端 `id/dramaId/episodeId/userId`、`viewCount`、`positionMs/durationMs`、
`downloadedBytes/totalBytes`、`TokenRespVO.expiresIn` → 字符串;`Result.code`、`PageResult.total`、
`Integer` 字段 → 仍是数字。
## 三、风险 / 待确认
1. **`PageResult.total` 的例外**(见 §一.2)—— 我自己拍的,一句话可回退。
2. **契约测试的 standalone MockMvc 自带 mapper**:它本来是手搓的 `JsonMapper`,不含全局模块,
会让"字符串化"在测试里**假绿**。所以本轮把模块也挂进 `ControllerContractSupport`,让测试与生产一致;
代价是几条老断言(id 断数字)要改成断字符串。
3. **Flutter 仓库当前只有 `main`**(无 develop)。按条目要求在 develop 提交推送,会新建分支;
客户端由另一位同事推进,若他在 main 上继续,需要把这笔改动带过去 —— 回执里会写清。
4. `Map<Long, X>` 的 **key** 不在本次范围(只要求 Map 值);当前也没有接口返回 `Map<Long, ?>` 给前端。
5. 大 id 只在"新建后立刻用 id 操作"时暴露;老页面用种子小 id,所以**回归要靠端到端实测**,不能只看单测。