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:
1 parent
c503629bf9
commit
f35db2cd3b
16 files changed
+355
-33
No files matched your search
+3
-4
@@ -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<String> create(@Valid @RequestBody NoticeSaveReqVO req) {
|
||||
// 雪花 id 用字符串回,避免前端拿到被四舍五入的 id
|
||||
return Result.ok(String.valueOf(opsNoticeService.create(req)));
|
||||
public Result<Long> create(@Valid @RequestBody NoticeSaveReqVO req) {
|
||||
return Result.ok(opsNoticeService.create(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "编辑公告")
|
||||
|
||||
+3
-4
@@ -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<String> create(@Valid @RequestBody ProductSaveReqVO req) {
|
||||
// 雪花 id 用字符串回,避免前端拿到被四舍五入的 id
|
||||
return Result.ok(String.valueOf(productService.create(req)));
|
||||
public Result<Long> create(@Valid @RequestBody ProductSaveReqVO req) {
|
||||
return Result.ok(productService.create(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "编辑商品")
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+3
@@ -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();
|
||||
|
||||
+132
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user