|
@@ -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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|