From f8f4df40730fdc06601b20dcd709b3aaf4b53a95 Mon Sep 17 00:00:00 2001 From: Jimmy Xue Date: Sun, 7 Dec 2025 10:32:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=9A=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 1 + docs/i18n.md | 510 ++++++++++++++++++++++++++++++++++++ examples/i18n_example.go | 101 +++++++ examples/locales/en-US.json | 38 +++ examples/locales/zh-CN.json | 38 +++ factory/factory.go | 208 ++++++++++++++- http/request.go | 8 + i18n/i18n.go | 286 ++++++++++++++++++++ middleware/language.go | 102 ++++++++ 9 files changed, 1287 insertions(+), 5 deletions(-) create mode 100644 docs/i18n.md create mode 100644 examples/i18n_example.go create mode 100644 examples/locales/en-US.json create mode 100644 examples/locales/zh-CN.json create mode 100644 i18n/i18n.go create mode 100644 middleware/language.go diff --git a/docs/README.md b/docs/README.md index eb4c1c0..1f98fc7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - [短信工具](./sms.md) - 阿里云短信发送 - [工厂工具](./factory.md) - 从配置直接创建已初始化客户端对象 - [日志工具](./logger.md) - 统一的日志记录功能 +- [国际化工具](./i18n.md) - 多语言内容管理和国际化支持 ## 快速开始 diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..8002653 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,510 @@ +# 国际化工具文档 + +## 概述 + +国际化工具(i18n)提供多语言内容管理功能,支持从文件加载语言内容,通过语言代码和消息代码获取对应语言的内容。 + +## 功能特性 + +- **多语言支持**:支持加载多个语言文件 +- **文件加载**:支持从目录或单个文件加载语言内容 +- **语言回退**:当指定语言不存在时,自动回退到默认语言 +- **参数替换**:支持在消息中使用格式化参数(类似 fmt.Sprintf) +- **并发安全**:使用读写锁保证并发安全 +- **动态加载**:支持重新加载语言文件 + +## 快速开始 + +### 1. 创建语言文件 + +创建语言文件目录 `locales/`,并创建对应的语言文件: + +**locales/zh-CN.json**: +```json +{ + "user.not_found": { + "code": 100002, + "message": "用户不存在" + }, + "user.login_success": { + "code": 0, + "message": "登录成功" + }, + "user.welcome": { + "code": 0, + "message": "欢迎,%s" + }, + "error.invalid_params": { + "code": 100001, + "message": "参数无效" + } +} +``` + +**locales/en-US.json**: +```json +{ + "user.not_found": { + "code": 100002, + "message": "User not found" + }, + "user.login_success": { + "code": 0, + "message": "Login successful" + }, + "user.welcome": { + "code": 0, + "message": "Welcome, %s" + }, + "error.invalid_params": { + "code": 100001, + "message": "Invalid parameters" + } +} +``` + +### 2. 初始化并使用 + +```go +package main + +import ( + "git.toowon.com/jimmy/go-common/factory" +) + +func main() { + // 创建工厂实例 + fac, _ := factory.NewFactoryFromFile("config.json") + + // 初始化国际化工具,设置默认语言为中文 + fac.InitI18n("zh-CN") + + // 从目录加载所有语言文件 + fac.LoadI18nFromDir("locales") + + // 获取消息 + msg := fac.GetMessage("zh-CN", "user.not_found") + // 返回: "用户不存在" + + // 带参数的消息 + msg = fac.GetMessage("zh-CN", "user.welcome", "Alice") + // 返回: "欢迎,Alice" + + // 在HTTP handler中使用Error方法(推荐) + // fac.Error(w, r, 0, "user.not_found") + // 会自动从语言文件获取业务code和国际化消息 + // 返回: {"code": 100002, "message": "用户不存在"} +} +``` + +## API 文档 + +### 初始化方法 + +#### InitI18n + +初始化国际化工具,设置默认语言。 + +```go +fac.InitI18n(defaultLang string) +``` + +**参数**: +- `defaultLang`: 默认语言代码(如 "zh-CN", "en-US") + +**示例**: +```go +fac.InitI18n("zh-CN") +``` + +### 加载方法 + +#### LoadI18nFromDir + +从目录加载多个语言文件(推荐方式)。 + +```go +fac.LoadI18nFromDir(dirPath string) error +``` + +**参数**: +- `dirPath`: 语言文件目录路径 + +**文件命名规则**: +- 文件必须以 `.json` 结尾 +- 文件名(去掉 `.json` 后缀)作为语言代码 +- 例如:`zh-CN.json` 对应语言代码 `zh-CN` + +**示例**: +```go +fac.LoadI18nFromDir("locales") +``` + +#### LoadI18nFromFile + +从单个语言文件加载内容。 + +```go +fac.LoadI18nFromFile(filePath, lang string) error +``` + +**参数**: +- `filePath`: 语言文件路径(JSON格式) +- `lang`: 语言代码(如 "zh-CN", "en-US") + +**示例**: +```go +fac.LoadI18nFromFile("locales/zh-CN.json", "zh-CN") +fac.LoadI18nFromFile("locales/en-US.json", "en-US") +``` + +### 错误响应方法 + +#### Error + +错误响应(自动国际化,推荐使用)。 + +```go +fac.Error(w, r, code int, message string, args ...interface{}) +``` + +**参数**: +- `w`: ResponseWriter +- `r`: HTTP请求(用于获取语言信息) +- `code`: 业务错误码(如果message是消息代码,此参数会被语言文件中的code覆盖) +- `message`: 错误消息或消息代码(如果i18n已初始化且message是消息代码格式,会自动获取国际化消息和业务code) +- `args`: 可选参数,用于格式化消息(类似 fmt.Sprintf,仅在message是消息代码时使用) + +**使用逻辑**: +1. 如果i18n已初始化,且message看起来是消息代码(包含点号,如 "user.not_found"), + 则从请求context中获取语言,并尝试从语言文件中获取国际化消息和业务code +2. 如果获取到国际化消息,使用语言文件中的code作为响应code,使用国际化消息作为响应message +3. 如果未获取到或i18n未初始化,使用传入的code和message + +**示例**: +```go +// 方式1:传入消息代码(推荐,自动获取业务code和国际化消息) +fac.Error(w, r, 0, "user.not_found") +// 如果请求语言是 zh-CN,且语言文件中 "user.not_found" 的 code 是 100002, +// 返回: {"code": 100002, "message": "用户不存在"} +// 如果请求语言是 en-US,返回: {"code": 100002, "message": "User not found"} +// 注意:业务code在所有语言中保持一致 + +// 方式2:带参数的消息代码 +fac.Error(w, r, 0, "user.welcome", "Alice") +// 如果消息内容是 "欢迎,%s",返回: {"code": 0, "message": "欢迎,Alice"} + +// 方式3:直接传入消息文本(不使用国际化) +fac.Error(w, r, 500, "系统错误") +// 返回: {"code": 500, "message": "系统错误"} +``` + +### 获取消息方法 + +#### GetMessage + +获取指定语言和代码的消息内容(推荐使用)。 + +```go +fac.GetMessage(lang, code string, args ...interface{}) string +``` + +**参数**: +- `lang`: 语言代码(如 "zh-CN", "en-US") +- `code`: 消息代码(如 "user.not_found") +- `args`: 可选参数,用于格式化消息(类似 fmt.Sprintf) + +**返回逻辑**: +1. 如果指定语言存在该code,返回对应内容 +2. 如果指定语言不存在,尝试使用默认语言 +3. 如果默认语言也不存在,返回code本身(作为fallback) + +**示例**: +```go +// 简单消息 +msg := fac.GetMessage("zh-CN", "user.not_found") +// 返回: "用户不存在" + +// 带参数的消息 +msg := fac.GetMessage("zh-CN", "user.welcome", "Alice") +// 如果消息内容是 "欢迎,%s",返回: "欢迎,Alice" +``` + +### GetLanguage + +从请求的context中获取语言代码(推荐使用)。 + +```go +fac.GetLanguage(r *http.Request) string +``` + +**参数**: +- `r`: HTTP请求 + +**返回逻辑**: +1. 从请求context中获取语言(由middleware.Language中间件设置) +2. 如果未设置,返回默认语言 zh-CN + +**示例**: +```go +lang := fac.GetLanguage(r) +// 可能返回: "zh-CN", "en-US" 等 +``` + +### 高级功能 + +#### GetI18n + +获取国际化工具对象(仅在需要高级功能时使用)。 + +```go +fac.GetI18n() (*i18n.I18n, error) +``` + +**返回**:国际化工具对象 + +**高级功能示例**: +```go +i18n, _ := fac.GetI18n() + +// 检查语言是否存在 +hasLang := i18n.HasLang("en-US") + +// 获取所有支持的语言 +langs := i18n.GetSupportedLangs() + +// 重新加载语言文件 +i18n.ReloadFromFile("locales/zh-CN.json", "zh-CN") + +// 动态设置默认语言 +i18n.SetDefaultLang("en-US") +``` + +## 使用场景 + +### 场景1:HTTP API 响应消息(推荐使用 Error 方法) + +**推荐方式**:使用 `Error` 方法,自动从请求中获取语言并返回国际化消息和业务code: + +```go +func handleLogin(w http.ResponseWriter, r *http.Request) { + fac, _ := factory.NewFactoryFromFile("config.json") + + // 验证用户 + user, err := validateUser(r) + if err != nil { + // 直接传入消息代码,自动获取业务code和国际化消息(推荐) + // 会自动从请求context获取语言(由middleware.Language中间件设置) + fac.Error(w, r, 0, "user.not_found") + // 返回: {"code": 100002, "message": "用户不存在"} 或 {"code": 100002, "message": "User not found"} + return + } + + // 成功消息 + lang := fac.GetLanguage(r) + msg := fac.GetMessage(lang, "user.login_success") + fac.Success(w, user, msg) +} +``` + +### 场景2:带参数的消息 + +```go +func handleWelcome(w http.ResponseWriter, r *http.Request) { + fac, _ := factory.NewFactoryFromFile("config.json") + lang := getLangFromRequest(r) + + username := "Alice" + // 消息内容: "欢迎,%s" + msg := fac.GetMessage(lang, "user.welcome", username) + // 返回: "欢迎,Alice" 或 "Welcome, Alice" + + fac.Success(w, nil, msg) +} +``` + +### 场景3:错误消息国际化(推荐使用 Error 方法) + +**推荐方式**:使用 `Error` 方法,自动获取业务code和国际化消息: + +```go +func handleError(w http.ResponseWriter, r *http.Request, messageCode string) { + fac, _ := factory.NewFactoryFromFile("config.json") + + // 直接传入消息代码,自动获取业务code和国际化消息(推荐) + // 会自动从请求context获取语言并查找对应的国际化消息和业务code + fac.Error(w, r, 0, "error."+messageCode) + // 例如:fac.Error(w, r, 0, "error.invalid_params") + // 返回: {"code": 100001, "message": "参数无效"} 或 {"code": 100001, "message": "Invalid parameters"} +} +``` + +## 语言文件格式 + +语言文件必须是 JSON 格式,每个消息包含业务code和消息内容: + +```json +{ + "user.not_found": { + "code": 100002, + "message": "用户不存在" + }, + "user.login_success": { + "code": 0, + "message": "登录成功" + }, + "user.welcome": { + "code": 0, + "message": "欢迎,%s" + }, + "error.invalid_params": { + "code": 100001, + "message": "参数无效" + } +} +``` + +**注意事项**: +- key(消息代码)建议使用点号分隔的层级结构(如 `user.not_found`) +- value 是一个对象,包含: + - `code`: 业务错误码(整数),用于业务逻辑判断,所有语言的同一消息代码应使用相同的code + - `message`: 消息内容(字符串),支持格式化占位符(如 `%s`, `%d`) +- 所有语言文件应该包含相同的 key,确保所有语言都有对应的翻译 +- **重要**:同一消息代码在所有语言文件中的 `code` 必须保持一致,只有 `message` 会根据语言变化 + +## 最佳实践 + +### 1. 消息代码命名规范 + +建议使用层级结构,便于管理和查找: + +``` +模块.功能.状态 +例如: +- user.not_found +- user.login_success +- order.created +- order.paid +- error.invalid_params +- error.server_error +``` + +### 2. 默认语言设置 + +建议将最常用的语言设置为默认语言,确保在语言不存在时有合理的回退: + +```go +fac.InitI18n("zh-CN") // 中文作为默认语言 +``` + +### 3. 语言代码规范 + +建议使用标准的语言代码格式: +- `zh-CN`: 简体中文 +- `zh-TW`: 繁体中文 +- `en-US`: 美式英语 +- `en-GB`: 英式英语 +- `ja-JP`: 日语 +- `ko-KR`: 韩语 + +### 4. 文件组织 + +建议将所有语言文件放在统一的目录下: + +``` +project/ + locales/ + zh-CN.json + en-US.json + ja-JP.json +``` + +### 5. 参数使用 + +对于需要动态内容的消息,使用格式化参数: + +```json +{ + "user.welcome": { + "code": 0, + "message": "欢迎,%s" + }, + "order.total": { + "code": 0, + "message": "订单总额:%.2f 元" + }, + "message.count": { + "code": 0, + "message": "您有 %d 条新消息" + } +} +``` + +使用方式: +```go +// 使用 GetMessage +msg := fac.GetMessage("zh-CN", "user.welcome", "Alice") +msg := fac.GetMessage("zh-CN", "order.total", 99.99) +msg := fac.GetMessage("zh-CN", "message.count", 5) + +// 使用 Error 方法(推荐) +fac.Error(w, r, 0, "user.welcome", "Alice") +fac.Error(w, r, 0, "message.count", 5) +``` + +### 6. 业务Code管理 + +**重要原则**:同一消息代码在所有语言文件中的业务code必须保持一致。 + +```json +// zh-CN.json +{ + "user.not_found": { + "code": 100002, // 业务code + "message": "用户不存在" + } +} + +// en-US.json +{ + "user.not_found": { + "code": 100002, // 必须与zh-CN.json中的code相同 + "message": "User not found" // 只有message会根据语言变化 + } +} +``` + +这样设计的好处: +- 调用端可以根据返回的 `code` 进行业务逻辑判断,不受语言影响 +- 所有语言的同一错误使用相同的业务code,便于统一管理 + +## 常见问题 + +### Q1: 如果语言文件不存在会怎样? + +A: 如果语言文件不存在,`LoadI18nFromFile` 或 `LoadI18nFromDir` 会返回错误。建议在初始化时检查错误。 + +### Q2: 如果消息代码不存在会怎样? + +A: `GetMessage` 和 `Error` 会按以下顺序查找: +1. 指定语言的消息 +2. 默认语言的消息 +3. 如果都不存在: + - `GetMessage` 返回消息代码本身(作为fallback) + - `Error` 使用传入的code参数和消息代码作为message + +### Q5: 业务code在不同语言中必须相同吗? + +A: **是的,必须相同**。同一消息代码在所有语言文件中的业务code必须保持一致,只有message内容会根据语言变化。这样调用端可以根据code进行业务逻辑判断,不受语言影响。 + +### Q3: 如何支持动态加载语言文件? + +A: 可以使用 `GetI18n()` 获取对象,然后调用 `ReloadFromFile()` 或 `ReloadFromDir()` 方法。 + +### Q4: 是否支持嵌套的消息结构? + +A: 当前版本只支持扁平结构(key-value),不支持嵌套。如果需要嵌套结构,建议使用点号分隔的层级命名(如 `user.profile.name`)。 + +## 完整示例 + +参考 [examples/i18n_example.go](../examples/i18n_example.go) diff --git a/examples/i18n_example.go b/examples/i18n_example.go new file mode 100644 index 0000000..75f7296 --- /dev/null +++ b/examples/i18n_example.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "log" + + "git.toowon.com/jimmy/go-common/factory" +) + +func main() { + // 创建工厂实例 + fac, err := factory.NewFactoryFromFile("config.json") + if err != nil { + log.Fatal(err) + } + + // ====== 方式1:从目录加载多个语言文件(推荐) ====== + // 目录结构: + // locales/ + // zh-CN.json + // en-US.json + // ja-JP.json + + fac.InitI18n("zh-CN") // 设置默认语言为中文 + if err := fac.LoadI18nFromDir("locales"); err != nil { + log.Fatal(err) + } + + // 使用示例 + fmt.Println("=== 示例1:简单消息 ===") + msg1 := fac.GetMessage("zh-CN", "user.not_found") + fmt.Printf("中文: %s\n", msg1) + + msg2 := fac.GetMessage("en-US", "user.not_found") + fmt.Printf("英文: %s\n", msg2) + + // ====== 方式2:从单个文件加载 ====== + fmt.Println("\n=== 示例2:从单个文件加载 ===") + fac2, _ := factory.NewFactoryFromFile("config.json") + fac2.InitI18n("zh-CN") + fac2.LoadI18nFromFile("locales/zh-CN.json", "zh-CN") + fac2.LoadI18nFromFile("locales/en-US.json", "en-US") + + msg3 := fac2.GetMessage("zh-CN", "user.login_success") + fmt.Printf("中文: %s\n", msg3) + + // ====== 示例3:带参数的消息 ====== + fmt.Println("\n=== 示例3:带参数的消息 ===") + // 如果消息内容是 "欢迎,%s",可以使用参数 + msg4 := fac.GetMessage("zh-CN", "user.welcome", "Alice") + fmt.Printf("中文: %s\n", msg4) + + msg5 := fac.GetMessage("en-US", "user.welcome", "Alice") + fmt.Printf("英文: %s\n", msg5) + + // ====== 示例4:语言回退机制 ====== + fmt.Println("\n=== 示例4:语言回退机制 ===") + // 如果请求的语言不存在,会使用默认语言 + msg6 := fac.GetMessage("fr-FR", "user.not_found") // fr-FR 不存在,使用默认语言 zh-CN + fmt.Printf("法语(不存在,回退到默认语言): %s\n", msg6) + + // ====== 示例5:高级功能 ====== + fmt.Println("\n=== 示例5:高级功能 ===") + i18n, _ := fac.GetI18n() + langs := i18n.GetSupportedLangs() + fmt.Printf("支持的语言: %v\n", langs) + + hasLang := i18n.HasLang("en-US") + fmt.Printf("是否支持英文: %v\n", hasLang) + + // ====== 示例6:在HTTP处理中使用 ====== + fmt.Println("\n=== 示例6:在HTTP处理中使用 ===") + // 在实际HTTP处理中,可以从请求头获取语言 + // lang := r.Header.Get("Accept-Language") // 例如: "zh-CN,en-US;q=0.9" + // 简化示例,假设从请求中获取到语言代码 + userLang := "zh-CN" + errorMsg := fac.GetMessage(userLang, "user.not_found") + fmt.Printf("错误消息: %s\n", errorMsg) + + successMsg := fac.GetMessage(userLang, "user.login_success") + fmt.Printf("成功消息: %s\n", successMsg) + + // ====== 示例7:通过错误码直接返回国际化消息(推荐) ====== + fmt.Println("\n=== 示例7:通过错误码直接返回国际化消息 ===") + // 在实际HTTP处理中,Error 方法会自动识别消息代码并返回国际化消息 + // 需要确保使用了 middleware.Language 中间件(factory.GetMiddlewareChain() 已包含) + // + // 示例代码(在HTTP handler中): + // func handleGetUser(w http.ResponseWriter, r *http.Request) { + // fac, _ := factory.NewFactoryFromFile("config.json") + // + // // 如果用户不存在,直接传入消息代码,会自动获取国际化消息 + // fac.Error(w, r, 404, "user.not_found") + // // 自动从context获取语言(由middleware.Language设置),返回对应语言的错误消息 + // // 返回: {"code": 404, "message": "用户不存在", "message_code": "user.not_found"} + // } + // + // 如果请求头是 Accept-Language: zh-CN,返回: {"code": 404, "message": "用户不存在", "message_code": "user.not_found"} + // 如果请求头是 Accept-Language: en-US,返回: {"code": 404, "message": "User not found", "message_code": "user.not_found"} + fmt.Println("Error 方法会自动识别消息代码并返回国际化消息和消息代码") +} diff --git a/examples/locales/en-US.json b/examples/locales/en-US.json new file mode 100644 index 0000000..7c98dcf --- /dev/null +++ b/examples/locales/en-US.json @@ -0,0 +1,38 @@ +{ + "user.not_found": { + "code": 100002, + "message": "User not found" + }, + "user.login_success": { + "code": 0, + "message": "Login successful" + }, + "user.welcome": { + "code": 0, + "message": "Welcome, %s" + }, + "user.logout": { + "code": 0, + "message": "Logout" + }, + "error.invalid_params": { + "code": 100001, + "message": "Invalid parameters" + }, + "error.server_error": { + "code": 100003, + "message": "Server error" + }, + "order.created": { + "code": 0, + "message": "Order created successfully" + }, + "order.paid": { + "code": 0, + "message": "Order paid successfully" + }, + "message.count": { + "code": 0, + "message": "You have %d new messages" + } +} diff --git a/examples/locales/zh-CN.json b/examples/locales/zh-CN.json new file mode 100644 index 0000000..0070530 --- /dev/null +++ b/examples/locales/zh-CN.json @@ -0,0 +1,38 @@ +{ + "user.not_found": { + "code": 100002, + "message": "用户不存在" + }, + "user.login_success": { + "code": 0, + "message": "登录成功" + }, + "user.welcome": { + "code": 0, + "message": "欢迎,%s" + }, + "user.logout": { + "code": 0, + "message": "退出登录" + }, + "error.invalid_params": { + "code": 100001, + "message": "参数无效" + }, + "error.server_error": { + "code": 100003, + "message": "服务器错误" + }, + "order.created": { + "code": 0, + "message": "订单创建成功" + }, + "order.paid": { + "code": 0, + "message": "订单支付成功" + }, + "message.count": { + "code": 0, + "message": "您有 %d 条新消息" + } +} diff --git a/factory/factory.go b/factory/factory.go index 710252c..3d86267 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -5,11 +5,13 @@ import ( "fmt" "io" "net/http" + "strings" "time" "git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/email" commonhttp "git.toowon.com/jimmy/go-common/http" + "git.toowon.com/jimmy/go-common/i18n" "git.toowon.com/jimmy/go-common/logger" "git.toowon.com/jimmy/go-common/middleware" "git.toowon.com/jimmy/go-common/migration" @@ -118,6 +120,7 @@ type Factory struct { sms *sms.SMS // 短信客户端(延迟初始化) db *gorm.DB // 数据库连接(延迟初始化) redis *redis.Client // Redis客户端(延迟初始化) + i18n *i18n.I18n // 国际化工具(延迟初始化) } // NewFactory 创建工厂实例 @@ -774,7 +777,10 @@ func (f *Factory) GetMiddlewareChain() *middleware.Chain { middlewares = append(middlewares, middleware.CORS(corsConfig)) } - // 5. Timezone 中间件(必需,处理时区) + // 5. Language 中间件(必需,处理语言) + middlewares = append(middlewares, middleware.Language) + + // 6. Timezone 中间件(必需,处理时区) middlewares = append(middlewares, middleware.Timezone) return middleware.NewChain(middlewares...) @@ -911,10 +917,69 @@ func (f *Factory) SuccessPage(w http.ResponseWriter, list interface{}, total int // Error 错误响应(黑盒模式,推荐使用) // w: ResponseWriter -// code: 业务错误码,非0表示业务错误 -// message: 错误消息 -func (f *Factory) Error(w http.ResponseWriter, code int, message string) { - commonhttp.Error(w, code, message) +// r: HTTP请求(用于获取语言信息和i18n处理) +// code: 业务错误码(如果message是消息代码,此参数会被语言文件中的code覆盖) +// message: 错误消息或消息代码(如果i18n已初始化且message是消息代码格式,会自动获取国际化消息和业务code) +// args: 可选参数,用于格式化消息(类似 fmt.Sprintf,仅在message是消息代码时使用) +// +// 使用逻辑: +// 1. 如果i18n已初始化,且message看起来是消息代码(包含点号,如 "user.not_found"), +// 则从请求context中获取语言,并尝试从语言文件中获取国际化消息和业务code +// 2. 如果获取到国际化消息,使用语言文件中的code作为响应code,使用国际化消息作为响应message +// 3. 如果未获取到或i18n未初始化,使用传入的code和message +// +// 示例: +// +// // 方式1:直接传入消息代码(推荐,自动国际化) +// fac.Error(w, r, 0, "user.not_found") +// // 如果请求语言是 zh-CN,且语言文件中 "user.not_found" 的 code 是 1001, +// // 返回: {"code": 1001, "message": "用户不存在"} +// // 如果请求语言是 en-US,返回: {"code": 1001, "message": "User not found"} +// +// // 方式2:带参数的消息代码 +// fac.Error(w, r, 0, "user.welcome", "Alice") +// // 如果消息内容是 "欢迎,%s",返回: {"code": 0, "message": "欢迎,Alice"} +// +// // 方式3:直接传入消息文本(不使用国际化) +// fac.Error(w, r, 500, "系统错误") +// // 返回: {"code": 500, "message": "系统错误"} +func (f *Factory) Error(w http.ResponseWriter, r *http.Request, code int, message string, args ...interface{}) { + // 判断message是否是消息代码(简单判断:包含点号) + isMessageCode := strings.Contains(message, ".") + + var finalCode int + var finalMessage string + + if isMessageCode { + // 尝试从i18n获取国际化消息和业务code + if i, err := f.getI18n(); err == nil { + // i18n已初始化,获取语言并查找消息 + lang := f.GetLanguage(r) + if lang == "" { + lang = i.GetDefaultLang() + } + msgInfo := i.GetMessageInfo(lang, message, args...) + // 如果获取到了国际化消息(不是返回code本身),使用国际化消息和业务code + if msgInfo.Message != message { + finalCode = msgInfo.Code + finalMessage = msgInfo.Message + } else { + // 消息代码不存在,使用传入的code和消息代码作为消息 + finalCode = code + finalMessage = message + } + } else { + // i18n未初始化,使用传入的code和消息代码作为消息 + finalCode = code + finalMessage = message + } + } else { + // 不是消息代码格式,使用传入的code和消息 + finalCode = code + finalMessage = message + } + + commonhttp.Error(w, finalCode, finalMessage) } // SystemError 系统错误响应(返回HTTP 500)(黑盒模式,推荐使用) @@ -1010,6 +1075,14 @@ func (f *Factory) GetTimezone(r *http.Request) string { return commonhttp.GetTimezone(r) } +// GetLanguage 从请求的context中获取语言(黑盒模式,推荐使用) +// r: HTTP请求 +// 如果使用了middleware.Language中间件,可以从context中获取语言信息 +// 如果未设置,返回默认语言 zh-CN +func (f *Factory) GetLanguage(r *http.Request) string { + return commonhttp.GetLanguage(r) +} + // ========== Tools工具方法(黑盒模式,推荐使用) ========== // // 这些方法直接调用 tools 包的公共方法,保持低耦合。 @@ -1355,3 +1428,128 @@ func (f *Factory) GenerateRefundNo() string { func (f *Factory) GenerateTransferNo() string { return tools.GenerateTransferNo() } + +// ========== I18n 国际化工具(黑盒模式,推荐使用) ========== +// +// 这些方法提供多语言内容管理功能,支持从文件加载语言内容,通过语言代码和消息代码获取对应语言的内容。 + +// getI18n 获取国际化工具实例(内部方法,延迟初始化) +func (f *Factory) getI18n() (*i18n.I18n, error) { + if f.i18n != nil { + return f.i18n, nil + } + + // 如果没有配置,返回错误 + return nil, fmt.Errorf("i18n not initialized, please call InitI18n first") +} + +// InitI18n 初始化国际化工具(黑盒模式,推荐使用) +// defaultLang: 默认语言代码(如 "zh-CN", "en-US") +// 初始化后可以调用 LoadI18nFromDir 或 LoadI18nFromFile 加载语言文件 +// +// 示例: +// +// fac, _ := factory.NewFactoryFromFile("config.json") +// fac.InitI18n("zh-CN") +// fac.LoadI18nFromDir("locales") +func (f *Factory) InitI18n(defaultLang string) { + f.i18n = i18n.NewI18n(defaultLang) +} + +// LoadI18nFromDir 从目录加载多个语言文件(黑盒模式,推荐使用) +// dirPath: 语言文件目录路径 +// 文件命名规则:{语言代码}.json(如 zh-CN.json, en-US.json) +// +// 文件格式示例(zh-CN.json): +// +// { +// "user.not_found": "用户不存在", +// "user.login_success": "登录成功", +// "user.welcome": "欢迎,%s" +// } +// +// 示例: +// +// fac.InitI18n("zh-CN") +// fac.LoadI18nFromDir("locales") +func (f *Factory) LoadI18nFromDir(dirPath string) error { + i, err := f.getI18n() + if err != nil { + return err + } + return i.LoadFromDir(dirPath) +} + +// LoadI18nFromFile 从单个语言文件加载内容(黑盒模式,推荐使用) +// filePath: 语言文件路径(JSON格式) +// lang: 语言代码(如 "zh-CN", "en-US") +// +// 示例: +// +// fac.InitI18n("zh-CN") +// fac.LoadI18nFromFile("locales/zh-CN.json", "zh-CN") +// fac.LoadI18nFromFile("locales/en-US.json", "en-US") +func (f *Factory) LoadI18nFromFile(filePath, lang string) error { + i, err := f.getI18n() + if err != nil { + return err + } + return i.LoadFromFile(filePath, lang) +} + +// GetMessage 获取指定语言和代码的消息内容(黑盒模式,推荐使用) +// lang: 语言代码(如 "zh-CN", "en-US") +// code: 消息代码(如 "user.not_found") +// args: 可选参数,用于格式化消息(类似 fmt.Sprintf) +// +// 返回逻辑: +// 1. 如果指定语言存在该code,返回对应内容 +// 2. 如果指定语言不存在,尝试使用默认语言 +// 3. 如果默认语言也不存在,返回code本身(作为fallback) +// +// 示例: +// +// // 简单消息 +// msg := fac.GetMessage("zh-CN", "user.not_found") +// // 返回: "用户不存在" +// +// // 带参数的消息 +// msg := fac.GetMessage("zh-CN", "user.welcome", "Alice") +// // 如果消息内容是 "欢迎,%s",返回: "欢迎,Alice" +func (f *Factory) GetMessage(lang, code string, args ...interface{}) string { + i, err := f.getI18n() + if err != nil { + // 如果未初始化,返回code本身 + return code + } + return i.GetMessage(lang, code, args...) +} + +// GetI18n 获取国际化工具对象(高级功能时使用) +// 返回已初始化的国际化工具对象 +// +// ℹ️ 推荐使用黑盒方法: +// - GetMessage():获取消息内容 +// - LoadI18nFromDir():加载语言文件目录 +// - LoadI18nFromFile():加载单个语言文件 +// +// 仅在需要使用高级功能时获取对象: +// - HasLang():检查语言是否存在 +// - GetSupportedLangs():获取所有支持的语言 +// - ReloadFromFile():重新加载语言文件 +// - SetDefaultLang():动态设置默认语言 +// +// 示例(常用操作,推荐): +// +// fac.InitI18n("zh-CN") +// fac.LoadI18nFromDir("locales") +// msg := fac.GetMessage("zh-CN", "user.not_found") +// +// 示例(高级功能): +// +// i18n, _ := fac.GetI18n() +// langs := i18n.GetSupportedLangs() +// hasLang := i18n.HasLang("en-US") +func (f *Factory) GetI18n() (*i18n.I18n, error) { + return f.getI18n() +} diff --git a/http/request.go b/http/request.go index 1b77816..293327e 100644 --- a/http/request.go +++ b/http/request.go @@ -104,3 +104,11 @@ func ParseJSON(r *http.Request, v interface{}) error { func GetTimezone(r *http.Request) string { return middleware.GetTimezoneFromContext(r.Context()) } + +// GetLanguage 从请求的context中获取语言(公共方法) +// r: HTTP请求 +// 如果使用了middleware.Language中间件,可以从context中获取语言信息 +// 如果未设置,返回默认语言 zh-CN +func GetLanguage(r *http.Request) string { + return middleware.GetLanguageFromContext(r.Context()) +} diff --git a/i18n/i18n.go b/i18n/i18n.go new file mode 100644 index 0000000..152a373 --- /dev/null +++ b/i18n/i18n.go @@ -0,0 +1,286 @@ +package i18n + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// MessageInfo 消息信息结构 +// 包含业务错误码和消息内容 +type MessageInfo struct { + Code int `json:"code"` // 业务错误码 + Message string `json:"message"` // 消息内容 +} + +// I18n 国际化工具 +// 支持多语言内容管理,通过语言代码和消息代码获取对应语言的内容 +type I18n struct { + messages map[string]map[string]MessageInfo // 存储格式:messages[语言][code] = MessageInfo + defaultLang string // 默认语言代码 + mu sync.RWMutex // 读写锁,保证并发安全 +} + +// NewI18n 创建国际化工具实例 +// defaultLang: 默认语言代码(如 "zh-CN", "en-US"),当指定语言不存在时使用 +func NewI18n(defaultLang string) *I18n { + return &I18n{ + messages: make(map[string]map[string]MessageInfo), + defaultLang: defaultLang, + } +} + +// LoadFromFile 从单个语言文件加载内容 +// filePath: 语言文件路径(JSON格式) +// lang: 语言代码(如 "zh-CN", "en-US") +// +// 文件格式示例(zh-CN.json): +// +// { +// "user.not_found": { +// "code": 1001, +// "message": "用户不存在" +// }, +// "user.login_success": { +// "code": 0, +// "message": "登录成功" +// }, +// "user.welcome": { +// "code": 0, +// "message": "欢迎,%s" +// } +// } +func (i *I18n) LoadFromFile(filePath, lang string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", filePath, err) + } + + var messages map[string]MessageInfo + if err := json.Unmarshal(data, &messages); err != nil { + return fmt.Errorf("failed to parse JSON file %s: %w", filePath, err) + } + + i.mu.Lock() + defer i.mu.Unlock() + + if i.messages[lang] == nil { + i.messages[lang] = make(map[string]MessageInfo) + } + + // 合并到现有消息中(如果key已存在会被覆盖) + for k, v := range messages { + i.messages[lang][k] = v + } + + return nil +} + +// LoadFromDir 从目录加载多个语言文件 +// dirPath: 语言文件目录路径 +// 文件命名规则:{语言代码}.json(如 zh-CN.json, en-US.json) +// +// 示例目录结构: +// +// locales/ +// zh-CN.json +// en-US.json +// ja-JP.json +func (i *I18n) LoadFromDir(dirPath string) error { + entries, err := os.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("failed to read directory %s: %w", dirPath, err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + // 只处理 .json 文件 + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + // 从文件名提取语言代码(去掉 .json 后缀) + lang := strings.TrimSuffix(entry.Name(), ".json") + filePath := filepath.Join(dirPath, entry.Name()) + + if err := i.LoadFromFile(filePath, lang); err != nil { + return fmt.Errorf("failed to load language file %s: %w", filePath, err) + } + } + + return nil +} + +// LoadFromMap 从map加载语言内容(用于测试或动态加载) +// lang: 语言代码 +// messages: 消息map,key为消息代码,value为消息信息 +// +// 示例: +// +// i18n.LoadFromMap("zh-CN", map[string]MessageInfo{ +// "user.not_found": {Code: 1001, Message: "用户不存在"}, +// "user.login_success": {Code: 0, Message: "登录成功"}, +// }) +func (i *I18n) LoadFromMap(lang string, messages map[string]MessageInfo) { + i.mu.Lock() + defer i.mu.Unlock() + + if i.messages[lang] == nil { + i.messages[lang] = make(map[string]MessageInfo) + } + + // 合并到现有消息中 + for k, v := range messages { + i.messages[lang][k] = v + } +} + +// GetMessage 获取指定语言和代码的消息内容 +// lang: 语言代码(如 "zh-CN", "en-US") +// code: 消息代码(如 "user.not_found") +// args: 可选参数,用于格式化消息(类似 fmt.Sprintf) +// +// 返回逻辑: +// 1. 如果指定语言存在该code,返回对应内容 +// 2. 如果指定语言不存在,尝试使用默认语言 +// 3. 如果默认语言也不存在,返回code本身(作为fallback) +// +// 示例: +// +// msg := i18n.GetMessage("zh-CN", "user.not_found") +// // 返回: "用户不存在" +// +// msg := i18n.GetMessage("zh-CN", "user.welcome", "Alice") +// // 如果消息内容是 "欢迎,%s",返回: "欢迎,Alice" +func (i *I18n) GetMessage(lang, code string, args ...interface{}) string { + info := i.GetMessageInfo(lang, code, args...) + return info.Message +} + +// GetMessageInfo 获取指定语言和代码的完整消息信息(包含业务code) +// lang: 语言代码(如 "zh-CN", "en-US") +// code: 消息代码(如 "user.not_found") +// args: 可选参数,用于格式化消息(类似 fmt.Sprintf) +// +// 返回逻辑: +// 1. 如果指定语言存在该code,返回对应的MessageInfo +// 2. 如果指定语言不存在,尝试使用默认语言 +// 3. 如果默认语言也不存在,返回code本身作为message,code为0 +// +// 示例: +// +// info := i18n.GetMessageInfo("zh-CN", "user.not_found") +// // 返回: MessageInfo{Code: 1001, Message: "用户不存在"} +func (i *I18n) GetMessageInfo(lang, code string, args ...interface{}) MessageInfo { + i.mu.RLock() + defer i.mu.RUnlock() + + // 尝试从指定语言获取 + if messages, ok := i.messages[lang]; ok { + if msgInfo, ok := messages[code]; ok { + // 格式化消息内容 + msgInfo.Message = i.formatMessage(msgInfo.Message, args...) + return msgInfo + } + } + + // 如果指定语言不存在该code,尝试使用默认语言 + if i.defaultLang != "" && i.defaultLang != lang { + if messages, ok := i.messages[i.defaultLang]; ok { + if msgInfo, ok := messages[code]; ok { + // 格式化消息内容 + msgInfo.Message = i.formatMessage(msgInfo.Message, args...) + return msgInfo + } + } + } + + // 如果都不存在,返回code本身作为message,code为0(作为fallback) + return MessageInfo{ + Code: 0, + Message: code, + } +} + +// formatMessage 格式化消息(支持参数替换) +// 如果消息中包含 %s, %d 等格式化占位符,使用 args 进行替换 +func (i *I18n) formatMessage(msg string, args ...interface{}) string { + if len(args) == 0 { + return msg + } + + // 检查消息中是否包含格式化占位符 + if strings.Contains(msg, "%") { + return fmt.Sprintf(msg, args...) + } + + return msg +} + +// SetDefaultLang 设置默认语言 +// lang: 默认语言代码 +func (i *I18n) SetDefaultLang(lang string) { + i.mu.Lock() + defer i.mu.Unlock() + i.defaultLang = lang +} + +// GetDefaultLang 获取默认语言代码 +func (i *I18n) GetDefaultLang() string { + i.mu.RLock() + defer i.mu.RUnlock() + return i.defaultLang +} + +// HasLang 检查是否已加载指定语言 +// lang: 语言代码 +func (i *I18n) HasLang(lang string) bool { + i.mu.RLock() + defer i.mu.RUnlock() + _, ok := i.messages[lang] + return ok +} + +// GetSupportedLangs 获取所有已加载的语言代码列表 +func (i *I18n) GetSupportedLangs() []string { + i.mu.RLock() + defer i.mu.RUnlock() + + langs := make([]string, 0, len(i.messages)) + for lang := range i.messages { + langs = append(langs, lang) + } + + return langs +} + +// ReloadFromFile 重新加载指定语言文件 +// filePath: 语言文件路径 +// lang: 语言代码 +func (i *I18n) ReloadFromFile(filePath, lang string) error { + // 先清除该语言的所有消息 + i.mu.Lock() + delete(i.messages, lang) + i.mu.Unlock() + + // 重新加载 + return i.LoadFromFile(filePath, lang) +} + +// ReloadFromDir 重新加载目录中的所有语言文件 +// dirPath: 语言文件目录路径 +func (i *I18n) ReloadFromDir(dirPath string) error { + // 先清除所有消息 + i.mu.Lock() + i.messages = make(map[string]map[string]MessageInfo) + i.mu.Unlock() + + // 重新加载 + return i.LoadFromDir(dirPath) +} diff --git a/middleware/language.go b/middleware/language.go new file mode 100644 index 0000000..6c1fa34 --- /dev/null +++ b/middleware/language.go @@ -0,0 +1,102 @@ +package middleware + +import ( + "context" + "net/http" + "strings" +) + +// LanguageKey context中存储语言的key +type languageKey struct{} + +// LanguageHeaderName 语言请求头名称 +const LanguageHeaderName = "X-Language" + +// AcceptLanguageHeaderName Accept-Language 请求头名称 +const AcceptLanguageHeaderName = "Accept-Language" + +// DefaultLanguage 默认语言 +const DefaultLanguage = "zh-CN" + +// GetLanguageFromContext 从context中获取语言 +func GetLanguageFromContext(ctx context.Context) string { + if lang, ok := ctx.Value(languageKey{}).(string); ok && lang != "" { + return lang + } + return DefaultLanguage +} + +// Language 语言处理中间件 +// 从请求头 X-Language 或 Accept-Language 读取语言信息,如果未传递则使用默认语言 zh-CN +// 语言信息会存储到context中,可以通过 GetLanguageFromContext 获取 +func Language(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 1. 优先从 X-Language 请求头获取(显式指定) + lang := r.Header.Get(LanguageHeaderName) + + // 2. 如果未设置,从 Accept-Language 请求头解析 + if lang == "" { + acceptLang := r.Header.Get(AcceptLanguageHeaderName) + if acceptLang != "" { + lang = parseAcceptLanguage(acceptLang) + } + } + + // 3. 如果都未设置,使用默认语言 + if lang == "" { + lang = DefaultLanguage + } + + // 将语言存储到context中 + ctx := context.WithValue(r.Context(), languageKey{}, lang) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// LanguageWithDefault 语言处理中间件(可自定义默认语言) +// defaultLanguage: 默认语言,如果未指定则使用 zh-CN +func LanguageWithDefault(defaultLanguage string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 1. 优先从 X-Language 请求头获取(显式指定) + lang := r.Header.Get(LanguageHeaderName) + + // 2. 如果未设置,从 Accept-Language 请求头解析 + if lang == "" { + acceptLang := r.Header.Get(AcceptLanguageHeaderName) + if acceptLang != "" { + lang = parseAcceptLanguage(acceptLang) + } + } + + // 3. 如果都未设置,使用指定的默认语言 + if lang == "" { + lang = defaultLanguage + } + + // 将语言存储到context中 + ctx := context.WithValue(r.Context(), languageKey{}, lang) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// parseAcceptLanguage 解析 Accept-Language 请求头 +// 返回第一个语言代码(去掉权重信息) +func parseAcceptLanguage(acceptLang string) string { + if acceptLang == "" { + return "" + } + + // 分割语言列表 + parts := strings.Split(acceptLang, ",") + if len(parts) == 0 { + return "" + } + + // 取第一个语言代码,去掉权重信息(如 ";q=0.9") + firstLang := strings.Split(parts[0], ";")[0] + firstLang = strings.TrimSpace(firstLang) + + return firstLang +}