Files
new-api/i18n/i18n.go
CaIon 67613e0642 fix(i18n): prioritize user settings over Accept-Language header
The i18n middleware runs before UserAuth, so user settings weren't
available when language was detected. Now GetLangFromContext checks
user settings first (set by UserAuth) before falling back to the
language set by middleware or Accept-Language header.
2026-02-05 00:37:18 +08:00

228 lines
5.7 KiB
Go

package i18n
import (
"embed"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
"gopkg.in/yaml.v3"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
)
const (
LangZh = "zh"
LangEn = "en"
DefaultLang = LangEn // Fallback to English if language not supported
)
//go:embed locales/*.yaml
var localeFS embed.FS
var (
bundle *i18n.Bundle
localizers = make(map[string]*i18n.Localizer)
mu sync.RWMutex
initOnce sync.Once
)
// Init initializes the i18n bundle and loads all translation files
func Init() error {
var initErr error
initOnce.Do(func() {
bundle = i18n.NewBundle(language.Chinese)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
// Load embedded translation files
files := []string{"locales/zh.yaml", "locales/en.yaml"}
for _, file := range files {
_, err := bundle.LoadMessageFileFS(localeFS, file)
if err != nil {
initErr = err
return
}
}
// Pre-create localizers for supported languages
localizers[LangZh] = i18n.NewLocalizer(bundle, LangZh)
localizers[LangEn] = i18n.NewLocalizer(bundle, LangEn)
// Set the TranslateMessage function in common package
common.TranslateMessage = T
})
return initErr
}
// GetLocalizer returns a localizer for the specified language
func GetLocalizer(lang string) *i18n.Localizer {
lang = normalizeLang(lang)
mu.RLock()
loc, ok := localizers[lang]
mu.RUnlock()
if ok {
return loc
}
// Create new localizer for unknown language (fallback to default)
mu.Lock()
defer mu.Unlock()
// Double-check after acquiring write lock
if loc, ok = localizers[lang]; ok {
return loc
}
loc = i18n.NewLocalizer(bundle, lang, DefaultLang)
localizers[lang] = loc
return loc
}
// T translates a message key using the language from gin context
func T(c *gin.Context, key string, args ...map[string]any) string {
lang := GetLangFromContext(c)
return Translate(lang, key, args...)
}
// Translate translates a message key for the specified language
func Translate(lang, key string, args ...map[string]any) string {
loc := GetLocalizer(lang)
config := &i18n.LocalizeConfig{
MessageID: key,
}
if len(args) > 0 && args[0] != nil {
config.TemplateData = args[0]
}
msg, err := loc.Localize(config)
if err != nil {
// Return key as fallback if translation not found
return key
}
return msg
}
// userLangLoaderFunc is a function that loads user language from database/cache
// It's set by the model package to avoid circular imports
var userLangLoaderFunc func(userId int) string
// SetUserLangLoader sets the function to load user language (called from model package)
func SetUserLangLoader(loader func(userId int) string) {
userLangLoaderFunc = loader
}
// GetLangFromContext extracts the language setting from gin context
// It checks multiple sources in priority order:
// 1. User settings (ContextKeyUserSetting) - if already loaded (e.g., by TokenAuth)
// 2. Lazy load user language from cache/DB using user ID
// 3. Language set by middleware (ContextKeyLanguage) - from Accept-Language header
// 4. Default language (English)
func GetLangFromContext(c *gin.Context) string {
if c == nil {
return DefaultLang
}
// 1. Try to get language from user settings (if already loaded by TokenAuth or other middleware)
if userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting); ok {
if userSetting.Language != "" {
normalized := normalizeLang(userSetting.Language)
if IsSupported(normalized) {
return normalized
}
}
}
// 2. Lazy load user language using user ID (for session-based auth where full settings aren't loaded)
if userLangLoaderFunc != nil {
if userId, exists := c.Get("id"); exists {
if uid, ok := userId.(int); ok && uid > 0 {
lang := userLangLoaderFunc(uid)
if lang != "" {
normalized := normalizeLang(lang)
if IsSupported(normalized) {
return normalized
}
}
}
}
}
// 3. Try to get language from context (set by I18n middleware from Accept-Language)
if lang := c.GetString(string(constant.ContextKeyLanguage)); lang != "" {
normalized := normalizeLang(lang)
if IsSupported(normalized) {
return normalized
}
}
// 4. Try Accept-Language header directly (fallback if middleware didn't run)
if acceptLang := c.GetHeader("Accept-Language"); acceptLang != "" {
lang := ParseAcceptLanguage(acceptLang)
if IsSupported(lang) {
return lang
}
}
return DefaultLang
}
// ParseAcceptLanguage parses the Accept-Language header and returns the preferred language
func ParseAcceptLanguage(header string) string {
if header == "" {
return DefaultLang
}
// Simple parsing: take the first language tag
parts := strings.Split(header, ",")
if len(parts) == 0 {
return DefaultLang
}
// Get the first language and remove quality value
firstLang := strings.TrimSpace(parts[0])
if idx := strings.Index(firstLang, ";"); idx > 0 {
firstLang = firstLang[:idx]
}
return normalizeLang(firstLang)
}
// normalizeLang normalizes language code to supported format
func normalizeLang(lang string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
// Handle common variations
switch {
case strings.HasPrefix(lang, "zh"):
return LangZh
case strings.HasPrefix(lang, "en"):
return LangEn
default:
return DefaultLang
}
}
// SupportedLanguages returns a list of supported language codes
func SupportedLanguages() []string {
return []string{LangZh, LangEn}
}
// IsSupported checks if a language code is supported
func IsSupported(lang string) bool {
lang = normalizeLang(lang)
for _, supported := range SupportedLanguages() {
if lang == supported {
return true
}
}
return false
}