Parcourir la source

制定后端泰语提示实施计划

规划泰语语言包、Locale 白名单、HTTP 语言切换、默认繁体回归契约及统一验证步骤,并明确与现有未提交注释修改隔离。
qmj dans 2 minutes
Parent
commit
47f18f84ad

+ 409 - 0
docs/superpowers/plans/2026-09-07-backend-thai-i18n.md

@@ -0,0 +1,409 @@
+# 后端泰语提示支持实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 为所有后端系统提示增加完整泰语资源和安全的泰语 Locale 解析,同时保持台湾繁体中文为默认语言。
+
+**Architecture:** Spring MessageSource 继续从 `i18n/messages` 读取资源,新增 `messages_th_TH.properties` 提供完整泰语值;同步请求继续由 `lang` 参数切换 Locale,异步消息通过修复后的 `LocaleUtils.parseLocale(String)` 解析用户语言。业务数据的数字语言映射保持不变,避免把尚不存在的泰语商品或门店数据纳入本阶段。
+
+**Tech Stack:** Java 21、Spring Boot MessageSource、Spring MVC LocaleResolver、JUnit 5、Maven、UTF-8 properties。
+
+## Global Constraints
+
+- 默认 Locale 必须继续是台湾繁体中文 `zh_TW`。
+- 泰语标准代码为 `th_TH`,并兼容 `th-TH` 和 `th`。
+- `messages_th_TH.properties` 必须与现有四个本地化语言包拥有完全一致且非空的消息 key。
+- 占位符集合必须逐 key 保持一致,例如 `{0}`、`{1}` 不得遗漏。
+- 不修改 `toLangCode()`、`getUserLanguageCode()`、数据库字段或业务数据语言映射。
+- 不修改平台管理端、商家管理端和 App 前端语言资源。
+- 现有工作区中的闪送请求 DTO/Controller 注释修改属于另一项工作,不得在泰语功能提交中丢失或误暂存。
+- 按用户要求,先完成全部生产代码和测试源码,再统一运行测试与构建;中途不执行 Maven。
+- Git 提交标题和正文使用中文,不添加英文 Conventional Commits 前缀。
+
+---
+
+### Task 1: 定义 Locale 解析契约并修复解析器
+
+**Files:**
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java`
+- Modify: `ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java`
+
+**Interfaces:**
+- Consumes: Redis 中已保存的字符串语言代码,以及直接调用 `LocaleUtils.parseLocale(String)` 的代码。
+- Produces: `public static Locale parseLocale(String language)`;支持白名单语言代码并对空值、未知值回退 `zh_TW`。
+
+- [ ] **Step 1: 编写 Locale 白名单解析测试源码**
+
+创建参数化测试,锁定所有受支持别名与默认回退:
+
+```java
+package com.ruoyi.common.utils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class LocaleUtilsTest {
+    @ParameterizedTest
+    @CsvSource({
+            "zh_CN,zh,CN", "zh-CN,zh,CN", "zh,zh,CN",
+            "zh_TW,zh,TW", "zh-TW,zh,TW", "tw,zh,TW",
+            "en_US,en,US", "en-US,en,US", "en,en,US",
+            "vi_VN,vi,VN", "vi-VN,vi,VN", "vi,vi,VN",
+            "th_TH,th,TH", "th-TH,th,TH", "th,th,TH"
+    })
+    void parsesSupportedLanguageAliases(String input, String language, String country) {
+        assertEquals(new Locale(language, country), LocaleUtils.parseLocale(input));
+    }
+
+    @Test
+    void fallsBackToTraditionalChineseForMissingOrUnknownLanguage() {
+        Locale expected = new Locale("zh", "TW");
+        assertEquals(expected, LocaleUtils.parseLocale(null));
+        assertEquals(expected, LocaleUtils.parseLocale("  "));
+        assertEquals(expected, LocaleUtils.parseLocale("fr-FR"));
+    }
+}
+```
+
+- [ ] **Step 2: 实现白名单 Locale 解析**
+
+保留 `DEFAULT_LOCALE`,新增明确的 Locale 常量并只修改 `parseLocale`。先 `trim()`,再把 `-` 转为 `_` 并使用 `Locale.ROOT` 转小写,最后使用 switch 返回白名单 Locale:
+
+```java
+private static final Locale SIMPLIFIED_CHINESE = new Locale("zh", "CN");
+private static final Locale ENGLISH = new Locale("en", "US");
+private static final Locale VIETNAMESE = new Locale("vi", "VN");
+private static final Locale THAI = new Locale("th", "TH");
+
+public static Locale parseLocale(String language)
+{
+    if (StringUtils.isEmpty(language) || language.trim().isEmpty())
+    {
+        return DEFAULT_LOCALE;
+    }
+    String normalized = language.trim().replace('-', '_').toLowerCase(Locale.ROOT);
+    return switch (normalized)
+    {
+        case "zh", "zh_cn" -> SIMPLIFIED_CHINESE;
+        case "zh_tw", "tw" -> DEFAULT_LOCALE;
+        case "en", "en_us" -> ENGLISH;
+        case "vi", "vi_vn" -> VIETNAMESE;
+        case "th", "th_th" -> THAI;
+        default -> DEFAULT_LOCALE;
+    };
+}
+```
+
+不得修改 `toLangCode()` 和 `getUserLanguageCode()`。
+
+- [ ] **Step 3: 静态检查本任务差异,暂不运行 Maven**
+
+运行:
+
+```powershell
+git diff --check -- ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java
+```
+
+预期:无错误输出。按全局约束,将测试执行延后到 Task 4。
+
+- [ ] **Step 4: 提交 Locale 解析改动**
+
+```powershell
+git add -- ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "支持后端泰语语言解析" -m "兼容 th_TH、th-TH 和 th,并为现有受支持语言建立白名单解析;空值和未知语言继续回退台湾繁体。"
+```
+
+### Task 2: 新增完整泰语消息资源和语言包契约
+
+**Files:**
+- Create: `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java`
+
+**Interfaces:**
+- Consumes: `ResourceBundleMessageSource` 的 basename `i18n/messages` 和现有 319 个本地化消息 key。
+- Produces: 完整的泰语资源包 `messages_th_TH.properties`,由 `Locale("th", "TH")` 自动加载。
+
+- [ ] **Step 1: 编写语言包完整性和占位符契约测试源码**
+
+创建 `BackendI18nBundleContractTest`,使用 UTF-8 Reader 加载资源;以繁体中文包作为 key 与占位符参考,逐一比较英文、简中、越南语和泰语:
+
+```java
+package com.ruoyi.common.utils;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.support.ResourceBundleMessageSource;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class BackendI18nBundleContractTest {
+    private static final List<String> LOCALIZED_BUNDLES = List.of(
+            "messages_zh_CN.properties", "messages_zh_TW.properties",
+            "messages_en_US.properties", "messages_vi.properties",
+            "messages_th_TH.properties");
+    private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\d+}");
+
+    @Test
+    void everyLocalizedBundleHasTheSameNonBlankKeysAndPlaceholders() throws Exception {
+        Properties reference = load("messages_zh_TW.properties");
+        for (String bundle : LOCALIZED_BUNDLES) {
+            Properties candidate = load(bundle);
+            assertEquals(reference.stringPropertyNames(), candidate.stringPropertyNames(), bundle);
+            for (String key : reference.stringPropertyNames()) {
+                assertFalse(candidate.getProperty(key).isBlank(), bundle + " blank " + key);
+                assertEquals(placeholders(reference.getProperty(key)),
+                        placeholders(candidate.getProperty(key)), bundle + " placeholders " + key);
+            }
+        }
+    }
+
+    @Test
+    void messageSourceLoadsThaiMessagesAndFormatsArguments() {
+        ResourceBundleMessageSource source = new ResourceBundleMessageSource();
+        source.setBasename("i18n/messages");
+        source.setDefaultEncoding(StandardCharsets.UTF_8.name());
+        Locale thai = new Locale("th", "TH");
+        assertEquals("ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง",
+                source.getMessage("flash.delivery.quote.changed", null, thai));
+        assertEquals("ป้อนรหัสผ่านผิด 3 ครั้ง บัญชีถูกล็อก 10 นาที",
+                source.getMessage("user.password.retry.limit.exceed", new Object[]{3, 10}, thai));
+    }
+
+    private Properties load(String file) throws Exception {
+        Properties properties = new Properties();
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream("i18n/" + file)) {
+            assertNotNull(input, file);
+            properties.load(new InputStreamReader(input, StandardCharsets.UTF_8));
+        }
+        return properties;
+    }
+
+    private List<String> placeholders(String value) {
+        List<String> result = new ArrayList<>();
+        Matcher matcher = PLACEHOLDER.matcher(value);
+        while (matcher.find()) result.add(matcher.group());
+        return result;
+    }
+}
+```
+
+- [ ] **Step 2: 将泰语文件纳入闪送消息契约**
+
+在 `FlashDeliveryI18nContractTest.FILES` 末尾增加:
+
+```java
+"messages_th_TH.properties"
+```
+
+保留现有全部闪送与地址消息 key。
+
+- [ ] **Step 3: 创建完整泰语语言包**
+
+以 `messages_zh_TW.properties` 的 key 和顺序为基准创建 `messages_th_TH.properties`:
+
+- 复制全部 319 个 key,不复制繁体中文值。
+- 将每个值翻译为自然、简洁的泰语系统提示。
+- 保持 `{0}`、`{1}`、`{min}`、`{max}` 等占位符原样。
+- 保持 URL、HTTP(S)、PIN、TWD、LINE、Google、Apple、OMG、ATM、CVS 等技术或品牌标识原样。
+- 文件使用 UTF-8,不使用 `\uXXXX` 转义泰文。
+- 关键值必须精确包含:
+
+```properties
+not.null=* จำเป็นต้องกรอก
+user.login.success=เข้าสู่ระบบสำเร็จ
+no.action.success=ดำเนินการสำเร็จ
+flash.delivery.item.invalid=ข้อมูลจำนวน น้ำหนักรวม หรือรายละเอียดสิ่งของไม่ถูกต้อง
+flash.delivery.quote.changed=ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง
+user.password.retry.limit.exceed=ป้อนรหัสผ่านผิด {0} ครั้ง บัญชีถูกล็อก {1} นาที
+```
+
+- [ ] **Step 4: 静态检查资源和测试差异,暂不运行 Maven**
+
+运行:
+
+```powershell
+git diff --check -- ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
+```
+
+预期:无错误输出。按全局约束,将测试执行延后到 Task 4。
+
+- [ ] **Step 5: 提交泰语消息资源**
+
+```powershell
+$paths=@(
+  'ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties',
+  'ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java',
+  'ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java'
+)
+git add -- $paths
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "新增完整后端泰语提示" -m "补齐全部后端消息的泰语翻译,并通过键集合、占位符和典型消息契约防止语言包缺失或格式错误。"
+```
+
+### Task 3: 锁定台湾繁体默认语言和 HTTP 泰语切换
+
+**Files:**
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java`
+- Modify: `ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java`
+
+**Interfaces:**
+- Consumes: `I18nConfig.localeResolver()` 和 `I18nConfig.localeChangeInterceptor()`。
+- Produces: 统一使用 `LocaleUtils.parseLocale(String)` 的 HTTP 语言切换,确保无 `lang` 时为 `zh_TW`,三种泰语代码都切换为 `th_TH`。
+
+- [ ] **Step 1: 编写默认语言与 HTTP 切换测试源码**
+
+```java
+package com.ruoyi.framework.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.servlet.DispatcherServlet;
+import org.springframework.web.servlet.LocaleResolver;
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class BackendI18nConfigTest {
+    @Test
+    void defaultsToTraditionalChinese() {
+        LocaleResolver resolver = new I18nConfig().localeResolver();
+        assertEquals(new Locale("zh", "TW"),
+                resolver.resolveLocale(new MockHttpServletRequest()));
+    }
+
+    @Test
+    void switchesAllThaiAliasesThroughExistingLangParameter() throws Exception {
+        for (String language : new String[]{"th_TH", "th-TH", "th"}) {
+            I18nConfig config = new I18nConfig();
+            LocaleResolver resolver = config.localeResolver();
+            LocaleChangeInterceptor interceptor = config.localeChangeInterceptor();
+            MockHttpServletRequest request = new MockHttpServletRequest();
+            request.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, resolver);
+            request.setParameter("lang", language);
+            interceptor.preHandle(request, new MockHttpServletResponse(), new Object());
+            assertEquals(new Locale("th", "TH"), resolver.resolveLocale(request), language);
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 让 HTTP 拦截器复用白名单解析器**
+
+在 `I18nConfig` 导入 `LocaleUtils`,并只覆盖拦截器的 Locale 值解析;参数名继续使用 `lang`:
+
+```java
+import com.ruoyi.common.utils.LocaleUtils;
+
+@Bean
+public LocaleChangeInterceptor localeChangeInterceptor()
+{
+    LocaleChangeInterceptor lci = new LocaleChangeInterceptor()
+    {
+        @Override
+        protected Locale parseLocaleValue(String localeValue)
+        {
+            return LocaleUtils.parseLocale(localeValue);
+        }
+    };
+    lci.setParamName("lang");
+    return lci;
+}
+```
+
+`localeResolver()` 保持 `slr.setDefaultLocale(Locale.TRADITIONAL_CHINESE)`,不得修改默认语言。
+
+- [ ] **Step 3: 静态检查配置和测试,暂不运行 Maven**
+
+```powershell
+git diff --check -- ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java
+```
+
+预期:无错误输出。
+
+- [ ] **Step 4: 提交默认语言和 HTTP 切换契约**
+
+```powershell
+git add -- ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "锁定后端默认语言与泰语切换" -m "让 lang 参数复用受支持语言白名单解析,兼容三种泰语代码,并增加台湾繁体默认语言回归契约。"
+```
+
+### Task 4: 统一验证和交付检查
+
+**Files:**
+- Verify: `ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java`
+- Verify: `ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java`
+- Verify: `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java`
+
+**Interfaces:**
+- Consumes: Tasks 1 至 3 的全部生产代码、资源和测试源码。
+- Produces: JDK 21 下可验证的完整后端泰语提示支持。
+
+- [ ] **Step 1: 使用 JDK 21 运行全部相关定向测试**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am "-Dtest=LocaleUtilsTest,BackendI18nBundleContractTest,BackendI18nConfigTest,FlashDeliveryI18nContractTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
+```
+
+预期:四个测试类全部通过,Maven 输出 `BUILD SUCCESS`。
+
+- [ ] **Step 2: 使用 JDK 21 执行后端模块构建**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am -DskipTests package
+```
+
+预期:`ruoyi-common`、`ruoyi-system`、`ruoyi-framework`、`ruoyi-quartz`、`ruoyi-generator` 和 `ruoyi-admin` 均为 `SUCCESS`。
+
+- [ ] **Step 3: 检查最终差异和未混入文件**
+
+```powershell
+git diff --check
+git status --short
+git log --oneline -4
+```
+
+确认泰语功能提交没有包含 `.claude/homunculus/observations.jsonl`,也没有包含此前未提交的闪送请求 DTO/Controller 注释修改。
+
+- [ ] **Step 4: 报告验证结果和提交 SHA**
+
+交付内容必须列出:
+
+- Locale 解析提交 SHA。
+- 泰语语言包提交 SHA。
+- 默认语言回归测试提交 SHA。
+- 实际执行的测试数量和 Maven 构建结果。
+- 明确说明默认语言仍是 `zh_TW`。
+- 明确说明未修改前端和业务数据多语言映射。

+ 2 - 2
docs/superpowers/specs/2026-09-07-backend-thai-i18n-design.md

@@ -29,7 +29,7 @@
 
 ### 同步 HTTP 请求
 
-保留现有 `SessionLocaleResolver` 和 `LocaleChangeInterceptor`,参数名继续使用 `lang`。客户端可通过 `?lang=th_TH` 选择泰语。默认 Locale 继续设置为台湾繁体中文。
+保留现有 `SessionLocaleResolver` 和 `LocaleChangeInterceptor`,参数名继续使用 `lang`。拦截器通过同一个白名单解析器规范化语言代码,因此客户端使用 `?lang=th_TH`、`?lang=th-TH` 或 `?lang=th` 都会得到泰国地区泰语 Locale。默认 Locale 继续设置为台湾繁体中文。
 
 ### 用户语言和异步消息
 
@@ -57,7 +57,7 @@
 
 新增或扩展测试,至少覆盖:
 
-1. 五个地区语言包与越南语包的 key 集合一致,泰语包包含全部现有后端消息 key。
+1. 五个受支持的本地化语言包(`zh_CN`、`zh_TW`、`en_US`、`vi`、`th_TH`)key 集合一致,泰语包包含全部现有后端消息 key。
 2. `th_TH`、`th-TH`、`th` 都解析为泰国地区泰语。
 3. 空值、未知语言仍解析为 `zh_TW`。
 4. 现有简中、繁中、英文、越南语解析保持正确。