Files
Calcium-Ion 128089fa7d feat(i18n): translate web console messages in the frontend and AI-client errors in the backend (#7712)
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend

Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.

Text is now translated where its reader is:

- Web console APIs return the English source text as message_key with
  message_params, and the web console translates it in all seven languages.
  message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
  the backend (en, zh-CN, zh-TW) in the language saved on the account, then
  the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
  messages that were Chinese before, word for word. Gateways that use another
  new-api as their upstream match that text, for example to disable a channel
  whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
  params) and rendered in the viewer's language. Task fail reasons and refund
  reasons written by the gateway are English keys. Rows written earlier are
  shown as before.
- Server logs are English.

Also:

- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
  Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
  administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
  non-English string literal, on a web console message without translations
  and on a locale key without a constant.

* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it

Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.

common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.

* docs: list LOG_LANGUAGE in the docker compose example

* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE

Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.

DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.

Review fixes:
- Channel test logs store the token name "Model test"; the console shows
  it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
  translates. The HTTP status and the upstream detail go to the server log
  when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.

Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
  read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
  translated message key.
- Remove guards and helpers that no caller needs.

* fix(i18n): address review on language lookup, notices and error responses

- Relay requests no longer load the user a second time to find the
  language: the settings TokenAuth put into the request are used as they
  are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
  language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
  messages again, with the keys and status codes used before this PR.
  Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
  senders. Running the rendered text through Sprintf appended
  %!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
  and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
2026-10-09 21:39:51 +08:00

206 lines
9.4 KiB
Go

package controller
import (
"errors"
"fmt"
"os"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// auditContentTemplates 将稳定的操作标识 action 映射为英文兜底模板,渲染后写入
// Log.Content(供导出等非本地化消费者使用)。占位符为 ${name},由该
// action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的
// 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。
var auditContentTemplates = map[string]string{
"user.create": "Created user ${username} (role ${role})",
"user.update": "Updated user ${username} (ID: ${id})",
"user.delete": "Deleted user ${username} (ID: ${id})",
"user.account_delete": "Account deletion",
"user.manage": "Performed ${action} on user ${username} (ID: ${id})",
"user.quota_add": "Increased user quota by ${quota}",
"user.quota_subtract": "Decreased user quota by ${quota}",
"user.quota_override": "Overrode user quota from ${from} to ${to}",
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
"user.2fa_disable": "Force-disabled two-factor authentication for the user",
"user.passkey_register": "Registered a passkey",
"access_token.generate": "Generated an access token",
"access_token.revoke": "Revoked an access token",
"access_token.rename": "Renamed an access token",
"access_token.update": "Changed access token permissions",
"user.2fa_setup": "Started two-factor authentication setup",
"user.2fa_enable": "Enabled two-factor authentication",
"user.2fa_disable_self": "Disabled two-factor authentication",
"user.2fa_backup_codes": "Regenerated two-factor backup codes",
"user.security_verify": "Completed security verification",
"user.password_change": "Account password change",
"user.binding_start": "Account binding request",
"user.binding_bind": "Account binding",
"user.binding_unbind": "Account unlinking",
"user.email_binding_resend": "Email confirmation code resend",
"user.passkey_delete": "Deleted a passkey",
"user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}",
"option.passkey_domains": "Updated Passkey domains: removed ${domains}; affected ${known}; unknown ${unknown}",
"option.passkey_domains_confirmed": "Confirmed removal of Passkey domains: ${domains}; affected ${known}; unknown ${unknown}",
"option.passkey_domains_blocked": "Passkey domain change blocked: ${domains}; affected ${known}; unknown ${unknown}",
"option.passkey_domains_failed": "Passkey domain update failed",
"channel.create": "Created channel ${name} (type ${type}, count ${count})",
"channel.update": "Updated channel ${name} (ID: ${id})",
"channel.delete": "Deleted channel ${name} (ID: ${id})",
"channel.delete_batch": "Batch deleted ${count} channels",
"channel.delete_disabled": "Deleted all disabled channels (${count})",
"channel.key_view": "Viewed channel key ${name} (ID: ${id})",
"channel.tag_disable": "Disabled channels with tag ${tag}",
"channel.tag_enable": "Enabled channels with tag ${tag}",
"channel.tag_edit": "Edited channels with tag ${tag}",
"channel.tag_batch_set": "Batch set tag for ${count} channels",
"channel.copy": "Copied channel (source ID: ${sourceId}) to ${name} (new ID: ${id})",
"channel.multi_key_manage": "Multi-key management ${action} on channel (ID: ${id})",
"channel.upstream_apply": "Applied upstream model changes to channel (ID: ${id})",
"channel.upstream_apply_all": "Applied upstream model changes to ${count} channels",
"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
"redemption.delete_batch": "Batch deleted ${count} redemption codes",
"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
"subscription.user_quota_reset": "Administrator reset the quota of subscription plan ${plan_title} (ID: ${plan_id})",
}
func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) {
confirmed = confirmed && err == nil && change != nil && len(change.RemovedRPIDs) > 0
params := map[string]any{"success": err == nil, "confirmed": confirmed}
if change != nil {
params["domains"] = strings.Join(change.RemovedRPIDs, ", ")
params["removed_rp_ids"] = change.RemovedRPIDs
params["known"] = change.AffectedCredentials
params["unknown"] = change.UnknownCredentials
params["previous_rp_id"] = change.PreviousRPID
params["effective_rp_id"] = change.EffectiveRPID
}
action := "option.passkey_domains"
if errors.Is(err, model.ErrPasskeyDomainRemovalConfirmation) {
action = "option.passkey_domains_blocked"
} else if err != nil {
action = "option.passkey_domains_failed"
} else if confirmed && change != nil && len(change.RemovedRPIDs) > 0 {
action = "option.passkey_domains_confirmed"
}
auditInfo := &model.AuditRequestInfo{
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(),
Status: c.Writer.Status(), Success: err == nil,
}
model.RecordOperationAuditLog(c.GetInt("id"), c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), auditInfo, c)
markAuditLogged(c)
}
// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
func auditContentEN(action string, params map[string]any) string {
tmpl, ok := auditContentTemplates[action]
if !ok {
return action
}
return os.Expand(tmpl, func(key string) string {
if v, ok := params[key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
})
}
// auditOperatorInfo 从上下文构建操作者身份信息(管理员 id/用户名/角色)。
func auditOperatorInfo(c *gin.Context) *model.AuditAdminInfo {
return &model.AuditAdminInfo{
AdminID: c.GetInt("id"),
AdminUsername: c.GetString("username"),
AdminRole: c.GetInt("role"),
AuthMethod: auditAuthMethod(c),
}
}
func auditAuthMethod(c *gin.Context) string {
if c.GetBool("use_access_token") {
return "access_token"
}
return "session"
}
// markAuditLogged 标记当前请求已在 handler 内手动记录审计日志,
// 使鉴权链路中的审计兜底(finishAdminAudit)跳过兜底记录,避免重复。
func markAuditLogged(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyAuditLogged, true)
}
// recordManageAudit 记录一条由操作者本人归属的管理/高危审计日志(资源类操作:
// 渠道 / 系统设置 / 兑换码等)。content 由 action+params 自动渲染。
func recordManageAudit(c *gin.Context, action string, params map[string]any) {
recordManageAuditFor(c, c.GetInt("id"), action, params)
}
// recordManageAuditFor 记录一条管理审计日志,日志归属于操作者;targetUserId
// 只表示被操作用户,用于在结构化参数中保留目标上下文。
func recordManageAuditFor(c *gin.Context, targetUserId int, action string, params map[string]any) {
if params == nil {
params = map[string]any{}
}
operatorUserId := c.GetInt("id")
if _, ok := params["target_user_id"]; !ok && targetUserId > 0 && targetUserId != operatorUserId {
params["target_user_id"] = targetUserId
}
model.RecordOperationAuditLog(operatorUserId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil, c)
markAuditLogged(c)
}
// recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。
// 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。
func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]any) {
if code := c.GetString("security_error_code"); code != "" {
if params == nil {
params = map[string]any{}
}
params["code"] = code
}
if c.GetBool("use_access_token") {
if params == nil {
params = map[string]any{}
}
params["token_ref"] = c.GetString("access_token_ref")
}
var auditInfo *model.AuditRequestInfo
if success, ok := params["success"].(bool); ok {
auditInfo = &model.AuditRequestInfo{
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(),
Status: c.Writer.Status(), Success: success,
}
}
model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, auditInfo, c)
}
func tokenAuditParams(c *gin.Context) model.AuditFields {
params, ok := common.GetContextKeyType[model.AuditFields](c, constant.ContextKeyTokenAuditParams)
if !ok {
params = model.AuditFields{}
common.SetContextKey(c, constant.ContextKeyTokenAuditParams, params)
}
return params
}
func tokenBatchAuditParams(c *gin.Context, ids []int) model.AuditFields {
params := tokenAuditParams(c)
params["total"] = len(ids)
// Bound audit payloads without changing the batch operation's limits.
params["requested_ids"] = append([]int{}, ids[:min(len(ids), 100)]...)
if len(ids) > 100 {
params["requested_ids_truncated"] = true
}
return params
}