Răsfoiți Sursa

UTC时间根据区域返回对应区域时间

hr~ 2 zile în urmă
părinte
comite
50575bed68

+ 2 - 1
user-common/src/main/java/com/poyee/aspect/UserLoginTokenAspect.java

@@ -1,6 +1,7 @@
 package com.poyee.aspect;
 
-import cn.hutool.core.util.StrUtil;import com.alibaba.fastjson.JSON;
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.poyee.annotation.NoLogin;
 import com.poyee.annotation.UserLoginToken;

+ 2 - 7
user-common/src/main/java/com/poyee/aspect/weblog/WebLogAspect.java

@@ -8,22 +8,17 @@ import org.aspectj.lang.annotation.*;
 import org.aspectj.lang.reflect.MethodSignature;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
 import org.springframework.core.annotation.Order;
 import org.springframework.stereotype.Component;
 import org.springframework.util.StringUtils;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestParam;
 
-import org.slf4j.MDC;
-
 import javax.servlet.http.HttpServletRequest;
 import java.lang.reflect.Method;
 import java.lang.reflect.Parameter;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.UUID;
+import java.util.*;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 

+ 56 - 0
user-common/src/main/java/com/poyee/config/JacksonConfig.java

@@ -0,0 +1,56 @@
+package com.poyee.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 全局 Jackson 配置
+ * <p>
+ * 注册时区感知序列化器 {@link LocalDateTimeZoneSerializerConfig}:
+ * 所有响应中的 {@link LocalDateTime} 字段将根据请求头 {@code Accept-Language}
+ * 自动从 UTC 转换为客户端对应的区域时间后输出。
+ * </p>
+ *
+ * <p>
+ * 注意:不使用 {@code JavaTimeModule} 注册自定义序列化器,
+ * 因为 Spring Boot 自动配置的 {@code Jackson2ObjectMapperBuilder} 已注册过 {@code JavaTimeModule},
+ * Jackson 的模块去重机制会导致第二次注册同类型模块被静默跳过。
+ * 改用独立命名的 {@code SimpleModule} 可绕过去重检查,确保自定义序列化器生效。
+ * </p>
+ */
+@Configuration
+public class JacksonConfig {
+
+    private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
+
+    @Bean
+    public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
+        // 保留 Spring Boot 自动配置的所有默认模块,在此基础上叠加
+        ObjectMapper mapper = builder.createXmlMapper(false).build();
+
+        // 使用 SimpleModule(独立类名)注册,绕过 Jackson 对 JavaTimeModule 的去重跳过机制
+        SimpleModule overrideModule = new SimpleModule("LocalDateTimeZoneOverride");
+
+        // 序列化:UTC LocalDateTime → 客户端时区时间(根据 Accept-Language 自动转换)
+        overrideModule.addSerializer(LocalDateTime.class, new LocalDateTimeZoneSerializerConfig());
+
+        // 反序列化:客户端传入时间字符串 → LocalDateTime(业务层保证传入 UTC)
+        overrideModule.addDeserializer(
+                LocalDateTime.class,
+                new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN))
+        );
+
+        mapper.registerModule(overrideModule);
+        // 禁用将时间序列化为时间戳
+        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+        return mapper;
+    }
+}

+ 60 - 0
user-common/src/main/java/com/poyee/config/LocalDateTimeZoneSerializerConfig.java

@@ -0,0 +1,60 @@
+package com.poyee.config;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import com.poyee.utils.LocaleTimeZoneUtil;
+
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 时区感知的 LocalDateTime Jackson 序列化器
+ * <p>
+ * 数据库存储的是 UTC 时间({@link LocalDateTime} 不带时区信息),
+ * 本序列化器在序列化响应时,从请求头 {@code Accept-Language} 推断客户端所在时区,
+ * 将 UTC 时间转换为对应的区域时间后输出。
+ * </p>
+ *
+ * <pre>
+ * 示例:
+ *   DB UTC时间:   2026-03-20 06:00:00
+ *   Accept-Language: zh-CN
+ *   输出时间:      2026-03-20 14:00:00   (UTC+8)
+ *
+ *   Accept-Language: en-US
+ *   输出时间:      2026-03-20 02:00:00   (UTC-4, 夏令时)
+ * </pre>
+ */
+public class LocalDateTimeZoneSerializerConfig extends StdSerializer<LocalDateTime> {
+
+    private static final DateTimeFormatter FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    public LocalDateTimeZoneSerializerConfig() {
+        super(LocalDateTime.class);
+    }
+
+    @Override
+    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider provider)
+            throws IOException {
+        if (value == null) {
+            gen.writeNull();
+            return;
+        }
+        // 1. 将数据库中的 LocalDateTime 视为 UTC 时间
+        ZonedDateTime utcDateTime = value.atZone(ZoneId.of("UTC"));
+
+        // 2. 根据请求 Accept-Language 解析目标时区
+        ZoneId targetZone = LocaleTimeZoneUtil.resolveZoneId();
+
+        // 3. 转换到目标时区
+        ZonedDateTime localDateTime = utcDateTime.withZoneSameInstant(targetZone);
+
+        // 4. 格式化输出
+        gen.writeString(localDateTime.format(FORMATTER));
+    }
+}

+ 0 - 1
user-common/src/main/java/com/poyee/domain/SysDictData.java

@@ -10,7 +10,6 @@ import lombok.NoArgsConstructor;
 
 import java.io.Serializable;
 import java.time.LocalDateTime;
-import java.util.Date;
 
 /**
  * <p>

+ 0 - 1
user-common/src/main/java/com/poyee/domain/SysDictType.java

@@ -10,7 +10,6 @@ import lombok.NoArgsConstructor;
 
 import java.io.Serializable;
 import java.time.LocalDateTime;
-import java.util.Date;
 
 /**
  * <p>

+ 0 - 3
user-common/src/main/java/com/poyee/enums/DefaultEnum.java

@@ -2,9 +2,6 @@ package com.poyee.enums;
 
 import lombok.Getter;
 
-import java.util.ArrayList;
-import java.util.List;
-
 /**
  * 是否默认枚举
  */

+ 6 - 7
user-common/src/main/java/com/poyee/exception/GlobalExceptionHandler.java

@@ -1,20 +1,19 @@
 package com.poyee.exception;
 
+import com.auth0.jwt.exceptions.JWTVerificationException;
+import com.auth0.jwt.exceptions.TokenExpiredException;
 import com.poyee.res.Result;
 import com.poyee.utils.I18nUtil;
 import com.poyee.utils.ServletUtils;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.validation.ObjectError;
-import org.springframework.web.bind.MethodArgumentNotValidException;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.RestControllerAdvice;
-
-import com.auth0.jwt.exceptions.JWTVerificationException;
-import com.auth0.jwt.exceptions.TokenExpiredException;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.ObjectError;
 import org.springframework.web.HttpMediaTypeNotSupportedException;
 import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
 import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
 
 import javax.servlet.http.HttpServletRequest;

+ 0 - 1
user-common/src/main/java/com/poyee/req/SysDictDataReq.java

@@ -7,7 +7,6 @@ import lombok.Data;
 import lombok.NoArgsConstructor;
 
 import javax.validation.constraints.NotBlank;
-import javax.validation.constraints.NotNull;
 import java.io.Serializable;
 
 /**

+ 183 - 0
user-common/src/main/java/com/poyee/utils/LocaleTimeZoneUtil.java

@@ -0,0 +1,183 @@
+package com.poyee.utils;
+
+import cn.hutool.core.util.StrUtil;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 多语言 → 时区工具类
+ * <p>
+ * 根据请求头 Accept-Language 推断对应的时区,
+ * 用于将数据库中存储的 UTC 时间转换为客户端所在区域的本地时间。
+ * </p>
+ */
+public class LocaleTimeZoneUtil {
+
+    /** 默认时区:UTC */
+    public static final ZoneId UTC = ZoneId.of("UTC");
+
+    /**
+     * 语言标签 → 时区映射表
+     * 优先精确匹配(language-COUNTRY),其次匹配语言前缀(language)
+     */
+    private static final Map<String, ZoneId> LOCALE_ZONE_MAP = new HashMap<>();
+
+    static {
+        // 中文
+        LOCALE_ZONE_MAP.put("zh-CN", ZoneId.of("Asia/Shanghai"));   // 简体中文 → 北京时间 UTC+8
+        LOCALE_ZONE_MAP.put("zh-TW", ZoneId.of("Asia/Taipei"));     // 繁体中文(台湾) → UTC+8
+        LOCALE_ZONE_MAP.put("zh-HK", ZoneId.of("Asia/Hong_Kong"));  // 繁体中文(香港) → UTC+8
+        LOCALE_ZONE_MAP.put("zh-MO", ZoneId.of("Asia/Macau"));      // 繁体中文(澳门) → UTC+8
+        LOCALE_ZONE_MAP.put("zh-SG", ZoneId.of("Asia/Singapore"));  // 简体中文(新加坡) → UTC+8
+        LOCALE_ZONE_MAP.put("zh",    ZoneId.of("Asia/Shanghai"));   // 纯 zh → 默认北京时间
+
+        // 英文
+        LOCALE_ZONE_MAP.put("en-US", ZoneId.of("America/New_York")); // 美国东部 UTC-5/4
+        LOCALE_ZONE_MAP.put("en-GB", ZoneId.of("Europe/London"));    // 英国 UTC+0/1
+        LOCALE_ZONE_MAP.put("en-AU", ZoneId.of("Australia/Sydney")); // 澳大利亚东部 UTC+10/11
+        LOCALE_ZONE_MAP.put("en-CA", ZoneId.of("America/Toronto"));  // 加拿大东部
+        LOCALE_ZONE_MAP.put("en-SG", ZoneId.of("Asia/Singapore"));   // 新加坡英文
+        LOCALE_ZONE_MAP.put("en-HK", ZoneId.of("Asia/Hong_Kong"));   // 香港英文
+        LOCALE_ZONE_MAP.put("en",    ZoneId.of("UTC"));              // 纯 en → UTC
+
+        // 日文
+        LOCALE_ZONE_MAP.put("ja-JP", ZoneId.of("Asia/Tokyo"));       // 日本 UTC+9
+        LOCALE_ZONE_MAP.put("ja",    ZoneId.of("Asia/Tokyo"));
+
+        // 韩文
+        LOCALE_ZONE_MAP.put("ko-KR", ZoneId.of("Asia/Seoul"));       // 韩国 UTC+9
+        LOCALE_ZONE_MAP.put("ko",    ZoneId.of("Asia/Seoul"));
+
+        // 德文
+        LOCALE_ZONE_MAP.put("de-DE", ZoneId.of("Europe/Berlin"));    // 德国 UTC+1/2
+        LOCALE_ZONE_MAP.put("de",    ZoneId.of("Europe/Berlin"));
+
+        // 法文
+        LOCALE_ZONE_MAP.put("fr-FR", ZoneId.of("Europe/Paris"));     // 法国 UTC+1/2
+        LOCALE_ZONE_MAP.put("fr",    ZoneId.of("Europe/Paris"));
+
+        // 西班牙文
+        LOCALE_ZONE_MAP.put("es-ES", ZoneId.of("Europe/Madrid"));    // 西班牙 UTC+1/2
+        LOCALE_ZONE_MAP.put("es-MX", ZoneId.of("America/Mexico_City")); // 墨西哥
+        LOCALE_ZONE_MAP.put("es",    ZoneId.of("Europe/Madrid"));
+
+        // 葡萄牙文
+        LOCALE_ZONE_MAP.put("pt-BR", ZoneId.of("America/Sao_Paulo")); // 巴西 UTC-3
+        LOCALE_ZONE_MAP.put("pt-PT", ZoneId.of("Europe/Lisbon"));     // 葡萄牙 UTC+0/1
+        LOCALE_ZONE_MAP.put("pt",    ZoneId.of("Europe/Lisbon"));
+
+        // 阿拉伯文
+        LOCALE_ZONE_MAP.put("ar-SA", ZoneId.of("Asia/Riyadh"));      // 沙特 UTC+3
+        LOCALE_ZONE_MAP.put("ar-AE", ZoneId.of("Asia/Dubai"));       // 迪拜 UTC+4
+        LOCALE_ZONE_MAP.put("ar",    ZoneId.of("Asia/Riyadh"));
+
+        // 印地文
+        LOCALE_ZONE_MAP.put("hi-IN", ZoneId.of("Asia/Kolkata"));     // 印度 UTC+5:30
+        LOCALE_ZONE_MAP.put("hi",    ZoneId.of("Asia/Kolkata"));
+
+        // 俄文
+        LOCALE_ZONE_MAP.put("ru-RU", ZoneId.of("Europe/Moscow"));    // 莫斯科 UTC+3
+        LOCALE_ZONE_MAP.put("ru",    ZoneId.of("Europe/Moscow"));
+
+        // 马来文
+        LOCALE_ZONE_MAP.put("ms-MY", ZoneId.of("Asia/Kuala_Lumpur")); // 马来西亚 UTC+8
+        LOCALE_ZONE_MAP.put("ms",    ZoneId.of("Asia/Kuala_Lumpur"));
+
+        // 泰文
+        LOCALE_ZONE_MAP.put("th-TH", ZoneId.of("Asia/Bangkok"));     // 泰国 UTC+7
+        LOCALE_ZONE_MAP.put("th",    ZoneId.of("Asia/Bangkok"));
+
+        // 越南文
+        LOCALE_ZONE_MAP.put("vi-VN", ZoneId.of("Asia/Ho_Chi_Minh")); // 越南 UTC+7
+        LOCALE_ZONE_MAP.put("vi",    ZoneId.of("Asia/Ho_Chi_Minh"));
+
+        // 印尼文
+        LOCALE_ZONE_MAP.put("id-ID", ZoneId.of("Asia/Jakarta"));     // 印尼西部 UTC+7
+        LOCALE_ZONE_MAP.put("id",    ZoneId.of("Asia/Jakarta"));
+    }
+
+    /**
+     * 从当前 HTTP 请求的 Accept-Language 头部解析对应时区。
+     * <ul>
+     *   <li>优先精确匹配,如 {@code zh-CN} → {@code Asia/Shanghai}</li>
+     *   <li>其次匹配语言前缀,如 {@code zh} → {@code Asia/Shanghai}</li>
+     *   <li>均未匹配时返回 {@code UTC}</li>
+     * </ul>
+     *
+     * @return 解析出的 {@link ZoneId},默认 UTC
+     */
+    public static ZoneId resolveZoneId() {
+        ServletRequestAttributes attributes =
+                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        if (attributes == null) {
+            return UTC;
+        }
+        HttpServletRequest request = attributes.getRequest();
+        String acceptLanguage = request.getHeader("Accept-Language");
+        return parseZoneId(acceptLanguage);
+    }
+
+
+    /**
+     * 根据 Accept-Language 字符串解析时区(可单独测试)。
+     *
+     * @param acceptLanguage HTTP Accept-Language 头部值,如 "zh-CN,zh;q=0.9,en;q=0.8"
+     * @return 匹配的 {@link ZoneId},未匹配返回 UTC
+     */
+    /**
+     * 获取当前 UTC 时间,用于统一存入数据库。
+     * <p>
+     * 全项目保存时间字段统一调用此方法,避免因服务器时区配置不同导致存入非 UTC 时间。
+     * </p>
+     * <pre>
+     * // 推荐用法(替代 LocalDateTime.now())
+     * entity.setCreateTime(LocaleTimeZoneUtil.nowUtc());
+     * entity.setUpdateTime(LocaleTimeZoneUtil.nowUtc());
+     * </pre>
+     *
+     * @return 当前 UTC 时间的 {@link LocalDateTime}
+     */
+    public static LocalDateTime nowUtc() {
+        return LocalDateTime.now(ZoneOffset.UTC);
+    }
+
+    public static ZoneId parseZoneId(String acceptLanguage) {
+        if (StrUtil.isBlank(acceptLanguage)) {
+            return UTC;
+        }
+        // Accept-Language 格式:zh-CN,zh;q=0.9,en-US;q=0.8
+        // 按逗号拆分,取第一个语言标签(优先级最高)
+        String[] languages = acceptLanguage.split(",");
+        for (String lang : languages) {
+            // 去掉权重部分,如 "zh-CN;q=0.9" → "zh-CN"
+            String tag = lang.split(";")[0].trim();
+            if (StrUtil.isBlank(tag)) {
+                continue;
+            }
+            // 统一格式:zh_CN → zh-CN
+            tag = tag.replace("_", "-");
+
+            // 1. 精确匹配
+            ZoneId zoneId = LOCALE_ZONE_MAP.get(tag);
+            if (zoneId != null) {
+                return zoneId;
+            }
+            // 2. 匹配语言前缀(取 "-" 之前部分)
+            if (tag.contains("-")) {
+                String prefix = tag.substring(0, tag.indexOf("-"));
+                zoneId = LOCALE_ZONE_MAP.get(prefix);
+                if (zoneId != null) {
+                    return zoneId;
+                }
+            }
+        }
+        return UTC;
+    }
+}

+ 4 - 4
user-web/src/main/java/com/poyee/facade/impl/SysDictDataFacade.java

@@ -17,11 +17,11 @@ import com.poyee.req.SysDictDataReq;
 import com.poyee.res.SysDictDataRes;
 import com.poyee.service.SysDictDataService;
 import com.poyee.utils.I18nUtil;
+import com.poyee.utils.LocaleTimeZoneUtil;
 import lombok.AllArgsConstructor;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Objects;
 
@@ -74,7 +74,7 @@ public class SysDictDataFacade implements ISysDictDataFacade {
         dictData.setStatus(Objects.nonNull(req.getStatus()) ? req.getStatus() : StatusEnum.FALSE.getCode());
         dictData.setDeleteFlag(StatusEnum.FALSE.getCode());
         dictData.setVersion(1);
-        dictData.setCreateTime(LocalDateTime.now());
+        dictData.setCreateTime(LocaleTimeZoneUtil.nowUtc());
         return sysDictDataService.save(dictData);
     }
 
@@ -92,7 +92,7 @@ public class SysDictDataFacade implements ISysDictDataFacade {
         dictData.setListClass(req.getListClass());
         dictData.setDefaultFlag(req.getDefaultFlag());
         dictData.setStatus(req.getStatus());
-        dictData.setUpdateTime(LocalDateTime.now());
+        dictData.setUpdateTime(LocaleTimeZoneUtil.nowUtc());
         return sysDictDataService.updateById(dictData);
     }
 
@@ -106,7 +106,7 @@ public class SysDictDataFacade implements ISysDictDataFacade {
         List<SysDictData> list = sysDictDataService.listByIds(req.getIds());
         list.forEach(item -> {
             item.setDeleteFlag(StatusEnum.TRUE.getCode());
-            item.setUpdateTime(LocalDateTime.now());
+            item.setUpdateTime(LocaleTimeZoneUtil.nowUtc());
         });
         return sysDictDataService.updateBatchById(list);
     }

+ 4 - 4
user-web/src/main/java/com/poyee/facade/impl/SysDictTypeFacade.java

@@ -17,11 +17,11 @@ import com.poyee.req.SysDictTypeReq;
 import com.poyee.res.SysDictTypeRes;
 import com.poyee.service.SysDictTypeService;
 import com.poyee.utils.I18nUtil;
+import com.poyee.utils.LocaleTimeZoneUtil;
 import lombok.AllArgsConstructor;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Objects;
 
@@ -79,7 +79,7 @@ public class SysDictTypeFacade implements ISysDictTypeFacade {
         dictType.setStatus(Objects.nonNull(req.getStatus()) ? req.getStatus() : StatusEnum.FALSE.getCode());
         dictType.setDeleteFlag(StatusEnum.FALSE.getCode());
         dictType.setVersion(1);
-        dictType.setCreateTime(LocalDateTime.now());
+        dictType.setCreateTime(LocaleTimeZoneUtil.nowUtc());
         return sysDictTypeService.save(dictType);
     }
 
@@ -102,7 +102,7 @@ public class SysDictTypeFacade implements ISysDictTypeFacade {
         dictType.setDictType(req.getDictType());
         dictType.setStatus(req.getStatus());
         dictType.setRemark(req.getRemark());
-        dictType.setUpdateTime(LocalDateTime.now());
+        dictType.setUpdateTime(LocaleTimeZoneUtil.nowUtc());
         return sysDictTypeService.updateById(dictType);
     }
 
@@ -116,7 +116,7 @@ public class SysDictTypeFacade implements ISysDictTypeFacade {
         List<SysDictType> list = sysDictTypeService.listByIds(req.getIds());
         list.forEach(item -> {
             item.setDeleteFlag(StatusEnum.TRUE.getCode());
-            item.setUpdateTime(LocalDateTime.now());
+            item.setUpdateTime(LocaleTimeZoneUtil.nowUtc());
         });
         return sysDictTypeService.updateBatchById(list);
     }