From 9a41ee233a74edcb4335465dfece83e9fe8c5d1e Mon Sep 17 00:00:00 2001 From: dadiyang Date: Sat, 26 Sep 2026 22:35:00 +0800 Subject: [PATCH] fix(common): LocalDateTime 反序列化兼容字符串日期,不再静默落 1970 --- yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializerTest.java | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializerTest.java diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java index 5bf5d6c..8702233 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java @@ -1,27 +1,80 @@ package cn.iocoder.yudao.framework.common.util.json.databind; import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import java.io.IOException; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; /** * 基于时间戳的 LocalDateTime 反序列化器 * + * 兼容范围(覆盖前端/接口实际在用的全部格式,避免把"原先能静默入库"的格式变成报错): + * 1. epoch 毫秒:JSON 数字 / 数字字符串(原契约,保持不变) + * 2. 常见日期时间字符串:yyyy-MM-dd HH:mm:ss(含毫秒) + * 3. ISO-8601:yyyy-MM-ddTHH:mm:ss(本地 / 带时区,后者覆盖前端 Date 对象序列化) + * 4. 纯日期:yyyy-MM-dd(当日零点) + * 无法解析时抛清晰错误(含原始值),不再静默落 1970-01-01(消除静默数据损坏)。 + * * @author 老五 */ public class TimestampLocalDateTimeDeserializer extends JsonDeserializer { public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer(); + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DT_MILLIS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + @Override public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - // 将 Long 时间戳,转换为 LocalDateTime 对象 - return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getValueAsLong()), ZoneId.systemDefault()); + // 1. JSON 数字 = epoch 毫秒(原契约,保持) + if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getLongValue()), ZoneId.systemDefault()); + } + String text = p.getText(); + if (text == null || text.trim().isEmpty()) { + return null; + } + text = text.trim(); + // 1. 数字字符串 = epoch 毫秒 + if (text.matches("-?\\d+")) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(text)), ZoneId.systemDefault()); + } + // 2. yyyy-MM-dd HH:mm:ss / .SSS + try { + return LocalDateTime.parse(text, DT); + } catch (DateTimeParseException ignored) { + } + try { + return LocalDateTime.parse(text, DT_MILLIS); + } catch (DateTimeParseException ignored) { + } + // 3. ISO-8601 本地(2026-09-11T00:00:00 / .SSS) + try { + return LocalDateTime.parse(text); + } catch (DateTimeParseException ignored) { + } + // 3. ISO-8601 带时区(前端 Date 对象序列化:2026-09-11T00:00:00.000Z / +08:00) + try { + return OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime(); + } catch (DateTimeParseException ignored) { + } + // 4. 纯日期(2026-09-11)→ 当日零点 + try { + return LocalDate.parse(text).atStartOfDay(); + } catch (DateTimeParseException ignored) { + } + // 无法解析:抛清晰错误(不再静默落 1970) + throw new IllegalArgumentException("无法解析时间值:" + text + + "(支持 epoch 毫秒 / yyyy-MM-dd HH:mm:ss / ISO-8601 / yyyy-MM-dd)"); } } diff --git a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializerTest.java b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializerTest.java new file mode 100644 index 0000000..19b9b72 --- /dev/null +++ b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializerTest.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.framework.common.util.json.databind; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * {@link TimestampLocalDateTimeDeserializer} 的单元测试 + * + * 覆盖全部在用格式(epoch 毫秒 / 常见字符串 / ISO / 纯日期), + * 以及非法输入抛清晰错误(不再静默落 1970-01-01)。 + * + * @author 芋道源码 + */ +public class TimestampLocalDateTimeDeserializerTest { + + private static final ObjectMapper MAPPER; + + static { + ObjectMapper mapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); + mapper.registerModule(module); + MAPPER = mapper; + } + + private LocalDateTime parse(String json) throws Exception { + return MAPPER.readValue(json, LocalDateTime.class); + } + + @Test + public void testEpochMilliNumber() throws Exception { + // 1700000000000 = 2023-11-14T22:13:20Z(本地时区换算后) + LocalDateTime expected = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(1700000000000L), java.time.ZoneId.systemDefault()); + assertEquals(expected, parse("1700000000000")); + } + + @Test + public void testEpochMilliString() throws Exception { + LocalDateTime expected = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(1700000000000L), java.time.ZoneId.systemDefault()); + assertEquals(expected, parse("\"1700000000000\"")); + } + + @Test + public void testSpaceFormat() throws Exception { + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0, 0), parse("\"2026-09-11 08:00:00\"")); + } + + @Test + public void testSpaceFormatWithMillis() throws Exception { + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0, 0, 123000000), parse("\"2026-09-11 08:00:00.123\"")); + } + + @Test + public void testIsoLocal() throws Exception { + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0, 0), parse("\"2026-09-11T08:00:00\"")); + } + + @Test + public void testIsoWithOffset() throws Exception { + // 前端 Date 对象序列化:带 Z(UTC)。2026-09-11T00:00:00Z 换算到系统时区 + LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T00:00:00Z") + .atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDateTime(); + assertEquals(expected, parse("\"2026-09-11T00:00:00Z\"")); + } + + @Test + public void testIsoWithOffsetMillis() throws Exception { + // JSON.stringify(Date) 实际产物:带毫秒 + Z(无 value-format 的 Date 对象控件提交格式) + LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T00:00:00.000Z") + .atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDateTime(); + assertEquals(expected, parse("\"2026-09-11T00:00:00.000Z\"")); + } + + @Test + public void testIsoWithOffsetPlus() throws Exception { + // 带 +08:00 时区偏移的形式 + LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T08:00:00.000+08:00") + .atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDateTime(); + assertEquals(expected, parse("\"2026-09-11T08:00:00.000+08:00\"")); + } + + @Test + public void testDateOnly() throws Exception { + assertEquals(LocalDateTime.of(2026, 9, 11, 0, 0, 0), parse("\"2026-09-11\"")); + } + + @Test + public void testNullAndEmpty() throws Exception { + assertNull(parse("null")); + assertNull(parse("\"\"")); + } + + @Test + public void testInvalidThrows() { + // 非法输入抛清晰错误,不再静默落 1970 + assertThrows(Exception.class, () -> parse("\"not-a-date\"")); + assertThrows(Exception.class, () -> parse("\"2026/09/11\"")); + } + +} -- libgit2 0.21.4