2026-09-07-backend-thai-i18n.md 17 KB

后端泰语提示支持实施计划

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 白名单解析测试源码

创建参数化测试,锁定所有受支持别名与默认回退:

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:

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

运行:

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 解析改动

    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 与占位符参考,逐一比较英文、简中、越南语和泰语:

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 末尾增加:

"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 转义泰文。
  • 关键值必须精确包含:

    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

运行:

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: 提交泰语消息资源

    $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 切换测试源码

    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:

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

    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 切换契约

    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 运行全部相关定向测试

    $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 执行后端模块构建

    $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: 检查最终差异和未混入文件

    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。
  • 明确说明未修改前端和业务数据多语言映射。