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

@@ -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);
@@ -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));
}
@@ -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"));
@@ -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"));
@@ -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();