Commit 9a41ee233a74edcb4335465dfece83e9fe8c5d1e
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 HHss(含毫秒) 3. ISO-8601(本地/带时区,覆盖前端 Date 对象序列化) 4. 纯日期 yyyy-MM-dd(当日零点) 无法解析时抛清晰错误(含原始值),不再静默落 1970。 单测 11 例覆盖全部在用格式+非法输入。全量回归(360 例)零日期回归。
Showing
2 changed files
with
162 additions
and
2 deletions
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java
| 1 | 1 | package cn.iocoder.yudao.framework.common.util.json.databind; |
| 2 | 2 | |
| 3 | 3 | import com.fasterxml.jackson.core.JsonParser; |
| 4 | +import com.fasterxml.jackson.core.JsonToken; | |
| 4 | 5 | import com.fasterxml.jackson.databind.DeserializationContext; |
| 5 | 6 | import com.fasterxml.jackson.databind.JsonDeserializer; |
| 6 | 7 | |
| 7 | 8 | import java.io.IOException; |
| 8 | 9 | import java.time.Instant; |
| 10 | +import java.time.LocalDate; | |
| 9 | 11 | import java.time.LocalDateTime; |
| 12 | +import java.time.OffsetDateTime; | |
| 10 | 13 | import java.time.ZoneId; |
| 14 | +import java.time.format.DateTimeFormatter; | |
| 15 | +import java.time.format.DateTimeParseException; | |
| 11 | 16 | |
| 12 | 17 | /** |
| 13 | 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 | 27 | * @author 老五 |
| 16 | 28 | */ |
| 17 | 29 | public class TimestampLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> { |
| 18 | 30 | |
| 19 | 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 | 36 | @Override |
| 22 | 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 | +} | ... | ... |