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}
* 在不改任何注解的前提下统一格式化口径。
*
*
规则:
*
* - 序列化:所有 {@code Date} 输出 {@code yyyy-MM-dd HH:mm}(沿用
* {@link ApplicationConfig} 配置的 JVM 默认时区;字段上显式声明了
* {@code @JsonFormat(timezone=...)} 的仍按其时区);
* - 反序列化:兼容 {@code yyyy-MM-dd HH:mm:ss} / {@code yyyy-MM-dd HH:mm} /
* {@code yyyy-MM-dd} / 斜杠分隔 / ISO8601 / 时间戳 —— 这样前端回填"无秒"的值原样提交也不会 400;
* - 运维日志(操作日志/登录日志/定时任务日志)保留秒,便于排查问题。
*
*
* 不影响的范围:数据库存储(仍为 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());
}
}