Commit 57e204bc353b382bbb176d23c85a35ab859d225c
1 parent
9a41ee23
refactor(common): 基于 Hutool 简化 LocalDateTime 日期解析,判空提前返回,补全边界用例
Showing
2 changed files
with
47 additions
and
50 deletions
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 | } |