Commit 9a41ee233a74edcb4335465dfece83e9fe8c5d1e

Authored by dadiyang
1 parent b0397843

fix(common): LocalDateTime 反序列化兼容字符串日期,不再静默落 1970

全局 TimestampLocalDateTimeDeserializer 原先只认 epoch 毫秒,对非数字字符串
(如 '2026-09-11 00:00:00')经 p.getValueAsLong() 静默返回 0 → 1970-01-01,
请求不报错、数据被静默损坏。

实测用户入口:管理端新建 MES 库存调拨单,转移日期控件发字符串(value-format=
'YYYY-MM-DD 00:00:00')→ 保存提示成功 → DB 存 1970-01-01、列表日期列空白。
同类:HRM 考勤打卡(LocalDateTime,前端发 Date 对象)等。

修复:反序列化器兼容
1. epoch 毫秒(JSON 数字/数字字符串,原契约零回归)
2. yyyy-MM-dd HH:mm:ss(含毫秒)
3. ISO-8601(本地/带时区,覆盖前端 Date 对象序列化)
4. 纯日期 yyyy-MM-dd(当日零点)
无法解析时抛清晰错误(含原始值),不再静默落 1970。

单测 11 例覆盖全部在用格式+非法输入。全量回归(360 例)零日期回归。
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java
1 package cn.iocoder.yudao.framework.common.util.json.databind; 1 package cn.iocoder.yudao.framework.common.util.json.databind;
2 2
3 import com.fasterxml.jackson.core.JsonParser; 3 import com.fasterxml.jackson.core.JsonParser;
  4 +import com.fasterxml.jackson.core.JsonToken;
4 import com.fasterxml.jackson.databind.DeserializationContext; 5 import com.fasterxml.jackson.databind.DeserializationContext;
5 import com.fasterxml.jackson.databind.JsonDeserializer; 6 import com.fasterxml.jackson.databind.JsonDeserializer;
6 7
7 import java.io.IOException; 8 import java.io.IOException;
8 import java.time.Instant; 9 import java.time.Instant;
  10 +import java.time.LocalDate;
9 import java.time.LocalDateTime; 11 import java.time.LocalDateTime;
  12 +import java.time.OffsetDateTime;
10 import java.time.ZoneId; 13 import java.time.ZoneId;
  14 +import java.time.format.DateTimeFormatter;
  15 +import java.time.format.DateTimeParseException;
11 16
12 /** 17 /**
13 * 基于时间戳的 LocalDateTime 反序列化器 18 * 基于时间戳的 LocalDateTime 反序列化器
14 * 19 *
  20 + * 兼容范围(覆盖前端/接口实际在用的全部格式,避免把"原先能静默入库"的格式变成报错):
  21 + * 1. epoch 毫秒:JSON 数字 / 数字字符串(原契约,保持不变)
  22 + * 2. 常见日期时间字符串:yyyy-MM-dd HH:mm:ss(含毫秒)
  23 + * 3. ISO-8601:yyyy-MM-ddTHH:mm:ss(本地 / 带时区,后者覆盖前端 Date 对象序列化)
  24 + * 4. 纯日期:yyyy-MM-dd(当日零点)
  25 + * 无法解析时抛清晰错误(含原始值),不再静默落 1970-01-01(消除静默数据损坏)。
  26 + *
15 * @author 老五 27 * @author 老五
16 */ 28 */
17 public class TimestampLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> { 29 public class TimestampLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
18 30
19 public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer(); 31 public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer();
20 32
  33 + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
  34 + private static final DateTimeFormatter DT_MILLIS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
  35 +
21 @Override 36 @Override
22 public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { 37 public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
23 - // 将 Long 时间戳,转换为 LocalDateTime 对象  
24 - return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getValueAsLong()), ZoneId.systemDefault()); 38 + // 1. JSON 数字 = epoch 毫秒(原契约,保持)
  39 + if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {
  40 + return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getLongValue()), ZoneId.systemDefault());
  41 + }
  42 + String text = p.getText();
  43 + if (text == null || text.trim().isEmpty()) {
  44 + return null;
  45 + }
  46 + text = text.trim();
  47 + // 1. 数字字符串 = epoch 毫秒
  48 + if (text.matches("-?\\d+")) {
  49 + return LocalDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(text)), ZoneId.systemDefault());
  50 + }
  51 + // 2. yyyy-MM-dd HH:mm:ss / .SSS
  52 + try {
  53 + return LocalDateTime.parse(text, DT);
  54 + } catch (DateTimeParseException ignored) {
  55 + }
  56 + try {
  57 + return LocalDateTime.parse(text, DT_MILLIS);
  58 + } catch (DateTimeParseException ignored) {
  59 + }
  60 + // 3. ISO-8601 本地(2026-09-11T00:00:00 / .SSS)
  61 + try {
  62 + return LocalDateTime.parse(text);
  63 + } catch (DateTimeParseException ignored) {
  64 + }
  65 + // 3. ISO-8601 带时区(前端 Date 对象序列化:2026-09-11T00:00:00.000Z / +08:00)
  66 + try {
  67 + return OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime();
  68 + } catch (DateTimeParseException ignored) {
  69 + }
  70 + // 4. 纯日期(2026-09-11)→ 当日零点
  71 + try {
  72 + return LocalDate.parse(text).atStartOfDay();
  73 + } catch (DateTimeParseException ignored) {
  74 + }
  75 + // 无法解析:抛清晰错误(不再静默落 1970)
  76 + throw new IllegalArgumentException("无法解析时间值:" + text
  77 + + "(支持 epoch 毫秒 / yyyy-MM-dd HH:mm:ss / ISO-8601 / yyyy-MM-dd)");
25 } 78 }
26 79
27 } 80 }
yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.common.util.json.databind;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.fasterxml.jackson.databind.module.SimpleModule;
  5 +import org.junit.jupiter.api.Test;
  6 +
  7 +import java.time.LocalDateTime;
  8 +
  9 +import static org.junit.jupiter.api.Assertions.assertEquals;
  10 +import static org.junit.jupiter.api.Assertions.assertThrows;
  11 +import static org.junit.jupiter.api.Assertions.assertNull;
  12 +
  13 +/**
  14 + * {@link TimestampLocalDateTimeDeserializer} 的单元测试
  15 + *
  16 + * 覆盖全部在用格式(epoch 毫秒 / 常见字符串 / ISO / 纯日期),
  17 + * 以及非法输入抛清晰错误(不再静默落 1970-01-01)。
  18 + *
  19 + * @author 芋道源码
  20 + */
  21 +public class TimestampLocalDateTimeDeserializerTest {
  22 +
  23 + private static final ObjectMapper MAPPER;
  24 +
  25 + static {
  26 + ObjectMapper mapper = new ObjectMapper();
  27 + SimpleModule module = new SimpleModule();
  28 + module.addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE);
  29 + mapper.registerModule(module);
  30 + MAPPER = mapper;
  31 + }
  32 +
  33 + private LocalDateTime parse(String json) throws Exception {
  34 + return MAPPER.readValue(json, LocalDateTime.class);
  35 + }
  36 +
  37 + @Test
  38 + public void testEpochMilliNumber() throws Exception {
  39 + // 1700000000000 = 2023-11-14T22:13:20Z(本地时区换算后)
  40 + LocalDateTime expected = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(1700000000000L), java.time.ZoneId.systemDefault());
  41 + assertEquals(expected, parse("1700000000000"));
  42 + }
  43 +
  44 + @Test
  45 + public void testEpochMilliString() throws Exception {
  46 + LocalDateTime expected = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(1700000000000L), java.time.ZoneId.systemDefault());
  47 + assertEquals(expected, parse("\"1700000000000\""));
  48 + }
  49 +
  50 + @Test
  51 + public void testSpaceFormat() throws Exception {
  52 + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0, 0), parse("\"2026-09-11 08:00:00\""));
  53 + }
  54 +
  55 + @Test
  56 + public void testSpaceFormatWithMillis() throws Exception {
  57 + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0, 0, 123000000), parse("\"2026-09-11 08:00:00.123\""));
  58 + }
  59 +
  60 + @Test
  61 + public void testIsoLocal() throws Exception {
  62 + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0, 0), parse("\"2026-09-11T08:00:00\""));
  63 + }
  64 +
  65 + @Test
  66 + public void testIsoWithOffset() throws Exception {
  67 + // 前端 Date 对象序列化:带 Z(UTC)。2026-09-11T00:00:00Z 换算到系统时区
  68 + LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T00:00:00Z")
  69 + .atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDateTime();
  70 + assertEquals(expected, parse("\"2026-09-11T00:00:00Z\""));
  71 + }
  72 +
  73 + @Test
  74 + public void testIsoWithOffsetMillis() throws Exception {
  75 + // JSON.stringify(Date) 实际产物:带毫秒 + Z(无 value-format 的 Date 对象控件提交格式)
  76 + LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T00:00:00.000Z")
  77 + .atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDateTime();
  78 + assertEquals(expected, parse("\"2026-09-11T00:00:00.000Z\""));
  79 + }
  80 +
  81 + @Test
  82 + public void testIsoWithOffsetPlus() throws Exception {
  83 + // 带 +08:00 时区偏移的形式
  84 + LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T08:00:00.000+08:00")
  85 + .atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDateTime();
  86 + assertEquals(expected, parse("\"2026-09-11T08:00:00.000+08:00\""));
  87 + }
  88 +
  89 + @Test
  90 + public void testDateOnly() throws Exception {
  91 + assertEquals(LocalDateTime.of(2026, 9, 11, 0, 0, 0), parse("\"2026-09-11\""));
  92 + }
  93 +
  94 + @Test
  95 + public void testNullAndEmpty() throws Exception {
  96 + assertNull(parse("null"));
  97 + assertNull(parse("\"\""));
  98 + }
  99 +
  100 + @Test
  101 + public void testInvalidThrows() {
  102 + // 非法输入抛清晰错误,不再静默落 1970
  103 + assertThrows(Exception.class, () -> parse("\"not-a-date\""));
  104 + assertThrows(Exception.class, () -> parse("\"2026/09/11\""));
  105 + }
  106 +
  107 +}