mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
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.
This commit is contained in:
1 parent
e0483122c7
commit
128089fa7d
288 files changed
+7126
-4126
No files matched your search
@@ -9,6 +9,8 @@
|
||||
# ENABLE_PPROF=true
|
||||
# 启用调试模式
|
||||
# DEBUG=true
|
||||
# 默认语言:日志输出,以及对方没有指定语言时返回的提示、邮件和通知。可选 en、zh-CN、zh-TW,不设置为 en
|
||||
# DEFAULT_LANGUAGE=zh-CN
|
||||
# Pyroscope 配置
|
||||
# PYROSCOPE_URL=http://localhost:4040
|
||||
# PYROSCOPE_APP_NAME=new-api
|
||||
|
||||
@@ -28,7 +28,11 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
|
||||
|
||||
### Backend (`i18n/`)
|
||||
- Library: `nicksnyder/go-i18n/v2`
|
||||
- Languages: en, zh
|
||||
- Languages: en, zh-CN, zh-TW
|
||||
- Scope: text with no web console in between (errors returned to AI clients, emails, notifications), translated with `i18n.T` / `i18n.Translate`
|
||||
- Web console API messages are English source text built with `common.NewMessage` / `common.ApiErrorT` / `common.ApiSuccessT`; the frontend translates them, so add each key to all seven frontend locale files
|
||||
- Stored log content is `[]*common.Message`; a fail reason written by the gateway is a fixed English sentence that is also a key in the frontend locale files
|
||||
- Server log lines are English text passed through `common.LogText`; `DEFAULT_LANGUAGE=zh-CN` prints the Chinese text of the lines that have one in `common/log_text.zh-CN.json`, and is the language of backend messages for a reader who states none
|
||||
|
||||
### Frontend (`web/src/i18n/`)
|
||||
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ import (
|
||||
// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
|
||||
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
|
||||
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
|
||||
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
|
||||
SysLog(LogText("GetAudioDuration: ext=%s", ext))
|
||||
ext = strings.ToLower(ext)
|
||||
// 根据文件扩展名选择解析器
|
||||
switch ext {
|
||||
@@ -46,7 +46,7 @@ func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duratio
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported audio format: %s", ext)
|
||||
}
|
||||
SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration))
|
||||
SysLog(LogText("GetAudioDuration: duration=%f", duration))
|
||||
return duration, err
|
||||
}
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ func CreateBodyStorage(data []byte) (BodyStorage, error) {
|
||||
storage, err := newDiskStorage(data, GetDiskCachePath())
|
||||
if err != nil {
|
||||
// 如果磁盘存储失败,回退到内存存储
|
||||
SysError(fmt.Sprintf("failed to create disk storage, falling back to memory: %v", err))
|
||||
SysError(LogText("failed to create disk storage, falling back to memory: %v", err))
|
||||
return newMemoryStorage(data), nil
|
||||
}
|
||||
return storage, nil
|
||||
|
||||
@@ -35,6 +35,11 @@ var DefaultCollapseSidebar = false // default value of collapse sidebar
|
||||
var SessionSecret = uuid.New().String()
|
||||
var CryptoSecret = uuid.New().String()
|
||||
var SessionCookieSecure = false
|
||||
|
||||
// DefaultLanguage is the DEFAULT_LANGUAGE environment variable: the language of
|
||||
// server log lines and of text for a reader who states no language: en, zh-CN
|
||||
// or zh-TW. English when empty.
|
||||
var DefaultLanguage string
|
||||
var SessionCookieTrustedURLs []string
|
||||
|
||||
const (
|
||||
|
||||
+2
-2
@@ -84,7 +84,7 @@ func SendEmail(subject string, receiver string, content string) error {
|
||||
return err2
|
||||
}
|
||||
if SMTPServer == "" && SMTPAccount == "" {
|
||||
return fmt.Errorf("SMTP 服务器未配置")
|
||||
return NewMessage("SMTP server is not configured")
|
||||
}
|
||||
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
|
||||
mail := []byte(fmt.Sprintf("To: %s\r\n"+
|
||||
@@ -130,7 +130,7 @@ func SendEmail(subject string, receiver string, content string) error {
|
||||
}
|
||||
err = client.Quit()
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
|
||||
SysError(LogText("failed to send email to %s: %v", receiver, err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
@@ -12,7 +11,7 @@ func GetEnvOrDefault(env string, defaultValue int) int {
|
||||
}
|
||||
num, err := strconv.Atoi(os.Getenv(env))
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
|
||||
SysError(LogText("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
|
||||
return defaultValue
|
||||
}
|
||||
return num
|
||||
@@ -31,7 +30,7 @@ func GetEnvOrDefaultBool(env string, defaultValue bool) bool {
|
||||
}
|
||||
b, err := strconv.ParseBool(os.Getenv(env))
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue))
|
||||
SysError(LogText("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue))
|
||||
return defaultValue
|
||||
}
|
||||
return b
|
||||
|
||||
+37
-2
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -196,11 +197,45 @@ func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool)
|
||||
return t, false
|
||||
}
|
||||
|
||||
// ApiError responds with err's text. When err is or wraps a *Message, the
|
||||
// response also carries message_key and message_params for the web console.
|
||||
func ApiError(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
ApiErrorStatus(c, http.StatusOK, err)
|
||||
}
|
||||
|
||||
// ApiErrorStatus is ApiError with another HTTP status. fields adds response
|
||||
// fields such as a stable error code or data.
|
||||
func ApiErrorStatus(c *gin.Context, status int, err error, fields ...gin.H) {
|
||||
body := gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
var message *Message
|
||||
if errors.As(err, &message) {
|
||||
maps.Copy(body, message.Fields())
|
||||
}
|
||||
for _, extra := range fields {
|
||||
maps.Copy(body, extra)
|
||||
}
|
||||
c.JSON(status, body)
|
||||
}
|
||||
|
||||
// ApiErrorT responds with a web console message; key is the English source
|
||||
// text, which the web console translates with params.
|
||||
func ApiErrorT(c *gin.Context, key string, params ...map[string]any) {
|
||||
ApiError(c, NewMessage(key, params...))
|
||||
}
|
||||
|
||||
// ApiSuccessT is the success counterpart of ApiErrorT.
|
||||
func ApiSuccessT(c *gin.Context, key string, data any, params ...map[string]any) {
|
||||
message := NewMessage(key, params...)
|
||||
body := gin.H{
|
||||
"success": true,
|
||||
"message": message.Error(),
|
||||
"data": data,
|
||||
}
|
||||
maps.Copy(body, message.Fields())
|
||||
c.JSON(http.StatusOK, body)
|
||||
}
|
||||
|
||||
func ApiErrorMsg(c *gin.Context, msg string) {
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
@@ -16,7 +15,7 @@ func init() {
|
||||
if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok {
|
||||
SafeSendBool(stopChan, true)
|
||||
}
|
||||
SysError(fmt.Sprintf("panic in gopool.RelayPool: %v", i))
|
||||
SysError(LogText("panic in gopool.RelayPool: %v", i))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+8
-6
@@ -47,12 +47,14 @@ func InitEnv() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
DefaultLanguage = os.Getenv("DEFAULT_LANGUAGE")
|
||||
SetLogLanguage(DefaultLanguage)
|
||||
|
||||
if os.Getenv("SESSION_SECRET") != "" {
|
||||
ss := os.Getenv("SESSION_SECRET")
|
||||
if ss == "random_string" {
|
||||
log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
|
||||
log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
|
||||
log.Fatal("Please set SESSION_SECRET to a random string.")
|
||||
log.Println(LogText("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string."))
|
||||
log.Fatal(LogText("Please set SESSION_SECRET to a random string."))
|
||||
} else {
|
||||
SessionSecret = ss
|
||||
}
|
||||
@@ -151,7 +153,7 @@ func initUserSessionSettings() {
|
||||
|
||||
const secondsPerDay = 24 * 60 * 60
|
||||
if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay {
|
||||
SysError(fmt.Sprintf(
|
||||
SysError(LogText(
|
||||
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d",
|
||||
DefaultUserSessionRevokedRetentionDays,
|
||||
))
|
||||
@@ -161,7 +163,7 @@ func initUserSessionSettings() {
|
||||
if UserSessionIssuanceWindowSeconds > retentionSeconds {
|
||||
configuredWindow := UserSessionIssuanceWindowSeconds
|
||||
UserSessionIssuanceWindowSeconds = retentionSeconds
|
||||
SysError(fmt.Sprintf(
|
||||
SysError(LogText(
|
||||
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
|
||||
configuredWindow,
|
||||
retentionSeconds,
|
||||
@@ -173,7 +175,7 @@ func initUserSessionSettings() {
|
||||
func positiveUserSessionEnv(name string, fallback int) int {
|
||||
value := GetEnvOrDefault(name, fallback)
|
||||
if value <= 0 {
|
||||
SysError(fmt.Sprintf("%s must be positive, using default value: %d", name, fallback))
|
||||
SysError(LogText("%s must be positive, using default value: %d", name, fallback))
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -28,7 +28,7 @@ func New(ctx context.Context, r *redis.Client) *RedisLimiter {
|
||||
// 预加载脚本
|
||||
limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result()
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("Failed to load rate limit script: %v", err))
|
||||
common.SysLog(common.LogText("Failed to load rate limit script: %v", err))
|
||||
}
|
||||
instance = &RedisLimiter{
|
||||
client: r,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Server log lines are English. logTextZhCN holds the Chinese text of the
|
||||
// lines that have one, keyed by their English format.
|
||||
//
|
||||
//go:embed log_text.zh-CN.json
|
||||
var logTextZhCN []byte
|
||||
|
||||
// logTexts is the translation table DEFAULT_LANGUAGE selected; nil prints English.
|
||||
var logTexts map[string]string
|
||||
|
||||
// SetLogLanguage selects the language of server log lines. A language that
|
||||
// starts with "zh" prints the Chinese text; anything else prints English.
|
||||
func SetLogLanguage(language string) {
|
||||
logTexts = nil
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(language)), "zh") {
|
||||
return
|
||||
}
|
||||
_ = Unmarshal(logTextZhCN, &logTexts)
|
||||
}
|
||||
|
||||
// LogText formats a server log line in the language of DEFAULT_LANGUAGE. format is
|
||||
// the English text and the key of its translation in log_text.zh-CN.json.
|
||||
func LogText(format string, args ...any) string {
|
||||
if translated, ok := logTexts[format]; ok {
|
||||
return fmt.Sprintf(translated, args...)
|
||||
}
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
{
|
||||
"%d quota points": "%d 点额度",
|
||||
"%s after refresh: %s": "%s(刷新凭证后重试): %s",
|
||||
"%s failed: %s": "%s 构建失败,%s",
|
||||
"%s must be positive, using default value: %d": "%s 必须大于 0,已使用默认值: %d",
|
||||
"%s must be positive; using default": "%s 必须大于 0,已使用默认值",
|
||||
"%s%.6f quota": "%s%.6f 额度",
|
||||
"--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)": "--form 'file=@\"%s\"' (大小: %d 字节, content-type: %s)",
|
||||
"Add abilities for channel %d failed: %s": "修复渠道一致性: 为渠道 %d 重建 abilities 记录失败: %s",
|
||||
"Auto selected group: %s": "Auto 分组选定: %s",
|
||||
"Auto selecting group: %s, priorityRetry: %d": "Auto 分组尝试: %s, priorityRetry: %d",
|
||||
"Batch task %s already transitioned by another process, skip billing": "批量轮询: 任务 %s 的状态已由其他进程更新,跳过计费",
|
||||
"Batch task %s reached terminal status without data; preserving existing task data": "批量轮询: 任务 %s 已结束,但上游没有返回数据,保留已有的任务数据",
|
||||
"Batch task response ignored: unknown task_id=%s": "批量轮询: 上游返回了未知的 task_id=%s,已忽略",
|
||||
"CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d": "CacheUpdateChannel 更新后: id=%d, name=%s, status=%d, polling_index=%d",
|
||||
"CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d": "CacheUpdateChannel 更新前: id=%d, name=%s, status=%d, polling_index=%d",
|
||||
"Channel #%d failed to update video async tasks: %s": "渠道 #%d 更新视频异步任务失败: %s",
|
||||
"Channel #%d pending video tasks: %d": "渠道 #%d 有 %d 个视频任务未完成",
|
||||
"CheckUserExistOrDeleted error: %v": "CheckUserExistOrDeleted 出错: %v",
|
||||
"Client IP %s passed the token IP restrictions check": "客户端 IP %s 在令牌的 IP 白名单内",
|
||||
"Creem API response received trade_no=%s status_code=%d body=%q": "Creem API 响应已收到 trade_no=%s status_code=%d body=%q",
|
||||
"Creem callback customer email is empty trade_no=%s creem_order_id=%s": "Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s",
|
||||
"Creem callback customer name is empty trade_no=%s creem_order_id=%s": "Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s",
|
||||
"Creem failed to create payment link user_id=%d trade_no=%s product_id=%s error=%q": "Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q",
|
||||
"Creem failed to create subscription payment link trade_no=%s product_id=%s error=%q": "Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q",
|
||||
"Creem failed to create top-up order user_id=%d trade_no=%s product_id=%s error=%q": "Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q",
|
||||
"Creem failed to parse product config user_id=%d error=%q": "Creem 产品配置解析失败 user_id=%d error=%q",
|
||||
"Creem failed to read payment request error=%q": "Creem 支付请求读取失败 error=%q",
|
||||
"Creem failed to read subscription payment request error=%q": "Creem 订阅支付请求读取失败 error=%q",
|
||||
"Creem order not paid, ignored request_id=%s order_id=%s order_status=%s": "Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s",
|
||||
"Creem order type not supported, ignored request_id=%s creem_order_id=%s order_type=%s": "Creem 暂不支持该订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s",
|
||||
"Creem payment completed callback trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q": "Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q",
|
||||
"Creem payment link created trade_no=%s response_id=%s checkout_url=%q": "Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q",
|
||||
"Creem payment request received user_id=%d body=%q": "Creem 支付请求已收到 user_id=%d body=%q",
|
||||
"Creem payment request sent api_url=%s product_id=%s email=%q trade_no=%s": "Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s",
|
||||
"Creem subscription order processed trade_no=%s creem_order_id=%s": "Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s",
|
||||
"Creem subscription order processing failed trade_no=%s creem_order_id=%s error=%q": "Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q",
|
||||
"Creem top-up order created user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f": "Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f",
|
||||
"Creem top-up order not found trade_no=%s creem_order_id=%s": "Creem 充值订单不存在 trade_no=%s creem_order_id=%s",
|
||||
"Creem top-up order not pending, ignored trade_no=%s status=%s creem_order_id=%s": "Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s",
|
||||
"Creem top-up processing failed trade_no=%s creem_order_id=%s client_ip=%s error=%q": "Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q",
|
||||
"Creem top-up succeeded trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s": "Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s",
|
||||
"Creem using test environment api_url=%s": "Creem 使用测试环境 api_url=%s",
|
||||
"Creem webhook failed to parse path=%q client_ip=%s error=%q body=%q": "Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q",
|
||||
"Creem webhook failed to read request body path=%q client_ip=%s error=%q": "Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q",
|
||||
"Creem webhook ignored event event_type=%s event_id=%s": "Creem webhook 忽略事件 event_type=%s event_id=%s",
|
||||
"Creem webhook missing request_id event_id=%s order_id=%s": "Creem webhook 缺少 request_id event_id=%s order_id=%s",
|
||||
"Creem webhook missing signature path=%q client_ip=%s body=%q": "Creem webhook 缺少签名 path=%q client_ip=%s body=%q",
|
||||
"Creem webhook parsed event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s": "Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s",
|
||||
"Creem webhook received request path=%q client_ip=%s signature=%q body=%q": "Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Creem webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Creem webhook secret is not configured test_mode=%t signature=%q body=%q": "Creem webhook secret 未配置 test_mode=%t signature=%q body=%q",
|
||||
"Creem webhook signature check skipped reason=test_mode signature=%q body=%q": "Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q",
|
||||
"Creem webhook signature verification failed path=%q client_ip=%s signature=%q body=%q": "Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Creem webhook signature verified path=%q client_ip=%s": "Creem webhook 验签成功 path=%q client_ip=%s",
|
||||
"Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry": "当前分组 %s 重试次数已用完 (priorityRetry=%d >= RetryTimes=%d),下次重试将切换到下一个分组",
|
||||
"Database pinged successfully": "数据库连接正常",
|
||||
"Delete abilities failed: %s": "修复渠道一致性: 删除 abilities 记录失败: %s",
|
||||
"Do gemini request failed: %s": "请求 Gemini 失败: %s",
|
||||
"Epay callback order not found trade_no=%s callback_type=%s client_ip=%s verify_info=%q": "易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q",
|
||||
"Epay client is not initialized path=%q client_ip=%s": "易支付 client 未初始化 path=%q client_ip=%s",
|
||||
"Epay duplicate callback ignored trade_no=%s callback_type=%s client_ip=%s": "易支付 重复回调幂等忽略 trade_no=%s callback_type=%s client_ip=%s",
|
||||
"Epay failed to create top-up order user_id=%d trade_no=%s payment_method=%s amount=%d error=%q": "易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q",
|
||||
"Epay failed to start payment user_id=%d trade_no=%s payment_method=%s amount=%d error=%q": "易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q",
|
||||
"Epay order payment gateway mismatch trade_no=%s callback_type=%s client_ip=%s": "易支付 订单支付网关不匹配 trade_no=%s callback_type=%s client_ip=%s",
|
||||
"Epay order status invalid trade_no=%s callback_type=%s client_ip=%s": "易支付 订单状态非法 trade_no=%s callback_type=%s client_ip=%s",
|
||||
"Epay top-up order created user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q": "易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q",
|
||||
"Epay top-up processing failed trade_no=%s client_ip=%s error=%q": "易支付 充值处理失败 trade_no=%s client_ip=%s error=%q",
|
||||
"Epay top-up succeeded trade_no=%s callback_type=%s client_ip=%s": "易支付 充值成功 trade_no=%s callback_type=%s client_ip=%s",
|
||||
"Epay top-up succeeded trade_no=%s user_id=%d quota_to_add=%d money=%.2f": "易支付充值成功 trade_no=%s user_id=%d quota_to_add=%d money=%.2f",
|
||||
"Epay webhook failed to parse POST form path=%q client_ip=%s error=%q": "易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q",
|
||||
"Epay webhook failed to write response path=%q client_ip=%s error=%q": "易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q",
|
||||
"Epay webhook failed to write response trade_no=%s client_ip=%s error=%q": "易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q",
|
||||
"Epay webhook ignored event trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q": "易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q",
|
||||
"Epay webhook params are empty path=%q client_ip=%s": "易支付 webhook 参数为空 path=%q client_ip=%s",
|
||||
"Epay webhook received request path=%q client_ip=%s method=%s params=%q": "易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q",
|
||||
"Epay webhook rejected reason=webhook_disabled path=%q client_ip=%s": "易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Epay webhook signature verification failed path=%q client_ip=%s verify_error=%q": "易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q",
|
||||
"Epay webhook signature verification failed path=%q client_ip=%s verify_status=false": "易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false",
|
||||
"Epay webhook signature verified trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q": "易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q",
|
||||
"Error getting sql.DB from GORM: %v": "从 GORM 取得 sql.DB 失败: %v",
|
||||
"Error pinging DB: %v": "数据库 ping 失败: %v",
|
||||
"Error: Failed to decode base64 string": "base64 字符串解码失败",
|
||||
"FRONTEND_BASE_URL is ignored on master node": "当前是主节点,FRONTEND_BASE_URL 设置已忽略",
|
||||
"Failed to get binding count for provider %s: %s": "查询 OAuth 提供商 %s 的绑定用户数失败: %s",
|
||||
"Failed to load custom OAuth providers: %s": "加载自定义 OAuth 提供商失败: %s",
|
||||
"Failed to load rate limit script: %v": "加载限流脚本失败: %v",
|
||||
"Failed to project plugin video for task %s": "读取任务 %s 的制品列表失败",
|
||||
"Failed to query task %s: %s": "查询任务 %s 失败: %s",
|
||||
"Failed to resolve plugin video content for task %s": "任务 %s: 生成视频内容的下载请求失败",
|
||||
"Failed to stream image: %v": "向客户端转发图片失败: %v",
|
||||
"Failed to stream task media: %v": "向客户端转发任务媒体文件失败: %v",
|
||||
"Failed to update task %s: %s": "更新任务 %s 失败: %s",
|
||||
"Failed to update video task %s: %s": "更新视频任务 %s 失败: %s",
|
||||
"Failed to write to disk cache, falling back to memory: %v": "写入磁盘缓存失败,改用内存缓存: %v",
|
||||
"File cached to disk: %s, size: %d bytes": "文件已缓存到磁盘: %s,大小: %d 字节",
|
||||
"Fix null mj_id task error: %v": "修复 mj_id 为空的任务出错: %v",
|
||||
"Fix null mj_id task success: %v": "修复 mj_id 为空的任务成功: %v",
|
||||
"Fix null task_id task error: %v": "修复 task_id 为空的任务出错: %v",
|
||||
"Fix null task_id task success: %v": "修复 task_id 为空的任务成功: %v",
|
||||
"Gemini embedding request body: %s": "Gemini 嵌入请求体: %s",
|
||||
"Gemini native embedding response body: %s": "Gemini 原生格式嵌入响应体: %s",
|
||||
"Gemini native response body: %s": "Gemini 原生格式响应体: %s",
|
||||
"Gemini request body: %s": "Gemini 请求体: %s",
|
||||
"Gemini response body: %s": "Gemini 响应体: %s",
|
||||
"Gemini responses response body: %s": "Gemini Responses 响应体: %s",
|
||||
"Gemini stream ended unexpectedly: %s": "Gemini 流式响应异常中断: %s",
|
||||
"Get Mjp Task parse body error2: %v, body: %s": "解析 Midjourney 任务查询响应失败: %v,响应体: %s",
|
||||
"Get Mjp Task parse body error: %v": "读取 Midjourney 任务查询响应失败: %v",
|
||||
"Get Suno Task parse body error: %v": "读取 Suno 任务查询响应失败: %v",
|
||||
"Get Task Do req error: %v": "发送任务查询请求失败: %v",
|
||||
"Get Task error: %v": "创建任务查询请求失败: %v",
|
||||
"Get Task marshal body error: %v": "序列化任务查询请求体失败: %v",
|
||||
"Get Task status code: %d": "任务查询返回状态码 %d",
|
||||
"GetAllEnableAbilityWithChannels error: %v": "GetAllEnableAbilityWithChannels 出错: %v",
|
||||
"GetPasskeyByCredentialID: database error for credential ID: %v": "GetPasskeyByCredentialID: 按凭证 ID 查询数据库出错: %v",
|
||||
"GetPasskeyByCredentialID: empty credential ID": "GetPasskeyByCredentialID: 凭证 ID 为空",
|
||||
"GetPasskeyByCredentialID: passkey not found for credential ID length %d": "GetPasskeyByCredentialID: 未找到凭证 ID 对应的通行密钥,凭证 ID 长度 %d",
|
||||
"GetPasskeyByUserID: database error for user %d: %v": "GetPasskeyByUserID: 查询用户 %d 时数据库出错: %v",
|
||||
"GetPasskeyByUserID: empty user ID": "GetPasskeyByUserID: 用户 ID 为空",
|
||||
"GetPreferredModelOwnerChannelTypes error: %v": "GetPreferredModelOwnerChannelTypes 出错: %v",
|
||||
"InitChannelCache failed: %s": "InitChannelCache 失败: %s",
|
||||
"InitChannelCache panic: %v": "InitChannelCache 发生 panic: %v",
|
||||
"InitChannelCache panic: %v, retrying once": "InitChannelCache 发生 panic: %v,重试一次",
|
||||
"LoadFileSource starting for: %s": "LoadFileSource 开始加载: %s",
|
||||
"Loaded %d custom OAuth providers": "已加载 %d 个自定义 OAuth 提供商",
|
||||
"Loaded custom OAuth provider: %s (%s)": "已加载自定义 OAuth 提供商: %s (%s)",
|
||||
"Local:": "本机地址:",
|
||||
"Login database error for user %s: %v": "用户 %s 登录时数据库出错: %v",
|
||||
"Midjourney action uses origin channel: id=%s, base_url=%s": "Midjourney 后续操作使用原任务所在的渠道: id=%s, base_url=%s",
|
||||
"Midjourney refund succeeded but clearing the quota failed, task %s: %s": "Midjourney 退款成功但清除 quota 失败 task %s: %s",
|
||||
"Midjourney task response ignored: unknown mj_id=%s": "上游返回了未知的 Midjourney 任务 mj_id=%s,已忽略",
|
||||
"Network:": "网络地址:",
|
||||
"New API %s started": "New API %s 已启动",
|
||||
"No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.": "未找到 .env 文件,直接使用进程的环境变量。需要时可以新建 .env 文件,把变量写在里面。",
|
||||
"No available channel in group %s for model %s at priorityRetry %d, trying next group": "分组 %s 没有模型 %s 的可用渠道 (priorityRetry=%d),尝试下一个分组",
|
||||
"No update needed for task %s": "任务 %s 没有变化,跳过更新",
|
||||
"OpenRouter parse failed from %s: %s": "解析 %s 返回的 OpenRouter 数据失败: %s",
|
||||
"PasskeyLogin: userHandle parse error for credential, length: %d": "PasskeyLogin: 凭证的 userHandle 无法解析为用户 ID,长度: %d",
|
||||
"Please set SESSION_SECRET to a random string.": "请将 SESSION_SECRET 设置为随机字符串。",
|
||||
"REDIS_CONN_STRING not set, Redis is not enabled": "未设置 REDIS_CONN_STRING,Redis 未启用",
|
||||
"Redis connected to %s": "Redis 已连接到 %s",
|
||||
"Redis database: %d": "Redis 数据库: %d",
|
||||
"Redis is enabled": "Redis 已启用",
|
||||
"Redis ping test failed: %s": "Redis 连接测试失败: %s",
|
||||
"RemoveDisabledFields Marshal error :%s": "RemoveDisabledFields 序列化失败 :%s",
|
||||
"RemoveDisabledFields Unmarshal error :%s": "RemoveDisabledFields 反序列化失败 :%s",
|
||||
"RemoveGeminiDisabledFields Marshal error: %s": "RemoveGeminiDisabledFields 序列化失败: %s",
|
||||
"RemoveGeminiDisabledFields Unmarshal error: %s": "RemoveGeminiDisabledFields 反序列化失败: %s",
|
||||
"Rerank request body: %s": "重排序请求体: %s",
|
||||
"SKIP_64BIT_QUOTA_SCHEMA_CHECK=true; skipping user quota schema check": "已设置 SKIP_64BIT_QUOTA_SCHEMA_CHECK=true,跳过用户额度字段的类型检查",
|
||||
"SQL_DSN not set, using SQLite as database": "未设置 SQL_DSN,数据库使用 SQLite",
|
||||
"SSE ping data sent": "SSE ping 数据已发送",
|
||||
"SSE ping error, stopping goroutine: %s": "SSE ping 出错,停止 goroutine: %s",
|
||||
"SSE ping error: %s": "SSE ping 出错: %s",
|
||||
"SSE ping goroutine panic recovered: %v": "SSE ping goroutine 发生 panic,已恢复: %v",
|
||||
"SSE ping goroutine started": "SSE ping goroutine 已启动",
|
||||
"SSE ping goroutine stopped": "SSE ping goroutine 已停止",
|
||||
"SSE ping goroutine stopped by defer": "SSE ping goroutine 已由 defer 停止",
|
||||
"SSE ping goroutine timeout, stopping": "SSE ping goroutine 超时,正在停止",
|
||||
"SSE ping ticker stopped": "SSE ping ticker 已停止",
|
||||
"SYNC_FREQUENCY not set, use default value 60": "未设置 SYNC_FREQUENCY,使用默认值 60",
|
||||
"SetPayloadHash body: %s": "SetPayloadHash 请求体: %s",
|
||||
"Stripe Checkout payment not completed, waiting for async result trade_no=%s payment_status=%s client_ip=%s": "Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s",
|
||||
"Stripe async payment failed but local order not found trade_no=%s client_ip=%s": "Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s",
|
||||
"Stripe async payment failed but order not pending, ignored trade_no=%s status=%s client_ip=%s": "Stripe 异步支付失败但订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s",
|
||||
"Stripe async payment failed but order payment gateway mismatch trade_no=%s payment_provider=%s client_ip=%s": "Stripe 异步支付失败但订单支付网关不匹配 trade_no=%s payment_provider=%s client_ip=%s",
|
||||
"Stripe async payment failed event missing order number client_ip=%s": "Stripe 异步支付失败事件缺少订单号 client_ip=%s",
|
||||
"Stripe async payment failed trade_no=%s client_ip=%s": "Stripe 异步支付失败 trade_no=%s client_ip=%s",
|
||||
"Stripe async payment succeeded trade_no=%s client_ip=%s": "Stripe 异步支付成功 trade_no=%s client_ip=%s",
|
||||
"Stripe checkout.completed has unexpected status, ignored trade_no=%s status=%s client_ip=%s": "Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s",
|
||||
"Stripe checkout.expired has unexpected status, ignored trade_no=%s status=%s": "Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s",
|
||||
"Stripe checkout.expired missing order number": "Stripe checkout.expired 缺少订单号",
|
||||
"Stripe failed to create Checkout Session user_id=%d trade_no=%s amount=%d error=%q": "Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q",
|
||||
"Stripe failed to create subscription payment link trade_no=%s plan_id=%d error=%q": "Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q",
|
||||
"Stripe failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
|
||||
"Stripe failed to mark top-up order as failed trade_no=%s client_ip=%s error=%q": "Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q",
|
||||
"Stripe order completion missing order number client_ip=%s": "Stripe 完成订单时缺少订单号 client_ip=%s",
|
||||
"Stripe subscription order expired trade_no=%s": "Stripe 订阅订单已过期 trade_no=%s",
|
||||
"Stripe subscription order expiry processing failed trade_no=%s error=%q": "Stripe 订阅订单过期处理失败 trade_no=%s error=%q",
|
||||
"Stripe subscription order processed trade_no=%s event_type=%s client_ip=%s": "Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s",
|
||||
"Stripe subscription order processing failed trade_no=%s event_type=%s client_ip=%s error=%q": "Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q",
|
||||
"Stripe top-up order created user_id=%d trade_no=%s amount=%d money=%.2f": "Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f",
|
||||
"Stripe top-up order expired trade_no=%s": "Stripe 充值订单已过期 trade_no=%s",
|
||||
"Stripe top-up order expiry processing failed trade_no=%s error=%q": "Stripe 充值订单过期处理失败 trade_no=%s error=%q",
|
||||
"Stripe top-up order marked as failed trade_no=%s client_ip=%s": "Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s",
|
||||
"Stripe top-up order not found, cannot mark as expired trade_no=%s": "Stripe 充值订单不存在,无法标记过期 trade_no=%s",
|
||||
"Stripe top-up processing failed trade_no=%s event_type=%s client_ip=%s error=%q": "Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q",
|
||||
"Stripe top-up succeeded trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s": "Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s",
|
||||
"Stripe webhook failed to read request body path=%q client_ip=%s error=%q": "Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q",
|
||||
"Stripe webhook ignored event event_type=%s client_ip=%s": "Stripe webhook 忽略事件 event_type=%s client_ip=%s",
|
||||
"Stripe webhook received request path=%q client_ip=%s signature=%q body=%q": "Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Stripe webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Stripe webhook signature verification failed path=%q client_ip=%s error=%q": "Stripe webhook 验签失败 path=%q client_ip=%s error=%q",
|
||||
"Stripe webhook signature verified event_type=%s client_ip=%s path=%q": "Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q",
|
||||
"Successfully migrated %s.%s to decimal(10,6)": "字段 %s.%s 的类型已改为 decimal(10,6)",
|
||||
"Successfully migrated %s.%s to text": "字段 %s.%s 的类型已改为 text",
|
||||
"Task %s CAS lost or no-op update, skip billing": "任务 %s 的状态已由其他进程更新或没有变化,跳过计费",
|
||||
"Task %s failed": "任务 %s 失败",
|
||||
"Task %s failed: %s": "任务 %s 失败: %s",
|
||||
"Task %s not found in taskM": "任务 %s 不在本次轮询的任务列表中",
|
||||
"Token has IP restrictions, checking client IP %s": "令牌设置了 IP 白名单,检查客户端 IP %s",
|
||||
"TokenAuth GetUserCache error for user %d: %v": "TokenAuth GetUserCache 获取用户 %d 出错: %v",
|
||||
"TokenAuth ValidateUserToken database error: %s": "TokenAuth ValidateUserToken 数据库出错: %s",
|
||||
"TokenAuthReadOnly GetTokenByKey database error: %s": "TokenAuthReadOnly GetTokenByKey 数据库出错: %s",
|
||||
"TokenAuthReadOnly GetUserCache error for user %d: %v": "TokenAuthReadOnly GetUserCache 获取用户 %d 出错: %v",
|
||||
"Truncate abilities failed: %s": "修复渠道一致性: 清空 abilities 表失败: %s",
|
||||
"Trying to read %d bytes to determine file type": "读取前 %d 字节来判断文件类型",
|
||||
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d": "USER_SESSION_ISSUANCE_WINDOW_SECONDS 大于已吊销会话的保留时长,已按保留时长生效: configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
|
||||
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d": "USER_SESSION_REVOKED_RETENTION_DAYS 的值过大,已使用默认值: %d",
|
||||
"UpdateBatchTasks fail: %s": "UpdateBatchTasks 失败: %s",
|
||||
"UpdateMidjourneyTask error: %v": "UpdateMidjourneyTask 出错: %v",
|
||||
"UpdateMidjourneyTask task error: %s": "UpdateMidjourneyTask 更新任务出错: %s",
|
||||
"UpdateSunoTask error: %v": "UpdateSunoTask 出错: %v",
|
||||
"UpdateSunoTask task error: %s": "UpdateSunoTask 更新任务出错: %s",
|
||||
"UpdateVideoTask error: %v": "UpdateVideoTask 出错: %v",
|
||||
"UpdateVideoTasks fail: %s": "UpdateVideoTasks 失败: %s",
|
||||
"UpdateWithStatus failed for task %s: %s": "更新任务 %s 的状态失败 (UpdateWithStatus): %s",
|
||||
"UpsertPasskeyCredential: failed to create credential for user %d: %v": "UpsertPasskeyCredential: 为用户 %d 创建凭证失败: %v",
|
||||
"UpsertPasskeyCredential: failed to delete existing credential for user %d: %v": "UpsertPasskeyCredential: 删除用户 %d 的现有凭证失败: %v",
|
||||
"ValidateUserToken: failed to get token: %s": "ValidateUserToken: 获取令牌失败: %s",
|
||||
"WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.": "警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。",
|
||||
"WARNING: TRUSTED_PROXIES is unset or blank; trusting loopback, RFC 1918, and IPv6 ULA proxy addresses for compatibility. Set TRUSTED_PROXIES=none to trust no proxies, or configure explicit proxy IPs/CIDRs to replace these defaults.": "警告:TRUSTED_PROXIES 未设置或为空。为兼容已有部署,来自回环地址、RFC 1918 内网地址和 IPv6 ULA 地址的代理会被信任。设置 TRUSTED_PROXIES=none 表示任何代理都不信任,也可以填写代理的 IP 或 CIDR 来替换这组默认值。",
|
||||
"Waffo Pancake failed to create checkout session user_id=%d trade_no=%s error=%q": "Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q",
|
||||
"Waffo Pancake failed to create plan product store_id=%q name=%q amount=%q error=%q": "Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q",
|
||||
"Waffo Pancake failed to create store and product orphan_store=%t store_id=%q error=%q": "Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q",
|
||||
"Waffo Pancake failed to create subscription checkout session user_id=%d plan_id=%d trade_no=%s error=%q": "Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q",
|
||||
"Waffo Pancake failed to create subscription order user_id=%d plan_id=%d trade_no=%s error=%q": "Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q",
|
||||
"Waffo Pancake failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
|
||||
"Waffo Pancake failed to fetch store and product catalog error=%q": "Waffo Pancake 拉取店铺与产品目录失败 error=%q",
|
||||
"Waffo Pancake failed to fetch subscription product list store_id=%q error=%q": "Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q",
|
||||
"Waffo Pancake failed to save config store_id=%q product_id=%q error=%q": "Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q",
|
||||
"Waffo Pancake subscription completed trade_no=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s",
|
||||
"Waffo Pancake subscription completion failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q": "Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q",
|
||||
"Waffo Pancake subscription order created user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f": "Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f",
|
||||
"Waffo Pancake top-up order created user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f": "Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f",
|
||||
"Waffo Pancake top-up processing failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q": "Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q",
|
||||
"Waffo Pancake top-up succeeded trade_no=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s",
|
||||
"Waffo Pancake webhook env mismatch expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s": "Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
|
||||
"Waffo Pancake webhook failed to read request body path=%q client_ip=%s error=%q": "Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q",
|
||||
"Waffo Pancake webhook failed to resolve order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q": "Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
"Waffo Pancake webhook failed to resolve subscription order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q": "Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
"Waffo Pancake webhook invalid env path segment env=%q path=%q client_ip=%s": "Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s",
|
||||
"Waffo Pancake webhook received request path=%q client_ip=%s signature=%q body=%q": "Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Waffo Pancake webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Waffo Pancake webhook signature verification failed path=%q client_ip=%s signature=%q body=%q error=%q": "Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q",
|
||||
"Waffo Pancake webhook signature verified event_type=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s",
|
||||
"Waffo SDK initialization failed user_id=%d trade_no=%s error=%q": "Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q",
|
||||
"Waffo failed to create order user_id=%d trade_no=%s error=%q": "Waffo 创建订单失败 user_id=%d trade_no=%s error=%q",
|
||||
"Waffo failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
|
||||
"Waffo failed to mark order as failed trade_no=%s error=%q": "Waffo 标记失败订单状态失败 trade_no=%s error=%q",
|
||||
"Waffo failed to parse payment callback payload event_type=%s client_ip=%s error=%q body=%q": "Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q",
|
||||
"Waffo invalid payment method index user_id=%d pay_method_index=%d method_count=%d": "Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d",
|
||||
"Waffo invalid payment method user_id=%d pay_method_type=%s pay_method_name=%q": "Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q",
|
||||
"Waffo order creation rejected user_id=%d trade_no=%s code=%s message=%q response=%q": "Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q",
|
||||
"Waffo order not successful, top-up skipped trade_no=%s order_status=%s client_ip=%s": "Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s",
|
||||
"Waffo top-up order created user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q": "Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q",
|
||||
"Waffo top-up processing failed trade_no=%s client_ip=%s error=%q": "Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q",
|
||||
"Waffo top-up succeeded trade_no=%s client_ip=%s": "Waffo 充值成功 trade_no=%s client_ip=%s",
|
||||
"Waffo webhook SDK initialization failed path=%q client_ip=%s error=%q": "Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q",
|
||||
"Waffo webhook failed to parse path=%q client_ip=%s error=%q body=%q": "Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q",
|
||||
"Waffo webhook failed to read request body path=%q client_ip=%s error=%q": "Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q",
|
||||
"Waffo webhook ignored event event_type=%s client_ip=%s": "Waffo webhook 忽略事件 event_type=%s client_ip=%s",
|
||||
"Waffo webhook received request path=%q client_ip=%s signature=%q body=%q": "Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Waffo webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Waffo webhook signature verification failed path=%q client_ip=%s signature=%q body=%q": "Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Waffo webhook verified and parsed event_type=%s merchant_order_id=%s order_status=%s client_ip=%s": "Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s",
|
||||
"Warning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.": "警告:SESSION_COOKIE_SECURE 未开启。登录 Cookie 可经 HTTP 明文传输,续期登录和退出登录接口接受任意来源(Origin)的请求。生产环境请设置 SESSION_COOKIE_SECURE=true。",
|
||||
"Warning: failed to migrate %s.%s to decimal: %v": "警告:把字段 %s.%s 的类型改为 decimal 失败: %v",
|
||||
"Warning: failed to query metadata for %s.%s: %v": "警告:查询字段 %s.%s 的类型信息失败: %v",
|
||||
"[OAuth-Discord] ExchangeToken decode error: %s": "[OAuth-Discord] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-Discord] ExchangeToken error: %s": "[OAuth-Discord] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-Discord] ExchangeToken failed: empty access token": "[OAuth-Discord] ExchangeToken 失败: 响应里没有 access token",
|
||||
"[OAuth-Discord] ExchangeToken response status: %d": "[OAuth-Discord] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-Discord] ExchangeToken success: scope=%s": "[OAuth-Discord] ExchangeToken 成功: scope=%s",
|
||||
"[OAuth-Discord] GetUserInfo decode error: %s": "[OAuth-Discord] GetUserInfo 解析响应失败: %s",
|
||||
"[OAuth-Discord] GetUserInfo error: %s": "[OAuth-Discord] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-Discord] GetUserInfo failed: empty user fields": "[OAuth-Discord] GetUserInfo 失败: 返回的用户信息为空",
|
||||
"[OAuth-Discord] GetUserInfo failed: status=%d": "[OAuth-Discord] GetUserInfo 失败: status=%d",
|
||||
"[OAuth-Discord] GetUserInfo response status: %d": "[OAuth-Discord] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-Discord] GetUserInfo success: uid=%s, username=%s, name=%s": "[OAuth-Discord] GetUserInfo 成功: uid=%s, username=%s, name=%s",
|
||||
"[OAuth-Discord] GetUserInfo: fetching user info": "[OAuth-Discord] GetUserInfo: 开始获取用户信息",
|
||||
"[OAuth-Generic-%s] ExchangeToken OAuth error: %s - %s": "[OAuth-Generic-%s] ExchangeToken 提供商返回错误: %s - %s",
|
||||
"[OAuth-Generic-%s] ExchangeToken error: %s": "[OAuth-Generic-%s] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-Generic-%s] ExchangeToken failed: empty access token": "[OAuth-Generic-%s] ExchangeToken 失败: 响应里没有 access token",
|
||||
"[OAuth-Generic-%s] ExchangeToken parse error: %s": "[OAuth-Generic-%s] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-Generic-%s] ExchangeToken read body error: %s": "[OAuth-Generic-%s] ExchangeToken 读取响应体失败: %s",
|
||||
"[OAuth-Generic-%s] ExchangeToken response status: %d": "[OAuth-Generic-%s] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-Generic-%s] ExchangeToken success: scope=%s": "[OAuth-Generic-%s] ExchangeToken 成功: scope=%s",
|
||||
"[OAuth-Generic-%s] GetUserInfo error: %s": "[OAuth-Generic-%s] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-Generic-%s] GetUserInfo failed: empty user ID (field: %s)": "[OAuth-Generic-%s] GetUserInfo 失败: 用户 ID 为空 (字段: %s)",
|
||||
"[OAuth-Generic-%s] GetUserInfo failed: status=%d": "[OAuth-Generic-%s] GetUserInfo 失败: status=%d",
|
||||
"[OAuth-Generic-%s] GetUserInfo read body error: %s": "[OAuth-Generic-%s] GetUserInfo 读取响应体失败: %s",
|
||||
"[OAuth-Generic-%s] GetUserInfo response status: %d": "[OAuth-Generic-%s] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-Generic-%s] GetUserInfo success: id=%s, username=%s, name=%s, email=%s": "[OAuth-Generic-%s] GetUserInfo 成功: id=%s, username=%s, name=%s, email=%s",
|
||||
"[OAuth-Generic-%s] GetUserInfo: fetching user info from %s": "[OAuth-Generic-%s] GetUserInfo: 开始从 %s 获取用户信息",
|
||||
"[OAuth-Generic-%s] access denied by policy: field=%s op=%s expected=%v current=%v": "[OAuth-Generic-%s] 不满足访问策略,已拒绝登录: field=%s op=%s expected=%v current=%v",
|
||||
"[OAuth-Generic-%s] invalid access policy: %s": "[OAuth-Generic-%s] 访问策略配置无效: %s",
|
||||
"[OAuth-GitHub] ExchangeToken decode error: %s": "[OAuth-GitHub] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-GitHub] ExchangeToken error: %s": "[OAuth-GitHub] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-GitHub] ExchangeToken failed: empty access token": "[OAuth-GitHub] ExchangeToken 失败: 响应里没有 access token",
|
||||
"[OAuth-GitHub] ExchangeToken response status: %d": "[OAuth-GitHub] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-GitHub] ExchangeToken success: scope=%s": "[OAuth-GitHub] ExchangeToken 成功: scope=%s",
|
||||
"[OAuth-GitHub] GetUserInfo decode error: %s": "[OAuth-GitHub] GetUserInfo 解析响应失败: %s",
|
||||
"[OAuth-GitHub] GetUserInfo error: %s": "[OAuth-GitHub] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-GitHub] GetUserInfo failed: empty id or login field": "[OAuth-GitHub] GetUserInfo 失败: id 或 login 字段为空",
|
||||
"[OAuth-GitHub] GetUserInfo failed: status=%d, body=%s": "[OAuth-GitHub] GetUserInfo 失败: status=%d, body=%s",
|
||||
"[OAuth-GitHub] GetUserInfo response status: %d": "[OAuth-GitHub] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-GitHub] GetUserInfo success: id=%d, login=%s, name=%s, email=%s": "[OAuth-GitHub] GetUserInfo 成功: id=%d, login=%s, name=%s, email=%s",
|
||||
"[OAuth-GitHub] GetUserInfo: fetching user info": "[OAuth-GitHub] GetUserInfo: 开始获取用户信息",
|
||||
"[OAuth-GitHub] GetVerifiedEmails decode error: %s": "[OAuth-GitHub] GetVerifiedEmails 解析响应失败: %s",
|
||||
"[OAuth-GitHub] GetVerifiedEmails error: %s": "[OAuth-GitHub] GetVerifiedEmails 请求失败: %s",
|
||||
"[OAuth-GitHub] GetVerifiedEmails failed: status=%d": "[OAuth-GitHub] GetVerifiedEmails 失败: status=%d",
|
||||
"[OAuth-GitHub] GetVerifiedEmails success: verified=%d": "[OAuth-GitHub] GetVerifiedEmails 成功: verified=%d",
|
||||
"[OAuth-LinuxDO] ExchangeToken decode error: %s": "[OAuth-LinuxDO] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-LinuxDO] ExchangeToken error: %s": "[OAuth-LinuxDO] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-LinuxDO] ExchangeToken failed: %s": "[OAuth-LinuxDO] ExchangeToken 失败: %s",
|
||||
"[OAuth-LinuxDO] ExchangeToken response status: %d": "[OAuth-LinuxDO] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-LinuxDO] ExchangeToken success": "[OAuth-LinuxDO] ExchangeToken 成功",
|
||||
"[OAuth-LinuxDO] GetUserInfo decode error: %s": "[OAuth-LinuxDO] GetUserInfo 解析响应失败: %s",
|
||||
"[OAuth-LinuxDO] GetUserInfo error: %s": "[OAuth-LinuxDO] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-LinuxDO] GetUserInfo failed: invalid user id": "[OAuth-LinuxDO] GetUserInfo 失败: 用户 id 无效",
|
||||
"[OAuth-LinuxDO] GetUserInfo response status: %d": "[OAuth-LinuxDO] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-LinuxDO] GetUserInfo success: id=%d, username=%s": "[OAuth-LinuxDO] GetUserInfo 成功: id=%d, username=%s",
|
||||
"[OAuth-LinuxDO] GetUserInfo: trust level too low (required=%d, current=%d)": "[OAuth-LinuxDO] GetUserInfo: 信任级别不足 (required=%d, current=%d)",
|
||||
"[OAuth-OIDC] ExchangeToken decode error: %s": "[OAuth-OIDC] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-OIDC] ExchangeToken error: %s": "[OAuth-OIDC] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-OIDC] ExchangeToken failed: empty access token": "[OAuth-OIDC] ExchangeToken 失败: 响应里没有 access token",
|
||||
"[OAuth-OIDC] ExchangeToken response status: %d": "[OAuth-OIDC] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-OIDC] ExchangeToken success: scope=%s": "[OAuth-OIDC] ExchangeToken 成功: scope=%s",
|
||||
"[OAuth-OIDC] GetUserInfo decode error: %s": "[OAuth-OIDC] GetUserInfo 解析响应失败: %s",
|
||||
"[OAuth-OIDC] GetUserInfo error: %s": "[OAuth-OIDC] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-OIDC] GetUserInfo failed: empty fields (sub=%s, email=%s)": "[OAuth-OIDC] GetUserInfo 失败: 字段为空 (sub=%s, email=%s)",
|
||||
"[OAuth-OIDC] GetUserInfo failed: status=%d": "[OAuth-OIDC] GetUserInfo 失败: status=%d",
|
||||
"[OAuth-OIDC] GetUserInfo response status: %d": "[OAuth-OIDC] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-OIDC] GetUserInfo success: sub=%s, username=%s, name=%s, email=%s": "[OAuth-OIDC] GetUserInfo 成功: sub=%s, username=%s, name=%s, email=%s",
|
||||
"[OAuth] Failed to load verified emails for user %d: %s": "[OAuth] 加载用户 %d 的已验证邮箱失败: %s",
|
||||
"access token last-use update failed (token_id=%d): %v": "更新访问令牌最后使用时间失败 (token_id=%d): %v",
|
||||
"access token route is not declared: %s": "这条路由没有声明访问令牌的访问规则,已拒绝: %s",
|
||||
"admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t": "管理员重置了订阅套餐 %d 的额度: reset_count=%d user_count=%d advance_reset_time=%t",
|
||||
"admin subscription creation": "管理员创建订阅",
|
||||
"admin subscription deletion": "管理员删除订阅",
|
||||
"admin subscription update": "管理员修改订阅",
|
||||
"api_version not found, using default: %s": "未找到 api_version,使用默认值: %s",
|
||||
"audit actor role unavailable (request_id=%s, actor_role=%d)": "无法识别审计日志的操作者角色 (request_id=%s, actor_role=%d)",
|
||||
"audit log write failed (request_id=%s): %v": "审计日志写入失败 (request_id=%s): %v",
|
||||
"audit log write failed (request_id=%s): log database unavailable": "审计日志写入失败 (request_id=%s): 日志数据库不可用",
|
||||
"auth session internal error (%s %s): %v": "登录会话接口内部错误 (%s %s): %v",
|
||||
"authorization policy %d has an unsupported legacy scope; loaded as deny; review the stored policy before granting access": "授权策略 %d 带有已不支持的旧范围设置,已按拒绝(deny)加载。放行前请先检查这条已保存的策略。",
|
||||
"bad response status code %d with empty error message, body: %s": "上游返回状态码 %d,错误信息为空,响应体: %s",
|
||||
"bad response status code %d, body: %s": "上游返回异常状态码 %d,响应体: %s",
|
||||
"batch update enabled with interval %ss": "已启用批量更新,每 %s 秒执行一次",
|
||||
"batch update finished": "批量更新完成",
|
||||
"batch update overflow: type=%d id=%d old=%d value=%d": "批量更新的累加值溢出: type=%d id=%d old=%d value=%d",
|
||||
"batch update started": "批量更新开始",
|
||||
"billable tool count ignored: tool=%s count=%d": "工具计费次数无效,已忽略: tool=%s count=%d",
|
||||
"build initial plugin router: %s": "初始化插件路由失败: %s",
|
||||
"build request failed: %s": "创建请求失败: %s",
|
||||
"build task plugin native submit view failed: %s": "任务插件提交响应: 构建任务数据失败: %s",
|
||||
"build task protocol relay info failed: %s": "任务协议: 构建请求上下文失败: %s",
|
||||
"build task protocol renderer context failed": "任务协议: 构建渲染钩子的上下文失败",
|
||||
"build task protocol view failed: %s": "任务协议: 构建任务数据失败: %s",
|
||||
"channel #%d failed to update async tasks: %s": "渠道 #%d 更新异步任务失败: %s",
|
||||
"channel #%d has %d unfinished tasks": "渠道 #%d 未完成的任务有: %d",
|
||||
"channel %d polling index: %d": "渠道 %d 的多密钥轮询位置: %d",
|
||||
"channel %q (#%d) failed, disabling it, reason: %s": "通道「%s」(#%d)发生错误,准备禁用,原因:%s",
|
||||
"channel %q (#%d) has automatic disabling turned off, skipping": "通道「%s」(#%d)未启用自动禁用功能,跳过禁用操作",
|
||||
"channel affinity cache delete current failed: err=%v": "渠道亲和性缓存: 删除当前请求的记录失败: err=%v",
|
||||
"channel affinity cache delete many failed: err=%v": "渠道亲和性缓存: 批量删除失败: err=%v",
|
||||
"channel affinity cache get failed: key=%s, err=%v": "渠道亲和性缓存: 读取失败: key=%s, err=%v",
|
||||
"channel affinity cache list keys failed: err=%v": "渠道亲和性缓存: 列出缓存键失败: err=%v",
|
||||
"channel affinity cache set failed: key=%s, err=%v": "渠道亲和性缓存: 写入失败: key=%s, err=%v",
|
||||
"channel error (channel #%d, status code: %d): %s": "渠道请求出错 (渠道 #%d,状态码 %d): %s",
|
||||
"channel pin overridden: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d": "请求被多处指定了渠道,已按优先级采用其中一个: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d",
|
||||
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v": "渠道测试响应异常: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
|
||||
"channels synced from database": "已从数据库同步渠道",
|
||||
"channels update done": "所有渠道的余额更新完成",
|
||||
"charging the difference after pre-consume: %s (actual %s, pre-consumed %s)": "预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
|
||||
"claude response usage is not complete, maybe upstream error": "Claude 响应里的用量信息不完整,可能是上游出错",
|
||||
"closed %d active websocket connection(s), channels=%v, kinds=%v, reason=%s": "已关闭 %d 个使用中的 WebSocket 连接: channels=%v, kinds=%v, reason=%s",
|
||||
"codex credential auto-refresh task started: tick=%s threshold=%s": "Codex 凭证自动刷新任务已启动: tick=%s threshold=%s",
|
||||
"codex credential auto-refresh: InitChannelCache panic: %v": "Codex 凭证自动刷新: InitChannelCache 发生 panic: %v",
|
||||
"codex credential auto-refresh: channel_id=%d name=%s refresh failed: %v": "Codex 凭证自动刷新: channel_id=%d name=%s 刷新失败: %v",
|
||||
"codex credential auto-refresh: channel_id=%d name=%s refreshed, expires_at=%s": "Codex 凭证自动刷新: channel_id=%d name=%s 已刷新,expires_at=%s",
|
||||
"codex credential auto-refresh: query channels failed: %v": "Codex 凭证自动刷新: 查询渠道失败: %v",
|
||||
"codex credential auto-refresh: scanned=%d refreshed=%d": "Codex 凭证自动刷新: scanned=%d refreshed=%d",
|
||||
"compile task plugin %s@%s: %v": "编译任务插件 %s@%s 失败: %v",
|
||||
"configure plugin router trusted proxies: %s": "为插件路由设置可信代理失败: %s",
|
||||
"conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q": "格式转换诊断: code=%q severity=%q from=%q to=%q path=%q message=%q",
|
||||
"conversion diagnostics truncated after %d distinct entries": "格式转换诊断已达 %d 条上限,后续不再记录",
|
||||
"converted embedding request body: %s": "转换后的嵌入请求体: %s",
|
||||
"cpu usage too high": "CPU 使用率过高",
|
||||
"creem topup": "Creem 充值",
|
||||
"creem topup failed: %s": "Creem 充值失败: %s",
|
||||
"dashboard authentication error: %s": "控制台登录校验出错: %s",
|
||||
"data handler goroutine panic: %v": "数据处理 goroutine 发生 panic: %v",
|
||||
"database migration started": "开始数据库迁移",
|
||||
"do request failed: %s": "请求上游失败: %s",
|
||||
"downloading file from worker: %s, reason: %s": "通过 Worker 下载文件: %s,原因: %s",
|
||||
"downloading from origin: %s, reason: %s": "直接从源地址下载: %s,原因: %s",
|
||||
"encode task plugin native submit view failed: %s": "任务插件提交响应: 序列化任务数据失败: %s",
|
||||
"encode task protocol view failed: %s": "任务协议: 序列化任务数据失败: %s",
|
||||
"epay topup": "易支付充值",
|
||||
"epay topup failed: %s": "易支付充值失败: %s",
|
||||
"error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s": "资金来源已结算,调整令牌额度时出错 (userId=%d, tokenId=%d, delta=%d): %s",
|
||||
"error closing websocket connection: %s": "关闭 WebSocket 连接出错: %s",
|
||||
"error consuming Midjourney quota: %s": "Midjourney 扣费出错: %s",
|
||||
"error converting Claude stream response: %s": "转换 Claude 流式响应出错: %s",
|
||||
"error converting final Gemini stream response: %s": "转换 Gemini 流式响应的最后一段出错: %s",
|
||||
"error creating Gemini stream state: %s": "初始化 Gemini 流式转换状态出错: %s",
|
||||
"error finalizing Gemini stream response: %s": "生成 Gemini 流式响应的结束段出错: %s",
|
||||
"error handling last response: %s, lastStreamData: [%s]": "处理最后一段流数据出错: %s, lastStreamData: [%s]",
|
||||
"error handling stream format: %s": "转换并发送流数据出错: %s",
|
||||
"error marshalling chats: %s": "序列化聊天配置出错: %s",
|
||||
"error marshalling model ratio: %s": "序列化模型倍率出错: %s",
|
||||
"error marshalling stream response: %s": "序列化流式响应出错: %s",
|
||||
"error marshalling topup group ratio: %s": "序列化充值分组倍率出错: %s",
|
||||
"error marshalling user groups: %s": "序列化用户分组出错: %s",
|
||||
"error processing stream token data: %s": "统计流数据的 token 出错: %s",
|
||||
"error reading stream response: %s": "读取流式响应出错: %s",
|
||||
"error reading stream: %s": "读取流式响应出错: %s",
|
||||
"error refunding billing source: %s": "退还资金来源出错: %s",
|
||||
"error refunding subscription extra reserved quota: %s": "退还订阅额外预扣的额度出错: %s",
|
||||
"error refunding token quota: %s": "退还令牌额度出错: %s",
|
||||
"error rolling back subscription funding reserve: %s": "回滚订阅预扣额度出错: %s",
|
||||
"error rolling back token quota (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s": "回滚令牌额度出错 (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s",
|
||||
"error rolling back wallet funding reserve: %s": "回滚钱包预扣额度出错: %s",
|
||||
"error sending final Gemini stream response: %s": "发送 Gemini 流式响应的最后一段出错: %s",
|
||||
"error sending finalized Gemini stream response: %s": "发送 Gemini 流式响应的结束段出错: %s",
|
||||
"error sending stream response: %s": "发送流式响应出错: %s",
|
||||
"error settling Midjourney quota: %s": "Midjourney 结算出错: %s",
|
||||
"error settling billing: %s": "计费结算出错: %s",
|
||||
"error unmarshalling stream response: %s": "解析流式响应出错: %s",
|
||||
"error: %s": "错误: %s",
|
||||
"error_rendering_final_usage_response: %s": "发送用量数据出错: %s",
|
||||
"error_rendering_stream_response: %s": "发送流式响应出错: %s",
|
||||
"error_scanning_stream_response: %s": "读取流式响应出错: %s",
|
||||
"error_unmarshalling_stream_response: %s": "解析流式响应出错: %s",
|
||||
"expected Claude stream responses, got %T": "这里需要 Claude 流式响应,实际类型是 %T",
|
||||
"fail to decode image config(gif, jpg, png): %s": "解析图片头信息失败 (gif, jpg, png): %s",
|
||||
"fail to decode image config(webp): %s": "解析图片头信息失败 (webp): %s",
|
||||
"fail to get file type from url: %s, error: %s": "获取文件类型失败,URL: %s,错误: %s",
|
||||
"fail to get image from url: %s": "下载图片失败: %s",
|
||||
"failed to add user field: %s": "写入表单字段 user 失败: %s",
|
||||
"failed to adjust funding for the difference, task %s: %s": "差额结算资金调整失败 task %s: %s",
|
||||
"failed to adjust token quota (delta=%d, task=%s): %s": "调整令牌额度失败 (delta=%d, task=%s): %s",
|
||||
"failed to batch update token quota: %s": "批量更新令牌额度失败: %s",
|
||||
"failed to batch update user quota, used quota and request count: %s": "批量更新用户额度、已用额度和请求次数失败: %s",
|
||||
"failed to bind upstream request: %s": "解析上游倍率同步的请求参数失败: %s",
|
||||
"failed to cache refresh-reuse session revoke: %s": "刷新令牌被重复使用,会话已吊销,写入缓存失败: %s",
|
||||
"failed to charge violation fee: %s": "违规扣费失败: %s",
|
||||
"failed to check channel status before closing active websockets: channel_id=%d, error=%v": "关闭 WebSocket 连接前查询渠道状态失败: channel_id=%d, error=%v",
|
||||
"failed to check notification limit: %s": "检查通知频率限制失败: %s",
|
||||
"failed to check the successful request limit: %s": "检查成功请求数限制失败: %s",
|
||||
"failed to check the total request limit: %s": "检查总请求数限制失败: %s",
|
||||
"failed to cleanup expired perf metrics: %s": "清理过期性能指标失败: %s",
|
||||
"failed to clone channel: %s": "复制渠道失败: %s",
|
||||
"failed to close database: %s": "关闭数据库失败: %s",
|
||||
"failed to close response body: %s": "关闭响应体失败: %s",
|
||||
"failed to compensate reserved token quota: result=%d error=%v": "回补缓存中已预扣的令牌额度失败: result=%d error=%v",
|
||||
"failed to compensate reserved user quota: result=%d error=%v": "回补缓存中已预扣的用户额度失败: result=%d error=%v",
|
||||
"failed to configure trusted proxies: %s": "设置可信代理失败: %s",
|
||||
"failed to copy file content: %s": "复制文件内容失败: %s",
|
||||
"failed to copy response body: %s": "复制响应体失败: %s",
|
||||
"failed to count backup codes: %s": "获取备用码数量失败: %s",
|
||||
"failed to count channel types: %s": "统计渠道类型失败: %s",
|
||||
"failed to count channels: %s": "统计渠道数量失败: %s",
|
||||
"failed to count hourly user session issuance: %s": "统计近一小时新建的登录会话数失败: %s",
|
||||
"failed to count search tokens: %s": "统计令牌搜索结果数量失败: %s",
|
||||
"failed to count search topups: %s": "统计充值记录搜索结果数量失败: %s",
|
||||
"failed to count tags: %s": "统计标签数量失败: %s",
|
||||
"failed to count user logs: %s": "统计用户日志数量失败: %s",
|
||||
"failed to count user tokens: %s": "统计用户令牌数量失败: %s",
|
||||
"failed to create disk storage, falling back to memory: %v": "请求体写入磁盘失败,改为保存在内存: %v",
|
||||
"failed to create form file: %s": "创建表单文件失败: %s",
|
||||
"failed to create request: %s": "创建请求失败: %s",
|
||||
"failed to create setup record: %s": "创建系统初始化记录失败: %s",
|
||||
"failed to create temp file: %s": "创建临时文件失败: %s",
|
||||
"failed to create the pprof directory: %s": "创建pprof文件夹失败 %s",
|
||||
"failed to create the pprof file: %s": "创建pprof文件失败 %s",
|
||||
"failed to decode base64: %s": "base64 解码失败: %s",
|
||||
"failed to decode response: %s": "解析响应失败: %s",
|
||||
"failed to decrease token quota: %s": "扣减令牌额度失败: %s",
|
||||
"failed to decrease user quota cache after subscription balance purchase: %s": "余额购买订阅后,扣减用户额度缓存失败: %s",
|
||||
"failed to decrease user quota: %s": "扣减用户额度失败: %s",
|
||||
"failed to delete expired access tokens: %s": "清理过期的访问令牌失败: %s",
|
||||
"failed to delete expired authentication flows: %s": "清理过期的登录验证流程失败: %s",
|
||||
"failed to delete expired user sessions: %s": "清理过期的登录会话失败: %s",
|
||||
"failed to delete old revoked user sessions: %s": "清理超过保留期的已吊销会话失败: %s",
|
||||
"failed to download file from %s, status code: %d": "从 %s 下载文件失败,状态码: %d",
|
||||
"failed to encode log admin info: %s": "序列化日志的 admin_info 失败: %s",
|
||||
"failed to fetch codex reset credits": "获取 Codex 重置次数失败",
|
||||
"failed to fetch codex usage": "获取 Codex 用量失败",
|
||||
"failed to finalize bulk user session revoke tombstone: %s": "批量吊销会话后,写入吊销缓存失败: %s",
|
||||
"failed to finalize refresh-authenticated session revoke tombstone: %s": "按刷新令牌吊销会话后,写入吊销缓存失败: %s",
|
||||
"failed to finalize user session revoke tombstone: %s": "吊销会话后,写入吊销缓存失败: %s",
|
||||
"failed to flush perf metric bucket model=%s group=%s bucket=%d: %s": "保存性能指标失败 model=%s group=%s bucket=%d: %s",
|
||||
"failed to generate backup codes: %s": "生成备用码失败: %s",
|
||||
"failed to generate the default sidebar config: %s": "生成默认边栏配置失败: %s",
|
||||
"failed to generate token key: %s": "生成令牌密钥失败: %s",
|
||||
"failed to get audio duration: %v": "获取音频时长失败: %v",
|
||||
"failed to get channel by id: %s": "按 ID 查询渠道失败: %s",
|
||||
"failed to get channels by tag: %s": "按标签查询渠道失败: %s",
|
||||
"failed to get channels: %s": "查询渠道失败: %s",
|
||||
"failed to get paginated tags: %s": "分页查询标签失败: %s",
|
||||
"failed to get token by key: %s": "按密钥查询令牌失败: %s",
|
||||
"failed to get token key (tokenId=%d, task=%s): %s": "获取令牌 key 失败 (tokenId=%d, task=%s): %s",
|
||||
"failed to increase token quota: %s": "增加令牌额度失败: %s",
|
||||
"failed to increase user quota: %s": "增加用户额度失败: %s",
|
||||
"failed to init token cache: %s": "初始化令牌缓存失败: %s",
|
||||
"failed to initialize authorization: %s": "初始化权限控制失败: %s",
|
||||
"failed to initialize database: %s": "初始化数据库失败: %s",
|
||||
"failed to initialize i18n: %s": "初始化 i18n 失败: %s",
|
||||
"failed to initialize password encryption: %s": "初始化密码加密失败: %s",
|
||||
"failed to initialize resources: %s": "初始化资源失败: %s",
|
||||
"failed to insert redemption: %s": "保存兑换码失败: %s",
|
||||
"failed to invalidate token cache after hard deleting user %d: %v": "永久删除用户 %d 后清除令牌缓存失败: %v",
|
||||
"failed to invalidate token cache before batch delete: %s": "批量删除令牌前清除缓存失败: %s",
|
||||
"failed to invalidate token cache before delete: %s": "删除令牌前清除缓存失败: %s",
|
||||
"failed to invalidate token cache before status update: %s": "更新令牌状态前清除缓存失败: %s",
|
||||
"failed to invalidate token cache before update: %s": "更新令牌前清除缓存失败: %s",
|
||||
"failed to invalidate tokens cache for user %d: %s": "清除用户 %d 的令牌缓存失败: %s",
|
||||
"failed to invalidate user cache after hard deleting user %d: %v": "永久删除用户 %d 后清除用户缓存失败: %v",
|
||||
"failed to load custom OAuth providers: %s": "加载自定义 OAuth 提供商失败: %s",
|
||||
"failed to load tool prices, using hardcoded fallbacks: %s": "加载工具价格失败,将使用硬编码兜底: %s",
|
||||
"failed to marshal gemini response: %s": "序列化 Gemini 响应失败: %s",
|
||||
"failed to marshal log other: %s": "序列化日志 other 字段失败: %s",
|
||||
"failed to marshal other info: channel_id=%d, tag=%s, name=%s, error=%v": "序列化渠道的 other_info 失败: channel_id=%d, tag=%s, name=%s, error=%v",
|
||||
"failed to marshal setting: %s": "序列化用户设置失败: %s",
|
||||
"failed to marshal setting: channel_id=%d, error=%v": "序列化渠道设置失败: channel_id=%d, error=%v",
|
||||
"failed to migrate options primary key: %s": "为 options 表补主键失败: %s",
|
||||
"failed to migrate retired frontend options: %s": "迁移旧前端的配置项失败: %s",
|
||||
"failed to notify root user: %s": "通知 root 用户失败: %s",
|
||||
"failed to notify user %d for upstream model update: %s": "向用户 %d 发送上游模型更新通知失败: %s",
|
||||
"failed to open log file": "打开日志文件失败",
|
||||
"failed to parse %s: %s, using default value: %d": "环境变量 %s 解析失败: %s,已使用默认值: %d",
|
||||
"failed to parse %s: %s, using default value: %t": "环境变量 %s 解析失败: %s,已使用默认值: %t",
|
||||
"failed to parse CHANNEL_UPDATE_FREQUENCY: %s": "解析 CHANNEL_UPDATE_FREQUENCY 失败: %s",
|
||||
"failed to parse Redis connection string: %s": "解析 Redis 连接字符串失败: %s",
|
||||
"failed to parse auto groups for token %d: %v": "解析令牌 %d 的 Auto 分组失败: %v",
|
||||
"failed to parse oauth key: %s": "解析渠道的 OAuth 凭证失败: %s",
|
||||
"failed to populate newly created user session cache: %s": "新建登录会话后写入缓存失败: %s",
|
||||
"failed to publish auth tombstone after hard deleting user %d: %v": "永久删除用户 %d 后发布登录失效通知失败: %v",
|
||||
"failed to publish websocket close event: %v": "向其他节点广播 WebSocket 关闭事件失败: %v",
|
||||
"failed to query channels: %s": "查询渠道失败: %s",
|
||||
"failed to query log stat: %s": "查询日志统计失败: %s",
|
||||
"failed to query rpm/tpm stat: %s": "查询 rpm/tpm 统计失败: %s",
|
||||
"failed to query upstream update notification users: %s": "查询接收上游更新通知的用户失败: %s",
|
||||
"failed to read TTS response body: %v": "读取 TTS 响应体失败: %v",
|
||||
"failed to record log: %s": "记录日志失败: %s",
|
||||
"failed to record task billing log: %s": "记录任务计费日志失败: %s",
|
||||
"failed to record topup log: %s": "记录充值日志失败: %s",
|
||||
"failed to redeem key %s for user %d: %s": "用户 %[2]d 使用兑换码 %[1]s 兑换失败: %[3]s",
|
||||
"failed to refresh codex channel credential: %s": "刷新 Codex 渠道凭证失败: %s",
|
||||
"failed to refresh user group cache after %s for user %d: %v": "刷新用户 %[2]d 的分组缓存失败(操作: %[1]s): %[3]v",
|
||||
"failed to refund Midjourney token quota, task %s: %s": "退还 Midjourney 令牌额度失败 task %s: %s",
|
||||
"failed to refund Midjourney user quota, task %s: %s": "退还 Midjourney 用户额度失败 task %s: %s",
|
||||
"failed to refund the funding source, task %s: %s": "退还资金来源失败 task %s: %s",
|
||||
"failed to reload authz policy: %s": "重新加载授权策略失败: %s",
|
||||
"failed to reset codex usage": "重置 Codex 用量失败",
|
||||
"failed to save backup codes: %s": "保存备用码失败: %s",
|
||||
"failed to search tokens: %s": "搜索令牌失败: %s",
|
||||
"failed to search topups: %s": "搜索充值记录失败: %s",
|
||||
"failed to search user logs: %s": "搜索用户日志失败: %s",
|
||||
"failed to send email to %s: %v": "向 %s 发送邮件失败: %v",
|
||||
"failed to send password reset email to %s: %s": "向 %s 发送密码重置邮件失败: %s",
|
||||
"failed to send quota notify to user %d: %s": "向用户 %d 发送额度通知失败: %s",
|
||||
"failed to send request: %s": "发送请求失败: %s",
|
||||
"failed to send subscription quota notify to user %d: %s": "向用户 %d 发送订阅额度通知失败: %s",
|
||||
"failed to serialize VideoUrls: %v": "序列化 VideoUrls 失败: %v",
|
||||
"failed to start HTTP server: %s": "HTTP 服务启动失败: %s",
|
||||
"failed to start pprof: %s": "启动pprof失败 %s",
|
||||
"failed to sync %s credit to user quota cache: %s": "把到账额度同步到用户额度缓存失败(操作: %s): %s",
|
||||
"failed to sync manual quota adjustment for user %d: %s": "把用户 %d 的手动调额同步到缓存失败: %s",
|
||||
"failed to synchronously populate user cache: %s": "同步写入用户缓存失败: %s",
|
||||
"failed to synchronously populate user session cache: %s": "同步写入用户会话缓存失败: %s",
|
||||
"failed to unmarshal Claude stream event: %s": "解析 Claude 流事件失败: %s",
|
||||
"failed to unmarshal buffered responses stream event: %s": "解析已缓冲的 Responses 流事件失败: %s",
|
||||
"failed to unmarshal chat stream response: %s": "解析 Chat 流式响应失败: %s",
|
||||
"failed to unmarshal header override: channel_id=%d, error=%v": "解析渠道的请求头覆盖失败: channel_id=%d, error=%v",
|
||||
"failed to unmarshal other info: channel_id=%d, tag=%s, name=%s, error=%v": "解析渠道的 other_info 失败: channel_id=%d, tag=%s, name=%s, error=%v",
|
||||
"failed to unmarshal param override: channel_id=%d, error=%v": "解析渠道的参数覆盖失败: channel_id=%d, error=%v",
|
||||
"failed to unmarshal responses stream event: %s": "解析 Responses 流事件失败: %s",
|
||||
"failed to unmarshal setting: %s": "解析用户设置失败: %s",
|
||||
"failed to unmarshal setting: channel_id=%d, error=%v": "解析渠道设置失败: channel_id=%d, error=%v",
|
||||
"failed to unmarshal stream response: %s": "解析流式响应失败: %s",
|
||||
"failed to unmarshal user: %s": "解析 user 失败: %s",
|
||||
"failed to unmarshal websocket close event: %v": "解析 WebSocket 关闭事件失败: %v",
|
||||
"failed to update abilities: channel_id=%d, tag=%s, error=%v": "更新渠道的 abilities 记录失败: channel_id=%d, tag=%s, error=%v",
|
||||
"failed to update ability status: channel_id=%d, error=%v": "更新渠道的 abilities 状态失败: channel_id=%d, error=%v",
|
||||
"failed to update balance: channel_id=%d, error=%v": "更新余额失败: channel_id=%d, error=%v",
|
||||
"failed to update channel status: channel_id=%d, status=%d, error=%v": "更新渠道状态失败: channel_id=%d, status=%d, error=%v",
|
||||
"failed to update channel used quota: channel_id=%d, delta_quota=%d, error=%v": "更新渠道已用额度失败: channel_id=%d, delta_quota=%d, error=%v",
|
||||
"failed to update config %s: %s": "更新配置 %s 失败: %s",
|
||||
"failed to update multi-key status: channel_id=%d, using key not found": "更新多密钥状态失败: channel_id=%d,密钥列表里找不到本次使用的密钥",
|
||||
"failed to update option map: %s": "加载配置项失败: %s",
|
||||
"failed to update response time: channel_id=%d, error=%v": "更新响应时间失败: channel_id=%d, error=%v",
|
||||
"failed to update rotated user session cache: %s": "续期登录后更新会话缓存失败: %s",
|
||||
"failed to update token status%s": "更新令牌状态失败: %s",
|
||||
"failed to update user group cache: %s": "更新用户分组缓存失败: %s",
|
||||
"failed to update user last_login_at: %s": "更新用户最后登录时间失败: %s",
|
||||
"failed to update user name cache: %s": "更新用户名缓存失败: %s",
|
||||
"failed to update user setting cache: %s": "更新用户设置缓存失败: %s",
|
||||
"failed to update user used quota and request count: %s": "更新用户已用额度和请求次数失败: %s",
|
||||
"failed to update user used quota: %s": "更新用户已用额度失败: %s",
|
||||
"failed to validate cloned channel: %s": "复制出的渠道未通过校验: %s",
|
||||
"failed to write TTS response: %v": "写入 TTS 响应失败: %v",
|
||||
"failed to write back the settled task quota, task %s: %s": "差额结算回写 quota 失败 task %s: %s",
|
||||
"failed to write stream data: %s": "写入流数据失败: %s",
|
||||
"failed to write to temp file: %s": "写入临时文件失败: %s",
|
||||
"final group ratio: %f": "最终分组倍率: %f",
|
||||
"final group: %s": "最终使用的分组: %s",
|
||||
"getAndValidateTextRequest failed: %s": "getAndValidateTextRequest 失败: %s",
|
||||
"get_channel_null: %s": "查询渠道失败: %s",
|
||||
"group ratio not found: %s": "未找到分组倍率: %s",
|
||||
"hourly user session issuance exceeded alert threshold: count=%d threshold=%d window_seconds=%d": "近一小时新建的登录会话数超过告警阈值: count=%d threshold=%d window_seconds=%d",
|
||||
"http error on %s: %s": "请求 %s 出错: %s",
|
||||
"http transport negotiated: host=%s protocol=%s shards=%d policy=%s negotiated=%s": "HTTP 连接协商结果: host=%s protocol=%s shards=%d policy=%s negotiated=%s",
|
||||
"http transport select: host=%s protocol=%s shards=%d policy=%s": "HTTP 连接选择: host=%s protocol=%s shards=%d policy=%s",
|
||||
"http transport: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s": "HTTP 连接: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s",
|
||||
"i18n initialized with languages: %s": "i18n 已初始化,语言: %s",
|
||||
"image request body: %s": "图片请求体: %s",
|
||||
"image token input: format=%s, width=%d, height=%d": "计算图片 token: format=%s, width=%d, height=%d",
|
||||
"increaseQuotaData error: %s": "increaseQuotaData 出错: %s",
|
||||
"initialize legacy access token deadline: %s": "设置旧访问令牌的停用时间失败: %s",
|
||||
"initialized the sidebar config for new user %s (role: %d)": "为新用户 %s (角色: %d) 初始化边栏配置",
|
||||
"insert task error: %s": "保存任务失败: %s",
|
||||
"invalid SQL_SLOW_THRESHOLD_MS %d (allowed 0-%d, 0 disables slow query log), using default %d": "SQL_SLOW_THRESHOLD_MS=%d 超出范围(可填 0 到 %d,0 表示关闭慢查询日志),已使用默认值 %d",
|
||||
"invalid access token scopes: %s": "访问令牌的作用域无效: %s",
|
||||
"invalid channel http transport setting clamped: %s=%q": "渠道的 HTTP 连接设置超出范围,已调整为允许的值: %s=%q",
|
||||
"invalid image cache token breakdown; using aggregate cache billing": "图片缓存 token 的分项数据无效,按缓存 token 总量计费",
|
||||
"invalid image stream response": "图片流式响应无效",
|
||||
"invalid request policy: %s": "请求策略无效: %s",
|
||||
"invalid response or response body": "上游响应为空或没有响应体",
|
||||
"invalid task artifact store configuration: %s; using upstream mode": "任务制品存储配置无效: %s,改用 upstream 模式",
|
||||
"invalid thinking_model_blacklist regex %q: %v": "thinking_model_blacklist 正则 %q 无效: %v",
|
||||
"invalid thinking_model_blacklist regex %q: pattern is empty": "thinking_model_blacklist 正则 %q 无效: 表达式为空",
|
||||
"invalid zhipu key: %s": "智谱密钥格式无效: %s",
|
||||
"json decode failed from %s: %s": "解析 %s 返回的 JSON 失败: %s",
|
||||
"json marshal failed: %s": "JSON 序列化失败: %s",
|
||||
"legacy Uptime Kuma options were not migrated: %v": "旧的 Uptime Kuma 配置迁移失败: %v",
|
||||
"legacy Uptime Kuma options were not migrated: both URL and slug are required": "旧的 Uptime Kuma 配置没有迁移: URL 和 slug 都需要填写",
|
||||
"legacy option %s was not migrated: %v": "旧配置项 %s 迁移失败: %v",
|
||||
"legacy proxy URL suffix ignored at runtime: scheme=%s host=%s; update the channel proxy setting": "渠道代理地址带有旧写法的后缀,运行时已忽略: scheme=%s host=%s。请修改渠道的代理设置",
|
||||
"load advanced custom channel settings error: channel_id=%d, error=%v": "加载高级自定义渠道设置出错: channel_id=%d, error=%v",
|
||||
"loadFromURL: decoding image config": "loadFromURL: 解析图片头信息",
|
||||
"loadFromURL: initiating download": "loadFromURL: 开始下载",
|
||||
"loadFromURL: reading response body": "loadFromURL: 读取响应体",
|
||||
"manual topup": "管理员补单",
|
||||
"memory cache enabled": "已启用内存缓存",
|
||||
"midjourney response body: %s": "Midjourney 响应体: %s",
|
||||
"model %s is free, skipping pre-consume": "模型 %s 免费,跳过预扣费",
|
||||
"model price not found: %s": "未找到模型价格: %s",
|
||||
"model_price_helper result: %s": "model_price_helper 结果: %s",
|
||||
"model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s": "model_price_helper_tiered 结果: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s",
|
||||
"models.dev parse failed from %s: %s": "解析 %s 返回的 models.dev 数据失败: %s",
|
||||
"no such user %s": "用户 %s 不存在",
|
||||
"no user exists, create a root user for you: username is root, password is 123456": "系统里还没有用户,已自动创建 root 账号:用户名 root,密码 123456",
|
||||
"non-200 from %s: %s": "%s 返回了非 200 状态: %s",
|
||||
"ollama stream json decode error: %s line=%s": "Ollama 流式响应 JSON 解析失败: %s line=%s",
|
||||
"ollama stream scan error: %s": "Ollama 流式响应读取出错: %s",
|
||||
"openrouter enterprise response success=false, data: %s": "OpenRouter Enterprise 响应 success=false, data: %s",
|
||||
"options key %q had conflicting values; keeping the last row": "options 表的键 %q 有多行且值不同,保留最后一行的值",
|
||||
"options table has duplicate pricing keys [%s]; the table is missing a primary key": "options 表缺少主键,定价配置出现重复的键 [%s]",
|
||||
"panic detected: %v": "发生 panic: %v",
|
||||
"panic in gopool.RelayPool: %v": "gopool.RelayPool 中发生 panic: %v",
|
||||
"panic recovered in plugin route": "插件路由发生 panic,已恢复",
|
||||
"passkey verification rejected: code=%s reason=%s rp_id=%q": "通行密钥验证未通过: code=%s reason=%s rp_id=%q",
|
||||
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d": "用户已确认支付合规条款 user_id=%d ip=%s terms_version=%s confirmed_at=%d",
|
||||
"persist task background flag failed: %s": "保存任务的后台运行标记失败: %s",
|
||||
"ping data error: %s": "发送 ping 数据出错: %s",
|
||||
"ping data sent": "ping 数据已发送",
|
||||
"ping goroutine exited": "ping goroutine 已退出",
|
||||
"ping goroutine max duration reached": "ping goroutine 运行达到最长时限,已停止",
|
||||
"ping goroutine panic: %v": "ping goroutine 发生 panic: %v",
|
||||
"ping interval seconds: %d": "ping 间隔: %d 秒",
|
||||
"pinned task plugin does not support the requested protocol form": "选定的任务插件不支持本次请求的调用方式(流式、后台或同步)",
|
||||
"pprof enabled": "已启用 pprof",
|
||||
"pre-consumed quota matches the actual quota, no adjustment: %s (per-request billing)": "预扣费与实际消耗一致,无需调整:%s(按次计费)",
|
||||
"quota saturation on consume log: op=%s kind=%s original=%g clamped=%d user=%d model=%s": "消费日志: 额度超出上限,已按上限记录: op=%s kind=%s original=%g clamped=%d user=%d model=%s",
|
||||
"rate limit check failed (key=%s): %v": "限流检查失败 (key=%s): %v",
|
||||
"rate limit check failed (mark=%s): %v": "限流检查失败 (mark=%s): %v",
|
||||
"read response failed from %s: %s": "读取 %s 的响应失败: %s",
|
||||
"ready in %d ms": "启动完成,耗时 %d ms",
|
||||
"realtime error: %s": "Realtime 出错: %s",
|
||||
"realtime streaming consume quota success, quota: %d": "Realtime 扣费成功,额度: %d",
|
||||
"realtime streaming localUsage: %v": "Realtime 流式 localUsage: %v",
|
||||
"realtime streaming sumUsage: %v": "Realtime 流式 sumUsage: %v",
|
||||
"rebuild task alias view: %s": "重建任务模型别名表失败: %s",
|
||||
"rebuilt options table with primary key from %d rows into %d keys; previous rows kept in %s": "已重建 options 表并加上主键,%d 行合并成 %d 个键,重建前的数据保存在 %s",
|
||||
"received [DONE], stopping scanner": "收到 [DONE],停止读取流",
|
||||
"received signal: %v, shutting down...": "收到信号 %v,正在关闭服务...",
|
||||
"record consume log: userId=%d, params=%s": "记录消费日志: userId=%d, params=%s",
|
||||
"record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s": "记录错误日志: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s",
|
||||
"redemption": "兑换码兑换",
|
||||
"redemption failed: %s": "兑换失败: %s",
|
||||
"refund succeeded but clearing the task quota failed, task %s: %s": "退款成功但清除 task quota 失败 task %s: %s",
|
||||
"refunding the difference after pre-consume: %s (actual %s, pre-consumed %s)": "预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
|
||||
"relay error (channel #%d, status code %d): %s": "转发请求出错 (渠道 #%d,状态码 %d): %s",
|
||||
"relay error: %s": "转发请求出错: %s",
|
||||
"relay max idle conns per host: %d": "转发连接池每个主机的最大空闲连接数: %d",
|
||||
"relay max idle conns: %d": "转发连接池的最大空闲连接数: %d",
|
||||
"relay timeout seconds: %d": "转发超时: %d 秒",
|
||||
"reranker response body: %s": "重排序响应体: %s",
|
||||
"reserve adjusted task billing error: %s": "按调整后的任务费用补扣预扣费失败: %s",
|
||||
"responses stream failed after output was delivered, billing the delivered output: %s": "Responses 流中途失败,已输出的内容照常计费: %s",
|
||||
"responses websocket relay error: %s": "Responses WebSocket 转发出错: %s",
|
||||
"retry: %s": "重试:%s",
|
||||
"running in debug mode": "调试模式已开启",
|
||||
"saved dashboard data, %d records": "保存数据看板数据成功,共保存%d条数据",
|
||||
"scanner error: %s": "读取流出错: %s",
|
||||
"scanner goroutine exited": "scanner goroutine 已退出",
|
||||
"scanner goroutine panic: %v": "scanner goroutine 发生 panic: %v",
|
||||
"send final Gemini stream response failed: %s": "发送 Gemini 流式响应的最后一段失败: %s",
|
||||
"send final response failed: %s": "发送最后一段响应失败: %s",
|
||||
"send_stream_response_failed: %s": "发送流式响应失败: %s",
|
||||
"server exited": "服务已退出",
|
||||
"server forced to shutdown: %v": "服务未能正常关闭,已强制退出: %v",
|
||||
"settle task billing error: %s": "任务计费结算出错: %s",
|
||||
"setup log is already working": "日志正在初始化,跳过本次调用",
|
||||
"skip password reset email for %s: %s": "未向 %s 发送密码重置邮件: %s",
|
||||
"skipped %d options rows with empty keys while rebuilding the primary key": "重建主键时跳过了 %d 行键为空的 options 记录",
|
||||
"stacktrace from panic: %s": "panic 堆栈: %s",
|
||||
"start pyroscope error : %v": "启动 pyroscope 出错: %v",
|
||||
"stream ended: %s": "流式响应结束: %s",
|
||||
"stream ended: %s, received=%d": "流式响应结束: %s, received=%d",
|
||||
"stream event error: %v %v": "流事件报错: %v %v",
|
||||
"stream scanner data: %s": "收到流数据: %s",
|
||||
"streaming timeout seconds: %d": "流式超时: %d 秒",
|
||||
"stripe topup": "Stripe 充值",
|
||||
"subscription balance purchase": "余额购买订阅",
|
||||
"subscription expiration": "订阅到期",
|
||||
"subscription expire task failed: %v": "处理过期订阅失败: %v",
|
||||
"subscription maintenance: reset_count=%d, expired_count=%d": "订阅维护: reset_count=%d, expired_count=%d",
|
||||
"subscription payment completion": "订阅支付完成",
|
||||
"subscription quota reset task failed: %v": "重置订阅额度失败: %v",
|
||||
"subscription quota reset task started: tick=%s": "订阅额度重置任务已启动: tick=%s",
|
||||
"sweepTimedOutTasks CAS update error for task %s: %v": "sweepTimedOutTasks: 更新任务 %s 的状态出错: %v",
|
||||
"sweepTimedOutTasks: task %s already transitioned, skip": "sweepTimedOutTasks: 任务 %s 的状态已由其他进程更新,跳过",
|
||||
"sweepTimedOutTasks: timed out %d tasks": "sweepTimedOutTasks: 已把 %d 个超时任务标记为失败",
|
||||
"sync frequency: %d seconds": "同步间隔: %d 秒",
|
||||
"syncing channels from database": "正在从数据库同步渠道",
|
||||
"syncing options from database": "正在从数据库同步配置项",
|
||||
"system instance report failed: %v": "上报本实例信息失败: %v",
|
||||
"system is already initialized at: %s": "系统已于 %s 完成初始化",
|
||||
"system is not initialized and no root user exists": "系统尚未初始化,还没有 root 用户",
|
||||
"system is not initialized, but root user exists": "系统尚未初始化,但已有 root 用户",
|
||||
"system task %s failed to persist result: %v": "系统任务 %s 保存结果失败: %v",
|
||||
"system task %s failed to save failure state: %v": "系统任务 %s 保存失败状态时出错: %v",
|
||||
"system task %s failed: %v": "系统任务 %s 失败: %v",
|
||||
"system task %s lock lost": "系统任务 %s 的执行锁已失效",
|
||||
"system task %s update failed: %v": "系统任务 %s 更新失败: %v",
|
||||
"system task claim failed: %v": "领取系统任务失败: %v",
|
||||
"system task runner query failed: %v": "查询待执行的系统任务失败: %v",
|
||||
"system task runner started: runner=%s idle_interval=%s": "系统任务执行器已启动: runner=%s idle_interval=%s",
|
||||
"system task scheduler active lookup failed: type=%s err=%v": "系统任务调度: 查询进行中的任务失败: type=%s err=%v",
|
||||
"system task scheduler create failed: type=%s err=%v": "系统任务调度: 创建任务失败: type=%s err=%v",
|
||||
"system task scheduler query failed: %v": "系统任务调度: 查询失败: %v",
|
||||
"system task stale lock cleanup failed: %v": "清理系统任务的过期执行锁失败: %v",
|
||||
"task %s expression settlement failed, keeping the pre-consumed quota: %v": "任务 %s 表达式结算失败,保留预扣额度: %v",
|
||||
"task %s expression settlement quota was clamped: %+v": "任务 %s 表达式结算额度发生饱和: %+v",
|
||||
"task %s is billed per request, skipping difference settlement": "任务 %s 按次计费,跳过差额结算",
|
||||
"task %s poll %s (failures=%d, http=%d): %s": "任务 %s 轮询结果异常: %s (failures=%d, http=%d): %s",
|
||||
"task %s pre-consumed quota matches the actual quota (%s, %s)": "任务 %s 预扣费准确(%s,%s)",
|
||||
"task %s settles the difference: delta=%s (actual %s, pre-consumed %s, %s)": "任务 %s 差额结算:delta=%s(实际:%s,预扣:%s,%s)",
|
||||
"task alias mapping cycle dropped: channel=%d key=%q": "渠道的模型映射成环,这个任务模型别名已忽略: channel=%d key=%q",
|
||||
"task alias view: channel %d model_mapping: %s": "解析渠道 %d 的 model_mapping 失败,任务模型别名未包含该渠道: %s",
|
||||
"task artifact S3 storage is not implemented; using upstream mode": "任务制品 S3 存储尚未实现,改用 upstream 模式",
|
||||
"task billing other ratio key rejected: %s": "任务计费: 倍率键 %s 不能写入日志,已跳过",
|
||||
"task immediate usage settlement failed; retaining reserved quota: %v": "任务提交即完成,按用量结算失败,保留预扣额度: %v",
|
||||
"task model alias %q dropped: maps to multiple plugins %v": "任务模型别名 %q 对应多个插件 %v,已忽略",
|
||||
"task plugin %s completion context failed; retaining reserved quota: %v": "任务插件 %s: 构建完成结算的上下文失败,保留预扣额度: %v",
|
||||
"task plugin %s completion usage failed; retaining reserved quota: %v": "任务插件 %s: 提取完成用量失败,保留预扣额度: %v",
|
||||
"task plugin %s completion usage rejected; retaining reserved quota: %v": "任务插件 %s: 返回的完成用量无效,保留预扣额度: %v",
|
||||
"task plugin %s rejected invalid %s billing facts": "任务插件 %s 的 %s 钩子返回了无效的计费数据,已忽略",
|
||||
"task plugin %s rejected invalid or oversized poll state": "任务插件 %s 返回的轮询状态无效或过大,未保存",
|
||||
"task plugin %s rejected oversized poll state (%d bytes)": "任务插件 %s 返回的轮询状态过大(%d 字节),未保存",
|
||||
"task plugin %s rejected oversized submit state (%d bytes)": "任务插件 %s 返回的提交状态过大(%d 字节),未保存",
|
||||
"task plugin native submit presenter failed: %s": "任务插件提交响应: 渲染钩子执行失败: %s",
|
||||
"task plugin route %s %s declares retainResult: false but returned an asynchronous result; retaining task %s": "任务插件路由 %s %s 声明了 retainResult: false,但返回的是异步结果,任务 %s 仍会保存",
|
||||
"task poll auth failure channel_id=%d http=%d": "任务轮询被上游拒绝(鉴权失败) channel_id=%d http=%d",
|
||||
"task poll auth failure channel_id=%d task=%s http=%d": "任务轮询被上游拒绝(鉴权失败) channel_id=%d task=%s http=%d",
|
||||
"task progress polling finished": "任务进度轮询完成",
|
||||
"task progress polling started": "任务进度轮询开始",
|
||||
"task protocol database observation failed": "任务协议: 读取任务状态失败",
|
||||
"task protocol database observation overloaded; plugin=%s task=%s": "任务协议: 读取任务状态超时,数据库繁忙 plugin=%s task=%s",
|
||||
"task protocol failure event failed: %s": "任务协议: 生成失败事件出错: %s",
|
||||
"task protocol failure response failed: %s": "任务协议: 生成失败响应出错: %s",
|
||||
"task protocol final hook failed": "任务协议: 插件的最终响应钩子执行失败",
|
||||
"task protocol final hook overloaded; plugin=%s task=%s": "任务协议: 插件运行时繁忙,最终响应钩子等待超时 plugin=%s task=%s",
|
||||
"task protocol image download failed; returning the upstream URL instead of b64_json: %s": "任务协议: 图片下载失败,b64_json 改用上游 URL 返回: %s",
|
||||
"task protocol image poll failed for task %s: %s": "任务协议: 轮询图片任务 %s 失败: %s",
|
||||
"task protocol image render hook failed: %s": "任务协议: 插件的图片渲染钩子执行失败: %s",
|
||||
"task protocol image render hook must return an object with a data array": "任务协议: 插件的图片渲染钩子需要返回带 data 数组的对象",
|
||||
"task protocol render hook failed": "任务协议: 插件的渲染钩子执行失败",
|
||||
"task protocol render hook overloaded; plugin=%s task=%s": "任务协议: 插件运行时繁忙,渲染钩子等待超时 plugin=%s task=%s",
|
||||
"task protocol render hook slow; plugin=%s task=%s elapsed_ms=%d": "任务协议: 插件的渲染钩子执行过慢 plugin=%s task=%s elapsed_ms=%d",
|
||||
"task protocol render result invalid: %s": "任务协议: 插件渲染钩子返回的结果无效: %s",
|
||||
"task protocol retrieve lookup failed": "任务协议: 查询任务失败",
|
||||
"task protocol retrieve render failed": "任务协议: 生成查询响应失败",
|
||||
"task protocol state transition failed: %s": "任务协议: 状态切换失败: %s",
|
||||
"task protocol submission returned an invalid durable outcome": "任务协议: 提交后没有得到有效的任务记录",
|
||||
"task protocol terminal failure response failed: %s": "任务协议: 生成任务失败的最终响应出错: %s",
|
||||
"task protocol timeout event failed: %s": "任务协议: 生成超时事件出错: %s",
|
||||
"task protocol timeout response failed: %s": "任务协议: 生成超时响应出错: %s",
|
||||
"testing channel #%d, response: \n%s": "测试渠道 #%d,响应: \n%s",
|
||||
"testing channel %d with model %s , info %+v ": "测试渠道 %d,模型 %s,信息 %+v ",
|
||||
"text request body: %s": "文本请求体: %s",
|
||||
"token quota cache reserve unavailable, falling back to database: %s": "无法在缓存中预扣令牌额度,改为直接扣数据库: %s",
|
||||
"tool surcharge call count clamped: tool=%s count=%d": "工具调用次数超出计费上限,按上限计费: tool=%s count=%d",
|
||||
"tool surcharge call count overflow for %s": "工具 %s 的计费调用次数溢出",
|
||||
"topup failed: %s": "充值失败: %s",
|
||||
"topup group ratio not found: %s": "未找到充值分组倍率: %s",
|
||||
"total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d": "token 总数为 0,无法扣费,userId %d, channelId %d, tokenId %d, model %s,预扣额度 %d",
|
||||
"try to decode image config with limit: %d": "读取前 %d 字节来解析图片头信息",
|
||||
"unexpected content-type from %s: %s": "%s 返回的 content-type 不符合预期: %s",
|
||||
"unfinished tasks found: %v": "检测到未完成的任务数有: %v",
|
||||
"union is nil or unknown type": "union 为 nil 或类型未知",
|
||||
"unknown tag: %v": "未知 tag: %v",
|
||||
"unrecognized data format from %s: %s": "无法识别 %s 返回的数据格式: %s",
|
||||
"updateVideoSingleTask parsed as new api response format: %+v": "updateVideoSingleTask 按 New API 响应格式解析: %+v",
|
||||
"updateVideoSingleTask response: %s": "updateVideoSingleTask 响应: %s",
|
||||
"updating all channels": "开始更新所有渠道的余额",
|
||||
"updating dashboard data...": "正在更新数据看板数据...",
|
||||
"upstream model update check failed: channel_id=%d channel_name=%s err=%v": "检查渠道的上游模型变化失败: channel_id=%d channel_name=%s err=%v",
|
||||
"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d": "24 小时内已发过相同内容的上游模型更新通知,本次不发送: changed_channels=%d failed_channels=%d",
|
||||
"upstream model update notifications sent: %d": "已发送 %d 条上游模型更新通知",
|
||||
"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d": "上游模型更新检查完成: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
|
||||
"upstream model update task query failed: %v": "上游模型更新检查: 查询渠道失败: %v",
|
||||
"upstream response body: %s": "上游响应体: %s",
|
||||
"usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d": "从倒数第二条 SSE 数据中取得用量: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
|
||||
"user %d has enough quota, trusted without pre-consume (funding=%s)": "用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)",
|
||||
"user %d has no bark url, skip sending bark": "用户 %d 没有填写 Bark 推送地址,未发送 Bark 通知",
|
||||
"user %d has no email, skip sending email": "用户 %d 没有绑定邮箱,未发送邮件通知",
|
||||
"user %d has no gotify url or token, skip sending gotify": "用户 %d 没有填写 Gotify 地址或令牌,未发送 Gotify 通知",
|
||||
"user %d has no webhook url, skip sending webhook": "用户 %d 没有填写 Webhook 地址,未发送 Webhook 通知",
|
||||
"user %d needs pre-consume %s (funding=%s)": "用户 %d 需要预扣费 %s (funding=%s)",
|
||||
"user %d request failed, refunding the pre-consumed quota (token_quota=%s, funding=%s)": "用户 %d 请求失败, 返还预扣费(token_quota=%s, funding=%s)",
|
||||
"user %d | %s": "用户 %d | %s",
|
||||
"user quota cache reserve unavailable, falling back to database: %s": "无法在缓存中预扣用户额度,改为直接扣数据库: %s",
|
||||
"using ClickHouse as log database": "日志数据库使用 ClickHouse",
|
||||
"using MySQL as database": "数据库使用 MySQL",
|
||||
"using PostgreSQL as database": "数据库使用 PostgreSQL",
|
||||
"waffo pancake topup": "Waffo Pancake 充值",
|
||||
"waffo pancake topup failed: %s": "Waffo Pancake 充值失败: %s",
|
||||
"waffo topup": "Waffo 充值",
|
||||
"waffo topup failed: %s": "Waffo 充值失败: %s",
|
||||
"websocket connection is nil": "WebSocket 连接为空",
|
||||
"zhipu_image_empty_b64": "智谱图片接口返回的 b64 为空",
|
||||
"zhipu_image_get_b64_failed: %s": "智谱图片转 b64 失败: %s",
|
||||
"zhipu_image_missing_url": "智谱图片接口没有返回 URL",
|
||||
"¥%.6f quota": "¥%.6f 额度",
|
||||
"$%.6f quota": "$%.6f 额度"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Message is text shown in the web console. Key is the English source text and
|
||||
// also its key in web/src/i18n/locales; Params fill its {{name}} placeholders.
|
||||
// The web console translates Key with Params, so the backend only renders the
|
||||
// English text, for API callers and log fallbacks. Params carry display-ready
|
||||
// values: format quota with logger.FormatQuota before passing it.
|
||||
type Message struct {
|
||||
Key string `json:"key"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
func NewMessage(key string, params ...map[string]any) *Message {
|
||||
message := &Message{Key: key}
|
||||
if len(params) > 0 {
|
||||
message.Params = params[0]
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// Error renders the English text, so a Message can be returned as an error and
|
||||
// reach the web console through ApiError.
|
||||
func (m *Message) Error() string {
|
||||
if len(m.Params) == 0 {
|
||||
return m.Key
|
||||
}
|
||||
// One pass over the key, so a value that contains "{{name}}" stays as is.
|
||||
pairs := make([]string, 0, len(m.Params)*2)
|
||||
for name, value := range m.Params {
|
||||
pairs = append(pairs, "{{"+name+"}}", fmt.Sprint(value))
|
||||
}
|
||||
return strings.NewReplacer(pairs...).Replace(m.Key)
|
||||
}
|
||||
|
||||
// Fields returns the response fields the web console translates. Callers put
|
||||
// the English text in the field their response shape already uses.
|
||||
func (m *Message) Fields() gin.H {
|
||||
fields := gin.H{"message_key": m.Key}
|
||||
if len(m.Params) > 0 {
|
||||
fields["message_params"] = m.Params
|
||||
}
|
||||
return fields
|
||||
}
|
||||
+4
-4
@@ -17,23 +17,23 @@ func Monitor() {
|
||||
panic(err)
|
||||
}
|
||||
if percent[0] > 80 {
|
||||
fmt.Println("cpu usage too high")
|
||||
fmt.Println(LogText("cpu usage too high"))
|
||||
// write pprof file
|
||||
if _, err := os.Stat("./pprof"); os.IsNotExist(err) {
|
||||
err := os.Mkdir("./pprof", os.ModePerm)
|
||||
if err != nil {
|
||||
SysLog("创建pprof文件夹失败 " + err.Error())
|
||||
SysLog(LogText("failed to create the pprof directory: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
}
|
||||
f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405")))
|
||||
if err != nil {
|
||||
SysLog("创建pprof文件失败 " + err.Error())
|
||||
SysLog(LogText("failed to create the pprof file: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
err = pprof.StartCPUProfile(f)
|
||||
if err != nil {
|
||||
SysLog("启动pprof失败 " + err.Error())
|
||||
SysLog(LogText("failed to start pprof: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
time.Sleep(10 * time.Second) // profile for 30 seconds
|
||||
|
||||
+17
-17
@@ -24,17 +24,17 @@ func RedisKeyCacheSeconds() int {
|
||||
func InitRedisClient() (err error) {
|
||||
if os.Getenv("REDIS_CONN_STRING") == "" {
|
||||
RedisEnabled = false
|
||||
SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
|
||||
SysLog(LogText("REDIS_CONN_STRING not set, Redis is not enabled"))
|
||||
return nil
|
||||
}
|
||||
if os.Getenv("SYNC_FREQUENCY") == "" {
|
||||
SysLog("SYNC_FREQUENCY not set, use default value 60")
|
||||
SysLog(LogText("SYNC_FREQUENCY not set, use default value 60"))
|
||||
SyncFrequency = 60
|
||||
}
|
||||
SysLog("Redis is enabled")
|
||||
SysLog(LogText("Redis is enabled"))
|
||||
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
|
||||
if err != nil {
|
||||
FatalLog("failed to parse Redis connection string: " + err.Error())
|
||||
FatalLog(LogText("failed to parse Redis connection string: %s", err.Error()))
|
||||
}
|
||||
opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10)
|
||||
RDB = redis.NewClient(opt)
|
||||
@@ -44,11 +44,11 @@ func InitRedisClient() (err error) {
|
||||
|
||||
_, err = RDB.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
FatalLog("Redis ping test failed: " + err.Error())
|
||||
FatalLog(LogText("Redis ping test failed: %s", err.Error()))
|
||||
}
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis connected to %s", opt.Addr))
|
||||
SysLog(fmt.Sprintf("Redis database: %d", opt.DB))
|
||||
SysLog(LogText("Redis connected to %s", opt.Addr))
|
||||
SysLog(LogText("Redis database: %d", opt.DB))
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -56,14 +56,14 @@ func InitRedisClient() (err error) {
|
||||
func ParseRedisOption() *redis.Options {
|
||||
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
|
||||
if err != nil {
|
||||
FatalLog("failed to parse Redis connection string: " + err.Error())
|
||||
FatalLog(LogText("failed to parse Redis connection string: %s", err.Error()))
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
func RedisSet(key string, value string, expiration time.Duration) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
|
||||
SysLog(LogText("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Set(ctx, key, value, expiration).Err()
|
||||
@@ -71,7 +71,7 @@ func RedisSet(key string, value string, expiration time.Duration) error {
|
||||
|
||||
func RedisGet(key string) (string, error) {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis GET: key=%s", key))
|
||||
SysLog(LogText("Redis GET: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
val, err := RDB.Get(ctx, key).Result()
|
||||
@@ -90,7 +90,7 @@ func RedisGet(key string) (string, error) {
|
||||
|
||||
func RedisDel(key string) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis DEL: key=%s", key))
|
||||
SysLog(LogText("Redis DEL: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Del(ctx, key).Err()
|
||||
@@ -98,7 +98,7 @@ func RedisDel(key string) error {
|
||||
|
||||
func RedisDelKey(key string) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
|
||||
SysLog(LogText("Redis DEL Key: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Del(ctx, key).Err()
|
||||
@@ -106,7 +106,7 @@ func RedisDelKey(key string) error {
|
||||
|
||||
func RedisHSetObj(key string, obj any, expiration time.Duration) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration))
|
||||
SysLog(LogText("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration))
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -160,7 +160,7 @@ func RedisHSetObj(key string, obj any, expiration time.Duration) error {
|
||||
|
||||
func RedisHGetObj(key string, obj any) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key))
|
||||
SysLog(LogText("Redis HGETALL: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -241,7 +241,7 @@ func RedisHGetObj(key string, obj any) error {
|
||||
// RedisIncr Add this function to handle atomic increments
|
||||
func RedisIncr(key string, delta int64) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis INCR: key=%s, delta=%d", key, delta))
|
||||
SysLog(LogText("Redis INCR: key=%s, delta=%d", key, delta))
|
||||
}
|
||||
// 检查键的剩余生存时间
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
@@ -274,7 +274,7 @@ func RedisIncr(key string, delta int64) error {
|
||||
|
||||
func RedisHIncrBy(key, field string, delta int64) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta))
|
||||
SysLog(LogText("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta))
|
||||
}
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
ttl, err := ttlCmd.Result()
|
||||
@@ -301,7 +301,7 @@ func RedisHIncrBy(key, field string, delta int64) error {
|
||||
|
||||
func RedisHSetField(key, field string, value any) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value))
|
||||
SysLog(LogText("Redis HSET field: key=%s, field=%s, value=%v", key, field, value))
|
||||
}
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
ttl, err := ttlCmd.Result()
|
||||
|
||||
+4
-4
@@ -50,20 +50,20 @@ func LogStartupSuccess(startTime time.Time, port string) {
|
||||
// Warn when the local HTTP compatibility mode disables cookie transport
|
||||
// security and refresh/logout Origin validation.
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[33m%s\033[0m\n", LogText("Warning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production."))
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m %s\n", SystemName, Version, LogText("ready in %d ms", durationMs))
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
|
||||
if !IsRunningInContainer() {
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1m%-8s\033[0m http://localhost:%s/\n", LogText("Local:"), port)
|
||||
}
|
||||
|
||||
for _, ip := range networkIps {
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1m%-8s\033[0m http://%s:%s/\n", LogText("Network:"), ip, port)
|
||||
}
|
||||
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
|
||||
@@ -17,7 +17,7 @@ func TopupGroupRatio2JSONString() string {
|
||||
defer topupGroupRatioMutex.RUnlock()
|
||||
jsonBytes, err := json.Marshal(topupGroupRatio)
|
||||
if err != nil {
|
||||
SysError("error marshalling topup group ratio: " + err.Error())
|
||||
SysError(LogText("error marshalling topup group ratio: %s", err.Error()))
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func GetTopupGroupRatio(name string) float64 {
|
||||
defer topupGroupRatioMutex.RUnlock()
|
||||
ratio, ok := topupGroupRatio[name]
|
||||
if !ok {
|
||||
SysError("topup group ratio not found: " + name)
|
||||
SysError(LogText("topup group ratio not found: %s", name))
|
||||
return 1
|
||||
}
|
||||
return ratio
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -130,12 +131,12 @@ func ValidateNumericCode(code string) (string, error) {
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
|
||||
if len(code) != 6 {
|
||||
return "", fmt.Errorf("验证码必须是6位数字")
|
||||
return "", errors.New("the verification code must be 6 digits")
|
||||
}
|
||||
|
||||
// 检查是否为纯数字
|
||||
if _, err := strconv.Atoi(code); err != nil {
|
||||
return "", fmt.Errorf("验证码只能包含数字")
|
||||
return "", errors.New("the verification code must contain only digits")
|
||||
}
|
||||
|
||||
return code, nil
|
||||
|
||||
@@ -163,31 +163,6 @@ func Bytes2Size(num int64) string {
|
||||
return numStr + " " + unit
|
||||
}
|
||||
|
||||
func Seconds2Time(num int) (time string) {
|
||||
if num/31104000 > 0 {
|
||||
time += strconv.Itoa(num/31104000) + " 年 "
|
||||
num %= 31104000
|
||||
}
|
||||
if num/2592000 > 0 {
|
||||
time += strconv.Itoa(num/2592000) + " 个月 "
|
||||
num %= 2592000
|
||||
}
|
||||
if num/86400 > 0 {
|
||||
time += strconv.Itoa(num/86400) + " 天 "
|
||||
num %= 86400
|
||||
}
|
||||
if num/3600 > 0 {
|
||||
time += strconv.Itoa(num/3600) + " 小时 "
|
||||
num %= 3600
|
||||
}
|
||||
if num/60 > 0 {
|
||||
time += strconv.Itoa(num/60) + " 分钟 "
|
||||
num %= 60
|
||||
}
|
||||
time += strconv.Itoa(num) + " 秒"
|
||||
return
|
||||
}
|
||||
|
||||
func Interface2String(inter any) string {
|
||||
switch inter.(type) {
|
||||
case string:
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -44,8 +43,9 @@ func newAccessTokenItem(token *model.UserAccessToken) accessTokenItem {
|
||||
return accessTokenItem{UserAccessToken: *token, Scopes: scopes}
|
||||
}
|
||||
|
||||
func writeAccessTokenError(c *gin.Context, status int, code, message string) {
|
||||
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
|
||||
// writeAccessTokenError responds with a stable code and a web console message.
|
||||
func writeAccessTokenError(c *gin.Context, status int, code, key string, params ...map[string]any) {
|
||||
common.ApiErrorStatus(c, status, common.NewMessage(key, params...), gin.H{"code": code})
|
||||
}
|
||||
|
||||
// normalizeAccessTokenName returns the trimmed name, or false when it is empty
|
||||
@@ -170,7 +170,7 @@ func CreateAccessToken(c *gin.Context) {
|
||||
}
|
||||
if err := model.CreateUserAccessToken(identity.UserID, token, service.AccessTokenMaxPerUser); err != nil {
|
||||
if errors.Is(err, model.ErrAccessTokenLimit) {
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_LIMIT", common.TranslateMessage(c, i18n.MsgAuthAccessTokenLimit, map[string]any{"Count": service.AccessTokenMaxPerUser}))
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_LIMIT", "You can create at most {{count}} access tokens", map[string]any{"count": service.AccessTokenMaxPerUser})
|
||||
return
|
||||
}
|
||||
writeSecurityOperationError(c, err)
|
||||
|
||||
@@ -741,7 +741,7 @@ func TestSecurityAndOperationEventsUseAuditTable(t *testing.T) {
|
||||
recordManageAudit(c, "option.update", map[string]any{"key": "safe"})
|
||||
recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{user.Id}}, &model.AuditAdminInfo{AdminID: user.Id})
|
||||
for _, typ := range []int{model.LogTypeTopup, model.LogTypeConsume, model.LogTypeRefund, model.LogTypeSystem} {
|
||||
model.RecordLog(user.Id, typ, "business entry")
|
||||
model.RecordLog(user.Id, typ, common.NewMessage("business entry"))
|
||||
}
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&audits).Error)
|
||||
@@ -1206,7 +1206,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, status.LastUsedAt)
|
||||
assert.Equal(t, "192.0.2.8", status.LastUsedIp)
|
||||
model.RecordLog(1, model.LogTypeTopup, "independent business")
|
||||
model.RecordLog(1, model.LogTypeTopup, common.NewMessage("independent business"))
|
||||
_, err = model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100)
|
||||
require.NoError(t, err)
|
||||
_, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, 1)
|
||||
|
||||
+3
-2
@@ -70,8 +70,9 @@ var auditContentTemplates = map[string]string{
|
||||
"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.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) {
|
||||
|
||||
@@ -2,10 +2,10 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -170,7 +170,7 @@ func writeAuthSessionError(c *gin.Context, err error) {
|
||||
// The response body only carries the generic AUTH_INTERNAL_ERROR
|
||||
// code; without this log the underlying Redis/database/session
|
||||
// failure is indistinguishable from the client side.
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("auth session internal error (%s %s): %v", c.Request.Method, c.Request.URL.Path, err))
|
||||
logger.LogError(c.Request.Context(), common.LogText("auth session internal error (%s %s): %v", c.Request.Method, c.Request.URL.Path, err))
|
||||
}
|
||||
c.JSON(status, gin.H{"success": false, "code": code, "message": http.StatusText(status)})
|
||||
}
|
||||
|
||||
@@ -524,7 +524,7 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
case constant.ChannelTypeAzure:
|
||||
return 0, errors.New("尚未实现")
|
||||
return 0, common.NewMessage("Not implemented yet")
|
||||
case constant.ChannelTypeCustom:
|
||||
baseURL = channel.GetBaseURL()
|
||||
//case common.ChannelTypeOpenAISB:
|
||||
@@ -544,7 +544,7 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
|
||||
case constant.ChannelTypeMoonshot:
|
||||
return updateChannelMoonshotBalance(channel)
|
||||
default:
|
||||
return 0, errors.New("尚未实现")
|
||||
return 0, common.NewMessage("Not implemented yet")
|
||||
}
|
||||
url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL)
|
||||
|
||||
@@ -594,10 +594,7 @@ func UpdateChannelBalance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if channel.ChannelInfo.IsMultiKey {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "多密钥渠道不支持余额查询",
|
||||
})
|
||||
common.ApiErrorT(c, "Multi-key channels do not support balance queries")
|
||||
return
|
||||
}
|
||||
result, err := updateChannelBalance(channel)
|
||||
@@ -639,7 +636,7 @@ func updateAllChannelsBalance() error {
|
||||
} else if result.RawResponse == "" {
|
||||
// err is nil & balance <= 0 means quota is used up
|
||||
if result.Balance <= 0 {
|
||||
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
|
||||
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "Insufficient balance")
|
||||
}
|
||||
}
|
||||
time.Sleep(common.RequestInterval)
|
||||
@@ -664,8 +661,8 @@ func UpdateAllChannelsBalance(c *gin.Context) {
|
||||
func AutomaticallyUpdateChannels(frequency int) {
|
||||
for {
|
||||
time.Sleep(time.Duration(frequency) * time.Minute)
|
||||
common.SysLog("updating all channels")
|
||||
common.SysLog(common.LogText("updating all channels"))
|
||||
_ = updateAllChannelsBalance()
|
||||
common.SysLog("channels update done")
|
||||
common.SysLog(common.LogText("channels update done"))
|
||||
}
|
||||
}
|
||||
+20
-16
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
@@ -292,7 +293,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
|
||||
//// 创建一个用于日志的 info 副本,移除 ApiKey
|
||||
//logInfo := info
|
||||
//logInfo.ApiKey = ""
|
||||
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
|
||||
common.SysLog(common.LogText("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
|
||||
|
||||
priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
|
||||
if err != nil {
|
||||
@@ -446,7 +447,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
|
||||
httpResp = resp.(*http.Response)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
|
||||
common.SysError(fmt.Sprintf(
|
||||
common.SysError(common.LogText(
|
||||
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
|
||||
channel.Id,
|
||||
channel.Name,
|
||||
@@ -507,15 +508,15 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
ModelName: info.OriginModelName,
|
||||
TokenName: "模型测试",
|
||||
TokenName: "Model test",
|
||||
Quota: quota,
|
||||
Content: "模型测试",
|
||||
Content: []*common.Message{common.NewMessage("Model test")},
|
||||
UseTimeSeconds: int(consumedTime),
|
||||
IsStream: info.IsStream,
|
||||
Group: info.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
||||
common.SysLog(common.LogText("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
||||
return testResult{
|
||||
context: c,
|
||||
localErr: nil,
|
||||
@@ -937,7 +938,12 @@ func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, test
|
||||
|
||||
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
|
||||
if milliseconds > disableThreshold {
|
||||
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
|
||||
// The reason is stored with the channel and sent in the disable
|
||||
// notice; no request states a language here.
|
||||
err := errors.New(i18n.Translate("", i18n.MsgChannelResponseTimeExceeded, map[string]any{
|
||||
"Seconds": fmt.Sprintf("%.2f", float64(milliseconds)/1000.0),
|
||||
"Threshold": fmt.Sprintf("%.2f", float64(disableThreshold)/1000.0),
|
||||
}))
|
||||
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
|
||||
shouldBanChannel = true
|
||||
}
|
||||
@@ -1103,7 +1109,8 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu
|
||||
concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency
|
||||
summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report)
|
||||
if notify && (ctx == nil || ctx.Err() == nil) {
|
||||
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
|
||||
lang := service.RootUserLanguage()
|
||||
service.NotifyRootUser(dto.NotifyTypeChannelTest, i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent))
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
@@ -1138,15 +1145,12 @@ func TestAllChannels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"success": false,
|
||||
"message": "已有通道测试任务正在运行或等待中,不能启动本次手动任务",
|
||||
"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"type": task.Type,
|
||||
},
|
||||
})
|
||||
msg := common.NewMessage("A channel test task is already running or queued. Cannot start this manual task")
|
||||
common.ApiErrorStatus(c, http.StatusConflict, msg, gin.H{"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"type": task.Type,
|
||||
}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
+82
-211
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
relaychannel "github.com/QuantumNous/new-api/relay/channel"
|
||||
@@ -213,14 +212,14 @@ func GetAllChannels(c *gin.Context) {
|
||||
if enableTagMode {
|
||||
tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.SysError("failed to get paginated tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to get paginated tags: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to fetch tags. Please try again later")
|
||||
return
|
||||
}
|
||||
total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter))
|
||||
if err != nil {
|
||||
common.SysError("failed to count tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签数量失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to count tags: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to count tags. Please try again later")
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
@@ -232,16 +231,16 @@ func GetAllChannels(c *gin.Context) {
|
||||
Omit("key").
|
||||
Find(&tagChannels).Error
|
||||
if err != nil {
|
||||
common.SysError("failed to get channels by tag: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签渠道失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to get channels by tag: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to fetch channels with this tag. Please try again later")
|
||||
return
|
||||
}
|
||||
channelData = append(channelData, tagChannels...)
|
||||
}
|
||||
} else {
|
||||
if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil {
|
||||
common.SysError("failed to count channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道数量失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to count channels: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to count channels. Please try again later")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -251,8 +250,8 @@ func GetAllChannels(c *gin.Context) {
|
||||
Omit("key").
|
||||
Find(&channelData).Error
|
||||
if err != nil {
|
||||
common.SysError("failed to get channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to get channels: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to fetch channels. Please try again later")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -267,8 +266,8 @@ func GetAllChannels(c *gin.Context) {
|
||||
Count int64
|
||||
}
|
||||
if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil {
|
||||
common.SysError("failed to count channel types: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道类型统计失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to count channel types: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to count channel types. Please try again later")
|
||||
return
|
||||
}
|
||||
typeCounts := make(map[int64]int64)
|
||||
@@ -334,10 +333,7 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
|
||||
ids, err := fetchChannelUpstreamModelIDs(channel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取模型列表失败: %s", err.Error()),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to fetch models: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -377,10 +373,7 @@ func SearchChannels(c *gin.Context) {
|
||||
if enableTagMode {
|
||||
tags, err := model.SearchTags(keyword, group, modelKeyword, idSort)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
@@ -390,10 +383,7 @@ func SearchChannels(c *gin.Context) {
|
||||
Omit("key").
|
||||
Find(&tagChannels).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
channelData = append(channelData, tagChannels...)
|
||||
@@ -402,10 +392,7 @@ func SearchChannels(c *gin.Context) {
|
||||
} else {
|
||||
channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort, sortOptions)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
channelData = channels
|
||||
@@ -507,14 +494,14 @@ func GetChannel(c *gin.Context) {
|
||||
func GetChannelKey(c *gin.Context) {
|
||||
channelId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || channelId <= 0 {
|
||||
common.ApiErrorMsg(c, "渠道ID格式错误")
|
||||
common.ApiErrorT(c, "Invalid channel ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取渠道信息(包含密钥)
|
||||
channel, err := model.GetChannelById(channelId, true)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ApiErrorI18n(c, i18n.MsgChannelNotExists)
|
||||
common.ApiErrorT(c, "Channel does not exist")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -529,12 +516,8 @@ func GetChannelKey(c *gin.Context) {
|
||||
})
|
||||
|
||||
// 返回渠道密钥
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "获取成功",
|
||||
"data": map[string]any{
|
||||
"key": channel.Key,
|
||||
},
|
||||
common.ApiSuccessT(c, "Fetched successfully", map[string]any{
|
||||
"key": channel.Key,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -550,7 +533,7 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
|
||||
|
||||
// 校验 channel settings
|
||||
if err := channel.ValidateSettings(); err != nil {
|
||||
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
|
||||
return common.NewMessage("Invalid channel settings format: {{error}}", map[string]any{"error": err.Error()})
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeTaskPlugin {
|
||||
pluginKey := strings.TrimSpace(channel.GetSetting().TaskPluginKey)
|
||||
@@ -631,7 +614,7 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
|
||||
// 检查模型名称长度是否超过 255
|
||||
for _, m := range channel.GetModels() {
|
||||
if len(m) > 255 {
|
||||
return fmt.Errorf("模型名称过长: %s", m)
|
||||
return common.NewMessage("Model name is too long: {{model}}", map[string]any{"model": m})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -639,16 +622,16 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
|
||||
// VertexAI 特殊校验
|
||||
if channel.Type == constant.ChannelTypeVertexAi {
|
||||
if channel.Other == "" {
|
||||
return fmt.Errorf("部署地区不能为空")
|
||||
return common.NewMessage("Deployment region cannot be empty")
|
||||
}
|
||||
|
||||
regionMap, err := common.StrToMap(channel.Other)
|
||||
if err != nil {
|
||||
return fmt.Errorf("部署地区必须是标准的Json格式,例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}")
|
||||
return common.NewMessage("Deployment region must be valid JSON, for example {{example}}", map[string]any{"example": `{"default": "us-central1", "region2": "us-east1"}`})
|
||||
}
|
||||
|
||||
if regionMap["default"] == nil {
|
||||
return fmt.Errorf("部署地区必须包含default字段")
|
||||
return common.NewMessage("Deployment region must include a default field")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,8 +670,8 @@ func RefreshCodexChannelCredential(c *gin.Context) {
|
||||
|
||||
oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
|
||||
if err != nil {
|
||||
common.SysError("failed to refresh codex channel credential: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to refresh codex channel credential: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to refresh credentials. Please try again later")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -721,7 +704,7 @@ func getVertexArrayKeys(keys string) ([]string, error) {
|
||||
var keyArray []any
|
||||
err := common.Unmarshal([]byte(keys), &keyArray)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量添加 Vertex AI 必须使用标准的JsonArray格式,例如[{key1}, {key2}...],请检查输入: %w", err)
|
||||
return nil, common.NewMessage("Batch-adding Vertex AI keys requires a JSON array, for example {{example}}. Check the input: {{error}}", map[string]any{"example": "[{key1}, {key2}...]", "error": err.Error()})
|
||||
}
|
||||
cleanKeys := make([]string, 0, len(keyArray))
|
||||
for _, key := range keyArray {
|
||||
@@ -730,9 +713,9 @@ func getVertexArrayKeys(keys string) ([]string, error) {
|
||||
case string:
|
||||
keyStr = strings.TrimSpace(v)
|
||||
default:
|
||||
bytes, err := json.Marshal(v)
|
||||
bytes, err := common.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Vertex AI key JSON 编码失败: %w", err)
|
||||
return nil, common.NewMessage("Failed to encode Vertex AI key JSON: {{error}}", map[string]any{"error": err.Error()})
|
||||
}
|
||||
keyStr = string(bytes)
|
||||
}
|
||||
@@ -741,7 +724,7 @@ func getVertexArrayKeys(keys string) ([]string, error) {
|
||||
}
|
||||
}
|
||||
if len(cleanKeys) == 0 {
|
||||
return nil, fmt.Errorf("批量添加 Vertex AI 的 keys 不能为空")
|
||||
return nil, common.NewMessage("Vertex AI keys for batch adding cannot be empty")
|
||||
}
|
||||
return cleanKeys, nil
|
||||
}
|
||||
@@ -771,10 +754,7 @@ func AddChannel(c *gin.Context) {
|
||||
(addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "")
|
||||
// 使用统一的校验函数
|
||||
if err := validateChannel(addChannelRequest.Channel, true); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -787,10 +767,7 @@ func AddChannel(c *gin.Context) {
|
||||
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
|
||||
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array)
|
||||
@@ -813,10 +790,7 @@ func AddChannel(c *gin.Context) {
|
||||
// multi json
|
||||
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -825,10 +799,7 @@ func AddChannel(c *gin.Context) {
|
||||
case "single":
|
||||
keys = []string{addChannelRequest.Channel.Key}
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "不支持的添加模式",
|
||||
})
|
||||
common.ApiErrorT(c, "Unsupported add mode")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -949,10 +920,7 @@ func DisableTagChannels(c *gin.Context) {
|
||||
channelTag := ChannelTag{}
|
||||
err := c.ShouldBindJSON(&channelTag)
|
||||
if err != nil || channelTag.Tag == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
channels, err := model.GetChannelsByTag(channelTag.Tag, false, false)
|
||||
@@ -982,10 +950,7 @@ func EnableTagChannels(c *gin.Context) {
|
||||
channelTag := ChannelTag{}
|
||||
err := c.ShouldBindJSON(&channelTag)
|
||||
if err != nil || channelTag.Tag == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
err = model.EnableChannelByTag(channelTag.Tag)
|
||||
@@ -1008,31 +973,22 @@ func EditTagChannels(c *gin.Context) {
|
||||
channelTag := ChannelTag{}
|
||||
err := c.ShouldBindJSON(&channelTag)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if channelTag.Tag == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "tag不能为空",
|
||||
})
|
||||
common.ApiErrorT(c, "Tag cannot be empty")
|
||||
return
|
||||
}
|
||||
if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
common.ApiErrorT(c, "Unauthorized, insufficient privileges")
|
||||
return
|
||||
}
|
||||
if channelTag.ParamOverride != nil {
|
||||
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
|
||||
if trimmed != "" && !json.Valid([]byte(trimmed)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数覆盖必须是合法的 JSON 格式",
|
||||
})
|
||||
common.ApiErrorT(c, "Parameter override must be valid JSON format")
|
||||
return
|
||||
}
|
||||
channelTag.ParamOverride = common.GetPointer[string](trimmed)
|
||||
@@ -1040,10 +996,7 @@ func EditTagChannels(c *gin.Context) {
|
||||
if channelTag.HeaderOverride != nil {
|
||||
trimmed := strings.TrimSpace(*channelTag.HeaderOverride)
|
||||
if trimmed != "" && !json.Valid([]byte(trimmed)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请求头覆盖必须是合法的 JSON 格式",
|
||||
})
|
||||
common.ApiErrorT(c, "Header override must be valid JSON format")
|
||||
return
|
||||
}
|
||||
channelTag.HeaderOverride = common.GetPointer[string](trimmed)
|
||||
@@ -1073,10 +1026,7 @@ func DeleteChannelBatch(c *gin.Context) {
|
||||
channelBatch := ChannelBatch{}
|
||||
err := c.ShouldBindJSON(&channelBatch)
|
||||
if err != nil || len(channelBatch.Ids) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
deletedCount, err := model.BatchDeleteChannels(channelBatch.Ids)
|
||||
@@ -1132,7 +1082,7 @@ func UpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if _, ok := requestData["status"]; ok {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
clearChannelReadOnlyFields(&channel, requestData)
|
||||
@@ -1150,19 +1100,13 @@ func UpdateChannel(c *gin.Context) {
|
||||
(channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "")
|
||||
// 使用统一的校验函数
|
||||
if err := validateChannel(&channel.Channel, false); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
|
||||
originChannel, err := model.GetChannelById(channel.Id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
originProxy := originChannel.GetSetting().Proxy
|
||||
@@ -1191,7 +1135,7 @@ func UpdateChannel(c *gin.Context) {
|
||||
|
||||
if channelHasSensitiveChanges(&channel, originChannel, requestData) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
common.ApiErrorT(c, "Unauthorized, insufficient privileges")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1230,10 +1174,7 @@ func UpdateChannel(c *gin.Context) {
|
||||
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
|
||||
array, err := getVertexArrayKeys(channel.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "追加密钥解析失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to parse appended keys: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
newKeys = array
|
||||
@@ -1328,12 +1269,12 @@ func UpdateChannel(c *gin.Context) {
|
||||
func UpdateChannelStatus(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
req := ChannelStatusRequest{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || !isManageableChannelStatus(req.Status) {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation")
|
||||
@@ -1358,7 +1299,7 @@ func UpdateChannelStatus(c *gin.Context) {
|
||||
func BatchUpdateChannelStatus(c *gin.Context) {
|
||||
req := ChannelStatusBatchRequest{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 || !isManageableChannelStatus(req.Status) {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
changedCount := 0
|
||||
@@ -1498,10 +1439,7 @@ func FetchModels(c *gin.Context) {
|
||||
var err error
|
||||
channel, err = buildAdvancedCustomModelPreviewChannel(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -1526,10 +1464,7 @@ func FetchModels(c *gin.Context) {
|
||||
|
||||
models, err := fetchChannelUpstreamModelIDs(channel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取模型列表失败: %s", err.Error()),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to fetch models: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -1543,10 +1478,7 @@ func BatchSetChannelTag(c *gin.Context) {
|
||||
channelBatch := ChannelBatch{}
|
||||
err := c.ShouldBindJSON(&channelBatch)
|
||||
if err != nil || len(channelBatch.Ids) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag)
|
||||
@@ -1569,10 +1501,7 @@ func BatchSetChannelTag(c *gin.Context) {
|
||||
func GetTagModels(c *gin.Context) {
|
||||
tag := c.Query("tag")
|
||||
if tag == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "tag不能为空",
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Tag cannot be empty"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1611,7 +1540,7 @@ func GetTagModels(c *gin.Context) {
|
||||
// POST /api/channel/copy/:id
|
||||
// Optional query params:
|
||||
//
|
||||
// suffix - string appended to the original name (default "_复制")
|
||||
// suffix - string appended to the original name (default "_copy")
|
||||
// reset_balance - bool, when true will reset balance & used_quota to 0 (default true)
|
||||
func CopyChannel(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
@@ -1620,7 +1549,7 @@ func CopyChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
suffix := c.DefaultQuery("suffix", "_复制")
|
||||
suffix := c.DefaultQuery("suffix", "_copy")
|
||||
resetBalance := true
|
||||
if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" {
|
||||
if v, err := strconv.ParseBool(rbStr); err == nil {
|
||||
@@ -1631,8 +1560,8 @@ func CopyChannel(c *gin.Context) {
|
||||
// fetch original channel with key
|
||||
origin, err := model.GetChannelById(id, true)
|
||||
if err != nil {
|
||||
common.SysError("failed to get channel by id: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道信息失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to get channel by id: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to fetch channel. Please try again later")
|
||||
return
|
||||
}
|
||||
if (origin.Type == constant.ChannelTypeTaskPlugin || len(origin.GetSetting().TaskPluginBindings()) > 0) &&
|
||||
@@ -1654,15 +1583,15 @@ func CopyChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := clone.ValidateSettings(); err != nil {
|
||||
common.SysError("failed to validate cloned channel: " + err.Error())
|
||||
common.SysError(common.LogText("failed to validate cloned channel: %s", err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "Failed to copy channel: invalid channel settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// insert
|
||||
if err := clone.Insert(); err != nil {
|
||||
common.SysError("failed to clone channel: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
|
||||
common.SysError(common.LogText("failed to clone channel: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to copy channel. Please try again later")
|
||||
return
|
||||
}
|
||||
model.InitChannelCache()
|
||||
@@ -1717,23 +1646,17 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
|
||||
channel, err := model.GetChannelById(request.ChannelId, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "渠道不存在",
|
||||
})
|
||||
common.ApiErrorT(c, "Channel does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
if !channel.ChannelInfo.IsMultiKey {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该渠道不是多密钥模式",
|
||||
})
|
||||
common.ApiErrorT(c, "This channel is not in multi-key mode")
|
||||
return
|
||||
}
|
||||
if multiKeyActionRequiresSensitiveWrite(request.Action) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
common.ApiErrorT(c, "Unauthorized, insufficient privileges")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1865,19 +1788,13 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
|
||||
case "disable_key":
|
||||
if request.KeyIndex == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "未指定要禁用的密钥索引",
|
||||
})
|
||||
common.ApiErrorT(c, "No key index specified to disable")
|
||||
return
|
||||
}
|
||||
|
||||
keyIndex := *request.KeyIndex
|
||||
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "密钥索引超出范围",
|
||||
})
|
||||
common.ApiErrorT(c, "Key index out of range")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1903,27 +1820,18 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
if shouldCloseWebSocket {
|
||||
closeActiveChannelWebSockets([]int{channel.Id})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "密钥已禁用",
|
||||
})
|
||||
common.ApiSuccessT(c, "Key disabled", nil)
|
||||
return
|
||||
|
||||
case "enable_key":
|
||||
if request.KeyIndex == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "未指定要启用的密钥索引",
|
||||
})
|
||||
common.ApiErrorT(c, "No key index specified to enable")
|
||||
return
|
||||
}
|
||||
|
||||
keyIndex := *request.KeyIndex
|
||||
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "密钥索引超出范围",
|
||||
})
|
||||
common.ApiErrorT(c, "Key index out of range")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1946,10 +1854,7 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
}
|
||||
|
||||
model.InitChannelCache()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "密钥已启用",
|
||||
})
|
||||
common.ApiSuccessT(c, "Key enabled", nil)
|
||||
return
|
||||
|
||||
case "enable_all_keys":
|
||||
@@ -1971,10 +1876,7 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
}
|
||||
|
||||
model.InitChannelCache()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("已启用 %d 个密钥", enabledCount),
|
||||
})
|
||||
common.ApiSuccessT(c, "Enabled {{count}} keys", nil, map[string]any{"count": enabledCount})
|
||||
return
|
||||
|
||||
case "disable_all_keys":
|
||||
@@ -2004,10 +1906,7 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
}
|
||||
|
||||
if disabledCount == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "没有可禁用的密钥",
|
||||
})
|
||||
common.ApiErrorT(c, "No keys to disable")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2021,27 +1920,18 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
if shouldCloseWebSocket {
|
||||
closeActiveChannelWebSockets([]int{channel.Id})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount),
|
||||
})
|
||||
common.ApiSuccessT(c, "Disabled {{count}} keys", nil, map[string]any{"count": disabledCount})
|
||||
return
|
||||
|
||||
case "delete_key":
|
||||
if request.KeyIndex == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "未指定要删除的密钥索引",
|
||||
})
|
||||
common.ApiErrorT(c, "No key index specified to delete")
|
||||
return
|
||||
}
|
||||
|
||||
keyIndex := *request.KeyIndex
|
||||
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "密钥索引超出范围",
|
||||
})
|
||||
common.ApiErrorT(c, "Key index out of range")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2080,10 +1970,7 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
}
|
||||
|
||||
if len(remainingKeys) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "不能删除最后一个密钥",
|
||||
})
|
||||
common.ApiErrorT(c, "Cannot delete the last key")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2104,10 +1991,7 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
if shouldCloseWebSocket {
|
||||
closeActiveChannelWebSockets([]int{channel.Id})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "密钥已删除",
|
||||
})
|
||||
common.ApiSuccessT(c, "Key deleted", nil)
|
||||
return
|
||||
|
||||
case "delete_disabled_keys":
|
||||
@@ -2151,10 +2035,7 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
}
|
||||
|
||||
if deletedCount == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "没有需要删除的自动禁用密钥",
|
||||
})
|
||||
common.ApiErrorT(c, "No auto-disabled keys to delete")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2175,18 +2056,11 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
if shouldCloseWebSocket {
|
||||
closeActiveChannelWebSockets([]int{channel.Id})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount),
|
||||
"data": deletedCount,
|
||||
})
|
||||
common.ApiSuccessT(c, "Deleted {{count}} auto-disabled keys", deletedCount, map[string]any{"count": deletedCount})
|
||||
return
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "不支持的操作",
|
||||
})
|
||||
common.ApiErrorT(c, "Unsupported operation")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2439,10 +2313,7 @@ func OllamaVersion(c *gin.Context) {
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
version, err := ollama.FetchOllamaVersion(baseURL, key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取Ollama版本失败: %s", err.Error()),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to fetch Ollama version: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -34,19 +35,13 @@ func ClearChannelAffinityCache(c *gin.Context) {
|
||||
}
|
||||
|
||||
if ruleName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "缺少参数:rule_name,或使用 all=true 清空全部",
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Missing parameter rule_name, or use all=true to clear everything"))
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := service.ClearChannelAffinityCacheByRuleName(ruleName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -491,5 +491,5 @@ func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
|
||||
|
||||
require.Equal(t, http.StatusConflict, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), existing.TaskID)
|
||||
require.Contains(t, recorder.Body.String(), "已有通道测试任务正在运行或等待中")
|
||||
require.Contains(t, recorder.Body.String(), "A channel test task is already running or queued")
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
|
||||
@@ -388,7 +389,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
||||
if channel.Type == constant.ChannelTypeGemini {
|
||||
key, _, apiErr := channel.GetNextEnabledKey()
|
||||
if apiErr != nil {
|
||||
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
|
||||
return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy)
|
||||
@@ -437,7 +438,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
||||
|
||||
key, _, apiErr := channel.GetNextEnabledKey()
|
||||
if apiErr != nil {
|
||||
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
|
||||
return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
@@ -467,7 +468,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
||||
func fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) ([]string, error) {
|
||||
key, _, apiErr := channel.GetNextEnabledKey()
|
||||
if apiErr != nil {
|
||||
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
|
||||
return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
@@ -564,7 +565,7 @@ func refreshChannelRuntimeCache() {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.SysLog(fmt.Sprintf("InitChannelCache panic: %v", r))
|
||||
common.SysLog(common.LogText("InitChannelCache panic: %v", r))
|
||||
}
|
||||
}()
|
||||
model.InitChannelCache()
|
||||
@@ -594,6 +595,7 @@ func shouldSendUpstreamModelUpdateNotification(now int64, changedChannels int, f
|
||||
}
|
||||
|
||||
func buildUpstreamModelUpdateTaskNotificationContent(
|
||||
lang string,
|
||||
checkedChannels int,
|
||||
changedChannels int,
|
||||
detectedAddModels int,
|
||||
@@ -606,50 +608,62 @@ func buildUpstreamModelUpdateTaskNotificationContent(
|
||||
) string {
|
||||
var builder strings.Builder
|
||||
failedChannels := len(failedChannelIDs)
|
||||
builder.WriteString(fmt.Sprintf(
|
||||
"上游模型巡检摘要:检测渠道 %d 个,发现变更 %d 个,新增 %d 个,删除 %d 个,自动同步新增 %d 个,失败 %d 个。",
|
||||
checkedChannels,
|
||||
changedChannels,
|
||||
detectedAddModels,
|
||||
detectedRemoveModels,
|
||||
autoAddedModels,
|
||||
failedChannels,
|
||||
))
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateSummary, map[string]any{
|
||||
"Checked": checkedChannels,
|
||||
"Changed": changedChannels,
|
||||
"Added": detectedAddModels,
|
||||
"Removed": detectedRemoveModels,
|
||||
"AutoAdded": autoAddedModels,
|
||||
"Failed": failedChannels,
|
||||
}))
|
||||
|
||||
if len(channelSummaries) > 0 {
|
||||
displayCount := min(len(channelSummaries), channelUpstreamModelUpdateNotifyMaxChannelDetails)
|
||||
builder.WriteString(fmt.Sprintf("\n\n变更渠道明细(展示 %d/%d):", displayCount, len(channelSummaries)))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateChangedChannels, map[string]any{
|
||||
"Shown": displayCount,
|
||||
"Total": len(channelSummaries),
|
||||
}))
|
||||
for _, summary := range channelSummaries[:displayCount] {
|
||||
builder.WriteString(fmt.Sprintf("\n- %s (+%d / -%d)", summary.ChannelName, summary.AddCount, summary.RemoveCount))
|
||||
}
|
||||
if len(channelSummaries) > displayCount {
|
||||
builder.WriteString(fmt.Sprintf("\n- 其余 %d 个渠道已省略", len(channelSummaries)-displayCount))
|
||||
builder.WriteString("\n- ")
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreChannels, map[string]any{
|
||||
"Count": len(channelSummaries) - displayCount,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
normalizedAddModelSamples := normalizeModelNames(addModelSamples)
|
||||
if len(normalizedAddModelSamples) > 0 {
|
||||
displayCount := min(len(normalizedAddModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
|
||||
builder.WriteString(fmt.Sprintf("\n\n新增模型示例(展示 %d/%d):%s",
|
||||
displayCount,
|
||||
len(normalizedAddModelSamples),
|
||||
strings.Join(normalizedAddModelSamples[:displayCount], ", "),
|
||||
))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateAddedModels, map[string]any{
|
||||
"Shown": displayCount,
|
||||
"Total": len(normalizedAddModelSamples),
|
||||
"Models": strings.Join(normalizedAddModelSamples[:displayCount], ", "),
|
||||
}))
|
||||
if len(normalizedAddModelSamples) > displayCount {
|
||||
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedAddModelSamples)-displayCount))
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
|
||||
"Count": len(normalizedAddModelSamples) - displayCount,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
normalizedRemoveModelSamples := normalizeModelNames(removeModelSamples)
|
||||
if len(normalizedRemoveModelSamples) > 0 {
|
||||
displayCount := min(len(normalizedRemoveModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
|
||||
builder.WriteString(fmt.Sprintf("\n\n删除模型示例(展示 %d/%d):%s",
|
||||
displayCount,
|
||||
len(normalizedRemoveModelSamples),
|
||||
strings.Join(normalizedRemoveModelSamples[:displayCount], ", "),
|
||||
))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateRemovedModels, map[string]any{
|
||||
"Shown": displayCount,
|
||||
"Total": len(normalizedRemoveModelSamples),
|
||||
"Models": strings.Join(normalizedRemoveModelSamples[:displayCount], ", "),
|
||||
}))
|
||||
if len(normalizedRemoveModelSamples) > displayCount {
|
||||
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedRemoveModelSamples)-displayCount))
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
|
||||
"Count": len(normalizedRemoveModelSamples) - displayCount,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,14 +672,16 @@ func buildUpstreamModelUpdateTaskNotificationContent(
|
||||
displayIDs := lo.Map(failedChannelIDs[:displayCount], func(channelID int, _ int) string {
|
||||
return fmt.Sprintf("%d", channelID)
|
||||
})
|
||||
builder.WriteString(fmt.Sprintf(
|
||||
"\n\n失败渠道 ID(展示 %d/%d):%s",
|
||||
displayCount,
|
||||
failedChannels,
|
||||
strings.Join(displayIDs, ", "),
|
||||
))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateFailedChannels, map[string]any{
|
||||
"Shown": displayCount,
|
||||
"Total": failedChannels,
|
||||
"Ids": strings.Join(displayIDs, ", "),
|
||||
}))
|
||||
if failedChannels > displayCount {
|
||||
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", failedChannels-displayCount))
|
||||
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
|
||||
"Count": failedChannels - displayCount,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
@@ -726,7 +742,7 @@ scanLoop:
|
||||
}
|
||||
err := query.Find(&channels).Error
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("upstream model update task query failed: %v", err))
|
||||
common.SysLog(common.LogText("upstream model update task query failed: %v", err))
|
||||
break
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
@@ -757,7 +773,7 @@ scanLoop:
|
||||
if err != nil {
|
||||
failedChannels++
|
||||
failedChannelIDs = append(failedChannelIDs, channel.Id)
|
||||
common.SysLog(fmt.Sprintf("upstream model update check failed: channel_id=%d channel_name=%s err=%v", channel.Id, channel.Name, err))
|
||||
common.SysLog(common.LogText("upstream model update check failed: channel_id=%d channel_name=%s err=%v", channel.Id, channel.Name, err))
|
||||
continue
|
||||
}
|
||||
currentAddModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
|
||||
@@ -817,7 +833,7 @@ scanLoop:
|
||||
}
|
||||
|
||||
if checkedChannels > 0 || common.DebugEnabled {
|
||||
common.SysLog(fmt.Sprintf(
|
||||
common.SysLog(common.LogText(
|
||||
"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
|
||||
checkedChannels,
|
||||
changedChannels,
|
||||
@@ -830,27 +846,28 @@ scanLoop:
|
||||
if changedChannels > 0 || failedChannels > 0 {
|
||||
now := common.GetTimestamp()
|
||||
if !shouldSendUpstreamModelUpdateNotification(now, changedChannels, failedChannels) {
|
||||
common.SysLog(fmt.Sprintf(
|
||||
common.SysLog(common.LogText(
|
||||
"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d",
|
||||
changedChannels,
|
||||
failedChannels,
|
||||
))
|
||||
return summary
|
||||
}
|
||||
service.NotifyUpstreamModelUpdateWatchers(
|
||||
"上游模型巡检通知",
|
||||
buildUpstreamModelUpdateTaskNotificationContent(
|
||||
checkedChannels,
|
||||
changedChannels,
|
||||
detectedAddModels,
|
||||
detectedRemoveModels,
|
||||
autoAddedModels,
|
||||
failedChannelIDs,
|
||||
channelSummaries,
|
||||
addModelSamples,
|
||||
removeModelSamples,
|
||||
),
|
||||
)
|
||||
service.NotifyUpstreamModelUpdateWatchers(func(lang string) (string, string) {
|
||||
return i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateNotifySubject),
|
||||
buildUpstreamModelUpdateTaskNotificationContent(
|
||||
lang,
|
||||
checkedChannels,
|
||||
changedChannels,
|
||||
detectedAddModels,
|
||||
detectedRemoveModels,
|
||||
autoAddedModels,
|
||||
failedChannelIDs,
|
||||
channelSummaries,
|
||||
addModelSamples,
|
||||
removeModelSamples,
|
||||
)
|
||||
})
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -1119,15 +1136,12 @@ func DetectAllChannelUpstreamModelUpdates(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"success": false,
|
||||
"message": "已有模型更新任务正在运行或等待中,不能启动本次手动任务",
|
||||
"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"type": task.Type,
|
||||
},
|
||||
})
|
||||
msg := common.NewMessage("A model update task is already running or queued. Cannot start this manual task")
|
||||
common.ApiErrorStatus(c, http.StatusConflict, msg, gin.H{"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"type": task.Type,
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -542,7 +543,9 @@ func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *
|
||||
})
|
||||
}
|
||||
|
||||
require.NoError(t, i18n.Init())
|
||||
content := buildUpstreamModelUpdateTaskNotificationContent(
|
||||
i18n.LangEn,
|
||||
24,
|
||||
12,
|
||||
56,
|
||||
@@ -561,10 +564,14 @@ func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *
|
||||
},
|
||||
)
|
||||
|
||||
require.Contains(t, content, "其余 4 个渠道已省略")
|
||||
require.Contains(t, content, "其余 1 个已省略")
|
||||
require.Contains(t, content, "失败渠道 ID(展示 10/12)")
|
||||
require.Contains(t, content, "其余 2 个已省略")
|
||||
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreChannels, map[string]any{"Count": 4}))
|
||||
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{"Count": 1}))
|
||||
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateFailedChannels, map[string]any{
|
||||
"Shown": 10,
|
||||
"Total": 12,
|
||||
"Ids": "1, 2, 3, 4, 5, 6, 7, 8, 9, 10",
|
||||
}))
|
||||
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{"Count": 2}))
|
||||
}
|
||||
|
||||
func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) {
|
||||
@@ -602,5 +609,5 @@ func TestDetectAllChannelUpstreamModelUpdatesRejectsExistingActiveTask(t *testin
|
||||
|
||||
require.Equal(t, http.StatusConflict, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), existing.TaskID)
|
||||
require.Contains(t, recorder.Body.String(), "已有模型更新任务正在运行或等待中")
|
||||
require.Contains(t, recorder.Body.String(), "A model update task is already running or queued")
|
||||
}
|
||||
+8
-18
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -16,7 +15,7 @@ import (
|
||||
func GetCheckinStatus(c *gin.Context) {
|
||||
setting := operation_setting.GetCheckinSetting()
|
||||
if !setting.Enabled {
|
||||
common.ApiErrorMsg(c, "签到功能未启用")
|
||||
common.ApiErrorT(c, "Check-in feature is not enabled")
|
||||
return
|
||||
}
|
||||
userId := c.GetInt("id")
|
||||
@@ -25,10 +24,7 @@ func GetCheckinStatus(c *gin.Context) {
|
||||
|
||||
stats, err := model.GetUserCheckinStats(userId, month)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -47,7 +43,7 @@ func GetCheckinStatus(c *gin.Context) {
|
||||
func DoCheckin(c *gin.Context) {
|
||||
setting := operation_setting.GetCheckinSetting()
|
||||
if !setting.Enabled {
|
||||
common.ApiErrorMsg(c, "签到功能未启用")
|
||||
common.ApiErrorT(c, "Check-in feature is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,18 +51,12 @@ func DoCheckin(c *gin.Context) {
|
||||
|
||||
checkin, err := model.UserCheckin(userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("用户签到,获得额度 %s", logger.LogQuota(checkin.QuotaAwarded)))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "签到成功",
|
||||
"data": gin.H{
|
||||
"quota_awarded": checkin.QuotaAwarded,
|
||||
"checkin_date": checkin.CheckinDate},
|
||||
model.RecordLog(userId, model.LogTypeSystem, common.NewMessage("Daily check-in, received {{quota}}", map[string]any{"quota": logger.FormatQuota(checkin.QuotaAwarded)}))
|
||||
common.ApiSuccessT(c, "Check-in successful", gin.H{
|
||||
"quota_awarded": checkin.QuotaAwarded,
|
||||
"checkin_date": checkin.CheckinDate,
|
||||
})
|
||||
}
|
||||
+11
-11
@@ -21,8 +21,8 @@ func GetCodexChannelUsage(c *gin.Context) {
|
||||
fetchCodexChannelWhamData(
|
||||
c,
|
||||
service.FetchCodexWhamUsage,
|
||||
"failed to fetch codex usage",
|
||||
"获取用量信息失败,请稍后重试",
|
||||
common.LogText("failed to fetch codex usage"),
|
||||
"Failed to fetch usage. Please try again later",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ func GetCodexChannelRateLimitResetCredits(c *gin.Context) {
|
||||
fetchCodexChannelWhamData(
|
||||
c,
|
||||
service.FetchCodexWhamRateLimitResetCredits,
|
||||
"failed to fetch codex reset credits",
|
||||
"获取重置次数详情失败,请稍后重试",
|
||||
common.LogText("failed to fetch codex reset credits"),
|
||||
"Failed to fetch reset credit details. Please try again later",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ func ResetCodexChannelUsage(c *gin.Context) {
|
||||
fetchCodexChannelWhamData(
|
||||
c,
|
||||
service.ConsumeCodexWhamRateLimitResetCredit,
|
||||
"failed to reset codex usage",
|
||||
"重置用量失败,请稍后重试",
|
||||
common.LogText("failed to reset codex usage"),
|
||||
"Failed to reset usage. Please try again later",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ func fetchCodexChannelWhamData(
|
||||
|
||||
oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key))
|
||||
if err != nil {
|
||||
common.SysError("failed to parse oauth key: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"})
|
||||
common.SysError(common.LogText("failed to parse oauth key: %s", err.Error()))
|
||||
common.ApiErrorT(c, "Failed to parse credentials. Please check the channel configuration")
|
||||
return
|
||||
}
|
||||
accessToken := strings.TrimSpace(oauthKey.AccessToken)
|
||||
@@ -111,7 +111,7 @@ func fetchCodexChannelWhamData(
|
||||
statusCode, body, err := fetch(ctx, client, ch.GetBaseURL(), accessToken, accountID)
|
||||
if err != nil {
|
||||
common.SysError(logPrefix + ": " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": userMessage})
|
||||
common.ApiErrorT(c, userMessage)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ func fetchCodexChannelWhamData(
|
||||
defer cancel2()
|
||||
statusCode, body, err = fetch(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID)
|
||||
if err != nil {
|
||||
common.SysError(logPrefix + " after refresh: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": userMessage})
|
||||
common.SysError(common.LogText("%s after refresh: %s", logPrefix, err.Error()))
|
||||
common.ApiErrorT(c, userMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
+29
-44
@@ -96,13 +96,13 @@ func GetCustomOAuthProvider(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 ID")
|
||||
common.ApiErrorT(c, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := model.GetCustomOAuthProviderById(id)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
|
||||
common.ApiErrorT(c, "OAuth provider not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ type FetchCustomOAuthDiscoveryRequest struct {
|
||||
func FetchCustomOAuthDiscovery(c *gin.Context) {
|
||||
var req FetchCustomOAuthDiscoveryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
|
||||
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
|
||||
issuerURL := strings.TrimSpace(req.IssuerURL)
|
||||
|
||||
if wellKnownURL == "" && issuerURL == "" {
|
||||
common.ApiErrorMsg(c, "请先填写 Discovery URL 或 Issuer URL")
|
||||
common.ApiErrorT(c, "Enter the Discovery URL or Issuer URL first")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
|
||||
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
|
||||
common.ApiErrorMsg(c, "Discovery URL 无效,仅支持 http/https")
|
||||
common.ApiErrorT(c, "Invalid Discovery URL. Only http and https are supported")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "创建 Discovery 请求失败: "+err.Error())
|
||||
common.ApiErrorT(c, "Failed to create the Discovery request: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
@@ -181,7 +181,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+err.Error())
|
||||
common.ApiErrorT(c, "Failed to fetch the Discovery configuration: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -192,13 +192,13 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
|
||||
if message == "" {
|
||||
message = resp.Status
|
||||
}
|
||||
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+message)
|
||||
common.ApiErrorT(c, "Failed to fetch the Discovery configuration: {{error}}", map[string]any{"error": message})
|
||||
return
|
||||
}
|
||||
|
||||
var discovery map[string]any
|
||||
if err = common.DecodeJson(resp.Body, &discovery); err != nil {
|
||||
common.ApiErrorMsg(c, "解析 Discovery 配置失败: "+err.Error())
|
||||
common.ApiErrorT(c, "Failed to parse the Discovery configuration: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,19 +216,19 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
|
||||
func CreateCustomOAuthProvider(c *gin.Context) {
|
||||
var req CreateCustomOAuthProviderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
|
||||
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if slug is already taken
|
||||
if model.IsSlugTaken(req.Slug, 0) {
|
||||
common.ApiErrorMsg(c, "该 Slug 已被使用")
|
||||
common.ApiErrorT(c, "This slug is already in use")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if slug conflicts with built-in providers
|
||||
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
|
||||
common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
|
||||
common.ApiErrorT(c, "This slug conflicts with a built-in OAuth provider")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -261,11 +261,7 @@ func CreateCustomOAuthProvider(c *gin.Context) {
|
||||
// Register the provider in the OAuth registry
|
||||
oauth.RegisterOrUpdateCustomProvider(provider)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "创建成功",
|
||||
"data": toCustomOAuthProviderResponse(provider),
|
||||
})
|
||||
common.ApiSuccessT(c, "Provider created successfully", toCustomOAuthProviderResponse(provider))
|
||||
}
|
||||
|
||||
// UpdateCustomOAuthProviderRequest is the request structure for updating a custom OAuth provider
|
||||
@@ -295,20 +291,20 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 ID")
|
||||
common.ApiErrorT(c, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateCustomOAuthProviderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
|
||||
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing provider
|
||||
provider, err := model.GetCustomOAuthProviderById(id)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
|
||||
common.ApiErrorT(c, "OAuth provider not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -317,12 +313,12 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
|
||||
// Check if new slug is taken by another provider
|
||||
if req.Slug != "" && req.Slug != provider.Slug {
|
||||
if model.IsSlugTaken(req.Slug, id) {
|
||||
common.ApiErrorMsg(c, "该 Slug 已被使用")
|
||||
common.ApiErrorT(c, "This slug is already in use")
|
||||
return
|
||||
}
|
||||
// Check if slug conflicts with built-in providers
|
||||
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
|
||||
common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
|
||||
common.ApiErrorT(c, "This slug conflicts with a built-in OAuth provider")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -394,11 +390,7 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
|
||||
}
|
||||
oauth.RegisterOrUpdateCustomProvider(provider)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "更新成功",
|
||||
"data": toCustomOAuthProviderResponse(provider),
|
||||
})
|
||||
common.ApiSuccessT(c, "Provider updated successfully", toCustomOAuthProviderResponse(provider))
|
||||
}
|
||||
|
||||
// DeleteCustomOAuthProvider deletes a custom OAuth provider
|
||||
@@ -406,26 +398,26 @@ func DeleteCustomOAuthProvider(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 ID")
|
||||
common.ApiErrorT(c, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing provider to get slug
|
||||
provider, err := model.GetCustomOAuthProviderById(id)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
|
||||
common.ApiErrorT(c, "OAuth provider not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there are any user bindings
|
||||
count, err := model.GetBindingCountByProviderId(id)
|
||||
if err != nil {
|
||||
common.SysError("Failed to get binding count for provider " + strconv.Itoa(id) + ": " + err.Error())
|
||||
common.ApiErrorMsg(c, "检查用户绑定时发生错误,请稍后重试")
|
||||
common.SysError(common.LogText("Failed to get binding count for provider %s: %s", strconv.Itoa(id), err.Error()))
|
||||
common.ApiErrorT(c, "Failed to check user bindings, please try again later")
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
common.ApiErrorMsg(c, "该 OAuth 提供商还有用户绑定,无法删除。请先解除所有用户绑定。")
|
||||
common.ApiErrorT(c, "This OAuth provider still has linked users and cannot be deleted. Unbind all users first.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -437,10 +429,7 @@ func DeleteCustomOAuthProvider(c *gin.Context) {
|
||||
// Unregister the provider from the OAuth registry
|
||||
oauth.UnregisterCustomProvider(provider.Slug)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "删除成功",
|
||||
})
|
||||
common.ApiSuccessT(c, "Provider deleted successfully", nil)
|
||||
}
|
||||
|
||||
func buildUserOAuthBindingsResponse(userId int) ([]UserOAuthBindingResponse, error) {
|
||||
@@ -471,7 +460,7 @@ func buildUserOAuthBindingsResponse(userId int) ([]UserOAuthBindingResponse, err
|
||||
func GetUserOAuthBindings(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
if userId == 0 {
|
||||
common.ApiErrorMsg(c, "未登录")
|
||||
common.ApiErrorT(c, "Not logged in")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -532,7 +521,7 @@ func UnbindCustomOAuth(c *gin.Context) {
|
||||
providerIdStr := c.Param("provider_id")
|
||||
providerId, err := strconv.Atoi(providerIdStr)
|
||||
if err != nil || providerId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的提供商 ID")
|
||||
common.ApiErrorT(c, "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -560,11 +549,7 @@ func UnbindCustomOAuth(c *gin.Context) {
|
||||
}
|
||||
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "Login account unlinked") != nil
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "解绑成功",
|
||||
"data": gin.H{"notification_warning": notificationFailed},
|
||||
})
|
||||
common.ApiSuccessT(c, "Unbound successfully", gin.H{"notification_warning": notificationFailed})
|
||||
}
|
||||
|
||||
func UnbindCustomOAuthByAdmin(c *gin.Context) {
|
||||
@@ -590,7 +575,7 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) {
|
||||
providerIdStr := c.Param("provider_id")
|
||||
providerId, err := strconv.Atoi(providerIdStr)
|
||||
if err != nil || providerId <= 0 {
|
||||
common.ApiErrorMsg(c, "invalid provider id")
|
||||
common.ApiErrorT(c, "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserBindingClear, service.AdminUserBindingContext{UserID: userId, ProviderID: providerId})
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ func GetUserGroups(c *gin.Context) {
|
||||
}
|
||||
if _, ok := userUsableGroups["auto"]; ok {
|
||||
usableGroups["auto"] = map[string]any{
|
||||
"ratio": "自动",
|
||||
"ratio": "auto",
|
||||
"desc": setting.GetUsableGroupDescription("auto"),
|
||||
}
|
||||
}
|
||||
|
||||
+4
-12
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -62,27 +63,18 @@ func GetUserLogs(c *gin.Context) {
|
||||
|
||||
// Deprecated: SearchAllLogs 已废弃,前端未使用该接口。
|
||||
func SearchAllLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该接口已废弃",
|
||||
})
|
||||
common.ApiErrorT(c, "This API is deprecated")
|
||||
}
|
||||
|
||||
// Deprecated: SearchUserLogs 已废弃,前端未使用该接口。
|
||||
func SearchUserLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该接口已废弃",
|
||||
})
|
||||
common.ApiErrorT(c, "This API is deprecated")
|
||||
}
|
||||
|
||||
func GetLogByKey(c *gin.Context) {
|
||||
tokenId := c.GetInt("token_id")
|
||||
if tokenId == 0 {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的令牌",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenInvalid)
|
||||
return
|
||||
}
|
||||
logs, err := model.GetLogByTokenId(tokenId)
|
||||
|
||||
@@ -20,7 +20,7 @@ func VerifyLogin(c *gin.Context) {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.Code == "" {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if request.Method == "" {
|
||||
@@ -44,7 +44,7 @@ func LoginPasskeyBegin(c *gin.Context) {
|
||||
RPID string `json:"rp_id"`
|
||||
}
|
||||
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey)
|
||||
@@ -90,7 +90,7 @@ func LoginPasskeyFinish(c *gin.Context) {
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.PasskeyFlowToken == "" || len(request.Credential) == 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey)
|
||||
|
||||
+19
-19
@@ -43,7 +43,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
}
|
||||
summary.UnfinishedTasks = len(tasks)
|
||||
|
||||
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
|
||||
logger.LogInfo(ctx, common.LogText("unfinished tasks found: %v", len(tasks)))
|
||||
taskChannelM := make(map[int][]string)
|
||||
taskM := make(map[string]*model.Midjourney)
|
||||
nullTaskIds := make([]int, 0)
|
||||
@@ -63,9 +63,9 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
"progress": "100%",
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
|
||||
logger.LogError(ctx, common.LogText("Fix null mj_id task error: %v", err))
|
||||
} else {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
|
||||
logger.LogInfo(ctx, common.LogText("Fix null mj_id task success: %v", nullTaskIds))
|
||||
}
|
||||
}
|
||||
if len(taskChannelM) == 0 {
|
||||
@@ -83,20 +83,20 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
}
|
||||
processedChannels++
|
||||
summary.ChannelsScanned++
|
||||
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
|
||||
logger.LogInfo(ctx, common.LogText("channel #%d has %d unfinished tasks", channelId, len(taskIds)))
|
||||
if len(taskIds) == 0 {
|
||||
continue
|
||||
}
|
||||
midjourneyChannel, err := model.CacheGetChannel(channelId)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
|
||||
logger.LogError(ctx, common.LogText("CacheGetChannel: %v", err))
|
||||
err := model.MjBulkUpdate(taskIds, map[string]any{
|
||||
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
|
||||
"fail_reason": "Failed to get the channel information. Please contact the administrator",
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
|
||||
logger.LogInfo(ctx, common.LogText("UpdateMidjourneyTask error: %v", err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
"ids": taskIds,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task marshal body error: %v", err))
|
||||
logger.LogError(ctx, common.LogText("Get Task marshal body error: %v", err))
|
||||
continue
|
||||
}
|
||||
timeout := time.Second * 15
|
||||
@@ -114,26 +114,26 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
req, err := http.NewRequestWithContext(requestCtx, "POST", requestUrl, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
cancel()
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
|
||||
logger.LogError(ctx, common.LogText("Get Task error: %v", err))
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
|
||||
resp, err := service.GetHttpClient().Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
|
||||
logger.LogError(ctx, common.LogText("Get Task Do req error: %v", err))
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||
logger.LogError(ctx, common.LogText("Get Task status code: %d", resp.StatusCode))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err))
|
||||
logger.LogError(ctx, common.LogText("Get Mjp Task parse body error: %v", err))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
continue
|
||||
@@ -141,7 +141,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
var responseItems []dto.MidjourneyDto
|
||||
err = common.Unmarshal(responseBody, &responseItems)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
logger.LogError(ctx, common.LogText("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
continue
|
||||
@@ -153,14 +153,14 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
for _, responseItem := range responseItems {
|
||||
task := taskM[responseItem.MjId]
|
||||
if task == nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Midjourney task response ignored: unknown mj_id=%s", responseItem.MjId))
|
||||
logger.LogWarn(ctx, common.LogText("Midjourney task response ignored: unknown mj_id=%s", responseItem.MjId))
|
||||
continue
|
||||
}
|
||||
|
||||
useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime
|
||||
// 如果时间超过一小时,且进度不是100%,则认为任务失败
|
||||
if useTime > 3600000 && task.Progress != "100%" {
|
||||
responseItem.FailReason = "上游任务超时(超过1小时)"
|
||||
responseItem.FailReason = "Upstream task timed out (over 1 hour)"
|
||||
responseItem.Status = "FAILURE"
|
||||
}
|
||||
if !checkMjTaskNeedUpdate(task, responseItem) {
|
||||
@@ -192,7 +192,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
|
||||
videoUrlsStr, err := common.Marshal(responseItem.VideoUrls)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
|
||||
logger.LogError(ctx, common.LogText("failed to serialize VideoUrls: %v", err))
|
||||
task.VideoUrls = "[]" // 失败时设置为空数组
|
||||
} else {
|
||||
task.VideoUrls = string(videoUrlsStr)
|
||||
@@ -203,7 +203,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
|
||||
shouldReturnQuota := false
|
||||
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
|
||||
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
|
||||
logger.LogInfo(ctx, common.LogText("%s failed: %s", task.MjId, task.FailReason))
|
||||
task.Progress = "100%"
|
||||
if task.Quota != 0 {
|
||||
shouldReturnQuota = true
|
||||
@@ -211,9 +211,9 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
|
||||
}
|
||||
won, err := task.UpdateWithStatus(preStatus)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
|
||||
logger.LogError(ctx, common.LogText("UpdateMidjourneyTask task error: %s", err.Error()))
|
||||
} else if won && shouldReturnQuota {
|
||||
service.RefundMidjourneyQuota(ctx, task, "构图失败")
|
||||
service.RefundMidjourneyQuota(ctx, task, "Composition failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-21
@@ -25,10 +25,7 @@ import (
|
||||
func TestStatus(c *gin.Context) {
|
||||
err := model.PingDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"message": "数据库连接失败",
|
||||
})
|
||||
writeAccountStatusError(c, http.StatusServiceUnavailable, "Database connection failed")
|
||||
return
|
||||
}
|
||||
// 获取HTTP统计信息
|
||||
@@ -223,15 +220,21 @@ func SendEmailVerification(c *gin.Context) {
|
||||
}
|
||||
|
||||
if model.IsEmailAlreadyTaken(email) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
|
||||
common.ApiErrorT(c, "Email address is already in use")
|
||||
return
|
||||
}
|
||||
code := common.GenerateVerificationCode(6)
|
||||
common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
|
||||
subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
|
||||
content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+
|
||||
"<p>您的验证码为: <strong>%s</strong></p>"+
|
||||
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
|
||||
// The address may not belong to an account yet, so the mail follows the
|
||||
// request language.
|
||||
systemName := map[string]any{"SystemName": common.SystemName}
|
||||
subject := i18n.T(c, i18n.MsgEmailVerificationSubject, systemName)
|
||||
content := fmt.Sprintf("<p>%s</p>"+
|
||||
"<p>%s</p>"+
|
||||
"<p>%s</p>",
|
||||
i18n.T(c, i18n.MsgEmailVerificationGreeting, systemName),
|
||||
i18n.T(c, i18n.MsgEmailVerificationCode, map[string]any{"Code": code}),
|
||||
i18n.T(c, i18n.MsgEmailVerificationValidity, map[string]any{"Minutes": common.VerificationValidMinutes}))
|
||||
err = common.SendEmail(subject, email, content)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -247,24 +250,34 @@ func SendEmailVerification(c *gin.Context) {
|
||||
func SendPasswordResetEmail(c *gin.Context) {
|
||||
email := model.NormalizeEmail(c.Query("email"))
|
||||
if err := common.Validate.Var(email, "required,email"); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if _, err := model.GetUniqueUserByEmail(email); err == nil {
|
||||
if user, err := model.GetUniqueUserByEmail(email); err == nil {
|
||||
code := common.GenerateVerificationCode(0)
|
||||
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
|
||||
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
|
||||
subject := fmt.Sprintf("%s密码重置", common.SystemName)
|
||||
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
|
||||
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
|
||||
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
|
||||
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
|
||||
lang := user.GetSetting().Language
|
||||
if lang == "" {
|
||||
lang = i18n.StatedLang(c)
|
||||
}
|
||||
systemName := map[string]any{"SystemName": common.SystemName}
|
||||
linkParams := map[string]any{"Link": link}
|
||||
subject := i18n.Translate(lang, i18n.MsgEmailPasswordResetSubject, systemName)
|
||||
content := fmt.Sprintf("<p>%s</p>"+
|
||||
"<p>%s</p>"+
|
||||
"<p>%s </p>"+
|
||||
"<p>%s</p>",
|
||||
i18n.Translate(lang, i18n.MsgEmailPasswordResetGreeting, systemName),
|
||||
i18n.Translate(lang, i18n.MsgEmailPasswordResetLink, linkParams),
|
||||
i18n.Translate(lang, i18n.MsgEmailPasswordResetLinkFallback, linkParams),
|
||||
i18n.Translate(lang, i18n.MsgEmailPasswordResetValidity, map[string]any{"Minutes": common.VerificationValidMinutes}))
|
||||
err := common.SendEmail(subject, email, content)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("failed to send password reset email to %s: %s", email, err.Error()))
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, model.ErrEmailNotFound) {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("skip password reset email for %s: %s", email, err.Error()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("skip password reset email for %s: %s", email, err.Error()))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@@ -286,18 +299,18 @@ func ResetPassword(c *gin.Context) {
|
||||
}
|
||||
req.Email = model.NormalizeEmail(req.Email)
|
||||
if req.Email == "" || req.Token == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPasswordResetLinkInvalid)
|
||||
common.ApiErrorT(c, "Password reset link is invalid or has expired")
|
||||
return
|
||||
}
|
||||
password := common.GenerateVerificationCode(12)
|
||||
err = model.ResetUserPasswordByEmail(req.Email, password)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrEmailNotFound) || errors.Is(err, model.ErrEmailAmbiguous) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPasswordResetLinkInvalid)
|
||||
common.ApiErrorT(c, "Password reset link is invalid or has expired")
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ func channelOwnerName(channelType int) string {
|
||||
func getPreferredModelOwners(modelNames []string, groups []string) map[string]string {
|
||||
channelTypes, err := model.GetPreferredModelOwnerChannelTypes(modelNames, groups)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("GetPreferredModelOwnerChannelTypes error: %v", err))
|
||||
common.SysLog(common.LogText("GetPreferredModelOwnerChannelTypes error: %v", err))
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ func CreateModelMeta(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if m.ModelName == "" {
|
||||
common.ApiErrorMsg(c, "模型名称不能为空")
|
||||
common.ApiErrorT(c, "Model name cannot be empty")
|
||||
return
|
||||
}
|
||||
if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil {
|
||||
@@ -123,7 +123,7 @@ func CreateModelMeta(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "模型名称已存在")
|
||||
common.ApiErrorT(c, "Model name already exists")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func UpdateModelMeta(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if m.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少模型 ID")
|
||||
common.ApiErrorT(c, "Missing model ID")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func UpdateModelMeta(c *gin.Context) {
|
||||
}
|
||||
} else {
|
||||
if strings.TrimSpace(m.ModelName) == "" {
|
||||
common.ApiErrorMsg(c, "模型名称不能为空")
|
||||
common.ApiErrorT(c, "Model name cannot be empty")
|
||||
return
|
||||
}
|
||||
if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil {
|
||||
@@ -174,7 +174,7 @@ func UpdateModelMeta(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "模型名称已存在")
|
||||
common.ApiErrorT(c, "Model name already exists")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+24
-39
@@ -3,7 +3,6 @@ package controller
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
@@ -38,16 +36,16 @@ type oauthFlowPayload struct {
|
||||
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
|
||||
}
|
||||
|
||||
// providerParams returns map with Provider key for i18n templates
|
||||
// providerParams returns the params of messages that name the OAuth provider.
|
||||
func providerParams(name string) map[string]any {
|
||||
return map[string]any{"Provider": name}
|
||||
return map[string]any{"provider": name}
|
||||
}
|
||||
|
||||
// GenerateOAuthCode generates a state code for OAuth CSRF protection
|
||||
func GenerateOAuthCode(c *gin.Context) {
|
||||
var request oauthStateRequest
|
||||
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
request.Provider = strings.TrimSpace(request.Provider)
|
||||
@@ -58,7 +56,7 @@ func GenerateOAuthCode(c *gin.Context) {
|
||||
len(request.Aff) > 32 ||
|
||||
(request.Intent != model.AuthFlowIntentLogin && request.Aff != "") ||
|
||||
(request.Intent != model.AuthFlowIntentVerify && (request.Scope != "" || len(request.Context) != 0)) {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
userID := 0
|
||||
@@ -76,7 +74,7 @@ func GenerateOAuthCode(c *gin.Context) {
|
||||
if request.Intent == model.AuthFlowIntentBind || request.Intent == model.AuthFlowIntentVerify {
|
||||
identity, ok := middleware.GetSessionAuthIdentity(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"})
|
||||
writeAccountStatusError(c, http.StatusUnauthorized, "Sign in to bind an account")
|
||||
return
|
||||
}
|
||||
userID = identity.UserID
|
||||
@@ -148,10 +146,7 @@ func HandleOAuth(c *gin.Context) {
|
||||
providerName := c.Param("provider")
|
||||
provider := oauth.GetProvider(providerName)
|
||||
if provider == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": i18n.T(c, i18n.MsgOAuthUnknownProvider),
|
||||
})
|
||||
writeAccountStatusError(c, http.StatusBadRequest, "Unknown OAuth provider")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,10 +157,7 @@ func HandleOAuth(c *gin.Context) {
|
||||
Provider: providerName,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
|
||||
})
|
||||
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -184,10 +176,7 @@ func HandleOAuth(c *gin.Context) {
|
||||
if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify {
|
||||
identity, ok := middleware.GetSessionAuthIdentity(c)
|
||||
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
|
||||
})
|
||||
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
|
||||
return
|
||||
}
|
||||
consumeMatch.UserId = identity.UserID
|
||||
@@ -209,7 +198,7 @@ func HandleOAuth(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
} else if pendingFlow.Intent != model.AuthFlowIntentLogin {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -238,7 +227,7 @@ func HandleOAuth(c *gin.Context) {
|
||||
c.Set(oauth.TelegramOAuthFlowContextKey, telegramPayload.Telegram)
|
||||
}
|
||||
if !provider.IsEnabled() {
|
||||
common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName()))
|
||||
common.ApiErrorT(c, "{{provider}} login and registration has not been enabled by administrator", providerParams(provider.GetName()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -246,7 +235,7 @@ func HandleOAuth(c *gin.Context) {
|
||||
errorCode := c.Query("error")
|
||||
if errorCode != "" {
|
||||
if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
|
||||
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
|
||||
return
|
||||
}
|
||||
errorDescription := c.Query("error_description")
|
||||
@@ -287,7 +276,7 @@ func HandleOAuth(c *gin.Context) {
|
||||
}
|
||||
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
|
||||
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -325,18 +314,18 @@ func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.
|
||||
user, migration, err := findOrCreateOAuthUser(c, provider, oauthUser, token, payload.AffiliateCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrEmailAlreadyTaken) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
|
||||
common.ApiErrorT(c, "Email address is already in use")
|
||||
return
|
||||
}
|
||||
switch err.(type) {
|
||||
case *OAuthUserDeletedError:
|
||||
common.ApiErrorI18n(c, i18n.MsgOAuthUserDeleted)
|
||||
common.ApiErrorT(c, "User has been deleted")
|
||||
case *OAuthRegistrationDisabledError:
|
||||
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
|
||||
common.ApiErrorT(c, "New user registration has been disabled by administrator")
|
||||
case *OAuthEmailAlreadyTakenError:
|
||||
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
|
||||
common.ApiErrorT(c, "Email address is already in use")
|
||||
case *OAuthLegacyBindingNotConfirmedError:
|
||||
common.ApiErrorI18n(c, i18n.MsgOAuthNotAutoLinked, providerParams(provider.GetName()))
|
||||
common.ApiErrorT(c, "This {{provider}} account cannot be linked to an existing account automatically, please sign in or register another way and then link {{provider}} in account settings", providerParams(provider.GetName()))
|
||||
default:
|
||||
writeSecurityOperationError(c, err)
|
||||
}
|
||||
@@ -345,7 +334,7 @@ func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.
|
||||
|
||||
// 8. Check user status
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
common.ApiErrorI18n(c, i18n.MsgOAuthUserBanned)
|
||||
common.ApiErrorT(c, "User has been banned")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -377,7 +366,7 @@ func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provide
|
||||
return false, false
|
||||
}
|
||||
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
|
||||
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
|
||||
common.ApiErrorT(c, "This {{provider}} account has already been bound", providerParams(provider.GetName()))
|
||||
return false, false
|
||||
}
|
||||
_, err = model.ConsumeAuthFlowWithAction(state, match, func(tx *gorm.DB, _ *model.AuthFlow) error {
|
||||
@@ -399,7 +388,7 @@ func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provide
|
||||
return true, true
|
||||
}
|
||||
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
|
||||
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind", "notification_warning": notificationFailed})
|
||||
common.ApiSuccessT(c, "Binding successful", gin.H{"action": "bind", "notification_warning": notificationFailed})
|
||||
return true, notificationFailed
|
||||
}
|
||||
|
||||
@@ -454,7 +443,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
||||
if emailProvider, ok := provider.(oauth.VerifiedEmailProvider); ok && user.Email != "" {
|
||||
emails, err := emailProvider.GetVerifiedEmails(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("[OAuth] Failed to load verified emails for user %d: %s", user.Id, err.Error()))
|
||||
common.SysError(common.LogText("[OAuth] Failed to load verified emails for user %d: %s", user.Id, err.Error()))
|
||||
reason = "verified_emails_unavailable"
|
||||
}
|
||||
accountEmail := model.NormalizeEmail(user.Email)
|
||||
@@ -625,19 +614,15 @@ func (e *OAuthLegacyBindingNotConfirmedError) Error() string {
|
||||
return "legacy binding was not confirmed"
|
||||
}
|
||||
|
||||
// handleOAuthError handles OAuth errors and returns translated message
|
||||
// handleOAuthError handles OAuth errors and returns a web console message
|
||||
func handleOAuthError(c *gin.Context, err error) {
|
||||
switch e := err.(type) {
|
||||
case *oauth.OAuthError:
|
||||
if e.Params != nil {
|
||||
common.ApiErrorI18n(c, e.MsgKey, e.Params)
|
||||
} else {
|
||||
common.ApiErrorI18n(c, e.MsgKey)
|
||||
}
|
||||
common.ApiError(c, e.Message)
|
||||
case *oauth.AccessDeniedError:
|
||||
common.ApiErrorMsg(c, e.Message)
|
||||
case *oauth.TrustLevelError:
|
||||
common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow)
|
||||
common.ApiErrorT(c, "Linux DO trust level does not meet the minimum required by administrator")
|
||||
default:
|
||||
writeSecurityOperationError(c, err)
|
||||
}
|
||||
|
||||
+34
-108
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
@@ -145,7 +144,7 @@ func UpdatePasskeyDomains(c *gin.Context) {
|
||||
RemovalConfirmation string `json:"removal_confirmation"`
|
||||
}
|
||||
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.RPID == nil || request.LegacyRPIDs == nil || request.Origins == nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
change, err := model.UpdatePasskeyDomainOptions(map[string]string{
|
||||
@@ -167,9 +166,9 @@ func UpdatePasskeyDomains(c *gin.Context) {
|
||||
func writePasskeyDomainSettingsError(c *gin.Context, err error) {
|
||||
var removal *model.PasskeyDomainRemovalError
|
||||
if errors.As(err, &removal) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"success": false, "code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED",
|
||||
"message": i18n.T(c, i18n.MsgPasskeyRPIDRemovalConfirmation), "data": removal.Change,
|
||||
common.ApiErrorStatus(c, http.StatusConflict, common.NewMessage("Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."), gin.H{
|
||||
"code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED",
|
||||
"data": removal.Change,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -184,10 +183,7 @@ func UpdateOption(c *gin.Context) {
|
||||
var option OptionUpdateRequest
|
||||
err := common.DecodeJson(c.Request.Body, &option)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
switch option.Value.(type) {
|
||||
@@ -203,77 +199,55 @@ func UpdateOption(c *gin.Context) {
|
||||
switch option.Key {
|
||||
case "QuotaForInviter", "QuotaForInvitee":
|
||||
if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
common.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
|
||||
return
|
||||
}
|
||||
default:
|
||||
if isPaymentComplianceOptionKey(option.Key) {
|
||||
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
|
||||
common.ApiErrorT(c, "Compliance confirmation fields cannot be changed through the general settings API")
|
||||
return
|
||||
}
|
||||
}
|
||||
if option.Key == "TaskPublicAddress" && option.Value.(string) != "" {
|
||||
if err := service.ValidateTaskArtifactBaseURL(option.Value.(string)); err != nil {
|
||||
common.ApiErrorMsg(c, err.Error())
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch option.Key {
|
||||
case "GitHubOAuthEnabled":
|
||||
if option.Value == "true" && common.GitHubClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!",
|
||||
})
|
||||
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "GitHub OAuth"})
|
||||
return
|
||||
}
|
||||
case "discord.enabled":
|
||||
if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用 Discord OAuth,请先填入 Discord Client Id 以及 Discord Client Secret!",
|
||||
})
|
||||
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "Discord OAuth"})
|
||||
return
|
||||
}
|
||||
case "oidc.enabled":
|
||||
if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用 OIDC 登录,请先填入 OIDC Client Id 以及 OIDC Client Secret!",
|
||||
})
|
||||
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "OIDC"})
|
||||
return
|
||||
}
|
||||
case "LinuxDOOAuthEnabled":
|
||||
if option.Value == "true" && common.LinuxDOClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用 LinuxDO OAuth,请先填入 LinuxDO Client Id 以及 LinuxDO Client Secret!",
|
||||
})
|
||||
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "LinuxDO OAuth"})
|
||||
return
|
||||
}
|
||||
case "EmailDomainRestrictionEnabled":
|
||||
if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!",
|
||||
})
|
||||
common.ApiErrorT(c, "Cannot enable the email domain restriction. Enter the allowed email domains first.")
|
||||
return
|
||||
}
|
||||
case "WeChatAuthEnabled":
|
||||
if option.Value == "true" && common.WeChatServerAddress == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用微信登录,请先填入微信登录相关配置信息!",
|
||||
})
|
||||
common.ApiErrorT(c, "Cannot enable WeChat login. Enter the WeChat login settings first.")
|
||||
return
|
||||
}
|
||||
case "TurnstileCheckEnabled":
|
||||
if option.Value == "true" && common.TurnstileSiteKey == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
|
||||
})
|
||||
|
||||
common.ApiErrorT(c, "Cannot enable Turnstile verification. Enter the Turnstile settings first.")
|
||||
return
|
||||
}
|
||||
case "TelegramOAuthEnabled":
|
||||
@@ -287,115 +261,79 @@ func UpdateOption(c *gin.Context) {
|
||||
}
|
||||
case "theme.frontend":
|
||||
if option.Value != "default" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Classic 前端已移除,主题只能设置为 default",
|
||||
})
|
||||
common.ApiErrorT(c, "The Classic frontend has been removed. The theme can only be set to default")
|
||||
return
|
||||
}
|
||||
case "GroupRatio":
|
||||
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "gemini.safety_settings":
|
||||
err = model_setting.ValidateGeminiSafetySettings(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "claude.default_max_tokens":
|
||||
err = model_setting.ValidateClaudeDefaultMaxTokens(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case operation_setting.ToolPriceOptionKey:
|
||||
err = operation_setting.ValidateToolPricesJSON(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "ImageRatio":
|
||||
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "图片倍率设置失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to set the image ratio: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "AudioRatio":
|
||||
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "音频倍率设置失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to set the audio ratio: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "AudioCompletionRatio":
|
||||
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "音频补全倍率设置失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to set the audio completion ratio: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "CreateCacheRatio":
|
||||
err = ratio_setting.UpdateCreateCacheRatioByJSONString(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "缓存创建倍率设置失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to set the cache creation ratio: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "ModelRequestRateLimitGroup":
|
||||
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "AutomaticDisableStatusCodes":
|
||||
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "AutomaticRetryStatusCodes":
|
||||
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "billing_setting.billing_expr":
|
||||
expressions := make(map[string]string)
|
||||
if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil {
|
||||
common.ApiErrorMsg(c, "计费表达式配置必须是模型到表达式的 JSON 对象: "+err.Error())
|
||||
common.ApiErrorT(c, "Billing expressions must be a JSON object mapping models to expressions: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
models := make([]string, 0, len(expressions))
|
||||
@@ -416,7 +354,7 @@ func UpdateOption(c *gin.Context) {
|
||||
billing_setting.PluginBillingExprOption: variants,
|
||||
})
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, fmt.Sprintf("模型 %s 的计费表达式无效: %v", modelName, err))
|
||||
common.ApiErrorT(c, "Invalid billing expression for model {{model}}: {{error}}", map[string]any{"model": modelName, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -435,44 +373,32 @@ func UpdateOption(c *gin.Context) {
|
||||
if err = model.ValidateModelPricing(name, model.PricingValues{
|
||||
billing_setting.PluginBillingExprOption: map[string]any{plugin: expression},
|
||||
}); err != nil {
|
||||
common.ApiErrorMsg(c, err.Error())
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
case "console_setting.api_info":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "console_setting.announcements":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "console_setting.faq":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
case "console_setting.uptime_kuma_groups":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
+31
-69
@@ -37,17 +37,14 @@ func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) {
|
||||
return nil, err
|
||||
}
|
||||
if request.FlowToken == "" || len(request.Credential) == 0 {
|
||||
return nil, errors.New("Passkey 流程参数不完整")
|
||||
return nil, errors.New("incomplete Passkey flow parameters")
|
||||
}
|
||||
return &request, nil
|
||||
}
|
||||
|
||||
func PasskeyRegisterBegin(c *gin.Context) {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
})
|
||||
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,7 +91,7 @@ func PasskeyRegisterBegin(c *gin.Context) {
|
||||
|
||||
identity, ok := middleware.GetStepUpIdentity(c)
|
||||
if !ok {
|
||||
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
|
||||
common.ApiErrorT(c, "The current sign-in method does not support security verification")
|
||||
return
|
||||
}
|
||||
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
|
||||
@@ -120,10 +117,7 @@ func PasskeyRegisterBegin(c *gin.Context) {
|
||||
|
||||
func PasskeyRegisterFinish(c *gin.Context) {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
})
|
||||
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,7 +128,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
}
|
||||
request, err := parsePasskeyFinishRequest(c)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
|
||||
common.ApiErrorT(c, "Invalid Passkey verification request")
|
||||
return
|
||||
}
|
||||
parsedCredential, err := protocol.ParseCredentialCreationResponseBytes(request.Credential)
|
||||
@@ -154,7 +148,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
|
||||
identity, ok := middleware.GetStepUpIdentity(c)
|
||||
if !ok {
|
||||
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
|
||||
common.ApiErrorT(c, "The current sign-in method does not support security verification")
|
||||
return
|
||||
}
|
||||
sessionData, security, err := passkeysvc.PopSessionDataFlow(
|
||||
@@ -190,7 +184,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
|
||||
passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
|
||||
if passkeyCredential == nil {
|
||||
common.ApiErrorMsg(c, "无法创建 Passkey 凭证")
|
||||
common.ApiErrorT(c, "Unable to create the Passkey credential")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,11 +200,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
}
|
||||
|
||||
recordUserSecurityAudit(c, user.Id, "user.passkey_register", nil)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Passkey 注册成功",
|
||||
"data": authRotationData(bundle),
|
||||
})
|
||||
common.ApiSuccessT(c, "Passkey registered successfully", authRotationData(bundle))
|
||||
}
|
||||
|
||||
func PasskeyDelete(c *gin.Context) {
|
||||
@@ -226,7 +216,7 @@ func PasskeyDelete(c *gin.Context) {
|
||||
|
||||
identity, ok := middleware.GetStepUpIdentity(c)
|
||||
if !ok {
|
||||
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
|
||||
common.ApiErrorT(c, "The current sign-in method does not support security verification")
|
||||
return
|
||||
}
|
||||
if err := model.DeletePasskeyForSession(identity); err != nil {
|
||||
@@ -240,11 +230,7 @@ func PasskeyDelete(c *gin.Context) {
|
||||
}
|
||||
|
||||
recordUserSecurityAudit(c, user.Id, "user.passkey_delete", nil)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Passkey 已解绑",
|
||||
"data": authRotationData(bundle),
|
||||
})
|
||||
common.ApiSuccessT(c, "Passkey removed successfully", authRotationData(bundle))
|
||||
}
|
||||
|
||||
func PasskeyStatus(c *gin.Context) {
|
||||
@@ -284,10 +270,7 @@ func PasskeyStatus(c *gin.Context) {
|
||||
|
||||
func PasskeyLoginBegin(c *gin.Context) {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
})
|
||||
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -296,7 +279,7 @@ func PasskeyLoginBegin(c *gin.Context) {
|
||||
}
|
||||
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
if common.DecodeJson(c.Request.Body, &request) != nil {
|
||||
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
|
||||
common.ApiErrorT(c, "Invalid Passkey verification request")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -336,16 +319,13 @@ func PasskeyLoginBegin(c *gin.Context) {
|
||||
|
||||
func PasskeyLoginFinish(c *gin.Context) {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
})
|
||||
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
request, err := parsePasskeyFinishRequest(c)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
|
||||
common.ApiErrorT(c, "Invalid Passkey verification request")
|
||||
return
|
||||
}
|
||||
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
|
||||
@@ -378,7 +358,7 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
// 首先通过凭证ID查找用户
|
||||
credential, err := model.GetPasskeyByCredentialID(rawID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
|
||||
return nil, fmt.Errorf("passkey credential not found: %w", err)
|
||||
}
|
||||
|
||||
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
|
||||
@@ -388,7 +368,7 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
// 通过凭证获取用户
|
||||
user := &model.User{Id: credential.UserID}
|
||||
if err := user.FillUserById(); err != nil {
|
||||
return nil, fmt.Errorf("用户信息获取失败: %w", err)
|
||||
return nil, fmt.Errorf("failed to load the passkey user: %w", err)
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
@@ -399,9 +379,9 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
userID, parseErr := strconv.Atoi(string(userHandle))
|
||||
if parseErr != nil {
|
||||
// 记录异常但继续验证,因为某些客户端可能使用非数字格式
|
||||
common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
|
||||
common.SysLog(common.LogText("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
|
||||
} else if userID != user.Id {
|
||||
return nil, errors.New("用户句柄与凭证不匹配")
|
||||
return nil, errors.New("user handle does not match the credential")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,18 +396,18 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
|
||||
userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
|
||||
if !ok {
|
||||
common.ApiErrorMsg(c, "Passkey 登录状态异常")
|
||||
common.ApiErrorT(c, "Invalid Passkey login state")
|
||||
return
|
||||
}
|
||||
|
||||
modelUser := userWrapper.ModelUser()
|
||||
if modelUser == nil {
|
||||
common.ApiErrorMsg(c, "Passkey 登录状态异常")
|
||||
common.ApiErrorT(c, "Invalid Passkey login state")
|
||||
return
|
||||
}
|
||||
|
||||
if modelUser.Status != common.UserStatusEnabled {
|
||||
common.ApiErrorMsg(c, "该用户已被禁用")
|
||||
common.ApiErrorT(c, "This user account is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -443,7 +423,7 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
func AdminResetPasskey(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的用户 ID")
|
||||
common.ApiErrorT(c, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -464,10 +444,7 @@ func AdminResetPasskey(c *gin.Context) {
|
||||
|
||||
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
|
||||
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户尚未绑定 Passkey",
|
||||
})
|
||||
common.ApiErrorT(c, "No Passkey is registered.")
|
||||
return
|
||||
}
|
||||
writeSecurityOperationError(c, err)
|
||||
@@ -488,18 +465,12 @@ func AdminResetPasskey(c *gin.Context) {
|
||||
"id": user.Id,
|
||||
"verification_method": authorization.Method,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Passkey 已重置",
|
||||
})
|
||||
common.ApiSuccessT(c, "Passkey reset successfully", nil)
|
||||
}
|
||||
|
||||
func PasskeyVerifyBegin(c *gin.Context) {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
})
|
||||
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -510,7 +481,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
|
||||
}
|
||||
var request passkeyVerifyBeginRequest
|
||||
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
|
||||
common.ApiErrorT(c, "Invalid Passkey verification request")
|
||||
return
|
||||
}
|
||||
binding, err := service.BindVerificationOperation(service.VerificationOperation{Scope: request.Scope, Context: request.Context})
|
||||
@@ -530,10 +501,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
|
||||
|
||||
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户尚未绑定 Passkey",
|
||||
})
|
||||
common.ApiErrorT(c, "No Passkey is registered.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -578,10 +546,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
|
||||
|
||||
func PasskeyVerifyFinish(c *gin.Context) {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
})
|
||||
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -593,7 +558,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
|
||||
|
||||
request, err := parsePasskeyFinishRequest(c)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
|
||||
common.ApiErrorT(c, "Invalid Passkey verification request")
|
||||
return
|
||||
}
|
||||
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
|
||||
@@ -604,16 +569,13 @@ func PasskeyVerifyFinish(c *gin.Context) {
|
||||
|
||||
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户尚未绑定 Passkey",
|
||||
})
|
||||
common.ApiErrorT(c, "No Passkey is registered.")
|
||||
return
|
||||
}
|
||||
|
||||
identity, ok := middleware.GetStepUpIdentity(c)
|
||||
if !ok {
|
||||
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
|
||||
common.ApiErrorT(c, "The current sign-in method does not support security verification")
|
||||
return
|
||||
}
|
||||
sessionData, security, err := passkeysvc.PopSessionDataFlow(
|
||||
|
||||
+16
-14
@@ -665,27 +665,28 @@ func TestPasskeyDomainFailuresDoNotLogCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyDomainErrorsRespectRequestLanguage(t *testing.T) {
|
||||
// The web console translates message_key, so every request language gets the
|
||||
// same English source text.
|
||||
func TestPasskeyDomainErrorsReturnMessageKeys(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com"
|
||||
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "unknown-domain", PublicKey: "key"}).Error)
|
||||
for _, locale := range []struct {
|
||||
language, invalid, unavailable, removal string
|
||||
}{
|
||||
{"zh-CN", "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。", "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。", "请核对受影响的通行密钥并确认删除域名。配置或影响范围发生变化后,需要重新确认。"},
|
||||
{"zh-TW", "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。", "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。", "請核對受影響的通行金鑰並確認刪除網域。設定或影響範圍變更後,需要重新確認。"},
|
||||
{"en", "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.", "This Passkey domain is not available on this website. Use its original website or another verification method.", "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."},
|
||||
} {
|
||||
t.Run(locale.language, func(t *testing.T) {
|
||||
const (
|
||||
invalid = "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard."
|
||||
unavailable = "This Passkey domain is not available on this website. Use its original website or another verification method."
|
||||
removal = "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."
|
||||
)
|
||||
for _, language := range []string{"zh-CN", "zh-TW", "en"} {
|
||||
t.Run(language, func(t *testing.T) {
|
||||
for _, request := range []struct {
|
||||
path, body, code, message string
|
||||
handler gin.HandlerFunc
|
||||
}{
|
||||
{"/api/option/", `{"key":"passkey.rp_id","value":"localhost:3000"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption},
|
||||
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption},
|
||||
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", locale.removal, UpdateOption},
|
||||
{"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", locale.unavailable, PasskeyLoginBegin},
|
||||
{"/api/option/", `{"key":"passkey.rp_id","value":"localhost:3000"}`, "PASSKEY_RP_ID_INVALID", invalid, UpdateOption},
|
||||
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", invalid, UpdateOption},
|
||||
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", removal, UpdateOption},
|
||||
{"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", unavailable, PasskeyLoginBegin},
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(response)
|
||||
@@ -694,7 +695,7 @@ func TestPasskeyDomainErrorsRespectRequestLanguage(t *testing.T) {
|
||||
method = http.MethodPut
|
||||
}
|
||||
c.Request = httptest.NewRequest(method, request.path, strings.NewReader(request.body))
|
||||
c.Request.Header.Set("Accept-Language", locale.language)
|
||||
c.Request.Header.Set("Accept-Language", language)
|
||||
c.Request.Header.Set("Origin", "https://example.com")
|
||||
c.Set("id", user.Id)
|
||||
c.Set("role", common.RoleRootUser)
|
||||
@@ -709,6 +710,7 @@ func TestPasskeyDomainErrorsRespectRequestLanguage(t *testing.T) {
|
||||
assert.False(t, result.Success)
|
||||
assert.Equal(t, request.code, result.Code)
|
||||
assert.Equal(t, request.message, result.Message)
|
||||
assert.Equal(t, request.message, result.MessageKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
@@ -21,7 +19,7 @@ type PaymentComplianceRequest struct {
|
||||
|
||||
func requirePaymentCompliance(c *gin.Context) bool {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
common.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -38,11 +36,11 @@ func ConfirmPaymentCompliance(c *gin.Context) {
|
||||
|
||||
var req PaymentComplianceRequest
|
||||
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if !req.Confirmed {
|
||||
common.ApiErrorMsg(c, "请确认合规声明")
|
||||
common.ApiErrorT(c, "Please confirm the compliance statement")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,7 +63,7 @@ func ConfirmPaymentCompliance(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf(
|
||||
logger.LogInfo(c.Request.Context(), common.LogText(
|
||||
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d",
|
||||
userId,
|
||||
clientIP,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -149,30 +148,21 @@ func ClearDiskCache(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "不活跃的磁盘缓存已清理",
|
||||
})
|
||||
common.ApiSuccessT(c, "Inactive disk cache cleared", nil)
|
||||
}
|
||||
|
||||
// ResetPerformanceStats 重置性能统计
|
||||
func ResetPerformanceStats(c *gin.Context) {
|
||||
common.ResetDiskCacheStats()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "统计信息已重置",
|
||||
})
|
||||
common.ApiSuccessT(c, "Statistics reset", nil)
|
||||
}
|
||||
|
||||
// ForceGC 强制执行 GC
|
||||
func ForceGC(c *gin.Context) {
|
||||
runtime.GC()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "GC 已执行",
|
||||
})
|
||||
common.ApiSuccessT(c, "GC executed", nil)
|
||||
}
|
||||
|
||||
// LogFileInfo 日志文件信息
|
||||
@@ -337,11 +327,11 @@ func CleanupLogFiles(c *gin.Context) {
|
||||
}
|
||||
|
||||
if len(failedFiles) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("部分文件删除失败(%d/%d)", len(failedFiles), len(toDelete)),
|
||||
"data": result,
|
||||
msg := common.NewMessage("Some files could not be deleted ({{failed}}/{{total}})", map[string]any{
|
||||
"failed": len(failedFiles),
|
||||
"total": len(toDelete),
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusOK, msg, gin.H{"data": result})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
@@ -25,7 +26,7 @@ func Playground(c *gin.Context) {
|
||||
|
||||
useAccessToken := c.GetBool("use_access_token")
|
||||
if useAccessToken {
|
||||
newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
|
||||
newAPIError = types.NewError(errors.New(i18n.T(c, i18n.MsgRelayAccessTokenUnsupported)), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ func serveTaskPluginProtocol(
|
||||
missing = true
|
||||
}
|
||||
if missing {
|
||||
logger.LogError(c, "pinned task plugin does not support the requested protocol form")
|
||||
logger.LogError(c, common.LogText("pinned task plugin does not support the requested protocol form"))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
@@ -293,7 +293,7 @@ func serveTaskPluginProtocol(
|
||||
}
|
||||
if relayInfoErr != nil {
|
||||
err := relayInfoErr
|
||||
logger.LogError(c, "build task protocol relay info failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("build task protocol relay info failed: %s", err.Error()))
|
||||
logger.LogDebug(c, "task_plugin subsystem=protocol event=bridge_failed generation=%d plugin=%q stage=relay_info reason=invalid_context", generation, pluginKey)
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
@@ -327,7 +327,7 @@ func serveTaskPluginProtocol(
|
||||
if outcome == nil || outcome.Task == nil || outcome.RelayInfo == nil ||
|
||||
outcome.Task.UserId != relayInfo.UserId ||
|
||||
outcome.Task.Platform != constant.TaskPlatform(pinned.Plugin.Meta.Key) {
|
||||
logger.LogError(c, "task protocol submission returned an invalid durable outcome")
|
||||
logger.LogError(c, common.LogText("task protocol submission returned an invalid durable outcome"))
|
||||
logger.LogDebug(c, "task_plugin subsystem=protocol event=submission_failed generation=%d plugin=%q reason=invalid_durable_outcome", generation, pluginKey)
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
@@ -365,7 +365,7 @@ func serveTaskPluginProtocol(
|
||||
outcome.Task.PrivateData.ResponsesBackground = true
|
||||
if outcome.Task.ID != 0 {
|
||||
if err := model.DB.Model(outcome.Task).Update("private_data", outcome.Task.PrivateData).Error; err != nil {
|
||||
logger.LogError(c, "persist task background flag failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("persist task background flag failed: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
machine.SetBackground(true)
|
||||
@@ -447,7 +447,7 @@ func streamTaskPluginProtocol(
|
||||
if errors.Is(loadContextErr, context.DeadlineExceeded) &&
|
||||
observationContext.Err() == nil &&
|
||||
c.Request.Context().Err() == nil {
|
||||
logger.LogWarn(c, fmt.Sprintf(
|
||||
logger.LogWarn(c, common.LogText(
|
||||
"task protocol database observation overloaded; plugin=%s task=%s",
|
||||
pinned.Plugin.Meta.Key,
|
||||
taskID,
|
||||
@@ -482,7 +482,7 @@ func streamTaskPluginProtocol(
|
||||
return
|
||||
}
|
||||
if loadErr != nil && !errors.Is(loadErr, context.Canceled) {
|
||||
logger.LogError(c, "task protocol database observation failed")
|
||||
logger.LogError(c, common.LogText("task protocol database observation failed"))
|
||||
}
|
||||
if c.Request.Context().Err() == nil {
|
||||
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=load reason=task_unavailable", generation, pluginKey)
|
||||
@@ -508,20 +508,20 @@ func streamTaskPluginProtocol(
|
||||
}
|
||||
view, viewErr := service.BuildTaskPluginView(task)
|
||||
if viewErr != nil {
|
||||
logger.LogError(c, "build task protocol view failed: "+viewErr.Error())
|
||||
logger.LogError(c, common.LogText("build task protocol view failed: %s", viewErr.Error()))
|
||||
writeTaskPluginProtocolFailure(c, machine, lastStatus)
|
||||
return
|
||||
}
|
||||
viewValue, viewErr := taskPluginProtocolJSONValue(view)
|
||||
if viewErr != nil {
|
||||
logger.LogError(c, "encode task protocol view failed: "+viewErr.Error())
|
||||
logger.LogError(c, common.LogText("encode task protocol view failed: %s", viewErr.Error()))
|
||||
writeTaskPluginProtocolFailure(c, machine, lastStatus)
|
||||
return
|
||||
}
|
||||
hookStarted := deps.now()
|
||||
rendererContext, contextErr := taskPluginProtocolRendererContext(protocolRequest, pinned, task, deps.artifactContentURL)
|
||||
if contextErr != nil {
|
||||
logger.LogError(c, "build task protocol renderer context failed")
|
||||
logger.LogError(c, common.LogText("build task protocol renderer context failed"))
|
||||
writeTaskPluginProtocolFailure(c, machine, lastStatus)
|
||||
return
|
||||
}
|
||||
@@ -544,13 +544,13 @@ func streamTaskPluginProtocol(
|
||||
}
|
||||
if errors.Is(callErr, pluginruntime.ErrCallAdmissionTimeout) {
|
||||
overloaded = true
|
||||
logger.LogWarn(c, fmt.Sprintf(
|
||||
logger.LogWarn(c, common.LogText(
|
||||
"task protocol render hook overloaded; plugin=%s task=%s",
|
||||
pinned.Plugin.Meta.Key,
|
||||
taskID,
|
||||
))
|
||||
} else {
|
||||
logger.LogError(c, "task protocol render hook failed")
|
||||
logger.LogError(c, common.LogText("task protocol render hook failed"))
|
||||
logger.LogDebug(
|
||||
c,
|
||||
"task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=render_events reason=hook_failed elapsed_ms=%d",
|
||||
@@ -565,13 +565,13 @@ func streamTaskPluginProtocol(
|
||||
if !overloaded {
|
||||
result, decodeErr := relay.DecodePluginProtocolEventResult(value, deps.protocolLimits)
|
||||
if decodeErr != nil {
|
||||
logger.LogError(c, "task protocol render result invalid: "+decodeErr.Error())
|
||||
logger.LogError(c, common.LogText("task protocol render result invalid: %s", decodeErr.Error()))
|
||||
writeTaskPluginProtocolFailure(c, machine, lastStatus)
|
||||
return
|
||||
}
|
||||
events, applyErr := machine.ApplyTick(result, lastStatus)
|
||||
if applyErr != nil {
|
||||
logger.LogError(c, "task protocol state transition failed: "+applyErr.Error())
|
||||
logger.LogError(c, common.LogText("task protocol state transition failed: %s", applyErr.Error()))
|
||||
writeTaskPluginProtocolFailure(c, machine, lastStatus)
|
||||
return
|
||||
}
|
||||
@@ -607,7 +607,7 @@ func streamTaskPluginProtocol(
|
||||
delay += deps.tickInterval
|
||||
} else if hookElapsed > deps.tickInterval {
|
||||
delay += deps.tickInterval
|
||||
logger.LogWarn(c, fmt.Sprintf(
|
||||
logger.LogWarn(c, common.LogText(
|
||||
"task protocol render hook slow; plugin=%s task=%s elapsed_ms=%d",
|
||||
pinned.Plugin.Meta.Key,
|
||||
taskID,
|
||||
@@ -692,7 +692,7 @@ func waitTaskPluginProtocol(
|
||||
observationContext.Err() == nil &&
|
||||
c.Request.Context().Err() == nil
|
||||
if loadOverloaded {
|
||||
logger.LogWarn(c, fmt.Sprintf(
|
||||
logger.LogWarn(c, common.LogText(
|
||||
"task protocol database observation overloaded; plugin=%s task=%s",
|
||||
pinned.Plugin.Meta.Key,
|
||||
taskID,
|
||||
@@ -712,7 +712,7 @@ func waitTaskPluginProtocol(
|
||||
return
|
||||
}
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.LogError(c, "task protocol database observation failed")
|
||||
logger.LogError(c, common.LogText("task protocol database observation failed"))
|
||||
}
|
||||
if c.Request.Context().Err() == nil {
|
||||
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=nonstream stage=load reason=task_unavailable", generation, pluginKey)
|
||||
@@ -773,13 +773,13 @@ func waitTaskPluginProtocol(
|
||||
}
|
||||
if errors.Is(callErr, pluginruntime.ErrCallAdmissionTimeout) {
|
||||
overloaded = true
|
||||
logger.LogWarn(c, fmt.Sprintf(
|
||||
logger.LogWarn(c, common.LogText(
|
||||
"task protocol final hook overloaded; plugin=%s task=%s",
|
||||
pinned.Plugin.Meta.Key,
|
||||
taskID,
|
||||
))
|
||||
} else {
|
||||
logger.LogError(c, "task protocol final hook failed")
|
||||
logger.LogError(c, common.LogText("task protocol final hook failed"))
|
||||
logger.LogDebug(
|
||||
c,
|
||||
"task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=nonstream stage=render_final reason=hook_failed elapsed_ms=%d",
|
||||
@@ -956,7 +956,7 @@ func retrieveTaskPluginResponse(c *gin.Context, deps pluginProtocolBridgeDeps) {
|
||||
|
||||
task, exists, err := deps.getByTaskId(userID, taskID)
|
||||
if err != nil {
|
||||
logger.LogError(c, "task protocol retrieve lookup failed")
|
||||
logger.LogError(c, common.LogText("task protocol retrieve lookup failed"))
|
||||
logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_failed reason=lookup_error public_task_id=%q", taskID)
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
@@ -1060,7 +1060,7 @@ func retrieveTaskPluginResponse(c *gin.Context, deps pluginProtocolBridgeDeps) {
|
||||
)
|
||||
}
|
||||
if renderErr != nil {
|
||||
logger.LogError(c, "task protocol retrieve render failed")
|
||||
logger.LogError(c, common.LogText("task protocol retrieve render failed"))
|
||||
logger.LogDebug(
|
||||
c,
|
||||
"task_plugin subsystem=protocol event=retrieve_failed generation=%d plugin=%q public_task_id=%q stage=render_final elapsed_ms=%d",
|
||||
@@ -1103,7 +1103,7 @@ func writeTaskPluginProtocolFailure(
|
||||
) {
|
||||
failed, err := machine.FailureEvent(taskStatus)
|
||||
if err != nil {
|
||||
logger.LogError(c, "task protocol failure event failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("task protocol failure event failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
_ = writeTaskPluginProtocolEvent(c, failed)
|
||||
@@ -1116,7 +1116,7 @@ func writeTaskPluginProtocolTimeout(
|
||||
) {
|
||||
incomplete, err := machine.TimeoutEvent(taskStatus)
|
||||
if err != nil {
|
||||
logger.LogError(c, "task protocol timeout event failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("task protocol timeout event failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
_ = writeTaskPluginProtocolEvent(c, incomplete)
|
||||
@@ -1130,7 +1130,7 @@ func writeTaskPluginProtocolFailureResponse(
|
||||
if taskStatus == string(model.TaskStatusFailure) {
|
||||
response, err := machine.FinalResponse(nil, taskStatus)
|
||||
if err != nil {
|
||||
logger.LogError(c, "task protocol terminal failure response failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("task protocol terminal failure response failed: %s", err.Error()))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
@@ -1139,7 +1139,7 @@ func writeTaskPluginProtocolFailureResponse(
|
||||
}
|
||||
response, err := machine.FailureResponse(taskStatus)
|
||||
if err != nil {
|
||||
logger.LogError(c, "task protocol failure response failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("task protocol failure response failed: %s", err.Error()))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
@@ -1153,7 +1153,7 @@ func writeTaskPluginProtocolTimeoutResponse(
|
||||
) {
|
||||
response, err := machine.TimeoutResponse(lastStatus)
|
||||
if err != nil {
|
||||
logger.LogError(c, "task protocol timeout response failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("task protocol timeout response failed: %s", err.Error()))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func serveTaskPluginImageProtocol(c *gin.Context, pinned pluginruntime.PinnedEnd
|
||||
return
|
||||
}
|
||||
if relayInfoErr != nil {
|
||||
logger.LogError(c, "build task protocol relay info failed: "+relayInfoErr.Error())
|
||||
logger.LogError(c, common.LogText("build task protocol relay info failed: %s", relayInfoErr.Error()))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func serveTaskPluginImageProtocol(c *gin.Context, pinned pluginruntime.PinnedEnd
|
||||
return
|
||||
}
|
||||
if outcome == nil || outcome.Task == nil || outcome.Task.Platform != constant.TaskPlatform(pinned.Plugin.Meta.Key) {
|
||||
logger.LogError(c, "task protocol submission returned an invalid durable outcome")
|
||||
logger.LogError(c, common.LogText("task protocol submission returned an invalid durable outcome"))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
@@ -112,26 +112,26 @@ func serveTaskPluginImageProtocol(c *gin.Context, pinned pluginruntime.PinnedEnd
|
||||
}
|
||||
view, err := service.BuildTaskPluginView(task)
|
||||
if err != nil {
|
||||
logger.LogError(c, "build task protocol view failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("build task protocol view failed: %s", err.Error()))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
viewValue, err := taskPluginProtocolJSONValue(view)
|
||||
if err != nil {
|
||||
logger.LogError(c, "encode task protocol view failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("encode task protocol view failed: %s", err.Error()))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
payload, err := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(c.Request.Context(), deps.admissionTimeout, "protocols", []string{pinned.Protocol, "render"}, protocolRequest.JSValue(), viewValue)
|
||||
if err != nil {
|
||||
logger.LogError(c, "task protocol image render hook failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("task protocol image render hook failed: %s", err.Error()))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
response, ok := payload.(map[string]any)
|
||||
data, hasData := response["data"].([]any)
|
||||
if !ok || !hasData {
|
||||
logger.LogError(c, "task protocol image render hook must return an object with a data array")
|
||||
logger.LogError(c, common.LogText("task protocol image render hook must return an object with a data array"))
|
||||
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
|
||||
return
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func serveTaskPluginImageProtocol(c *gin.Context, pinned pluginruntime.PinnedEnd
|
||||
}
|
||||
_, encoded, downloadErr := deps.downloadImage(url)
|
||||
if downloadErr != nil {
|
||||
logger.LogWarn(c, fmt.Sprintf("task protocol image download failed; returning the upstream URL instead of b64_json: %s", common.MaskSensitiveInfo(downloadErr.Error())))
|
||||
logger.LogWarn(c, common.LogText("task protocol image download failed; returning the upstream URL instead of b64_json: %s", common.MaskSensitiveInfo(downloadErr.Error())))
|
||||
continue
|
||||
}
|
||||
item["b64_json"] = encoded
|
||||
@@ -185,7 +185,7 @@ func waitTaskPluginImageTask(c *gin.Context, task *model.Task, deps pluginProtoc
|
||||
case <-timer.C:
|
||||
}
|
||||
if err := deps.pollTask(ctx, task); err != nil {
|
||||
logger.LogWarn(c, fmt.Sprintf("task protocol image poll failed for task %s: %s", task.TaskID, common.MaskSensitiveInfo(err.Error())))
|
||||
logger.LogWarn(c, common.LogText("task protocol image poll failed for task %s: %s", task.TaskID, common.MaskSensitiveInfo(err.Error())))
|
||||
continue
|
||||
}
|
||||
if task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure {
|
||||
|
||||
@@ -28,7 +28,7 @@ func CreatePrefillGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if g.Name == "" || g.Type == "" {
|
||||
common.ApiErrorMsg(c, "组名称和类型不能为空")
|
||||
common.ApiErrorT(c, "Group name and type cannot be empty")
|
||||
return
|
||||
}
|
||||
// 创建前检查名称
|
||||
@@ -36,7 +36,7 @@ func CreatePrefillGroup(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "组名称已存在")
|
||||
common.ApiErrorT(c, "Group name already exists")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func UpdatePrefillGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if g.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少组 ID")
|
||||
common.ApiErrorT(c, "Missing group ID")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
@@ -63,7 +63,7 @@ func UpdatePrefillGroup(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "组名称已存在")
|
||||
common.ApiErrorT(c, "Group name already exists")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+3
-12
@@ -80,22 +80,13 @@ func ResetModelRatio(c *gin.Context) {
|
||||
defaultStr := ratio_setting.DefaultModelRatio2JSONString()
|
||||
err := model.UpdateOption("ModelRatio", defaultStr)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
err = ratio_setting.UpdateModelRatioByJSONString(defaultStr)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"message": "重置模型倍率成功",
|
||||
})
|
||||
common.ApiSuccessT(c, "Model ratios reset", nil)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
@@ -11,10 +12,7 @@ import (
|
||||
|
||||
func GetRatioConfig(c *gin.Context) {
|
||||
if !ratio_setting.IsExposeRatioEnabled() {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "倍率配置接口未启用",
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusForbidden, common.NewMessage("The ratio config API is not enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -211,8 +211,8 @@ func modelPricingSyncValues(data map[string]any, name string) map[string]any {
|
||||
func FetchUpstreamRatios(c *gin.Context) {
|
||||
var req dto.UpstreamRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.SysError("failed to bind upstream request: " + err.Error())
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请求参数格式错误"})
|
||||
common.SysError(common.LogText("failed to bind upstream request: %s", err.Error()))
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Invalid request parameter format"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,8 +239,8 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
}
|
||||
dbChannels, err := model.GetChannelsByIds(intIds)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("failed to query channels: %s", err.Error()))
|
||||
common.ApiErrorStatus(c, http.StatusInternalServerError, common.NewMessage("Failed to query channels"))
|
||||
return
|
||||
}
|
||||
for _, ch := range dbChannels {
|
||||
@@ -256,7 +256,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
}
|
||||
|
||||
if len(upstreams) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
|
||||
common.ApiErrorT(c, "No valid upstream channel")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("build request failed: %s", err.Error()))
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
}
|
||||
@@ -360,25 +360,25 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
|
||||
}
|
||||
if lastErr != nil {
|
||||
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("http error on %s: %s", chItem.Name, lastErr.Error()))
|
||||
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("non-200 from %s: %s", chItem.Name, resp.Status))
|
||||
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
|
||||
return
|
||||
}
|
||||
|
||||
// Content-Type 和响应体大小校验
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
|
||||
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("unexpected content-type from %s: %s", chItem.Name, ct))
|
||||
}
|
||||
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
|
||||
bodyBytes, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), "read response failed from "+chItem.Name+": "+err.Error())
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("read response failed from %s: %s", chItem.Name, err.Error()))
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
}
|
||||
@@ -387,7 +387,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
if isOpenRouter {
|
||||
converted, err := convertOpenRouterToRatioData(bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), "OpenRouter parse failed from "+chItem.Name+": "+err.Error())
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("OpenRouter parse failed from %s: %s", chItem.Name, err.Error()))
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
}
|
||||
@@ -399,7 +399,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
if isModelsDev {
|
||||
converted, err := convertModelsDevToRatioData(bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), "models.dev parse failed from "+chItem.Name+": "+err.Error())
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("models.dev parse failed from %s: %s", chItem.Name, err.Error()))
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
}
|
||||
@@ -417,7 +417,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := common.DecodeJson(bytes.NewReader(bodyBytes), &body); err != nil {
|
||||
logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("json decode failed from %s: %s", chItem.Name, err.Error()))
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
}
|
||||
@@ -462,8 +462,8 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
BillingExpr string `json:"billing_expr"`
|
||||
}
|
||||
if err := common.Unmarshal(body.Data, &pricingItems); err != nil {
|
||||
logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
|
||||
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("unrecognized data format from %s: %s", chItem.Name, err.Error()))
|
||||
ch <- upstreamResult{Name: uniqueName, Err: "failed to parse the upstream response"}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+17
-23
@@ -1,13 +1,11 @@
|
||||
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/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
@@ -64,7 +62,7 @@ func GetRedemption(c *gin.Context) {
|
||||
|
||||
func AddRedemption(c *gin.Context) {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
common.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,27 +73,27 @@ func AddRedemption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
|
||||
common.ApiErrorT(c, "Redemption code name length must be between 1-20")
|
||||
return
|
||||
}
|
||||
if redemption.Count <= 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountPositive)
|
||||
common.ApiErrorT(c, "Redemption code count must be greater than 0")
|
||||
return
|
||||
}
|
||||
if redemption.Count > 100 {
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
||||
common.ApiErrorT(c, "Maximum 100 redemption codes can be generated at once")
|
||||
return
|
||||
}
|
||||
if redemption.Quota <= 0 {
|
||||
common.ApiError(c, errors.New("redemption quota must be positive"))
|
||||
common.ApiErrorT(c, "Redemption quota must be positive")
|
||||
return
|
||||
}
|
||||
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var keys []string
|
||||
@@ -111,12 +109,8 @@ func AddRedemption(c *gin.Context) {
|
||||
}
|
||||
err = cleanRedemption.Insert()
|
||||
if err != nil {
|
||||
common.SysError("failed to insert redemption: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": i18n.T(c, i18n.MsgRedemptionCreateFailed),
|
||||
"data": keys,
|
||||
})
|
||||
common.SysError(common.LogText("failed to insert redemption: %s", err.Error()))
|
||||
common.ApiErrorStatus(c, http.StatusOK, common.NewMessage("Failed to create redemption code, please try again later"), gin.H{"data": keys})
|
||||
return
|
||||
}
|
||||
keys = append(keys, key)
|
||||
@@ -124,7 +118,7 @@ func AddRedemption(c *gin.Context) {
|
||||
recordManageAudit(c, "redemption.create", map[string]any{
|
||||
"name": redemption.Name,
|
||||
"count": redemption.Count,
|
||||
"quota": logger.LogQuota(redemption.Quota),
|
||||
"quota": logger.FormatQuota(redemption.Quota),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@@ -163,15 +157,15 @@ func UpdateRedemption(c *gin.Context) {
|
||||
}
|
||||
if statusOnly == "" {
|
||||
if redemption.Quota <= 0 {
|
||||
common.ApiError(c, errors.New("redemption quota must be positive"))
|
||||
common.ApiErrorT(c, "Redemption quota must be positive")
|
||||
return
|
||||
}
|
||||
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// If you add more fields, please also update redemption.Update()
|
||||
@@ -209,11 +203,11 @@ func DeleteInvalidRedemption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
|
||||
func validateExpiredTime(expired int64) error {
|
||||
if expired != 0 && expired < common.GetTimestamp() {
|
||||
return false, i18n.T(c, i18n.MsgRedemptionExpireTimeInvalid)
|
||||
return common.NewMessage("Expiration time cannot be earlier than current time")
|
||||
}
|
||||
return true, ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteRedemptionBatch(c *gin.Context) {
|
||||
@@ -221,7 +215,7 @@ func DeleteRedemptionBatch(c *gin.Context) {
|
||||
Ids []int `json:"ids" binding:"required,min=1,max=1000,dive,gt=0"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
count, err := model.BatchDeleteRedemptions(request.Ids)
|
||||
|
||||
+16
-15
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
taskdto "github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -89,7 +90,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
defer func() {
|
||||
if newAPIError != nil {
|
||||
service.RecordRequestPolicyTermination(c, newAPIError)
|
||||
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
|
||||
logger.LogError(c, common.LogText("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
|
||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
||||
switch relayFormat {
|
||||
case types.RelayFormatOpenAIRealtime:
|
||||
@@ -215,7 +216,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
|
||||
useChannel := c.GetStringSlice("use_channel")
|
||||
if len(useChannel) > 1 {
|
||||
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
||||
retryLogStr := common.LogText("retry: %s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
||||
logger.LogInfo(c, retryLogStr)
|
||||
}
|
||||
}
|
||||
@@ -277,10 +278,10 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
|
||||
}
|
||||
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)
|
||||
if err != nil {
|
||||
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
return nil, types.NewError(errors.New(i18n.T(c, i18n.MsgRelayRetryGetChannelFailed, map[string]any{"Group": selectGroup, "Model": info.OriginModelName, "Error": err.Error()})), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
if channel == nil {
|
||||
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
return nil, types.NewError(errors.New(i18n.T(c, i18n.MsgRelayRetryNoAvailableChannel, map[string]any{"Group": selectGroup, "Model": info.OriginModelName})), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
|
||||
@@ -334,7 +335,7 @@ func RelayMidjourney(c *gin.Context) {
|
||||
policy.Successful = false
|
||||
statusCode := http.StatusBadRequest
|
||||
if mjErr.Code == 30 {
|
||||
mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
|
||||
mjErr.Result = i18n.T(c, i18n.MsgRelayGroupSaturated)
|
||||
statusCode = http.StatusTooManyRequests
|
||||
}
|
||||
c.JSON(statusCode, gin.H{
|
||||
@@ -343,7 +344,7 @@ func RelayMidjourney(c *gin.Context) {
|
||||
"code": mjErr.Code,
|
||||
})
|
||||
channelId := c.GetInt("channel_id")
|
||||
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
|
||||
logger.LogError(c, common.LogText("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,7 +577,7 @@ func executeTaskSubmissionWith(
|
||||
|
||||
useChannel := c.GetStringSlice("use_channel")
|
||||
if len(useChannel) > 1 {
|
||||
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
||||
retryLogStr := common.LogText("retry: %s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
||||
logger.LogInfo(c, retryLogStr)
|
||||
}
|
||||
|
||||
@@ -601,7 +602,7 @@ func executeTaskSubmissionWith(
|
||||
stage = "reserve"
|
||||
diagnostics.reserve("reserve_start", result.Quota)
|
||||
if reserveErr := relayInfo.Billing.Reserve(result.Quota); reserveErr != nil {
|
||||
common.SysError("reserve adjusted task billing error: " + reserveErr.Error())
|
||||
common.SysError(common.LogText("reserve adjusted task billing error: %s", reserveErr.Error()))
|
||||
taskErr = service.TaskErrorWrapperLocal(errors.New("insufficient quota for adjusted task cost"), string(types.ErrorCodeInsufficientUserQuota), http.StatusForbidden)
|
||||
diagnostics.failed("reserve", "insufficient_quota", taskErr, false)
|
||||
return nil, taskErr
|
||||
@@ -668,7 +669,7 @@ func executeTaskSubmissionWith(
|
||||
task.PrivateData.ResultDiscarded = true
|
||||
insertOmits = append(insertOmits, "data")
|
||||
} else {
|
||||
logger.LogWarn(c, fmt.Sprintf("task plugin route %s %s declares retainResult: false but returned an asynchronous result; retaining task %s", pinned.Route.Method, pinned.Route.Path, task.TaskID))
|
||||
logger.LogWarn(c, common.LogText("task plugin route %s %s declares retainResult: false but returned an asynchronous result; retaining task %s", pinned.Route.Method, pinned.Route.Path, task.TaskID))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,7 +681,7 @@ func executeTaskSubmissionWith(
|
||||
}
|
||||
diagnostics.insertStart(task)
|
||||
if insertErr := task.InsertWithContext(c.Request.Context(), insertOmits...); insertErr != nil {
|
||||
common.SysError("insert task error: " + insertErr.Error())
|
||||
common.SysError(common.LogText("insert task error: %s", insertErr.Error()))
|
||||
taskErr = service.TaskErrorWrapperLocal(errors.New("failed to persist task"), "task_insert_failed", http.StatusInternalServerError)
|
||||
diagnostics.failed("insert", "database_error", taskErr, false)
|
||||
return nil, taskErr
|
||||
@@ -691,7 +692,7 @@ func executeTaskSubmissionWith(
|
||||
diagnostics.settleStart(task, result.Quota)
|
||||
|
||||
if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil {
|
||||
common.SysError("settle task billing error: " + settleErr.Error())
|
||||
common.SysError(common.LogText("settle task billing error: %s", settleErr.Error()))
|
||||
taskErr = service.TaskErrorWrapperLocal(errors.New("failed to settle task billing"), "task_billing_settlement_failed", http.StatusInternalServerError)
|
||||
diagnostics.failed("settle", "billing_error", taskErr, true)
|
||||
return nil, taskErr
|
||||
@@ -729,13 +730,13 @@ func presentTaskSubmission(c *gin.Context, outcome *taskSubmissionOutcome) {
|
||||
c.JSON(http.StatusOK, body)
|
||||
return
|
||||
} else {
|
||||
logger.LogError(c, "task plugin native submit presenter failed: "+callErr.Error())
|
||||
logger.LogError(c, common.LogText("task plugin native submit presenter failed: %s", callErr.Error()))
|
||||
}
|
||||
} else {
|
||||
logger.LogError(c, "encode task plugin native submit view failed: "+valueErr.Error())
|
||||
logger.LogError(c, common.LogText("encode task plugin native submit view failed: %s", valueErr.Error()))
|
||||
}
|
||||
} else {
|
||||
logger.LogError(c, "build task plugin native submit view failed: "+err.Error())
|
||||
logger.LogError(c, common.LogText("build task plugin native submit view failed: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -772,7 +773,7 @@ func respondTaskSubmissionError(c *gin.Context, taskErr *taskdto.TaskError) {
|
||||
// respondTaskError 统一输出 Task 错误响应(含 429 限流提示改写)
|
||||
func respondTaskError(c *gin.Context, taskErr *taskdto.TaskError) {
|
||||
if taskErr.StatusCode == http.StatusTooManyRequests {
|
||||
taskErr.Message = "当前分组上游负载已饱和,请稍后再试"
|
||||
taskErr.Message = i18n.T(c, i18n.MsgRelayGroupUpstreamSaturated)
|
||||
}
|
||||
c.JSON(taskErr.StatusCode, taskErr)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -122,7 +121,7 @@ func ResponsesWebSocket(c *gin.Context) {
|
||||
defer ws.Close()
|
||||
|
||||
if apiError := relay.ResponsesWebSocketHelper(c, ws, runner); apiError != nil {
|
||||
logger.LogError(c, fmt.Sprintf("responses websocket relay error: %s", common.LocalLogPreview(apiError.Error())))
|
||||
logger.LogError(c, common.LogText("responses websocket relay error: %s", common.LocalLogPreview(apiError.Error())))
|
||||
apiError.SetMessage(common.MessageWithRequestId(apiError.Error(), requestID))
|
||||
helper.WssError(c, ws, apiError.ToOpenAIError())
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -22,7 +21,7 @@ import (
|
||||
func GetVerificationMethods(c *gin.Context) {
|
||||
identity, ok := middleware.GetStepUpIdentity(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
|
||||
writeAccountStatusError(c, http.StatusUnauthorized, "The current sign-in method does not support security verification")
|
||||
return
|
||||
}
|
||||
requirements, err := service.GetVerificationRequirements(identity, c.Query("scope"))
|
||||
@@ -41,9 +40,9 @@ func writeSecurityOperationError(c *gin.Context, err error) {
|
||||
var protocolError *protocol.Error
|
||||
switch {
|
||||
case errors.Is(err, passkeysvc.ErrRPIDUnavailable):
|
||||
code, message = "PASSKEY_RP_ID_UNAVAILABLE", i18n.T(c, i18n.MsgPasskeyRPIDUnavailable)
|
||||
code, message = "PASSKEY_RP_ID_UNAVAILABLE", "This Passkey domain is not available on this website. Use its original website or another verification method."
|
||||
case errors.Is(err, system_setting.ErrPasskeyRPIDInvalid):
|
||||
code, message = "PASSKEY_RP_ID_INVALID", i18n.T(c, i18n.MsgPasskeyRPIDInvalid)
|
||||
code, message = "PASSKEY_RP_ID_INVALID", "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard."
|
||||
case errors.Is(err, service.ErrAccountEmailInvalid), errors.Is(err, service.ErrAccountEmailRestricted):
|
||||
code, message = "EMAIL_ADDRESS_REJECTED", err.Error()
|
||||
case errors.Is(err, model.ErrEmailAlreadyTaken):
|
||||
@@ -129,9 +128,17 @@ func writeSecurityOperationError(c *gin.Context, err error) {
|
||||
}
|
||||
// Protocol details can contain challenges and client-controlled data.
|
||||
// Only fixed categories and the server-selected public RP ID are logged.
|
||||
logger.LogWarn(c.Request.Context(), "passkey verification rejected: code=%s reason=%s rp_id=%q", code, reason, c.GetString("passkey_rp_id"))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("passkey verification rejected: code=%s reason=%s rp_id=%q", code, reason, c.GetString("passkey_rp_id")))
|
||||
}
|
||||
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
|
||||
// Every message above is fixed English text, so it is also the key the web
|
||||
// console translates.
|
||||
common.ApiErrorStatus(c, status, common.NewMessage(message), gin.H{"code": code})
|
||||
}
|
||||
|
||||
// writeAccountStatusError responds with a web console message under a non-200
|
||||
// status.
|
||||
func writeAccountStatusError(c *gin.Context, status int, key string) {
|
||||
common.ApiErrorStatus(c, status, common.NewMessage(key))
|
||||
}
|
||||
|
||||
// requireAdminUserProof consumes the step-up proof for an administrative user
|
||||
@@ -150,12 +157,12 @@ func requireAdminUserProof(c *gin.Context, scope string, context any) *model.Aut
|
||||
func UniversalVerify(c *gin.Context) {
|
||||
identity, ok := middleware.GetStepUpIdentity(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
|
||||
writeAccountStatusError(c, http.StatusUnauthorized, "The current sign-in method does not support security verification")
|
||||
return
|
||||
}
|
||||
var request service.VerificationInput
|
||||
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
proof, err := service.VerifySecurityInput(identity, request)
|
||||
|
||||
@@ -635,10 +635,11 @@ func TestSecurityEnrollmentRejectsMissingProofBeforeCreatingCredentials(t *testi
|
||||
}
|
||||
|
||||
type securityEnrollmentResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
MessageKey string `json:"message_key"`
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func TestSecurityEnrollmentMethodPolicy(t *testing.T) {
|
||||
@@ -1399,7 +1400,7 @@ func TestSecurityEnrollmentTwoFAFailureAccountingAndStorageErrors(t *testing.T)
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
||||
assert.False(t, body.Success, endpoint.path)
|
||||
if endpoint.path == "/api/user/login/2fa" {
|
||||
assert.Equal(t, "参数错误", body.Message, endpoint.path)
|
||||
assert.Equal(t, "Invalid parameters", body.Message, endpoint.path)
|
||||
} else {
|
||||
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code, endpoint.path)
|
||||
}
|
||||
@@ -1766,23 +1767,21 @@ func TestSecurityEnrollmentMissingTargetsAreBusinessErrors(t *testing.T) {
|
||||
_, identity := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.Channel{}))
|
||||
for _, target := range []struct {
|
||||
path, key string
|
||||
handler gin.HandlerFunc
|
||||
path, message string
|
||||
handler gin.HandlerFunc
|
||||
}{
|
||||
{"/api/channel/999/key", i18n.MsgChannelNotExists, GetChannelKey},
|
||||
{"/api/user/999/2fa", i18n.MsgUserNotExists, AdminDisable2FA},
|
||||
{"/api/channel/999/key", "Channel does not exist", GetChannelKey},
|
||||
{"/api/user/999/2fa", "User does not exist", AdminDisable2FA},
|
||||
} {
|
||||
var expectedMessage string
|
||||
response := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
|
||||
c.Params = gin.Params{{Key: "id", Value: "999"}}
|
||||
expectedMessage = i18n.T(c, target.key)
|
||||
target.handler(c)
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, response.Code)
|
||||
var body securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
||||
assert.False(t, body.Success)
|
||||
assert.Equal(t, expectedMessage, body.Message)
|
||||
assert.Equal(t, target.message, body.Message)
|
||||
assert.NotEqual(t, "AUTH_UNAUTHORIZED", body.Code)
|
||||
for _, id := range []string{"invalid", "0", "-1"} {
|
||||
invalid := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
|
||||
|
||||
+10
-40
@@ -46,10 +46,7 @@ func GetSetup(c *gin.Context) {
|
||||
func PostSetup(c *gin.Context) {
|
||||
// Check if setup is already completed
|
||||
if constant.Setup {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "系统已经初始化完成",
|
||||
})
|
||||
common.ApiErrorT(c, "The system is already initialized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,10 +56,7 @@ func PostSetup(c *gin.Context) {
|
||||
var req SetupRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "请求参数有误",
|
||||
})
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,18 +64,12 @@ func PostSetup(c *gin.Context) {
|
||||
if !rootExists {
|
||||
// Validate username length: max 12 characters to align with model.User validation
|
||||
if len(req.Username) > 12 {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "用户名长度不能超过12个字符",
|
||||
})
|
||||
common.ApiErrorT(c, "Username must be {{max}} characters or fewer", map[string]any{"max": 12})
|
||||
return
|
||||
}
|
||||
// Validate password
|
||||
if req.Password != req.ConfirmPassword {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "两次输入的密码不一致",
|
||||
})
|
||||
common.ApiErrorT(c, "Passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,10 +84,7 @@ func PostSetup(c *gin.Context) {
|
||||
// Create root user
|
||||
hashedPassword, err := common.HashAccountPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "系统错误: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "System error: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rootUser := model.User{
|
||||
@@ -113,10 +98,7 @@ func PostSetup(c *gin.Context) {
|
||||
}
|
||||
err = model.DB.Create(&rootUser).Error
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "创建管理员账号失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to create the administrator account: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -128,19 +110,13 @@ func PostSetup(c *gin.Context) {
|
||||
// Save operation modes to database for persistence
|
||||
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "保存自用模式设置失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to save the self-use mode setting: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "保存演示站点模式设置失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to save the demo site mode setting: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,17 +129,11 @@ func PostSetup(c *gin.Context) {
|
||||
}
|
||||
err = model.DB.Create(&setup).Error
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"message": "系统初始化失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorT(c, "Failed to initialize the system: {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"message": "系统初始化成功",
|
||||
})
|
||||
common.ApiSuccessT(c, "System initialized successfully", nil)
|
||||
}
|
||||
|
||||
func boolToString(b bool) string {
|
||||
|
||||
+54
-56
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -78,7 +77,7 @@ func UpdateSubscriptionPreference(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
var req BillingPreferenceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
pref := common.NormalizeBillingPreference(req.BillingPreference)
|
||||
@@ -105,7 +104,7 @@ func SubscriptionRequestBalancePay(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
var req SubscriptionBalancePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -145,20 +144,20 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
req.Plan.Id = 0
|
||||
if strings.TrimSpace(req.Plan.Title) == "" {
|
||||
common.ApiErrorMsg(c, "套餐标题不能为空")
|
||||
common.ApiErrorT(c, "Subscription plan title cannot be empty")
|
||||
return
|
||||
}
|
||||
if req.Plan.PriceAmount < 0 {
|
||||
common.ApiErrorMsg(c, "价格不能为负数")
|
||||
common.ApiErrorT(c, "Price cannot be negative")
|
||||
return
|
||||
}
|
||||
if req.Plan.PriceAmount > 9999 {
|
||||
common.ApiErrorMsg(c, "价格不能超过9999")
|
||||
common.ApiErrorT(c, "Price cannot exceed 9999")
|
||||
return
|
||||
}
|
||||
if req.Plan.Currency == "" {
|
||||
@@ -178,30 +177,30 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
if req.Plan.MaxPurchasePerUser < 0 {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
common.ApiErrorT(c, "Purchase limit cannot be negative")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
common.ApiErrorT(c, "Total quota cannot be negative")
|
||||
return
|
||||
}
|
||||
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
|
||||
if req.Plan.UpgradeGroup != "" {
|
||||
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
|
||||
common.ApiErrorMsg(c, "升级分组不存在")
|
||||
common.ApiErrorT(c, "Upgrade group does not exist")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.Plan.DowngradeGroup = strings.TrimSpace(req.Plan.DowngradeGroup)
|
||||
if req.Plan.DowngradeGroup != "" {
|
||||
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.DowngradeGroup]; !ok {
|
||||
common.ApiErrorMsg(c, "降级分组不存在")
|
||||
common.ApiErrorT(c, "Downgrade group does not exist")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||
common.ApiErrorT(c, "Custom reset cycle must be greater than 0 seconds")
|
||||
return
|
||||
}
|
||||
err := model.DB.Create(&req.Plan).Error
|
||||
@@ -220,24 +219,24 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
common.ApiErrorT(c, "Invalid ID")
|
||||
return
|
||||
}
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Plan.Title) == "" {
|
||||
common.ApiErrorMsg(c, "套餐标题不能为空")
|
||||
common.ApiErrorT(c, "Subscription plan title cannot be empty")
|
||||
return
|
||||
}
|
||||
if req.Plan.PriceAmount < 0 {
|
||||
common.ApiErrorMsg(c, "价格不能为负数")
|
||||
common.ApiErrorT(c, "Price cannot be negative")
|
||||
return
|
||||
}
|
||||
if req.Plan.PriceAmount > 9999 {
|
||||
common.ApiErrorMsg(c, "价格不能超过9999")
|
||||
common.ApiErrorT(c, "Price cannot exceed 9999")
|
||||
return
|
||||
}
|
||||
req.Plan.Id = id
|
||||
@@ -252,30 +251,30 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
if req.Plan.MaxPurchasePerUser < 0 {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
common.ApiErrorT(c, "Purchase limit cannot be negative")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
common.ApiErrorT(c, "Total quota cannot be negative")
|
||||
return
|
||||
}
|
||||
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
|
||||
if req.Plan.UpgradeGroup != "" {
|
||||
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
|
||||
common.ApiErrorMsg(c, "升级分组不存在")
|
||||
common.ApiErrorT(c, "Upgrade group does not exist")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.Plan.DowngradeGroup = strings.TrimSpace(req.Plan.DowngradeGroup)
|
||||
if req.Plan.DowngradeGroup != "" {
|
||||
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.DowngradeGroup]; !ok {
|
||||
common.ApiErrorMsg(c, "降级分组不存在")
|
||||
common.ApiErrorT(c, "Downgrade group does not exist")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||
common.ApiErrorT(c, "Custom reset cycle must be greater than 0 seconds")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -332,12 +331,12 @@ func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
|
||||
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
common.ApiErrorT(c, "Invalid ID")
|
||||
return
|
||||
}
|
||||
var req AdminUpdateSubscriptionPlanStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil {
|
||||
@@ -360,7 +359,7 @@ func AdminBindSubscription(c *gin.Context) {
|
||||
|
||||
var req AdminBindSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminBindSubscription(req.UserId, req.PlanId, "")
|
||||
@@ -368,11 +367,19 @@ func AdminBindSubscription(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
subscriptionChangeSuccess(c, msg)
|
||||
}
|
||||
|
||||
// subscriptionChangeSuccess replies to an admin subscription change. notice
|
||||
// tells the admin how the user's group changes, when it does.
|
||||
func subscriptionChangeSuccess(c *gin.Context, notice *common.Message) {
|
||||
if notice == nil {
|
||||
common.ApiSuccess(c, nil)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
data := notice.Fields()
|
||||
data["message"] = notice.Error()
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
// ---- Admin: user subscription management ----
|
||||
@@ -380,7 +387,7 @@ func AdminBindSubscription(c *gin.Context) {
|
||||
func AdminListUserSubscriptions(c *gin.Context) {
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
common.ApiErrorT(c, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
subs, err := model.GetAllUserSubscriptions(userId)
|
||||
@@ -411,9 +418,12 @@ func recordSubscriptionResetUserLogs(c *gin.Context, result *model.SubscriptionR
|
||||
if result == nil || result.ResetCount == 0 {
|
||||
return
|
||||
}
|
||||
content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId)
|
||||
const action = "subscription.user_quota_reset"
|
||||
params := map[string]any{"plan_id": result.PlanId, "plan_title": result.PlanTitle}
|
||||
operation := model.AuditOperation{Action: action, Params: params}
|
||||
content := auditContentEN(action, params)
|
||||
for _, userId := range result.AffectedUserIds {
|
||||
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, nil, c)
|
||||
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, &operation, c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,12 +435,12 @@ func AdminCreateUserSubscription(c *gin.Context) {
|
||||
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
common.ApiErrorT(c, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
var req AdminCreateUserSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminBindSubscription(userId, req.PlanId, "")
|
||||
@@ -438,26 +448,22 @@ func AdminCreateUserSubscription(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
subscriptionChangeSuccess(c, msg)
|
||||
}
|
||||
|
||||
func AdminResetUserSubscriptionsByPlan(c *gin.Context) {
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
common.ApiErrorT(c, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
var req AdminResetSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime)
|
||||
@@ -481,12 +487,12 @@ func AdminResetUserSubscriptionsByPlan(c *gin.Context) {
|
||||
func AdminResetPlanSubscriptions(c *gin.Context) {
|
||||
planId, _ := strconv.Atoi(c.Param("id"))
|
||||
if planId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
common.ApiErrorT(c, "Invalid ID")
|
||||
return
|
||||
}
|
||||
var req AdminResetSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime)
|
||||
@@ -496,7 +502,7 @@ func AdminResetPlanSubscriptions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
recordSubscriptionResetUserLogs(c, result, auditOperatorInfo(c))
|
||||
common.SysLog(fmt.Sprintf("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t",
|
||||
common.SysLog(common.LogText("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t",
|
||||
result.PlanId, result.ResetCount, result.UserCount, result.AdvanceResetTime))
|
||||
recordManageAudit(c, "subscription.plan_reset", map[string]any{
|
||||
"plan_id": result.PlanId,
|
||||
@@ -512,7 +518,7 @@ func AdminResetPlanSubscriptions(c *gin.Context) {
|
||||
func AdminInvalidateUserSubscription(c *gin.Context) {
|
||||
subId, _ := strconv.Atoi(c.Param("id"))
|
||||
if subId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||
common.ApiErrorT(c, "Invalid subscription ID")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminInvalidateUserSubscription(subId)
|
||||
@@ -520,18 +526,14 @@ func AdminInvalidateUserSubscription(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
subscriptionChangeSuccess(c, msg)
|
||||
}
|
||||
|
||||
// AdminDeleteUserSubscription hard-deletes a user subscription.
|
||||
func AdminDeleteUserSubscription(c *gin.Context) {
|
||||
subId, _ := strconv.Atoi(c.Param("id"))
|
||||
if subId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||
common.ApiErrorT(c, "Invalid subscription ID")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminDeleteUserSubscription(subId)
|
||||
@@ -539,9 +541,5 @@ func AdminDeleteUserSubscription(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
subscriptionChangeSuccess(c, msg)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -30,14 +29,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
// Keep body for debugging consistency (like RequestCreemPay)
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付请求读取失败 error=%q", err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem failed to read subscription payment request error=%q", err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -47,15 +46,15 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
common.ApiErrorT(c, "Subscription plan is not enabled")
|
||||
return
|
||||
}
|
||||
if plan.CreemProductId == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 CreemProductId")
|
||||
common.ApiErrorT(c, "CreemProductId is not configured for this plan")
|
||||
return
|
||||
}
|
||||
if setting.CreemWebhookSecret == "" && !setting.CreemTestMode {
|
||||
common.ApiErrorMsg(c, "Creem Webhook 未配置")
|
||||
common.ApiErrorT(c, "Creem webhook is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,7 +65,7 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
common.ApiErrorT(c, "User does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,7 +76,7 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
common.ApiErrorT(c, "Purchase limit for this plan has been reached")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -97,7 +96,7 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
paymentError(c, common.NewMessage("Failed to create order"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,8 +120,8 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
|
||||
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, product, user.Email, user.Username)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem failed to create subscription payment link trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
|
||||
var req SubscriptionEpayPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,15 +38,15 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
common.ApiErrorT(c, "Subscription plan is not enabled")
|
||||
return
|
||||
}
|
||||
if plan.PriceAmount < 0.01 {
|
||||
common.ApiErrorMsg(c, "套餐金额过低")
|
||||
common.ApiErrorT(c, "Plan amount is too low")
|
||||
return
|
||||
}
|
||||
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
|
||||
common.ApiErrorMsg(c, "支付方式不存在")
|
||||
common.ApiErrorT(c, "Payment method does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
common.ApiErrorT(c, "Purchase limit for this plan has been reached")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -66,12 +66,12 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
callBackAddress := service.GetCallbackAddress()
|
||||
returnUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/return")
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "回调地址配置错误")
|
||||
common.ApiErrorT(c, "Callback URL configuration error")
|
||||
return
|
||||
}
|
||||
notifyUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/notify")
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "回调地址配置错误")
|
||||
common.ApiErrorT(c, "Callback URL configuration error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
common.ApiErrorMsg(c, "当前管理员未配置支付信息")
|
||||
common.ApiErrorT(c, "Payment information has not been configured by administrator")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
common.ApiErrorMsg(c, "创建订单失败")
|
||||
common.ApiErrorT(c, "Failed to create order")
|
||||
return
|
||||
}
|
||||
uri, params, err := client.Purchase(&epay.PurchaseArgs{
|
||||
@@ -109,7 +109,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
})
|
||||
if err != nil {
|
||||
_ = model.ExpireSubscriptionOrder(tradeNo, model.PaymentProviderEpay)
|
||||
common.ApiErrorMsg(c, "拉起支付失败")
|
||||
common.ApiErrorT(c, "Failed to start payment")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
|
||||
|
||||
@@ -27,7 +27,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
|
||||
var req SubscriptionStripePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,19 +37,19 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
common.ApiErrorT(c, "Subscription plan is not enabled")
|
||||
return
|
||||
}
|
||||
if plan.StripePriceId == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 StripePriceId")
|
||||
common.ApiErrorT(c, "StripePriceId is not configured for this plan")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
|
||||
common.ApiErrorMsg(c, "Stripe 未配置或密钥无效")
|
||||
common.ApiErrorT(c, "Stripe is not configured or key is invalid")
|
||||
return
|
||||
}
|
||||
if setting.StripeWebhookSecret == "" {
|
||||
common.ApiErrorMsg(c, "Stripe Webhook 未配置")
|
||||
common.ApiErrorT(c, "Stripe webhook is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
common.ApiErrorT(c, "User does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
common.ApiErrorT(c, "Purchase limit for this plan has been reached")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,8 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
|
||||
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q", referenceId, plan.Id, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Stripe failed to create subscription payment link trade_no=%s plan_id=%d error=%q", referenceId, plan.Id, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
paymentError(c, common.NewMessage("Failed to create order"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
|
||||
var req SubscriptionWaffoPancakePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,18 +37,18 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
common.ApiErrorT(c, "Subscription plan is not enabled")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(plan.WaffoPancakeProductId) == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 WaffoPancakeProductId")
|
||||
common.ApiErrorT(c, "WaffoPancakeProductId is not configured for this plan")
|
||||
return
|
||||
}
|
||||
// Plan targets its own Pancake product, so we only require credentials
|
||||
// here — not the gateway-level WaffoPancakeProductID.
|
||||
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" {
|
||||
common.ApiErrorMsg(c, "Waffo Pancake 未配置或密钥无效")
|
||||
common.ApiErrorT(c, "Waffo Pancake is not configured or key is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
common.ApiErrorT(c, "User does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
common.ApiErrorT(c, "Purchase limit for this plan has been reached")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,8 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create subscription order user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to create order"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,13 +108,13 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
OrderMerchantExternalID: tradeNo,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create subscription checkout session user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
order.Status = common.TopUpStatusFailed
|
||||
_ = order.Update()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f", userId, plan.Id, tradeNo, session.SessionID, plan.PriceAmount))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake subscription order created user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f", userId, plan.Id, tradeNo, session.SessionID, plan.PriceAmount))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
|
||||
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -158,6 +157,6 @@ func finishSystemTaskHandler(task *model.SystemTask, runnerID string, status mod
|
||||
errorMessage = runErr.Error()
|
||||
}
|
||||
if err := model.FinishSystemTask(task.TaskID, runnerID, status, result, errorMessage); err != nil {
|
||||
common.SysLog(fmt.Sprintf("system task %s failed to persist result: %v", task.TaskID, err))
|
||||
common.SysLog(common.LogText("system task %s failed to persist result: %v", task.TaskID, err))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
@@ -31,7 +30,7 @@ const maxTaskPluginSourceBytes = 8 << 20
|
||||
func taskPluginCompileError(c *gin.Context, err error) {
|
||||
var unknownField *jsplugin.UnknownMetaFieldError
|
||||
if errors.As(err, &unknownField) {
|
||||
common.ApiErrorI18n(c, i18n.MsgTaskPluginUnknownMetaField, map[string]any{"Field": unknownField.Field})
|
||||
common.ApiErrorT(c, "Plugin metadata contains an unknown field \"{{field}}\". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.", map[string]any{"field": unknownField.Field})
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
@@ -787,7 +786,7 @@ func syncTaskPluginsOnceContext(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
taskPluginSyncState.errors[plugin.Key] = compileErr.Error()
|
||||
common.SysError(fmt.Sprintf("compile task plugin %s@%s: %v", plugin.Key, plugin.Version, compileErr))
|
||||
common.SysError(common.LogText("compile task plugin %s@%s: %v", plugin.Key, plugin.Version, compileErr))
|
||||
logger.LogDebug(
|
||||
ctx,
|
||||
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_failed retained_incumbent=%t",
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
"github.com/QuantumNous/new-api/plugins"
|
||||
@@ -983,37 +982,29 @@ func TestUploadTaskPluginPreflightConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadTaskPluginLocalizesUnknownMetaField(t *testing.T) {
|
||||
require.NoError(t, i18n.Init())
|
||||
func TestUploadTaskPluginReportsUnknownMetaField(t *testing.T) {
|
||||
body, err := common.Marshal(map[string]any{
|
||||
"source": `export const meta = { futureField: true };`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
for _, tc := range []struct {
|
||||
language string
|
||||
message string
|
||||
}{
|
||||
{"en", `Plugin metadata contains an unknown field "futureField". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`},
|
||||
{"zh-CN", "插件元数据包含未知字段“futureField”。如果插件来自官方市场,可能需要更高版本的 new-api。请尝试更新 new-api 后重新安装插件。"},
|
||||
{"zh-TW", "外掛中繼資料包含未知欄位「futureField」。如果外掛來自官方市集,可能需要較新版本的 new-api。請嘗試更新 new-api 後重新安裝外掛。"},
|
||||
} {
|
||||
t.Run(tc.language, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("Accept-Language", tc.language)
|
||||
UploadTaskPlugin(c)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.False(t, response.Success)
|
||||
assert.Equal(t, tc.message, response.Message)
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("Accept-Language", "zh-CN")
|
||||
UploadTaskPlugin(c)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
MessageKey string `json:"message_key"`
|
||||
MessageParams map[string]any `json:"message_params"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.False(t, response.Success)
|
||||
assert.Equal(t, `Plugin metadata contains an unknown field "futureField". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`, response.Message)
|
||||
assert.Equal(t, `Plugin metadata contains an unknown field "{{field}}". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`, response.MessageKey)
|
||||
assert.Equal(t, map[string]any{"field": "futureField"}, response.MessageParams)
|
||||
}
|
||||
|
||||
func TestUploadTaskPluginRejectsMetaViolatingV1Schema(t *testing.T) {
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestUpdateOptionRejectsRetiredFrontendTheme(t *testing.T) {
|
||||
UpdateOption(context)
|
||||
|
||||
assert.Equal(t, http.StatusOK, response.Code)
|
||||
assert.JSONEq(t, `{"success":false,"message":"Classic 前端已移除,主题只能设置为 default"}`, response.Body.String())
|
||||
assert.JSONEq(t, `{"success":false,"message":"The Classic frontend has been removed. The theme can only be set to default","message_key":"The Classic frontend has been removed. The theme can only be set to default"}`, response.Body.String())
|
||||
}
|
||||
|
||||
func TestGetStatusAdvertisesDefaultDashboard(t *testing.T) {
|
||||
|
||||
+22
-25
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -9,6 +8,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
@@ -60,7 +60,7 @@ func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
|
||||
maskedToken.Key = token.GetMaskedKey()
|
||||
autoGroups, err := token.GetAutoGroups()
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
|
||||
common.SysError(common.LogText("failed to parse auto groups for token %d: %v", token.Id, err))
|
||||
autoGroups = nil
|
||||
}
|
||||
if len(autoGroups) == 0 {
|
||||
@@ -98,7 +98,7 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo
|
||||
|
||||
maxCount := setting.GetMaxTokenAutoGroups()
|
||||
if len(groups) > maxCount {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsTooMany, map[string]any{"Max": maxCount})
|
||||
common.ApiErrorT(c, "A token can select at most {{max}} Auto groups", map[string]any{"max": maxCount})
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -110,12 +110,12 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo
|
||||
seen := make(map[string]struct{}, len(groups))
|
||||
for _, group := range groups {
|
||||
if _, ok := seen[group]; ok {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": group})
|
||||
common.ApiErrorT(c, "Auto group {{group}} is duplicated", map[string]any{"group": group})
|
||||
return false
|
||||
}
|
||||
seen[group] = struct{}{}
|
||||
if !service.IsUserSelectableGroup(userGroup, group) {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsInvalid, map[string]any{"Group": group})
|
||||
common.ApiErrorT(c, "Auto group {{group}} is unavailable or unauthorized", map[string]any{"group": group})
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,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())
|
||||
common.SysError(common.LogText("failed to get token by key: %s", err.Error()))
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
|
||||
return
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func AddToken(c *gin.Context) {
|
||||
}
|
||||
token := request.Token
|
||||
if len(token.Name) > 50 {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
|
||||
common.ApiErrorT(c, "Token name is too long")
|
||||
return
|
||||
}
|
||||
params := tokenAuditParams(c)
|
||||
@@ -292,12 +292,12 @@ func AddToken(c *gin.Context) {
|
||||
// 非无限额度时,检查额度值是否超出有效范围
|
||||
if !token.UnlimitedQuota {
|
||||
if token.RemainQuota < 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
|
||||
common.ApiErrorT(c, "Quota value cannot be negative")
|
||||
return
|
||||
}
|
||||
maxQuotaValue := maxTokenQuota()
|
||||
if token.RemainQuota > maxQuotaValue {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
|
||||
common.ApiErrorT(c, "Quota value exceeds valid range, maximum is {{max}}", map[string]any{"max": logger.FormatQuota(maxQuotaValue)})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -309,10 +309,7 @@ func AddToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if int(count) >= maxTokens {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("已达到最大令牌数量限制 (%d)", maxTokens),
|
||||
})
|
||||
common.ApiErrorT(c, "Maximum number of tokens reached ({{max}})", map[string]any{"max": maxTokens})
|
||||
return
|
||||
}
|
||||
if token.Group == "auto" {
|
||||
@@ -325,8 +322,8 @@ func AddToken(c *gin.Context) {
|
||||
}
|
||||
key, err := common.GenerateKey()
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
|
||||
common.SysLog("failed to generate token key: " + err.Error())
|
||||
common.ApiErrorT(c, "Failed to generate token")
|
||||
common.SysLog(common.LogText("failed to generate token key: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
cleanToken := model.Token{
|
||||
@@ -395,17 +392,17 @@ func UpdateToken(c *gin.Context) {
|
||||
params["id"] = token.Id
|
||||
}
|
||||
if len(token.Name) > 50 {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
|
||||
common.ApiErrorT(c, "Token name is too long")
|
||||
return
|
||||
}
|
||||
if !token.UnlimitedQuota {
|
||||
if token.RemainQuota < 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
|
||||
common.ApiErrorT(c, "Quota value cannot be negative")
|
||||
return
|
||||
}
|
||||
maxQuotaValue := maxTokenQuota()
|
||||
if token.RemainQuota > maxQuotaValue {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
|
||||
common.ApiErrorT(c, "Quota value exceeds valid range, maximum is {{max}}", map[string]any{"max": logger.FormatQuota(maxQuotaValue)})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -418,11 +415,11 @@ func UpdateToken(c *gin.Context) {
|
||||
previous := *cleanToken
|
||||
if token.Status == common.TokenStatusEnabled {
|
||||
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable)
|
||||
common.ApiErrorT(c, "Token has expired and cannot be enabled. Please modify the expiration time or set it to never expire")
|
||||
return
|
||||
}
|
||||
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenExhaustedCannotEable)
|
||||
common.ApiErrorT(c, "Token quota is exhausted and cannot be enabled. Please modify the remaining quota or set it to unlimited")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -495,12 +492,12 @@ type TokenBatch struct {
|
||||
func DeleteTokenBatch(c *gin.Context) {
|
||||
tokenBatch := TokenBatch{}
|
||||
if err := c.ShouldBindJSON(&tokenBatch); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
params := tokenBatchAuditParams(c, tokenBatch.Ids)
|
||||
if len(tokenBatch.Ids) == 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
userId := c.GetInt("id")
|
||||
@@ -521,16 +518,16 @@ func DeleteTokenBatch(c *gin.Context) {
|
||||
func GetTokenKeysBatch(c *gin.Context) {
|
||||
tokenBatch := TokenBatch{}
|
||||
if err := c.ShouldBindJSON(&tokenBatch); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
params := tokenBatchAuditParams(c, tokenBatch.Ids)
|
||||
if len(tokenBatch.Ids) == 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if len(tokenBatch.Ids) > 100 {
|
||||
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
|
||||
common.ApiErrorT(c, "Too many items in batch request, maximum is {{max}}", map[string]any{"max": 100})
|
||||
return
|
||||
}
|
||||
userId := c.GetInt("id")
|
||||
|
||||
+54
-41
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -223,10 +224,10 @@ func getMaxTopUpAmount() int64 {
|
||||
func validateCreditedQuota(quota decimal.Decimal) (int, error) {
|
||||
value, err := common.WalletQuotaFromDecimalStrict(quota)
|
||||
if err != nil {
|
||||
return 0, errors.New("充值额度超出系统可表示范围")
|
||||
return 0, common.NewMessage("Top-up quota exceeds the range the system can represent")
|
||||
}
|
||||
if value <= 0 {
|
||||
return 0, errors.New("充值额度必须大于 0")
|
||||
return 0, common.NewMessage("Top-up quota must be greater than 0")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -238,9 +239,21 @@ func validateTopUpQuota(amount int64) (int, error) {
|
||||
}
|
||||
maxAmount := getMaxTopUpAmount()
|
||||
if maxAmount > 0 && amount > maxAmount {
|
||||
return 0, fmt.Errorf("单笔充值数量不能大于 %d", maxAmount)
|
||||
return 0, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": maxAmount})
|
||||
}
|
||||
return 0, errors.New("充值数量无效")
|
||||
return 0, common.NewMessage("Invalid top-up amount")
|
||||
}
|
||||
|
||||
// paymentError answers payment endpoints in their {"message": "error",
|
||||
// "data": text} shape. When err is or wraps a *common.Message, the response
|
||||
// also carries message_key and message_params for the web console.
|
||||
func paymentError(c *gin.Context, err error) {
|
||||
body := gin.H{"message": "error", "data": err.Error()}
|
||||
var message *common.Message
|
||||
if errors.As(err, &message) {
|
||||
maps.Copy(body, message.Fields())
|
||||
}
|
||||
c.JSON(http.StatusOK, body)
|
||||
}
|
||||
|
||||
func rejectInvalidCreditedQuota(c *gin.Context, userId int, quota decimal.Decimal) bool {
|
||||
@@ -249,7 +262,7 @@ func rejectInvalidCreditedQuota(c *gin.Context, userId int, quota decimal.Decima
|
||||
err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
||||
paymentError(c, err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -261,7 +274,7 @@ func rejectInvalidTopUpQuota(c *gin.Context, userId int, amount int64) bool {
|
||||
err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
||||
paymentError(c, err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -271,11 +284,11 @@ func RequestEpay(c *gin.Context) {
|
||||
var req EpayRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
if req.Amount < getMinTopup() {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getMinTopup()}))
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
@@ -285,17 +298,17 @@ func RequestEpay(c *gin.Context) {
|
||||
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
paymentError(c, common.NewMessage("Failed to get the user group"))
|
||||
return
|
||||
}
|
||||
payMoney := getPayMoney(req.Amount, group)
|
||||
if payMoney < 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
paymentError(c, common.NewMessage("Payment amount is too low"))
|
||||
return
|
||||
}
|
||||
|
||||
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"})
|
||||
paymentError(c, common.NewMessage("Payment method does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -306,7 +319,7 @@ func RequestEpay(c *gin.Context) {
|
||||
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
|
||||
paymentError(c, common.NewMessage("Payment information has not been configured by administrator"))
|
||||
return
|
||||
}
|
||||
uri, params, err := client.Purchase(&epay.PurchaseArgs{
|
||||
@@ -319,8 +332,8 @@ func RequestEpay(c *gin.Context) {
|
||||
ReturnUrl: returnUrl,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay failed to start payment user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
amount := req.Amount
|
||||
@@ -341,11 +354,11 @@ func RequestEpay(c *gin.Context) {
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay failed to create top-up order user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to create order"))
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Epay top-up order created user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
|
||||
}
|
||||
|
||||
@@ -393,7 +406,7 @@ func UnlockOrder(tradeNo string) {
|
||||
|
||||
func EpayNotify(c *gin.Context) {
|
||||
if !isEpayWebhookEnabled() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
@@ -403,7 +416,7 @@ func EpayNotify(c *gin.Context) {
|
||||
if c.Request.Method == "POST" {
|
||||
// POST 请求:从 POST body 解析参数
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to parse POST form path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
@@ -418,35 +431,35 @@ func EpayNotify(c *gin.Context) {
|
||||
return r
|
||||
}, map[string]string{})
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Epay webhook received request path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
|
||||
|
||||
if len(params) == 0 {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 参数为空 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook params are empty path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay client is not initialized path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
_, err := c.Writer.Write([]byte("fail"))
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err != nil || !verifyInfo.VerifyStatus {
|
||||
if _, writeErr := c.Writer.Write([]byte("fail")); writeErr != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), writeErr.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), writeErr.Error()))
|
||||
}
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook signature verification failed path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
} else {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook signature verification failed path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
|
||||
}
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Epay webhook signature verified trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
|
||||
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||
// 进程内锁只是优化;重复/并发回调的正确性由 RechargeEpay 的
|
||||
@@ -457,29 +470,29 @@ func EpayNotify(c *gin.Context) {
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrTopUpNotFound):
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Epay callback order not found trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
case errors.Is(err, model.ErrPaymentMethodMismatch):
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付网关不匹配 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Epay order payment gateway mismatch trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
||||
case errors.Is(err, model.ErrTopUpStatusInvalid):
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单状态非法 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Epay order status invalid trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
||||
default:
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 充值处理失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay top-up processing failed trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
|
||||
}
|
||||
if _, writeErr := c.Writer.Write([]byte("fail")); writeErr != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
if alreadyDone {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 重复回调幂等忽略 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Epay duplicate callback ignored trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
||||
} else {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Epay top-up succeeded trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
|
||||
}
|
||||
} else {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Epay webhook ignored event trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
}
|
||||
if _, writeErr := c.Writer.Write([]byte("success")); writeErr != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,12 +500,12 @@ func RequestAmount(c *gin.Context) {
|
||||
var req AmountRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount < getMinTopup() {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getMinTopup()}))
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
@@ -501,12 +514,12 @@ func RequestAmount(c *gin.Context) {
|
||||
}
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
paymentError(c, common.NewMessage("Failed to get the user group"))
|
||||
return
|
||||
}
|
||||
payMoney := getPayMoney(req.Amount, group)
|
||||
if payMoney <= 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
paymentError(c, common.NewMessage("Payment amount is too low"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||
@@ -570,7 +583,7 @@ type AdminCompleteTopupRequest struct {
|
||||
func AdminCompleteTopUp(c *gin.Context) {
|
||||
var req AdminCompleteTopupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+47
-47
@@ -36,9 +36,9 @@ func generateCreemSignature(payload string, secret string) string {
|
||||
// 验证Creem webhook签名
|
||||
func verifyCreemSignature(payload string, signature string, secret string) bool {
|
||||
if secret == "" {
|
||||
logger.LogWarn(context.Background(), fmt.Sprintf("Creem webhook secret 未配置 test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
|
||||
logger.LogWarn(context.Background(), common.LogText("Creem webhook secret is not configured test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
|
||||
if setting.CreemTestMode {
|
||||
logger.LogInfo(context.Background(), fmt.Sprintf("Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q", signature, payload))
|
||||
logger.LogInfo(context.Background(), common.LogText("Creem webhook signature check skipped reason=test_mode signature=%q body=%q", signature, payload))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -66,12 +66,12 @@ type CreemAdaptor struct {
|
||||
|
||||
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
if req.PaymentMethod != model.PaymentMethodCreem {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||
paymentError(c, common.NewMessage("Unsupported payment channel"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.ProductId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "请选择产品"})
|
||||
paymentError(c, common.NewMessage("Please select a product"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
var products []CreemProduct
|
||||
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 产品配置解析失败 user_id=%d error=%q", c.GetInt("id"), err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品配置错误"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem failed to parse product config user_id=%d error=%q", c.GetInt("id"), err.Error()))
|
||||
paymentError(c, common.NewMessage("Product configuration error"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
}
|
||||
|
||||
if selectedProduct == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
|
||||
paymentError(c, common.NewMessage("Product does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||
paymentError(c, common.NewMessage("User does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,20 +126,20 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem failed to create top-up order user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to create order"))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建支付链接,传入用户邮箱
|
||||
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, selectedProduct, user.Email, user.Username)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem failed to create payment link user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem top-up order created user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
@@ -156,19 +156,19 @@ func RequestCreemPay(c *gin.Context) {
|
||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 支付请求读取失败 error=%q", err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem failed to read payment request error=%q", err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付请求已收到 user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem payment request received user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
|
||||
|
||||
// 重新设置body供后续的ShouldBindJSON使用
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
err = c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
creemAdaptor.RequestPay(c, &req)
|
||||
@@ -237,7 +237,7 @@ type CreemWebhookEvent struct {
|
||||
|
||||
func CreemWebhook(c *gin.Context) {
|
||||
if !isCreemWebhookEnabled() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Creem webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -245,28 +245,28 @@ func CreemWebhook(c *gin.Context) {
|
||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取签名头
|
||||
signature := c.GetHeader(CreemSignatureHeader)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
if signature == "" {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少签名 path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Creem webhook missing signature path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Creem webhook signature verification failed path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 验签成功 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem webhook signature verified path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
|
||||
// 重新设置body供后续的ShouldBindJSON使用
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
@@ -274,19 +274,19 @@ func CreemWebhook(c *gin.Context) {
|
||||
// 解析新格式的webhook数据
|
||||
var webhookEvent CreemWebhookEvent
|
||||
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem webhook failed to parse path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem webhook parsed event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
|
||||
|
||||
// 根据事件类型处理不同的webhook
|
||||
switch webhookEvent.EventType {
|
||||
case "checkout.completed":
|
||||
handleCheckoutCompleted(c, &webhookEvent)
|
||||
default:
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 忽略事件 event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem webhook ignored event event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -295,7 +295,7 @@ func CreemWebhook(c *gin.Context) {
|
||||
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
// 验证订单状态
|
||||
if event.Object.Order.Status != "paid" {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem order not paid, ignored request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
// 获取引用ID(这是我们创建订单时传递的request_id)
|
||||
referenceId := event.Object.RequestId
|
||||
if referenceId == "" {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少 request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Creem webhook missing request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -312,34 +312,34 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event), model.PaymentProviderCreem, ""); err == nil {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem subscription order processed trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem subscription order processing failed trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款(充值)
|
||||
if event.Object.Order.Type != "onetime" {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 暂不支持该订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem order type not supported, ignored request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem payment completed callback trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
|
||||
|
||||
// 查询本地订单确认存在
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 充值订单不存在 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Creem top-up order not found trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem top-up order not pending, ignored trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
|
||||
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
|
||||
return
|
||||
}
|
||||
@@ -350,20 +350,20 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
|
||||
// 防护性检查,确保邮箱和姓名不为空字符串
|
||||
if customerEmail == "" {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Creem callback customer email is empty trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
}
|
||||
if customerName == "" {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Creem callback customer name is empty trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
}
|
||||
|
||||
err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Creem top-up processing failed trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Creem top-up succeeded trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -383,14 +383,14 @@ type CreemCheckoutResponse struct {
|
||||
|
||||
func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct, email string, username string) (string, error) {
|
||||
if setting.CreemApiKey == "" {
|
||||
return "", fmt.Errorf("未配置Creem API密钥")
|
||||
return "", errors.New("Creem API key is not configured")
|
||||
}
|
||||
|
||||
// 根据测试模式选择 API 端点
|
||||
apiUrl := "https://api.creem.io/v1/checkouts"
|
||||
if setting.CreemTestMode {
|
||||
apiUrl = "https://test-api.creem.io/v1/checkouts"
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Creem 使用测试环境 api_url=%s", apiUrl))
|
||||
logger.LogInfo(ctx, common.LogText("Creem using test environment api_url=%s", apiUrl))
|
||||
}
|
||||
|
||||
// 构建请求数据,确保包含用户邮箱
|
||||
@@ -413,20 +413,20 @@ func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct
|
||||
// 序列化请求数据
|
||||
jsonData, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化请求数据失败: %v", err)
|
||||
return "", fmt.Errorf("failed to marshal request data: %v", err)
|
||||
}
|
||||
|
||||
// 创建 HTTP 请求
|
||||
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建HTTP请求失败: %v", err)
|
||||
return "", fmt.Errorf("failed to create HTTP request: %v", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", setting.CreemApiKey)
|
||||
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
|
||||
logger.LogInfo(ctx, common.LogText("Creem payment request sent api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{
|
||||
@@ -434,17 +434,17 @@ func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("发送HTTP请求失败: %v", err)
|
||||
return "", fmt.Errorf("failed to send HTTP request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||
return "", fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Creem API 响应已收到 trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
|
||||
logger.LogInfo(ctx, common.LogText("Creem API response received trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode/100 != 2 {
|
||||
@@ -454,13 +454,13 @@ func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct
|
||||
var checkoutResp CreemCheckoutResponse
|
||||
err = json.Unmarshal(body, &checkoutResp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析响应失败: %v", err)
|
||||
return "", fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if checkoutResp.CheckoutUrl == "" {
|
||||
return "", fmt.Errorf("Creem API resp no checkout url ")
|
||||
}
|
||||
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
|
||||
logger.LogInfo(ctx, common.LogText("Creem payment link created trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
|
||||
return checkoutResp.CheckoutUrl, nil
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) {
|
||||
_, err := validateTopUpQuota(maxAmount)
|
||||
require.NoError(t, err)
|
||||
_, err = validateTopUpQuota(maxAmount + 1)
|
||||
require.EqualError(t, err, fmt.Sprintf("单笔充值数量不能大于 %d", maxAmount))
|
||||
require.EqualError(t, err, fmt.Sprintf("Top-up amount cannot exceed %d", maxAmount))
|
||||
}
|
||||
|
||||
func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
|
||||
@@ -120,7 +120,7 @@ func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
|
||||
RequestAmount(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.JSONEq(t, fmt.Sprintf(`{"message":"error","data":"单笔充值数量不能大于 %d"}`, maxAmount), recorder.Body.String())
|
||||
assert.JSONEq(t, fmt.Sprintf(`{"message":"error","data":"Top-up amount cannot exceed %d","message_key":"Top-up amount cannot exceed {{max}}","message_params":{"max":%d}}`, maxAmount, maxAmount), recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
@@ -165,19 +165,19 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
RequestAmount(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.JSONEq(t, `{"message":"error","data":"top-up quota limit exceeded"}`, recorder.Body.String())
|
||||
assert.JSONEq(t, `{"message":"error","data":"Top-up quota limit exceeded","message_key":"Top-up quota limit exceeded"}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) {
|
||||
_, err := validateCreditedQuota(decimal.NewFromInt(int64(common.MaxWalletQuota / 2)))
|
||||
require.NoError(t, err)
|
||||
_, err = validateCreditedQuota(decimal.Zero)
|
||||
require.EqualError(t, err, "充值额度必须大于 0")
|
||||
require.EqualError(t, err, "Top-up quota must be greater than 0")
|
||||
_, err = validateCreditedQuota(decimal.NewFromInt(common.MaxWalletQuota + 1))
|
||||
require.EqualError(
|
||||
t,
|
||||
err,
|
||||
"充值额度超出系统可表示范围",
|
||||
"Top-up quota exceeds the range the system can represent",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+46
-46
@@ -45,17 +45,17 @@ type StripeAdaptor struct {
|
||||
|
||||
func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
|
||||
if req.Amount < getStripeMinTopup() {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
|
||||
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getStripeMinTopup()}))
|
||||
return
|
||||
}
|
||||
if req.Amount > 10000 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值数量不能大于 10000"})
|
||||
paymentError(c, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": 10000}))
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
paymentError(c, common.NewMessage("Failed to get the user group"))
|
||||
return
|
||||
}
|
||||
if rejectInvalidCreditedQuota(c, id, getStripeCreditedQuota(req.Amount, group)) {
|
||||
@@ -63,7 +63,7 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
|
||||
}
|
||||
payMoney := getStripePayMoney(float64(req.Amount), group)
|
||||
if payMoney <= 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
paymentError(c, common.NewMessage("Payment amount is too low"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||
@@ -71,32 +71,32 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
|
||||
|
||||
func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
if req.PaymentMethod != model.PaymentMethodStripe {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||
paymentError(c, common.NewMessage("Unsupported payment channel"))
|
||||
return
|
||||
}
|
||||
if req.Amount < getStripeMinTopup() {
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
|
||||
common.ApiErrorStatus(c, http.StatusOK, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getStripeMinTopup()}), gin.H{"data": 10})
|
||||
return
|
||||
}
|
||||
if req.Amount > 10000 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "充值数量不能大于 10000", "data": 10})
|
||||
common.ApiErrorStatus(c, http.StatusOK, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": 10000}), gin.H{"data": 10})
|
||||
return
|
||||
}
|
||||
|
||||
if req.SuccessURL != "" && common.ValidateRedirectURL(req.SuccessURL) != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "支付成功重定向URL不在可信任域名列表中", "data": ""})
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Payment success redirect URL is not in the trusted domain list"), gin.H{"data": ""})
|
||||
return
|
||||
}
|
||||
|
||||
if req.CancelURL != "" && common.ValidateRedirectURL(req.CancelURL) != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "支付取消重定向URL不在可信任域名列表中", "data": ""})
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Payment cancel redirect URL is not in the trusted domain list"), gin.H{"data": ""})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||
paymentError(c, common.NewMessage("User does not exist"))
|
||||
return
|
||||
}
|
||||
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
|
||||
@@ -111,8 +111,8 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
|
||||
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Stripe failed to create Checkout Session user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,11 +128,11 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Stripe failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to create order"))
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Stripe top-up order created user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
@@ -145,7 +145,7 @@ func RequestStripeAmount(c *gin.Context) {
|
||||
var req StripePayRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
stripeAdaptor.RequestAmount(c, &req)
|
||||
@@ -155,7 +155,7 @@ func RequestStripePay(c *gin.Context) {
|
||||
var req StripePayRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
stripeAdaptor.RequestPay(c, &req)
|
||||
@@ -164,32 +164,32 @@ func RequestStripePay(c *gin.Context) {
|
||||
func StripeWebhook(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
if !isStripeWebhookEnabled() {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogError(ctx, common.LogText("Stripe webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
signature := c.GetHeader("Stripe-Signature")
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload)))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload)))
|
||||
event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{
|
||||
IgnoreAPIVersionMismatch: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe webhook signature verification failed path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
callerIp := c.ClientIP()
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe webhook signature verified event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI))
|
||||
switch event.Type {
|
||||
case stripe.EventTypeCheckoutSessionCompleted:
|
||||
sessionCompleted(ctx, event, callerIp)
|
||||
@@ -200,7 +200,7 @@ func StripeWebhook(c *gin.Context) {
|
||||
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
|
||||
sessionAsyncPaymentFailed(ctx, event, callerIp)
|
||||
default:
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 忽略事件 event_type=%s client_ip=%s", string(event.Type), callerIp))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe webhook ignored event event_type=%s client_ip=%s", string(event.Type), callerIp))
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
@@ -211,13 +211,13 @@ func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string)
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
status := event.GetObjectValue("status")
|
||||
if "complete" != status {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe checkout.completed has unexpected status, ignored trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
paymentStatus := event.GetObjectValue("payment_status")
|
||||
if paymentStatus != "paid" {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe Checkout payment not completed, waiting for async result trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string)
|
||||
func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, callerIp string) {
|
||||
customerId := event.GetObjectValue("customer")
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付成功 trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe async payment succeeded trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
|
||||
fulfillOrder(ctx, event, referenceId, customerId, callerIp)
|
||||
}
|
||||
@@ -238,10 +238,10 @@ func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, calle
|
||||
// ultimately fail (e.g. bank transfer not received, SEPA rejected).
|
||||
func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp string) {
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败 trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe async payment failed trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
|
||||
if len(referenceId) == 0 {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败事件缺少订单号 client_ip=%s", callerIp))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe async payment failed event missing order number client_ip=%s", callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -250,32 +250,32 @@ func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp
|
||||
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe async payment failed but local order not found trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.PaymentProvider != model.PaymentProviderStripe {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但订单支付网关不匹配 trade_no=%s payment_provider=%s client_ip=%s", referenceId, topUp.PaymentProvider, callerIp))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe async payment failed but order payment gateway mismatch trade_no=%s payment_provider=%s client_ip=%s", referenceId, topUp.PaymentProvider, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付失败但订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe async payment failed but order not pending, ignored trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
if err := topUp.Update(); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error()))
|
||||
logger.LogError(ctx, common.LogText("Stripe failed to mark top-up order as failed trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error()))
|
||||
return
|
||||
}
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe top-up order marked as failed trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
}
|
||||
|
||||
// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
|
||||
func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, customerId string, callerIp string) {
|
||||
if len(referenceId) == 0 {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 完成订单时缺少订单号 client_ip=%s", callerIp))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe order completion missing order number client_ip=%s", callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -288,34 +288,34 @@ func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, c
|
||||
"event_type": string(event.Type),
|
||||
}
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload), model.PaymentProviderStripe, ""); err == nil {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe subscription order processed trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp))
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
|
||||
logger.LogError(ctx, common.LogText("Stripe subscription order processing failed trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
err := model.Recharge(referenceId, customerId, callerIp)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
|
||||
logger.LogError(ctx, common.LogText("Stripe top-up processing failed trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64)
|
||||
currency := strings.ToUpper(event.GetObjectValue("currency"))
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe top-up succeeded trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp))
|
||||
}
|
||||
|
||||
func sessionExpired(ctx context.Context, event stripe.Event) {
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
status := event.GetObjectValue("status")
|
||||
if "expired" != status {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s", referenceId, status))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe checkout.expired has unexpected status, ignored trade_no=%s status=%s", referenceId, status))
|
||||
return
|
||||
}
|
||||
|
||||
if len(referenceId) == 0 {
|
||||
logger.LogWarn(ctx, "Stripe checkout.expired 缺少订单号")
|
||||
logger.LogWarn(ctx, common.LogText("Stripe checkout.expired missing order number"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -323,24 +323,24 @@ func sessionExpired(ctx context.Context, event stripe.Event) {
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
if err := model.ExpireSubscriptionOrder(referenceId, model.PaymentProviderStripe); err == nil {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单已过期 trade_no=%s", referenceId))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe subscription order expired trade_no=%s", referenceId))
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
|
||||
logger.LogError(ctx, common.LogText("Stripe subscription order expiry processing failed trade_no=%s error=%q", referenceId, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentProviderStripe, common.TopUpStatusExpired)
|
||||
if errors.Is(err, model.ErrTopUpNotFound) {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 充值订单不存在,无法标记过期 trade_no=%s", referenceId))
|
||||
logger.LogWarn(ctx, common.LogText("Stripe top-up order not found, cannot mark as expired trade_no=%s", referenceId))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 充值订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
|
||||
logger.LogError(ctx, common.LogText("Stripe top-up order expiry processing failed trade_no=%s error=%q", referenceId, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已过期 trade_no=%s", referenceId))
|
||||
logger.LogInfo(ctx, common.LogText("Stripe top-up order expired trade_no=%s", referenceId))
|
||||
}
|
||||
|
||||
// genStripeLink generates a Stripe Checkout session URL for payment.
|
||||
@@ -357,7 +357,7 @@ func sessionExpired(ctx context.Context, event stripe.Event) {
|
||||
// Returns the checkout session URL or an error if the session creation fails.
|
||||
func genStripeLink(referenceId string, customerId string, email string, amount int64, successURL string, cancelURL string) (string, error) {
|
||||
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
|
||||
return "", fmt.Errorf("无效的Stripe API密钥")
|
||||
return "", errors.New("invalid Stripe API key")
|
||||
}
|
||||
|
||||
stripe.Key = setting.StripeApiSecret
|
||||
|
||||
+35
-35
@@ -114,13 +114,13 @@ type WaffoPayRequest struct {
|
||||
func RequestWaffoAmount(c *gin.Context) {
|
||||
var req WaffoPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
waffoMinTopup := int64(setting.WaffoMinTopUp)
|
||||
if req.Amount < waffoMinTopup {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": waffoMinTopup}))
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
@@ -130,13 +130,13 @@ func RequestWaffoAmount(c *gin.Context) {
|
||||
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
paymentError(c, common.NewMessage("Failed to get the user group"))
|
||||
return
|
||||
}
|
||||
|
||||
payMoney := getWaffoPayMoney(float64(req.Amount), group)
|
||||
if payMoney <= 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
paymentError(c, common.NewMessage("Payment amount is too low"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,18 +146,18 @@ func RequestWaffoAmount(c *gin.Context) {
|
||||
// RequestWaffoPay 创建 Waffo 支付订单
|
||||
func RequestWaffoPay(c *gin.Context) {
|
||||
if !setting.WaffoEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo 支付未启用"})
|
||||
paymentError(c, common.NewMessage("Waffo payment is not enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
var req WaffoPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
waffoMinTopup := int64(setting.WaffoMinTopUp)
|
||||
if req.Amount < waffoMinTopup {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": waffoMinTopup}))
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
@@ -167,7 +167,7 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||
paymentError(c, common.NewMessage("User does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
// 新协议:按索引查找
|
||||
idx := *req.PayMethodIndex
|
||||
if idx < 0 || idx >= len(methods) {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods)))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Waffo invalid payment method index user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods)))
|
||||
paymentError(c, common.NewMessage("Unsupported payment method"))
|
||||
return
|
||||
}
|
||||
resolvedPayMethodType = methods[idx].PayMethodType
|
||||
@@ -196,8 +196,8 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q", id, req.PayMethodType, req.PayMethodName))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Waffo invalid payment method user_id=%d pay_method_type=%s pay_method_name=%q", id, req.PayMethodType, req.PayMethodName))
|
||||
paymentError(c, common.NewMessage("Unsupported payment method"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -206,7 +206,7 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
group, _ := model.GetUserGroup(id, true)
|
||||
payMoney := getWaffoPayMoney(float64(req.Amount), group)
|
||||
if payMoney < 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
paymentError(c, common.NewMessage("Payment amount is too low"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -232,17 +232,17 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := topUp.Insert(); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, merchantOrderId, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q", id, merchantOrderId, req.Amount, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to create order"))
|
||||
return
|
||||
}
|
||||
|
||||
sdk, err := getWaffoSDK()
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo SDK initialization failed user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付配置错误"})
|
||||
paymentError(c, common.NewMessage("Payment configuration error"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -285,22 +285,22 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
}
|
||||
resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建订单失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo failed to create order user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
if !resp.IsSuccess() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q", id, merchantOrderId, resp.Code, resp.Message, common.GetJsonString(resp)))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Waffo order creation rejected user_id=%d trade_no=%s code=%s message=%q response=%q", id, merchantOrderId, resp.Code, resp.Message, common.GetJsonString(resp)))
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
|
||||
orderData := resp.GetData()
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo top-up order created user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName))
|
||||
|
||||
paymentUrl := orderData.FetchRedirectURL()
|
||||
if paymentUrl == "" {
|
||||
@@ -335,21 +335,21 @@ type webhookSubscriptionInfo struct {
|
||||
// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
|
||||
func WaffoWebhook(c *gin.Context) {
|
||||
if !isWaffoWebhookEnabled() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Waffo webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sdk, err := getWaffoSDK()
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo webhook SDK initialization failed path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -357,18 +357,18 @@ func WaffoWebhook(c *gin.Context) {
|
||||
wh := sdk.Webhook()
|
||||
bodyStr := string(bodyBytes)
|
||||
signature := c.GetHeader("X-SIGNATURE")
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
|
||||
|
||||
// 验证请求签名
|
||||
if !wh.VerifySignature(bodyStr, signature) {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Waffo webhook signature verification failed path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var event core.WebhookEvent
|
||||
if err := common.Unmarshal(bodyBytes, &event); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), bodyStr))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo webhook failed to parse path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), bodyStr))
|
||||
sendWaffoWebhookResponse(c, wh, false, "invalid payload")
|
||||
return
|
||||
}
|
||||
@@ -378,14 +378,14 @@ func WaffoWebhook(c *gin.Context) {
|
||||
// 解析为扩展类型,区分普通支付和订阅支付
|
||||
var payload webhookPayloadWithSubInfo
|
||||
if err := common.Unmarshal(bodyBytes, &payload); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q", event.EventType, c.ClientIP(), err.Error(), bodyStr))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo failed to parse payment callback payload event_type=%s client_ip=%s error=%q body=%q", event.EventType, c.ClientIP(), err.Error(), bodyStr))
|
||||
sendWaffoWebhookResponse(c, wh, false, "invalid payment payload")
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s", event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo webhook verified and parsed event_type=%s merchant_order_id=%s order_status=%s client_ip=%s", event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus, c.ClientIP()))
|
||||
handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
|
||||
default:
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 忽略事件 event_type=%s client_ip=%s", event.EventType, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo webhook ignored event event_type=%s client_ip=%s", event.EventType, c.ClientIP()))
|
||||
sendWaffoWebhookResponse(c, wh, true, "")
|
||||
}
|
||||
}
|
||||
@@ -393,13 +393,13 @@ func WaffoWebhook(c *gin.Context) {
|
||||
// handleWaffoPayment 处理支付完成通知
|
||||
func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) {
|
||||
if result.OrderStatus != "PAY_SUCCESS" {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s", result.MerchantOrderID, result.OrderStatus, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo order not successful, top-up skipped trade_no=%s order_status=%s client_ip=%s", result.MerchantOrderID, result.OrderStatus, c.ClientIP()))
|
||||
// 终态失败订单标记为 failed,避免永远停在 pending
|
||||
if result.MerchantOrderID != "" {
|
||||
if err := model.UpdatePendingTopUpStatus(result.MerchantOrderID, model.PaymentProviderWaffo, common.TopUpStatusFailed); err != nil &&
|
||||
!errors.Is(err, model.ErrTopUpNotFound) &&
|
||||
!errors.Is(err, model.ErrTopUpStatusInvalid) {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 标记失败订单状态失败 trade_no=%s error=%q", result.MerchantOrderID, err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo failed to mark order as failed trade_no=%s error=%q", result.MerchantOrderID, err.Error()))
|
||||
}
|
||||
}
|
||||
sendWaffoWebhookResponse(c, wh, true, "")
|
||||
@@ -412,12 +412,12 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa
|
||||
defer UnlockOrder(merchantOrderId)
|
||||
|
||||
if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo top-up processing failed trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error()))
|
||||
sendWaffoWebhookResponse(c, wh, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值成功 trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo top-up succeeded trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP()))
|
||||
sendWaffoWebhookResponse(c, wh, true, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ type WaffoPancakePayRequest struct {
|
||||
func RequestWaffoPancakeAmount(c *gin.Context) {
|
||||
var req WaffoPancakePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
||||
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": setting.WaffoPancakeMinTopUp}))
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
@@ -40,13 +40,13 @@ func RequestWaffoPancakeAmount(c *gin.Context) {
|
||||
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
paymentError(c, common.NewMessage("Failed to get the user group"))
|
||||
return
|
||||
}
|
||||
|
||||
payMoney := getWaffoPancakePayMoney(req.Amount, group)
|
||||
if payMoney <= 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
paymentError(c, common.NewMessage("Payment amount is too low"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ type createWaffoPancakePairRequest struct {
|
||||
func SaveWaffoPancake(c *gin.Context) {
|
||||
var req saveWaffoPancakeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
if err := service.SaveWaffoPancakeConfig(
|
||||
@@ -136,11 +136,11 @@ func SaveWaffoPancake(c *gin.Context) {
|
||||
req.StoreID,
|
||||
req.ProductID,
|
||||
); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q",
|
||||
logger.LogError(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake failed to save config store_id=%q product_id=%q error=%q",
|
||||
req.StoreID, req.ProductID, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "保存配置失败"})
|
||||
paymentError(c, common.NewMessage("Failed to save configuration"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -172,13 +172,13 @@ func CreateWaffoPancakePair(c *gin.Context) {
|
||||
var req createWaffoPancakePairRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
}
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
|
||||
if merchantID == "" || privateKey == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
|
||||
paymentError(c, common.NewMessage("Waffo Pancake credentials are not configured"))
|
||||
return
|
||||
}
|
||||
result, err := service.CreateWaffoPancakePrimaryPair(
|
||||
@@ -186,8 +186,8 @@ func CreateWaffoPancakePair(c *gin.Context) {
|
||||
)
|
||||
if err != nil {
|
||||
orphan := result != nil && result.OrphanStore
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q",
|
||||
logger.LogError(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake failed to create store and product orphan_store=%t store_id=%q error=%q",
|
||||
orphan, func() string {
|
||||
if result == nil {
|
||||
return ""
|
||||
@@ -225,15 +225,15 @@ func ListWaffoPancakeCatalog(c *gin.Context) {
|
||||
strings.TrimSpace(c.Query("private_key")),
|
||||
)
|
||||
if merchantID == "" || privateKey == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
|
||||
paymentError(c, common.NewMessage("Waffo Pancake credentials are not configured"))
|
||||
return
|
||||
}
|
||||
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 拉取店铺与产品目录失败 error=%q", err.Error(),
|
||||
logger.LogError(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake failed to fetch store and product catalog error=%q", err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取目录失败"})
|
||||
paymentError(c, common.NewMessage("Failed to fetch the catalog"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": catalog})
|
||||
@@ -253,22 +253,22 @@ func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) {
|
||||
var req createWaffoPancakeSubscriptionProductRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐名称不能为空"})
|
||||
paymentError(c, common.NewMessage("Plan name cannot be empty"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Amount) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐价格不能为空"})
|
||||
paymentError(c, common.NewMessage("Plan price cannot be empty"))
|
||||
return
|
||||
}
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
|
||||
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
|
||||
if merchantID == "" || privateKey == "" || storeID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
|
||||
paymentError(c, common.NewMessage("Waffo Pancake is not fully configured. Complete the gateway binding in payment settings first."))
|
||||
return
|
||||
}
|
||||
productID, err := service.CreateWaffoPancakeProductForPlan(
|
||||
@@ -281,11 +281,11 @@ func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) {
|
||||
setting.WaffoPancakeReturnURL,
|
||||
)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q",
|
||||
logger.LogError(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake failed to create plan product store_id=%q name=%q amount=%q error=%q",
|
||||
storeID, req.Name, req.Amount, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建套餐产品失败"})
|
||||
paymentError(c, common.NewMessage("Failed to create the plan product"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -305,15 +305,15 @@ func ListWaffoPancakeSubscriptionProductOptions(c *gin.Context) {
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
|
||||
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
|
||||
if merchantID == "" || privateKey == "" || storeID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
|
||||
paymentError(c, common.NewMessage("Waffo Pancake is not fully configured. Complete the gateway binding in payment settings first."))
|
||||
return
|
||||
}
|
||||
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q", storeID, err.Error(),
|
||||
logger.LogError(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake failed to fetch subscription product list store_id=%q error=%q", storeID, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取产品列表失败"})
|
||||
paymentError(c, common.NewMessage("Failed to fetch the product list"))
|
||||
return
|
||||
}
|
||||
products := []service.WaffoPancakeCatalogProduct{}
|
||||
@@ -341,17 +341,17 @@ func getWaffoPancakeBuyerIdentity(user *model.User) string {
|
||||
|
||||
func RequestWaffoPancakePay(c *gin.Context) {
|
||||
if !isWaffoPancakeTopUpEnabled() {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
|
||||
paymentError(c, common.NewMessage("Waffo Pancake configuration is incomplete"))
|
||||
return
|
||||
}
|
||||
|
||||
var req WaffoPancakePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
paymentError(c, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
||||
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": setting.WaffoPancakeMinTopUp}))
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
@@ -361,19 +361,19 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||
paymentError(c, common.NewMessage("User does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
paymentError(c, common.NewMessage("Failed to get the user group"))
|
||||
return
|
||||
}
|
||||
|
||||
payMoney := getWaffoPancakePayMoney(req.Amount, group)
|
||||
if payMoney < 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
paymentError(c, common.NewMessage("Payment amount is too low"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -389,8 +389,8 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := topUp.Insert(); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
|
||||
paymentError(c, common.NewMessage("Failed to create order"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -407,13 +407,13 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
OrderMerchantExternalID: tradeNo,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create checkout session user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
paymentError(c, common.NewMessage("Failed to start payment"))
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake top-up order created user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
@@ -430,7 +430,7 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
|
||||
func WaffoPancakeWebhook(c *gin.Context) {
|
||||
if !isWaffoPancakeWebhookEnabled() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Waffo Pancake webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
c.String(http.StatusForbidden, "webhook disabled")
|
||||
return
|
||||
}
|
||||
@@ -440,8 +440,8 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
// We then enforce event.mode == expectedEnv to catch mis-registrations.
|
||||
expectedEnv := strings.TrimSpace(c.Param("env"))
|
||||
if expectedEnv != "test" && expectedEnv != "prod" {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s",
|
||||
logger.LogWarn(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake webhook invalid env path segment env=%q path=%q client_ip=%s",
|
||||
expectedEnv, c.Request.RequestURI, c.ClientIP(),
|
||||
))
|
||||
c.String(http.StatusNotFound, "unknown env")
|
||||
@@ -450,31 +450,31 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.String(http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
signature := c.GetHeader("X-Waffo-Signature")
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
|
||||
event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature)
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Waffo Pancake webhook signature verification failed path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
|
||||
c.String(http.StatusUnauthorized, "invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(strings.TrimSpace(event.Mode), expectedEnv) {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
|
||||
logger.LogError(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake webhook env mismatch expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
|
||||
expectedEnv, event.Mode, event.ID, event.Data.OrderID, c.ClientIP(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake webhook signature verified event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
if event.NormalizedEventType() != "order.completed" {
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
@@ -488,8 +488,8 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
if isSubscription {
|
||||
tradeNo, err := service.ResolveWaffoPancakeSubscriptionTradeNo(event)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
logger.LogError(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake webhook failed to resolve subscription order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
@@ -498,11 +498,11 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
LockOrder(tradeNo)
|
||||
defer UnlockOrder(tradeNo)
|
||||
if err := model.CompleteSubscriptionOrder(tradeNo, string(bodyBytes), model.PaymentProviderWaffoPancake, ""); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake subscription completion failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
||||
c.String(http.StatusInternalServerError, "retry")
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake subscription completed trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
@@ -512,8 +512,8 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
// LogError (not LogWarn): covers order-not-found and buyer-identity
|
||||
// mismatch — both warrant human attention. 200 OK so Waffo doesn't
|
||||
// retry a permanently-unresolvable webhook.
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
logger.LogError(c.Request.Context(), common.LogText(
|
||||
"Waffo Pancake webhook failed to resolve order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
@@ -524,11 +524,11 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
defer UnlockOrder(tradeNo)
|
||||
|
||||
if err := model.RechargeWaffoPancake(tradeNo); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake top-up processing failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
||||
c.String(http.StatusInternalServerError, "retry")
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake top-up succeeded trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
c.String(http.StatusOK, "OK")
|
||||
}
|
||||
+13
-40
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -43,7 +42,7 @@ func Enable2FA(c *gin.Context) {
|
||||
}
|
||||
var req Verify2FARequest
|
||||
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if err := service.FinishTwoFASetup(identity, req.FlowToken, req.Code); err != nil {
|
||||
@@ -79,11 +78,7 @@ func Disable2FA(c *gin.Context) {
|
||||
// 记录操作日志
|
||||
recordUserSecurityAudit(c, userId, "user.2fa_disable_self", nil)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "两步验证已禁用",
|
||||
"data": authRotationData(bundle),
|
||||
})
|
||||
common.ApiSuccessT(c, "Two-factor authentication disabled", authRotationData(bundle))
|
||||
}
|
||||
|
||||
// Get2FAStatus 获取用户2FA状态
|
||||
@@ -108,7 +103,7 @@ func Get2FAStatus(c *gin.Context) {
|
||||
// 获取剩余备用码数量
|
||||
backupCount, err := model.GetUnusedBackupCodeCount(userId)
|
||||
if err != nil {
|
||||
common.SysLog("获取备用码数量失败: " + err.Error())
|
||||
common.SysLog(common.LogText("failed to count backup codes: %s", err.Error()))
|
||||
} else {
|
||||
status["backup_codes_remaining"] = backupCount
|
||||
}
|
||||
@@ -132,21 +127,15 @@ func RegenerateBackupCodes(c *gin.Context) {
|
||||
// 生成新的备用码
|
||||
backupCodes, err := common.GenerateBackupCodes()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "生成备用码失败",
|
||||
})
|
||||
common.SysLog("生成备用码失败: " + err.Error())
|
||||
common.ApiErrorT(c, "Failed to regenerate backup codes")
|
||||
common.SysLog(common.LogText("failed to generate backup codes: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 保存新的备用码并原子推进用户鉴权版本
|
||||
if err := model.ReplaceBackupCodesForSession(identity, backupCodes); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "保存备用码失败",
|
||||
})
|
||||
common.SysLog("保存备用码失败: " + err.Error())
|
||||
common.ApiErrorT(c, "Failed to save backup codes")
|
||||
common.SysLog(common.LogText("failed to save backup codes: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_backup_codes_regenerated")
|
||||
@@ -160,11 +149,7 @@ func RegenerateBackupCodes(c *gin.Context) {
|
||||
|
||||
data := authRotationData(bundle)
|
||||
data["backup_codes"] = backupCodes
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "备用码重新生成成功",
|
||||
"data": data,
|
||||
})
|
||||
common.ApiSuccessT(c, "Backup codes regenerated successfully", data)
|
||||
}
|
||||
|
||||
// Verify2FALogin 登录时验证2FA
|
||||
@@ -192,17 +177,14 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
userIdStr := c.Param("id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil || userId <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户ID格式错误",
|
||||
})
|
||||
common.ApiErrorT(c, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标用户权限
|
||||
targetUser, err := model.GetUserById(userId, false)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
|
||||
common.ApiErrorT(c, "User does not exist")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -212,10 +194,7 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权操作同级或更高级用户的2FA设置",
|
||||
})
|
||||
common.ApiErrorT(c, "No permission to change the 2FA settings of users at the same or higher level")
|
||||
return
|
||||
}
|
||||
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserTwoFADisable, service.AdminUserContext{UserID: userId})
|
||||
@@ -226,10 +205,7 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
// 禁用2FA
|
||||
if err := model.DisableTwoFAWithAuthVersion(userId); err != nil {
|
||||
if errors.Is(err, model.ErrTwoFANotEnabled) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户未启用2FA",
|
||||
})
|
||||
common.ApiErrorT(c, "Two-factor authentication is not enabled.")
|
||||
return
|
||||
}
|
||||
writeSecurityOperationError(c, err)
|
||||
@@ -245,8 +221,5 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
"verification_method": authorization.Method,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "用户2FA已被强制禁用",
|
||||
})
|
||||
common.ApiSuccessT(c, "Two-factor authentication reset", nil)
|
||||
}
|
||||
@@ -66,10 +66,7 @@ func GetUserQuotaDates(c *gin.Context) {
|
||||
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
||||
// 判断时间跨度是否超过 1 个月
|
||||
if endTimestamp-startTimestamp > 2592000 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "时间跨度不能超过 1 个月",
|
||||
})
|
||||
common.ApiErrorT(c, "The time range cannot exceed 1 month")
|
||||
return
|
||||
}
|
||||
dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp)
|
||||
@@ -111,10 +108,7 @@ func GetUserFlowQuotaDates(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if endTimestamp-startTimestamp > 2592000 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "时间跨度不能超过 1 个月",
|
||||
})
|
||||
common.ApiErrorT(c, "The time range cannot exceed 1 month")
|
||||
return
|
||||
}
|
||||
dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, "", userId, common.RoleCommonUser)
|
||||
|
||||
+94
-95
@@ -40,7 +40,7 @@ func GetPasswordEncryptionKey(c *gin.Context) {
|
||||
}
|
||||
keyID, publicKey := common.PasswordEncryptionPublicKey()
|
||||
if keyID == "" || publicKey == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
|
||||
common.ApiErrorT(c, "Database error, please contact the administrator")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
@@ -52,30 +52,30 @@ func GetPasswordEncryptionKey(c *gin.Context) {
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
if !common.PasswordLoginEnabled {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled)
|
||||
common.ApiErrorT(c, "Password login has been disabled by administrator")
|
||||
return
|
||||
}
|
||||
var loginRequest LoginRequest
|
||||
err := common.DecodeJson(c.Request.Body, &loginRequest)
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
username := loginRequest.Username
|
||||
password := loginRequest.Password
|
||||
if common.PasswordLoginEncryptionEnabled {
|
||||
if loginRequest.PasswordEncrypted == "" || loginRequest.EncryptionKeyID == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
password, err = common.DecryptPassword(loginRequest.PasswordEncrypted, loginRequest.EncryptionKeyID)
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
|
||||
common.ApiErrorT(c, "Username or password is incorrect, or user has been banned")
|
||||
return
|
||||
}
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
@@ -86,12 +86,12 @@ func Login(c *gin.Context) {
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrDatabase):
|
||||
common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err))
|
||||
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
|
||||
common.SysLog(common.LogText("Login database error for user %s: %v", username, err))
|
||||
common.ApiErrorT(c, "Database error, please contact the administrator")
|
||||
case errors.Is(err, model.ErrUserEmptyCredentials):
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
default:
|
||||
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
|
||||
common.ApiErrorT(c, "Username or password is incorrect, or user has been banned")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func setupLogin(user *model.User, migration *service.LegacyGitHubMigration, c *g
|
||||
|
||||
func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin.Context) {
|
||||
if user == nil || user.Id <= 0 || user.Status != common.UserStatusEnabled {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthUserBanned)
|
||||
common.ApiErrorT(c, "User has been banned")
|
||||
return
|
||||
}
|
||||
currentUser, err := model.GetSelfUserById(user.Id)
|
||||
@@ -216,44 +216,44 @@ func writeLoginResponse(c *gin.Context, user *model.User, bundle *service.AuthBu
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
if !common.RegisterEnabled {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
|
||||
common.ApiErrorT(c, "New user registration has been disabled by administrator")
|
||||
return
|
||||
}
|
||||
if !common.PasswordRegisterEnabled {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPasswordRegisterDisabled)
|
||||
common.ApiErrorT(c, "Password registration has been disabled by administrator, please use third-party account verification")
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
err := common.DecodeJson(c.Request.Body, &user)
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
user.Username = strings.TrimSpace(user.Username)
|
||||
user.Email = model.NormalizeEmail(user.Email)
|
||||
if user.Username == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if err := common.Validate.Struct(&user); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
|
||||
common.ApiErrorT(c, "Invalid input {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if common.EmailVerificationEnabled {
|
||||
if user.Email == "" || user.VerificationCode == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserEmailVerificationRequired)
|
||||
common.ApiErrorT(c, "Email verification is enabled, please enter email address and verification code")
|
||||
return
|
||||
}
|
||||
if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
|
||||
common.ApiErrorT(c, "Verification code is incorrect or has expired")
|
||||
return
|
||||
}
|
||||
if err := model.EnsureEmailAvailable(user.Email, 0); err != nil {
|
||||
if errors.Is(err, model.ErrEmailAlreadyTaken) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
|
||||
common.ApiErrorT(c, "Email address is already in use")
|
||||
return
|
||||
}
|
||||
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
|
||||
common.ApiErrorT(c, "Database error, please contact the administrator")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -263,12 +263,12 @@ func Register(c *gin.Context) {
|
||||
}
|
||||
exist, err := model.CheckUserExistOrDeleted(user.Username, emailForExistCheck)
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
|
||||
common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
|
||||
common.ApiErrorT(c, "Database error, please contact the administrator")
|
||||
common.SysLog(common.LogText("CheckUserExistOrDeleted error: %v", err))
|
||||
return
|
||||
}
|
||||
if exist {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserExists)
|
||||
common.ApiErrorT(c, "Username already exists or has been deleted")
|
||||
return
|
||||
}
|
||||
affCode := user.AffCode // this code is the inviter's code, not the user's own code
|
||||
@@ -285,7 +285,7 @@ func Register(c *gin.Context) {
|
||||
}
|
||||
if err := cleanUser.Insert(inviterId); err != nil {
|
||||
if errors.Is(err, model.ErrEmailAlreadyTaken) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
|
||||
common.ApiErrorT(c, "Email address is already in use")
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
@@ -295,21 +295,21 @@ func Register(c *gin.Context) {
|
||||
// 获取插入后的用户ID
|
||||
var insertedUser model.User
|
||||
if err := model.DB.Where("username = ?", cleanUser.Username).First(&insertedUser).Error; err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed)
|
||||
common.ApiErrorT(c, "User registration failed or user ID retrieval failed")
|
||||
return
|
||||
}
|
||||
// 生成默认令牌
|
||||
if constant.GenerateDefaultToken {
|
||||
key, err := common.GenerateKey()
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserDefaultTokenFailed)
|
||||
common.SysLog("failed to generate token key: " + err.Error())
|
||||
common.ApiErrorT(c, "Failed to generate default token")
|
||||
common.SysLog(common.LogText("failed to generate token key: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
// 生成默认令牌
|
||||
token := model.Token{
|
||||
UserId: insertedUser.Id, // 使用插入后的用户ID
|
||||
Name: cleanUser.Username + "的初始令牌",
|
||||
Name: i18n.T(c, i18n.MsgUserDefaultTokenName, map[string]any{"Username": cleanUser.Username}),
|
||||
Key: key,
|
||||
CreatedTime: common.GetTimestamp(),
|
||||
AccessedTime: common.GetTimestamp(),
|
||||
@@ -322,7 +322,7 @@ func Register(c *gin.Context) {
|
||||
token.Group = "auto"
|
||||
}
|
||||
if err := token.Insert(); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgCreateDefaultTokenErr)
|
||||
common.ApiErrorT(c, "Failed to create default token")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -396,7 +396,7 @@ func GetUser(c *gin.Context) {
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
common.ApiErrorT(c, "No permission to access users of same or higher level")
|
||||
return
|
||||
}
|
||||
user.AdminPermissions = authz.Capabilities(user.Id, user.Role)
|
||||
@@ -429,11 +429,16 @@ func TransferAffQuota(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
err = user.TransferAffQuotaToQuota(tran.Quota)
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserTransferFailed, map[string]any{"Error": err.Error()})
|
||||
var message *common.Message
|
||||
if errors.As(err, &message) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil)
|
||||
if err != nil {
|
||||
common.ApiErrorT(c, "Transfer failed {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
common.ApiSuccessT(c, "Transfer successful", nil)
|
||||
}
|
||||
|
||||
func GetAffCode(c *gin.Context) {
|
||||
@@ -446,10 +451,7 @@ func GetAffCode(c *gin.Context) {
|
||||
if user.AffCode == "" {
|
||||
user.AffCode = common.GetRandomString(4)
|
||||
if err := user.Update(false); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -605,7 +607,7 @@ func generateDefaultSidebarConfig(userRole int) string {
|
||||
// 转换为JSON字符串
|
||||
configBytes, err := common.Marshal(defaultConfig)
|
||||
if err != nil {
|
||||
common.SysLog("生成默认边栏配置失败: " + err.Error())
|
||||
common.SysLog(common.LogText("failed to generate the default sidebar config: %s", err.Error()))
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -650,16 +652,16 @@ func UpdateUser(c *gin.Context) {
|
||||
var updatedUser model.User
|
||||
err := common.DecodeJson(c.Request.Body, &updatedUser)
|
||||
if err != nil || updatedUser.Id == 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
updatedUser.Username = strings.TrimSpace(updatedUser.Username)
|
||||
if updatedUser.Username == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if err := common.Validate.StructExcept(&updatedUser, "Password"); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
|
||||
common.ApiErrorT(c, "Invalid input {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
originUser, err := model.GetUserById(updatedUser.Id, false)
|
||||
@@ -668,13 +670,13 @@ func UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if updatedUser.Role != common.RoleGuestUser && updatedUser.Role != originUser.Role {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
updatedUser.Role = originUser.Role
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, originUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
common.ApiErrorT(c, "No permission to update users of same or higher permission level")
|
||||
return
|
||||
}
|
||||
updatePassword := updatedUser.Password != ""
|
||||
@@ -732,13 +734,13 @@ func UpdateUser(c *gin.Context) {
|
||||
func AdminClearUserBinding(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
bindingType := strings.ToLower(strings.TrimSpace(c.Param("binding_type")))
|
||||
if bindingType == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -750,7 +752,7 @@ func AdminClearUserBinding(c *gin.Context) {
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
common.ApiErrorT(c, "No permission to access users of same or higher level")
|
||||
return
|
||||
}
|
||||
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserBindingClear, service.AdminUserBindingContext{UserID: user.Id, BindingType: bindingType})
|
||||
@@ -778,7 +780,7 @@ func AdminClearUserBinding(c *gin.Context) {
|
||||
func UpdateSelf(c *gin.Context) {
|
||||
var requestData map[string]any
|
||||
if err := common.DecodeJson(c.Request.Body, &requestData); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -811,11 +813,11 @@ func UpdateSelf(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := model.UpdateUserSetting(user.Id, currentSetting); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
|
||||
common.ApiErrorT(c, "Update failed")
|
||||
return
|
||||
}
|
||||
|
||||
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
|
||||
common.ApiSuccessT(c, "Update successful", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -837,11 +839,11 @@ func UpdateSelf(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := model.UpdateUserSetting(user.Id, currentSetting); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
|
||||
common.ApiErrorT(c, "Update failed")
|
||||
return
|
||||
}
|
||||
|
||||
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
|
||||
common.ApiSuccessT(c, "Update successful", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -849,16 +851,16 @@ func UpdateSelf(c *gin.Context) {
|
||||
var user model.User
|
||||
requestDataBytes, err := common.Marshal(requestData)
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if err = common.Unmarshal(requestDataBytes, &user); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.Validate.StructExcept(&user, "Password"); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidInput)
|
||||
common.ApiErrorT(c, "Invalid input")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -935,7 +937,7 @@ func DeleteUser(c *gin.Context) {
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= originUser.Role {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
common.ApiErrorT(c, "No permission to update users of same or higher permission level")
|
||||
return
|
||||
}
|
||||
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserDelete, service.AdminUserContext{UserID: originUser.Id})
|
||||
@@ -978,7 +980,7 @@ func DeleteSelf(c *gin.Context) {
|
||||
var err error
|
||||
if revokedAccessTokens, err = model.DeleteUserForSession(identity); err != nil {
|
||||
if errors.Is(err, model.ErrCannotDeleteRootUser) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
|
||||
common.ApiErrorT(c, "Cannot delete super administrator account")
|
||||
return
|
||||
}
|
||||
writeSecurityOperationError(c, err)
|
||||
@@ -998,15 +1000,15 @@ func CreateUser(c *gin.Context) {
|
||||
err := common.DecodeJson(c.Request.Body, &user)
|
||||
user.Username = strings.TrimSpace(user.Username)
|
||||
if err != nil || user.Username == "" || user.Password == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if err := common.Validate.Struct(&user); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
|
||||
common.ApiErrorT(c, "Invalid input {{error}}", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !common.IsValidateRole(user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if user.DisplayName == "" {
|
||||
@@ -1014,7 +1016,7 @@ func CreateUser(c *gin.Context) {
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if user.Role >= myRole {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
|
||||
common.ApiErrorT(c, "Cannot create users with permission level equal to or higher than yourself")
|
||||
return
|
||||
}
|
||||
auditParams := map[string]any{"role": user.Role}
|
||||
@@ -1091,7 +1093,7 @@ func ManageUser(c *gin.Context) {
|
||||
err := common.DecodeJson(c.Request.Body, &req)
|
||||
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
if req.Action == "add_quota" {
|
||||
@@ -1104,26 +1106,26 @@ func ManageUser(c *gin.Context) {
|
||||
// Fill attributes
|
||||
model.DB.Unscoped().Where(&user).First(&user)
|
||||
if user.Id == 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
|
||||
common.ApiErrorT(c, "User does not exist")
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
common.ApiErrorT(c, "No permission to update users of same or higher permission level")
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "disable":
|
||||
user.Status = common.UserStatusDisabled
|
||||
if user.Role == common.RoleRootUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotDisableRootUser)
|
||||
common.ApiErrorT(c, "Cannot disable super administrator user")
|
||||
return
|
||||
}
|
||||
case "enable":
|
||||
user.Status = common.UserStatusEnabled
|
||||
case "delete":
|
||||
if user.Role == common.RoleRootUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
|
||||
common.ApiErrorT(c, "Cannot delete super administrator account")
|
||||
return
|
||||
}
|
||||
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserDelete, service.AdminUserContext{UserID: user.Id})
|
||||
@@ -1132,16 +1134,13 @@ func ManageUser(c *gin.Context) {
|
||||
}
|
||||
revokedAccessTokens, err := user.Delete()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// 删除用户后,强制清理 Redis 中所有该用户令牌的缓存,
|
||||
// 避免已缓存的令牌在 TTL 过期前仍能通过 TokenAuth 校验。
|
||||
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
|
||||
common.SysLog(common.LogText("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
|
||||
}
|
||||
recordManageAuditFor(c, user.Id, "user.manage", map[string]any{
|
||||
"action": req.Action,
|
||||
@@ -1157,26 +1156,26 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
case "promote":
|
||||
if myRole != common.RoleRootUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
|
||||
common.ApiErrorT(c, "Regular administrators cannot promote other users to administrator")
|
||||
return
|
||||
}
|
||||
if user.Role >= common.RoleAdminUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserAlreadyAdmin)
|
||||
common.ApiErrorT(c, "This user is already an administrator")
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleAdminUser
|
||||
case "demote":
|
||||
if user.Role == common.RoleRootUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotDemoteRootUser)
|
||||
common.ApiErrorT(c, "Cannot demote super administrator user")
|
||||
return
|
||||
}
|
||||
if user.Role == common.RoleCommonUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserAlreadyCommon)
|
||||
common.ApiErrorT(c, "This user is already a common user")
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleCommonUser
|
||||
default:
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1217,7 +1216,7 @@ func ManageUser(c *gin.Context) {
|
||||
// explicit invalidation; deleting the user hash here would discard the
|
||||
// freshly published auth-version floor.
|
||||
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
|
||||
common.SysLog(common.LogText("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
|
||||
}
|
||||
recordManageAuditFor(c, user.Id, "user.manage", map[string]any{
|
||||
"action": req.Action,
|
||||
@@ -1284,14 +1283,14 @@ func getTopUpLock(userID int) *topUpTryLock {
|
||||
|
||||
func TopUp(c *gin.Context) {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
common.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
lock := getTopUpLock(id)
|
||||
if !lock.TryLock() {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserTopUpProcessing)
|
||||
common.ApiErrorT(c, "Top-up is processing, please try again later")
|
||||
return
|
||||
}
|
||||
defer lock.Unlock()
|
||||
@@ -1304,8 +1303,8 @@ func TopUp(c *gin.Context) {
|
||||
quota, err := model.Redeem(req.Key, id)
|
||||
if err != nil {
|
||||
// 不向用户暴露兑换失败的细分原因,避免攻击者根据错误类型判断兑换码状态。
|
||||
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
|
||||
logger.LogError(c, fmt.Sprintf("failed to redeem key %s for user %d: %s", req.Key, id, err.Error()))
|
||||
common.ApiErrorT(c, "Redemption failed, please try again later")
|
||||
logger.LogError(c, common.LogText("failed to redeem key %s for user %d: %s", req.Key, id, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -1333,31 +1332,31 @@ type UpdateUserSettingRequest struct {
|
||||
func UpdateUserSetting(c *gin.Context) {
|
||||
var req UpdateUserSettingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证预警类型
|
||||
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingInvalidType)
|
||||
common.ApiErrorT(c, "Invalid warning type")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证预警阈值
|
||||
if req.QuotaWarningThreshold <= 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgQuotaThresholdGtZero)
|
||||
common.ApiErrorT(c, "Warning threshold must be greater than 0")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是webhook类型,验证webhook地址
|
||||
if req.QuotaWarningType == dto.NotifyTypeWebhook {
|
||||
if req.WebhookUrl == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingWebhookEmpty)
|
||||
common.ApiErrorT(c, "Webhook URL cannot be empty")
|
||||
return
|
||||
}
|
||||
// 验证URL格式
|
||||
if _, err := url.ParseRequestURI(req.WebhookUrl); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingWebhookInvalid)
|
||||
common.ApiErrorT(c, "Invalid Webhook URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1366,7 +1365,7 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
|
||||
// 验证邮箱格式
|
||||
if !strings.Contains(req.NotificationEmail, "@") {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingEmailInvalid)
|
||||
common.ApiErrorT(c, "Invalid email address")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1374,17 +1373,17 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
// 如果是Bark类型,验证Bark URL
|
||||
if req.QuotaWarningType == dto.NotifyTypeBark {
|
||||
if req.BarkUrl == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlEmpty)
|
||||
common.ApiErrorT(c, "Bark push URL cannot be empty")
|
||||
return
|
||||
}
|
||||
// 验证URL格式
|
||||
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlInvalid)
|
||||
common.ApiErrorT(c, "Invalid Bark push URL")
|
||||
return
|
||||
}
|
||||
// 检查是否是HTTP或HTTPS
|
||||
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
|
||||
common.ApiErrorT(c, "URL must start with http:// or https://")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1392,21 +1391,21 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
// 如果是Gotify类型,验证Gotify URL和Token
|
||||
if req.QuotaWarningType == dto.NotifyTypeGotify {
|
||||
if req.GotifyUrl == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlEmpty)
|
||||
common.ApiErrorT(c, "Gotify server URL cannot be empty")
|
||||
return
|
||||
}
|
||||
if req.GotifyToken == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingGotifyTokenEmpty)
|
||||
common.ApiErrorT(c, "Gotify token cannot be empty")
|
||||
return
|
||||
}
|
||||
// 验证URL格式
|
||||
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlInvalid)
|
||||
common.ApiErrorT(c, "Invalid Gotify server URL")
|
||||
return
|
||||
}
|
||||
// 检查是否是HTTP或HTTPS
|
||||
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
|
||||
common.ApiErrorT(c, "URL must start with http:// or https://")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1464,9 +1463,9 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
|
||||
// 更新用户设置
|
||||
if err := model.UpdateUserSetting(user.Id, settings); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
|
||||
common.ApiErrorT(c, "Update failed")
|
||||
return
|
||||
}
|
||||
|
||||
common.ApiSuccessI18n(c, i18n.MsgSettingSaved, nil)
|
||||
common.ApiSuccessT(c, "Settings updated", nil)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -50,16 +49,16 @@ func manageUserQuota(c *gin.Context, req ManageRequest) {
|
||||
case errors.Is(err, model.ErrInvalidUserQuotaAdjustment):
|
||||
params["failure_reason"] = "invalid_parameters"
|
||||
if (req.Mode == "add" || req.Mode == "subtract") && req.Value <= 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
|
||||
common.ApiErrorT(c, "Quota change amount cannot be zero")
|
||||
} else {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
common.ApiErrorT(c, "Invalid parameters")
|
||||
}
|
||||
case errors.Is(err, model.ErrUserQuotaPermission):
|
||||
params["failure_reason"] = "permission_denied"
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
common.ApiErrorT(c, "No permission to update users of same or higher permission level")
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
params["failure_reason"] = "target_not_found"
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
|
||||
common.ApiErrorT(c, "User does not exist")
|
||||
case errors.Is(err, model.ErrWalletQuotaLimitExceeded):
|
||||
params["failure_reason"] = "quota_limit_exceeded"
|
||||
common.ApiError(c, err)
|
||||
|
||||
@@ -65,7 +65,7 @@ func UpdateVendorMeta(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if v.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少供应商 ID")
|
||||
common.ApiErrorT(c, "Missing vendor ID")
|
||||
return
|
||||
}
|
||||
if err := v.Update(); err != nil {
|
||||
|
||||
@@ -68,7 +68,7 @@ func VideoProxy(c *gin.Context) {
|
||||
|
||||
task, exists, err := getTaskForArtifactRequest(c, taskID)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error()))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Failed to query task %s: %s", taskID, err.Error()))
|
||||
videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to query task")
|
||||
return
|
||||
}
|
||||
@@ -100,13 +100,13 @@ func VideoProxy(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if adaptorErr != nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Failed to resolve plugin video content for task %s", taskID))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Failed to resolve plugin video content for task %s", taskID))
|
||||
descriptor = nil
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Failed to project plugin video for task %s", taskID))
|
||||
logger.LogWarn(c.Request.Context(), common.LogText("Failed to project plugin video for task %s", taskID))
|
||||
}
|
||||
}
|
||||
if descriptor == nil {
|
||||
@@ -289,7 +289,7 @@ func proxyTaskMedia(c *gin.Context, task *model.Task, descriptor *relaychannel.T
|
||||
return nil
|
||||
}
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to stream task media: %v", err))
|
||||
logger.LogError(c.Request.Context(), common.LogText("Failed to stream task media: %v", err))
|
||||
}
|
||||
return nil
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
|
||||
+13
-46
@@ -26,7 +26,7 @@ type wechatLoginResponse struct {
|
||||
|
||||
func getWeChatIdByCode(code string) (string, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("无效的参数")
|
||||
return "", common.NewMessage("Invalid parameters")
|
||||
}
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, url.QueryEscape(code)), nil)
|
||||
if err != nil {
|
||||
@@ -50,26 +50,20 @@ func getWeChatIdByCode(code string) (string, error) {
|
||||
return "", errors.New(res.Message)
|
||||
}
|
||||
if res.Data == "" {
|
||||
return "", errors.New("验证码错误或已过期")
|
||||
return "", common.NewMessage("Verification code is incorrect or has expired")
|
||||
}
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
func WeChatAuth(c *gin.Context) {
|
||||
if !common.WeChatAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员未开启通过微信登录以及注册",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorT(c, "{{provider}} login and registration has not been enabled by administrator", providerParams("WeChat"))
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
wechatId, err := getWeChatIdByCode(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
@@ -78,17 +72,11 @@ func WeChatAuth(c *gin.Context) {
|
||||
if model.IsWeChatIdAlreadyTaken(wechatId) {
|
||||
err := user.FillUserByWeChatId()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if user.Id == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已注销",
|
||||
})
|
||||
common.ApiErrorT(c, "User has been deleted")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -99,26 +87,17 @@ func WeChatAuth(c *gin.Context) {
|
||||
user.Status = common.UserStatusEnabled
|
||||
|
||||
if err := user.Insert(0); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员关闭了新用户注册",
|
||||
})
|
||||
common.ApiErrorT(c, "New user registration has been disabled by administrator")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "用户已被封禁",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorT(c, "User has been banned")
|
||||
return
|
||||
}
|
||||
setupLogin(&user, nil, c)
|
||||
@@ -139,18 +118,12 @@ func WeChatBind(c *gin.Context) {
|
||||
recordUserSecurityAudit(c, identity.UserID, "user.binding_bind", map[string]any{"provider": "wechat", "success": succeeded, "notification_failed": notificationFailed})
|
||||
}()
|
||||
if !common.WeChatAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员未开启通过微信登录以及注册",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorT(c, "{{provider}} login and registration has not been enabled by administrator", providerParams("WeChat"))
|
||||
return
|
||||
}
|
||||
var req wechatBindRequest
|
||||
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的请求",
|
||||
})
|
||||
common.ApiErrorT(c, "Invalid request")
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
@@ -164,17 +137,11 @@ func WeChatBind(c *gin.Context) {
|
||||
}
|
||||
wechatId, err := getWeChatIdByCode(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if model.IsWeChatIdAlreadyTaken(wechatId) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该微信账号已被绑定",
|
||||
})
|
||||
common.ApiErrorT(c, "This {{provider}} account has already been bound", providerParams("WeChat"))
|
||||
return
|
||||
}
|
||||
// 只更新绑定列,避免完整用户快照覆盖并发的封禁、降权或分组变更。
|
||||
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
|
||||
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
|
||||
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
|
||||
# - DEFAULT_LANGUAGE=zh-CN # 默认语言:日志输出,以及对方没有指定语言时返回的提示、邮件和通知;可选 en、zh-CN、zh-TW,不设置为 en (Default language of logs and of messages, emails and notifications when the reader states none: en, zh-CN or zh-TW; en if unset)
|
||||
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
|
||||
# - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable)
|
||||
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!)
|
||||
|
||||
+38
-52
@@ -2,6 +2,7 @@ package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -86,14 +87,17 @@ func GetLocalizer(lang string) *i18n.Localizer {
|
||||
return loc
|
||||
}
|
||||
|
||||
// T translates a message key using the language from gin context
|
||||
// T translates a message key in the language the reader of the request stated
|
||||
func T(c *gin.Context, key string, args ...map[string]any) string {
|
||||
lang := GetLangFromContext(c)
|
||||
return Translate(lang, key, args...)
|
||||
return Translate(StatedLang(c), key, args...)
|
||||
}
|
||||
|
||||
// Translate translates a message key for the specified language
|
||||
// Translate translates a message key for the specified language. An empty
|
||||
// language means the reader stated none and gets common.DefaultLanguage.
|
||||
func Translate(lang, key string, args ...map[string]any) string {
|
||||
if lang == "" {
|
||||
lang = common.DefaultLanguage
|
||||
}
|
||||
loc := GetLocalizer(lang)
|
||||
|
||||
config := &i18n.LocalizeConfig{
|
||||
@@ -121,29 +125,30 @@ func SetUserLangLoader(loader func(userId int) string) {
|
||||
userLangLoaderFunc = loader
|
||||
}
|
||||
|
||||
// GetLangFromContext extracts the language setting from gin context
|
||||
// It checks multiple sources in priority order:
|
||||
// StatedLang returns the language the reader of a request stated, or "" when
|
||||
// there is none. 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
|
||||
// 2. Lazy load user language from cache/DB using user ID, when step 1 found no settings
|
||||
// 3. Language set by middleware (ContextKeyLanguage) - from Accept-Language header
|
||||
// 4. Default language (English)
|
||||
func GetLangFromContext(c *gin.Context) string {
|
||||
// 4. The Accept-Language header
|
||||
func StatedLang(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return DefaultLang
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
userSetting, settingLoaded := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting)
|
||||
if settingLoaded && 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 {
|
||||
// 2. Lazy load user language using user ID (for session-based auth where full settings aren't loaded).
|
||||
// Loaded settings without a language mean the user saved none; loading the user again would cost
|
||||
// a cache or database read on every relay request.
|
||||
if !settingLoaded && userLangLoaderFunc != nil {
|
||||
if userId, exists := c.Get("id"); exists {
|
||||
if uid, ok := userId.(int); ok && uid > 0 {
|
||||
lang := userLangLoaderFunc(uid)
|
||||
@@ -166,44 +171,30 @@ func GetLangFromContext(c *gin.Context) string {
|
||||
}
|
||||
|
||||
// 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
|
||||
return ParseAcceptLanguage(c.GetHeader("Accept-Language"))
|
||||
}
|
||||
|
||||
// ParseAcceptLanguage parses the Accept-Language header and returns the preferred language
|
||||
// ParseAcceptLanguage returns the first language of an Accept-Language header,
|
||||
// or "" when the header is absent or states no preference ("*").
|
||||
func ParseAcceptLanguage(header string) string {
|
||||
if header == "" {
|
||||
return DefaultLang
|
||||
first, _, _ := strings.Cut(header, ",")
|
||||
first, _, _ = strings.Cut(first, ";")
|
||||
first = strings.TrimSpace(first)
|
||||
if first == "" || first == "*" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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)
|
||||
return normalizeLang(first)
|
||||
}
|
||||
|
||||
// normalizeLang normalizes language code to supported format
|
||||
func normalizeLang(lang string) string {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
lang = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(lang), "_", "-"))
|
||||
|
||||
// Handle common variations
|
||||
// Handle common variations. The web console saves its own codes zhCN and
|
||||
// zhTW, and maps zh-HK, zh-MO and zh-Hant to Traditional Chinese.
|
||||
switch {
|
||||
case strings.HasPrefix(lang, "zh-tw"):
|
||||
case lang == "zhtw" || strings.HasPrefix(lang, "zh-tw") || strings.HasPrefix(lang, "zh-hk") ||
|
||||
strings.HasPrefix(lang, "zh-mo") || strings.HasPrefix(lang, "zh-hant"):
|
||||
return LangZhTW
|
||||
case strings.HasPrefix(lang, "zh"):
|
||||
return LangZhCN
|
||||
@@ -222,10 +213,5 @@ func SupportedLanguages() []string {
|
||||
// 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
|
||||
return slices.Contains(SupportedLanguages(), lang)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// The web console saves its own language codes (zhCN, zhTW, fr, ...) in the
|
||||
// user setting; backend messages must follow them, and languages without a
|
||||
// backend locale fall back to English.
|
||||
func TestLanguageFromUserSetting(t *testing.T) {
|
||||
require.NoError(t, Init())
|
||||
for _, tc := range []struct {
|
||||
saved string
|
||||
want string
|
||||
}{
|
||||
{"zhCN", LangZhCN},
|
||||
{"zhTW", LangZhTW},
|
||||
{"zh-HK", LangZhTW},
|
||||
{"zh_TW", LangZhTW},
|
||||
{"zh_HK", LangZhTW},
|
||||
{"zh-Hant-TW", LangZhTW},
|
||||
{"zh", LangZhCN},
|
||||
{"en", LangEn},
|
||||
{"fr", LangEn},
|
||||
{"ja", LangEn},
|
||||
} {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/v1/chat/completions", nil)
|
||||
c.Set(string(constant.ContextKeyUserSetting), dto.UserSetting{Language: tc.saved})
|
||||
assert.Equal(t, tc.want, StatedLang(c), tc.saved)
|
||||
}
|
||||
}
|
||||
|
||||
// TokenAuth puts the user's settings into the request. A user who saved no
|
||||
// language must not be loaded again, which would cost a cache or database read
|
||||
// on every relay request; a request without loaded settings still loads them.
|
||||
func TestStatedLangLoadsTheUserOnlyWithoutLoadedSettings(t *testing.T) {
|
||||
previous := userLangLoaderFunc
|
||||
t.Cleanup(func() { userLangLoaderFunc = previous })
|
||||
loads := 0
|
||||
SetUserLangLoader(func(int) string {
|
||||
loads++
|
||||
return "zhTW"
|
||||
})
|
||||
|
||||
relay, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
relay.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
|
||||
relay.Set("id", 7)
|
||||
relay.Set(string(constant.ContextKeyUserSetting), dto.UserSetting{})
|
||||
assert.Equal(t, "", StatedLang(relay))
|
||||
assert.Zero(t, loads)
|
||||
|
||||
dashboard, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
dashboard.Request = httptest.NewRequest("GET", "/api/user/self", nil)
|
||||
dashboard.Set("id", 7)
|
||||
assert.Equal(t, LangZhTW, StatedLang(dashboard))
|
||||
assert.Equal(t, 1, loads)
|
||||
}
|
||||
|
||||
// A saved language or an Accept-Language header selects the language of a
|
||||
// backend message. A reader who states neither gets DEFAULT_LANGUAGE, which is
|
||||
// English when unset.
|
||||
func TestBackendMessageLanguage(t *testing.T) {
|
||||
require.NoError(t, Init())
|
||||
t.Cleanup(func() { common.DefaultLanguage = "" })
|
||||
quota := map[string]any{"Remaining": "$0.10"}
|
||||
const english = "Insufficient user quota, remaining quota: $0.10"
|
||||
const chinese = "用户额度不足, 剩余额度: $0.10"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
defaultLanguage string
|
||||
saved string
|
||||
acceptLanguage string
|
||||
want string
|
||||
}{
|
||||
{"nothing stated", "", "", "", english},
|
||||
{"no preference header", "", "", "*", english},
|
||||
{"nothing stated with a Chinese default", "zh-CN", "", "", chinese},
|
||||
{"nothing stated with an English default", "en", "", "", english},
|
||||
{"header wins over the default", "zh-CN", "", "en-US,en;q=0.9", english},
|
||||
{"header in a language without a locale", "zh-CN", "", "fr-FR", english},
|
||||
{"saved language wins over the header", "", "zhTW", "en-US", "使用者額度不足,剩餘額度:$0.10"},
|
||||
{"saved language", "zh-CN", "en", "", english},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
common.DefaultLanguage = tc.defaultLanguage
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
|
||||
if tc.acceptLanguage != "" {
|
||||
c.Request.Header.Set("Accept-Language", tc.acceptLanguage)
|
||||
}
|
||||
if tc.saved != "" {
|
||||
c.Set(string(constant.ContextKeyUserSetting), dto.UserSetting{Language: tc.saved})
|
||||
}
|
||||
assert.Equal(t, tc.want, T(c, MsgQuotaUserInsufficient, quota))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A key missing from a locale, or a template that does not parse, reaches
|
||||
// clients as the raw key.
|
||||
func TestLocalesRenderEveryKey(t *testing.T) {
|
||||
require.NoError(t, Init())
|
||||
keysFile, err := parser.ParseFile(token.NewFileSet(), "keys.go", nil, parser.SkipObjectResolution)
|
||||
require.NoError(t, err)
|
||||
var declared []string
|
||||
ast.Inspect(keysFile, func(node ast.Node) bool {
|
||||
if spec, ok := node.(*ast.ValueSpec); ok {
|
||||
for _, value := range spec.Values {
|
||||
if literal, ok := value.(*ast.BasicLit); ok {
|
||||
key, err := strconv.Unquote(literal.Value)
|
||||
require.NoError(t, err)
|
||||
declared = append(declared, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
slices.Sort(declared)
|
||||
|
||||
keysByLang := make(map[string][]string)
|
||||
for _, lang := range SupportedLanguages() {
|
||||
data, err := localeFS.ReadFile("locales/" + lang + ".yaml")
|
||||
require.NoError(t, err)
|
||||
var messages map[string]string
|
||||
require.NoError(t, yaml.Unmarshal(data, &messages))
|
||||
for key := range messages {
|
||||
keysByLang[lang] = append(keysByLang[lang], key)
|
||||
}
|
||||
slices.Sort(keysByLang[lang])
|
||||
}
|
||||
for _, lang := range SupportedLanguages() {
|
||||
assert.Equal(t, declared, keysByLang[lang], lang)
|
||||
for _, key := range keysByLang[lang] {
|
||||
text := Translate(lang, key, map[string]any{})
|
||||
assert.NotEqual(t, key, text, "%s %s", lang, key)
|
||||
assert.False(t, strings.Contains(text, "{{"), "%s %s: %s", lang, key, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Web console responses carry the English source text as message_key plus its
|
||||
// params, and the web console translates them. The backend sends the same body
|
||||
// whatever language the request asks for.
|
||||
func TestWebConsoleMessageResponse(t *testing.T) {
|
||||
require.NoError(t, Init())
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "params",
|
||||
err: common.NewMessage("Channel {{name}} (#{{id}}) does not exist", map[string]any{"name": "{{id}}", "id": 7}),
|
||||
want: `{"success":false,"message":"Channel {{id}} (#7) does not exist","message_key":"Channel {{name}} (#{{id}}) does not exist","message_params":{"id":7,"name":"{{id}}"}}`,
|
||||
},
|
||||
{
|
||||
name: "no params",
|
||||
err: common.NewMessage("Invalid parameters"),
|
||||
want: `{"success":false,"message":"Invalid parameters","message_key":"Invalid parameters"}`,
|
||||
},
|
||||
{
|
||||
name: "plain error",
|
||||
err: errors.New("record not found"),
|
||||
want: `{"success":false,"message":"record not found"}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest("GET", "/api/channel/7", nil)
|
||||
c.Request.Header.Set("Accept-Language", "zh-CN")
|
||||
common.ApiError(c, tc.err)
|
||||
assert.JSONEq(t, tc.want, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Server log lines are English. DEFAULT_LANGUAGE=zh-CN prints the Chinese text of
|
||||
// the lines that have one in common/log_text.zh-CN.json.
|
||||
func TestServerLogLanguage(t *testing.T) {
|
||||
t.Cleanup(func() { common.SetLogLanguage("") })
|
||||
const line = "channel #%d has %d unfinished tasks"
|
||||
assert.Equal(t, "channel #7 has 2 unfinished tasks", common.LogText(line, 7, 2))
|
||||
common.SetLogLanguage("zh-CN")
|
||||
assert.Equal(t, "渠道 #7 未完成的任务有: 2", common.LogText(line, 7, 2))
|
||||
assert.Equal(t, "a line without a Chinese text: x", common.LogText("a line without a Chinese text: %s", "x"))
|
||||
assert.Equal(t, "任务进度轮询开始", common.LogText("task progress polling started"))
|
||||
common.SetLogLanguage("en")
|
||||
assert.Equal(t, "channel #7 has 2 unfinished tasks", common.LogText(line, 7, 2))
|
||||
}
|
||||
+70
-323
@@ -1,330 +1,30 @@
|
||||
package i18n
|
||||
|
||||
const MsgTaskPluginUnknownMetaField = "task_plugin.unknown_meta_field"
|
||||
// Message keys of i18n/locales/*.yaml, for text the backend translates itself:
|
||||
// errors returned to AI clients, emails and notifications. Web console messages
|
||||
// are not listed here; the web console translates them (see common.Message).
|
||||
|
||||
// Message keys for i18n translations
|
||||
// Use these constants instead of hardcoded strings
|
||||
|
||||
// Common error messages
|
||||
// Errors returned to AI clients
|
||||
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"
|
||||
MsgBatchTooMany = "common.batch_too_many"
|
||||
)
|
||||
MsgDatabaseError = "common.database_error"
|
||||
MsgAuthUserBanned = "auth.user_banned"
|
||||
MsgTokenGetInfoFailed = "token.get_info_failed"
|
||||
MsgTokenInvalid = "token.invalid"
|
||||
MsgTokenNotProvided = "token.not_provided"
|
||||
MsgTokenStatusUnavailable = "token.status_unavailable"
|
||||
|
||||
// Auth middleware messages
|
||||
const (
|
||||
MsgAuthNotLoggedIn = "auth.not_logged_in"
|
||||
MsgAuthAccessTokenInvalid = "auth.access_token_invalid"
|
||||
MsgAuthUserInfoInvalid = "auth.user_info_invalid"
|
||||
MsgAuthUserIdNotProvided = "auth.user_id_not_provided"
|
||||
MsgAuthUserIdFormatError = "auth.user_id_format_error"
|
||||
MsgAuthUserIdMismatch = "auth.user_id_mismatch"
|
||||
MsgAuthUserBanned = "auth.user_banned"
|
||||
MsgAuthInsufficientPrivilege = "auth.insufficient_privilege"
|
||||
MsgAuthAccessTokenExpired = "auth.access_token_expired"
|
||||
MsgAuthLegacyTokenRetired = "auth.legacy_access_token_retired"
|
||||
MsgAuthAccessTokenScope = "auth.access_token_scope_denied"
|
||||
MsgAuthAccessTokenLimit = "auth.access_token_limit"
|
||||
)
|
||||
MsgAuthClientIPUnresolved = "auth.client_ip_unresolved"
|
||||
MsgAuthTokenIPNotAllowed = "auth.token_ip_not_allowed"
|
||||
MsgAuthTokenGroupForbidden = "auth.token_group_forbidden"
|
||||
MsgAuthTokenGroupDeprecated = "auth.token_group_deprecated"
|
||||
MsgAuthChannelSelectForbidden = "auth.channel_select_forbidden"
|
||||
|
||||
// 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"
|
||||
MsgTokenAutoGroupsTooMany = "token.auto_groups_too_many"
|
||||
MsgTokenAutoGroupsDuplicate = "token.auto_groups_duplicate"
|
||||
MsgTokenAutoGroupsInvalid = "token.auto_groups_invalid"
|
||||
)
|
||||
|
||||
// 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"
|
||||
MsgUserEmailAlreadyTaken = "user.email_already_taken"
|
||||
MsgUserPasswordUnset = "user.password_unset"
|
||||
MsgUserPasswordResetLinkInvalid = "user.password_reset_link_invalid"
|
||||
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"
|
||||
MsgUserQuotaChangeZero = "user.quota_change_zero"
|
||||
)
|
||||
|
||||
// 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"
|
||||
MsgPaymentComplianceRequired = "payment.compliance_required"
|
||||
)
|
||||
|
||||
// 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"
|
||||
MsgPasskeyRPIDInvalid = "passkey.rp_id_invalid"
|
||||
MsgPasskeyRPIDUnavailable = "passkey.rp_id_unavailable"
|
||||
MsgPasskeyRPIDRemovalConfirmation = "passkey.rp_id_removal_confirmation"
|
||||
)
|
||||
|
||||
// 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"
|
||||
MsgOAuthUnknownProvider = "oauth.unknown_provider"
|
||||
MsgOAuthStateInvalid = "oauth.state_invalid"
|
||||
MsgOAuthNotEnabled = "oauth.not_enabled"
|
||||
MsgOAuthUserDeleted = "oauth.user_deleted"
|
||||
MsgOAuthUserBanned = "oauth.user_banned"
|
||||
MsgOAuthBindSuccess = "oauth.bind_success"
|
||||
MsgOAuthAlreadyBound = "oauth.already_bound"
|
||||
MsgOAuthNotAutoLinked = "oauth.not_auto_linked"
|
||||
MsgOAuthConnectFailed = "oauth.connect_failed"
|
||||
MsgOAuthTokenFailed = "oauth.token_failed"
|
||||
MsgOAuthUserInfoEmpty = "oauth.user_info_empty"
|
||||
MsgOAuthTrustLevelLow = "oauth.trust_level_low"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Distributor related messages
|
||||
const (
|
||||
MsgDistributorInvalidRequest = "distributor.invalid_request"
|
||||
MsgDistributorInvalidChannelId = "distributor.invalid_channel_id"
|
||||
MsgDistributorChannelDisabled = "distributor.channel_disabled"
|
||||
MsgDistributorAffinityChannelDisabled = "distributor.affinity_channel_disabled"
|
||||
MsgDistributorTokenNoModelAccess = "distributor.token_no_model_access"
|
||||
MsgDistributorTokenModelForbidden = "distributor.token_model_forbidden"
|
||||
MsgDistributorModelNameRequired = "distributor.model_name_required"
|
||||
@@ -335,15 +35,62 @@ const (
|
||||
MsgDistributorNoAvailableChannelTaskPlugin = "distributor.no_available_channel_task_plugin"
|
||||
MsgDistributorInvalidMidjourney = "distributor.invalid_midjourney_request"
|
||||
MsgDistributorInvalidParseModel = "distributor.invalid_request_parse_model"
|
||||
|
||||
MsgQuotaUserInsufficient = "quota.user_insufficient"
|
||||
MsgQuotaPreConsumeInsufficient = "quota.pre_consume_insufficient"
|
||||
MsgQuotaSubscriptionInsufficient = "quota.subscription_insufficient"
|
||||
MsgRelayModelPriceNotConfigured = "relay.model_price_not_configured"
|
||||
MsgRelayModelPriceNotConfiguredAdmin = "relay.model_price_not_configured_admin"
|
||||
MsgRelayTaskChannelDisabled = "relay.task_channel_disabled"
|
||||
MsgRelayGroupSaturated = "relay.group_saturated"
|
||||
MsgRelayGroupUpstreamSaturated = "relay.group_upstream_saturated"
|
||||
MsgRelayRetryGetChannelFailed = "relay.retry_get_channel_failed"
|
||||
MsgRelayRetryNoAvailableChannel = "relay.retry_no_available_channel"
|
||||
MsgRelayAccessTokenUnsupported = "relay.access_token_unsupported"
|
||||
)
|
||||
|
||||
// Custom OAuth provider related messages
|
||||
// Emails and the name of the token created at registration, in the
|
||||
// recipient's language
|
||||
const (
|
||||
MsgCustomOAuthNotFound = "custom_oauth.not_found"
|
||||
MsgCustomOAuthSlugEmpty = "custom_oauth.slug_empty"
|
||||
MsgCustomOAuthSlugExists = "custom_oauth.slug_exists"
|
||||
MsgCustomOAuthNameEmpty = "custom_oauth.name_empty"
|
||||
MsgCustomOAuthHasBindings = "custom_oauth.has_bindings"
|
||||
MsgCustomOAuthBindingNotFound = "custom_oauth.binding_not_found"
|
||||
MsgCustomOAuthProviderIdInvalid = "custom_oauth.provider_id_field_invalid"
|
||||
MsgUserDefaultTokenName = "user.default_token_name"
|
||||
|
||||
MsgEmailVerificationSubject = "email.verification.subject"
|
||||
MsgEmailVerificationGreeting = "email.verification.greeting"
|
||||
MsgEmailVerificationCode = "email.verification.code"
|
||||
MsgEmailVerificationValidity = "email.verification.validity"
|
||||
|
||||
MsgEmailPasswordResetSubject = "email.password_reset.subject"
|
||||
MsgEmailPasswordResetGreeting = "email.password_reset.greeting"
|
||||
MsgEmailPasswordResetLink = "email.password_reset.link"
|
||||
MsgEmailPasswordResetLinkFallback = "email.password_reset.link_fallback"
|
||||
MsgEmailPasswordResetValidity = "email.password_reset.validity"
|
||||
)
|
||||
|
||||
// Low-quota notices, in the recipient's saved language
|
||||
const (
|
||||
MsgNotifyQuotaLowTitle = "notify.quota_low_title"
|
||||
MsgNotifySubscriptionQuotaLowTitle = "notify.subscription_quota_low_title"
|
||||
MsgNotifyQuotaLowBark = "notify.quota_low_bark"
|
||||
MsgNotifyQuotaLowGotify = "notify.quota_low_gotify"
|
||||
MsgNotifyQuotaLowEmail = "notify.quota_low_email"
|
||||
)
|
||||
|
||||
// Channel notices sent to the root user and model update watchers
|
||||
const (
|
||||
MsgChannelNotifyDisabledSubject = "channel.notify_disabled_subject"
|
||||
MsgChannelNotifyDisabledContent = "channel.notify_disabled_content"
|
||||
MsgChannelNotifyEnabledSubject = "channel.notify_enabled_subject"
|
||||
MsgChannelNotifyEnabledContent = "channel.notify_enabled_content"
|
||||
MsgChannelTestCompletedSubject = "channel.test_completed_subject"
|
||||
MsgChannelTestCompletedContent = "channel.test_completed_content"
|
||||
MsgChannelResponseTimeExceeded = "channel.response_time_exceeded"
|
||||
|
||||
MsgChannelUpstreamUpdateNotifySubject = "channel.upstream_update_notify_subject"
|
||||
MsgChannelUpstreamUpdateSummary = "channel.upstream_update_summary"
|
||||
MsgChannelUpstreamUpdateChangedChannels = "channel.upstream_update_changed_channels"
|
||||
MsgChannelUpstreamUpdateMoreChannels = "channel.upstream_update_more_channels"
|
||||
MsgChannelUpstreamUpdateAddedModels = "channel.upstream_update_added_models"
|
||||
MsgChannelUpstreamUpdateRemovedModels = "channel.upstream_update_removed_models"
|
||||
MsgChannelUpstreamUpdateFailedChannels = "channel.upstream_update_failed_channels"
|
||||
MsgChannelUpstreamUpdateMoreOmitted = "channel.upstream_update_more_omitted"
|
||||
)
|
||||
+53
-274
@@ -1,281 +1,22 @@
|
||||
# English translations
|
||||
|
||||
# Task plugin messages
|
||||
task_plugin.unknown_meta_field: 'Plugin metadata contains an unknown field "{{.Field}}". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.'
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "Invalid parameters"
|
||||
# Errors returned to AI clients
|
||||
common.database_error: "Database error, please contact the administrator"
|
||||
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"
|
||||
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "Unauthorized, not logged in and no access token provided"
|
||||
auth.access_token_invalid: "Unauthorized, invalid access token"
|
||||
auth.user_info_invalid: "Unauthorized, invalid user info"
|
||||
auth.user_id_not_provided: "Unauthorized, New-Api-User header not provided"
|
||||
auth.user_id_format_error: "Unauthorized, New-Api-User header format error"
|
||||
auth.user_id_mismatch: "Unauthorized, New-Api-User does not match logged in user"
|
||||
auth.user_banned: "User has been banned"
|
||||
auth.insufficient_privilege: "Unauthorized, insufficient privileges"
|
||||
auth.access_token_expired: "Unauthorized, the access token has expired"
|
||||
auth.legacy_access_token_retired: "Legacy access tokens have been retired. Create a new access token in security settings"
|
||||
auth.access_token_scope_denied: "The access token lacks the required permission: {{.Scope}}"
|
||||
auth.access_token_limit: "You can create at most {{.Count}} access tokens"
|
||||
|
||||
# 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"
|
||||
token.auto_groups_too_many: "A token can select at most {{.Max}} Auto groups"
|
||||
token.auto_groups_duplicate: "Auto group {{.Group}} is duplicated"
|
||||
token.auto_groups_invalid: "Auto group {{.Group}} is unavailable or unauthorized"
|
||||
|
||||
# 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.email_already_taken: "Email address is already in use"
|
||||
user.password_unset: "This account has no password set. Please use password reset or contact an administrator to reset it."
|
||||
user.password_reset_link_invalid: "Password reset link is invalid 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!"
|
||||
user.quota_change_zero: "Quota change amount cannot be zero"
|
||||
|
||||
# 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"
|
||||
payment.compliance_required: "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them."
|
||||
|
||||
# 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"
|
||||
passkey.rp_id_invalid: "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard."
|
||||
passkey.rp_id_unavailable: "This Passkey domain is not available on this website. Use its original website or another verification method."
|
||||
passkey.rp_id_removal_confirmation: "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."
|
||||
|
||||
# 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
|
||||
auth.client_ip_unresolved: "Unable to resolve the client IP address"
|
||||
auth.token_ip_not_allowed: "Your IP address is not on this token's allowlist"
|
||||
auth.token_group_forbidden: "No permission to access group {{.Group}}"
|
||||
auth.token_group_deprecated: "Group {{.Group}} has been deprecated"
|
||||
auth.channel_select_forbidden: "Regular users cannot specify a channel"
|
||||
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"
|
||||
oauth.unknown_provider: "Unknown OAuth provider"
|
||||
oauth.state_invalid: "State parameter is empty or mismatched"
|
||||
oauth.not_enabled: "{{.Provider}} login and registration has not been enabled by administrator"
|
||||
oauth.user_deleted: "User has been deleted"
|
||||
oauth.user_banned: "User has been banned"
|
||||
oauth.bind_success: "Binding successful"
|
||||
oauth.already_bound: "This {{.Provider}} account has already been bound"
|
||||
oauth.not_auto_linked: "This {{.Provider}} account cannot be linked to an existing account automatically, please sign in or register another way and then link {{.Provider}} in account settings"
|
||||
oauth.connect_failed: "Unable to connect to {{.Provider}} server, please try again later"
|
||||
oauth.token_failed: "Failed to get token from {{.Provider}}, please check settings"
|
||||
oauth.user_info_empty: "{{.Provider}} returned empty user info, please check settings"
|
||||
oauth.trust_level_low: "Linux DO trust level does not meet the minimum required by administrator"
|
||||
|
||||
# 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"
|
||||
|
||||
# Distributor messages
|
||||
rate_limit.total_reached: "You have reached the total request limit: maximum {{.Max}} requests in {{.Minutes}} minutes, including failed attempts. Please check that your requests are correct"
|
||||
distributor.invalid_request: "Invalid request: {{.Error}}"
|
||||
distributor.invalid_channel_id: "Invalid channel ID"
|
||||
distributor.channel_disabled: "This channel has been disabled"
|
||||
distributor.affinity_channel_disabled: "The channel selected by channel affinity has been disabled, and retry was stopped by rule. Please contact the administrator"
|
||||
distributor.token_no_model_access: "This token has no access to any models"
|
||||
distributor.token_model_forbidden: "This token has no access to model {{.Model}}"
|
||||
distributor.model_name_required: "Model name not specified, model name cannot be empty"
|
||||
@@ -286,12 +27,50 @@ distributor.no_available_channel: "No available channel for model {{.Model}} und
|
||||
distributor.no_available_channel_task_plugin: "No available channel for model {{.Model}} under group {{.Group}}: the model is claimed by a task plugin, which has no enabled channel serving it (distributor)"
|
||||
distributor.invalid_midjourney_request: "Invalid Midjourney request: {{.Error}}"
|
||||
distributor.invalid_request_parse_model: "Invalid request, unable to parse model"
|
||||
quota.user_insufficient: "Insufficient user quota, remaining quota: {{.Remaining}}"
|
||||
quota.pre_consume_insufficient: "Not enough quota to pre-consume for this request: {{.Remaining}} remaining, {{.Required}} required"
|
||||
quota.subscription_insufficient: "Subscription quota is insufficient or no subscription is configured: {{.Error}}"
|
||||
relay.model_price_not_configured: "Model {{.Model}} has not been priced by the administrator yet. Please contact the site administrator to enable this model."
|
||||
relay.model_price_not_configured_admin: "Model {{.Model}} price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing."
|
||||
relay.task_channel_disabled: "The channel of this task has been disabled"
|
||||
relay.group_saturated: "The current group is at capacity. Please try again later, or upgrade your account for better service."
|
||||
relay.group_upstream_saturated: "The upstream of the current group is at capacity. Please try again later"
|
||||
relay.retry_get_channel_failed: "Failed to get available channel for model {{.Model}} under group {{.Group}} (retry): {{.Error}}"
|
||||
relay.retry_no_available_channel: "No available channel for model {{.Model}} under group {{.Group}} (retry)"
|
||||
relay.access_token_unsupported: "Access tokens are not supported here"
|
||||
|
||||
# Custom OAuth provider messages
|
||||
custom_oauth.not_found: "Custom OAuth provider not found"
|
||||
custom_oauth.slug_empty: "Slug cannot be empty"
|
||||
custom_oauth.slug_exists: "Slug already exists"
|
||||
custom_oauth.name_empty: "Provider name cannot be empty"
|
||||
custom_oauth.has_bindings: "Cannot delete provider with existing user bindings"
|
||||
custom_oauth.binding_not_found: "OAuth binding not found"
|
||||
custom_oauth.provider_id_field_invalid: "Could not extract user ID from provider response"
|
||||
# Emails and the name of the token created at registration
|
||||
user.default_token_name: "{{.Username}}'s initial token"
|
||||
email.verification.subject: "{{.SystemName}} email verification"
|
||||
email.verification.greeting: "Hello, you are verifying your email address for {{.SystemName}}."
|
||||
email.verification.code: "Your verification code is: <strong>{{.Code}}</strong>"
|
||||
email.verification.validity: "The verification code is valid for {{.Minutes}} minutes. If you did not request it, please ignore this email."
|
||||
email.password_reset.subject: "{{.SystemName}} password reset"
|
||||
email.password_reset.greeting: "Hello, you are resetting your password for {{.SystemName}}."
|
||||
email.password_reset.link: "Click <a href='{{.Link}}'>here</a> to reset your password."
|
||||
email.password_reset.link_fallback: "If the link does not open, copy the link below into your browser:<br> {{.Link}}"
|
||||
email.password_reset.validity: "The reset link is valid for {{.Minutes}} minutes. If you did not request it, please ignore this email."
|
||||
|
||||
# Low-quota notices
|
||||
notify.quota_low_title: "Your quota is running low"
|
||||
notify.subscription_quota_low_title: "Your subscription quota is running low"
|
||||
notify.quota_low_bark: "{{.Prompt}}, remaining quota: {{.Quota}}. Please top up soon"
|
||||
notify.quota_low_gotify: "{{.Prompt}}. Your remaining quota is {{.Quota}}. Please top up soon."
|
||||
notify.quota_low_email: "{{.Prompt}}. Your remaining quota is {{.Quota}}. Top up soon to keep using the service.<br/>Top-up link: <a href='{{.Link}}'>{{.Link}}</a>"
|
||||
|
||||
# Channel notices sent to the root user and model update watchers
|
||||
channel.notify_disabled_subject: "Channel \"{{.Name}}\" (#{{.Id}}) has been disabled"
|
||||
channel.notify_disabled_content: "Channel \"{{.Name}}\" (#{{.Id}}) has been disabled. Reason: {{.Reason}}"
|
||||
channel.notify_enabled_subject: "Channel \"{{.Name}}\" (#{{.Id}}) has been enabled"
|
||||
channel.notify_enabled_content: "Channel \"{{.Name}}\" (#{{.Id}}) has been enabled"
|
||||
channel.test_completed_subject: "Channel test completed"
|
||||
channel.test_completed_content: "All channel tests have completed"
|
||||
channel.response_time_exceeded: "response time {{.Seconds}}s exceeds the threshold of {{.Threshold}}s"
|
||||
channel.upstream_update_notify_subject: "Upstream model check notice"
|
||||
channel.upstream_update_summary: "Upstream model check summary: {{.Checked}} channels checked, {{.Changed}} changed, {{.Added}} models added, {{.Removed}} models removed, {{.AutoAdded}} added automatically, {{.Failed}} failed."
|
||||
channel.upstream_update_changed_channels: "Changed channels (showing {{.Shown}}/{{.Total}}):"
|
||||
channel.upstream_update_more_channels: "{{.Count}} more channels omitted"
|
||||
channel.upstream_update_added_models: "Added model examples (showing {{.Shown}}/{{.Total}}): {{.Models}}"
|
||||
channel.upstream_update_removed_models: "Removed model examples (showing {{.Shown}}/{{.Total}}): {{.Models}}"
|
||||
channel.upstream_update_failed_channels: "Failed channel IDs (showing {{.Shown}}/{{.Total}}): {{.Ids}}"
|
||||
channel.upstream_update_more_omitted: " ({{.Count}} more omitted)"
|
||||
+53
-274
@@ -1,282 +1,23 @@
|
||||
# Chinese (Simplified) translations
|
||||
# 中文(简体)翻译文件
|
||||
|
||||
# Task plugin messages
|
||||
task_plugin.unknown_meta_field: '插件元数据包含未知字段“{{.Field}}”。如果插件来自官方市场,可能需要更高版本的 new-api。请尝试更新 new-api 后重新安装插件。'
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "无效的参数"
|
||||
# Errors returned to AI clients
|
||||
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: "名称不能为空"
|
||||
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "无权进行此操作,未登录且未提供 access token"
|
||||
auth.access_token_invalid: "无权进行此操作,access token 无效"
|
||||
auth.user_info_invalid: "无权进行此操作,用户信息无效"
|
||||
auth.user_id_not_provided: "无权进行此操作,未提供 New-Api-User"
|
||||
auth.user_id_format_error: "无权进行此操作,New-Api-User 格式错误"
|
||||
auth.user_id_mismatch: "无权进行此操作,New-Api-User 与登录用户不匹配"
|
||||
auth.user_banned: "用户已被封禁"
|
||||
auth.insufficient_privilege: "无权进行此操作,权限不足"
|
||||
auth.access_token_expired: "访问令牌已过期"
|
||||
auth.legacy_access_token_retired: "旧版访问令牌已停用,请在安全设置中创建新的访问令牌"
|
||||
auth.access_token_scope_denied: "访问令牌缺少所需权限:{{.Scope}}"
|
||||
auth.access_token_limit: "最多可创建 {{.Count}} 个访问令牌"
|
||||
|
||||
# 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: "无效的令牌,数据库查询出错,请联系管理员"
|
||||
token.auto_groups_too_many: "每个令牌最多可选择 {{.Max}} 个 Auto 分组"
|
||||
token.auto_groups_duplicate: "Auto 分组 {{.Group}} 重复"
|
||||
token.auto_groups_invalid: "Auto 分组 {{.Group}} 不可用或无权访问"
|
||||
|
||||
# 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.email_already_taken: "邮箱地址已被占用"
|
||||
user.password_unset: "当前账号未设置密码,请使用密码重置或联系管理员重置密码"
|
||||
user.password_reset_link_invalid: "重置链接非法或已过期"
|
||||
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 为空!"
|
||||
user.quota_change_zero: "额度变更量不能为0"
|
||||
|
||||
# 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"
|
||||
payment.compliance_required: "支付、兑换码、订阅计划和邀请返利功能已禁用。管理员需先确认合规声明后方可启用。"
|
||||
|
||||
# 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 验证失败,请重试或联系管理员"
|
||||
passkey.rp_id_invalid: "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。"
|
||||
passkey.rp_id_unavailable: "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。"
|
||||
passkey.rp_id_removal_confirmation: "请核对受影响的通行密钥并确认删除域名。配置或影响范围发生变化后,需要重新确认。"
|
||||
|
||||
# 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
|
||||
auth.client_ip_unresolved: "无法解析客户端 IP 地址"
|
||||
auth.token_ip_not_allowed: "您的 IP 不在令牌允许访问的列表中"
|
||||
auth.token_group_forbidden: "无权访问 {{.Group}} 分组"
|
||||
auth.token_group_deprecated: "分组 {{.Group}} 已被弃用"
|
||||
auth.channel_select_forbidden: "普通用户不支持指定渠道"
|
||||
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: "该账户已被其他用户绑定"
|
||||
oauth.unknown_provider: "未知的 OAuth 提供商"
|
||||
oauth.state_invalid: "state 参数为空或不匹配"
|
||||
oauth.not_enabled: "管理员未开启通过 {{.Provider}} 登录以及注册"
|
||||
oauth.user_deleted: "用户已注销"
|
||||
oauth.user_banned: "用户已被封禁"
|
||||
oauth.bind_success: "绑定成功"
|
||||
oauth.already_bound: "该 {{.Provider}} 账户已被绑定"
|
||||
oauth.not_auto_linked: "无法自动将该 {{.Provider}} 账户关联到现有账户,请使用其他方式登录或注册后,在账户设置中绑定 {{.Provider}}"
|
||||
oauth.connect_failed: "无法连接至 {{.Provider}} 服务器,请稍后重试"
|
||||
oauth.token_failed: "{{.Provider}} 获取 Token 失败,请检查设置"
|
||||
oauth.user_info_empty: "{{.Provider}} 获取用户信息为空,请检查设置"
|
||||
oauth.trust_level_low: "Linux DO 信任等级未达到管理员设置的最低信任等级"
|
||||
|
||||
# Model layer error messages
|
||||
redeem.failed: "兑换失败,请稍后重试"
|
||||
user.create_default_token_error: "创建默认令牌失败"
|
||||
common.uuid_duplicate: "请重试,系统生成的 UUID 竟然重复了!"
|
||||
common.invalid_input: "输入不合法"
|
||||
|
||||
# Distributor messages
|
||||
rate_limit.total_reached: "您已达到总请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次,包括失败次数,请检查您的请求是否正确"
|
||||
distributor.invalid_request: "无效的请求,{{.Error}}"
|
||||
distributor.invalid_channel_id: "无效的渠道 Id"
|
||||
distributor.channel_disabled: "该渠道已被禁用"
|
||||
distributor.affinity_channel_disabled: "渠道亲和性命中的渠道已被禁用,已按规则停止重试,请联系管理员处理"
|
||||
distributor.token_no_model_access: "该令牌无权访问任何模型"
|
||||
distributor.token_model_forbidden: "该令牌无权访问模型 {{.Model}}"
|
||||
distributor.model_name_required: "未指定模型名称,模型名称不能为空"
|
||||
@@ -287,12 +28,50 @@ distributor.no_available_channel: "分组 {{.Group}} 下模型 {{.Model}} 无可
|
||||
distributor.no_available_channel_task_plugin: "分组 {{.Group}} 下模型 {{.Model}} 无可用渠道:该模型由任务插件认领,但当前没有启用的渠道可服务此模型(distributor)"
|
||||
distributor.invalid_midjourney_request: "无效的midjourney请求,{{.Error}}"
|
||||
distributor.invalid_request_parse_model: "无效的请求,无法解析模型"
|
||||
quota.user_insufficient: "用户额度不足, 剩余额度: {{.Remaining}}"
|
||||
quota.pre_consume_insufficient: "预扣费额度失败, 用户剩余额度: {{.Remaining}}, 需要预扣费额度: {{.Required}}"
|
||||
quota.subscription_insufficient: "订阅额度不足或未配置订阅: {{.Error}}"
|
||||
relay.model_price_not_configured: "模型 {{.Model}} 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;Model {{.Model}} has not been priced by the administrator yet. Please contact the site administrator to enable this model."
|
||||
relay.model_price_not_configured_admin: "模型 {{.Model}} 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;Model {{.Model}} price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing."
|
||||
relay.task_channel_disabled: "该任务所属渠道已被禁用"
|
||||
relay.group_saturated: "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
|
||||
relay.group_upstream_saturated: "当前分组上游负载已饱和,请稍后再试"
|
||||
relay.retry_get_channel_failed: "获取分组 {{.Group}} 下模型 {{.Model}} 的可用渠道失败(retry): {{.Error}}"
|
||||
relay.retry_no_available_channel: "分组 {{.Group}} 下模型 {{.Model}} 的可用渠道不存在(retry)"
|
||||
relay.access_token_unsupported: "暂不支持使用 access token"
|
||||
|
||||
# Custom OAuth provider messages
|
||||
custom_oauth.not_found: "自定义 OAuth 提供商不存在"
|
||||
custom_oauth.slug_empty: "标识符不能为空"
|
||||
custom_oauth.slug_exists: "标识符已存在"
|
||||
custom_oauth.name_empty: "提供商名称不能为空"
|
||||
custom_oauth.has_bindings: "无法删除已有用户绑定的提供商"
|
||||
custom_oauth.binding_not_found: "OAuth 绑定不存在"
|
||||
custom_oauth.provider_id_field_invalid: "无法从提供商响应中提取用户 ID"
|
||||
# Emails and the name of the token created at registration
|
||||
user.default_token_name: "{{.Username}}的初始令牌"
|
||||
email.verification.subject: "{{.SystemName}}邮箱验证邮件"
|
||||
email.verification.greeting: "您好,你正在进行{{.SystemName}}邮箱验证。"
|
||||
email.verification.code: "您的验证码为: <strong>{{.Code}}</strong>"
|
||||
email.verification.validity: "验证码 {{.Minutes}} 分钟内有效,如果不是本人操作,请忽略。"
|
||||
email.password_reset.subject: "{{.SystemName}}密码重置"
|
||||
email.password_reset.greeting: "您好,你正在进行{{.SystemName}}密码重置。"
|
||||
email.password_reset.link: "点击 <a href='{{.Link}}'>此处</a> 进行密码重置。"
|
||||
email.password_reset.link_fallback: "如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> {{.Link}}"
|
||||
email.password_reset.validity: "重置链接 {{.Minutes}} 分钟内有效,如果不是本人操作,请忽略。"
|
||||
|
||||
# Low-quota notices
|
||||
notify.quota_low_title: "您的额度即将用尽"
|
||||
notify.subscription_quota_low_title: "您的订阅额度即将用尽"
|
||||
notify.quota_low_bark: "{{.Prompt}},剩余额度:{{.Quota}},请及时充值"
|
||||
notify.quota_low_gotify: "{{.Prompt}},当前剩余额度为 {{.Quota}},请及时充值。"
|
||||
notify.quota_low_email: "{{.Prompt}},当前剩余额度为 {{.Quota}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{.Link}}'>{{.Link}}</a>"
|
||||
|
||||
# Channel notices sent to the root user and model update watchers
|
||||
channel.notify_disabled_subject: "通道「{{.Name}}」(#{{.Id}})已被禁用"
|
||||
channel.notify_disabled_content: "通道「{{.Name}}」(#{{.Id}})已被禁用,原因:{{.Reason}}"
|
||||
channel.notify_enabled_subject: "通道「{{.Name}}」(#{{.Id}})已被启用"
|
||||
channel.notify_enabled_content: "通道「{{.Name}}」(#{{.Id}})已被启用"
|
||||
channel.test_completed_subject: "通道测试完成"
|
||||
channel.test_completed_content: "所有通道测试已完成"
|
||||
channel.response_time_exceeded: "响应时间 {{.Seconds}}s 超过阈值 {{.Threshold}}s"
|
||||
channel.upstream_update_notify_subject: "上游模型巡检通知"
|
||||
channel.upstream_update_summary: "上游模型巡检摘要:检测渠道 {{.Checked}} 个,发现变更 {{.Changed}} 个,新增 {{.Added}} 个,删除 {{.Removed}} 个,自动同步新增 {{.AutoAdded}} 个,失败 {{.Failed}} 个。"
|
||||
channel.upstream_update_changed_channels: "变更渠道明细(展示 {{.Shown}}/{{.Total}}):"
|
||||
channel.upstream_update_more_channels: "其余 {{.Count}} 个渠道已省略"
|
||||
channel.upstream_update_added_models: "新增模型示例(展示 {{.Shown}}/{{.Total}}):{{.Models}}"
|
||||
channel.upstream_update_removed_models: "删除模型示例(展示 {{.Shown}}/{{.Total}}):{{.Models}}"
|
||||
channel.upstream_update_failed_channels: "失败渠道 ID(展示 {{.Shown}}/{{.Total}}):{{.Ids}}"
|
||||
channel.upstream_update_more_omitted: "(其余 {{.Count}} 个已省略)"
|
||||
+53
-274
@@ -1,282 +1,23 @@
|
||||
# Chinese (Traditional) translations
|
||||
# 中文(繁體)翻譯檔案
|
||||
|
||||
# Task plugin messages
|
||||
task_plugin.unknown_meta_field: '外掛中繼資料包含未知欄位「{{.Field}}」。如果外掛來自官方市集,可能需要較新版本的 new-api。請嘗試更新 new-api 後重新安裝外掛。'
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "無效的參數"
|
||||
# Errors returned to AI clients
|
||||
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: "名稱不能為空"
|
||||
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "無權進行此操作,未登入且未提供 access token"
|
||||
auth.access_token_invalid: "無權進行此操作,access token 無效"
|
||||
auth.user_info_invalid: "無權進行此操作,使用者資訊無效"
|
||||
auth.user_id_not_provided: "無權進行此操作,未提供 New-Api-User"
|
||||
auth.user_id_format_error: "無權進行此操作,New-Api-User 格式錯誤"
|
||||
auth.user_id_mismatch: "無權進行此操作,New-Api-User 與登入使用者不匹配"
|
||||
auth.user_banned: "使用者已被封禁"
|
||||
auth.insufficient_privilege: "無權進行此操作,權限不足"
|
||||
auth.access_token_expired: "存取權杖已過期"
|
||||
auth.legacy_access_token_retired: "舊版存取權杖已停用,請在安全設定中建立新的存取權杖"
|
||||
auth.access_token_scope_denied: "存取權杖缺少所需權限:{{.Scope}}"
|
||||
auth.access_token_limit: "最多可建立 {{.Count}} 個存取權杖"
|
||||
|
||||
# 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: "無效的令牌,資料庫查詢出錯,請聯繫管理員"
|
||||
token.auto_groups_too_many: "每個令牌最多可選擇 {{.Max}} 個 Auto 分組"
|
||||
token.auto_groups_duplicate: "Auto 分組 {{.Group}} 重複"
|
||||
token.auto_groups_invalid: "Auto 分組 {{.Group}} 不可用或無權存取"
|
||||
|
||||
# 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.email_already_taken: "信箱位址已被占用"
|
||||
user.password_unset: "目前帳號未設定密碼,請使用密碼重置或聯繫管理員重置密碼"
|
||||
user.password_reset_link_invalid: "重置連結非法或已過期"
|
||||
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 為空!"
|
||||
user.quota_change_zero: "額度變更量不能為0"
|
||||
|
||||
# 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"
|
||||
payment.compliance_required: "支付、兌換碼、訂閱方案和邀請返利功能已停用。管理員需先確認合規聲明後方可啟用。"
|
||||
|
||||
# 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 驗證失敗,請重試或聯繫管理員"
|
||||
passkey.rp_id_invalid: "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。"
|
||||
passkey.rp_id_unavailable: "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。"
|
||||
passkey.rp_id_removal_confirmation: "請核對受影響的通行金鑰並確認刪除網域。設定或影響範圍變更後,需要重新確認。"
|
||||
|
||||
# 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
|
||||
auth.client_ip_unresolved: "無法解析用戶端 IP 位址"
|
||||
auth.token_ip_not_allowed: "您的 IP 不在令牌允許存取的清單中"
|
||||
auth.token_group_forbidden: "無權存取 {{.Group}} 分組"
|
||||
auth.token_group_deprecated: "分組 {{.Group}} 已被棄用"
|
||||
auth.channel_select_forbidden: "一般使用者不支援指定管道"
|
||||
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: "該帳號已被其他使用者綁定"
|
||||
oauth.unknown_provider: "未知的 OAuth 供應者"
|
||||
oauth.state_invalid: "state 參數為空或不匹配"
|
||||
oauth.not_enabled: "管理員未開啟通過 {{.Provider}} 登錄以及註冊"
|
||||
oauth.user_deleted: "使用者已註銷"
|
||||
oauth.user_banned: "使用者已被封禁"
|
||||
oauth.bind_success: "綁定成功"
|
||||
oauth.already_bound: "該 {{.Provider}} 帳號已被綁定"
|
||||
oauth.not_auto_linked: "無法自動將該 {{.Provider}} 帳號關聯到現有帳號,請使用其他方式登入或註冊後,在帳號設定中綁定 {{.Provider}}"
|
||||
oauth.connect_failed: "無法連接至 {{.Provider}} 伺服器,請稍後重試"
|
||||
oauth.token_failed: "{{.Provider}} 獲取 Token 失敗,請檢查設定"
|
||||
oauth.user_info_empty: "{{.Provider}} 獲取使用者資訊為空,請檢查設定"
|
||||
oauth.trust_level_low: "Linux DO 信任等級未達到管理員設定的最低信任等級"
|
||||
|
||||
# Model layer error messages
|
||||
redeem.failed: "兌換失敗,請稍後重試"
|
||||
user.create_default_token_error: "建立預設令牌失敗"
|
||||
common.uuid_duplicate: "請重試,系統生成的 UUID 竟然重複了!"
|
||||
common.invalid_input: "輸入不合法"
|
||||
|
||||
# Distributor messages
|
||||
rate_limit.total_reached: "您已達到總請求數限制:{{.Minutes}}分鐘內最多請求{{.Max}}次,包括失敗次數,請檢查您的請求是否正確"
|
||||
distributor.invalid_request: "無效的請求,{{.Error}}"
|
||||
distributor.invalid_channel_id: "無效的管道 Id"
|
||||
distributor.channel_disabled: "該管道已被禁用"
|
||||
distributor.affinity_channel_disabled: "管道親和性命中的管道已被禁用,已按規則停止重試,請聯絡管理員處理"
|
||||
distributor.token_no_model_access: "該令牌無權存取任何模型"
|
||||
distributor.token_model_forbidden: "該令牌無權存取模型 {{.Model}}"
|
||||
distributor.model_name_required: "未指定模型名稱,模型名稱不能為空"
|
||||
@@ -287,12 +28,50 @@ distributor.no_available_channel: "分組 {{.Group}} 下模型 {{.Model}} 無可
|
||||
distributor.no_available_channel_task_plugin: "分組 {{.Group}} 下模型 {{.Model}} 無可用管道:該模型由任務插件認領,但目前沒有啟用的管道可服務此模型(distributor)"
|
||||
distributor.invalid_midjourney_request: "無效的midjourney請求,{{.Error}}"
|
||||
distributor.invalid_request_parse_model: "無效的請求,無法解析模型"
|
||||
quota.user_insufficient: "使用者額度不足,剩餘額度:{{.Remaining}}"
|
||||
quota.pre_consume_insufficient: "預扣費額度失敗,使用者剩餘額度:{{.Remaining}},需要預扣費額度:{{.Required}}"
|
||||
quota.subscription_insufficient: "訂閱額度不足或未設定訂閱:{{.Error}}"
|
||||
relay.model_price_not_configured: "模型 {{.Model}} 的價格尚未由管理員設定,暫時無法使用,請聯絡站點管理員開啟該模型"
|
||||
relay.model_price_not_configured_admin: "模型 {{.Model}} 的價格未設定。請前往「系統設定 → 營運設定」開啟自用模式,或在「系統設定 → 分組與模型定價設定」中為該模型設定價格"
|
||||
relay.task_channel_disabled: "該任務所屬管道已被停用"
|
||||
relay.group_saturated: "目前分組負載已飽和,請稍後再試,或升級帳戶以提升服務品質。"
|
||||
relay.group_upstream_saturated: "目前分組上游負載已飽和,請稍後再試"
|
||||
relay.retry_get_channel_failed: "獲取分組 {{.Group}} 下模型 {{.Model}} 的可用管道失敗(retry):{{.Error}}"
|
||||
relay.retry_no_available_channel: "分組 {{.Group}} 下模型 {{.Model}} 的可用管道不存在(retry)"
|
||||
relay.access_token_unsupported: "暫不支援使用 access token"
|
||||
|
||||
# Custom OAuth provider messages
|
||||
custom_oauth.not_found: "自訂 OAuth 供應者不存在"
|
||||
custom_oauth.slug_empty: "標識符不能為空"
|
||||
custom_oauth.slug_exists: "標識符已存在"
|
||||
custom_oauth.name_empty: "供應者名稱不能為空"
|
||||
custom_oauth.has_bindings: "無法刪除已有使用者綁定的供應者"
|
||||
custom_oauth.binding_not_found: "OAuth 綁定不存在"
|
||||
custom_oauth.provider_id_field_invalid: "無法從供應者響應中提取使用者 ID"
|
||||
# Emails and the name of the token created at registration
|
||||
user.default_token_name: "{{.Username}}的初始令牌"
|
||||
email.verification.subject: "{{.SystemName}}電子郵件驗證"
|
||||
email.verification.greeting: "您好,您正在進行{{.SystemName}}電子郵件驗證。"
|
||||
email.verification.code: "您的驗證碼為: <strong>{{.Code}}</strong>"
|
||||
email.verification.validity: "驗證碼 {{.Minutes}} 分鐘內有效,如果不是本人操作,請忽略。"
|
||||
email.password_reset.subject: "{{.SystemName}}密碼重設"
|
||||
email.password_reset.greeting: "您好,您正在進行{{.SystemName}}密碼重設。"
|
||||
email.password_reset.link: "點擊 <a href='{{.Link}}'>此處</a> 進行密碼重設。"
|
||||
email.password_reset.link_fallback: "如果連結無法點擊,請嘗試點擊下面的連結或將其複製到瀏覽器中開啟:<br> {{.Link}}"
|
||||
email.password_reset.validity: "重設連結 {{.Minutes}} 分鐘內有效,如果不是本人操作,請忽略。"
|
||||
|
||||
# Low-quota notices
|
||||
notify.quota_low_title: "您的額度即將用盡"
|
||||
notify.subscription_quota_low_title: "您的訂閱額度即將用盡"
|
||||
notify.quota_low_bark: "{{.Prompt}},剩餘額度:{{.Quota}},請及時儲值"
|
||||
notify.quota_low_gotify: "{{.Prompt}},目前剩餘額度為 {{.Quota}},請及時儲值。"
|
||||
notify.quota_low_email: "{{.Prompt}},目前剩餘額度為 {{.Quota}},為了不影響您的使用,請及時儲值。<br/>儲值連結:<a href='{{.Link}}'>{{.Link}}</a>"
|
||||
|
||||
# Channel notices sent to the root user and model update watchers
|
||||
channel.notify_disabled_subject: "管道「{{.Name}}」(#{{.Id}})已被停用"
|
||||
channel.notify_disabled_content: "管道「{{.Name}}」(#{{.Id}})已被停用,原因:{{.Reason}}"
|
||||
channel.notify_enabled_subject: "管道「{{.Name}}」(#{{.Id}})已被啟用"
|
||||
channel.notify_enabled_content: "管道「{{.Name}}」(#{{.Id}})已被啟用"
|
||||
channel.test_completed_subject: "管道測試完成"
|
||||
channel.test_completed_content: "所有管道測試已完成"
|
||||
channel.response_time_exceeded: "回應時間 {{.Seconds}}s 超過閾值 {{.Threshold}}s"
|
||||
channel.upstream_update_notify_subject: "上游模型巡檢通知"
|
||||
channel.upstream_update_summary: "上游模型巡檢摘要:檢測管道 {{.Checked}} 個,發現變更 {{.Changed}} 個,新增 {{.Added}} 個,刪除 {{.Removed}} 個,自動同步新增 {{.AutoAdded}} 個,失敗 {{.Failed}} 個。"
|
||||
channel.upstream_update_changed_channels: "變更管道明細(顯示 {{.Shown}}/{{.Total}}):"
|
||||
channel.upstream_update_more_channels: "其餘 {{.Count}} 個管道已省略"
|
||||
channel.upstream_update_added_models: "新增模型範例(顯示 {{.Shown}}/{{.Total}}):{{.Models}}"
|
||||
channel.upstream_update_removed_models: "刪除模型範例(顯示 {{.Shown}}/{{.Total}}):{{.Models}}"
|
||||
channel.upstream_update_failed_channels: "失敗管道 ID(顯示 {{.Shown}}/{{.Total}}):{{.Ids}}"
|
||||
channel.upstream_update_more_omitted: "(其餘 {{.Count}} 個已省略)"
|
||||
+11
-11
@@ -46,7 +46,7 @@ func SetupLogger() {
|
||||
if *common.LogDir != "" {
|
||||
ok := setupLogLock.TryLock()
|
||||
if !ok {
|
||||
log.Println("setup log is already working")
|
||||
log.Println(common.LogText("setup log is already working"))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
@@ -55,7 +55,7 @@ func SetupLogger() {
|
||||
logPath := filepath.Join(*common.LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405")))
|
||||
fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Fatal("failed to open log file")
|
||||
log.Fatal(common.LogText("failed to open log file"))
|
||||
}
|
||||
currentLogPathMu.Lock()
|
||||
oldFile := currentLogFile
|
||||
@@ -88,12 +88,11 @@ func LogError(ctx context.Context, msg string) {
|
||||
logHelper(ctx, loggerError, msg)
|
||||
}
|
||||
|
||||
// LogDebug formats msg with common.LogText only when debug logging is on, so
|
||||
// callers pass the format and its arguments instead of a formatted string.
|
||||
func LogDebug(ctx context.Context, msg string, args ...any) {
|
||||
if common.DebugEnabled {
|
||||
if len(args) > 0 {
|
||||
msg = fmt.Sprintf(msg, args...)
|
||||
}
|
||||
logHelper(ctx, loggerDebug, msg)
|
||||
logHelper(ctx, loggerDebug, common.LogText(msg, args...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +121,7 @@ func logHelper(ctx context.Context, level string, msg string) {
|
||||
}
|
||||
}
|
||||
|
||||
// LogQuota formats a quota for a server log line.
|
||||
func LogQuota(quota int) string {
|
||||
// 新逻辑:根据额度展示类型输出
|
||||
q := float64(quota)
|
||||
@@ -129,7 +129,7 @@ func LogQuota(quota int) string {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
usd := q / common.QuotaPerUnit
|
||||
cny := usd * operation_setting.USDExchangeRate
|
||||
return fmt.Sprintf("¥%.6f 额度", cny)
|
||||
return common.LogText("¥%.6f quota", cny)
|
||||
case operation_setting.QuotaDisplayTypeCustom:
|
||||
usd := q / common.QuotaPerUnit
|
||||
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||
@@ -141,11 +141,11 @@ func LogQuota(quota int) string {
|
||||
rate = 1
|
||||
}
|
||||
v := usd * rate
|
||||
return fmt.Sprintf("%s%.6f 额度", symbol, v)
|
||||
return common.LogText("%s%.6f quota", symbol, v)
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return fmt.Sprintf("%d 点额度", quota)
|
||||
return common.LogText("%d quota points", quota)
|
||||
default: // USD
|
||||
return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit)
|
||||
return common.LogText("$%.6f quota", q/common.QuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func LogJson(ctx context.Context, msg string, obj any) {
|
||||
}
|
||||
jsonStr, err := common.Marshal(obj)
|
||||
if err != nil {
|
||||
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
|
||||
LogError(ctx, common.LogText("json marshal failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
LogDebug(ctx, "%s | %s", msg, jsonStr)
|
||||
|
||||
@@ -59,16 +59,16 @@ func main() {
|
||||
|
||||
err := InitResources()
|
||||
if err != nil {
|
||||
common.FatalLog("failed to initialize resources: " + err.Error())
|
||||
common.FatalLog(common.LogText("failed to initialize resources: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
common.SysLog("New API " + common.Version + " started")
|
||||
common.SysLog(common.LogText("New API %s started", common.Version))
|
||||
if os.Getenv("GIN_MODE") != "debug" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
common.SysLog("running in debug mode")
|
||||
common.SysLog(common.LogText("running in debug mode"))
|
||||
}
|
||||
|
||||
kitutil.Debug.Store(common.DebugEnabled)
|
||||
@@ -76,7 +76,7 @@ func main() {
|
||||
defer func() {
|
||||
err := model.CloseDB()
|
||||
if err != nil {
|
||||
common.FatalLog("failed to close database: " + err.Error())
|
||||
common.FatalLog(common.LogText("failed to close database: %s", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -85,18 +85,18 @@ func main() {
|
||||
common.MemoryCacheEnabled = true
|
||||
}
|
||||
if common.MemoryCacheEnabled {
|
||||
common.SysLog("memory cache enabled")
|
||||
common.SysLog(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency))
|
||||
common.SysLog(common.LogText("memory cache enabled"))
|
||||
common.SysLog(common.LogText("sync frequency: %d seconds", common.SyncFrequency))
|
||||
|
||||
// Add panic recovery and retry for InitChannelCache
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.SysLog(fmt.Sprintf("InitChannelCache panic: %v, retrying once", r))
|
||||
common.SysLog(common.LogText("InitChannelCache panic: %v, retrying once", r))
|
||||
// Retry once
|
||||
_, _, fixErr := model.FixAbility()
|
||||
if fixErr != nil {
|
||||
common.FatalLog(fmt.Sprintf("InitChannelCache failed: %s", fixErr.Error()))
|
||||
common.FatalLog(common.LogText("InitChannelCache failed: %s", fixErr.Error()))
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -124,7 +124,7 @@ func main() {
|
||||
if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" {
|
||||
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY"))
|
||||
if err != nil {
|
||||
common.FatalLog("failed to parse CHANNEL_UPDATE_FREQUENCY: " + err.Error())
|
||||
common.FatalLog(common.LogText("failed to parse CHANNEL_UPDATE_FREQUENCY: %s", err.Error()))
|
||||
}
|
||||
go controller.AutomaticallyUpdateChannels(frequency)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func main() {
|
||||
|
||||
if os.Getenv("BATCH_UPDATE_ENABLED") == "true" {
|
||||
common.BatchUpdateEnabled = true
|
||||
common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s")
|
||||
common.SysLog(common.LogText("batch update enabled with interval %ss", strconv.Itoa(common.BatchUpdateInterval)))
|
||||
model.InitBatchUpdater()
|
||||
}
|
||||
|
||||
@@ -169,22 +169,22 @@ func main() {
|
||||
log.Println(http.ListenAndServe("0.0.0.0:8005", nil))
|
||||
})
|
||||
go common.Monitor()
|
||||
common.SysLog("pprof enabled")
|
||||
common.SysLog(common.LogText("pprof enabled"))
|
||||
}
|
||||
|
||||
err = common.StartPyroScope()
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("start pyroscope error : %v", err))
|
||||
common.SysError(common.LogText("start pyroscope error : %v", err))
|
||||
}
|
||||
|
||||
// Initialize HTTP server
|
||||
server := gin.New()
|
||||
if err := middleware.ConfigureTrustedProxies(server); err != nil {
|
||||
common.FatalLog("failed to configure trusted proxies: " + err.Error())
|
||||
common.FatalLog(common.LogText("failed to configure trusted proxies: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
server.Use(gin.CustomRecovery(func(c *gin.Context, err any) {
|
||||
common.SysLog(fmt.Sprintf("panic detected: %v", err))
|
||||
common.SysLog(common.LogText("panic detected: %v", err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
|
||||
@@ -218,7 +218,7 @@ func main() {
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
common.FatalLog("failed to start HTTP server: " + err.Error())
|
||||
common.FatalLog(common.LogText("failed to start HTTP server: %s", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -229,20 +229,20 @@ func main() {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
sig := <-quit
|
||||
common.SysLog(fmt.Sprintf("received signal: %v, shutting down...", sig))
|
||||
common.SysLog(common.LogText("received signal: %v, shutting down...", sig))
|
||||
|
||||
// SSE streams may run for minutes; give them time to finish before forced exit
|
||||
shutdownTimeout := time.Duration(common.GetEnvOrDefault("SHUTDOWN_TIMEOUT_SECONDS", 120)) * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
common.SysError(fmt.Sprintf("server forced to shutdown: %v", err))
|
||||
common.SysError(common.LogText("server forced to shutdown: %v", err))
|
||||
}
|
||||
// 内存中的看板数据保存入库,避免重启丢失未落库数据 (issue #5679)
|
||||
if common.DataExportEnabled {
|
||||
model.SaveQuotaDataCache()
|
||||
}
|
||||
common.SysLog("server exited")
|
||||
common.SysLog(common.LogText("server exited"))
|
||||
}
|
||||
|
||||
func InjectUmamiAnalytics() {
|
||||
@@ -294,7 +294,7 @@ func InitResources() error {
|
||||
err := godotenv.Load(".env")
|
||||
if err != nil {
|
||||
if common.DebugEnabled {
|
||||
common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
|
||||
common.SysLog(common.LogText("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables."))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,16 +311,16 @@ func InitResources() error {
|
||||
// Initialize SQL Database
|
||||
err = model.InitDB()
|
||||
if err != nil {
|
||||
common.FatalLog("failed to initialize database: " + err.Error())
|
||||
common.FatalLog(common.LogText("failed to initialize database: %s", err.Error()))
|
||||
return err
|
||||
}
|
||||
if err = authz.Init(model.DB); err != nil {
|
||||
common.FatalLog("failed to initialize authorization: " + err.Error())
|
||||
common.FatalLog(common.LogText("failed to initialize authorization: %s", err.Error()))
|
||||
return err
|
||||
}
|
||||
if common.PasswordLoginEncryptionEnabled {
|
||||
if err = model.InitPasswordEncryption(); err != nil {
|
||||
common.FatalLog("failed to initialize password encryption: " + err.Error())
|
||||
common.FatalLog(common.LogText("failed to initialize password encryption: %s", err.Error()))
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -330,7 +330,7 @@ func InitResources() error {
|
||||
// Initialize options, should after model.InitDB()
|
||||
if common.IsMasterNode {
|
||||
if err := model.MigrateRetiredFrontendOptions(); err != nil {
|
||||
common.SysError("failed to migrate retired frontend options: " + err.Error())
|
||||
common.SysError(common.LogText("failed to migrate retired frontend options: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
model.InitOptionMap()
|
||||
@@ -358,10 +358,10 @@ func InitResources() error {
|
||||
// Initialize i18n
|
||||
err = i18n.Init()
|
||||
if err != nil {
|
||||
common.SysError("failed to initialize i18n: " + err.Error())
|
||||
common.SysError(common.LogText("failed to initialize i18n: %s", err.Error()))
|
||||
// Don't return error, i18n is not critical
|
||||
} else {
|
||||
common.SysLog("i18n initialized with languages: " + strings.Join(i18n.SupportedLanguages(), ", "))
|
||||
common.SysLog(common.LogText("i18n initialized with languages: %s", strings.Join(i18n.SupportedLanguages(), ", ")))
|
||||
}
|
||||
// Register user language loader for lazy loading
|
||||
i18n.SetUserLangLoader(model.GetUserLanguage)
|
||||
@@ -369,7 +369,7 @@ func InitResources() error {
|
||||
// Load custom OAuth providers from database
|
||||
err = oauth.LoadCustomProviders()
|
||||
if err != nil {
|
||||
common.SysError("failed to load custom OAuth providers: " + err.Error())
|
||||
common.SysError(common.LogText("failed to load custom OAuth providers: %s", err.Error()))
|
||||
// Don't return error, custom OAuth is not critical
|
||||
}
|
||||
|
||||
|
||||
+47
-57
@@ -59,11 +59,11 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
return
|
||||
}
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_USER_DISABLED", "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned)})
|
||||
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_USER_DISABLED", common.NewMessage("User has been banned"))
|
||||
return
|
||||
}
|
||||
if user.Role < minRole {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"success": false, "code": "AUTH_INSUFFICIENT_PRIVILEGE", "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege)})
|
||||
abortWithWebMessage(c, http.StatusForbidden, "AUTH_INSUFFICIENT_PRIVILEGE", common.NewMessage("Unauthorized, insufficient privileges"))
|
||||
return
|
||||
}
|
||||
var lookup *accessTokenLookup
|
||||
@@ -74,7 +74,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
}
|
||||
}
|
||||
if !validUserInfo(user.Username, user.Role) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_USER_INVALID", "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid)})
|
||||
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_USER_INVALID", common.NewMessage("Unauthorized, invalid user info"))
|
||||
return
|
||||
}
|
||||
setDashboardAuthContext(c, user, identity, useAccessToken)
|
||||
@@ -289,26 +289,27 @@ func requestAccessTokenLookup(c *gin.Context) *accessTokenLookup {
|
||||
func enforceAccessTokenRoute(c *gin.Context, lookup *accessTokenLookup, abort bool) bool {
|
||||
key := c.Request.Method + " " + c.FullPath()
|
||||
rule, declared := AccessTokenRouteRule(key)
|
||||
code, reason, message := "", "", ""
|
||||
code, reason := "", ""
|
||||
var message *common.Message
|
||||
switch {
|
||||
case declared && rule.kind == accessTokenRuleSession:
|
||||
code, reason, message = "AUTH_SESSION_REQUIRED", "session_required", accessTokenSessionRequiredMsg
|
||||
code, reason, message = "AUTH_SESSION_REQUIRED", "session_required", common.NewMessage(accessTokenSessionRequiredMsg)
|
||||
case lookup.legacy:
|
||||
return true
|
||||
case !declared:
|
||||
common.SysError("access token route is not declared: " + key)
|
||||
code, reason, message = "ACCESS_TOKEN_ROUTE_UNDECLARED", "route_undeclared", common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
common.SysError(common.LogText("access token route is not declared: %s", key))
|
||||
code, reason, message = "ACCESS_TOKEN_ROUTE_UNDECLARED", "route_undeclared", common.NewMessage("Unauthorized, insufficient privileges")
|
||||
case rule.kind == accessTokenRuleAny, service.AccessTokenScopeGranted(lookup.token.GetScopes(), rule.scope):
|
||||
return true
|
||||
default:
|
||||
code, reason, message = "ACCESS_TOKEN_SCOPE_DENIED", "scope_denied", common.TranslateMessage(c, i18n.MsgAuthAccessTokenScope, map[string]any{"Scope": rule.scope})
|
||||
code, reason, message = "ACCESS_TOKEN_SCOPE_DENIED", "scope_denied", common.NewMessage("The access token lacks the required permission: {{scope}}", map[string]any{"scope": rule.scope})
|
||||
if abort {
|
||||
setAccessTokenAuditParam(c, "required_scope", rule.scope)
|
||||
}
|
||||
}
|
||||
if abort {
|
||||
setAccessTokenAuditParam(c, "failure_reason", reason)
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"success": false, "code": code, "message": message})
|
||||
abortWithWebMessage(c, http.StatusForbidden, code, message)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -337,7 +338,7 @@ func setAccessTokenContext(c *gin.Context, lookup *accessTokenLookup) {
|
||||
return
|
||||
}
|
||||
if err := model.TouchUserAccessToken(lookup.token.Id, c.ClientIP(), common.GetTimestamp()); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("access token last-use update failed (token_id=%d): %v", lookup.token.Id, err))
|
||||
logger.LogError(c.Request.Context(), common.LogText("access token last-use update failed (token_id=%d): %v", lookup.token.Id, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,29 +371,40 @@ func setDashboardAuthContext(c *gin.Context, user *model.UserBase, identity serv
|
||||
user.WriteContext(c)
|
||||
}
|
||||
|
||||
// abortWithWebMessage answers a web console request; the web console
|
||||
// translates the message, and code, when set, stays a stable error code.
|
||||
func abortWithWebMessage(c *gin.Context, status int, code string, message *common.Message) {
|
||||
var fields []gin.H
|
||||
if code != "" {
|
||||
fields = append(fields, gin.H{"code": code})
|
||||
}
|
||||
common.ApiErrorStatus(c, status, message, fields...)
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func writeDashboardAuthError(c *gin.Context, err error) {
|
||||
if errors.Is(err, model.ErrAccessTokenExpired) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "ACCESS_TOKEN_EXPIRED", "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenExpired)})
|
||||
abortWithWebMessage(c, http.StatusUnauthorized, "ACCESS_TOKEN_EXPIRED", common.NewMessage("Unauthorized, the access token has expired"))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, model.ErrLegacyAccessTokenRetired) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "ACCESS_TOKEN_LEGACY_RETIRED", "message": common.TranslateMessage(c, i18n.MsgAuthLegacyTokenRetired)})
|
||||
abortWithWebMessage(c, http.StatusUnauthorized, "ACCESS_TOKEN_LEGACY_RETIRED", common.NewMessage("Legacy access tokens have been retired. Create a new access token in security settings"))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrAuthTokenExpired) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_TOKEN_EXPIRED", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn)})
|
||||
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_TOKEN_EXPIRED", common.NewMessage("Unauthorized, not logged in and no access token provided"))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrLoginSessionRevoked) || errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_SESSION_REVOKED", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn)})
|
||||
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_SESSION_REVOKED", common.NewMessage("Unauthorized, not logged in and no access token provided"))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrAuthTokenInvalid) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_UNAUTHORIZED", "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid)})
|
||||
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", common.NewMessage("Unauthorized, invalid access token"))
|
||||
return
|
||||
}
|
||||
common.SysLog("dashboard authentication error: " + err.Error())
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"success": false, "code": "AUTH_INTERNAL_ERROR", "message": common.TranslateMessage(c, i18n.MsgDatabaseError)})
|
||||
common.SysLog(common.LogText("dashboard authentication error: %s", err.Error()))
|
||||
abortWithWebMessage(c, http.StatusInternalServerError, "AUTH_INTERNAL_ERROR", common.NewMessage("Database error, please contact the administrator"))
|
||||
}
|
||||
|
||||
func RequirePermission(permission authz.Permission) func(c *gin.Context) {
|
||||
@@ -403,11 +415,7 @@ func RequirePermission(permission authz.Permission) func(c *gin.Context) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
|
||||
})
|
||||
c.Abort()
|
||||
abortWithWebMessage(c, http.StatusForbidden, "", common.NewMessage("Unauthorized, insufficient privileges"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,10 +460,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
key := c.Request.Header.Get("Authorization")
|
||||
if key == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgTokenNotProvided),
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusUnauthorized, errors.New(common.TranslateMessage(c, i18n.MsgTokenNotProvided)))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -469,16 +474,10 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
token, err := model.GetTokenByKey(key, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgTokenInvalid),
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusUnauthorized, errors.New(common.TranslateMessage(c, i18n.MsgTokenInvalid)))
|
||||
} else {
|
||||
common.SysLog("TokenAuthReadOnly GetTokenByKey database error: " + err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
common.SysLog(common.LogText("TokenAuthReadOnly GetTokenByKey database error: %s", err.Error()))
|
||||
common.ApiErrorStatus(c, http.StatusInternalServerError, errors.New(common.TranslateMessage(c, i18n.MsgDatabaseError)))
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
@@ -487,29 +486,20 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
// TokenAuthReadOnly must keep allowing other token states to query read-only
|
||||
// data, such as token usage logs; only explicitly disabled tokens are denied.
|
||||
if token.Status == common.TokenStatusDisabled {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgTokenStatusUnavailable),
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusUnauthorized, errors.New(common.TranslateMessage(c, i18n.MsgTokenStatusUnavailable)))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userCache, err := model.GetUserCache(token.UserId)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
common.SysLog(common.LogText("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
|
||||
common.ApiErrorStatus(c, http.StatusInternalServerError, errors.New(common.TranslateMessage(c, i18n.MsgDatabaseError)))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if userCache.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusForbidden, errors.New(common.TranslateMessage(c, i18n.MsgAuthUserBanned)))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -574,7 +564,7 @@ func TokenAuth() func(c *gin.Context) {
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDatabase) {
|
||||
common.SysLog("TokenAuth ValidateUserToken database error: " + err.Error())
|
||||
common.SysLog(common.LogText("TokenAuth ValidateUserToken database error: %s", err.Error()))
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError,
|
||||
common.TranslateMessage(c, i18n.MsgDatabaseError))
|
||||
} else {
|
||||
@@ -590,11 +580,11 @@ func TokenAuth() func(c *gin.Context) {
|
||||
logger.LogDebug(c, "Token has IP restrictions, checking client IP %s", clientIp)
|
||||
ip := net.ParseIP(clientIp)
|
||||
if ip == nil {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "无法解析客户端 IP 地址")
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthClientIPUnresolved))
|
||||
return
|
||||
}
|
||||
if common.IsIpInCIDRList(ip, allowIps) == false {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中", types.ErrorCodeAccessDenied)
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthTokenIPNotAllowed), types.ErrorCodeAccessDenied)
|
||||
return
|
||||
}
|
||||
logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp)
|
||||
@@ -602,7 +592,7 @@ func TokenAuth() func(c *gin.Context) {
|
||||
|
||||
userCache, err := model.GetUserCache(token.UserId)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
|
||||
common.SysLog(common.LogText("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError,
|
||||
common.TranslateMessage(c, i18n.MsgDatabaseError))
|
||||
return
|
||||
@@ -620,13 +610,13 @@ func TokenAuth() func(c *gin.Context) {
|
||||
if tokenGroup != "" {
|
||||
// check common.UserUsableGroups[userGroup]
|
||||
if _, ok := service.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("无权访问 %s 分组", tokenGroup))
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthTokenGroupForbidden, map[string]any{"Group": tokenGroup}))
|
||||
return
|
||||
}
|
||||
// check group in common.GroupRatio
|
||||
if !ratio_setting.ContainsGroupRatio(tokenGroup) {
|
||||
if tokenGroup != "auto" {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthTokenGroupDeprecated, map[string]any{"Group": tokenGroup}))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -689,7 +679,7 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
|
||||
if token.AutoGroups != "" {
|
||||
autoGroups, err := token.GetAutoGroups()
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
|
||||
common.SysError(common.LogText("failed to parse auto groups for token %d: %v", token.Id, err))
|
||||
autoGroups = []string{}
|
||||
common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
|
||||
} else if len(autoGroups) > 0 {
|
||||
@@ -711,8 +701,8 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
|
||||
})
|
||||
} else {
|
||||
c.Header("specific_channel_version", "701e3ae1dc3f7975556d354e0675168d004891c8")
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道")
|
||||
return fmt.Errorf("普通用户不支持指定渠道")
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthChannelSelectForbidden))
|
||||
return errors.New("regular users cannot specify a channel")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
@@ -90,6 +91,29 @@ func createMiddlewarePATUser(t *testing.T, username, token string) *model.User {
|
||||
return user
|
||||
}
|
||||
|
||||
// Read-only API key routes (/api/usage/token, /api/log/token) are called by
|
||||
// scripts, not the web console, so their errors are translated by the backend
|
||||
// in the language the request asks for.
|
||||
func TestTokenAuthReadOnlyTranslatesErrors(t *testing.T) {
|
||||
require.NoError(t, i18n.Init())
|
||||
for _, tc := range []struct {
|
||||
acceptLanguage string
|
||||
want string
|
||||
}{
|
||||
{"zh-CN", "未提供令牌"},
|
||||
{"en-US", "Token not provided"},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/log/token", nil)
|
||||
c.Request.Header.Set("Accept-Language", tc.acceptLanguage)
|
||||
TokenAuthReadOnly()(c)
|
||||
assert.True(t, c.IsAborted(), tc.acceptLanguage)
|
||||
assert.Equal(t, http.StatusUnauthorized, recorder.Code, tc.acceptLanguage)
|
||||
assert.JSONEq(t, `{"success":false,"message":"`+tc.want+`"}`, recorder.Body.String(), tc.acceptLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAuthAllowsOpaqueDottedPAT(t *testing.T) {
|
||||
setupDashboardAuthMiddlewareTest(t)
|
||||
user := createMiddlewarePATUser(t, "dotted-pat-user", "opaque.key.with-dots")
|
||||
|
||||
@@ -148,7 +148,7 @@ func noAvailableChannelMessage(c *gin.Context, group, modelName string) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.LogWarn(c, "task_plugin subsystem=distribution event=no_available_channel group=%q model=%q plugins=%q reason=no_eligible_channel", group, modelName, strings.Join(keys, ","))
|
||||
logger.LogWarn(c, common.LogText("task_plugin subsystem=distribution event=no_available_channel group=%q model=%q plugins=%q reason=no_eligible_channel", group, modelName, strings.Join(keys, ",")))
|
||||
return i18n.T(c, i18n.MsgDistributorNoAvailableChannelTaskPlugin, map[string]any{"Group": group, "Model": modelName})
|
||||
}
|
||||
return i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": group, "Model": modelName})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -36,10 +35,7 @@ func redisEmailVerificationRateLimiter(c *gin.Context) {
|
||||
waitSeconds = ttlSeconds
|
||||
}
|
||||
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("发送过于频繁,请等待 %d 秒后再试", waitSeconds),
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusTooManyRequests, common.NewMessage("Please wait {{seconds}} seconds before requesting another verification code.", map[string]any{"seconds": waitSeconds}))
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
@@ -47,10 +43,7 @@ func memoryEmailVerificationRateLimiter(c *gin.Context) {
|
||||
key := EmailVerificationRateLimitMark + ":" + c.ClientIP()
|
||||
|
||||
if !inMemoryRateLimiter.Request(key, EmailVerificationMaxRequests, EmailVerificationDuration) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"success": false,
|
||||
"message": "发送过于频繁,请稍后再试",
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusTooManyRequests, common.NewMessage("Please wait before requesting another verification code."))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
+3
-20
@@ -18,8 +18,8 @@ func I18n() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// detectLanguage determines the language preference for the request
|
||||
// Priority: 1. User setting (if logged in) -> 2. Accept-Language header -> 3. Default language
|
||||
// detectLanguage returns the language the request states, or "" when it states
|
||||
// none. Priority: 1. User setting (if logged in) -> 2. Accept-Language header
|
||||
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 {
|
||||
@@ -29,22 +29,5 @@ func detectLanguage(c *gin.Context) string {
|
||||
}
|
||||
|
||||
// 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
|
||||
return i18n.ParseAcceptLanguage(c.GetHeader("Accept-Language"))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/common/limiter"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
|
||||
@@ -88,12 +89,12 @@ func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) g
|
||||
successKey := fmt.Sprintf("rateLimit:%s:%s", ModelRequestRateLimitSuccessCountMark, userId)
|
||||
allowed, err := checkRedisRateLimit(ctx, rdb, successKey, successMaxCount, duration)
|
||||
if err != nil {
|
||||
fmt.Println("检查成功请求数限制失败:", err.Error())
|
||||
fmt.Println(common.LogText("failed to check the successful request limit: %s", err.Error()))
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError, "rate_limit_check_failed")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到请求数限制:%d分钟内最多请求%d次", setting.ModelRequestRateLimitDurationMinutes, successMaxCount))
|
||||
abortWithOpenAiMessage(c, http.StatusTooManyRequests, i18n.T(c, i18n.MsgRateLimitReached, map[string]any{"Minutes": setting.ModelRequestRateLimitDurationMinutes, "Max": successMaxCount}))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,13 +112,13 @@ func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) g
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("检查总请求数限制失败:", err.Error())
|
||||
fmt.Println(common.LogText("failed to check the total request limit: %s", err.Error()))
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError, "rate_limit_check_failed")
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到总请求数限制:%d分钟内最多请求%d次,包括失败次数,请检查您的请求是否正确", setting.ModelRequestRateLimitDurationMinutes, totalMaxCount))
|
||||
abortWithOpenAiMessage(c, http.StatusTooManyRequests, i18n.T(c, i18n.MsgRateLimitTotalReached, map[string]any{"Minutes": setting.ModelRequestRateLimitDurationMinutes, "Max": totalMaxCount}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark st
|
||||
duration,
|
||||
)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("rate limit check failed (mark=%s): %v", mark, err))
|
||||
logger.LogError(c.Request.Context(), common.LogText("rate limit check failed (mark=%s): %v", mark, err))
|
||||
c.Status(http.StatusInternalServerError)
|
||||
c.Abort()
|
||||
return
|
||||
@@ -250,7 +250,7 @@ func userRateLimitFactory(maxRequestNum int, duration int64, mark string) func(c
|
||||
func userRedisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, key string) {
|
||||
allowed, _, ttlSeconds, err := redisFixedWindowTake(c.Request.Context(), key, maxRequestNum, duration)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("rate limit check failed (key=%s): %v", key, err))
|
||||
logger.LogError(c.Request.Context(), common.LogText("rate limit check failed (key=%s): %v", key, err))
|
||||
c.Status(http.StatusInternalServerError)
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestRedisEmailVerificationRateLimiterPreservesResponseAndTTL(t *testing.T)
|
||||
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/verify", remoteAddr).Code)
|
||||
response := performRateLimitRequest(router, "/verify", remoteAddr)
|
||||
assert.Equal(t, http.StatusTooManyRequests, response.Code)
|
||||
assert.JSONEq(t, `{"success":false,"message":"发送过于频繁,请等待 30 秒后再试"}`, response.Body.String())
|
||||
assert.JSONEq(t, `{"success":false,"message":"Please wait 30 seconds before requesting another verification code.","message_key":"Please wait {{seconds}} seconds before requesting another verification code.","message_params":{"seconds":30}}`, response.Body.String())
|
||||
|
||||
key := redisIPRateLimitKey(EmailVerificationRateLimitMark, "192.0.2.30")
|
||||
assert.True(t, redisServer.Exists(key))
|
||||
|
||||
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user