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}),入参宽松兼容带/不带秒。
|
*
|
* <p><b>为什么用全局配置而不是逐字段改注解:</b>系统里
|
* {@code @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")} 有 182 处、{@code @DateTimeFormat} 73 处,
|
* 逐个改既容易漏(漏一处就是提交 400),也覆盖不到未加注解的 {@code Date} 字段。
|
* 这里用 Jackson 的 {@link BeanSerializerModifier} / {@link BeanDeserializerModifier}
|
* 在<b>不改任何注解</b>的前提下统一格式化口径。
|
*
|
* <p><b>规则:</b>
|
* <ol>
|
* <li>序列化:所有 {@code Date} 输出 {@code yyyy-MM-dd HH:mm}(沿用
|
* {@link ApplicationConfig} 配置的 JVM 默认时区;字段上显式声明了
|
* {@code @JsonFormat(timezone=...)} 的仍按其时区);</li>
|
* <li>反序列化:兼容 {@code yyyy-MM-dd HH:mm:ss} / {@code yyyy-MM-dd HH:mm} /
|
* {@code yyyy-MM-dd} / 斜杠分隔 / ISO8601 / 时间戳 —— 这样前端回填"无秒"的值原样提交也不会 400;</li>
|
* <li>运维日志(操作日志/登录日志/定时任务日志)<b>保留秒</b>,便于排查问题。</li>
|
* </ol>
|
*
|
* <p><b>不影响的范围:</b>数据库存储(仍为 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<String> KEEP_SECONDS_CLASSES = new HashSet<String>(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}。
|
*
|
* <p><b>为什么改:</b>{@code Jackson2ObjectMapperBuilder.modulesToInstall(...)} 的语义是
|
* "设置"模块列表(不是追加),而 Spring Boot 自身也用同一方法注册它自带的模块(JavaTime 等);
|
* 两个定制器互相覆盖,谁后执行谁的列表生效。实测后果:本模块被丢掉 ——
|
* 表现为"类在包里、bean 也创建了,但 Date 的宽松解析与去秒序列化都不起作用",
|
* 前端按 {@code yyyy-MM-dd} 提交时直接 400(Cannot deserialize ... expected format yyyy-MM-dd HH:mm:ss)。
|
*
|
* <p>改为把模块声明成 {@code SimpleModule} bean:Spring Boot 通过 {@code ObjectProvider<Module>}
|
* 统一收集所有 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;
|
}
|
|
/**
|
* 序列化器:按分钟输出。
|
* 只作用于序列化,<b>不影响入参解析</b>。
|
*/
|
static class NoSecondsDateSerializer extends JsonSerializer<Date> 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<BeanPropertyWriter> changeProperties(SerializationConfig config, BeanDescription beanDesc,
|
List<BeanPropertyWriter> 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<SettableBeanProperty> properties = builder.getProperties();
|
List<SettableBeanProperty> replaced = new ArrayList<SettableBeanProperty>();
|
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<Date> {
|
|
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());
|
}
|
}
|