mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
* 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.
206 lines
9.4 KiB
Go
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
|
|
}
|