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

@@ -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
@@ -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
@@ -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();
@@ -0,0 +1,132 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.admin.controller.ProductAdminController;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.commerce.service.ProductService;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.framework.web.JacksonLongToStringConfig;
import lombok.Data;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* J1 全局 ID 字符串化:Long/long 一律以 JSON 字符串下发,覆盖普通字段 / 嵌套对象 / List / Map 值 / 分页 list;
* 同时保证 Integer/int 与分页 total 仍是数字,且**字符串形式的 id 能反序列化回 Long**。
*/
class LongToStringSerializationTest {
/** 雪花量级的 id(> 2^53-1),裸数字下发会被 JS 四舍五入 */
private static final long SNOWFLAKE_ID = 2111012753262120962L;
private ObjectMapper mapper;
private ProductService productService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
// 用生产同一套装配(Jackson2ObjectMapperBuilder + 我们的 customizer),而不是手搓一个 mapper,
// 否则这条测试可能"测的是测试自己"
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
new JacksonLongToStringConfig().longToStringCustomizer().customize(builder);
mapper = builder.build();
productService = mock(ProductService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new ProductAdminController(productService));
}
@Test
void should_render_long_as_string_when_field_is_long() throws Exception {
JsonNode json = mapper.readTree(mapper.writeValueAsString(new Sample(SNOWFLAKE_ID, 7)));
assertThat(json.get("id").isTextual()).isTrue();
assertThat(json.get("id").asText()).isEqualTo("2111012753262120962");
// Integer 字段不受影响:分页参数这类必须还是数字
assertThat(json.get("sort").isNumber()).isTrue();
}
@Test
void should_render_long_as_string_when_it_is_nested_list_or_map_value() throws Exception {
Nested nested = new Nested();
nested.setInner(new Sample(SNOWFLAKE_ID, 1));
nested.setList(List.of(new Sample(SNOWFLAKE_ID + 1, 1)));
nested.setMap(Map.of("drama", new Sample(SNOWFLAKE_ID + 2, 1)));
JsonNode json = mapper.readTree(mapper.writeValueAsString(nested));
assertThat(json.get("inner").get("id").isTextual()).isTrue();
assertThat(json.get("list").get(0).get("id").isTextual()).isTrue();
assertThat(json.get("map").get("drama").get("id").isTextual()).isTrue();
assertThat(json.get("list").get(0).get("id").asText()).isEqualTo("2111012753262120963");
assertThat(json.get("map").get("drama").get("id").asText()).isEqualTo("2111012753262120964");
}
@Test
void should_keep_total_as_number_and_list_ids_as_string_when_serializing_page() throws Exception {
PageResult<Sample> page = new PageResult<>(SNOWFLAKE_ID, List.of(new Sample(SNOWFLAKE_ID, 1)));
JsonNode json = mapper.readTree(mapper.writeValueAsString(page));
// total 是计数不是 id:保持数字,否则前端分页组件全部要改
assertThat(json.get("total").isNumber()).isTrue();
assertThat(json.get("total").asLong()).isEqualTo(SNOWFLAKE_ID);
assertThat(json.get("list").get(0).get("id").isTextual()).isTrue();
}
@Test
void should_read_long_from_string_when_request_body_sends_string_id() throws Exception {
Sample sample = mapper.readValue("{\"id\":\"2111012753262120962\",\"sort\":3}", Sample.class);
assertThat(sample.getId()).isEqualTo(SNOWFLAKE_ID);
assertThat(sample.getSort()).isEqualTo(3);
}
@Test
void should_bind_long_when_path_variable_is_a_19_digit_string() throws Exception {
mockMvc.perform(put("/admin/products/2111012753262120962/status").param("status", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 路径变量按字符串传来,必须原样绑定成 Long(不能被截断)
verify(productService).updateStatus(eq(SNOWFLAKE_ID), eq(1));
}
/** 普通字段 */
@Data
public static class Sample {
private Long id;
private Integer sort;
public Sample() {
}
public Sample(Long id, Integer sort) {
this.id = id;
this.sort = sort;
}
}
/** 嵌套容器 */
@Data
public static class Nested {
private Sample inner;
private List<Sample> list;
private Map<String, Sample> map;
}
}