package com.ruoyi.framework.config; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.BeanDescription; import com.fasterxml.jackson.databind.BeanProperty; import com.fasterxml.jackson.databind.DeserializationConfig; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializationConfig; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder; import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier; import com.fasterxml.jackson.databind.deser.SettableBeanProperty; import com.fasterxml.jackson.databind.introspect.AnnotatedMember; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; import com.fasterxml.jackson.databind.ser.ContextualSerializer; import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.io.IOException; import java.text.ParsePosition; import java.text.SimpleDateFormat; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.TimeZone; /** * 时间格式全局配置:接口出参一律精确到分({@code yyyy-MM-dd HH:mm}),入参宽松兼容带/不带秒。 * *

为什么用全局配置而不是逐字段改注解:系统里 * {@code @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")} 有 182 处、{@code @DateTimeFormat} 73 处, * 逐个改既容易漏(漏一处就是提交 400),也覆盖不到未加注解的 {@code Date} 字段。 * 这里用 Jackson 的 {@link BeanSerializerModifier} / {@link BeanDeserializerModifier} * 在不改任何注解的前提下统一格式化口径。 * *

规则: *

    *
  1. 序列化:所有 {@code Date} 输出 {@code yyyy-MM-dd HH:mm}(沿用 * {@link ApplicationConfig} 配置的 JVM 默认时区;字段上显式声明了 * {@code @JsonFormat(timezone=...)} 的仍按其时区);
  2. *
  3. 反序列化:兼容 {@code yyyy-MM-dd HH:mm:ss} / {@code yyyy-MM-dd HH:mm} / * {@code yyyy-MM-dd} / 斜杠分隔 / ISO8601 / 时间戳 —— 这样前端回填"无秒"的值原样提交也不会 400;
  4. *
  5. 运维日志(操作日志/登录日志/定时任务日志)保留秒,便于排查问题。
  6. *
* *

不影响的范围:数据库存储(仍为 datetime,秒位照常保留)、 * Excel 导入导出(走 {@code @Excel.dateFormat},未改动)、 * 致远协同推送报文(自建 {@code SimpleDateFormat},未改动)、 * 钉钉消息时间({@code FundNotifyMessageBuilder} 自建格式)。 * * @author ruoyi */ @Configuration public class JacksonDateConfig { /** 无秒格式 */ private static final String PATTERN_NO_SECONDS = "yyyy-MM-dd HH:mm"; /** 有秒格式(运维日志用) */ private static final String PATTERN_WITH_SECONDS = "yyyy-MM-dd HH:mm:ss"; /** 保留秒的类:运维日志,秒对排查有用 */ private static final Set KEEP_SECONDS_CLASSES = new HashSet(Arrays.asList( "com.ruoyi.system.domain.SysOperLog", "com.ruoyi.system.domain.SysLogininfor", "com.ruoyi.quartz.domain.SysJobLog" )); /** 反序列化尝试顺序:从最具体到最宽松 */ private static final String[] PARSE_PATTERNS = new String[]{ "yyyy-MM-dd HH:mm:ss.SSS", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ss.SSS", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm", "yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMddHHmmss", "yyyyMMdd" }; /** * 2026-10-15 改造:不再用 {@code Jackson2ObjectMapperBuilderCustomizer} + {@code modulesToInstall}。 * *

为什么改:{@code Jackson2ObjectMapperBuilder.modulesToInstall(...)} 的语义是 * "设置"模块列表(不是追加),而 Spring Boot 自身也用同一方法注册它自带的模块(JavaTime 等); * 两个定制器互相覆盖,谁后执行谁的列表生效。实测后果:本模块被丢掉 —— * 表现为"类在包里、bean 也创建了,但 Date 的宽松解析与去秒序列化都不起作用", * 前端按 {@code yyyy-MM-dd} 提交时直接 400(Cannot deserialize ... expected format yyyy-MM-dd HH:mm:ss)。 * *

改为把模块声明成 {@code SimpleModule} bean:Spring Boot 通过 {@code ObjectProvider} * 统一收集所有 Module bean 并一次性安装,与定制器的执行顺序无关。 */ @Bean public SimpleModule dateNoSecondsModule() { SimpleModule module = new SimpleModule("dateNoSecondsModule"); module.addSerializer(Date.class, new NoSecondsDateSerializer()); module.setSerializerModifier(new NoSecondsDateSerializerModifier()); module.setDeserializerModifier(new LenientDateDeserializerModifier()); // 启动自证:这条日志出现即说明模块已被创建并会被 Spring Boot 安装 LoggerFactory.getLogger(JacksonDateConfig.class).info( "[JacksonDateConfig] 已启用:Date 出参去秒({}),入参宽松兼容({})", PATTERN_NO_SECONDS, String.join(" / ", PARSE_PATTERNS)); return module; } /** * 序列化器:按分钟输出。 * 只作用于序列化,不影响入参解析。 */ static class NoSecondsDateSerializer extends JsonSerializer implements ContextualSerializer { private final DateTimeFormatter formatter; NoSecondsDateSerializer() { this.formatter = null; } private NoSecondsDateSerializer(TimeZone timeZone, boolean keepSeconds) { this.formatter = DateTimeFormatter .ofPattern(keepSeconds ? PATTERN_WITH_SECONDS : PATTERN_NO_SECONDS) .withZone((timeZone == null ? TimeZone.getDefault() : timeZone).toZoneId()); } @Override public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) { TimeZone timeZone = resolveTimeZone(prov.getConfig().getTimeZone(), property == null ? null : property.getMember()); return new NoSecondsDateSerializer(timeZone, isKeepSeconds(property == null ? null : property.getMember())); } @Override public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); return; } if (formatter != null) { gen.writeString(formatter.format(value.toInstant())); return; } // 兜底:未被 contextualize 时按当前配置时区、无秒输出 gen.writeString(DateTimeFormatter.ofPattern(PATTERN_NO_SECONDS) .withZone(serializers.getConfig().getTimeZone().toZoneId()) .format(value.toInstant())); } } /** * 让 Bean 属性上的日期也走上面的序列化器。 * 字段级 {@code @JsonFormat} 生成的序列化器优先级更高,必须在这里替换掉,否则注解字段仍带秒。 */ static class NoSecondsDateSerializerModifier extends BeanSerializerModifier { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public List changeProperties(SerializationConfig config, BeanDescription beanDesc, List beanProperties) { boolean keepSeconds = KEEP_SECONDS_CLASSES.contains(beanDesc.getBeanClass().getName()); for (BeanPropertyWriter writer : beanProperties) { if (Date.class.isAssignableFrom(writer.getType().getRawClass())) { TimeZone timeZone = resolveTimeZone(config.getTimeZone(), writer.getMember()); writer.assignSerializer((JsonSerializer) new NoSecondsDateSerializer(timeZone, keepSeconds)); } } return beanProperties; } } /** * 入参解析:兼容带秒/不带秒/纯日期/ISO/时间戳,避免"出参无秒、回填后再提交"导致 400。 */ static class LenientDateDeserializerModifier extends BeanDeserializerModifier { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanDescription beanDesc, BeanDeserializerBuilder builder) { Iterator properties = builder.getProperties(); List replaced = new ArrayList(); while (properties.hasNext()) { SettableBeanProperty property = properties.next(); if (property.getType() != null && Date.class.isAssignableFrom(property.getType().getRawClass())) { TimeZone timeZone = resolveTimeZone(config.getTimeZone(), property.getMember()); replaced.add(property.withValueDeserializer(new LenientDateDeserializer(timeZone))); } } // 不能边遍历边改,先收集再替换 for (SettableBeanProperty property : replaced) { builder.addOrReplaceProperty(property, true); } return builder; } } /** 宽松日期解析器 */ static class LenientDateDeserializer extends JsonDeserializer { private final TimeZone timeZone; LenientDateDeserializer(TimeZone timeZone) { this.timeZone = timeZone == null ? TimeZone.getDefault() : timeZone; } @Override public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { if (p.hasToken(JsonToken.VALUE_NUMBER_INT)) { return new Date(p.getLongValue()); } String text = p.getValueAsString(); if (text == null || text.trim().isEmpty()) { return null; } text = text.trim(); for (String pattern : PARSE_PATTERNS) { SimpleDateFormat format = new SimpleDateFormat(pattern); format.setLenient(false); format.setTimeZone(timeZone); ParsePosition position = new ParsePosition(0); Date parsed = format.parse(text, position); // 必须整串被消费,否则 "yyyy-MM-dd" 会把 "2026-10-08 15:23" 的时分静默丢掉 if (parsed != null && position.getIndex() == text.length()) { return parsed; } } try { return Date.from(Instant.parse(text)); } catch (Exception ignore) { // 落到下面统一报错 } throw ctxt.weirdStringException(text, Date.class, "无法解析的时间格式,支持 yyyy-MM-dd[ HH:mm[:ss]]"); } } /** 属性上显式声明的时区优先,否则用全局配置(即 JVM 默认时区) */ private static TimeZone resolveTimeZone(TimeZone fallback, AnnotatedMember member) { if (member != null) { try { JsonFormat jsonFormat = member.getAnnotation(JsonFormat.class); if (jsonFormat != null && jsonFormat.timezone() != null && !jsonFormat.timezone().isEmpty() && !"##default".equals(jsonFormat.timezone())) { return TimeZone.getTimeZone(jsonFormat.timezone()); } } catch (Exception ignore) { // 取不到注记时用全局时区 } } return fallback == null ? TimeZone.getDefault() : fallback; } /** 运维日志类保留秒 */ private static boolean isKeepSeconds(AnnotatedMember member) { return member != null && KEEP_SECONDS_CLASSES.contains(member.getDeclaringClass().getName()); } }