yxh
5 天以前 b1f41c066b0b7c38faffddbfe73b9be8d0bd508e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
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());
    }
}