fix(json): 注册 OffsetDateTime query/form 参数 Converter 修复时间范围查询绑定失败; 抽取 OffsetDateTimeParseUtil 工具类(@UtilityClass)

This commit is contained in:
daxpay
2026-08-02 17:44:33 +08:00
parent fbe8a2a240
commit c9924f8f04
3 changed files with 67 additions and 33 deletions

View File

@@ -4,6 +4,7 @@ import jakarta.annotation.PostConstruct;
import cn.daxpay.open.platform.common.json.jdk.Java8TimeFormatModule;
import cn.daxpay.open.platform.common.json.jdk.JavaLongTypeModule;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.common.json.util.OffsetDateTimeParseUtil;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.PropertyAccessor;
@@ -11,15 +12,18 @@ import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.SerializationFeature;
import java.time.OffsetDateTime;
import java.util.TimeZone;
/// # jackson 序列化自动配置
/// # jackson 序列化与时间参数自动配置
///
/// 使用 Spring Boot 4 标准的 JsonMapperBuilderCustomizer 定制机制, 不手动创建 ObjectMapper
/// 使用 Spring Boot 4 标准的 JsonMapperBuilderCustomizer 定制机制, 不手动创建 ObjectMapper.
/// 另注册 OffsetDateTime 的 query/form 参数 [Converter], 使前端时间范围无时区字符串可绑定 (见 [#offsetDateTimeConverter])
@Configuration
public class JacksonConfiguration {
@@ -64,4 +68,12 @@ public class JacksonConfiguration {
return copy;
}
}
/// OffsetDateTime query/form 参数转换 (Spring ConversionService, 非 jackson)
/// 前端时间范围选择器提交 `yyyy-MM-dd HH:mm:ss` 无时区字符串, 默认无法绑定 OffsetDateTime,
/// 复用 [OffsetDateTimeParseUtil] 与 JSON 反序列化保持两条绑定路径解析一致
@Bean
Converter<String, OffsetDateTime> offsetDateTimeConverter() {
return OffsetDateTimeParseUtil::parse;
}
}

View File

@@ -1,45 +1,19 @@
package cn.daxpay.open.platform.common.json.deserializer;
import cn.daxpay.open.platform.common.json.util.OffsetDateTimeParseUtil;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.ValueDeserializer;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/// # OffsetDateTime 反序列化器, 支持三种格式:
/// ## - ISO 8601 带偏移: 2026-06-13T08:30:00Z 或 2026-06-13T16:30:00+08:00
/// ## - 旧格式: 2026-06-13 16:30:00 (按 Asia/Shanghai 时区解析后转 UTC)
/// ## - 旧格式毫秒: 2026-06-13 16:30:00.123
///
/// # OffsetDateTime 反序列化器
/// 解析逻辑详见 [OffsetDateTimeParseUtil], 支持三种格式: ISO 带偏移 / `yyyy-MM-dd HH:mm:ss` / `yyyy-MM-dd HH:mm:ss.SSS`
public class DaxpayOffsetDateTimeDeserializer extends ValueDeserializer<OffsetDateTime> {
private static final DateTimeFormatter FORMATTER_OLD = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter FORMATTER_OLD_MS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private static final ZoneOffset SHANGHAI_OFFSET = ZoneOffset.ofHours(8);
@Override
public OffsetDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
String text = p.getString();
if (text == null || text.isBlank()) {
return null;
}
try {
return OffsetDateTime.parse(text);
} catch (Exception ignored) {
}
try {
if (text.contains(".")) {
LocalDateTime localDateTime = LocalDateTime.parse(text, FORMATTER_OLD_MS);
return localDateTime.atOffset(SHANGHAI_OFFSET).withOffsetSameInstant(ZoneOffset.UTC);
}
LocalDateTime localDateTime = LocalDateTime.parse(text, FORMATTER_OLD);
return localDateTime.atOffset(SHANGHAI_OFFSET).withOffsetSameInstant(ZoneOffset.UTC);
} catch (Exception e) {
throw new RuntimeException("无法解析日期时间: " + text, e);
}
return OffsetDateTimeParseUtil.parse(p.getString());
}
}

View File

@@ -0,0 +1,48 @@
package cn.daxpay.open.platform.common.json.util;
import lombok.experimental.UtilityClass;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/// # OffsetDateTime 字符串解析工具
/// 供 Jackson 反序列化器与 Spring query/form 参数 [Converter] 共用,
/// 保证 JSON body 与 GET query / form 表单两条绑定路径的解析行为一致.
///
/// 支持三种格式:
/// ## - ISO 8601 带偏移: 2026-06-13T08:30:00Z 或 2026-06-13T16:30:00+08:00
/// ## - 旧格式: 2026-06-13 16:30:00 (按东八区解析后转 UTC)
/// ## - 旧格式毫秒: 2026-06-13 16:30:00.123
@UtilityClass
public class OffsetDateTimeParseUtil {
private static final DateTimeFormatter FORMATTER_OLD = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter FORMATTER_OLD_MS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private static final ZoneOffset SHANGHAI_OFFSET = ZoneOffset.ofHours(8);
/// 将字符串解析为 OffsetDateTime
public OffsetDateTime parse(String text) {
if (text == null || text.isBlank()) {
return null;
}
// 优先按 ISO 带偏移解析
try {
return OffsetDateTime.parse(text);
} catch (Exception ignored) {
}
// 回退: 旧格式(无时区字面量), 先用 LocalDateTime 接住, 再附加东八区偏移转为 UTC
try {
DateTimeFormatter formatter = text.contains(".")
? FORMATTER_OLD_MS
: FORMATTER_OLD;
LocalDateTime localDateTime = LocalDateTime.parse(text, formatter);
return localDateTime.atOffset(SHANGHAI_OFFSET).withOffsetSameInstant(ZoneOffset.UTC);
} catch (Exception e) {
throw new RuntimeException("无法解析日期时间: " + text, e);
}
}
}