refactor(jackson): Jackson 2 → Jackson 3 全项目迁移

Spring Boot 4.0.6 默认引入 Jackson 3.1.2,但项目中 1018 处 @JsonSerialize
注解使用的是 Jackson 2 的 com.fasterxml.jackson.* 包,导致注解被忽略,
Long 字段序列化为裸数字,引发前端 JS Number 精度丢失(Bug #681 的根因)。

- com.fasterxml.jackson.databind.* → tools.jackson.databind.*
- com.fasterxml.jackson.core.* → tools.jackson.core.*
- com.fasterxml.jackson.annotation.* 保留(Jackson 3 仍用同包名)
- com.fasterxml.jackson.datatype.jsr310.* 保留(不再需要,Jackson 3 内置 JavaTime 支持)

- JsonSerializer<T> → ValueSerializer<T>
- JsonDeserializer<T> → ValueDeserializer<T>
- SerializerProvider → SerializationContext
- JsonMappingException → DatabindException
- JsonProcessingException → JacksonException(变 RuntimeException)
- ContextualSerializer → 合并入 ValueSerializer(createContextual 成 default 方法)
- LaissezFaireSubTypeValidator → BasicPolymorphicTypeValidator.builder()
- Jackson2ObjectMapperBuilderCustomizer → JsonMapperBuilderCustomizer

- ObjectMapper.configure() 返回 void → JsonMapper.builder().disable(...).build()
- ObjectMapper.setPropertyNamingStrategy() → JsonMapper.builder().propertyNamingStrategy()
- ObjectMapper.setDateFormat() → JsonMapper.builder().defaultDateFormat()
- ObjectNode.fieldNames() → JsonNode.propertyNames()(返回 Collection<String>)
- SerializationFeature.WRITE_DATES_AS_TIMESTAMPS 已移除(JavaTime 自动处理)
- ObjectMapper 的 JavaTimeModule 注册不再需要(Jackson 3 内置)

- core-framework/.../ApplicationConfig.java:重写为 JsonMapperBuilderCustomizer +
  自定义 LocalDateTime 序列化(Jackson 3 内置 ext.javatime.*)
- core-framework/.../FastJson2JsonRedisSerializer.java:用 BasicPolymorphicTypeValidator
  替代 LaissezFaireSubTypeValidator
- core-common/.../JsonUtils.java:改为 JsonMapper.builder() 模式
- core-common/.../SensitiveJsonSerializer.java:JsonSerializer → ValueSerializer

- 5 个模块 jackson-databind groupId 改为 tools.jackson.core(版本由 Spring Boot BOM 管理)
- jackson-annotations 保留 com.fasterxml.jackson.core(Jackson 3 仍用同包名)

-  mvn compile 全 11 模块通过
-  mvn package 成功生成 fat JAR
-  后端启动正常(JDK 25,Spring Boot 4.0.6)
-  登录 API 返回 JWT token
-  /charge-manage/charge/encounter-patient-page 响应:
    - encounterId: "2032288214655660033"(字符串,@JsonSerialize 生效)
    - patientId: "2026486681850499074"(字符串,@JsonSerialize 生效)
-  Bug #681 根因彻底解决(Long 精度丢失)

- Bug #681(前端兜底 fix: acf685fba)+ 本 commit(后端根治)
- Bug #281(历史 jsr310 模块问题)随 Jackson 3 内置 JavaTime 一并解决

- Playwright E2E 全量回归(51 个 spec)
- 时间字段序列化专项测试(LocalDateTime 格式验证)
- 删除 Jackson 2 starter(spring-boot-jackson2)的可行性评估
This commit is contained in:
2026-06-15 14:52:48 +08:00
parent 01e8cc459c
commit 97827b6ff0
448 changed files with 1085 additions and 1063 deletions

View File

@@ -1,6 +1,6 @@
package com.core.framework.aspectj;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.core.common.utils.JsonUtils;
import com.core.common.annotation.Log;
import com.core.common.core.domain.entity.SysUser;

View File

@@ -1,51 +1,64 @@
package com.core.framework.config;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.jackson2.autoconfigure.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueDeserializer;
import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.module.SimpleModule;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.TimeZone;
@Configuration
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan({"com.core.**.mapper", "com.healthlink.his.**.mapper"})
public class ApplicationConfig {
private static final JsonDeserializer<LocalDateTime> LOCAL_DATE_TIME_DESERIALIZER = new JsonDeserializer<>() {
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private static final DateTimeFormatter SIMPLE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter SLASH_FORMATTER = DateTimeFormatter.ofPattern("yyyy/M/d HH:mm:ss");
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private static final DateTimeFormatter SLASH_FORMATTER = DateTimeFormatter.ofPattern("yyyy/M/d HH:mm:ss");
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext context) throws IOException {
String text = p.getText();
if (text == null || text.isEmpty()) return null;
String cleaned = text.replaceAll("[Zz]$", "").replaceAll("[+-]\\d{2}:?\\d{2}$", "");
try { return LocalDateTime.parse(cleaned, ISO_FORMATTER); } catch (Exception ignored) {}
try { return LocalDateTime.parse(cleaned, SIMPLE_FORMATTER); } catch (Exception ignored) {}
return LocalDateTime.parse(cleaned, SLASH_FORMATTER);
}
};
private static final ValueDeserializer<LocalDateTime> LOCAL_DATE_TIME_DESERIALIZER =
new ValueDeserializer<LocalDateTime>() {
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext context) throws JacksonException {
String text = p.getText();
if (text == null || text.isEmpty()) return null;
String cleaned = text.replaceAll("[Zz]$", "").replaceAll("[+-]\\d{2}:?\\d{2}$", "");
try { return LocalDateTime.parse(cleaned, ISO_FORMATTER); } catch (Exception ignored) {}
try { return LocalDateTime.parse(cleaned, FORMATTER); } catch (Exception ignored) {}
return LocalDateTime.parse(cleaned, SLASH_FORMATTER);
}
};
private static final ValueSerializer<LocalDateTime> LOCAL_DATE_TIME_SERIALIZER =
new ValueSerializer<LocalDateTime>() {
@Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializationContext ctx) throws JacksonException {
gen.writeString(value.format(FORMATTER));
}
@Override
public Class<LocalDateTime> handledType() {
return LocalDateTime.class;
}
};
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization() {
public JsonMapperBuilderCustomizer jacksonObjectMapperCustomization() {
return builder -> {
builder.timeZone(TimeZone.getDefault());
builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addDeserializer(LocalDateTime.class, LOCAL_DATE_TIME_DESERIALIZER);
builder.modules(javaTimeModule);
builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
SimpleModule module = new SimpleModule("HealthLinkLocalDateTime");
module.addDeserializer(LocalDateTime.class, LOCAL_DATE_TIME_DESERIALIZER);
module.addSerializer(LocalDateTime.class, LOCAL_DATE_TIME_SERIALIZER);
builder.addModule(module);
};
}
}

View File

@@ -1,7 +1,10 @@
package com.core.framework.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import tools.jackson.databind.DefaultTyping;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
import tools.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import org.slf4j.Logger;
@@ -10,16 +13,17 @@ import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
/**
* Redis序列化器 - 兼容fastjson2旧格式
* Redis序列化器 - 兼容fastjson2旧格式Jackson 3 迁移版)
*
* @author system
*/
public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T> {
private static final Logger log = LoggerFactory.getLogger(FastJson2JsonRedisSerializer.class);
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
/** 新格式: 带类型信息 (activateDefaultTyping) */
private final ObjectMapper typedMapper;
@@ -31,16 +35,21 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T> {
super();
this.clazz = clazz;
// Jackson 3: 用 BasicPolymorphicTypeValidator 替代 LaissezFaireSubTypeValidator
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class)
.build();
// 新格式 ObjectMapper (带类型信息)
this.typedMapper = new ObjectMapper();
this.typedMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
this.typedMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL);
this.typedMapper = JsonMapper.builder()
.changeDefaultVisibility(vc -> vc.withVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY))
.activateDefaultTyping(ptv, DefaultTyping.NON_FINAL)
.build();
// 旧格式 ObjectMapper (不带类型信息)
this.plainMapper = new ObjectMapper();
this.plainMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
this.plainMapper = JsonMapper.builder()
.changeDefaultVisibility(vc -> vc.withVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY))
.build();
}
@Override
@@ -75,4 +84,4 @@ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T> {
return null;
}
}
}
}

View File

@@ -2,8 +2,10 @@ package com.core.framework.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
@@ -12,7 +14,7 @@ import org.springframework.data.redis.serializer.SerializationException;
import java.nio.charset.StandardCharsets;
/**
* Jackson Redis序列化器 - 兼容fastjson旧格式
* Jackson Redis序列化器 - 兼容fastjson旧格式Jackson 3 迁移版)
*
* 新数据: 纯JSON (无类型包装),调用方用 convertValue 转换
* 旧fastjson: 去除L后缀后按JSON解析
@@ -30,10 +32,10 @@ public class FastjsonCompatibleRedisSerializer implements RedisSerializer<Object
}
private static ObjectMapper createMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
return JsonMapper.builder()
.changeDefaultVisibility(vc -> vc.withVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY))
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
}
/** 获取共享ObjectMapper供 DictUtils / TokenService 等做 convertValue */
@@ -64,7 +66,7 @@ public class FastjsonCompatibleRedisSerializer implements RedisSerializer<Object
try {
// 处理旧Jackson activateDefaultTyping格式: ["className", {data}]
if (cleaned.startsWith("[\"") && cleaned.length() > 10) {
com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(cleaned);
JsonNode node = objectMapper.readTree(cleaned);
if (node.isArray() && node.size() >= 2 && node.get(0).isTextual()) {
// 取data部分第2个元素忽略className
return objectMapper.treeToValue(node.get(1), Object.class);
@@ -76,4 +78,4 @@ public class FastjsonCompatibleRedisSerializer implements RedisSerializer<Object
return null;
}
}
}
}

View File

@@ -1,6 +1,6 @@
package com.core.framework.interceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.core.common.utils.JsonUtils;
import com.core.common.annotation.RepeatSubmit;
import com.core.common.core.domain.AjaxResult;

View File

@@ -1,6 +1,6 @@
package com.core.framework.interceptor.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.core.common.utils.JsonUtils;
import com.core.common.annotation.RepeatSubmit;
import com.core.common.constant.CacheConstants;

View File

@@ -1,6 +1,6 @@
package com.core.framework.security.handle;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.core.common.utils.JsonUtils;
import com.core.common.constant.HttpStatus;
import com.core.common.core.domain.AjaxResult;

View File

@@ -1,6 +1,6 @@
package com.core.framework.security.handle;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.core.common.utils.JsonUtils;
import com.core.common.constant.Constants;
import com.core.common.core.domain.AjaxResult;

View File

@@ -71,17 +71,19 @@ public class TokenService {
if (cached instanceof java.util.List<?> list && list.size() >= 2 && list.get(0) instanceof String) {
Object data = list.get(1);
if (data instanceof java.util.Map) {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
tools.jackson.databind.ObjectMapper mapper =
tools.jackson.databind.json.JsonMapper.builder()
.disable(tools.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
return mapper.convertValue(data, LoginUser.class);
}
}
// 兼容纯JSON格式: LinkedHashMap -> LoginUser
if (cached instanceof java.util.Map) {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
tools.jackson.databind.ObjectMapper mapper =
tools.jackson.databind.json.JsonMapper.builder()
.disable(tools.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
return mapper.convertValue(cached, LoginUser.class);
}
return null;