diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 0849787..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 天且每次刷新轮换。 @@ -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` 为空或未过期的公告, 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/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 index 4eeb2e1..469beba 100644 --- 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 @@ -53,13 +53,12 @@ public class NoticeAdminController { return Result.ok(opsNoticeService.detail(id)); } - @Operation(summary = "新建公告(默认发布,C 端立即可见;返回新公告 id,字符串)") + @Operation(summary = "新建公告(默认发布,C 端立即可见;返回新公告 id,按 §0 以字符串下发)") @PostMapping @PreAuthorize("@ss.hasPermission('notice:update')") @OperationLog(module = "系统公告", action = "新建公告") - public Result create(@Valid @RequestBody NoticeSaveReqVO req) { - // 雪花 id 用字符串回,避免前端拿到被四舍五入的 id - return Result.ok(String.valueOf(opsNoticeService.create(req))); + public Result create(@Valid @RequestBody NoticeSaveReqVO req) { + return Result.ok(opsNoticeService.create(req)); } @Operation(summary = "编辑公告") 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 index abc4ae0..214258c 100644 --- 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 @@ -53,13 +53,12 @@ public class ProductAdminController { return Result.ok(productService.detail(id)); } - @Operation(summary = "新建商品(默认下架,需显式上架;返回新商品 id,字符串)") + @Operation(summary = "新建商品(默认下架,需显式上架;返回新商品 id,按 §0 以字符串下发)") @PostMapping @PreAuthorize("@ss.hasPermission('product:update')") @OperationLog(module = "商品配置", action = "新建商品") - public Result create(@Valid @RequestBody ProductSaveReqVO req) { - // 雪花 id 用字符串回,避免前端拿到被四舍五入的 id - return Result.ok(String.valueOf(productService.create(req))); + public Result create(@Valid @RequestBody ProductSaveReqVO req) { + return Result.ok(productService.create(req)); } @Operation(summary = "编辑商品") 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 index 27a7337..3edd5fa 100644 --- 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 @@ -69,7 +69,8 @@ class NoticeAdminControllerContractTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) // id 以字符串回:雪花 id 直接下发数字会被浏览器四舍五入 - .andExpect(jsonPath("$.data").value("66")); + .andExpect(jsonPath("$.data").value("66")) + .andExpect(jsonPath("$.data").isString()); } @Test 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 index 0868cf6..4ea7c62 100644 --- 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 @@ -71,7 +71,8 @@ class ProductAdminControllerContractTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) // id 以字符串回:雪花 id 直接下发数字会被浏览器四舍五入 - .andExpect(jsonPath("$.data").value("88")); + .andExpect(jsonPath("$.data").value("88")) + .andExpect(jsonPath("$.data").isString()); } @Test 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/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 98146e7..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 @@ -90,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")); 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/vo/ProductRespVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/ProductRespVO.java index 7352752..24952bb 100644 --- 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 @@ -2,8 +2,6 @@ package com.fruitshorts.commerce.vo; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.fruitshorts.commerce.dal.entity.Product; import lombok.Data; @@ -18,12 +16,7 @@ import java.time.LocalDateTime; @Data public class ProductRespVO { - /** - * 雪花 id 超过 JS 的安全整数范围(2^53-1),直接下发数字会被浏览器四舍五入 —— - * 后台页面再拿这个 id 回查就会"商品不存在"。所以 id 一律以**字符串**下发, - * 前端原样拼进 URL 即可(详见 api-phase1.md §2.11)。 - */ - @JsonSerialize(using = ToStringSerializer.class) + /** id 由全局规则以字符串下发(见 api-phase1.md §0「ID 一律字符串」),这里不需要逐字段注解。 */ private Long id; private String code; private String name; 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 index cfcc196..cc430da 100644 --- 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 @@ -2,8 +2,6 @@ package com.fruitshorts.system.vo; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.fruitshorts.system.dal.entity.OpsNotice; import lombok.Data; @@ -13,8 +11,7 @@ import java.time.LocalDateTime; @Data public class NoticeRespVO { - /** 同样是雪花 id 的精度问题:id 以字符串下发,避免浏览器把 id 四舍五入(见 api-phase1.md §2.12)。 */ - @JsonSerialize(using = ToStringSerializer.class) + /** id 由全局规则以字符串下发(见 api-phase1.md §0「ID 一律字符串」)。 */ private Long id; private String title; private String content;