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

@@ -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 字符串。
*
* <p>为什么需要它:S0 把主键换成雪花 id 之后,id 是 19 位十进制数(例如 {@code 2111012753262120962}),
* 而 JS 的 {@code Number.MAX_SAFE_INTEGER} 只有 {@code 9007199254740991}。裸数字下发会被浏览器
* (以及任何用 IEEE754 double 解析 JSON 的客户端)四舍五入,页面拿这个 id 回查就是"数据不存在" ——
* 后台"新建一条记录再点编辑/下架"必然踩中。
*
* <p>所以:**所有 ID 字段一律以字符串下发**(见 `docs/api-phase1.md` §0)。注意两点:
* <ul>
* <li>只动 {@code Long}/{@code long},**不动 {@code Integer}/{@code int}**(分页 pageIndex/pageSize、
* {@code Result.code} 等保持数字);</li>
* <li>反序列化方向不受影响:Jackson 默认允许把 {@code "123"} 标量强转成 {@code Long},
* 路径变量与查询参数本来就走 String→Long 转换,所以前端把 id 当字符串回传没问题。</li>
* </ul>
*
* <p>放在 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} 的序列化钉成数字的混入声明。
*
* <p>用 mixin 而不是给实体加注解,是为了让"例外"跟"规则"写在同一个文件里,
* 也避免给 fruitshorts-common 引入 jackson 依赖。
*/
abstract static class PageResultTotalAsNumber {
@JsonSerialize(using = PlainLongSerializer.class)
abstract long getTotal();
}
/** 把 long 原样写成 JSON 数字(全局规则把它们都变成字符串了,这里显式还原)。 */
public static final class PlainLongSerializer extends JsonSerializer<Long> {
@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());
}
}