diff --git a/common/gin.go b/common/gin.go index 1a8a2b31c..e40279723 100644 --- a/common/gin.go +++ b/common/gin.go @@ -218,6 +218,39 @@ func ApiSuccess(c *gin.Context, data any) { }) } +// ApiErrorI18n returns a translated error message based on the user's language preference +// key is the i18n message key, args is optional template data +func ApiErrorI18n(c *gin.Context, key string, args ...map[string]any) { + msg := TranslateMessage(c, key, args...) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": msg, + }) +} + +// ApiSuccessI18n returns a translated success message based on the user's language preference +func ApiSuccessI18n(c *gin.Context, key string, data any, args ...map[string]any) { + msg := TranslateMessage(c, key, args...) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": msg, + "data": data, + }) +} + +// TranslateMessage is a helper function that calls i18n.T +// This function is defined here to avoid circular imports +// The actual implementation will be set during init +var TranslateMessage func(c *gin.Context, key string, args ...map[string]any) string + +func init() { + // Default implementation that returns the key as-is + // This will be replaced by i18n.T during i18n initialization + TranslateMessage = func(c *gin.Context, key string, args ...map[string]any) string { + return key + } +} + func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) { requestBody, err := GetRequestBody(c) if err != nil { diff --git a/constant/context_key.go b/constant/context_key.go index 93a553c7a..2ba2fe274 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -62,4 +62,7 @@ const ( // ContextKeyAdminRejectReason stores an admin-only reject/block reason extracted from upstream responses. // It is not returned to end users, but can be persisted into consume/error logs for debugging. ContextKeyAdminRejectReason ContextKey = "admin_reject_reason" + + // ContextKeyLanguage stores the user's language preference for i18n + ContextKeyLanguage ContextKey = "language" ) diff --git a/controller/redemption.go b/controller/redemption.go index 33c17346c..76c35bc32 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -1,12 +1,12 @@ package controller import ( - "errors" "net/http" "strconv" "unicode/utf8" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" @@ -66,28 +66,19 @@ func AddRedemption(c *gin.Context) { return } if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "兑换码名称长度必须在1-20之间", - }) + common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength) return } if redemption.Count <= 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "兑换码个数必须大于0", - }) + common.ApiErrorI18n(c, i18n.MsgRedemptionCountPositive) return } if redemption.Count > 100 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "一次兑换码批量生成的个数不能大于 100", - }) + common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax) return } - if err := validateExpiredTime(redemption.ExpiredTime); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid { + c.JSON(http.StatusOK, gin.H{"success": false, "message": msg}) return } var keys []string @@ -106,7 +97,7 @@ func AddRedemption(c *gin.Context) { common.SysError("failed to insert redemption: " + err.Error()) c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "创建兑换码失败,请稍后重试", + "message": i18n.T(c, i18n.MsgRedemptionCreateFailed), "data": keys, }) return @@ -149,8 +140,8 @@ func UpdateRedemption(c *gin.Context) { return } if statusOnly == "" { - if err := validateExpiredTime(redemption.ExpiredTime); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid { + c.JSON(http.StatusOK, gin.H{"success": false, "message": msg}) return } // If you add more fields, please also update redemption.Update() @@ -188,9 +179,9 @@ func DeleteInvalidRedemption(c *gin.Context) { return } -func validateExpiredTime(expired int64) error { +func validateExpiredTime(c *gin.Context, expired int64) (bool, string) { if expired != 0 && expired < common.GetTimestamp() { - return errors.New("过期时间不能早于当前时间") + return false, i18n.T(c, i18n.MsgRedemptionExpireTimeInvalid) } - return nil + return true, "" } diff --git a/controller/token.go b/controller/token.go index b683b730f..d2d095a04 100644 --- a/controller/token.go +++ b/controller/token.go @@ -1,12 +1,12 @@ package controller import ( - "fmt" "net/http" "strconv" "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" @@ -108,10 +108,7 @@ func GetTokenUsage(c *gin.Context) { token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false) if err != nil { common.SysError("failed to get token by key: " + err.Error()) - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "获取令牌信息失败,请稍后重试", - }) + common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed) return } @@ -145,36 +142,24 @@ func AddToken(c *gin.Context) { return } if len(token.Name) > 50 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "令牌名称过长", - }) + common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return } // 非无限额度时,检查额度值是否超出有效范围 if !token.UnlimitedQuota { if token.RemainQuota < 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "额度值不能为负数", - }) + common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative) return } maxQuotaValue := int((1000000000 * common.QuotaPerUnit)) if token.RemainQuota > maxQuotaValue { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue), - }) + common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue}) return } } key, err := common.GenerateKey() if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "生成令牌失败", - }) + common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed) common.SysLog("failed to generate token key: " + err.Error()) return } @@ -230,26 +215,17 @@ func UpdateToken(c *gin.Context) { return } if len(token.Name) > 50 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "令牌名称过长", - }) + common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return } if !token.UnlimitedQuota { if token.RemainQuota < 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "额度值不能为负数", - }) + common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative) return } maxQuotaValue := int((1000000000 * common.QuotaPerUnit)) if token.RemainQuota > maxQuotaValue { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue), - }) + common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue}) return } } @@ -260,17 +236,11 @@ func UpdateToken(c *gin.Context) { } if token.Status == common.TokenStatusEnabled { if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期", - }) + common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable) return } if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度", - }) + common.ApiErrorI18n(c, i18n.MsgTokenExhaustedCannotEable) return } } @@ -307,10 +277,7 @@ type TokenBatch struct { func DeleteTokenBatch(c *gin.Context) { tokenBatch := TokenBatch{} if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "参数错误", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } userId := c.GetInt("id") diff --git a/controller/user.go b/controller/user.go index 1fc83c99e..db078071e 100644 --- a/controller/user.go +++ b/controller/user.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -11,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" @@ -29,28 +31,19 @@ type LoginRequest struct { func Login(c *gin.Context) { if !common.PasswordLoginEnabled { - c.JSON(http.StatusOK, gin.H{ - "message": "管理员关闭了密码登录", - "success": false, - }) + common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled) return } var loginRequest LoginRequest err := json.NewDecoder(c.Request.Body).Decode(&loginRequest) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "message": "无效的参数", - "success": false, - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } username := loginRequest.Username password := loginRequest.Password if username == "" || password == "" { - c.JSON(http.StatusOK, gin.H{ - "message": "无效的参数", - "success": false, - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } user := model.User{ @@ -74,15 +67,12 @@ func Login(c *gin.Context) { session.Set("pending_user_id", user.Id) err := session.Save() if err != nil { - c.JSON(http.StatusOK, gin.H{ - "message": "无法保存会话信息,请重试", - "success": false, - }) + common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) return } c.JSON(http.StatusOK, gin.H{ - "message": "请输入两步验证码", + "message": i18n.T(c, i18n.MsgUserRequire2FA), "success": true, "data": map[string]interface{}{ "require_2fa": true, @@ -104,10 +94,7 @@ func setupLogin(user *model.User, c *gin.Context) { session.Set("group", user.Group) err := session.Save() if err != nil { - c.JSON(http.StatusOK, gin.H{ - "message": "无法保存会话信息,请重试", - "success": false, - }) + common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) return } c.JSON(http.StatusOK, gin.H{ @@ -143,65 +130,41 @@ func Logout(c *gin.Context) { func Register(c *gin.Context) { if !common.RegisterEnabled { - c.JSON(http.StatusOK, gin.H{ - "message": "管理员关闭了新用户注册", - "success": false, - }) + common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled) return } if !common.PasswordRegisterEnabled { - c.JSON(http.StatusOK, gin.H{ - "message": "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册", - "success": false, - }) + common.ApiErrorI18n(c, i18n.MsgUserPasswordRegisterDisabled) return } var user model.User err := json.NewDecoder(c.Request.Body).Decode(&user) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } if err := common.Validate.Struct(&user); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "输入不合法 " + err.Error(), - }) + common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()}) return } if common.EmailVerificationEnabled { if user.Email == "" || user.VerificationCode == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "管理员开启了邮箱验证,请输入邮箱地址和验证码", - }) + common.ApiErrorI18n(c, i18n.MsgUserEmailVerificationRequired) return } if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "验证码错误或已过期", - }) + common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError) return } } exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "数据库错误,请稍后重试", - }) + common.ApiErrorI18n(c, i18n.MsgDatabaseError) common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err)) return } if exist { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户名已存在,或已注销", - }) + common.ApiErrorI18n(c, i18n.MsgUserExists) return } affCode := user.AffCode // this code is the inviter's code, not the user's own code @@ -224,20 +187,14 @@ func Register(c *gin.Context) { // 获取插入后的用户ID var insertedUser model.User if err := model.DB.Where("username = ?", cleanUser.Username).First(&insertedUser).Error; err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户注册失败或用户ID获取失败", - }) + common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed) return } // 生成默认令牌 if constant.GenerateDefaultToken { key, err := common.GenerateKey() if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "生成默认令牌失败", - }) + common.ApiErrorI18n(c, i18n.MsgUserDefaultTokenFailed) common.SysLog("failed to generate token key: " + err.Error()) return } @@ -257,10 +214,7 @@ func Register(c *gin.Context) { token.Group = "auto" } if err := token.Insert(); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "创建默认令牌失败", - }) + common.ApiErrorI18n(c, i18n.MsgCreateDefaultTokenErr) return } } @@ -316,10 +270,7 @@ func GetUser(c *gin.Context) { } myRole := c.GetInt("role") if myRole <= user.Role && myRole != common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无权获取同级或更高等级用户的信息", - }) + common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel) return } c.JSON(http.StatusOK, gin.H{ @@ -341,20 +292,14 @@ func GenerateAccessToken(c *gin.Context) { randI := common.GetRandomInt(4) key, err := common.GenerateRandomKey(29 + randI) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "生成失败", - }) + common.ApiErrorI18n(c, i18n.MsgGenerateFailed) common.SysLog("failed to generate key: " + err.Error()) return } user.SetAccessToken(key) if model.DB.Where("access_token = ?", user.AccessToken).First(user).RowsAffected != 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "请重试,系统生成的 UUID 竟然重复了!", - }) + common.ApiErrorI18n(c, i18n.MsgUuidDuplicate) return } @@ -389,16 +334,10 @@ func TransferAffQuota(c *gin.Context) { } err = user.TransferAffQuotaToQuota(tran.Quota) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "划转失败 " + err.Error(), - }) + common.ApiErrorI18n(c, i18n.MsgUserTransferFailed, map[string]any{"Error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "划转成功", - }) + common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil) } func GetAffCode(c *gin.Context) { @@ -601,20 +540,14 @@ func UpdateUser(c *gin.Context) { var updatedUser model.User err := json.NewDecoder(c.Request.Body).Decode(&updatedUser) if err != nil || updatedUser.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } if updatedUser.Password == "" { updatedUser.Password = "$I_LOVE_U" // make Validator happy :) } if err := common.Validate.Struct(&updatedUser); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "输入不合法 " + err.Error(), - }) + common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()}) return } originUser, err := model.GetUserById(updatedUser.Id, false) @@ -624,17 +557,11 @@ func UpdateUser(c *gin.Context) { } myRole := c.GetInt("role") if myRole <= originUser.Role && myRole != common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无权更新同权限等级或更高权限等级的用户信息", - }) + common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } if myRole <= updatedUser.Role && myRole != common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无权将其他用户权限等级提升到大于等于自己的权限等级", - }) + common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel) return } if updatedUser.Password == "$I_LOVE_U" { @@ -659,15 +586,12 @@ func UpdateSelf(c *gin.Context) { var requestData map[string]interface{} err := json.NewDecoder(c.Request.Body).Decode(&requestData) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } - // 检查是否是sidebar_modules更新请求 - if sidebarModules, exists := requestData["sidebar_modules"]; exists { + // 检查是否是用户设置更新请求 (sidebar_modules 或 language) + if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists { userId := c.GetInt("id") user, err := model.GetUserById(userId, false) if err != nil { @@ -686,17 +610,39 @@ func UpdateSelf(c *gin.Context) { // 保存更新后的设置 user.SetSetting(currentSetting) if err := user.Update(false); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "更新设置失败: " + err.Error(), - }) + common.ApiErrorI18n(c, i18n.MsgUpdateFailed) return } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "设置更新成功", - }) + common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil) + return + } + + // 检查是否是语言偏好更新请求 + if language, langExists := requestData["language"]; langExists { + userId := c.GetInt("id") + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + // 获取当前用户设置 + currentSetting := user.GetSetting() + + // 更新language字段 + if langStr, ok := language.(string); ok { + currentSetting.Language = langStr + } + + // 保存更新后的设置 + user.SetSetting(currentSetting) + if err := user.Update(false); err != nil { + common.ApiErrorI18n(c, i18n.MsgUpdateFailed) + return + } + + common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil) return } @@ -704,18 +650,12 @@ func UpdateSelf(c *gin.Context) { var user model.User requestDataBytes, err := json.Marshal(requestData) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } err = json.Unmarshal(requestDataBytes, &user) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } @@ -723,10 +663,7 @@ func UpdateSelf(c *gin.Context) { user.Password = "$I_LOVE_U" // make Validator happy :) } if err := common.Validate.Struct(&user); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "输入不合法 " + err.Error(), - }) + common.ApiErrorI18n(c, i18n.MsgInvalidInput) return } @@ -790,10 +727,7 @@ func DeleteUser(c *gin.Context) { } myRole := c.GetInt("role") if myRole <= originUser.Role { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无权删除同权限等级或更高权限等级的用户", - }) + common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } err = model.HardDeleteUserById(id) @@ -811,10 +745,7 @@ func DeleteSelf(c *gin.Context) { user, _ := model.GetUserById(id, false) if user.Role == common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "不能删除超级管理员账户", - }) + common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser) return } @@ -835,17 +766,11 @@ func CreateUser(c *gin.Context) { err := json.NewDecoder(c.Request.Body).Decode(&user) user.Username = strings.TrimSpace(user.Username) if err != nil || user.Username == "" || user.Password == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } if err := common.Validate.Struct(&user); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "输入不合法 " + err.Error(), - }) + common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()}) return } if user.DisplayName == "" { @@ -853,10 +778,7 @@ func CreateUser(c *gin.Context) { } myRole := c.GetInt("role") if user.Role >= myRole { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无法创建权限大于等于自己的用户", - }) + common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel) return } // Even for admin users, we cannot fully trust them! @@ -889,10 +811,7 @@ func ManageUser(c *gin.Context) { err := json.NewDecoder(c.Request.Body).Decode(&req) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } user := model.User{ @@ -901,38 +820,26 @@ func ManageUser(c *gin.Context) { // Fill attributes model.DB.Unscoped().Where(&user).First(&user) if user.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户不存在", - }) + common.ApiErrorI18n(c, i18n.MsgUserNotExists) return } myRole := c.GetInt("role") if myRole <= user.Role && myRole != common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无权更新同权限等级或更高权限等级的用户信息", - }) + common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } switch req.Action { case "disable": user.Status = common.UserStatusDisabled if user.Role == common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无法禁用超级管理员用户", - }) + common.ApiErrorI18n(c, i18n.MsgUserCannotDisableRootUser) return } case "enable": user.Status = common.UserStatusEnabled case "delete": if user.Role == common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无法删除超级管理员用户", - }) + common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser) return } if err := user.Delete(); err != nil { @@ -944,33 +851,21 @@ func ManageUser(c *gin.Context) { } case "promote": if myRole != common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "普通管理员用户无法提升其他用户为管理员", - }) + common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote) return } if user.Role >= common.RoleAdminUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "该用户已经是管理员", - }) + common.ApiErrorI18n(c, i18n.MsgUserAlreadyAdmin) return } user.Role = common.RoleAdminUser case "demote": if user.Role == common.RoleRootUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无法降级超级管理员用户", - }) + common.ApiErrorI18n(c, i18n.MsgUserCannotDemoteRootUser) return } if user.Role == common.RoleCommonUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "该用户已经是普通用户", - }) + common.ApiErrorI18n(c, i18n.MsgUserAlreadyCommon) return } user.Role = common.RoleCommonUser @@ -996,10 +891,7 @@ func EmailBind(c *gin.Context) { email := c.Query("email") code := c.Query("code") if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "验证码错误或已过期", - }) + common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError) return } session := sessions.Default(c) @@ -1075,10 +967,7 @@ func TopUp(c *gin.Context) { id := c.GetInt("id") lock := getTopUpLock(id) if !lock.TryLock() { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "充值处理中,请稍后重试", - }) + common.ApiErrorI18n(c, i18n.MsgUserTopUpProcessing) return } defer lock.Unlock() @@ -1090,6 +979,10 @@ func TopUp(c *gin.Context) { } quota, err := model.Redeem(req.Key, id) if err != nil { + if errors.Is(err, model.ErrRedeemFailed) { + common.ApiErrorI18n(c, i18n.MsgRedeemFailed) + return + } common.ApiError(c, err) return } @@ -1117,46 +1010,31 @@ type UpdateUserSettingRequest struct { func UpdateUserSetting(c *gin.Context) { var req UpdateUserSettingRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } // 验证预警类型 if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的预警类型", - }) + common.ApiErrorI18n(c, i18n.MsgSettingInvalidType) return } // 验证预警阈值 if req.QuotaWarningThreshold <= 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "预警阈值必须大于0", - }) + common.ApiErrorI18n(c, i18n.MsgQuotaThresholdGtZero) return } // 如果是webhook类型,验证webhook地址 if req.QuotaWarningType == dto.NotifyTypeWebhook { if req.WebhookUrl == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "Webhook地址不能为空", - }) + common.ApiErrorI18n(c, i18n.MsgSettingWebhookEmpty) return } // 验证URL格式 if _, err := url.ParseRequestURI(req.WebhookUrl); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的Webhook地址", - }) + common.ApiErrorI18n(c, i18n.MsgSettingWebhookInvalid) return } } @@ -1165,10 +1043,7 @@ func UpdateUserSetting(c *gin.Context) { if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" { // 验证邮箱格式 if !strings.Contains(req.NotificationEmail, "@") { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的邮箱地址", - }) + common.ApiErrorI18n(c, i18n.MsgSettingEmailInvalid) return } } @@ -1176,26 +1051,17 @@ func UpdateUserSetting(c *gin.Context) { // 如果是Bark类型,验证Bark URL if req.QuotaWarningType == dto.NotifyTypeBark { if req.BarkUrl == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "Bark推送URL不能为空", - }) + common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlEmpty) return } // 验证URL格式 if _, err := url.ParseRequestURI(req.BarkUrl); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的Bark推送URL", - }) + common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlInvalid) return } // 检查是否是HTTP或HTTPS if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "Bark推送URL必须以http://或https://开头", - }) + common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp) return } } @@ -1203,33 +1069,21 @@ func UpdateUserSetting(c *gin.Context) { // 如果是Gotify类型,验证Gotify URL和Token if req.QuotaWarningType == dto.NotifyTypeGotify { if req.GotifyUrl == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "Gotify服务器地址不能为空", - }) + common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlEmpty) return } if req.GotifyToken == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "Gotify令牌不能为空", - }) + common.ApiErrorI18n(c, i18n.MsgSettingGotifyTokenEmpty) return } // 验证URL格式 if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的Gotify服务器地址", - }) + common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlInvalid) return } // 检查是否是HTTP或HTTPS if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "Gotify服务器地址必须以http://或https://开头", - }) + common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp) return } } @@ -1282,15 +1136,9 @@ func UpdateUserSetting(c *gin.Context) { // 更新用户设置 user.SetSetting(settings) if err := user.Update(false); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "更新设置失败: " + err.Error(), - }) + common.ApiErrorI18n(c, i18n.MsgUpdateFailed) return } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "设置已更新", - }) + common.ApiSuccessI18n(c, i18n.MsgSettingSaved, nil) } diff --git a/dto/user_settings.go b/dto/user_settings.go index aba6ba511..48411c86d 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -14,6 +14,7 @@ type UserSetting struct { RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包) + Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en) } var ( diff --git a/go.mod b/go.mod index 0ea30e998..cf5fbfd34 100644 --- a/go.mod +++ b/go.mod @@ -32,8 +32,10 @@ require ( github.com/jinzhu/copier v0.4.0 github.com/joho/godotenv v1.5.1 github.com/mewkiz/flac v1.0.13 + github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 + github.com/samber/hot v0.11.0 github.com/samber/lo v1.52.0 github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.4.0 @@ -48,7 +50,10 @@ require ( golang.org/x/crypto v0.45.0 golang.org/x/image v0.23.0 golang.org/x/net v0.47.0 - golang.org/x/sync v0.18.0 + golang.org/x/sync v0.19.0 + golang.org/x/sys v0.38.0 + golang.org/x/text v0.32.0 + gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.4.3 gorm.io/driver/postgres v1.5.2 gorm.io/gorm v1.25.2 @@ -115,7 +120,6 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/samber/go-singleflightx v0.3.2 // indirect - github.com/samber/hot v0.11.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect @@ -127,10 +131,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.21.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 7e9f3bd70..23fe79489 100644 --- a/go.sum +++ b/go.sum @@ -213,6 +213,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= @@ -329,6 +331,8 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -349,9 +353,12 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/i18n/i18n.go b/i18n/i18n.go new file mode 100644 index 000000000..99505ee45 --- /dev/null +++ b/i18n/i18n.go @@ -0,0 +1,227 @@ +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 +} diff --git a/i18n/keys.go b/i18n/keys.go new file mode 100644 index 000000000..5de0d43b2 --- /dev/null +++ b/i18n/keys.go @@ -0,0 +1,278 @@ +package i18n + +// Message keys for i18n translations +// Use these constants instead of hardcoded strings + +// Common error messages +const ( + MsgInvalidParams = "common.invalid_params" + MsgDatabaseError = "common.database_error" + MsgRetryLater = "common.retry_later" + MsgGenerateFailed = "common.generate_failed" + MsgNotFound = "common.not_found" + MsgUnauthorized = "common.unauthorized" + MsgForbidden = "common.forbidden" + MsgInvalidId = "common.invalid_id" + MsgIdEmpty = "common.id_empty" + MsgFeatureDisabled = "common.feature_disabled" + MsgOperationSuccess = "common.operation_success" + MsgOperationFailed = "common.operation_failed" + MsgUpdateSuccess = "common.update_success" + MsgUpdateFailed = "common.update_failed" + MsgCreateSuccess = "common.create_success" + MsgCreateFailed = "common.create_failed" + MsgDeleteSuccess = "common.delete_success" + MsgDeleteFailed = "common.delete_failed" + MsgAlreadyExists = "common.already_exists" + MsgNameCannotBeEmpty = "common.name_cannot_be_empty" +) + +// Token related messages +const ( + MsgTokenNameTooLong = "token.name_too_long" + MsgTokenQuotaNegative = "token.quota_negative" + MsgTokenQuotaExceedMax = "token.quota_exceed_max" + MsgTokenGenerateFailed = "token.generate_failed" + MsgTokenGetInfoFailed = "token.get_info_failed" + MsgTokenExpiredCannotEnable = "token.expired_cannot_enable" + MsgTokenExhaustedCannotEable = "token.exhausted_cannot_enable" + MsgTokenInvalid = "token.invalid" + MsgTokenNotProvided = "token.not_provided" + MsgTokenExpired = "token.expired" + MsgTokenExhausted = "token.exhausted" + MsgTokenStatusUnavailable = "token.status_unavailable" + MsgTokenDbError = "token.db_error" +) + +// Redemption related messages +const ( + MsgRedemptionNameLength = "redemption.name_length" + MsgRedemptionCountPositive = "redemption.count_positive" + MsgRedemptionCountMax = "redemption.count_max" + MsgRedemptionCreateFailed = "redemption.create_failed" + MsgRedemptionInvalid = "redemption.invalid" + MsgRedemptionUsed = "redemption.used" + MsgRedemptionExpired = "redemption.expired" + MsgRedemptionFailed = "redemption.failed" + MsgRedemptionNotProvided = "redemption.not_provided" + MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid" +) + +// User related messages +const ( + MsgUserPasswordLoginDisabled = "user.password_login_disabled" + MsgUserRegisterDisabled = "user.register_disabled" + MsgUserPasswordRegisterDisabled = "user.password_register_disabled" + MsgUserUsernameOrPasswordEmpty = "user.username_or_password_empty" + MsgUserUsernameOrPasswordError = "user.username_or_password_error" + MsgUserEmailOrPasswordEmpty = "user.email_or_password_empty" + MsgUserExists = "user.exists" + MsgUserNotExists = "user.not_exists" + MsgUserDisabled = "user.disabled" + MsgUserSessionSaveFailed = "user.session_save_failed" + MsgUserRequire2FA = "user.require_2fa" + MsgUserEmailVerificationRequired = "user.email_verification_required" + MsgUserVerificationCodeError = "user.verification_code_error" + MsgUserInputInvalid = "user.input_invalid" + MsgUserNoPermissionSameLevel = "user.no_permission_same_level" + MsgUserNoPermissionHigherLevel = "user.no_permission_higher_level" + MsgUserCannotCreateHigherLevel = "user.cannot_create_higher_level" + MsgUserCannotDeleteRootUser = "user.cannot_delete_root_user" + MsgUserCannotDisableRootUser = "user.cannot_disable_root_user" + MsgUserCannotDemoteRootUser = "user.cannot_demote_root_user" + MsgUserAlreadyAdmin = "user.already_admin" + MsgUserAlreadyCommon = "user.already_common" + MsgUserAdminCannotPromote = "user.admin_cannot_promote" + MsgUserOriginalPasswordError = "user.original_password_error" + MsgUserInviteQuotaInsufficient = "user.invite_quota_insufficient" + MsgUserTransferQuotaMinimum = "user.transfer_quota_minimum" + MsgUserTransferSuccess = "user.transfer_success" + MsgUserTransferFailed = "user.transfer_failed" + MsgUserTopUpProcessing = "user.topup_processing" + MsgUserRegisterFailed = "user.register_failed" + MsgUserDefaultTokenFailed = "user.default_token_failed" + MsgUserAffCodeEmpty = "user.aff_code_empty" + MsgUserEmailEmpty = "user.email_empty" + MsgUserGitHubIdEmpty = "user.github_id_empty" + MsgUserDiscordIdEmpty = "user.discord_id_empty" + MsgUserOidcIdEmpty = "user.oidc_id_empty" + MsgUserWeChatIdEmpty = "user.wechat_id_empty" + MsgUserTelegramIdEmpty = "user.telegram_id_empty" + MsgUserTelegramNotBound = "user.telegram_not_bound" + MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty" +) + +// Quota related messages +const ( + MsgQuotaNegative = "quota.negative" + MsgQuotaExceedMax = "quota.exceed_max" + MsgQuotaInsufficient = "quota.insufficient" + MsgQuotaWarningInvalid = "quota.warning_invalid" + MsgQuotaThresholdGtZero = "quota.threshold_gt_zero" +) + +// Subscription related messages +const ( + MsgSubscriptionNotEnabled = "subscription.not_enabled" + MsgSubscriptionTitleEmpty = "subscription.title_empty" + MsgSubscriptionPriceNegative = "subscription.price_negative" + MsgSubscriptionPriceMax = "subscription.price_max" + MsgSubscriptionPurchaseLimitNeg = "subscription.purchase_limit_negative" + MsgSubscriptionQuotaNegative = "subscription.quota_negative" + MsgSubscriptionGroupNotExists = "subscription.group_not_exists" + MsgSubscriptionResetCycleGtZero = "subscription.reset_cycle_gt_zero" + MsgSubscriptionPurchaseMax = "subscription.purchase_max" + MsgSubscriptionInvalidId = "subscription.invalid_id" + MsgSubscriptionInvalidUserId = "subscription.invalid_user_id" +) + +// Payment related messages +const ( + MsgPaymentNotConfigured = "payment.not_configured" + MsgPaymentMethodNotExists = "payment.method_not_exists" + MsgPaymentCallbackError = "payment.callback_error" + MsgPaymentCreateFailed = "payment.create_failed" + MsgPaymentStartFailed = "payment.start_failed" + MsgPaymentAmountTooLow = "payment.amount_too_low" + MsgPaymentStripeNotConfig = "payment.stripe_not_configured" + MsgPaymentWebhookNotConfig = "payment.webhook_not_configured" + MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured" + MsgPaymentCreemNotConfig = "payment.creem_not_configured" +) + +// Topup related messages +const ( + MsgTopupNotProvided = "topup.not_provided" + MsgTopupOrderNotExists = "topup.order_not_exists" + MsgTopupOrderStatus = "topup.order_status" + MsgTopupFailed = "topup.failed" + MsgTopupInvalidQuota = "topup.invalid_quota" +) + +// Channel related messages +const ( + MsgChannelNotExists = "channel.not_exists" + MsgChannelIdFormatError = "channel.id_format_error" + MsgChannelNoAvailableKey = "channel.no_available_key" + MsgChannelGetListFailed = "channel.get_list_failed" + MsgChannelGetTagsFailed = "channel.get_tags_failed" + MsgChannelGetKeyFailed = "channel.get_key_failed" + MsgChannelGetOllamaFailed = "channel.get_ollama_failed" + MsgChannelQueryFailed = "channel.query_failed" + MsgChannelNoValidUpstream = "channel.no_valid_upstream" + MsgChannelUpstreamSaturated = "channel.upstream_saturated" + MsgChannelGetAvailableFailed = "channel.get_available_failed" +) + +// Model related messages +const ( + MsgModelNameEmpty = "model.name_empty" + MsgModelNameExists = "model.name_exists" + MsgModelIdMissing = "model.id_missing" + MsgModelGetListFailed = "model.get_list_failed" + MsgModelGetFailed = "model.get_failed" + MsgModelResetSuccess = "model.reset_success" +) + +// Vendor related messages +const ( + MsgVendorNameEmpty = "vendor.name_empty" + MsgVendorNameExists = "vendor.name_exists" + MsgVendorIdMissing = "vendor.id_missing" +) + +// Group related messages +const ( + MsgGroupNameTypeEmpty = "group.name_type_empty" + MsgGroupNameExists = "group.name_exists" + MsgGroupIdMissing = "group.id_missing" +) + +// Checkin related messages +const ( + MsgCheckinDisabled = "checkin.disabled" + MsgCheckinAlreadyToday = "checkin.already_today" + MsgCheckinFailed = "checkin.failed" + MsgCheckinQuotaFailed = "checkin.quota_failed" +) + +// Passkey related messages +const ( + MsgPasskeyCreateFailed = "passkey.create_failed" + MsgPasskeyLoginAbnormal = "passkey.login_abnormal" + MsgPasskeyUpdateFailed = "passkey.update_failed" + MsgPasskeyInvalidUserId = "passkey.invalid_user_id" + MsgPasskeyVerifyFailed = "passkey.verify_failed" +) + +// 2FA related messages +const ( + MsgTwoFANotEnabled = "twofa.not_enabled" + MsgTwoFAUserIdEmpty = "twofa.user_id_empty" + MsgTwoFAAlreadyExists = "twofa.already_exists" + MsgTwoFARecordIdEmpty = "twofa.record_id_empty" + MsgTwoFACodeInvalid = "twofa.code_invalid" +) + +// Rate limit related messages +const ( + MsgRateLimitReached = "rate_limit.reached" + MsgRateLimitTotalReached = "rate_limit.total_reached" +) + +// Setting related messages +const ( + MsgSettingInvalidType = "setting.invalid_type" + MsgSettingWebhookEmpty = "setting.webhook_empty" + MsgSettingWebhookInvalid = "setting.webhook_invalid" + MsgSettingEmailInvalid = "setting.email_invalid" + MsgSettingBarkUrlEmpty = "setting.bark_url_empty" + MsgSettingBarkUrlInvalid = "setting.bark_url_invalid" + MsgSettingGotifyUrlEmpty = "setting.gotify_url_empty" + MsgSettingGotifyTokenEmpty = "setting.gotify_token_empty" + MsgSettingGotifyUrlInvalid = "setting.gotify_url_invalid" + MsgSettingUrlMustHttp = "setting.url_must_http" + MsgSettingSaved = "setting.saved" +) + +// Deployment related messages (io.net) +const ( + MsgDeploymentNotEnabled = "deployment.not_enabled" + MsgDeploymentIdRequired = "deployment.id_required" + MsgDeploymentContainerIdReq = "deployment.container_id_required" + MsgDeploymentNameEmpty = "deployment.name_empty" + MsgDeploymentNameTaken = "deployment.name_taken" + MsgDeploymentHardwareIdReq = "deployment.hardware_id_required" + MsgDeploymentHardwareInvId = "deployment.hardware_invalid_id" + MsgDeploymentApiKeyRequired = "deployment.api_key_required" + MsgDeploymentInvalidPayload = "deployment.invalid_payload" + MsgDeploymentNotFound = "deployment.not_found" +) + +// Performance related messages +const ( + MsgPerfDiskCacheCleared = "performance.disk_cache_cleared" + MsgPerfStatsReset = "performance.stats_reset" + MsgPerfGcExecuted = "performance.gc_executed" +) + +// Ability related messages +const ( + MsgAbilityDbCorrupted = "ability.db_corrupted" + MsgAbilityRepairRunning = "ability.repair_running" +) + +// OAuth related messages +const ( + MsgOAuthInvalidCode = "oauth.invalid_code" + MsgOAuthGetUserErr = "oauth.get_user_error" + MsgOAuthAccountUsed = "oauth.account_used" +) + +// Model layer error messages (for translation in controller) +const ( + MsgRedeemFailed = "redeem.failed" + MsgCreateDefaultTokenErr = "user.create_default_token_error" + MsgUuidDuplicate = "common.uuid_duplicate" + MsgInvalidInput = "common.invalid_input" +) diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml new file mode 100644 index 000000000..994ff7837 --- /dev/null +++ b/i18n/locales/en.yaml @@ -0,0 +1,231 @@ +# English translations + +# Common messages +common.invalid_params: "Invalid parameters" +common.database_error: "Database error, please try again later" +common.retry_later: "Please try again later" +common.generate_failed: "Generation failed" +common.not_found: "Not found" +common.unauthorized: "Unauthorized" +common.forbidden: "Forbidden" +common.invalid_id: "Invalid ID" +common.id_empty: "ID is empty!" +common.feature_disabled: "This feature is not enabled" +common.operation_success: "Operation successful" +common.operation_failed: "Operation failed" +common.update_success: "Update successful" +common.update_failed: "Update failed" +common.create_success: "Creation successful" +common.create_failed: "Creation failed" +common.delete_success: "Deletion successful" +common.delete_failed: "Deletion failed" +common.already_exists: "Already exists" +common.name_cannot_be_empty: "Name cannot be empty" + +# Token messages +token.name_too_long: "Token name is too long" +token.quota_negative: "Quota value cannot be negative" +token.quota_exceed_max: "Quota value exceeds valid range, maximum is {{.Max}}" +token.generate_failed: "Failed to generate token" +token.get_info_failed: "Failed to get token info, please try again later" +token.expired_cannot_enable: "Token has expired and cannot be enabled. Please modify the expiration time or set it to never expire" +token.exhausted_cannot_enable: "Token quota is exhausted and cannot be enabled. Please modify the remaining quota or set it to unlimited" +token.invalid: "Invalid token" +token.not_provided: "Token not provided" +token.expired: "This token has expired" +token.exhausted: "This token quota is exhausted TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]" +token.status_unavailable: "This token status is unavailable" +token.db_error: "Invalid token, database query error, please contact administrator" + +# Redemption messages +redemption.name_length: "Redemption code name length must be between 1-20" +redemption.count_positive: "Redemption code count must be greater than 0" +redemption.count_max: "Maximum 100 redemption codes can be generated at once" +redemption.create_failed: "Failed to create redemption code, please try again later" +redemption.invalid: "Invalid redemption code" +redemption.used: "This redemption code has been used" +redemption.expired: "This redemption code has expired" +redemption.failed: "Redemption failed, please try again later" +redemption.not_provided: "Redemption code not provided" +redemption.expire_time_invalid: "Expiration time cannot be earlier than current time" + +# User messages +user.password_login_disabled: "Password login has been disabled by administrator" +user.register_disabled: "New user registration has been disabled by administrator" +user.password_register_disabled: "Password registration has been disabled by administrator, please use third-party account verification" +user.username_or_password_empty: "Username or password is empty" +user.username_or_password_error: "Username or password is incorrect, or user has been banned" +user.email_or_password_empty: "Email or password is empty!" +user.exists: "Username already exists or has been deleted" +user.not_exists: "User does not exist" +user.disabled: "This user has been disabled" +user.session_save_failed: "Failed to save session, please try again" +user.require_2fa: "Please enter two-factor authentication code" +user.email_verification_required: "Email verification is enabled, please enter email address and verification code" +user.verification_code_error: "Verification code is incorrect or has expired" +user.input_invalid: "Invalid input {{.Error}}" +user.no_permission_same_level: "No permission to access users of same or higher level" +user.no_permission_higher_level: "No permission to update users of same or higher permission level" +user.cannot_create_higher_level: "Cannot create users with permission level equal to or higher than yourself" +user.cannot_delete_root_user: "Cannot delete super administrator account" +user.cannot_disable_root_user: "Cannot disable super administrator user" +user.cannot_demote_root_user: "Cannot demote super administrator user" +user.already_admin: "This user is already an administrator" +user.already_common: "This user is already a common user" +user.admin_cannot_promote: "Regular administrators cannot promote other users to administrator" +user.original_password_error: "Original password is incorrect" +user.invite_quota_insufficient: "Invitation quota is insufficient!" +user.transfer_quota_minimum: "Minimum transfer quota is {{.Min}}!" +user.transfer_success: "Transfer successful" +user.transfer_failed: "Transfer failed {{.Error}}" +user.topup_processing: "Top-up is processing, please try again later" +user.register_failed: "User registration failed or user ID retrieval failed" +user.default_token_failed: "Failed to generate default token" +user.aff_code_empty: "Affiliate code is empty!" +user.email_empty: "Email is empty!" +user.github_id_empty: "GitHub ID is empty!" +user.discord_id_empty: "Discord ID is empty!" +user.oidc_id_empty: "OIDC ID is empty!" +user.wechat_id_empty: "WeChat ID is empty!" +user.telegram_id_empty: "Telegram ID is empty!" +user.telegram_not_bound: "This Telegram account is not bound" +user.linux_do_id_empty: "Linux DO ID is empty!" + +# Quota messages +quota.negative: "Quota cannot be negative!" +quota.exceed_max: "Quota value exceeds valid range" +quota.insufficient: "Insufficient quota" +quota.warning_invalid: "Invalid warning type" +quota.threshold_gt_zero: "Warning threshold must be greater than 0" + +# Subscription messages +subscription.not_enabled: "Subscription plan is not enabled" +subscription.title_empty: "Subscription plan title cannot be empty" +subscription.price_negative: "Price cannot be negative" +subscription.price_max: "Price cannot exceed 9999" +subscription.purchase_limit_negative: "Purchase limit cannot be negative" +subscription.quota_negative: "Total quota cannot be negative" +subscription.group_not_exists: "Upgrade group does not exist" +subscription.reset_cycle_gt_zero: "Custom reset cycle must be greater than 0 seconds" +subscription.purchase_max: "Purchase limit for this plan has been reached" +subscription.invalid_id: "Invalid subscription ID" +subscription.invalid_user_id: "Invalid user ID" + +# Payment messages +payment.not_configured: "Payment information has not been configured by administrator" +payment.method_not_exists: "Payment method does not exist" +payment.callback_error: "Callback URL configuration error" +payment.create_failed: "Failed to create order" +payment.start_failed: "Failed to start payment" +payment.amount_too_low: "Plan amount is too low" +payment.stripe_not_configured: "Stripe is not configured or key is invalid" +payment.webhook_not_configured: "Webhook is not configured" +payment.price_id_not_configured: "StripePriceId is not configured for this plan" +payment.creem_not_configured: "CreemProductId is not configured for this plan" + +# Topup messages +topup.not_provided: "Payment order number not provided" +topup.order_not_exists: "Top-up order does not exist" +topup.order_status: "Top-up order status error" +topup.failed: "Top-up failed, please try again later" +topup.invalid_quota: "Invalid top-up quota" + +# Channel messages +channel.not_exists: "Channel does not exist" +channel.id_format_error: "Channel ID format error" +channel.no_available_key: "No available channel keys" +channel.get_list_failed: "Failed to get channel list, please try again later" +channel.get_tags_failed: "Failed to get tags, please try again later" +channel.get_key_failed: "Failed to get channel key" +channel.get_ollama_failed: "Failed to get Ollama models" +channel.query_failed: "Failed to query channel" +channel.no_valid_upstream: "No valid upstream channel" +channel.upstream_saturated: "Current group upstream load is saturated, please try again later" +channel.get_available_failed: "Failed to get available channels for model {{.Model}} under group {{.Group}}" + +# Model messages +model.name_empty: "Model name cannot be empty" +model.name_exists: "Model name already exists" +model.id_missing: "Model ID is missing" +model.get_list_failed: "Failed to get model list, please try again later" +model.get_failed: "Failed to get upstream models" +model.reset_success: "Model ratio reset successful" + +# Vendor messages +vendor.name_empty: "Vendor name cannot be empty" +vendor.name_exists: "Vendor name already exists" +vendor.id_missing: "Vendor ID is missing" + +# Group messages +group.name_type_empty: "Group name and type cannot be empty" +group.name_exists: "Group name already exists" +group.id_missing: "Group ID is missing" + +# Checkin messages +checkin.disabled: "Check-in feature is not enabled" +checkin.already_today: "Already checked in today" +checkin.failed: "Check-in failed, please try again later" +checkin.quota_failed: "Check-in failed: quota update error" + +# Passkey messages +passkey.create_failed: "Unable to create Passkey credential" +passkey.login_abnormal: "Passkey login status is abnormal" +passkey.update_failed: "Passkey credential update failed" +passkey.invalid_user_id: "Invalid user ID" +passkey.verify_failed: "Passkey verification failed, please try again or contact administrator" + +# 2FA messages +twofa.not_enabled: "User has not enabled 2FA" +twofa.user_id_empty: "User ID cannot be empty" +twofa.already_exists: "User already has 2FA configured" +twofa.record_id_empty: "2FA record ID cannot be empty" +twofa.code_invalid: "Verification code or backup code is incorrect" + +# Rate limit messages +rate_limit.reached: "You have reached the request limit: maximum {{.Max}} requests in {{.Minutes}} minutes" +rate_limit.total_reached: "You have reached the total request limit: maximum {{.Max}} requests in {{.Minutes}} minutes, including failed attempts" + +# Setting messages +setting.invalid_type: "Invalid warning type" +setting.webhook_empty: "Webhook URL cannot be empty" +setting.webhook_invalid: "Invalid Webhook URL" +setting.email_invalid: "Invalid email address" +setting.bark_url_empty: "Bark push URL cannot be empty" +setting.bark_url_invalid: "Invalid Bark push URL" +setting.gotify_url_empty: "Gotify server URL cannot be empty" +setting.gotify_token_empty: "Gotify token cannot be empty" +setting.gotify_url_invalid: "Invalid Gotify server URL" +setting.url_must_http: "URL must start with http:// or https://" +setting.saved: "Settings updated" + +# Deployment messages (io.net) +deployment.not_enabled: "io.net model deployment is not enabled or API key is missing" +deployment.id_required: "Deployment ID is required" +deployment.container_id_required: "Container ID is required" +deployment.name_empty: "Deployment name cannot be empty" +deployment.name_taken: "Deployment name is not available, please choose a different name" +deployment.hardware_id_required: "hardware_id parameter is required" +deployment.hardware_invalid_id: "Invalid hardware_id parameter" +deployment.api_key_required: "api_key is required" +deployment.invalid_payload: "Invalid request payload" +deployment.not_found: "Container details not found" + +# Performance messages +performance.disk_cache_cleared: "Inactive disk cache has been cleared" +performance.stats_reset: "Statistics have been reset" +performance.gc_executed: "GC has been executed" + +# Ability messages +ability.db_corrupted: "Database consistency has been compromised" +ability.repair_running: "A repair task is already running, please try again later" + +# OAuth messages +oauth.invalid_code: "Invalid authorization code" +oauth.get_user_error: "Failed to get user information" +oauth.account_used: "This account has been bound to another user" + +# Model layer error messages +redeem.failed: "Redemption failed, please try again later" +user.create_default_token_error: "Failed to create default token" +common.uuid_duplicate: "Please retry, the system generated a duplicate UUID!" +common.invalid_input: "Invalid input" diff --git a/i18n/locales/zh.yaml b/i18n/locales/zh.yaml new file mode 100644 index 000000000..58576ac7c --- /dev/null +++ b/i18n/locales/zh.yaml @@ -0,0 +1,232 @@ +# Chinese (Simplified) translations +# 中文(简体)翻译文件 + +# Common messages +common.invalid_params: "无效的参数" +common.database_error: "数据库错误,请稍后重试" +common.retry_later: "请稍后重试" +common.generate_failed: "生成失败" +common.not_found: "未找到" +common.unauthorized: "未授权" +common.forbidden: "无权限" +common.invalid_id: "无效的ID" +common.id_empty: "ID 为空!" +common.feature_disabled: "该功能未启用" +common.operation_success: "操作成功" +common.operation_failed: "操作失败" +common.update_success: "更新成功" +common.update_failed: "更新失败" +common.create_success: "创建成功" +common.create_failed: "创建失败" +common.delete_success: "删除成功" +common.delete_failed: "删除失败" +common.already_exists: "已存在" +common.name_cannot_be_empty: "名称不能为空" + +# Token messages +token.name_too_long: "令牌名称过长" +token.quota_negative: "额度值不能为负数" +token.quota_exceed_max: "额度值超出有效范围,最大值为 {{.Max}}" +token.generate_failed: "生成令牌失败" +token.get_info_failed: "获取令牌信息失败,请稍后重试" +token.expired_cannot_enable: "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期" +token.exhausted_cannot_enable: "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度" +token.invalid: "无效的令牌" +token.not_provided: "未提供令牌" +token.expired: "该令牌已过期" +token.exhausted: "该令牌额度已用尽 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]" +token.status_unavailable: "该令牌状态不可用" +token.db_error: "无效的令牌,数据库查询出错,请联系管理员" + +# Redemption messages +redemption.name_length: "兑换码名称长度必须在1-20之间" +redemption.count_positive: "兑换码个数必须大于0" +redemption.count_max: "一次兑换码批量生成的个数不能大于 100" +redemption.create_failed: "创建兑换码失败,请稍后重试" +redemption.invalid: "无效的兑换码" +redemption.used: "该兑换码已被使用" +redemption.expired: "该兑换码已过期" +redemption.failed: "兑换失败,请稍后重试" +redemption.not_provided: "未提供兑换码" +redemption.expire_time_invalid: "过期时间不能早于当前时间" + +# User messages +user.password_login_disabled: "管理员关闭了密码登录" +user.register_disabled: "管理员关闭了新用户注册" +user.password_register_disabled: "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册" +user.username_or_password_empty: "用户名或密码为空" +user.username_or_password_error: "用户名或密码错误,或用户已被封禁" +user.email_or_password_empty: "邮箱地址或密码为空!" +user.exists: "用户名已存在,或已注销" +user.not_exists: "用户不存在" +user.disabled: "该用户已被禁用" +user.session_save_failed: "无法保存会话信息,请重试" +user.require_2fa: "请输入两步验证码" +user.email_verification_required: "管理员开启了邮箱验证,请输入邮箱地址和验证码" +user.verification_code_error: "验证码错误或已过期" +user.input_invalid: "输入不合法 {{.Error}}" +user.no_permission_same_level: "无权获取同级或更高等级用户的信息" +user.no_permission_higher_level: "无权更新同权限等级或更高权限等级的用户信息" +user.cannot_create_higher_level: "无法创建权限大于等于自己的用户" +user.cannot_delete_root_user: "不能删除超级管理员账户" +user.cannot_disable_root_user: "无法禁用超级管理员用户" +user.cannot_demote_root_user: "无法降级超级管理员用户" +user.already_admin: "该用户已经是管理员" +user.already_common: "该用户已经是普通用户" +user.admin_cannot_promote: "普通管理员用户无法提升其他用户为管理员" +user.original_password_error: "原密码错误" +user.invite_quota_insufficient: "邀请额度不足!" +user.transfer_quota_minimum: "转移额度最小为{{.Min}}!" +user.transfer_success: "划转成功" +user.transfer_failed: "划转失败 {{.Error}}" +user.topup_processing: "充值处理中,请稍后重试" +user.register_failed: "用户注册失败或用户ID获取失败" +user.default_token_failed: "生成默认令牌失败" +user.aff_code_empty: "affCode 为空!" +user.email_empty: "email 为空!" +user.github_id_empty: "GitHub id 为空!" +user.discord_id_empty: "discord id 为空!" +user.oidc_id_empty: "oidc id 为空!" +user.wechat_id_empty: "WeChat id 为空!" +user.telegram_id_empty: "Telegram id 为空!" +user.telegram_not_bound: "该 Telegram 账户未绑定" +user.linux_do_id_empty: "Linux DO id 为空!" + +# Quota messages +quota.negative: "额度不能为负数!" +quota.exceed_max: "额度值超出有效范围" +quota.insufficient: "额度不足" +quota.warning_invalid: "无效的预警类型" +quota.threshold_gt_zero: "预警阈值必须大于0" + +# Subscription messages +subscription.not_enabled: "套餐未启用" +subscription.title_empty: "套餐标题不能为空" +subscription.price_negative: "价格不能为负数" +subscription.price_max: "价格不能超过9999" +subscription.purchase_limit_negative: "购买上限不能为负数" +subscription.quota_negative: "总额度不能为负数" +subscription.group_not_exists: "升级分组不存在" +subscription.reset_cycle_gt_zero: "自定义重置周期需大于0秒" +subscription.purchase_max: "已达到该套餐购买上限" +subscription.invalid_id: "无效的订阅ID" +subscription.invalid_user_id: "无效的用户ID" + +# Payment messages +payment.not_configured: "当前管理员未配置支付信息" +payment.method_not_exists: "支付方式不存在" +payment.callback_error: "回调地址配置错误" +payment.create_failed: "创建订单失败" +payment.start_failed: "拉起支付失败" +payment.amount_too_low: "套餐金额过低" +payment.stripe_not_configured: "Stripe 未配置或密钥无效" +payment.webhook_not_configured: "Webhook 未配置" +payment.price_id_not_configured: "该套餐未配置 StripePriceId" +payment.creem_not_configured: "该套餐未配置 CreemProductId" + +# Topup messages +topup.not_provided: "未提供支付单号" +topup.order_not_exists: "充值订单不存在" +topup.order_status: "充值订单状态错误" +topup.failed: "充值失败,请稍后重试" +topup.invalid_quota: "无效的充值额度" + +# Channel messages +channel.not_exists: "渠道不存在" +channel.id_format_error: "渠道ID格式错误" +channel.no_available_key: "没有可用的渠道密钥" +channel.get_list_failed: "获取渠道列表失败,请稍后重试" +channel.get_tags_failed: "获取标签失败,请稍后重试" +channel.get_key_failed: "获取渠道密钥失败" +channel.get_ollama_failed: "获取Ollama模型失败" +channel.query_failed: "查询渠道失败" +channel.no_valid_upstream: "无有效上游渠道" +channel.upstream_saturated: "当前分组上游负载已饱和,请稍后再试" +channel.get_available_failed: "获取分组 {{.Group}} 下模型 {{.Model}} 的可用渠道失败" + +# Model messages +model.name_empty: "模型名称不能为空" +model.name_exists: "模型名称已存在" +model.id_missing: "缺少模型 ID" +model.get_list_failed: "获取模型列表失败,请稍后重试" +model.get_failed: "获取上游模型失败" +model.reset_success: "重置模型倍率成功" + +# Vendor messages +vendor.name_empty: "供应商名称不能为空" +vendor.name_exists: "供应商名称已存在" +vendor.id_missing: "缺少供应商 ID" + +# Group messages +group.name_type_empty: "组名称和类型不能为空" +group.name_exists: "组名称已存在" +group.id_missing: "缺少组 ID" + +# Checkin messages +checkin.disabled: "签到功能未启用" +checkin.already_today: "今日已签到" +checkin.failed: "签到失败,请稍后重试" +checkin.quota_failed: "签到失败:更新额度出错" + +# Passkey messages +passkey.create_failed: "无法创建 Passkey 凭证" +passkey.login_abnormal: "Passkey 登录状态异常" +passkey.update_failed: "Passkey 凭证更新失败" +passkey.invalid_user_id: "无效的用户 ID" +passkey.verify_failed: "Passkey 验证失败,请重试或联系管理员" + +# 2FA messages +twofa.not_enabled: "用户未启用2FA" +twofa.user_id_empty: "用户ID不能为空" +twofa.already_exists: "用户已存在2FA设置" +twofa.record_id_empty: "2FA记录ID不能为空" +twofa.code_invalid: "验证码或备用码不正确" + +# Rate limit messages +rate_limit.reached: "您已达到请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次" +rate_limit.total_reached: "您已达到总请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次,包括失败次数" + +# Setting messages +setting.invalid_type: "无效的预警类型" +setting.webhook_empty: "Webhook地址不能为空" +setting.webhook_invalid: "无效的Webhook地址" +setting.email_invalid: "无效的邮箱地址" +setting.bark_url_empty: "Bark推送URL不能为空" +setting.bark_url_invalid: "无效的Bark推送URL" +setting.gotify_url_empty: "Gotify服务器地址不能为空" +setting.gotify_token_empty: "Gotify令牌不能为空" +setting.gotify_url_invalid: "无效的Gotify服务器地址" +setting.url_must_http: "URL必须以http://或https://开头" +setting.saved: "设置已更新" + +# Deployment messages (io.net) +deployment.not_enabled: "io.net 模型部署功能未启用或 API 密钥缺失" +deployment.id_required: "deployment ID 为必填项" +deployment.container_id_required: "container ID 为必填项" +deployment.name_empty: "deployment 名称不能为空" +deployment.name_taken: "deployment 名称已被使用,请选择其他名称" +deployment.hardware_id_required: "hardware_id 参数为必填项" +deployment.hardware_invalid_id: "无效的 hardware_id 参数" +deployment.api_key_required: "api_key 为必填项" +deployment.invalid_payload: "无效的请求内容" +deployment.not_found: "未找到容器详情" + +# Performance messages +performance.disk_cache_cleared: "不活跃的磁盘缓存已清理" +performance.stats_reset: "统计信息已重置" +performance.gc_executed: "GC 已执行" + +# Ability messages +ability.db_corrupted: "数据库一致性被破坏" +ability.repair_running: "已经有一个修复任务在运行中,请稍后再试" + +# OAuth messages +oauth.invalid_code: "无效的授权码" +oauth.get_user_error: "获取用户信息失败" +oauth.account_used: "该账户已被其他用户绑定" + +# Model layer error messages +redeem.failed: "兑换失败,请稍后重试" +user.create_default_token_error: "创建默认令牌失败" +common.uuid_duplicate: "请重试,系统生成的 UUID 竟然重复了!" +common.invalid_input: "输入不合法" diff --git a/main.go b/main.go index 2bc897e8c..4f9cf84ee 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" @@ -151,6 +152,7 @@ func main() { //server.Use(gzip.Gzip(gzip.DefaultCompression)) server.Use(middleware.RequestId()) server.Use(middleware.PoweredBy()) + server.Use(middleware.I18n()) middleware.SetUpLogger(server) // Initialize session store store := cookie.NewStore([]byte(common.SessionSecret)) @@ -278,5 +280,16 @@ func InitResources() error { // 启动系统监控 common.StartSystemMonitor() + // Initialize i18n + err = i18n.Init() + if err != nil { + common.SysError("failed to initialize i18n: " + err.Error()) + // Don't return error, i18n is not critical + } else { + common.SysLog("i18n initialized with languages: " + strings.Join(i18n.SupportedLanguages(), ", ")) + } + // Register user language loader for lazy loading + i18n.SetUserLangLoader(model.GetUserLanguage) + return nil } diff --git a/middleware/auth.go b/middleware/auth.go index a5d283d26..0bb27ead0 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -132,17 +132,6 @@ func authHelper(c *gin.Context, minRole int) { c.Set("user_group", session.Get("group")) c.Set("use_access_token", useAccessToken) - //userCache, err := model.GetUserCache(id.(int)) - //if err != nil { - // c.JSON(http.StatusOK, gin.H{ - // "success": false, - // "message": err.Error(), - // }) - // c.Abort() - // return - //} - //userCache.WriteContext(c) - c.Next() } diff --git a/middleware/i18n.go b/middleware/i18n.go new file mode 100644 index 000000000..279a738a3 --- /dev/null +++ b/middleware/i18n.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/i18n" +) + +// I18n middleware detects and sets the language preference for the request +func I18n() gin.HandlerFunc { + return func(c *gin.Context) { + lang := detectLanguage(c) + c.Set(string(constant.ContextKeyLanguage), lang) + c.Next() + } +} + +// detectLanguage determines the language preference for the request +// Priority: 1. User setting (if logged in) -> 2. Accept-Language header -> 3. Default language +func detectLanguage(c *gin.Context) string { + // 1. Try to get language from user setting (set by auth middleware) + if userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting); ok { + if userSetting.Language != "" && i18n.IsSupported(userSetting.Language) { + return userSetting.Language + } + } + + // 2. Parse Accept-Language header + acceptLang := c.GetHeader("Accept-Language") + if acceptLang != "" { + lang := i18n.ParseAcceptLanguage(acceptLang) + if i18n.IsSupported(lang) { + return lang + } + } + + // 3. Return default language + return i18n.DefaultLang +} + +// GetLanguage returns the current language from gin context +func GetLanguage(c *gin.Context) string { + if lang := c.GetString(string(constant.ContextKeyLanguage)); lang != "" { + return lang + } + return i18n.DefaultLang +} diff --git a/model/redemption.go b/model/redemption.go index 237561bec..378976a36 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -11,6 +11,9 @@ import ( "gorm.io/gorm" ) +// ErrRedeemFailed is returned when redemption fails due to database error +var ErrRedeemFailed = errors.New("redeem.failed") + type Redemption struct { Id int `json:"id"` UserId int `json:"user_id"` @@ -149,7 +152,7 @@ func Redeem(key string, userId int) (quota int, err error) { }) if err != nil { common.SysError("redemption failed: " + err.Error()) - return 0, errors.New("兑换失败,请稍后重试") + return 0, ErrRedeemFailed } RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id)) return redemption.Quota, nil diff --git a/model/user_cache.go b/model/user_cache.go index 7a6af0987..2ba1f18ec 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -221,3 +221,13 @@ func updateUserSettingCache(userId int, setting string) error { } return common.RedisHSetField(getUserCacheKey(userId), "Setting", setting) } + +// GetUserLanguage returns the user's language preference from cache +// Uses the existing GetUserCache mechanism for efficiency +func GetUserLanguage(userId int) string { + userCache, err := GetUserCache(userId) + if err != nil { + return "" + } + return userCache.GetSetting().Language +} diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 657d9b4ff..8ee6415ac 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -39,6 +39,7 @@ import { useTranslation } from 'react-i18next'; import UserInfoHeader from './personal/components/UserInfoHeader'; import AccountManagement from './personal/cards/AccountManagement'; import NotificationSettings from './personal/cards/NotificationSettings'; +import PreferencesSettings from './personal/cards/PreferencesSettings'; import CheckinCalendar from './personal/cards/CheckinCalendar'; import EmailBindModal from './personal/modals/EmailBindModal'; import WeChatBindModal from './personal/modals/WeChatBindModal'; @@ -463,24 +464,29 @@ const PersonalSetting = () => { {/* 账户管理和其他设置 */}
{/* 左侧:账户管理设置 */} - +
+ + + {/* 偏好设置(语言等) */} + +
{/* 右侧:其他设置 */} . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useContext } from 'react'; +import { Card, Select, Typography, Avatar } from '@douyinfe/semi-ui'; +import { Languages } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { API, showSuccess, showError } from '../../../../helpers'; +import { UserContext } from '../../../../context/User'; + +// Language options with native names and flags +const languageOptions = [ + { value: 'zh', label: '中文', flag: '🇨🇳' }, + { value: 'en', label: 'English', flag: '🇺🇸' }, + { value: 'fr', label: 'Français', flag: '🇫🇷' }, + { value: 'ru', label: 'Русский', flag: '🇷🇺' }, + { value: 'ja', label: '日本語', flag: '🇯🇵' }, + { value: 'vi', label: 'Tiếng Việt', flag: '🇻🇳' }, +]; + +const PreferencesSettings = ({ t }) => { + const { i18n } = useTranslation(); + const [userState, userDispatch] = useContext(UserContext); + const [currentLanguage, setCurrentLanguage] = useState(i18n.language || 'zh'); + const [loading, setLoading] = useState(false); + + // Load saved language preference from user settings + useEffect(() => { + if (userState?.user?.setting) { + try { + const settings = JSON.parse(userState.user.setting); + if (settings.language) { + setCurrentLanguage(settings.language); + // Sync i18n with saved preference + if (i18n.language !== settings.language) { + i18n.changeLanguage(settings.language); + } + } + } catch (e) { + // Ignore parse errors + } + } + }, [userState?.user?.setting, i18n]); + + const handleLanguagePreferenceChange = async (lang) => { + if (lang === currentLanguage) return; + + setLoading(true); + const previousLang = currentLanguage; + + try { + // Update language immediately for responsive UX + setCurrentLanguage(lang); + i18n.changeLanguage(lang); + + // Save to backend + const res = await API.put('/api/user/self', { + language: lang, + }); + + if (res.data.success) { + showSuccess(t('语言偏好已保存')); + // Update user context with new setting + if (userState?.user?.setting) { + try { + const settings = JSON.parse(userState.user.setting); + settings.language = lang; + userDispatch({ + type: 'login', + payload: { + ...userState.user, + setting: JSON.stringify(settings), + }, + }); + } catch (e) { + // Ignore + } + } + } else { + showError(res.data.message || t('保存失败')); + // Revert on error + setCurrentLanguage(previousLang); + i18n.changeLanguage(previousLang); + } + } catch (error) { + showError(t('保存失败,请重试')); + // Revert on error + setCurrentLanguage(previousLang); + i18n.changeLanguage(previousLang); + } finally { + setLoading(false); + } + }; + + return ( + + {/* Card Header */} +
+ + + +
+ + {t('偏好设置')} + +
+ {t('界面语言和其他个人偏好')} +
+
+
+ + {/* Language Setting Card */} + +
+
+
+ +
+
+ + {t('语言偏好')} + + + {t('选择您的首选界面语言,设置将自动保存并同步到所有设备')} + +
+
+