Commit 57e204bc353b382bbb176d23c85a35ab859d225c

Authored by dadiyang
1 parent 9a41ee23

refactor(common): 基于 Hutool 简化 LocalDateTime 日期解析,判空提前返回,补全边界用例

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 cn.hutool.core.date.DateUtil;
  4 +import cn.hutool.core.date.LocalDateTimeUtil;
  5 +import cn.hutool.core.util.StrUtil;
3 import com.fasterxml.jackson.core.JsonParser; 6 import com.fasterxml.jackson.core.JsonParser;
4 -import com.fasterxml.jackson.core.JsonToken;  
5 import com.fasterxml.jackson.databind.DeserializationContext; 7 import com.fasterxml.jackson.databind.DeserializationContext;
6 import com.fasterxml.jackson.databind.JsonDeserializer; 8 import com.fasterxml.jackson.databind.JsonDeserializer;
7 9
8 import java.io.IOException; 10 import java.io.IOException;
9 import java.time.Instant; 11 import java.time.Instant;
10 -import java.time.LocalDate;  
11 import java.time.LocalDateTime; 12 import java.time.LocalDateTime;
12 -import java.time.OffsetDateTime;  
13 -import java.time.ZoneId;  
14 import java.time.format.DateTimeFormatter; 13 import java.time.format.DateTimeFormatter;
15 -import java.time.format.DateTimeParseException;  
16 14
17 /** 15 /**
18 - * 基于时间戳的 LocalDateTime 反序列化器  
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(消除静默数据损坏)。 16 + * LocalDateTime 反序列化器,支持毫秒时间戳、常见日期时间字符串和 ISO-8601 日期。
26 * 17 *
27 * @author 老五 18 * @author 老五
28 */ 19 */
@@ -30,51 +21,22 @@ public class TimestampLocalDateTimeDeserializer extends JsonDeserializer<LocalDa @@ -30,51 +21,22 @@ public class TimestampLocalDateTimeDeserializer extends JsonDeserializer<LocalDa
30 21
31 public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer(); 22 public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer();
32 23
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 -  
36 @Override 24 @Override
37 public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { 25 public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
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(); 26 String text = p.getText();
43 - if (text == null || text.trim().isEmpty()) { 27 + if (StrUtil.isBlank(text)) {
44 return null; 28 return null;
45 } 29 }
46 text = text.trim(); 30 text = text.trim();
47 - // 1. 数字字符串 = epoch 毫秒  
48 if (text.matches("-?\\d+")) { 31 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) { 32 + return LocalDateTimeUtil.of(Long.parseLong(text));
69 } 33 }
70 - // 4. 纯日期(2026-09-11)→ 当日零点  
71 - try {  
72 - return LocalDate.parse(text).atStartOfDay();  
73 - } catch (DateTimeParseException ignored) { 34 + // Hutool 5.x 的自动解析会忽略部分负时区偏移,并截断纳秒。
  35 + if (text.contains("T")) {
  36 + return LocalDateTimeUtil.of(DateTimeFormatter.ISO_DATE_TIME.parseBest(
  37 + text, Instant::from, LocalDateTime::from));
74 } 38 }
75 - // 无法解析:抛清晰错误(不再静默落 1970)  
76 - throw new IllegalArgumentException("无法解析时间值:" + text  
77 - + "(支持 epoch 毫秒 / yyyy-MM-dd HH:mm:ss / ISO-8601 / yyyy-MM-dd)"); 39 + return LocalDateTimeUtil.of(DateUtil.parse(text).toInstant());
78 } 40 }
79 41
80 } 42 }
yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializerTest.java
@@ -63,6 +63,12 @@ public class TimestampLocalDateTimeDeserializerTest { @@ -63,6 +63,12 @@ public class TimestampLocalDateTimeDeserializerTest {
63 } 63 }
64 64
65 @Test 65 @Test
  66 + public void testIsoLocalWithNanos() throws Exception {
  67 + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0, 0, 123456789),
  68 + parse("\"2026-09-11T08:00:00.123456789\""));
  69 + }
  70 +
  71 + @Test
66 public void testIsoWithOffset() throws Exception { 72 public void testIsoWithOffset() throws Exception {
67 // 前端 Date 对象序列化:带 Z(UTC)。2026-09-11T00:00:00Z 换算到系统时区 73 // 前端 Date 对象序列化:带 Z(UTC)。2026-09-11T00:00:00Z 换算到系统时区
68 LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T00:00:00Z") 74 LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T00:00:00Z")
@@ -92,16 +98,45 @@ public class TimestampLocalDateTimeDeserializerTest { @@ -92,16 +98,45 @@ public class TimestampLocalDateTimeDeserializerTest {
92 } 98 }
93 99
94 @Test 100 @Test
  101 + public void testIsoWithNegativeOffsetAndMillis() throws Exception {
  102 + LocalDateTime expected = java.time.OffsetDateTime.parse("2026-09-11T23:30:00.123-05:30")
  103 + .atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDateTime();
  104 + assertEquals(expected, parse("\"2026-09-11T23:30:00.123-05:30\""));
  105 + }
  106 +
  107 + @Test
  108 + public void testZeroAndNegativeEpochMilli() throws Exception {
  109 + for (long millis : new long[]{0L, -1L}) {
  110 + LocalDateTime expected = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(millis),
  111 + java.time.ZoneId.systemDefault());
  112 + assertEquals(expected, parse(Long.toString(millis)));
  113 + assertEquals(expected, parse("\"" + millis + "\""));
  114 + }
  115 + }
  116 +
  117 + @Test
  118 + public void testSurroundingWhitespace() throws Exception {
  119 + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0, 0), parse("\" 2026-09-11 08:00:00 \""));
  120 + }
  121 +
  122 + @Test
95 public void testNullAndEmpty() throws Exception { 123 public void testNullAndEmpty() throws Exception {
96 assertNull(parse("null")); 124 assertNull(parse("null"));
97 assertNull(parse("\"\"")); 125 assertNull(parse("\"\""));
  126 + assertNull(parse("\" \""));
  127 + }
  128 +
  129 + @Test
  130 + public void testHutoolDateFormats() throws Exception {
  131 + assertEquals(LocalDateTime.of(2026, 9, 11, 0, 0), parse("\"2026/09/11\""));
  132 + assertEquals(LocalDateTime.of(2026, 9, 11, 8, 0), parse("\"2026/09/11 08:00:00\""));
98 } 133 }
99 134
100 @Test 135 @Test
101 public void testInvalidThrows() { 136 public void testInvalidThrows() {
102 // 非法输入抛清晰错误,不再静默落 1970 137 // 非法输入抛清晰错误,不再静默落 1970
103 assertThrows(Exception.class, () -> parse("\"not-a-date\"")); 138 assertThrows(Exception.class, () -> parse("\"not-a-date\""));
104 - assertThrows(Exception.class, () -> parse("\"2026/09/11\"")); 139 +
105 } 140 }
106 141
107 } 142 }