mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-11 03:37:27 +00:00
Compare commits
38 Commits
feature/su
...
v0.10.8-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c7687f952 | ||
|
|
a21ee5f9ed | ||
|
|
b23bae587a | ||
|
|
acfcff368a | ||
|
|
c4b6f8eef0 | ||
|
|
f3e6585441 | ||
|
|
89a10cf3f7 | ||
|
|
a4617097fb | ||
|
|
67613e0642 | ||
|
|
32fae53a3f | ||
|
|
42b5aeaae4 | ||
|
|
7e13a01a96 | ||
|
|
f60fce6584 | ||
|
|
ded79c7684 | ||
|
|
ca91d6992e | ||
|
|
65b2ca4176 | ||
|
|
7a4fc68bcc | ||
|
|
7cfed0df8e | ||
|
|
564f407a6b | ||
|
|
117c9a8699 | ||
|
|
e2ebd42a8c | ||
|
|
9ef7740fe7 | ||
|
|
89b2782675 | ||
|
|
e7d5c61d53 | ||
|
|
bb54ed91dc | ||
|
|
d8d1f141c2 | ||
|
|
dd467ed592 | ||
|
|
f1e6c1bf77 | ||
|
|
1ee80930d4 | ||
|
|
35a4c586aa | ||
|
|
85b5d0100a | ||
|
|
6a9522ac5b | ||
|
|
3b76b770b9 | ||
|
|
59c076978e | ||
|
|
5889856a55 | ||
|
|
9da3412fde | ||
|
|
8d67c571e4 | ||
|
|
34ac066f36 |
32
.github/workflows/docker-image-arm64.yml
vendored
32
.github/workflows/docker-image-arm64.yml
vendored
@@ -4,6 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag name to build (e.g., v0.10.8-alpha.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build_single_arch:
|
||||
@@ -25,15 +31,24 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Check out (shallow)
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve tag & write VERSION
|
||||
run: |
|
||||
git fetch --tags --force --depth=1
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
# Verify tag exists
|
||||
if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: Tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
fi
|
||||
echo "TAG=$TAG" >> $GITHUB_ENV
|
||||
echo "$TAG" > VERSION
|
||||
echo "Building tag: $TAG for ${{ matrix.arch }}"
|
||||
@@ -87,10 +102,15 @@ jobs:
|
||||
name: Create multi-arch manifests (Docker Hub)
|
||||
needs: [build_single_arch]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Extract tag
|
||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||
echo "TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
fi
|
||||
#
|
||||
# - name: Normalize GHCR repository
|
||||
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||
|
||||
@@ -445,6 +445,14 @@ Bienvenue à toutes les formes de contribution!
|
||||
|
||||
---
|
||||
|
||||
## 📜 Licence
|
||||
|
||||
Ce projet est sous licence [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
|
||||
|
||||
Si les politiques de votre organisation ne permettent pas l'utilisation de logiciels sous licence AGPLv3, ou si vous souhaitez éviter les obligations open-source de l'AGPLv3, veuillez nous contacter à : [support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Historique des étoiles
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -445,6 +445,14 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
---
|
||||
|
||||
## 📜 ライセンス
|
||||
|
||||
このプロジェクトは [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE) の下でライセンスされています。
|
||||
|
||||
お客様の組織のポリシーがAGPLv3ライセンスのソフトウェアの使用を許可していない場合、またはAGPLv3のオープンソース義務を回避したい場合は、こちらまでお問い合わせください:[support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
---
|
||||
|
||||
## 🌟 スター履歴
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -445,6 +445,14 @@ Welcome all forms of contribution!
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
|
||||
|
||||
If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -445,6 +445,14 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
---
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
本项目采用 [GNU Affero 通用公共许可证 v3.0 (AGPLv3)](./LICENSE) 授权。
|
||||
|
||||
如果您所在的组织政策不允许使用 AGPLv3 许可的软件,或您希望规避 AGPLv3 的开源义务,请发送邮件至:[support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -5,12 +5,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// BodyStorage 请求体存储接口
|
||||
@@ -101,25 +98,10 @@ type diskStorage struct {
|
||||
}
|
||||
|
||||
func newDiskStorage(data []byte, cachePath string) (*diskStorage, error) {
|
||||
// 确定缓存目录
|
||||
dir := cachePath
|
||||
if dir == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
dir = filepath.Join(dir, "new-api-body-cache")
|
||||
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// 创建临时文件
|
||||
filename := fmt.Sprintf("body-%s-%d.tmp", uuid.New().String()[:8], time.Now().UnixNano())
|
||||
filePath := filepath.Join(dir, filename)
|
||||
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
|
||||
// 使用统一的缓存目录管理
|
||||
filePath, file, err := CreateDiskCacheFile(DiskCacheTypeBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 写入数据
|
||||
@@ -148,25 +130,10 @@ func newDiskStorage(data []byte, cachePath string) (*diskStorage, error) {
|
||||
}
|
||||
|
||||
func newDiskStorageFromReader(reader io.Reader, maxBytes int64, cachePath string) (*diskStorage, error) {
|
||||
// 确定缓存目录
|
||||
dir := cachePath
|
||||
if dir == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
dir = filepath.Join(dir, "new-api-body-cache")
|
||||
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// 创建临时文件
|
||||
filename := fmt.Sprintf("body-%s-%d.tmp", uuid.New().String()[:8], time.Now().UnixNano())
|
||||
filePath := filepath.Join(dir, filename)
|
||||
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
|
||||
// 使用统一的缓存目录管理
|
||||
filePath, file, err := CreateDiskCacheFile(DiskCacheTypeBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 从 reader 读取并写入文件
|
||||
@@ -337,29 +304,6 @@ func CreateBodyStorageFromReader(reader io.Reader, contentLength int64, maxBytes
|
||||
|
||||
// CleanupOldCacheFiles 清理旧的缓存文件(用于启动时清理残留)
|
||||
func CleanupOldCacheFiles() {
|
||||
cachePath := GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(cachePath, "new-api-body-cache")
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return // 目录不存在或无法读取
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// 删除超过 5 分钟的旧文件
|
||||
if now.Sub(info.ModTime()) > 5*time.Minute {
|
||||
os.Remove(filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
// 使用统一的缓存管理
|
||||
CleanupOldDiskCacheFiles(5 * time.Minute)
|
||||
}
|
||||
|
||||
176
common/disk_cache.go
Normal file
176
common/disk_cache.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DiskCacheType 磁盘缓存类型
|
||||
type DiskCacheType string
|
||||
|
||||
const (
|
||||
DiskCacheTypeBody DiskCacheType = "body" // 请求体缓存
|
||||
DiskCacheTypeFile DiskCacheType = "file" // 文件数据缓存
|
||||
)
|
||||
|
||||
// 统一的缓存目录名
|
||||
const diskCacheDir = "new-api-body-cache"
|
||||
|
||||
// GetDiskCacheDir 获取统一的磁盘缓存目录
|
||||
// 注意:每次调用都会重新计算,以响应配置变化
|
||||
func GetDiskCacheDir() string {
|
||||
cachePath := GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
return filepath.Join(cachePath, diskCacheDir)
|
||||
}
|
||||
|
||||
// EnsureDiskCacheDir 确保缓存目录存在
|
||||
func EnsureDiskCacheDir() error {
|
||||
dir := GetDiskCacheDir()
|
||||
return os.MkdirAll(dir, 0755)
|
||||
}
|
||||
|
||||
// CreateDiskCacheFile 创建磁盘缓存文件
|
||||
// cacheType: 缓存类型(body/file)
|
||||
// 返回文件路径和文件句柄
|
||||
func CreateDiskCacheFile(cacheType DiskCacheType) (string, *os.File, error) {
|
||||
if err := EnsureDiskCacheDir(); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
dir := GetDiskCacheDir()
|
||||
filename := fmt.Sprintf("%s-%s-%d.tmp", cacheType, uuid.New().String()[:8], time.Now().UnixNano())
|
||||
filePath := filepath.Join(dir, filename)
|
||||
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create cache file: %w", err)
|
||||
}
|
||||
|
||||
return filePath, file, nil
|
||||
}
|
||||
|
||||
// WriteDiskCacheFile 写入数据到磁盘缓存文件
|
||||
// 返回文件路径
|
||||
func WriteDiskCacheFile(cacheType DiskCacheType, data []byte) (string, error) {
|
||||
filePath, file, err := CreateDiskCacheFile(cacheType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = file.Write(data)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
os.Remove(filePath)
|
||||
return "", fmt.Errorf("failed to write cache file: %w", err)
|
||||
}
|
||||
|
||||
if err := file.Close(); err != nil {
|
||||
os.Remove(filePath)
|
||||
return "", fmt.Errorf("failed to close cache file: %w", err)
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// WriteDiskCacheFileString 写入字符串到磁盘缓存文件
|
||||
func WriteDiskCacheFileString(cacheType DiskCacheType, data string) (string, error) {
|
||||
return WriteDiskCacheFile(cacheType, []byte(data))
|
||||
}
|
||||
|
||||
// ReadDiskCacheFile 读取磁盘缓存文件
|
||||
func ReadDiskCacheFile(filePath string) ([]byte, error) {
|
||||
return os.ReadFile(filePath)
|
||||
}
|
||||
|
||||
// ReadDiskCacheFileString 读取磁盘缓存文件为字符串
|
||||
func ReadDiskCacheFileString(filePath string) (string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// RemoveDiskCacheFile 删除磁盘缓存文件
|
||||
func RemoveDiskCacheFile(filePath string) error {
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
// CleanupOldDiskCacheFiles 清理旧的缓存文件
|
||||
// maxAge: 文件最大存活时间
|
||||
// 注意:此函数只删除文件,不更新统计(因为无法知道每个文件的原始大小)
|
||||
func CleanupOldDiskCacheFiles(maxAge time.Duration) error {
|
||||
dir := GetDiskCacheDir()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // 目录不存在,无需清理
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(info.ModTime()) > maxAge {
|
||||
// 注意:后台清理任务删除文件时,由于无法得知原始 base64Size,
|
||||
// 只能按磁盘文件大小扣减。这在目前 base64 存储模式下是准确的。
|
||||
if err := os.Remove(filepath.Join(dir, entry.Name())); err == nil {
|
||||
DecrementDiskFiles(info.Size())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDiskCacheInfo 获取磁盘缓存目录信息
|
||||
func GetDiskCacheInfo() (fileCount int, totalSize int64, err error) {
|
||||
dir := GetDiskCacheDir()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fileCount++
|
||||
totalSize += info.Size()
|
||||
}
|
||||
return fileCount, totalSize, nil
|
||||
}
|
||||
|
||||
// ShouldUseDiskCache 判断是否应该使用磁盘缓存
|
||||
func ShouldUseDiskCache(dataSize int64) bool {
|
||||
if !IsDiskCacheEnabled() {
|
||||
return false
|
||||
}
|
||||
threshold := GetDiskCacheThresholdBytes()
|
||||
if dataSize < threshold {
|
||||
return false
|
||||
}
|
||||
return IsDiskCacheAvailable(dataSize)
|
||||
}
|
||||
@@ -113,8 +113,12 @@ func IncrementDiskFiles(size int64) {
|
||||
|
||||
// DecrementDiskFiles 减少磁盘文件计数
|
||||
func DecrementDiskFiles(size int64) {
|
||||
atomic.AddInt64(&diskCacheStats.ActiveDiskFiles, -1)
|
||||
atomic.AddInt64(&diskCacheStats.CurrentDiskUsageBytes, -size)
|
||||
if atomic.AddInt64(&diskCacheStats.ActiveDiskFiles, -1) < 0 {
|
||||
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, 0)
|
||||
}
|
||||
if atomic.AddInt64(&diskCacheStats.CurrentDiskUsageBytes, -size) < 0 {
|
||||
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// IncrementMemoryBuffers 增加内存缓存计数
|
||||
@@ -139,12 +143,29 @@ func IncrementMemoryCacheHits() {
|
||||
atomic.AddInt64(&diskCacheStats.MemoryCacheHits, 1)
|
||||
}
|
||||
|
||||
// ResetDiskCacheStats 重置统计信息(不重置当前使用量)
|
||||
// ResetDiskCacheStats 重置命中统计信息(不重置当前使用量)
|
||||
func ResetDiskCacheStats() {
|
||||
atomic.StoreInt64(&diskCacheStats.DiskCacheHits, 0)
|
||||
atomic.StoreInt64(&diskCacheStats.MemoryCacheHits, 0)
|
||||
}
|
||||
|
||||
// ResetDiskCacheUsage 重置磁盘缓存使用量统计(用于清理缓存后)
|
||||
func ResetDiskCacheUsage() {
|
||||
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, 0)
|
||||
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, 0)
|
||||
}
|
||||
|
||||
// SyncDiskCacheStats 从实际磁盘状态同步统计信息
|
||||
// 用于修正统计与实际不符的情况
|
||||
func SyncDiskCacheStats() {
|
||||
fileCount, totalSize, err := GetDiskCacheInfo()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, int64(fileCount))
|
||||
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, totalSize)
|
||||
}
|
||||
|
||||
// IsDiskCacheAvailable 检查是否可以创建新的磁盘缓存
|
||||
func IsDiskCacheAvailable(requestSize int64) bool {
|
||||
if !IsDiskCacheEnabled() {
|
||||
|
||||
@@ -218,6 +218,39 @@ func ApiSuccess(c *gin.Context, data any) {
|
||||
})
|
||||
}
|
||||
|
||||
// ApiErrorI18n returns a translated error message based on the user's language preference
|
||||
// key is the i18n message key, args is optional template data
|
||||
func ApiErrorI18n(c *gin.Context, key string, args ...map[string]any) {
|
||||
msg := TranslateMessage(c, key, args...)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// ApiSuccessI18n returns a translated success message based on the user's language preference
|
||||
func ApiSuccessI18n(c *gin.Context, key string, data any, args ...map[string]any) {
|
||||
msg := TranslateMessage(c, key, args...)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": msg,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
// TranslateMessage is a helper function that calls i18n.T
|
||||
// This function is defined here to avoid circular imports
|
||||
// The actual implementation will be set during init
|
||||
var TranslateMessage func(c *gin.Context, key string, args ...map[string]any) string
|
||||
|
||||
func init() {
|
||||
// Default implementation that returns the key as-is
|
||||
// This will be replaced by i18n.T during i18n initialization
|
||||
TranslateMessage = func(c *gin.Context, key string, args ...map[string]any) string {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
|
||||
requestBody, err := GetRequestBody(c)
|
||||
if err != nil {
|
||||
|
||||
@@ -137,7 +137,6 @@ func initConstantEnv() {
|
||||
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
|
||||
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
|
||||
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
|
||||
constant.GeminiVisionMaxImageNum = GetEnvOrDefault("GEMINI_VISION_MAX_IMAGE_NUM", 16)
|
||||
constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
|
||||
constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
|
||||
// GenerateDefaultToken 是否生成初始令牌,默认关闭。
|
||||
|
||||
33
common/performance_config.go
Normal file
33
common/performance_config.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package common
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// PerformanceMonitorConfig 性能监控配置
|
||||
type PerformanceMonitorConfig struct {
|
||||
Enabled bool
|
||||
CPUThreshold int
|
||||
MemoryThreshold int
|
||||
DiskThreshold int
|
||||
}
|
||||
|
||||
var performanceMonitorConfig atomic.Value
|
||||
|
||||
func init() {
|
||||
// 初始化默认配置
|
||||
performanceMonitorConfig.Store(PerformanceMonitorConfig{
|
||||
Enabled: true,
|
||||
CPUThreshold: 90,
|
||||
MemoryThreshold: 90,
|
||||
DiskThreshold: 90,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPerformanceMonitorConfig 获取性能监控配置
|
||||
func GetPerformanceMonitorConfig() PerformanceMonitorConfig {
|
||||
return performanceMonitorConfig.Load().(PerformanceMonitorConfig)
|
||||
}
|
||||
|
||||
// SetPerformanceMonitorConfig 设置性能监控配置
|
||||
func SetPerformanceMonitorConfig(config PerformanceMonitorConfig) {
|
||||
performanceMonitorConfig.Store(config)
|
||||
}
|
||||
81
common/system_monitor.go
Normal file
81
common/system_monitor.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/cpu"
|
||||
"github.com/shirou/gopsutil/mem"
|
||||
)
|
||||
|
||||
// DiskSpaceInfo 磁盘空间信息
|
||||
type DiskSpaceInfo struct {
|
||||
// 总空间(字节)
|
||||
Total uint64 `json:"total"`
|
||||
// 可用空间(字节)
|
||||
Free uint64 `json:"free"`
|
||||
// 已用空间(字节)
|
||||
Used uint64 `json:"used"`
|
||||
// 使用百分比
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
}
|
||||
|
||||
// SystemStatus 系统状态信息
|
||||
type SystemStatus struct {
|
||||
CPUUsage float64
|
||||
MemoryUsage float64
|
||||
DiskUsage float64
|
||||
}
|
||||
|
||||
var latestSystemStatus atomic.Value
|
||||
|
||||
func init() {
|
||||
latestSystemStatus.Store(SystemStatus{})
|
||||
}
|
||||
|
||||
// StartSystemMonitor 启动系统监控
|
||||
func StartSystemMonitor() {
|
||||
go func() {
|
||||
for {
|
||||
config := GetPerformanceMonitorConfig()
|
||||
if !config.Enabled {
|
||||
time.Sleep(30 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
updateSystemStatus()
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func updateSystemStatus() {
|
||||
var status SystemStatus
|
||||
|
||||
// CPU
|
||||
// 注意:cpu.Percent(0, false) 返回自上次调用以来的 CPU 使用率
|
||||
// 如果是第一次调用,可能会返回错误或不准确的值,但在循环中会逐渐正常
|
||||
percents, err := cpu.Percent(0, false)
|
||||
if err == nil && len(percents) > 0 {
|
||||
status.CPUUsage = percents[0]
|
||||
}
|
||||
|
||||
// Memory
|
||||
memInfo, err := mem.VirtualMemory()
|
||||
if err == nil {
|
||||
status.MemoryUsage = memInfo.UsedPercent
|
||||
}
|
||||
|
||||
// Disk
|
||||
diskInfo := GetDiskSpaceInfo()
|
||||
if diskInfo.Total > 0 {
|
||||
status.DiskUsage = diskInfo.UsedPercent
|
||||
}
|
||||
|
||||
latestSystemStatus.Store(status)
|
||||
}
|
||||
|
||||
// GetSystemStatus 获取当前系统状态
|
||||
func GetSystemStatus() SystemStatus {
|
||||
return latestSystemStatus.Load().(SystemStatus)
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package controller
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// getDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Unix/Linux/macOS)
|
||||
func getDiskSpaceInfo() DiskSpaceInfo {
|
||||
cachePath := common.GetDiskCachePath()
|
||||
// GetDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Unix/Linux/macOS)
|
||||
func GetDiskSpaceInfo() DiskSpaceInfo {
|
||||
cachePath := GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package controller
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// getDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Windows)
|
||||
func getDiskSpaceInfo() DiskSpaceInfo {
|
||||
cachePath := common.GetDiskCachePath()
|
||||
// GetDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Windows)
|
||||
func GetDiskSpaceInfo() DiskSpaceInfo {
|
||||
cachePath := GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
@@ -56,7 +56,13 @@ const (
|
||||
|
||||
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
|
||||
|
||||
// ContextKeyFileSourcesToCleanup stores file sources that need cleanup when request ends
|
||||
ContextKeyFileSourcesToCleanup ContextKey = "file_sources_to_cleanup"
|
||||
|
||||
// ContextKeyAdminRejectReason stores an admin-only reject/block reason extracted from upstream responses.
|
||||
// It is not returned to end users, but can be persisted into consume/error logs for debugging.
|
||||
ContextKeyAdminRejectReason ContextKey = "admin_reject_reason"
|
||||
|
||||
// ContextKeyLanguage stores the user's language preference for i18n
|
||||
ContextKeyLanguage ContextKey = "language"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ var GetMediaTokenNotStream bool
|
||||
var UpdateTask bool
|
||||
var MaxRequestBodyMB int
|
||||
var AzureDefaultAPIVersion string
|
||||
var GeminiVisionMaxImageNum int
|
||||
var NotifyLimitCount int
|
||||
var NotificationLimitDurationMinute int
|
||||
var GenerateDefaultToken bool
|
||||
|
||||
@@ -89,7 +89,8 @@ func GetAllChannels(c *gin.Context) {
|
||||
if enableTagMode {
|
||||
tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to get paginated tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
@@ -136,7 +137,8 @@ func GetAllChannels(c *gin.Context) {
|
||||
|
||||
err := baseQuery.Order(order).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to get channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -641,7 +643,8 @@ func RefreshCodexChannelCredential(c *gin.Context) {
|
||||
|
||||
oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to refresh codex channel credential: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1315,7 +1318,8 @@ func CopyChannel(c *gin.Context) {
|
||||
// fetch original channel with key
|
||||
origin, err := model.GetChannelById(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to get channel by id: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道信息失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1333,7 +1337,8 @@ func CopyChannel(c *gin.Context) {
|
||||
|
||||
// insert
|
||||
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to clone channel: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
model.InitChannelCache()
|
||||
|
||||
@@ -132,7 +132,8 @@ func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) {
|
||||
|
||||
code, state, err := parseCodexAuthorizationInput(req.Input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to parse codex authorization input: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析授权信息失败,请检查输入格式"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
@@ -177,7 +178,8 @@ func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) {
|
||||
|
||||
tokenRes, err := service.ExchangeCodexAuthorizationCode(ctx, code, verifier)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to exchange codex authorization code: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "授权码交换失败,请重试"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ func GetCodexChannelUsage(c *gin.Context) {
|
||||
|
||||
oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to parse oauth key: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"})
|
||||
return
|
||||
}
|
||||
accessToken := strings.TrimSpace(oauthKey.AccessToken)
|
||||
@@ -70,7 +71,8 @@ func GetCodexChannelUsage(c *gin.Context) {
|
||||
|
||||
statusCode, body, err := service.FetchCodexWhamUsage(ctx, client, ch.GetBaseURL(), accessToken, accountID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to fetch codex usage: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取用量信息失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,7 +101,8 @@ func GetCodexChannelUsage(c *gin.Context) {
|
||||
defer cancel2()
|
||||
statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to fetch codex usage after refresh: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取用量信息失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ func MigrateConsoleSetting(c *gin.Context) {
|
||||
// 读取全部 option
|
||||
opts, err := model.AllOption()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to get all options: " + err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "获取配置失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
// 建立 map
|
||||
|
||||
@@ -20,7 +20,8 @@ func GetAllLogs(c *gin.Context) {
|
||||
modelName := c.Query("model_name")
|
||||
channel, _ := strconv.Atoi(c.Query("channel"))
|
||||
group := c.Query("group")
|
||||
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group)
|
||||
requestId := c.Query("request_id")
|
||||
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -40,7 +41,8 @@ func GetUserLogs(c *gin.Context) {
|
||||
tokenName := c.Query("token_name")
|
||||
modelName := c.Query("model_name")
|
||||
group := c.Query("group")
|
||||
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group)
|
||||
requestId := c.Query("request_id")
|
||||
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
@@ -272,7 +272,8 @@ func SyncUpstreamModels(c *gin.Context) {
|
||||
// 1) 获取未配置模型列表
|
||||
missing, err := model.GetMissingModels()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to get missing models: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取模型列表失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package controller
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,7 +19,7 @@ type PerformanceStats struct {
|
||||
// 磁盘缓存目录信息
|
||||
DiskCacheInfo DiskCacheInfo `json:"disk_cache_info"`
|
||||
// 磁盘空间信息
|
||||
DiskSpaceInfo DiskSpaceInfo `json:"disk_space_info"`
|
||||
DiskSpaceInfo common.DiskSpaceInfo `json:"disk_space_info"`
|
||||
// 配置信息
|
||||
Config PerformanceConfig `json:"config"`
|
||||
}
|
||||
@@ -50,18 +50,6 @@ type DiskCacheInfo struct {
|
||||
TotalSize int64 `json:"total_size"`
|
||||
}
|
||||
|
||||
// DiskSpaceInfo 磁盘空间信息
|
||||
type DiskSpaceInfo struct {
|
||||
// 总空间(字节)
|
||||
Total uint64 `json:"total"`
|
||||
// 可用空间(字节)
|
||||
Free uint64 `json:"free"`
|
||||
// 已用空间(字节)
|
||||
Used uint64 `json:"used"`
|
||||
// 使用百分比
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
}
|
||||
|
||||
// PerformanceConfig 性能配置
|
||||
type PerformanceConfig struct {
|
||||
// 是否启用磁盘缓存
|
||||
@@ -74,11 +62,21 @@ type PerformanceConfig struct {
|
||||
DiskCachePath string `json:"disk_cache_path"`
|
||||
// 是否在容器中运行
|
||||
IsRunningInContainer bool `json:"is_running_in_container"`
|
||||
|
||||
// MonitorEnabled 是否启用性能监控
|
||||
MonitorEnabled bool `json:"monitor_enabled"`
|
||||
// MonitorCPUThreshold CPU 使用率阈值(%)
|
||||
MonitorCPUThreshold int `json:"monitor_cpu_threshold"`
|
||||
// MonitorMemoryThreshold 内存使用率阈值(%)
|
||||
MonitorMemoryThreshold int `json:"monitor_memory_threshold"`
|
||||
// MonitorDiskThreshold 磁盘使用率阈值(%)
|
||||
MonitorDiskThreshold int `json:"monitor_disk_threshold"`
|
||||
}
|
||||
|
||||
// GetPerformanceStats 获取性能统计信息
|
||||
func GetPerformanceStats(c *gin.Context) {
|
||||
// 获取缓存统计
|
||||
// 不再每次获取统计都全量扫描磁盘,依赖原子计数器保证性能
|
||||
// 仅在系统启动或显式清理时同步
|
||||
cacheStats := common.GetDiskCacheStats()
|
||||
|
||||
// 获取内存统计
|
||||
@@ -90,16 +88,30 @@ func GetPerformanceStats(c *gin.Context) {
|
||||
|
||||
// 获取配置信息
|
||||
diskConfig := common.GetDiskCacheConfig()
|
||||
monitorConfig := common.GetPerformanceMonitorConfig()
|
||||
config := PerformanceConfig{
|
||||
DiskCacheEnabled: diskConfig.Enabled,
|
||||
DiskCacheThresholdMB: diskConfig.ThresholdMB,
|
||||
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
|
||||
DiskCachePath: diskConfig.Path,
|
||||
IsRunningInContainer: common.IsRunningInContainer(),
|
||||
DiskCacheEnabled: diskConfig.Enabled,
|
||||
DiskCacheThresholdMB: diskConfig.ThresholdMB,
|
||||
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
|
||||
DiskCachePath: diskConfig.Path,
|
||||
IsRunningInContainer: common.IsRunningInContainer(),
|
||||
MonitorEnabled: monitorConfig.Enabled,
|
||||
MonitorCPUThreshold: monitorConfig.CPUThreshold,
|
||||
MonitorMemoryThreshold: monitorConfig.MemoryThreshold,
|
||||
MonitorDiskThreshold: monitorConfig.DiskThreshold,
|
||||
}
|
||||
|
||||
// 获取磁盘空间信息
|
||||
diskSpaceInfo := getDiskSpaceInfo()
|
||||
// 使用缓存的系统状态,避免频繁调用系统 API
|
||||
systemStatus := common.GetSystemStatus()
|
||||
diskSpaceInfo := common.DiskSpaceInfo{
|
||||
UsedPercent: systemStatus.DiskUsage,
|
||||
}
|
||||
// 如果需要详细信息,可以按需获取,或者扩展 SystemStatus
|
||||
// 这里为了保持接口兼容性,我们仍然调用 GetDiskSpaceInfo,但注意这可能会有性能开销
|
||||
// 考虑到 GetPerformanceStats 是管理接口,频率较低,直接调用是可以接受的
|
||||
// 但为了一致性,我们也可以考虑从 SystemStatus 中获取部分信息
|
||||
diskSpaceInfo = common.GetDiskSpaceInfo()
|
||||
|
||||
stats := PerformanceStats{
|
||||
CacheStats: cacheStats,
|
||||
@@ -121,27 +133,19 @@ func GetPerformanceStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ClearDiskCache 清理磁盘缓存
|
||||
// ClearDiskCache 清理不活跃的磁盘缓存
|
||||
func ClearDiskCache(c *gin.Context) {
|
||||
cachePath := common.GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(cachePath, "new-api-body-cache")
|
||||
|
||||
// 删除缓存目录
|
||||
err := os.RemoveAll(dir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
// 清理超过 10 分钟未使用的缓存文件
|
||||
// 10 分钟是一个安全的阈值,确保正在进行的请求不会被误删
|
||||
err := common.CleanupOldDiskCacheFiles(10 * time.Minute)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 重置统计
|
||||
common.ResetDiskCacheStats()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "磁盘缓存已清理",
|
||||
"message": "不活跃的磁盘缓存已清理",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,11 +171,8 @@ func ForceGC(c *gin.Context) {
|
||||
|
||||
// getDiskCacheInfo 获取磁盘缓存目录信息
|
||||
func getDiskCacheInfo() DiskCacheInfo {
|
||||
cachePath := common.GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(cachePath, "new-api-body-cache")
|
||||
// 使用统一的缓存目录
|
||||
dir := common.GetDiskCacheDir()
|
||||
|
||||
info := DiskCacheInfo{
|
||||
Path: dir,
|
||||
|
||||
@@ -56,7 +56,8 @@ type upstreamResult struct {
|
||||
func FetchUpstreamRatios(c *gin.Context) {
|
||||
var req dto.UpstreamRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
||||
common.SysError("failed to bind upstream request: " + err.Error())
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请求参数格式错误"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -66,28 +66,19 @@ func AddRedemption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "兑换码名称长度必须在1-20之间",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
|
||||
return
|
||||
}
|
||||
if redemption.Count <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "兑换码个数必须大于0",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountPositive)
|
||||
return
|
||||
}
|
||||
if redemption.Count > 100 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "一次兑换码批量生成的个数不能大于 100",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
||||
return
|
||||
}
|
||||
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
return
|
||||
}
|
||||
var keys []string
|
||||
@@ -103,9 +94,10 @@ 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": err.Error(),
|
||||
"message": i18n.T(c, i18n.MsgRedemptionCreateFailed),
|
||||
"data": keys,
|
||||
})
|
||||
return
|
||||
@@ -148,8 +140,8 @@ func UpdateRedemption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if statusOnly == "" {
|
||||
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
return
|
||||
}
|
||||
// If you add more fields, please also update redemption.Update()
|
||||
@@ -187,9 +179,9 @@ func DeleteInvalidRedemption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func validateExpiredTime(expired int64) error {
|
||||
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
|
||||
if expired != 0 && expired < common.GetTimestamp() {
|
||||
return errors.New("过期时间不能早于当前时间")
|
||||
return false, i18n.T(c, i18n.MsgRedemptionExpireTimeInvalid)
|
||||
}
|
||||
return nil
|
||||
return true, ""
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
@@ -373,7 +374,12 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
|
||||
}
|
||||
service.AppendChannelAffinityAdminInfo(c, adminInfo)
|
||||
other["admin_info"] = adminInfo
|
||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, 0, false, userGroup, other)
|
||||
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
|
||||
if startTime.IsZero() {
|
||||
startTime = time.Now()
|
||||
}
|
||||
useTimeSeconds := int(time.Since(startTime).Seconds())
|
||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, false, userGroup, other)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -118,6 +118,14 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "套餐标题不能为空")
|
||||
return
|
||||
}
|
||||
if req.Plan.PriceAmount < 0 {
|
||||
common.ApiErrorMsg(c, "价格不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.PriceAmount > 9999 {
|
||||
common.ApiErrorMsg(c, "价格不能超过9999")
|
||||
return
|
||||
}
|
||||
if req.Plan.Currency == "" {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
@@ -172,6 +180,14 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "套餐标题不能为空")
|
||||
return
|
||||
}
|
||||
if req.Plan.PriceAmount < 0 {
|
||||
common.ApiErrorMsg(c, "价格不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.PriceAmount > 9999 {
|
||||
common.ApiErrorMsg(c, "价格不能超过9999")
|
||||
return
|
||||
}
|
||||
req.Plan.Id = id
|
||||
if req.Plan.Currency == "" {
|
||||
req.Plan.Currency = "USD"
|
||||
|
||||
@@ -108,25 +108,35 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "拉起支付失败")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"data": params, "url": uri})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
|
||||
}
|
||||
|
||||
func SubscriptionEpayNotify(c *gin.Context) {
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
if len(params) == 0 {
|
||||
var params map[string]string
|
||||
|
||||
if c.Request.Method == "POST" {
|
||||
// POST 请求:从 POST body 解析参数
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
} else {
|
||||
// GET 请求:从 URL Query 解析参数
|
||||
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.URL.Query().Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
@@ -157,21 +167,31 @@ func SubscriptionEpayNotify(c *gin.Context) {
|
||||
// SubscriptionEpayReturn handles browser return after payment.
|
||||
// It verifies the payload and completes the order, then redirects to console.
|
||||
func SubscriptionEpayReturn(c *gin.Context) {
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
if len(params) == 0 {
|
||||
var params map[string]string
|
||||
|
||||
if c.Request.Method == "POST" {
|
||||
// POST 请求:从 POST body 解析参数
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
} else {
|
||||
// GET 请求:从 URL Query 解析参数
|
||||
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.URL.Query().Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -107,10 +107,8 @@ func GetTokenUsage(c *gin.Context) {
|
||||
|
||||
token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.SysError("failed to get token by key: " + err.Error())
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -144,36 +142,24 @@ func AddToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if len(token.Name) > 50 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "令牌名称过长",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
|
||||
return
|
||||
}
|
||||
// 非无限额度时,检查额度值是否超出有效范围
|
||||
if !token.UnlimitedQuota {
|
||||
if token.RemainQuota < 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "额度值不能为负数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
|
||||
return
|
||||
}
|
||||
maxQuotaValue := int((1000000000 * common.QuotaPerUnit))
|
||||
if token.RemainQuota > maxQuotaValue {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
|
||||
return
|
||||
}
|
||||
}
|
||||
key, err := common.GenerateKey()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "生成令牌失败",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
|
||||
common.SysLog("failed to generate token key: " + err.Error())
|
||||
return
|
||||
}
|
||||
@@ -229,26 +215,17 @@ func UpdateToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if len(token.Name) > 50 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "令牌名称过长",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
|
||||
return
|
||||
}
|
||||
if !token.UnlimitedQuota {
|
||||
if token.RemainQuota < 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "额度值不能为负数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
|
||||
return
|
||||
}
|
||||
maxQuotaValue := int((1000000000 * common.QuotaPerUnit))
|
||||
if token.RemainQuota > maxQuotaValue {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -259,17 +236,11 @@ func UpdateToken(c *gin.Context) {
|
||||
}
|
||||
if token.Status == common.TokenStatusEnabled {
|
||||
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable)
|
||||
return
|
||||
}
|
||||
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenExhaustedCannotEable)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -306,10 +277,7 @@ type TokenBatch struct {
|
||||
func DeleteTokenBatch(c *gin.Context) {
|
||||
tokenBatch := TokenBatch{}
|
||||
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
userId := c.GetInt("id")
|
||||
|
||||
@@ -228,21 +228,32 @@ func UnlockOrder(tradeNo string) {
|
||||
}
|
||||
|
||||
func EpayNotify(c *gin.Context) {
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
log.Println("易支付回调解析失败:", err)
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
if len(params) == 0 {
|
||||
var params map[string]string
|
||||
|
||||
if c.Request.Method == "POST" {
|
||||
// POST 请求:从 POST body 解析参数
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
log.Println("易支付回调POST解析失败:", err)
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
} else {
|
||||
// GET 请求:从 URL Query 解析参数
|
||||
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.URL.Query().Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
log.Println("易支付回调参数为空")
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
log.Println("易支付回调失败 未找到配置信息")
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -29,28 +31,19 @@ type LoginRequest struct {
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
if !common.PasswordLoginEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员关闭了密码登录",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled)
|
||||
return
|
||||
}
|
||||
var loginRequest LoginRequest
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无效的参数",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
username := loginRequest.Username
|
||||
password := loginRequest.Password
|
||||
if username == "" || password == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无效的参数",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
@@ -74,15 +67,12 @@ func Login(c *gin.Context) {
|
||||
session.Set("pending_user_id", user.Id)
|
||||
err := session.Save()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无法保存会话信息,请重试",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "请输入两步验证码",
|
||||
"message": i18n.T(c, i18n.MsgUserRequire2FA),
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"require_2fa": true,
|
||||
@@ -104,10 +94,7 @@ func setupLogin(user *model.User, c *gin.Context) {
|
||||
session.Set("group", user.Group)
|
||||
err := session.Save()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无法保存会话信息,请重试",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -143,65 +130,41 @@ func Logout(c *gin.Context) {
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
if !common.RegisterEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员关闭了新用户注册",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
|
||||
return
|
||||
}
|
||||
if !common.PasswordRegisterEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册",
|
||||
"success": false,
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPasswordRegisterDisabled)
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
if err := common.Validate.Struct(&user); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "输入不合法 " + err.Error(),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
if common.EmailVerificationEnabled {
|
||||
if user.Email == "" || user.VerificationCode == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员开启了邮箱验证,请输入邮箱地址和验证码",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserEmailVerificationRequired)
|
||||
return
|
||||
}
|
||||
if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "验证码错误或已过期",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
|
||||
return
|
||||
}
|
||||
}
|
||||
exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "数据库错误,请稍后重试",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
|
||||
common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
|
||||
return
|
||||
}
|
||||
if exist {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户名已存在,或已注销",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserExists)
|
||||
return
|
||||
}
|
||||
affCode := user.AffCode // this code is the inviter's code, not the user's own code
|
||||
@@ -224,20 +187,14 @@ func Register(c *gin.Context) {
|
||||
// 获取插入后的用户ID
|
||||
var insertedUser model.User
|
||||
if err := model.DB.Where("username = ?", cleanUser.Username).First(&insertedUser).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户注册失败或用户ID获取失败",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed)
|
||||
return
|
||||
}
|
||||
// 生成默认令牌
|
||||
if constant.GenerateDefaultToken {
|
||||
key, err := common.GenerateKey()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "生成默认令牌失败",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserDefaultTokenFailed)
|
||||
common.SysLog("failed to generate token key: " + err.Error())
|
||||
return
|
||||
}
|
||||
@@ -257,10 +214,7 @@ func Register(c *gin.Context) {
|
||||
token.Group = "auto"
|
||||
}
|
||||
if err := token.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "创建默认令牌失败",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgCreateDefaultTokenErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -316,10 +270,7 @@ func GetUser(c *gin.Context) {
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权获取同级或更高等级用户的信息",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -341,20 +292,14 @@ func GenerateAccessToken(c *gin.Context) {
|
||||
randI := common.GetRandomInt(4)
|
||||
key, err := common.GenerateRandomKey(29 + randI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "生成失败",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgGenerateFailed)
|
||||
common.SysLog("failed to generate key: " + err.Error())
|
||||
return
|
||||
}
|
||||
user.SetAccessToken(key)
|
||||
|
||||
if model.DB.Where("access_token = ?", user.AccessToken).First(user).RowsAffected != 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请重试,系统生成的 UUID 竟然重复了!",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUuidDuplicate)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -389,16 +334,10 @@ func TransferAffQuota(c *gin.Context) {
|
||||
}
|
||||
err = user.TransferAffQuotaToQuota(tran.Quota)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "划转失败 " + err.Error(),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserTransferFailed, map[string]any{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "划转成功",
|
||||
})
|
||||
common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil)
|
||||
}
|
||||
|
||||
func GetAffCode(c *gin.Context) {
|
||||
@@ -601,20 +540,14 @@ func UpdateUser(c *gin.Context) {
|
||||
var updatedUser model.User
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
|
||||
if err != nil || updatedUser.Id == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
if updatedUser.Password == "" {
|
||||
updatedUser.Password = "$I_LOVE_U" // make Validator happy :)
|
||||
}
|
||||
if err := common.Validate.Struct(&updatedUser); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "输入不合法 " + err.Error(),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
originUser, err := model.GetUserById(updatedUser.Id, false)
|
||||
@@ -624,17 +557,11 @@ func UpdateUser(c *gin.Context) {
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= originUser.Role && myRole != common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权更新同权限等级或更高权限等级的用户信息",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
if myRole <= updatedUser.Role && myRole != common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权将其他用户权限等级提升到大于等于自己的权限等级",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
|
||||
return
|
||||
}
|
||||
if updatedUser.Password == "$I_LOVE_U" {
|
||||
@@ -659,15 +586,12 @@ func UpdateSelf(c *gin.Context) {
|
||||
var requestData map[string]interface{}
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是sidebar_modules更新请求
|
||||
if sidebarModules, exists := requestData["sidebar_modules"]; exists {
|
||||
// 检查是否是用户设置更新请求 (sidebar_modules 或 language)
|
||||
if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists {
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
@@ -686,17 +610,39 @@ func UpdateSelf(c *gin.Context) {
|
||||
// 保存更新后的设置
|
||||
user.SetSetting(currentSetting)
|
||||
if err := user.Update(false); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "更新设置失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "设置更新成功",
|
||||
})
|
||||
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是语言偏好更新请求
|
||||
if language, langExists := requestData["language"]; langExists {
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户设置
|
||||
currentSetting := user.GetSetting()
|
||||
|
||||
// 更新language字段
|
||||
if langStr, ok := language.(string); ok {
|
||||
currentSetting.Language = langStr
|
||||
}
|
||||
|
||||
// 保存更新后的设置
|
||||
user.SetSetting(currentSetting)
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
|
||||
return
|
||||
}
|
||||
|
||||
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -704,18 +650,12 @@ func UpdateSelf(c *gin.Context) {
|
||||
var user model.User
|
||||
requestDataBytes, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(requestDataBytes, &user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -723,10 +663,7 @@ func UpdateSelf(c *gin.Context) {
|
||||
user.Password = "$I_LOVE_U" // make Validator happy :)
|
||||
}
|
||||
if err := common.Validate.Struct(&user); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "输入不合法 " + err.Error(),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidInput)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -790,10 +727,7 @@ func DeleteUser(c *gin.Context) {
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= originUser.Role {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权删除同权限等级或更高权限等级的用户",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
err = model.HardDeleteUserById(id)
|
||||
@@ -811,10 +745,7 @@ func DeleteSelf(c *gin.Context) {
|
||||
user, _ := model.GetUserById(id, false)
|
||||
|
||||
if user.Role == common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "不能删除超级管理员账户",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -835,17 +766,11 @@ func CreateUser(c *gin.Context) {
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&user)
|
||||
user.Username = strings.TrimSpace(user.Username)
|
||||
if err != nil || user.Username == "" || user.Password == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
if err := common.Validate.Struct(&user); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "输入不合法 " + err.Error(),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
if user.DisplayName == "" {
|
||||
@@ -853,10 +778,7 @@ func CreateUser(c *gin.Context) {
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if user.Role >= myRole {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法创建权限大于等于自己的用户",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
|
||||
return
|
||||
}
|
||||
// Even for admin users, we cannot fully trust them!
|
||||
@@ -889,10 +811,7 @@ func ManageUser(c *gin.Context) {
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&req)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
@@ -901,38 +820,26 @@ func ManageUser(c *gin.Context) {
|
||||
// Fill attributes
|
||||
model.DB.Unscoped().Where(&user).First(&user)
|
||||
if user.Id == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户不存在",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权更新同权限等级或更高权限等级的用户信息",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "disable":
|
||||
user.Status = common.UserStatusDisabled
|
||||
if user.Role == common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法禁用超级管理员用户",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotDisableRootUser)
|
||||
return
|
||||
}
|
||||
case "enable":
|
||||
user.Status = common.UserStatusEnabled
|
||||
case "delete":
|
||||
if user.Role == common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法删除超级管理员用户",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
|
||||
return
|
||||
}
|
||||
if err := user.Delete(); err != nil {
|
||||
@@ -944,33 +851,21 @@ func ManageUser(c *gin.Context) {
|
||||
}
|
||||
case "promote":
|
||||
if myRole != common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "普通管理员用户无法提升其他用户为管理员",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
|
||||
return
|
||||
}
|
||||
if user.Role >= common.RoleAdminUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户已经是管理员",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserAlreadyAdmin)
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleAdminUser
|
||||
case "demote":
|
||||
if user.Role == common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法降级超级管理员用户",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotDemoteRootUser)
|
||||
return
|
||||
}
|
||||
if user.Role == common.RoleCommonUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户已经是普通用户",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserAlreadyCommon)
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleCommonUser
|
||||
@@ -996,10 +891,7 @@ func EmailBind(c *gin.Context) {
|
||||
email := c.Query("email")
|
||||
code := c.Query("code")
|
||||
if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "验证码错误或已过期",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
@@ -1075,10 +967,7 @@ func TopUp(c *gin.Context) {
|
||||
id := c.GetInt("id")
|
||||
lock := getTopUpLock(id)
|
||||
if !lock.TryLock() {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "充值处理中,请稍后重试",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUserTopUpProcessing)
|
||||
return
|
||||
}
|
||||
defer lock.Unlock()
|
||||
@@ -1090,6 +979,10 @@ func TopUp(c *gin.Context) {
|
||||
}
|
||||
quota, err := model.Redeem(req.Key, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrRedeemFailed) {
|
||||
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -1117,46 +1010,31 @@ type UpdateUserSettingRequest struct {
|
||||
func UpdateUserSetting(c *gin.Context) {
|
||||
var req UpdateUserSettingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证预警类型
|
||||
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的预警类型",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingInvalidType)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证预警阈值
|
||||
if req.QuotaWarningThreshold <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "预警阈值必须大于0",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgQuotaThresholdGtZero)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是webhook类型,验证webhook地址
|
||||
if req.QuotaWarningType == dto.NotifyTypeWebhook {
|
||||
if req.WebhookUrl == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Webhook地址不能为空",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingWebhookEmpty)
|
||||
return
|
||||
}
|
||||
// 验证URL格式
|
||||
if _, err := url.ParseRequestURI(req.WebhookUrl); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的Webhook地址",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingWebhookInvalid)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1165,10 +1043,7 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
|
||||
// 验证邮箱格式
|
||||
if !strings.Contains(req.NotificationEmail, "@") {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的邮箱地址",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingEmailInvalid)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1176,26 +1051,17 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
// 如果是Bark类型,验证Bark URL
|
||||
if req.QuotaWarningType == dto.NotifyTypeBark {
|
||||
if req.BarkUrl == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Bark推送URL不能为空",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlEmpty)
|
||||
return
|
||||
}
|
||||
// 验证URL格式
|
||||
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的Bark推送URL",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlInvalid)
|
||||
return
|
||||
}
|
||||
// 检查是否是HTTP或HTTPS
|
||||
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Bark推送URL必须以http://或https://开头",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1203,33 +1069,21 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
// 如果是Gotify类型,验证Gotify URL和Token
|
||||
if req.QuotaWarningType == dto.NotifyTypeGotify {
|
||||
if req.GotifyUrl == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Gotify服务器地址不能为空",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlEmpty)
|
||||
return
|
||||
}
|
||||
if req.GotifyToken == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Gotify令牌不能为空",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingGotifyTokenEmpty)
|
||||
return
|
||||
}
|
||||
// 验证URL格式
|
||||
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的Gotify服务器地址",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlInvalid)
|
||||
return
|
||||
}
|
||||
// 检查是否是HTTP或HTTPS
|
||||
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Gotify服务器地址必须以http://或https://开头",
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1282,15 +1136,9 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
// 更新用户设置
|
||||
user.SetSetting(settings)
|
||||
if err := user.Update(false); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "更新设置失败: " + err.Error(),
|
||||
})
|
||||
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "设置已更新",
|
||||
})
|
||||
common.ApiSuccessI18n(c, i18n.MsgSettingSaved, nil)
|
||||
}
|
||||
|
||||
@@ -214,6 +214,14 @@ type ClaudeRequest struct {
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
}
|
||||
|
||||
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createClaudeFileSource(data string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, "")
|
||||
}
|
||||
|
||||
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
var tokenCountMeta = types.TokenCountMeta{
|
||||
TokenType: types.TokenTypeTokenizer,
|
||||
@@ -243,7 +251,10 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
data = common.Interface2String(media.Source.Data)
|
||||
}
|
||||
if data != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data})
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createClaudeFileSource(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +286,10 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
data = common.Interface2String(media.Source.Data)
|
||||
}
|
||||
if data != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data})
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createClaudeFileSource(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
case "tool_use":
|
||||
|
||||
@@ -64,6 +64,14 @@ type LatLng struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createGeminiFileSource(data string, mimeType string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, mimeType)
|
||||
}
|
||||
|
||||
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
var files []*types.FileMeta = make([]*types.FileMeta, 0)
|
||||
|
||||
@@ -80,27 +88,23 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
if part.InlineData != nil && part.InlineData.Data != "" {
|
||||
if strings.HasPrefix(part.InlineData.MimeType, "image/") {
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
OriginData: part.InlineData.Data,
|
||||
})
|
||||
} else if strings.HasPrefix(part.InlineData.MimeType, "audio/") {
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: types.FileTypeAudio,
|
||||
OriginData: part.InlineData.Data,
|
||||
})
|
||||
} else if strings.HasPrefix(part.InlineData.MimeType, "video/") {
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: types.FileTypeVideo,
|
||||
OriginData: part.InlineData.Data,
|
||||
})
|
||||
mimeType := part.InlineData.MimeType
|
||||
source := createGeminiFileSource(part.InlineData.Data, mimeType)
|
||||
var fileType types.FileType
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
fileType = types.FileTypeImage
|
||||
} else if strings.HasPrefix(mimeType, "audio/") {
|
||||
fileType = types.FileTypeAudio
|
||||
} else if strings.HasPrefix(mimeType, "video/") {
|
||||
fileType = types.FileTypeVideo
|
||||
} else {
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: types.FileTypeFile,
|
||||
OriginData: part.InlineData.Data,
|
||||
})
|
||||
fileType = types.FileTypeFile
|
||||
}
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: fileType,
|
||||
Source: source,
|
||||
MimeType: mimeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,14 @@ type GeneralOpenAIRequest struct {
|
||||
SearchMode string `json:"search_mode,omitempty"`
|
||||
}
|
||||
|
||||
// createFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createFileSource(data string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, "")
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
var tokenCountMeta types.TokenCountMeta
|
||||
var texts = make([]string, 0)
|
||||
@@ -144,42 +152,40 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
for _, m := range arrayContent {
|
||||
if m.Type == ContentTypeImageURL {
|
||||
imageUrl := m.GetImageMedia()
|
||||
if imageUrl != nil {
|
||||
if imageUrl.Url != "" {
|
||||
meta := &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
}
|
||||
meta.OriginData = imageUrl.Url
|
||||
meta.Detail = imageUrl.Detail
|
||||
fileMeta = append(fileMeta, meta)
|
||||
}
|
||||
if imageUrl != nil && imageUrl.Url != "" {
|
||||
source := createFileSource(imageUrl.Url)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
Detail: imageUrl.Detail,
|
||||
})
|
||||
}
|
||||
} else if m.Type == ContentTypeInputAudio {
|
||||
inputAudio := m.GetInputAudio()
|
||||
if inputAudio != nil {
|
||||
meta := &types.FileMeta{
|
||||
if inputAudio != nil && inputAudio.Data != "" {
|
||||
source := createFileSource(inputAudio.Data)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeAudio,
|
||||
}
|
||||
meta.OriginData = inputAudio.Data
|
||||
fileMeta = append(fileMeta, meta)
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else if m.Type == ContentTypeFile {
|
||||
file := m.GetFile()
|
||||
if file != nil {
|
||||
meta := &types.FileMeta{
|
||||
if file != nil && file.FileData != "" {
|
||||
source := createFileSource(file.FileData)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeFile,
|
||||
}
|
||||
meta.OriginData = file.FileData
|
||||
fileMeta = append(fileMeta, meta)
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else if m.Type == ContentTypeVideoUrl {
|
||||
videoUrl := m.GetVideoUrl()
|
||||
if videoUrl != nil && videoUrl.Url != "" {
|
||||
meta := &types.FileMeta{
|
||||
source := createFileSource(videoUrl.Url)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeVideo,
|
||||
}
|
||||
meta.OriginData = videoUrl.Url
|
||||
fileMeta = append(fileMeta, meta)
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
texts = append(texts, m.Text)
|
||||
@@ -833,16 +839,16 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
if input.Type == "input_image" {
|
||||
if input.ImageUrl != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
OriginData: input.ImageUrl,
|
||||
Detail: input.Detail,
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createFileSource(input.ImageUrl),
|
||||
Detail: input.Detail,
|
||||
})
|
||||
}
|
||||
} else if input.Type == "input_file" {
|
||||
if input.FileUrl != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeFile,
|
||||
OriginData: input.FileUrl,
|
||||
FileType: types.FileTypeFile,
|
||||
Source: createFileSource(input.FileUrl),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -352,6 +352,11 @@ type ResponsesOutputContent struct {
|
||||
Annotations []interface{} `json:"annotations"`
|
||||
}
|
||||
|
||||
type ResponsesReasoningSummaryPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
const (
|
||||
BuildInToolWebSearchPreview = "web_search_preview"
|
||||
BuildInToolFileSearch = "file_search"
|
||||
@@ -374,8 +379,11 @@ type ResponsesStreamResponse struct {
|
||||
Item *ResponsesOutput `json:"item,omitempty"`
|
||||
// - response.function_call_arguments.delta
|
||||
// - response.function_call_arguments.done
|
||||
OutputIndex *int `json:"output_index,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
OutputIndex *int `json:"output_index,omitempty"`
|
||||
ContentIndex *int `json:"content_index,omitempty"`
|
||||
SummaryIndex *int `json:"summary_index,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
Part *ResponsesReasoningSummaryPart `json:"part,omitempty"`
|
||||
}
|
||||
|
||||
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
|
||||
|
||||
@@ -14,6 +14,7 @@ type UserSetting struct {
|
||||
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
|
||||
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
|
||||
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
|
||||
Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en)
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
11
go.mod
11
go.mod
@@ -32,8 +32,10 @@ require (
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mewkiz/flac v1.0.13
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/samber/hot v0.11.0
|
||||
github.com/samber/lo v1.52.0
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
@@ -48,7 +50,10 @@ require (
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.32.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.4.3
|
||||
gorm.io/driver/postgres v1.5.2
|
||||
gorm.io/gorm v1.25.2
|
||||
@@ -115,7 +120,6 @@ require (
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/samber/go-singleflightx v0.3.2 // indirect
|
||||
github.com/samber/hot v0.11.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
@@ -127,10 +131,7 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||
golang.org/x/arch v0.21.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
7
go.sum
7
go.sum
@@ -213,6 +213,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
@@ -329,6 +331,8 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -349,9 +353,12 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
|
||||
227
i18n/i18n.go
Normal file
227
i18n/i18n.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"golang.org/x/text/language"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
LangZh = "zh"
|
||||
LangEn = "en"
|
||||
DefaultLang = LangEn // Fallback to English if language not supported
|
||||
)
|
||||
|
||||
//go:embed locales/*.yaml
|
||||
var localeFS embed.FS
|
||||
|
||||
var (
|
||||
bundle *i18n.Bundle
|
||||
localizers = make(map[string]*i18n.Localizer)
|
||||
mu sync.RWMutex
|
||||
initOnce sync.Once
|
||||
)
|
||||
|
||||
// Init initializes the i18n bundle and loads all translation files
|
||||
func Init() error {
|
||||
var initErr error
|
||||
initOnce.Do(func() {
|
||||
bundle = i18n.NewBundle(language.Chinese)
|
||||
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||
|
||||
// Load embedded translation files
|
||||
files := []string{"locales/zh.yaml", "locales/en.yaml"}
|
||||
for _, file := range files {
|
||||
_, err := bundle.LoadMessageFileFS(localeFS, file)
|
||||
if err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-create localizers for supported languages
|
||||
localizers[LangZh] = i18n.NewLocalizer(bundle, LangZh)
|
||||
localizers[LangEn] = i18n.NewLocalizer(bundle, LangEn)
|
||||
|
||||
// Set the TranslateMessage function in common package
|
||||
common.TranslateMessage = T
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetLocalizer returns a localizer for the specified language
|
||||
func GetLocalizer(lang string) *i18n.Localizer {
|
||||
lang = normalizeLang(lang)
|
||||
|
||||
mu.RLock()
|
||||
loc, ok := localizers[lang]
|
||||
mu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return loc
|
||||
}
|
||||
|
||||
// Create new localizer for unknown language (fallback to default)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if loc, ok = localizers[lang]; ok {
|
||||
return loc
|
||||
}
|
||||
|
||||
loc = i18n.NewLocalizer(bundle, lang, DefaultLang)
|
||||
localizers[lang] = loc
|
||||
return loc
|
||||
}
|
||||
|
||||
// T translates a message key using the language from gin context
|
||||
func T(c *gin.Context, key string, args ...map[string]any) string {
|
||||
lang := GetLangFromContext(c)
|
||||
return Translate(lang, key, args...)
|
||||
}
|
||||
|
||||
// Translate translates a message key for the specified language
|
||||
func Translate(lang, key string, args ...map[string]any) string {
|
||||
loc := GetLocalizer(lang)
|
||||
|
||||
config := &i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
}
|
||||
|
||||
if len(args) > 0 && args[0] != nil {
|
||||
config.TemplateData = args[0]
|
||||
}
|
||||
|
||||
msg, err := loc.Localize(config)
|
||||
if err != nil {
|
||||
// Return key as fallback if translation not found
|
||||
return key
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// userLangLoaderFunc is a function that loads user language from database/cache
|
||||
// It's set by the model package to avoid circular imports
|
||||
var userLangLoaderFunc func(userId int) string
|
||||
|
||||
// SetUserLangLoader sets the function to load user language (called from model package)
|
||||
func SetUserLangLoader(loader func(userId int) string) {
|
||||
userLangLoaderFunc = loader
|
||||
}
|
||||
|
||||
// GetLangFromContext extracts the language setting from gin context
|
||||
// It checks multiple sources in priority order:
|
||||
// 1. User settings (ContextKeyUserSetting) - if already loaded (e.g., by TokenAuth)
|
||||
// 2. Lazy load user language from cache/DB using user ID
|
||||
// 3. Language set by middleware (ContextKeyLanguage) - from Accept-Language header
|
||||
// 4. Default language (English)
|
||||
func GetLangFromContext(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return DefaultLang
|
||||
}
|
||||
|
||||
// 1. Try to get language from user settings (if already loaded by TokenAuth or other middleware)
|
||||
if userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting); ok {
|
||||
if userSetting.Language != "" {
|
||||
normalized := normalizeLang(userSetting.Language)
|
||||
if IsSupported(normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Lazy load user language using user ID (for session-based auth where full settings aren't loaded)
|
||||
if userLangLoaderFunc != nil {
|
||||
if userId, exists := c.Get("id"); exists {
|
||||
if uid, ok := userId.(int); ok && uid > 0 {
|
||||
lang := userLangLoaderFunc(uid)
|
||||
if lang != "" {
|
||||
normalized := normalizeLang(lang)
|
||||
if IsSupported(normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try to get language from context (set by I18n middleware from Accept-Language)
|
||||
if lang := c.GetString(string(constant.ContextKeyLanguage)); lang != "" {
|
||||
normalized := normalizeLang(lang)
|
||||
if IsSupported(normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try Accept-Language header directly (fallback if middleware didn't run)
|
||||
if acceptLang := c.GetHeader("Accept-Language"); acceptLang != "" {
|
||||
lang := ParseAcceptLanguage(acceptLang)
|
||||
if IsSupported(lang) {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultLang
|
||||
}
|
||||
|
||||
// ParseAcceptLanguage parses the Accept-Language header and returns the preferred language
|
||||
func ParseAcceptLanguage(header string) string {
|
||||
if header == "" {
|
||||
return DefaultLang
|
||||
}
|
||||
|
||||
// Simple parsing: take the first language tag
|
||||
parts := strings.Split(header, ",")
|
||||
if len(parts) == 0 {
|
||||
return DefaultLang
|
||||
}
|
||||
|
||||
// Get the first language and remove quality value
|
||||
firstLang := strings.TrimSpace(parts[0])
|
||||
if idx := strings.Index(firstLang, ";"); idx > 0 {
|
||||
firstLang = firstLang[:idx]
|
||||
}
|
||||
|
||||
return normalizeLang(firstLang)
|
||||
}
|
||||
|
||||
// normalizeLang normalizes language code to supported format
|
||||
func normalizeLang(lang string) string {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
|
||||
// Handle common variations
|
||||
switch {
|
||||
case strings.HasPrefix(lang, "zh"):
|
||||
return LangZh
|
||||
case strings.HasPrefix(lang, "en"):
|
||||
return LangEn
|
||||
default:
|
||||
return DefaultLang
|
||||
}
|
||||
}
|
||||
|
||||
// SupportedLanguages returns a list of supported language codes
|
||||
func SupportedLanguages() []string {
|
||||
return []string{LangZh, LangEn}
|
||||
}
|
||||
|
||||
// IsSupported checks if a language code is supported
|
||||
func IsSupported(lang string) bool {
|
||||
lang = normalizeLang(lang)
|
||||
for _, supported := range SupportedLanguages() {
|
||||
if lang == supported {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
278
i18n/keys.go
Normal file
278
i18n/keys.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package i18n
|
||||
|
||||
// Message keys for i18n translations
|
||||
// Use these constants instead of hardcoded strings
|
||||
|
||||
// Common error messages
|
||||
const (
|
||||
MsgInvalidParams = "common.invalid_params"
|
||||
MsgDatabaseError = "common.database_error"
|
||||
MsgRetryLater = "common.retry_later"
|
||||
MsgGenerateFailed = "common.generate_failed"
|
||||
MsgNotFound = "common.not_found"
|
||||
MsgUnauthorized = "common.unauthorized"
|
||||
MsgForbidden = "common.forbidden"
|
||||
MsgInvalidId = "common.invalid_id"
|
||||
MsgIdEmpty = "common.id_empty"
|
||||
MsgFeatureDisabled = "common.feature_disabled"
|
||||
MsgOperationSuccess = "common.operation_success"
|
||||
MsgOperationFailed = "common.operation_failed"
|
||||
MsgUpdateSuccess = "common.update_success"
|
||||
MsgUpdateFailed = "common.update_failed"
|
||||
MsgCreateSuccess = "common.create_success"
|
||||
MsgCreateFailed = "common.create_failed"
|
||||
MsgDeleteSuccess = "common.delete_success"
|
||||
MsgDeleteFailed = "common.delete_failed"
|
||||
MsgAlreadyExists = "common.already_exists"
|
||||
MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
|
||||
)
|
||||
|
||||
// Token related messages
|
||||
const (
|
||||
MsgTokenNameTooLong = "token.name_too_long"
|
||||
MsgTokenQuotaNegative = "token.quota_negative"
|
||||
MsgTokenQuotaExceedMax = "token.quota_exceed_max"
|
||||
MsgTokenGenerateFailed = "token.generate_failed"
|
||||
MsgTokenGetInfoFailed = "token.get_info_failed"
|
||||
MsgTokenExpiredCannotEnable = "token.expired_cannot_enable"
|
||||
MsgTokenExhaustedCannotEable = "token.exhausted_cannot_enable"
|
||||
MsgTokenInvalid = "token.invalid"
|
||||
MsgTokenNotProvided = "token.not_provided"
|
||||
MsgTokenExpired = "token.expired"
|
||||
MsgTokenExhausted = "token.exhausted"
|
||||
MsgTokenStatusUnavailable = "token.status_unavailable"
|
||||
MsgTokenDbError = "token.db_error"
|
||||
)
|
||||
|
||||
// Redemption related messages
|
||||
const (
|
||||
MsgRedemptionNameLength = "redemption.name_length"
|
||||
MsgRedemptionCountPositive = "redemption.count_positive"
|
||||
MsgRedemptionCountMax = "redemption.count_max"
|
||||
MsgRedemptionCreateFailed = "redemption.create_failed"
|
||||
MsgRedemptionInvalid = "redemption.invalid"
|
||||
MsgRedemptionUsed = "redemption.used"
|
||||
MsgRedemptionExpired = "redemption.expired"
|
||||
MsgRedemptionFailed = "redemption.failed"
|
||||
MsgRedemptionNotProvided = "redemption.not_provided"
|
||||
MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid"
|
||||
)
|
||||
|
||||
// User related messages
|
||||
const (
|
||||
MsgUserPasswordLoginDisabled = "user.password_login_disabled"
|
||||
MsgUserRegisterDisabled = "user.register_disabled"
|
||||
MsgUserPasswordRegisterDisabled = "user.password_register_disabled"
|
||||
MsgUserUsernameOrPasswordEmpty = "user.username_or_password_empty"
|
||||
MsgUserUsernameOrPasswordError = "user.username_or_password_error"
|
||||
MsgUserEmailOrPasswordEmpty = "user.email_or_password_empty"
|
||||
MsgUserExists = "user.exists"
|
||||
MsgUserNotExists = "user.not_exists"
|
||||
MsgUserDisabled = "user.disabled"
|
||||
MsgUserSessionSaveFailed = "user.session_save_failed"
|
||||
MsgUserRequire2FA = "user.require_2fa"
|
||||
MsgUserEmailVerificationRequired = "user.email_verification_required"
|
||||
MsgUserVerificationCodeError = "user.verification_code_error"
|
||||
MsgUserInputInvalid = "user.input_invalid"
|
||||
MsgUserNoPermissionSameLevel = "user.no_permission_same_level"
|
||||
MsgUserNoPermissionHigherLevel = "user.no_permission_higher_level"
|
||||
MsgUserCannotCreateHigherLevel = "user.cannot_create_higher_level"
|
||||
MsgUserCannotDeleteRootUser = "user.cannot_delete_root_user"
|
||||
MsgUserCannotDisableRootUser = "user.cannot_disable_root_user"
|
||||
MsgUserCannotDemoteRootUser = "user.cannot_demote_root_user"
|
||||
MsgUserAlreadyAdmin = "user.already_admin"
|
||||
MsgUserAlreadyCommon = "user.already_common"
|
||||
MsgUserAdminCannotPromote = "user.admin_cannot_promote"
|
||||
MsgUserOriginalPasswordError = "user.original_password_error"
|
||||
MsgUserInviteQuotaInsufficient = "user.invite_quota_insufficient"
|
||||
MsgUserTransferQuotaMinimum = "user.transfer_quota_minimum"
|
||||
MsgUserTransferSuccess = "user.transfer_success"
|
||||
MsgUserTransferFailed = "user.transfer_failed"
|
||||
MsgUserTopUpProcessing = "user.topup_processing"
|
||||
MsgUserRegisterFailed = "user.register_failed"
|
||||
MsgUserDefaultTokenFailed = "user.default_token_failed"
|
||||
MsgUserAffCodeEmpty = "user.aff_code_empty"
|
||||
MsgUserEmailEmpty = "user.email_empty"
|
||||
MsgUserGitHubIdEmpty = "user.github_id_empty"
|
||||
MsgUserDiscordIdEmpty = "user.discord_id_empty"
|
||||
MsgUserOidcIdEmpty = "user.oidc_id_empty"
|
||||
MsgUserWeChatIdEmpty = "user.wechat_id_empty"
|
||||
MsgUserTelegramIdEmpty = "user.telegram_id_empty"
|
||||
MsgUserTelegramNotBound = "user.telegram_not_bound"
|
||||
MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty"
|
||||
)
|
||||
|
||||
// Quota related messages
|
||||
const (
|
||||
MsgQuotaNegative = "quota.negative"
|
||||
MsgQuotaExceedMax = "quota.exceed_max"
|
||||
MsgQuotaInsufficient = "quota.insufficient"
|
||||
MsgQuotaWarningInvalid = "quota.warning_invalid"
|
||||
MsgQuotaThresholdGtZero = "quota.threshold_gt_zero"
|
||||
)
|
||||
|
||||
// Subscription related messages
|
||||
const (
|
||||
MsgSubscriptionNotEnabled = "subscription.not_enabled"
|
||||
MsgSubscriptionTitleEmpty = "subscription.title_empty"
|
||||
MsgSubscriptionPriceNegative = "subscription.price_negative"
|
||||
MsgSubscriptionPriceMax = "subscription.price_max"
|
||||
MsgSubscriptionPurchaseLimitNeg = "subscription.purchase_limit_negative"
|
||||
MsgSubscriptionQuotaNegative = "subscription.quota_negative"
|
||||
MsgSubscriptionGroupNotExists = "subscription.group_not_exists"
|
||||
MsgSubscriptionResetCycleGtZero = "subscription.reset_cycle_gt_zero"
|
||||
MsgSubscriptionPurchaseMax = "subscription.purchase_max"
|
||||
MsgSubscriptionInvalidId = "subscription.invalid_id"
|
||||
MsgSubscriptionInvalidUserId = "subscription.invalid_user_id"
|
||||
)
|
||||
|
||||
// Payment related messages
|
||||
const (
|
||||
MsgPaymentNotConfigured = "payment.not_configured"
|
||||
MsgPaymentMethodNotExists = "payment.method_not_exists"
|
||||
MsgPaymentCallbackError = "payment.callback_error"
|
||||
MsgPaymentCreateFailed = "payment.create_failed"
|
||||
MsgPaymentStartFailed = "payment.start_failed"
|
||||
MsgPaymentAmountTooLow = "payment.amount_too_low"
|
||||
MsgPaymentStripeNotConfig = "payment.stripe_not_configured"
|
||||
MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
|
||||
MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
|
||||
MsgPaymentCreemNotConfig = "payment.creem_not_configured"
|
||||
)
|
||||
|
||||
// Topup related messages
|
||||
const (
|
||||
MsgTopupNotProvided = "topup.not_provided"
|
||||
MsgTopupOrderNotExists = "topup.order_not_exists"
|
||||
MsgTopupOrderStatus = "topup.order_status"
|
||||
MsgTopupFailed = "topup.failed"
|
||||
MsgTopupInvalidQuota = "topup.invalid_quota"
|
||||
)
|
||||
|
||||
// Channel related messages
|
||||
const (
|
||||
MsgChannelNotExists = "channel.not_exists"
|
||||
MsgChannelIdFormatError = "channel.id_format_error"
|
||||
MsgChannelNoAvailableKey = "channel.no_available_key"
|
||||
MsgChannelGetListFailed = "channel.get_list_failed"
|
||||
MsgChannelGetTagsFailed = "channel.get_tags_failed"
|
||||
MsgChannelGetKeyFailed = "channel.get_key_failed"
|
||||
MsgChannelGetOllamaFailed = "channel.get_ollama_failed"
|
||||
MsgChannelQueryFailed = "channel.query_failed"
|
||||
MsgChannelNoValidUpstream = "channel.no_valid_upstream"
|
||||
MsgChannelUpstreamSaturated = "channel.upstream_saturated"
|
||||
MsgChannelGetAvailableFailed = "channel.get_available_failed"
|
||||
)
|
||||
|
||||
// Model related messages
|
||||
const (
|
||||
MsgModelNameEmpty = "model.name_empty"
|
||||
MsgModelNameExists = "model.name_exists"
|
||||
MsgModelIdMissing = "model.id_missing"
|
||||
MsgModelGetListFailed = "model.get_list_failed"
|
||||
MsgModelGetFailed = "model.get_failed"
|
||||
MsgModelResetSuccess = "model.reset_success"
|
||||
)
|
||||
|
||||
// Vendor related messages
|
||||
const (
|
||||
MsgVendorNameEmpty = "vendor.name_empty"
|
||||
MsgVendorNameExists = "vendor.name_exists"
|
||||
MsgVendorIdMissing = "vendor.id_missing"
|
||||
)
|
||||
|
||||
// Group related messages
|
||||
const (
|
||||
MsgGroupNameTypeEmpty = "group.name_type_empty"
|
||||
MsgGroupNameExists = "group.name_exists"
|
||||
MsgGroupIdMissing = "group.id_missing"
|
||||
)
|
||||
|
||||
// Checkin related messages
|
||||
const (
|
||||
MsgCheckinDisabled = "checkin.disabled"
|
||||
MsgCheckinAlreadyToday = "checkin.already_today"
|
||||
MsgCheckinFailed = "checkin.failed"
|
||||
MsgCheckinQuotaFailed = "checkin.quota_failed"
|
||||
)
|
||||
|
||||
// Passkey related messages
|
||||
const (
|
||||
MsgPasskeyCreateFailed = "passkey.create_failed"
|
||||
MsgPasskeyLoginAbnormal = "passkey.login_abnormal"
|
||||
MsgPasskeyUpdateFailed = "passkey.update_failed"
|
||||
MsgPasskeyInvalidUserId = "passkey.invalid_user_id"
|
||||
MsgPasskeyVerifyFailed = "passkey.verify_failed"
|
||||
)
|
||||
|
||||
// 2FA related messages
|
||||
const (
|
||||
MsgTwoFANotEnabled = "twofa.not_enabled"
|
||||
MsgTwoFAUserIdEmpty = "twofa.user_id_empty"
|
||||
MsgTwoFAAlreadyExists = "twofa.already_exists"
|
||||
MsgTwoFARecordIdEmpty = "twofa.record_id_empty"
|
||||
MsgTwoFACodeInvalid = "twofa.code_invalid"
|
||||
)
|
||||
|
||||
// Rate limit related messages
|
||||
const (
|
||||
MsgRateLimitReached = "rate_limit.reached"
|
||||
MsgRateLimitTotalReached = "rate_limit.total_reached"
|
||||
)
|
||||
|
||||
// Setting related messages
|
||||
const (
|
||||
MsgSettingInvalidType = "setting.invalid_type"
|
||||
MsgSettingWebhookEmpty = "setting.webhook_empty"
|
||||
MsgSettingWebhookInvalid = "setting.webhook_invalid"
|
||||
MsgSettingEmailInvalid = "setting.email_invalid"
|
||||
MsgSettingBarkUrlEmpty = "setting.bark_url_empty"
|
||||
MsgSettingBarkUrlInvalid = "setting.bark_url_invalid"
|
||||
MsgSettingGotifyUrlEmpty = "setting.gotify_url_empty"
|
||||
MsgSettingGotifyTokenEmpty = "setting.gotify_token_empty"
|
||||
MsgSettingGotifyUrlInvalid = "setting.gotify_url_invalid"
|
||||
MsgSettingUrlMustHttp = "setting.url_must_http"
|
||||
MsgSettingSaved = "setting.saved"
|
||||
)
|
||||
|
||||
// Deployment related messages (io.net)
|
||||
const (
|
||||
MsgDeploymentNotEnabled = "deployment.not_enabled"
|
||||
MsgDeploymentIdRequired = "deployment.id_required"
|
||||
MsgDeploymentContainerIdReq = "deployment.container_id_required"
|
||||
MsgDeploymentNameEmpty = "deployment.name_empty"
|
||||
MsgDeploymentNameTaken = "deployment.name_taken"
|
||||
MsgDeploymentHardwareIdReq = "deployment.hardware_id_required"
|
||||
MsgDeploymentHardwareInvId = "deployment.hardware_invalid_id"
|
||||
MsgDeploymentApiKeyRequired = "deployment.api_key_required"
|
||||
MsgDeploymentInvalidPayload = "deployment.invalid_payload"
|
||||
MsgDeploymentNotFound = "deployment.not_found"
|
||||
)
|
||||
|
||||
// Performance related messages
|
||||
const (
|
||||
MsgPerfDiskCacheCleared = "performance.disk_cache_cleared"
|
||||
MsgPerfStatsReset = "performance.stats_reset"
|
||||
MsgPerfGcExecuted = "performance.gc_executed"
|
||||
)
|
||||
|
||||
// Ability related messages
|
||||
const (
|
||||
MsgAbilityDbCorrupted = "ability.db_corrupted"
|
||||
MsgAbilityRepairRunning = "ability.repair_running"
|
||||
)
|
||||
|
||||
// OAuth related messages
|
||||
const (
|
||||
MsgOAuthInvalidCode = "oauth.invalid_code"
|
||||
MsgOAuthGetUserErr = "oauth.get_user_error"
|
||||
MsgOAuthAccountUsed = "oauth.account_used"
|
||||
)
|
||||
|
||||
// Model layer error messages (for translation in controller)
|
||||
const (
|
||||
MsgRedeemFailed = "redeem.failed"
|
||||
MsgCreateDefaultTokenErr = "user.create_default_token_error"
|
||||
MsgUuidDuplicate = "common.uuid_duplicate"
|
||||
MsgInvalidInput = "common.invalid_input"
|
||||
)
|
||||
231
i18n/locales/en.yaml
Normal file
231
i18n/locales/en.yaml
Normal file
@@ -0,0 +1,231 @@
|
||||
# English translations
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "Invalid parameters"
|
||||
common.database_error: "Database error, please try again later"
|
||||
common.retry_later: "Please try again later"
|
||||
common.generate_failed: "Generation failed"
|
||||
common.not_found: "Not found"
|
||||
common.unauthorized: "Unauthorized"
|
||||
common.forbidden: "Forbidden"
|
||||
common.invalid_id: "Invalid ID"
|
||||
common.id_empty: "ID is empty!"
|
||||
common.feature_disabled: "This feature is not enabled"
|
||||
common.operation_success: "Operation successful"
|
||||
common.operation_failed: "Operation failed"
|
||||
common.update_success: "Update successful"
|
||||
common.update_failed: "Update failed"
|
||||
common.create_success: "Creation successful"
|
||||
common.create_failed: "Creation failed"
|
||||
common.delete_success: "Deletion successful"
|
||||
common.delete_failed: "Deletion failed"
|
||||
common.already_exists: "Already exists"
|
||||
common.name_cannot_be_empty: "Name cannot be empty"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "Token name is too long"
|
||||
token.quota_negative: "Quota value cannot be negative"
|
||||
token.quota_exceed_max: "Quota value exceeds valid range, maximum is {{.Max}}"
|
||||
token.generate_failed: "Failed to generate token"
|
||||
token.get_info_failed: "Failed to get token info, please try again later"
|
||||
token.expired_cannot_enable: "Token has expired and cannot be enabled. Please modify the expiration time or set it to never expire"
|
||||
token.exhausted_cannot_enable: "Token quota is exhausted and cannot be enabled. Please modify the remaining quota or set it to unlimited"
|
||||
token.invalid: "Invalid token"
|
||||
token.not_provided: "Token not provided"
|
||||
token.expired: "This token has expired"
|
||||
token.exhausted: "This token quota is exhausted TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
|
||||
token.status_unavailable: "This token status is unavailable"
|
||||
token.db_error: "Invalid token, database query error, please contact administrator"
|
||||
|
||||
# Redemption messages
|
||||
redemption.name_length: "Redemption code name length must be between 1-20"
|
||||
redemption.count_positive: "Redemption code count must be greater than 0"
|
||||
redemption.count_max: "Maximum 100 redemption codes can be generated at once"
|
||||
redemption.create_failed: "Failed to create redemption code, please try again later"
|
||||
redemption.invalid: "Invalid redemption code"
|
||||
redemption.used: "This redemption code has been used"
|
||||
redemption.expired: "This redemption code has expired"
|
||||
redemption.failed: "Redemption failed, please try again later"
|
||||
redemption.not_provided: "Redemption code not provided"
|
||||
redemption.expire_time_invalid: "Expiration time cannot be earlier than current time"
|
||||
|
||||
# User messages
|
||||
user.password_login_disabled: "Password login has been disabled by administrator"
|
||||
user.register_disabled: "New user registration has been disabled by administrator"
|
||||
user.password_register_disabled: "Password registration has been disabled by administrator, please use third-party account verification"
|
||||
user.username_or_password_empty: "Username or password is empty"
|
||||
user.username_or_password_error: "Username or password is incorrect, or user has been banned"
|
||||
user.email_or_password_empty: "Email or password is empty!"
|
||||
user.exists: "Username already exists or has been deleted"
|
||||
user.not_exists: "User does not exist"
|
||||
user.disabled: "This user has been disabled"
|
||||
user.session_save_failed: "Failed to save session, please try again"
|
||||
user.require_2fa: "Please enter two-factor authentication code"
|
||||
user.email_verification_required: "Email verification is enabled, please enter email address and verification code"
|
||||
user.verification_code_error: "Verification code is incorrect or has expired"
|
||||
user.input_invalid: "Invalid input {{.Error}}"
|
||||
user.no_permission_same_level: "No permission to access users of same or higher level"
|
||||
user.no_permission_higher_level: "No permission to update users of same or higher permission level"
|
||||
user.cannot_create_higher_level: "Cannot create users with permission level equal to or higher than yourself"
|
||||
user.cannot_delete_root_user: "Cannot delete super administrator account"
|
||||
user.cannot_disable_root_user: "Cannot disable super administrator user"
|
||||
user.cannot_demote_root_user: "Cannot demote super administrator user"
|
||||
user.already_admin: "This user is already an administrator"
|
||||
user.already_common: "This user is already a common user"
|
||||
user.admin_cannot_promote: "Regular administrators cannot promote other users to administrator"
|
||||
user.original_password_error: "Original password is incorrect"
|
||||
user.invite_quota_insufficient: "Invitation quota is insufficient!"
|
||||
user.transfer_quota_minimum: "Minimum transfer quota is {{.Min}}!"
|
||||
user.transfer_success: "Transfer successful"
|
||||
user.transfer_failed: "Transfer failed {{.Error}}"
|
||||
user.topup_processing: "Top-up is processing, please try again later"
|
||||
user.register_failed: "User registration failed or user ID retrieval failed"
|
||||
user.default_token_failed: "Failed to generate default token"
|
||||
user.aff_code_empty: "Affiliate code is empty!"
|
||||
user.email_empty: "Email is empty!"
|
||||
user.github_id_empty: "GitHub ID is empty!"
|
||||
user.discord_id_empty: "Discord ID is empty!"
|
||||
user.oidc_id_empty: "OIDC ID is empty!"
|
||||
user.wechat_id_empty: "WeChat ID is empty!"
|
||||
user.telegram_id_empty: "Telegram ID is empty!"
|
||||
user.telegram_not_bound: "This Telegram account is not bound"
|
||||
user.linux_do_id_empty: "Linux DO ID is empty!"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "Quota cannot be negative!"
|
||||
quota.exceed_max: "Quota value exceeds valid range"
|
||||
quota.insufficient: "Insufficient quota"
|
||||
quota.warning_invalid: "Invalid warning type"
|
||||
quota.threshold_gt_zero: "Warning threshold must be greater than 0"
|
||||
|
||||
# Subscription messages
|
||||
subscription.not_enabled: "Subscription plan is not enabled"
|
||||
subscription.title_empty: "Subscription plan title cannot be empty"
|
||||
subscription.price_negative: "Price cannot be negative"
|
||||
subscription.price_max: "Price cannot exceed 9999"
|
||||
subscription.purchase_limit_negative: "Purchase limit cannot be negative"
|
||||
subscription.quota_negative: "Total quota cannot be negative"
|
||||
subscription.group_not_exists: "Upgrade group does not exist"
|
||||
subscription.reset_cycle_gt_zero: "Custom reset cycle must be greater than 0 seconds"
|
||||
subscription.purchase_max: "Purchase limit for this plan has been reached"
|
||||
subscription.invalid_id: "Invalid subscription ID"
|
||||
subscription.invalid_user_id: "Invalid user ID"
|
||||
|
||||
# Payment messages
|
||||
payment.not_configured: "Payment information has not been configured by administrator"
|
||||
payment.method_not_exists: "Payment method does not exist"
|
||||
payment.callback_error: "Callback URL configuration error"
|
||||
payment.create_failed: "Failed to create order"
|
||||
payment.start_failed: "Failed to start payment"
|
||||
payment.amount_too_low: "Plan amount is too low"
|
||||
payment.stripe_not_configured: "Stripe is not configured or key is invalid"
|
||||
payment.webhook_not_configured: "Webhook is not configured"
|
||||
payment.price_id_not_configured: "StripePriceId is not configured for this plan"
|
||||
payment.creem_not_configured: "CreemProductId is not configured for this plan"
|
||||
|
||||
# Topup messages
|
||||
topup.not_provided: "Payment order number not provided"
|
||||
topup.order_not_exists: "Top-up order does not exist"
|
||||
topup.order_status: "Top-up order status error"
|
||||
topup.failed: "Top-up failed, please try again later"
|
||||
topup.invalid_quota: "Invalid top-up quota"
|
||||
|
||||
# Channel messages
|
||||
channel.not_exists: "Channel does not exist"
|
||||
channel.id_format_error: "Channel ID format error"
|
||||
channel.no_available_key: "No available channel keys"
|
||||
channel.get_list_failed: "Failed to get channel list, please try again later"
|
||||
channel.get_tags_failed: "Failed to get tags, please try again later"
|
||||
channel.get_key_failed: "Failed to get channel key"
|
||||
channel.get_ollama_failed: "Failed to get Ollama models"
|
||||
channel.query_failed: "Failed to query channel"
|
||||
channel.no_valid_upstream: "No valid upstream channel"
|
||||
channel.upstream_saturated: "Current group upstream load is saturated, please try again later"
|
||||
channel.get_available_failed: "Failed to get available channels for model {{.Model}} under group {{.Group}}"
|
||||
|
||||
# Model messages
|
||||
model.name_empty: "Model name cannot be empty"
|
||||
model.name_exists: "Model name already exists"
|
||||
model.id_missing: "Model ID is missing"
|
||||
model.get_list_failed: "Failed to get model list, please try again later"
|
||||
model.get_failed: "Failed to get upstream models"
|
||||
model.reset_success: "Model ratio reset successful"
|
||||
|
||||
# Vendor messages
|
||||
vendor.name_empty: "Vendor name cannot be empty"
|
||||
vendor.name_exists: "Vendor name already exists"
|
||||
vendor.id_missing: "Vendor ID is missing"
|
||||
|
||||
# Group messages
|
||||
group.name_type_empty: "Group name and type cannot be empty"
|
||||
group.name_exists: "Group name already exists"
|
||||
group.id_missing: "Group ID is missing"
|
||||
|
||||
# Checkin messages
|
||||
checkin.disabled: "Check-in feature is not enabled"
|
||||
checkin.already_today: "Already checked in today"
|
||||
checkin.failed: "Check-in failed, please try again later"
|
||||
checkin.quota_failed: "Check-in failed: quota update error"
|
||||
|
||||
# Passkey messages
|
||||
passkey.create_failed: "Unable to create Passkey credential"
|
||||
passkey.login_abnormal: "Passkey login status is abnormal"
|
||||
passkey.update_failed: "Passkey credential update failed"
|
||||
passkey.invalid_user_id: "Invalid user ID"
|
||||
passkey.verify_failed: "Passkey verification failed, please try again or contact administrator"
|
||||
|
||||
# 2FA messages
|
||||
twofa.not_enabled: "User has not enabled 2FA"
|
||||
twofa.user_id_empty: "User ID cannot be empty"
|
||||
twofa.already_exists: "User already has 2FA configured"
|
||||
twofa.record_id_empty: "2FA record ID cannot be empty"
|
||||
twofa.code_invalid: "Verification code or backup code is incorrect"
|
||||
|
||||
# Rate limit messages
|
||||
rate_limit.reached: "You have reached the request limit: maximum {{.Max}} requests in {{.Minutes}} minutes"
|
||||
rate_limit.total_reached: "You have reached the total request limit: maximum {{.Max}} requests in {{.Minutes}} minutes, including failed attempts"
|
||||
|
||||
# Setting messages
|
||||
setting.invalid_type: "Invalid warning type"
|
||||
setting.webhook_empty: "Webhook URL cannot be empty"
|
||||
setting.webhook_invalid: "Invalid Webhook URL"
|
||||
setting.email_invalid: "Invalid email address"
|
||||
setting.bark_url_empty: "Bark push URL cannot be empty"
|
||||
setting.bark_url_invalid: "Invalid Bark push URL"
|
||||
setting.gotify_url_empty: "Gotify server URL cannot be empty"
|
||||
setting.gotify_token_empty: "Gotify token cannot be empty"
|
||||
setting.gotify_url_invalid: "Invalid Gotify server URL"
|
||||
setting.url_must_http: "URL must start with http:// or https://"
|
||||
setting.saved: "Settings updated"
|
||||
|
||||
# Deployment messages (io.net)
|
||||
deployment.not_enabled: "io.net model deployment is not enabled or API key is missing"
|
||||
deployment.id_required: "Deployment ID is required"
|
||||
deployment.container_id_required: "Container ID is required"
|
||||
deployment.name_empty: "Deployment name cannot be empty"
|
||||
deployment.name_taken: "Deployment name is not available, please choose a different name"
|
||||
deployment.hardware_id_required: "hardware_id parameter is required"
|
||||
deployment.hardware_invalid_id: "Invalid hardware_id parameter"
|
||||
deployment.api_key_required: "api_key is required"
|
||||
deployment.invalid_payload: "Invalid request payload"
|
||||
deployment.not_found: "Container details not found"
|
||||
|
||||
# Performance messages
|
||||
performance.disk_cache_cleared: "Inactive disk cache has been cleared"
|
||||
performance.stats_reset: "Statistics have been reset"
|
||||
performance.gc_executed: "GC has been executed"
|
||||
|
||||
# Ability messages
|
||||
ability.db_corrupted: "Database consistency has been compromised"
|
||||
ability.repair_running: "A repair task is already running, please try again later"
|
||||
|
||||
# OAuth messages
|
||||
oauth.invalid_code: "Invalid authorization code"
|
||||
oauth.get_user_error: "Failed to get user information"
|
||||
oauth.account_used: "This account has been bound to another user"
|
||||
|
||||
# Model layer error messages
|
||||
redeem.failed: "Redemption failed, please try again later"
|
||||
user.create_default_token_error: "Failed to create default token"
|
||||
common.uuid_duplicate: "Please retry, the system generated a duplicate UUID!"
|
||||
common.invalid_input: "Invalid input"
|
||||
232
i18n/locales/zh.yaml
Normal file
232
i18n/locales/zh.yaml
Normal file
@@ -0,0 +1,232 @@
|
||||
# Chinese (Simplified) translations
|
||||
# 中文(简体)翻译文件
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "无效的参数"
|
||||
common.database_error: "数据库错误,请稍后重试"
|
||||
common.retry_later: "请稍后重试"
|
||||
common.generate_failed: "生成失败"
|
||||
common.not_found: "未找到"
|
||||
common.unauthorized: "未授权"
|
||||
common.forbidden: "无权限"
|
||||
common.invalid_id: "无效的ID"
|
||||
common.id_empty: "ID 为空!"
|
||||
common.feature_disabled: "该功能未启用"
|
||||
common.operation_success: "操作成功"
|
||||
common.operation_failed: "操作失败"
|
||||
common.update_success: "更新成功"
|
||||
common.update_failed: "更新失败"
|
||||
common.create_success: "创建成功"
|
||||
common.create_failed: "创建失败"
|
||||
common.delete_success: "删除成功"
|
||||
common.delete_failed: "删除失败"
|
||||
common.already_exists: "已存在"
|
||||
common.name_cannot_be_empty: "名称不能为空"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "令牌名称过长"
|
||||
token.quota_negative: "额度值不能为负数"
|
||||
token.quota_exceed_max: "额度值超出有效范围,最大值为 {{.Max}}"
|
||||
token.generate_failed: "生成令牌失败"
|
||||
token.get_info_failed: "获取令牌信息失败,请稍后重试"
|
||||
token.expired_cannot_enable: "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期"
|
||||
token.exhausted_cannot_enable: "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度"
|
||||
token.invalid: "无效的令牌"
|
||||
token.not_provided: "未提供令牌"
|
||||
token.expired: "该令牌已过期"
|
||||
token.exhausted: "该令牌额度已用尽 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
|
||||
token.status_unavailable: "该令牌状态不可用"
|
||||
token.db_error: "无效的令牌,数据库查询出错,请联系管理员"
|
||||
|
||||
# Redemption messages
|
||||
redemption.name_length: "兑换码名称长度必须在1-20之间"
|
||||
redemption.count_positive: "兑换码个数必须大于0"
|
||||
redemption.count_max: "一次兑换码批量生成的个数不能大于 100"
|
||||
redemption.create_failed: "创建兑换码失败,请稍后重试"
|
||||
redemption.invalid: "无效的兑换码"
|
||||
redemption.used: "该兑换码已被使用"
|
||||
redemption.expired: "该兑换码已过期"
|
||||
redemption.failed: "兑换失败,请稍后重试"
|
||||
redemption.not_provided: "未提供兑换码"
|
||||
redemption.expire_time_invalid: "过期时间不能早于当前时间"
|
||||
|
||||
# User messages
|
||||
user.password_login_disabled: "管理员关闭了密码登录"
|
||||
user.register_disabled: "管理员关闭了新用户注册"
|
||||
user.password_register_disabled: "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册"
|
||||
user.username_or_password_empty: "用户名或密码为空"
|
||||
user.username_or_password_error: "用户名或密码错误,或用户已被封禁"
|
||||
user.email_or_password_empty: "邮箱地址或密码为空!"
|
||||
user.exists: "用户名已存在,或已注销"
|
||||
user.not_exists: "用户不存在"
|
||||
user.disabled: "该用户已被禁用"
|
||||
user.session_save_failed: "无法保存会话信息,请重试"
|
||||
user.require_2fa: "请输入两步验证码"
|
||||
user.email_verification_required: "管理员开启了邮箱验证,请输入邮箱地址和验证码"
|
||||
user.verification_code_error: "验证码错误或已过期"
|
||||
user.input_invalid: "输入不合法 {{.Error}}"
|
||||
user.no_permission_same_level: "无权获取同级或更高等级用户的信息"
|
||||
user.no_permission_higher_level: "无权更新同权限等级或更高权限等级的用户信息"
|
||||
user.cannot_create_higher_level: "无法创建权限大于等于自己的用户"
|
||||
user.cannot_delete_root_user: "不能删除超级管理员账户"
|
||||
user.cannot_disable_root_user: "无法禁用超级管理员用户"
|
||||
user.cannot_demote_root_user: "无法降级超级管理员用户"
|
||||
user.already_admin: "该用户已经是管理员"
|
||||
user.already_common: "该用户已经是普通用户"
|
||||
user.admin_cannot_promote: "普通管理员用户无法提升其他用户为管理员"
|
||||
user.original_password_error: "原密码错误"
|
||||
user.invite_quota_insufficient: "邀请额度不足!"
|
||||
user.transfer_quota_minimum: "转移额度最小为{{.Min}}!"
|
||||
user.transfer_success: "划转成功"
|
||||
user.transfer_failed: "划转失败 {{.Error}}"
|
||||
user.topup_processing: "充值处理中,请稍后重试"
|
||||
user.register_failed: "用户注册失败或用户ID获取失败"
|
||||
user.default_token_failed: "生成默认令牌失败"
|
||||
user.aff_code_empty: "affCode 为空!"
|
||||
user.email_empty: "email 为空!"
|
||||
user.github_id_empty: "GitHub id 为空!"
|
||||
user.discord_id_empty: "discord id 为空!"
|
||||
user.oidc_id_empty: "oidc id 为空!"
|
||||
user.wechat_id_empty: "WeChat id 为空!"
|
||||
user.telegram_id_empty: "Telegram id 为空!"
|
||||
user.telegram_not_bound: "该 Telegram 账户未绑定"
|
||||
user.linux_do_id_empty: "Linux DO id 为空!"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "额度不能为负数!"
|
||||
quota.exceed_max: "额度值超出有效范围"
|
||||
quota.insufficient: "额度不足"
|
||||
quota.warning_invalid: "无效的预警类型"
|
||||
quota.threshold_gt_zero: "预警阈值必须大于0"
|
||||
|
||||
# Subscription messages
|
||||
subscription.not_enabled: "套餐未启用"
|
||||
subscription.title_empty: "套餐标题不能为空"
|
||||
subscription.price_negative: "价格不能为负数"
|
||||
subscription.price_max: "价格不能超过9999"
|
||||
subscription.purchase_limit_negative: "购买上限不能为负数"
|
||||
subscription.quota_negative: "总额度不能为负数"
|
||||
subscription.group_not_exists: "升级分组不存在"
|
||||
subscription.reset_cycle_gt_zero: "自定义重置周期需大于0秒"
|
||||
subscription.purchase_max: "已达到该套餐购买上限"
|
||||
subscription.invalid_id: "无效的订阅ID"
|
||||
subscription.invalid_user_id: "无效的用户ID"
|
||||
|
||||
# Payment messages
|
||||
payment.not_configured: "当前管理员未配置支付信息"
|
||||
payment.method_not_exists: "支付方式不存在"
|
||||
payment.callback_error: "回调地址配置错误"
|
||||
payment.create_failed: "创建订单失败"
|
||||
payment.start_failed: "拉起支付失败"
|
||||
payment.amount_too_low: "套餐金额过低"
|
||||
payment.stripe_not_configured: "Stripe 未配置或密钥无效"
|
||||
payment.webhook_not_configured: "Webhook 未配置"
|
||||
payment.price_id_not_configured: "该套餐未配置 StripePriceId"
|
||||
payment.creem_not_configured: "该套餐未配置 CreemProductId"
|
||||
|
||||
# Topup messages
|
||||
topup.not_provided: "未提供支付单号"
|
||||
topup.order_not_exists: "充值订单不存在"
|
||||
topup.order_status: "充值订单状态错误"
|
||||
topup.failed: "充值失败,请稍后重试"
|
||||
topup.invalid_quota: "无效的充值额度"
|
||||
|
||||
# Channel messages
|
||||
channel.not_exists: "渠道不存在"
|
||||
channel.id_format_error: "渠道ID格式错误"
|
||||
channel.no_available_key: "没有可用的渠道密钥"
|
||||
channel.get_list_failed: "获取渠道列表失败,请稍后重试"
|
||||
channel.get_tags_failed: "获取标签失败,请稍后重试"
|
||||
channel.get_key_failed: "获取渠道密钥失败"
|
||||
channel.get_ollama_failed: "获取Ollama模型失败"
|
||||
channel.query_failed: "查询渠道失败"
|
||||
channel.no_valid_upstream: "无有效上游渠道"
|
||||
channel.upstream_saturated: "当前分组上游负载已饱和,请稍后再试"
|
||||
channel.get_available_failed: "获取分组 {{.Group}} 下模型 {{.Model}} 的可用渠道失败"
|
||||
|
||||
# Model messages
|
||||
model.name_empty: "模型名称不能为空"
|
||||
model.name_exists: "模型名称已存在"
|
||||
model.id_missing: "缺少模型 ID"
|
||||
model.get_list_failed: "获取模型列表失败,请稍后重试"
|
||||
model.get_failed: "获取上游模型失败"
|
||||
model.reset_success: "重置模型倍率成功"
|
||||
|
||||
# Vendor messages
|
||||
vendor.name_empty: "供应商名称不能为空"
|
||||
vendor.name_exists: "供应商名称已存在"
|
||||
vendor.id_missing: "缺少供应商 ID"
|
||||
|
||||
# Group messages
|
||||
group.name_type_empty: "组名称和类型不能为空"
|
||||
group.name_exists: "组名称已存在"
|
||||
group.id_missing: "缺少组 ID"
|
||||
|
||||
# Checkin messages
|
||||
checkin.disabled: "签到功能未启用"
|
||||
checkin.already_today: "今日已签到"
|
||||
checkin.failed: "签到失败,请稍后重试"
|
||||
checkin.quota_failed: "签到失败:更新额度出错"
|
||||
|
||||
# Passkey messages
|
||||
passkey.create_failed: "无法创建 Passkey 凭证"
|
||||
passkey.login_abnormal: "Passkey 登录状态异常"
|
||||
passkey.update_failed: "Passkey 凭证更新失败"
|
||||
passkey.invalid_user_id: "无效的用户 ID"
|
||||
passkey.verify_failed: "Passkey 验证失败,请重试或联系管理员"
|
||||
|
||||
# 2FA messages
|
||||
twofa.not_enabled: "用户未启用2FA"
|
||||
twofa.user_id_empty: "用户ID不能为空"
|
||||
twofa.already_exists: "用户已存在2FA设置"
|
||||
twofa.record_id_empty: "2FA记录ID不能为空"
|
||||
twofa.code_invalid: "验证码或备用码不正确"
|
||||
|
||||
# Rate limit messages
|
||||
rate_limit.reached: "您已达到请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次"
|
||||
rate_limit.total_reached: "您已达到总请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次,包括失败次数"
|
||||
|
||||
# Setting messages
|
||||
setting.invalid_type: "无效的预警类型"
|
||||
setting.webhook_empty: "Webhook地址不能为空"
|
||||
setting.webhook_invalid: "无效的Webhook地址"
|
||||
setting.email_invalid: "无效的邮箱地址"
|
||||
setting.bark_url_empty: "Bark推送URL不能为空"
|
||||
setting.bark_url_invalid: "无效的Bark推送URL"
|
||||
setting.gotify_url_empty: "Gotify服务器地址不能为空"
|
||||
setting.gotify_token_empty: "Gotify令牌不能为空"
|
||||
setting.gotify_url_invalid: "无效的Gotify服务器地址"
|
||||
setting.url_must_http: "URL必须以http://或https://开头"
|
||||
setting.saved: "设置已更新"
|
||||
|
||||
# Deployment messages (io.net)
|
||||
deployment.not_enabled: "io.net 模型部署功能未启用或 API 密钥缺失"
|
||||
deployment.id_required: "deployment ID 为必填项"
|
||||
deployment.container_id_required: "container ID 为必填项"
|
||||
deployment.name_empty: "deployment 名称不能为空"
|
||||
deployment.name_taken: "deployment 名称已被使用,请选择其他名称"
|
||||
deployment.hardware_id_required: "hardware_id 参数为必填项"
|
||||
deployment.hardware_invalid_id: "无效的 hardware_id 参数"
|
||||
deployment.api_key_required: "api_key 为必填项"
|
||||
deployment.invalid_payload: "无效的请求内容"
|
||||
deployment.not_found: "未找到容器详情"
|
||||
|
||||
# Performance messages
|
||||
performance.disk_cache_cleared: "不活跃的磁盘缓存已清理"
|
||||
performance.stats_reset: "统计信息已重置"
|
||||
performance.gc_executed: "GC 已执行"
|
||||
|
||||
# Ability messages
|
||||
ability.db_corrupted: "数据库一致性被破坏"
|
||||
ability.repair_running: "已经有一个修复任务在运行中,请稍后再试"
|
||||
|
||||
# OAuth messages
|
||||
oauth.invalid_code: "无效的授权码"
|
||||
oauth.get_user_error: "获取用户信息失败"
|
||||
oauth.account_used: "该账户已被其他用户绑定"
|
||||
|
||||
# Model layer error messages
|
||||
redeem.failed: "兑换失败,请稍后重试"
|
||||
user.create_default_token_error: "创建默认令牌失败"
|
||||
common.uuid_duplicate: "请重试,系统生成的 UUID 竟然重复了!"
|
||||
common.invalid_input: "输入不合法"
|
||||
17
main.go
17
main.go
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/controller"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -151,6 +152,7 @@ func main() {
|
||||
//server.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
server.Use(middleware.RequestId())
|
||||
server.Use(middleware.PoweredBy())
|
||||
server.Use(middleware.I18n())
|
||||
middleware.SetUpLogger(server)
|
||||
// Initialize session store
|
||||
store := cookie.NewStore([]byte(common.SessionSecret))
|
||||
@@ -274,5 +276,20 @@ func InitResources() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 启动系统监控
|
||||
common.StartSystemMonitor()
|
||||
|
||||
// Initialize i18n
|
||||
err = i18n.Init()
|
||||
if err != nil {
|
||||
common.SysError("failed to initialize i18n: " + err.Error())
|
||||
// Don't return error, i18n is not critical
|
||||
} else {
|
||||
common.SysLog("i18n initialized with languages: " + strings.Join(i18n.SupportedLanguages(), ", "))
|
||||
}
|
||||
// Register user language loader for lazy loading
|
||||
i18n.SetUserLangLoader(model.GetUserLanguage)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -132,17 +132,6 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
c.Set("user_group", session.Get("group"))
|
||||
c.Set("use_access_token", useAccessToken)
|
||||
|
||||
//userCache, err := model.GetUserCache(id.(int))
|
||||
//if err != nil {
|
||||
// c.JSON(http.StatusOK, gin.H{
|
||||
// "success": false,
|
||||
// "message": err.Error(),
|
||||
// })
|
||||
// c.Abort()
|
||||
// return
|
||||
//}
|
||||
//userCache.WriteContext(c)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -14,5 +15,8 @@ func BodyStorageCleanup() gin.HandlerFunc {
|
||||
|
||||
// 请求结束后清理存储
|
||||
common.CleanupBodyStorage(c)
|
||||
|
||||
// 清理文件缓存(URL 下载的文件等)
|
||||
service.CleanupFileSources(c)
|
||||
}
|
||||
}
|
||||
|
||||
50
middleware/i18n.go
Normal file
50
middleware/i18n.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
)
|
||||
|
||||
// I18n middleware detects and sets the language preference for the request
|
||||
func I18n() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
lang := detectLanguage(c)
|
||||
c.Set(string(constant.ContextKeyLanguage), lang)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// detectLanguage determines the language preference for the request
|
||||
// Priority: 1. User setting (if logged in) -> 2. Accept-Language header -> 3. Default language
|
||||
func detectLanguage(c *gin.Context) string {
|
||||
// 1. Try to get language from user setting (set by auth middleware)
|
||||
if userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting); ok {
|
||||
if userSetting.Language != "" && i18n.IsSupported(userSetting.Language) {
|
||||
return userSetting.Language
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse Accept-Language header
|
||||
acceptLang := c.GetHeader("Accept-Language")
|
||||
if acceptLang != "" {
|
||||
lang := i18n.ParseAcceptLanguage(acceptLang)
|
||||
if i18n.IsSupported(lang) {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Return default language
|
||||
return i18n.DefaultLang
|
||||
}
|
||||
|
||||
// GetLanguage returns the current language from gin context
|
||||
func GetLanguage(c *gin.Context) string {
|
||||
if lang := c.GetString(string(constant.ContextKeyLanguage)); lang != "" {
|
||||
return lang
|
||||
}
|
||||
return i18n.DefaultLang
|
||||
}
|
||||
65
middleware/performance.go
Normal file
65
middleware/performance.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SystemPerformanceCheck 检查系统性能中间件
|
||||
func SystemPerformanceCheck() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 仅检查 Relay 接口 (/v1, /v1beta 等)
|
||||
// 这里简单判断路径前缀,可以根据实际路由调整
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/v1/messages") {
|
||||
if err := checkSystemPerformance(); err != nil {
|
||||
c.JSON(err.StatusCode, gin.H{
|
||||
"error": err.ToClaudeError(),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := checkSystemPerformance(); err != nil {
|
||||
c.JSON(err.StatusCode, gin.H{
|
||||
"error": err.ToOpenAIError(),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// checkSystemPerformance 检查系统性能是否超过阈值
|
||||
func checkSystemPerformance() *types.NewAPIError {
|
||||
config := common.GetPerformanceMonitorConfig()
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
status := common.GetSystemStatus()
|
||||
|
||||
// 检查 CPU
|
||||
if config.CPUThreshold > 0 && int(status.CPUUsage) > config.CPUThreshold {
|
||||
return types.NewErrorWithStatusCode(errors.New("system cpu overloaded"), "system_cpu_overloaded", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// 检查内存
|
||||
if config.MemoryThreshold > 0 && int(status.MemoryUsage) > config.MemoryThreshold {
|
||||
return types.NewErrorWithStatusCode(errors.New("system memory overloaded"), "system_memory_overloaded", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// 检查磁盘
|
||||
if config.DiskThreshold > 0 && int(status.DiskUsage) > config.DiskThreshold {
|
||||
return types.NewErrorWithStatusCode(errors.New("system disk overloaded"), "system_disk_overloaded", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
20
model/log.go
20
model/log.go
@@ -36,6 +36,7 @@ type Log struct {
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
|
||||
Other string `json:"other"`
|
||||
}
|
||||
|
||||
@@ -58,7 +59,6 @@ func formatUserLogs(logs []*Log) {
|
||||
if otherMap != nil {
|
||||
// Remove admin-only debug fields.
|
||||
delete(otherMap, "admin_info")
|
||||
delete(otherMap, "request_conversion")
|
||||
delete(otherMap, "reject_reason")
|
||||
}
|
||||
logs[i].Other = common.MapToJsonStr(otherMap)
|
||||
@@ -102,6 +102,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
|
||||
isStream bool, group string, other map[string]interface{}) {
|
||||
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
|
||||
username := c.GetString("username")
|
||||
requestId := c.GetString(common.RequestIdKey)
|
||||
otherStr := common.MapToJsonStr(other)
|
||||
// 判断是否需要记录 IP
|
||||
needRecordIp := false
|
||||
@@ -132,7 +133,8 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Other: otherStr,
|
||||
RequestId: requestId,
|
||||
Other: otherStr,
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
if err != nil {
|
||||
@@ -161,6 +163,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
|
||||
username := c.GetString("username")
|
||||
requestId := c.GetString(common.RequestIdKey)
|
||||
otherStr := common.MapToJsonStr(params.Other)
|
||||
// 判断是否需要记录 IP
|
||||
needRecordIp := false
|
||||
@@ -191,7 +194,8 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Other: otherStr,
|
||||
RequestId: requestId,
|
||||
Other: otherStr,
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
if err != nil {
|
||||
@@ -204,7 +208,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string) (logs []*Log, total int64, err error) {
|
||||
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string) (logs []*Log, total int64, err error) {
|
||||
var tx *gorm.DB
|
||||
if logType == LogTypeUnknown {
|
||||
tx = LOG_DB
|
||||
@@ -221,6 +225,9 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
}
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("logs.created_at >= ?", startTimestamp)
|
||||
}
|
||||
@@ -269,7 +276,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
return logs, total, err
|
||||
}
|
||||
|
||||
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string) (logs []*Log, total int64, err error) {
|
||||
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string) (logs []*Log, total int64, err error) {
|
||||
var tx *gorm.DB
|
||||
if logType == LogTypeUnknown {
|
||||
tx = LOG_DB.Where("logs.user_id = ?", userId)
|
||||
@@ -283,6 +290,9 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
}
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("logs.created_at >= ?", startTimestamp)
|
||||
}
|
||||
|
||||
156
model/main.go
156
model/main.go
@@ -248,6 +248,9 @@ func InitLogDB() (err error) {
|
||||
}
|
||||
|
||||
func migrateDB() error {
|
||||
// Migrate price_amount column from float/double to decimal for existing tables
|
||||
migrateSubscriptionPlanPriceAmount()
|
||||
|
||||
err := DB.AutoMigrate(
|
||||
&Channel{},
|
||||
&Token{},
|
||||
@@ -268,7 +271,6 @@ func migrateDB() error {
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
&Checkin{},
|
||||
&SubscriptionPlan{},
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
&SubscriptionPreConsumeRecord{},
|
||||
@@ -276,6 +278,15 @@ func migrateDB() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if common.UsingSQLite {
|
||||
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -306,7 +317,6 @@ func migrateDBFast() error {
|
||||
{&TwoFA{}, "TwoFA"},
|
||||
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||
{&Checkin{}, "Checkin"},
|
||||
{&SubscriptionPlan{}, "SubscriptionPlan"},
|
||||
{&SubscriptionOrder{}, "SubscriptionOrder"},
|
||||
{&UserSubscription{}, "UserSubscription"},
|
||||
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
|
||||
@@ -334,6 +344,15 @@ func migrateDBFast() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if common.UsingSQLite {
|
||||
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
common.SysLog("database migrated")
|
||||
return nil
|
||||
}
|
||||
@@ -346,6 +365,139 @@ func migrateLOGDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type sqliteColumnDef struct {
|
||||
Name string
|
||||
DDL string
|
||||
}
|
||||
|
||||
func ensureSubscriptionPlanTableSQLite() error {
|
||||
if !common.UsingSQLite {
|
||||
return nil
|
||||
}
|
||||
tableName := "subscription_plans"
|
||||
if !DB.Migrator().HasTable(tableName) {
|
||||
createSQL := `CREATE TABLE ` + "`" + tableName + "`" + ` (
|
||||
` + "`id`" + ` integer,
|
||||
` + "`title`" + ` varchar(128) NOT NULL,
|
||||
` + "`subtitle`" + ` varchar(255) DEFAULT '',
|
||||
` + "`price_amount`" + ` decimal(10,6) NOT NULL,
|
||||
` + "`currency`" + ` varchar(8) NOT NULL DEFAULT 'USD',
|
||||
` + "`duration_unit`" + ` varchar(16) NOT NULL DEFAULT 'month',
|
||||
` + "`duration_value`" + ` integer NOT NULL DEFAULT 1,
|
||||
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
|
||||
` + "`enabled`" + ` numeric DEFAULT 1,
|
||||
` + "`sort_order`" + ` integer DEFAULT 0,
|
||||
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`max_purchase_per_user`" + ` integer DEFAULT 0,
|
||||
` + "`upgrade_group`" + ` varchar(64) DEFAULT '',
|
||||
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
|
||||
` + "`quota_reset_period`" + ` varchar(16) DEFAULT 'never',
|
||||
` + "`quota_reset_custom_seconds`" + ` bigint DEFAULT 0,
|
||||
` + "`created_at`" + ` bigint,
|
||||
` + "`updated_at`" + ` bigint,
|
||||
PRIMARY KEY (` + "`id`" + `)
|
||||
)`
|
||||
return DB.Exec(createSQL).Error
|
||||
}
|
||||
var cols []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err := DB.Raw("PRAGMA table_info(`" + tableName + "`)").Scan(&cols).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existing := make(map[string]struct{}, len(cols))
|
||||
for _, c := range cols {
|
||||
existing[c.Name] = struct{}{}
|
||||
}
|
||||
required := []sqliteColumnDef{
|
||||
{Name: "title", DDL: "`title` varchar(128) NOT NULL"},
|
||||
{Name: "subtitle", DDL: "`subtitle` varchar(255) DEFAULT ''"},
|
||||
{Name: "price_amount", DDL: "`price_amount` decimal(10,6) NOT NULL"},
|
||||
{Name: "currency", DDL: "`currency` varchar(8) NOT NULL DEFAULT 'USD'"},
|
||||
{Name: "duration_unit", DDL: "`duration_unit` varchar(16) NOT NULL DEFAULT 'month'"},
|
||||
{Name: "duration_value", DDL: "`duration_value` integer NOT NULL DEFAULT 1"},
|
||||
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
|
||||
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
|
||||
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
|
||||
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"},
|
||||
{Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"},
|
||||
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
|
||||
{Name: "quota_reset_period", DDL: "`quota_reset_period` varchar(16) DEFAULT 'never'"},
|
||||
{Name: "quota_reset_custom_seconds", DDL: "`quota_reset_custom_seconds` bigint DEFAULT 0"},
|
||||
{Name: "created_at", DDL: "`created_at` bigint"},
|
||||
{Name: "updated_at", DDL: "`updated_at` bigint"},
|
||||
}
|
||||
for _, col := range required {
|
||||
if _, ok := existing[col.Name]; ok {
|
||||
continue
|
||||
}
|
||||
if err := DB.Exec("ALTER TABLE `" + tableName + "` ADD COLUMN " + col.DDL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateSubscriptionPlanPriceAmount migrates price_amount column from float/double to decimal(10,6)
|
||||
// This is safe to run multiple times - it checks the column type first
|
||||
func migrateSubscriptionPlanPriceAmount() {
|
||||
// SQLite doesn't support ALTER COLUMN, and its type affinity handles this automatically
|
||||
// Skip early to avoid GORM parsing the existing table DDL which may cause issues
|
||||
if common.UsingSQLite {
|
||||
return
|
||||
}
|
||||
|
||||
tableName := "subscription_plans"
|
||||
columnName := "price_amount"
|
||||
|
||||
// Check if table exists first
|
||||
if !DB.Migrator().HasTable(tableName) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if column exists
|
||||
if !DB.Migrator().HasColumn(&SubscriptionPlan{}, columnName) {
|
||||
return
|
||||
}
|
||||
|
||||
var alterSQL string
|
||||
if common.UsingPostgreSQL {
|
||||
// PostgreSQL: Check if already decimal/numeric
|
||||
var dataType string
|
||||
DB.Raw(`SELECT data_type FROM information_schema.columns
|
||||
WHERE table_name = ? AND column_name = ?`, tableName, columnName).Scan(&dataType)
|
||||
if dataType == "numeric" {
|
||||
return // Already decimal/numeric
|
||||
}
|
||||
alterSQL = fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE decimal(10,6) USING %s::decimal(10,6)`,
|
||||
tableName, columnName, columnName)
|
||||
} else if common.UsingMySQL {
|
||||
// MySQL: Check if already decimal
|
||||
var columnType string
|
||||
DB.Raw(`SELECT COLUMN_TYPE FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
|
||||
tableName, columnName).Scan(&columnType)
|
||||
if strings.HasPrefix(strings.ToLower(columnType), "decimal") {
|
||||
return // Already decimal
|
||||
}
|
||||
alterSQL = fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN %s decimal(10,6) NOT NULL DEFAULT 0",
|
||||
tableName, columnName)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
if alterSQL != "" {
|
||||
if err := DB.Exec(alterSQL).Error; err != nil {
|
||||
common.SysLog(fmt.Sprintf("Warning: failed to migrate %s.%s to decimal: %v", tableName, columnName, err))
|
||||
} else {
|
||||
common.SysLog(fmt.Sprintf("Successfully migrated %s.%s to decimal(10,6)", tableName, columnName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeDB(db *gorm.DB) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrRedeemFailed is returned when redemption fails due to database error
|
||||
var ErrRedeemFailed = errors.New("redeem.failed")
|
||||
|
||||
type Redemption struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
@@ -148,7 +151,8 @@ func Redeem(key string, userId int) (quota int, err error) {
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, errors.New("兑换失败," + err.Error())
|
||||
common.SysError("redemption failed: " + err.Error())
|
||||
return 0, ErrRedeemFailed
|
||||
}
|
||||
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
|
||||
return redemption.Quota, nil
|
||||
|
||||
@@ -149,7 +149,7 @@ type SubscriptionPlan struct {
|
||||
Subtitle string `json:"subtitle" gorm:"type:varchar(255);default:''"`
|
||||
|
||||
// Display money amount (follow existing code style: float64 for money)
|
||||
PriceAmount float64 `json:"price_amount" gorm:"type:double;not null;default:0"`
|
||||
PriceAmount float64 `json:"price_amount" gorm:"type:decimal(10,6);not null;default:0"`
|
||||
Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'USD'"`
|
||||
|
||||
DurationUnit string `json:"duration_unit" gorm:"type:varchar(16);not null;default:'month'"`
|
||||
|
||||
@@ -57,6 +57,7 @@ type Task struct {
|
||||
FinishTime int64 `json:"finish_time" gorm:"index"`
|
||||
Progress string `json:"progress" gorm:"type:varchar(20);index"`
|
||||
Properties Properties `json:"properties" gorm:"type:json"`
|
||||
Username string `json:"username,omitempty" gorm:"-"`
|
||||
// 禁止返回给用户,内部可能包含key等隐私信息
|
||||
PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"`
|
||||
Data json.RawMessage `json:"data" gorm:"type:json"`
|
||||
@@ -233,6 +234,12 @@ func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []*
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
if cache, err := GetUserCache(task.UserId); err == nil {
|
||||
task.Username = cache.Username
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ func Recharge(referenceId string, customerId string) (err error) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.New("充值失败," + err.Error())
|
||||
common.SysError("topup failed: " + err.Error())
|
||||
return errors.New("充值失败,请稍后重试")
|
||||
}
|
||||
|
||||
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount))
|
||||
@@ -367,7 +368,8 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.New("充值失败," + err.Error())
|
||||
common.SysError("creem topup failed: " + err.Error())
|
||||
return errors.New("充值失败,请稍后重试")
|
||||
}
|
||||
|
||||
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money))
|
||||
|
||||
@@ -221,3 +221,13 @@ func updateUserSettingCache(userId int, setting string) error {
|
||||
}
|
||||
return common.RedisHSetField(getUserCacheKey(userId), "Setting", setting)
|
||||
}
|
||||
|
||||
// GetUserLanguage returns the user's language preference from cache
|
||||
// Uses the existing GetUserCache mechanism for efficiency
|
||||
func GetUserLanguage(userId int) string {
|
||||
userCache, err := GetUserCache(userId)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return userCache.GetSetting().Language
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -40,6 +41,86 @@ func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Hea
|
||||
|
||||
const clientHeaderPlaceholderPrefix = "{client_header:"
|
||||
|
||||
const (
|
||||
headerPassthroughAllKey = "*"
|
||||
headerPassthroughRegexPrefix = "re:"
|
||||
headerPassthroughRegexPrefixV2 = "regex:"
|
||||
)
|
||||
|
||||
var passthroughSkipHeaderNamesLower = map[string]struct{}{
|
||||
// RFC 7230 hop-by-hop headers.
|
||||
"connection": {},
|
||||
"keep-alive": {},
|
||||
"proxy-authenticate": {},
|
||||
"proxy-authorization": {},
|
||||
"te": {},
|
||||
"trailer": {},
|
||||
"transfer-encoding": {},
|
||||
"upgrade": {},
|
||||
|
||||
// Additional headers that should not be forwarded by name-matching passthrough rules.
|
||||
"host": {},
|
||||
"content-length": {},
|
||||
|
||||
// Do not passthrough credentials by wildcard/regex.
|
||||
"authorization": {},
|
||||
"x-api-key": {},
|
||||
"x-goog-api-key": {},
|
||||
|
||||
// WebSocket handshake headers are generated by the client/dialer.
|
||||
"sec-websocket-key": {},
|
||||
"sec-websocket-version": {},
|
||||
"sec-websocket-extensions": {},
|
||||
}
|
||||
|
||||
var headerPassthroughRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
|
||||
func getHeaderPassthroughRegex(pattern string) (*regexp.Regexp, error) {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
return nil, errors.New("empty regex pattern")
|
||||
}
|
||||
if v, ok := headerPassthroughRegexCache.Load(pattern); ok {
|
||||
if re, ok := v.(*regexp.Regexp); ok {
|
||||
return re, nil
|
||||
}
|
||||
headerPassthroughRegexCache.Delete(pattern)
|
||||
}
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual, _ := headerPassthroughRegexCache.LoadOrStore(pattern, compiled)
|
||||
if re, ok := actual.(*regexp.Regexp); ok {
|
||||
return re, nil
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
func isHeaderPassthroughRuleKey(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
if key == headerPassthroughAllKey {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(key)
|
||||
return strings.HasPrefix(lower, headerPassthroughRegexPrefix) || strings.HasPrefix(lower, headerPassthroughRegexPrefixV2)
|
||||
}
|
||||
|
||||
func shouldSkipPassthroughHeader(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
if _, ok := passthroughSkipHeaderNamesLower[lower]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyHeaderOverridePlaceholders(template string, c *gin.Context, apiKey string) (string, bool, error) {
|
||||
trimmed := strings.TrimSpace(template)
|
||||
if strings.HasPrefix(trimmed, clientHeaderPlaceholderPrefix) {
|
||||
@@ -77,9 +158,85 @@ func applyHeaderOverridePlaceholders(template string, c *gin.Context, apiKey str
|
||||
// Supported placeholders:
|
||||
// - {api_key}: resolved to the channel API key
|
||||
// - {client_header:<name>}: resolved to the incoming request header value
|
||||
//
|
||||
// Header passthrough rules (keys only; values are ignored):
|
||||
// - "*": passthrough all incoming headers by name (excluding unsafe headers)
|
||||
// - "re:<regex>" / "regex:<regex>": passthrough headers whose names match the regex (Go regexp)
|
||||
//
|
||||
// Passthrough rules are applied first, then normal overrides are applied, so explicit overrides win.
|
||||
func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]string, error) {
|
||||
headerOverride := make(map[string]string)
|
||||
|
||||
passAll := false
|
||||
var passthroughRegex []*regexp.Regexp
|
||||
for k := range info.HeadersOverride {
|
||||
key := strings.TrimSpace(k)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if key == headerPassthroughAllKey {
|
||||
passAll = true
|
||||
continue
|
||||
}
|
||||
|
||||
lower := strings.ToLower(key)
|
||||
var pattern string
|
||||
switch {
|
||||
case strings.HasPrefix(lower, headerPassthroughRegexPrefix):
|
||||
pattern = strings.TrimSpace(key[len(headerPassthroughRegexPrefix):])
|
||||
case strings.HasPrefix(lower, headerPassthroughRegexPrefixV2):
|
||||
pattern = strings.TrimSpace(key[len(headerPassthroughRegexPrefixV2):])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if pattern == "" {
|
||||
return nil, types.NewError(fmt.Errorf("header passthrough regex pattern is empty: %q", k), types.ErrorCodeChannelHeaderOverrideInvalid)
|
||||
}
|
||||
compiled, err := getHeaderPassthroughRegex(pattern)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid)
|
||||
}
|
||||
passthroughRegex = append(passthroughRegex, compiled)
|
||||
}
|
||||
|
||||
if passAll || len(passthroughRegex) > 0 {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil, types.NewError(fmt.Errorf("missing request context for header passthrough"), types.ErrorCodeChannelHeaderOverrideInvalid)
|
||||
}
|
||||
for name := range c.Request.Header {
|
||||
if shouldSkipPassthroughHeader(name) {
|
||||
continue
|
||||
}
|
||||
if !passAll {
|
||||
matched := false
|
||||
for _, re := range passthroughRegex {
|
||||
if re.MatchString(name) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
value := strings.TrimSpace(c.Request.Header.Get(name))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
headerOverride[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range info.HeadersOverride {
|
||||
if isHeaderPassthroughRuleKey(k) {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(k)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, types.NewError(nil, types.ErrorCodeChannelHeaderOverrideInvalid)
|
||||
@@ -93,7 +250,7 @@ func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]s
|
||||
continue
|
||||
}
|
||||
|
||||
headerOverride[k] = value
|
||||
headerOverride[key] = value
|
||||
}
|
||||
return headerOverride, nil
|
||||
}
|
||||
|
||||
@@ -49,12 +49,14 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
for i2, mediaMessage := range content {
|
||||
if mediaMessage.Source != nil {
|
||||
if mediaMessage.Source.Type == "url" {
|
||||
fileData, err := service.GetFileBase64FromUrl(c, mediaMessage.Source.Url, "formatting image for Claude")
|
||||
// 使用统一的文件服务获取图片数据
|
||||
source := types.NewURLFileSource(mediaMessage.Source.Url)
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error())
|
||||
}
|
||||
mediaMessage.Source.MediaType = fileData.MimeType
|
||||
mediaMessage.Source.Data = fileData.Base64Data
|
||||
mediaMessage.Source.MediaType = mimeType
|
||||
mediaMessage.Source.Data = base64Data
|
||||
mediaMessage.Source.Url = ""
|
||||
mediaMessage.Source.Type = "base64"
|
||||
content[i2] = mediaMessage
|
||||
|
||||
@@ -364,23 +364,19 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
claudeMediaMessage.Source = &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
}
|
||||
// 判断是否是url
|
||||
// 使用统一的文件服务获取图片数据
|
||||
var source *types.FileSource
|
||||
if strings.HasPrefix(imageUrl.Url, "http") {
|
||||
// 是url,获取图片的类型和base64编码的数据
|
||||
fileData, err := service.GetFileBase64FromUrl(c, imageUrl.Url, "formatting image for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error())
|
||||
}
|
||||
claudeMediaMessage.Source.MediaType = fileData.MimeType
|
||||
claudeMediaMessage.Source.Data = fileData.Base64Data
|
||||
source = types.NewURLFileSource(imageUrl.Url)
|
||||
} else {
|
||||
_, format, base64String, err := service.DecodeBase64ImageData(imageUrl.Url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claudeMediaMessage.Source.MediaType = "image/" + format
|
||||
claudeMediaMessage.Source.Data = base64String
|
||||
source = types.NewBase64FileSource(imageUrl.Url, "")
|
||||
}
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||
}
|
||||
claudeMediaMessage.Source.MediaType = mimeType
|
||||
claudeMediaMessage.Source.Data = base64Data
|
||||
}
|
||||
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
|
||||
}
|
||||
|
||||
@@ -466,7 +466,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
}
|
||||
|
||||
openaiContent := message.ParseContent()
|
||||
imageNum := 0
|
||||
for _, part := range openaiContent {
|
||||
if part.Type == dto.ContentTypeText {
|
||||
if part.Text == "" {
|
||||
@@ -507,10 +506,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
}
|
||||
// 提取 data URL (从 "](" 后面开始,到 ")" 之前)
|
||||
dataUrl := text[bracketIdx+2 : closeIdx]
|
||||
imageNum += 1
|
||||
if constant.GeminiVisionMaxImageNum != -1 && imageNum > constant.GeminiVisionMaxImageNum {
|
||||
return nil, fmt.Errorf("too many images in the message, max allowed is %d", constant.GeminiVisionMaxImageNum)
|
||||
}
|
||||
format, base64String, err := service.DecodeBase64FileData(dataUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode markdown base64 image data failed: %s", err.Error())
|
||||
@@ -535,69 +530,58 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
})
|
||||
}
|
||||
} else if part.Type == dto.ContentTypeImageURL {
|
||||
imageNum += 1
|
||||
|
||||
if constant.GeminiVisionMaxImageNum != -1 && imageNum > constant.GeminiVisionMaxImageNum {
|
||||
return nil, fmt.Errorf("too many images in the message, max allowed is %d", constant.GeminiVisionMaxImageNum)
|
||||
}
|
||||
// 判断是否是url
|
||||
if strings.HasPrefix(part.GetImageMedia().Url, "http") {
|
||||
// 是url,获取文件的类型和base64编码的数据
|
||||
fileData, err := service.GetFileBase64FromUrl(c, part.GetImageMedia().Url, "formatting image for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file base64 from url '%s' failed: %w", part.GetImageMedia().Url, err)
|
||||
}
|
||||
|
||||
// 校验 MimeType 是否在 Gemini 支持的白名单中
|
||||
if _, ok := geminiSupportedMimeTypes[strings.ToLower(fileData.MimeType)]; !ok {
|
||||
url := part.GetImageMedia().Url
|
||||
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList())
|
||||
}
|
||||
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: fileData.MimeType, // 使用原始的 MimeType,因为大小写可能对API有意义
|
||||
Data: fileData.Base64Data,
|
||||
},
|
||||
})
|
||||
// 使用统一的文件服务获取图片数据
|
||||
var source *types.FileSource
|
||||
imageUrl := part.GetImageMedia().Url
|
||||
if strings.HasPrefix(imageUrl, "http") {
|
||||
source = types.NewURLFileSource(imageUrl)
|
||||
} else {
|
||||
format, base64String, err := service.DecodeBase64FileData(part.GetImageMedia().Url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 image data failed: %s", err.Error())
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: format,
|
||||
Data: base64String,
|
||||
},
|
||||
})
|
||||
source = types.NewBase64FileSource(imageUrl, "")
|
||||
}
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
|
||||
}
|
||||
|
||||
// 校验 MimeType 是否在 Gemini 支持的白名单中
|
||||
if _, ok := geminiSupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
|
||||
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
|
||||
}
|
||||
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
} else if part.Type == dto.ContentTypeFile {
|
||||
if part.GetFile().FileId != "" {
|
||||
return nil, fmt.Errorf("only base64 file is supported in gemini")
|
||||
}
|
||||
format, base64String, err := service.DecodeBase64FileData(part.GetFile().FileData)
|
||||
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: format,
|
||||
Data: base64String,
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
} else if part.Type == dto.ContentTypeInputAudio {
|
||||
if part.GetInputAudio().Data == "" {
|
||||
return nil, fmt.Errorf("only base64 audio is supported in gemini")
|
||||
}
|
||||
base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data)
|
||||
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: "audio/" + part.GetInputAudio().Format,
|
||||
Data: base64String,
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -988,11 +972,9 @@ func unescapeMapOrSlice(data interface{}) interface{} {
|
||||
func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
|
||||
var argsBytes []byte
|
||||
var err error
|
||||
if result, ok := item.FunctionCall.Arguments.(map[string]interface{}); ok {
|
||||
argsBytes, err = json.Marshal(unescapeMapOrSlice(result))
|
||||
} else {
|
||||
argsBytes, err = json.Marshal(item.FunctionCall.Arguments)
|
||||
}
|
||||
// 移除 unescapeMapOrSlice 调用,直接使用 json.Marshal
|
||||
// JSON 序列化/反序列化已经正确处理了转义字符
|
||||
argsBytes, err = json.Marshal(item.FunctionCall.Arguments)
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
|
||||
@@ -99,19 +99,16 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
if part.Type == dto.ContentTypeImageURL {
|
||||
img := part.GetImageMedia()
|
||||
if img != nil && img.Url != "" {
|
||||
var base64Data string
|
||||
// 使用统一的文件服务获取图片数据
|
||||
var source *types.FileSource
|
||||
if strings.HasPrefix(img.Url, "http") {
|
||||
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base64Data = fileData.Base64Data
|
||||
} else if strings.HasPrefix(img.Url, "data:") {
|
||||
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) {
|
||||
base64Data = img.Url[idx+1:]
|
||||
}
|
||||
source = types.NewURLFileSource(img.Url)
|
||||
} else {
|
||||
base64Data = img.Url
|
||||
source = types.NewBase64FileSource(img.Url, "")
|
||||
}
|
||||
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if base64Data != "" {
|
||||
images = append(images, base64Data)
|
||||
|
||||
@@ -585,6 +585,9 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
|
||||
}
|
||||
request.Model = originModel
|
||||
}
|
||||
if info != nil && request.Reasoning != nil && request.Reasoning.Effort != "" {
|
||||
info.ReasoningEffort = request.Reasoning.Effort
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,26 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func responsesStreamIndexKey(itemID string, idx *int) string {
|
||||
if itemID == "" {
|
||||
return ""
|
||||
}
|
||||
if idx == nil {
|
||||
return itemID
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", itemID, *idx)
|
||||
}
|
||||
|
||||
func stringDeltaFromPrefix(prev string, next string) string {
|
||||
if next == "" {
|
||||
return ""
|
||||
}
|
||||
if prev != "" && strings.HasPrefix(next, prev) {
|
||||
return next[len(prev):]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
@@ -86,6 +106,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
toolCallArgsByID := make(map[string]string)
|
||||
toolCallNameSent := make(map[string]bool)
|
||||
toolCallCanonicalIDByItemID := make(map[string]string)
|
||||
//reasoningSummaryTextByKey := make(map[string]string)
|
||||
|
||||
sendStartIfNeeded := func() bool {
|
||||
if sentStart {
|
||||
@@ -99,6 +120,66 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return true
|
||||
}
|
||||
|
||||
//sendReasoningDelta := func(delta string) bool {
|
||||
// if delta == "" {
|
||||
// return true
|
||||
// }
|
||||
// if !sendStartIfNeeded() {
|
||||
// return false
|
||||
// }
|
||||
//
|
||||
// usageText.WriteString(delta)
|
||||
// chunk := &dto.ChatCompletionsStreamResponse{
|
||||
// Id: responseId,
|
||||
// Object: "chat.completion.chunk",
|
||||
// Created: createAt,
|
||||
// Model: model,
|
||||
// Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
// {
|
||||
// Index: 0,
|
||||
// Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
// ReasoningContent: &delta,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// if err := helper.ObjectData(c, chunk); err != nil {
|
||||
// streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
// return false
|
||||
// }
|
||||
// return true
|
||||
//}
|
||||
|
||||
sendReasoningSummaryDelta := func(delta string) bool {
|
||||
if delta == "" {
|
||||
return true
|
||||
}
|
||||
if !sendStartIfNeeded() {
|
||||
return false
|
||||
}
|
||||
|
||||
usageText.WriteString(delta)
|
||||
chunk := &dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: createAt,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
ReasoningContent: &delta,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := helper.ObjectData(c, chunk); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
sendToolCallDelta := func(callID string, name string, argsDelta string) bool {
|
||||
if callID == "" {
|
||||
return true
|
||||
@@ -188,6 +269,37 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
}
|
||||
}
|
||||
|
||||
//case "response.reasoning_text.delta":
|
||||
//if !sendReasoningDelta(streamResp.Delta) {
|
||||
// return false
|
||||
//}
|
||||
|
||||
//case "response.reasoning_text.done":
|
||||
|
||||
case "response.reasoning_summary_text.delta":
|
||||
if !sendReasoningSummaryDelta(streamResp.Delta) {
|
||||
return false
|
||||
}
|
||||
|
||||
case "response.reasoning_summary_text.done":
|
||||
|
||||
//case "response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
|
||||
// key := responsesStreamIndexKey(strings.TrimSpace(streamResp.ItemID), streamResp.SummaryIndex)
|
||||
// if key == "" || streamResp.Part == nil {
|
||||
// break
|
||||
// }
|
||||
// // Only handle summary text parts, ignore other part types.
|
||||
// if streamResp.Part.Type != "" && streamResp.Part.Type != "summary_text" {
|
||||
// break
|
||||
// }
|
||||
// prev := reasoningSummaryTextByKey[key]
|
||||
// next := streamResp.Part.Text
|
||||
// delta := stringDeltaFromPrefix(prev, next)
|
||||
// reasoningSummaryTextByKey[key] = next
|
||||
// if !sendReasoningSummaryDelta(delta) {
|
||||
// return false
|
||||
// }
|
||||
|
||||
case "response.output_text.delta":
|
||||
if !sendStartIfNeeded() {
|
||||
return false
|
||||
|
||||
@@ -59,6 +59,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
|
||||
userRoute.GET("/logout", controller.Logout)
|
||||
userRoute.POST("/epay/notify", controller.EpayNotify)
|
||||
userRoute.GET("/epay/notify", controller.EpayNotify)
|
||||
userRoute.GET("/groups", controller.GetUserGroups)
|
||||
|
||||
selfRoute := userRoute.Group("/")
|
||||
@@ -149,6 +150,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
|
||||
// Subscription payment callbacks (no auth)
|
||||
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
|
||||
apiRouter.POST("/subscription/epay/return", controller.SubscriptionEpayReturn)
|
||||
optionRoute := apiRouter.Group("/option")
|
||||
|
||||
@@ -57,11 +57,13 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
playgroundRouter := router.Group("/pg")
|
||||
playgroundRouter.Use(middleware.SystemPerformanceCheck())
|
||||
playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute())
|
||||
{
|
||||
playgroundRouter.POST("/chat/completions", controller.Playground)
|
||||
}
|
||||
relayV1Router := router.Group("/v1")
|
||||
relayV1Router.Use(middleware.SystemPerformanceCheck())
|
||||
relayV1Router.Use(middleware.TokenAuth())
|
||||
relayV1Router.Use(middleware.ModelRequestRateLimit())
|
||||
{
|
||||
@@ -159,13 +161,16 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
relayMjRouter := router.Group("/mj")
|
||||
relayMjRouter.Use(middleware.SystemPerformanceCheck())
|
||||
registerMjRouterGroup(relayMjRouter)
|
||||
|
||||
relayMjModeRouter := router.Group("/:mode/mj")
|
||||
relayMjModeRouter.Use(middleware.SystemPerformanceCheck())
|
||||
registerMjRouterGroup(relayMjModeRouter)
|
||||
//relayMjRouter.Use()
|
||||
|
||||
relaySunoRouter := router.Group("/suno")
|
||||
relaySunoRouter.Use(middleware.SystemPerformanceCheck())
|
||||
relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
relaySunoRouter.POST("/submit/:action", controller.RelayTask)
|
||||
@@ -174,6 +179,7 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
relayGeminiRouter := router.Group("/v1beta")
|
||||
relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
|
||||
relayGeminiRouter.Use(middleware.TokenAuth())
|
||||
relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
|
||||
relayGeminiRouter.Use(middleware.Distribute())
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
@@ -130,90 +128,27 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
|
||||
// GetFileBase64FromUrl 从 URL 获取文件的 base64 编码数据
|
||||
// Deprecated: 请使用 GetBase64Data 配合 types.NewURLFileSource 替代
|
||||
// 此函数保留用于向后兼容,内部已重构为调用统一的文件服务
|
||||
func GetFileBase64FromUrl(c *gin.Context, url string, reason ...string) (*types.LocalFileData, error) {
|
||||
contextKey := fmt.Sprintf("file_download_%s", common.GenerateHMAC(url))
|
||||
|
||||
// Check if the file has already been downloaded in this request
|
||||
if cachedData, exists := c.Get(contextKey); exists {
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("Using cached file data for URL: %s", url))
|
||||
}
|
||||
return cachedData.(*types.LocalFileData), nil
|
||||
}
|
||||
|
||||
var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024
|
||||
|
||||
resp, err := DoDownloadRequest(url, reason...)
|
||||
source := types.NewURLFileSource(url)
|
||||
cachedData, err := LoadFileSource(c, source, reason...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Always use LimitReader to prevent oversized downloads
|
||||
fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxFileSize+1)))
|
||||
// 转换为旧的 LocalFileData 格式以保持兼容
|
||||
base64Data, err := cachedData.GetBase64Data()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Check actual size after reading
|
||||
if len(fileBytes) > maxFileSize {
|
||||
return nil, fmt.Errorf("file size exceeds maximum allowed size: %dMB", constant.MaxFileDownloadMB)
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
base64Data := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
|
||||
mimeType := resp.Header.Get("Content-Type")
|
||||
if len(strings.Split(mimeType, ";")) > 1 {
|
||||
// If Content-Type has parameters, take the first part
|
||||
mimeType = strings.Split(mimeType, ";")[0]
|
||||
}
|
||||
if mimeType == "application/octet-stream" {
|
||||
logger.LogDebug(c, fmt.Sprintf("MIME type is application/octet-stream for URL: %s", url))
|
||||
// try to guess the MIME type from the url last segment
|
||||
urlParts := strings.Split(url, "/")
|
||||
if len(urlParts) > 0 {
|
||||
lastSegment := urlParts[len(urlParts)-1]
|
||||
if strings.Contains(lastSegment, ".") {
|
||||
// Extract the file extension
|
||||
filename := strings.Split(lastSegment, ".")
|
||||
if len(filename) > 1 {
|
||||
ext := strings.ToLower(filename[len(filename)-1])
|
||||
// Guess MIME type based on file extension
|
||||
mimeType = GetMimeTypeByExtension(ext)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// try to guess the MIME type from the file extension
|
||||
fileName := resp.Header.Get("Content-Disposition")
|
||||
if fileName != "" {
|
||||
// Extract the filename from the Content-Disposition header
|
||||
parts := strings.Split(fileName, ";")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(strings.TrimSpace(part), "filename=") {
|
||||
fileName = strings.TrimSpace(strings.TrimPrefix(part, "filename="))
|
||||
// Remove quotes if present
|
||||
if len(fileName) > 2 && fileName[0] == '"' && fileName[len(fileName)-1] == '"' {
|
||||
fileName = fileName[1 : len(fileName)-1]
|
||||
}
|
||||
// Guess MIME type based on file extension
|
||||
if ext := strings.ToLower(strings.TrimPrefix(fileName, ".")); ext != "" {
|
||||
mimeType = GetMimeTypeByExtension(ext)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data := &types.LocalFileData{
|
||||
return &types.LocalFileData{
|
||||
Base64Data: base64Data,
|
||||
MimeType: mimeType,
|
||||
Size: int64(len(fileBytes)),
|
||||
}
|
||||
// Store the file data in the context to avoid re-downloading
|
||||
c.Set(contextKey, data)
|
||||
|
||||
return data, nil
|
||||
MimeType: cachedData.MimeType,
|
||||
Size: cachedData.Size,
|
||||
Url: url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetMimeTypeByExtension(ext string) string {
|
||||
|
||||
471
service/file_service.go
Normal file
471
service/file_service.go
Normal file
@@ -0,0 +1,471 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// FileService 统一的文件处理服务
|
||||
// 提供文件下载、解码、缓存等功能的统一入口
|
||||
|
||||
// getContextCacheKey 生成 context 缓存的 key
|
||||
func getContextCacheKey(url string) string {
|
||||
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
|
||||
}
|
||||
|
||||
// LoadFileSource 加载文件源数据
|
||||
// 这是统一的入口,会自动处理缓存和不同的来源类型
|
||||
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
|
||||
if source == nil {
|
||||
return nil, fmt.Errorf("file source is nil")
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("LoadFileSource starting for: %s", source.GetIdentifier()))
|
||||
}
|
||||
|
||||
// 1. 快速检查内部缓存
|
||||
if source.HasCache() {
|
||||
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
return source.GetCache(), nil
|
||||
}
|
||||
|
||||
// 2. 加锁保护加载过程
|
||||
source.Mu().Lock()
|
||||
defer source.Mu().Unlock()
|
||||
|
||||
// 3. 双重检查
|
||||
if source.HasCache() {
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
return source.GetCache(), nil
|
||||
}
|
||||
|
||||
// 4. 如果是 URL,检查 Context 缓存
|
||||
var contextKey string
|
||||
if source.IsURL() && c != nil {
|
||||
contextKey = getContextCacheKey(source.URL)
|
||||
if cachedData, exists := c.Get(contextKey); exists {
|
||||
data := cachedData.(*types.CachedFileData)
|
||||
source.SetCache(data)
|
||||
registerSourceForCleanup(c, source)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行加载逻辑
|
||||
var cachedData *types.CachedFileData
|
||||
var err error
|
||||
|
||||
if source.IsURL() {
|
||||
cachedData, err = loadFromURL(c, source.URL, reason...)
|
||||
} else {
|
||||
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. 设置缓存
|
||||
source.SetCache(cachedData)
|
||||
if contextKey != "" && c != nil {
|
||||
c.Set(contextKey, cachedData)
|
||||
}
|
||||
|
||||
// 7. 注册到 context 以便请求结束时自动清理
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
|
||||
return cachedData, nil
|
||||
}
|
||||
|
||||
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
|
||||
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
|
||||
if source.IsRegistered() {
|
||||
return
|
||||
}
|
||||
|
||||
key := string(constant.ContextKeyFileSourcesToCleanup)
|
||||
var sources []*types.FileSource
|
||||
if existing, exists := c.Get(key); exists {
|
||||
sources = existing.([]*types.FileSource)
|
||||
}
|
||||
sources = append(sources, source)
|
||||
c.Set(key, sources)
|
||||
source.SetRegistered(true)
|
||||
}
|
||||
|
||||
// CleanupFileSources 清理请求中所有注册的 FileSource
|
||||
// 应在请求结束时调用(通常由中间件自动调用)
|
||||
func CleanupFileSources(c *gin.Context) {
|
||||
key := string(constant.ContextKeyFileSourcesToCleanup)
|
||||
if sources, exists := c.Get(key); exists {
|
||||
for _, source := range sources.([]*types.FileSource) {
|
||||
if cache := source.GetCache(); cache != nil {
|
||||
cache.Close()
|
||||
}
|
||||
}
|
||||
c.Set(key, nil) // 清除引用
|
||||
}
|
||||
}
|
||||
|
||||
// loadFromURL 从 URL 加载文件
|
||||
func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFileData, error) {
|
||||
// 下载文件
|
||||
var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, "loadFromURL: initiating download")
|
||||
}
|
||||
resp, err := DoDownloadRequest(url, reason...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download file from %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to download file, status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取文件内容(限制大小)
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, "loadFromURL: reading response body")
|
||||
}
|
||||
fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxFileSize+1)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file content: %w", err)
|
||||
}
|
||||
if len(fileBytes) > maxFileSize {
|
||||
return nil, fmt.Errorf("file size exceeds maximum allowed size: %dMB", constant.MaxFileDownloadMB)
|
||||
}
|
||||
|
||||
// 转换为 base64
|
||||
base64Data := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
|
||||
// 智能获取 MIME 类型
|
||||
mimeType := smartDetectMimeType(resp, url, fileBytes)
|
||||
|
||||
// 判断是否使用磁盘缓存
|
||||
base64Size := int64(len(base64Data))
|
||||
var cachedData *types.CachedFileData
|
||||
|
||||
if shouldUseDiskCache(base64Size) {
|
||||
// 使用磁盘缓存
|
||||
diskPath, err := writeToDiskCache(base64Data)
|
||||
if err != nil {
|
||||
// 磁盘缓存失败,回退到内存
|
||||
logger.LogWarn(c, fmt.Sprintf("Failed to write to disk cache, falling back to memory: %v", err))
|
||||
cachedData = types.NewMemoryCachedData(base64Data, mimeType, int64(len(fileBytes)))
|
||||
} else {
|
||||
cachedData = types.NewDiskCachedData(diskPath, mimeType, int64(len(fileBytes)))
|
||||
cachedData.DiskSize = base64Size
|
||||
cachedData.OnClose = func(size int64) {
|
||||
common.DecrementDiskFiles(size)
|
||||
}
|
||||
common.IncrementDiskFiles(base64Size)
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("File cached to disk: %s, size: %d bytes", diskPath, base64Size))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 使用内存缓存
|
||||
cachedData = types.NewMemoryCachedData(base64Data, mimeType, int64(len(fileBytes)))
|
||||
}
|
||||
|
||||
// 如果是图片,尝试获取图片配置
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, "loadFromURL: decoding image config")
|
||||
}
|
||||
config, format, err := decodeImageConfig(fileBytes)
|
||||
if err == nil {
|
||||
cachedData.ImageConfig = &config
|
||||
cachedData.ImageFormat = format
|
||||
// 如果通过图片解码获取了更准确的格式,更新 MIME 类型
|
||||
if mimeType == "application/octet-stream" || mimeType == "" {
|
||||
cachedData.MimeType = "image/" + format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cachedData, nil
|
||||
}
|
||||
|
||||
// shouldUseDiskCache 判断是否应该使用磁盘缓存
|
||||
func shouldUseDiskCache(dataSize int64) bool {
|
||||
return common.ShouldUseDiskCache(dataSize)
|
||||
}
|
||||
|
||||
// writeToDiskCache 将数据写入磁盘缓存
|
||||
func writeToDiskCache(base64Data string) (string, error) {
|
||||
return common.WriteDiskCacheFileString(common.DiskCacheTypeFile, base64Data)
|
||||
}
|
||||
|
||||
// smartDetectMimeType 智能检测 MIME 类型
|
||||
func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) string {
|
||||
// 1. 尝试从 Content-Type header 获取
|
||||
mimeType := resp.Header.Get("Content-Type")
|
||||
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
||||
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
if mimeType != "" && mimeType != "application/octet-stream" {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
// 2. 尝试从 Content-Disposition header 的 filename 获取
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
||||
parts := strings.Split(cd, ";")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(strings.ToLower(part), "filename=") {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(part, "filename="))
|
||||
// 移除引号
|
||||
if len(name) > 2 && name[0] == '"' && name[len(name)-1] == '"' {
|
||||
name = name[1 : len(name)-1]
|
||||
}
|
||||
if dot := strings.LastIndex(name, "."); dot != -1 && dot+1 < len(name) {
|
||||
ext := strings.ToLower(name[dot+1:])
|
||||
if ext != "" {
|
||||
mt := GetMimeTypeByExtension(ext)
|
||||
if mt != "application/octet-stream" {
|
||||
return mt
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试从 URL 路径获取扩展名
|
||||
mt := guessMimeTypeFromURL(url)
|
||||
if mt != "application/octet-stream" {
|
||||
return mt
|
||||
}
|
||||
|
||||
// 4. 使用 http.DetectContentType 内容嗅探
|
||||
if len(fileBytes) > 0 {
|
||||
sniffed := http.DetectContentType(fileBytes)
|
||||
if sniffed != "" && sniffed != "application/octet-stream" {
|
||||
// 去除可能的 charset 参数
|
||||
if idx := strings.Index(sniffed, ";"); idx != -1 {
|
||||
sniffed = strings.TrimSpace(sniffed[:idx])
|
||||
}
|
||||
return sniffed
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 尝试作为图片解码获取格式
|
||||
if len(fileBytes) > 0 {
|
||||
if _, format, err := decodeImageConfig(fileBytes); err == nil && format != "" {
|
||||
return "image/" + strings.ToLower(format)
|
||||
}
|
||||
}
|
||||
|
||||
// 最终回退
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// loadFromBase64 从 base64 字符串加载文件
|
||||
func loadFromBase64(base64String string, providedMimeType string) (*types.CachedFileData, error) {
|
||||
var mimeType string
|
||||
var cleanBase64 string
|
||||
|
||||
// 处理 data: 前缀
|
||||
if strings.HasPrefix(base64String, "data:") {
|
||||
idx := strings.Index(base64String, ",")
|
||||
if idx != -1 {
|
||||
header := base64String[:idx]
|
||||
cleanBase64 = base64String[idx+1:]
|
||||
|
||||
if strings.Contains(header, ":") && strings.Contains(header, ";") {
|
||||
mimeStart := strings.Index(header, ":") + 1
|
||||
mimeEnd := strings.Index(header, ";")
|
||||
if mimeStart < mimeEnd {
|
||||
mimeType = header[mimeStart:mimeEnd]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cleanBase64 = base64String
|
||||
}
|
||||
} else {
|
||||
cleanBase64 = base64String
|
||||
}
|
||||
|
||||
if providedMimeType != "" {
|
||||
mimeType = providedMimeType
|
||||
}
|
||||
|
||||
decodedData, err := base64.StdEncoding.DecodeString(cleanBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 data: %w", err)
|
||||
}
|
||||
|
||||
base64Size := int64(len(cleanBase64))
|
||||
var cachedData *types.CachedFileData
|
||||
|
||||
if shouldUseDiskCache(base64Size) {
|
||||
diskPath, err := writeToDiskCache(cleanBase64)
|
||||
if err != nil {
|
||||
cachedData = types.NewMemoryCachedData(cleanBase64, mimeType, int64(len(decodedData)))
|
||||
} else {
|
||||
cachedData = types.NewDiskCachedData(diskPath, mimeType, int64(len(decodedData)))
|
||||
cachedData.DiskSize = base64Size
|
||||
cachedData.OnClose = func(size int64) {
|
||||
common.DecrementDiskFiles(size)
|
||||
}
|
||||
common.IncrementDiskFiles(base64Size)
|
||||
}
|
||||
} else {
|
||||
cachedData = types.NewMemoryCachedData(cleanBase64, mimeType, int64(len(decodedData)))
|
||||
}
|
||||
|
||||
if mimeType == "" || strings.HasPrefix(mimeType, "image/") {
|
||||
config, format, err := decodeImageConfig(decodedData)
|
||||
if err == nil {
|
||||
cachedData.ImageConfig = &config
|
||||
cachedData.ImageFormat = format
|
||||
if mimeType == "" {
|
||||
cachedData.MimeType = "image/" + format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cachedData, nil
|
||||
}
|
||||
|
||||
// GetImageConfig 获取图片配置
|
||||
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
|
||||
cachedData, err := LoadFileSource(c, source, "get_image_config")
|
||||
if err != nil {
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
|
||||
if cachedData.ImageConfig != nil {
|
||||
return *cachedData.ImageConfig, cachedData.ImageFormat, nil
|
||||
}
|
||||
|
||||
base64Str, err := cachedData.GetBase64Data()
|
||||
if err != nil {
|
||||
return image.Config{}, "", fmt.Errorf("failed to get base64 data: %w", err)
|
||||
}
|
||||
decodedData, err := base64.StdEncoding.DecodeString(base64Str)
|
||||
if err != nil {
|
||||
return image.Config{}, "", fmt.Errorf("failed to decode base64 for image config: %w", err)
|
||||
}
|
||||
|
||||
config, format, err := decodeImageConfig(decodedData)
|
||||
if err != nil {
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
|
||||
cachedData.ImageConfig = &config
|
||||
cachedData.ImageFormat = format
|
||||
|
||||
return config, format, nil
|
||||
}
|
||||
|
||||
// GetBase64Data 获取 base64 编码的数据
|
||||
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
|
||||
cachedData, err := LoadFileSource(c, source, reason...)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
base64Str, err := cachedData.GetBase64Data()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get base64 data: %w", err)
|
||||
}
|
||||
return base64Str, cachedData.MimeType, nil
|
||||
}
|
||||
|
||||
// GetMimeType 获取文件的 MIME 类型
|
||||
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
|
||||
if source.HasCache() {
|
||||
return source.GetCache().MimeType, nil
|
||||
}
|
||||
|
||||
if source.IsURL() {
|
||||
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
|
||||
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
|
||||
return mimeType, nil
|
||||
}
|
||||
}
|
||||
|
||||
cachedData, err := LoadFileSource(c, source, "get_mime_type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cachedData.MimeType, nil
|
||||
}
|
||||
|
||||
// DetectFileType 检测文件类型
|
||||
func DetectFileType(mimeType string) types.FileType {
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
return types.FileTypeImage
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "audio/") {
|
||||
return types.FileTypeAudio
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "video/") {
|
||||
return types.FileTypeVideo
|
||||
}
|
||||
return types.FileTypeFile
|
||||
}
|
||||
|
||||
// decodeImageConfig 从字节数据解码图片配置
|
||||
func decodeImageConfig(data []byte) (image.Config, string, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
config, format, err := image.DecodeConfig(reader)
|
||||
if err == nil {
|
||||
return config, format, nil
|
||||
}
|
||||
|
||||
reader.Seek(0, io.SeekStart)
|
||||
config, err = webp.DecodeConfig(reader)
|
||||
if err == nil {
|
||||
return config, "webp", nil
|
||||
}
|
||||
|
||||
return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format")
|
||||
}
|
||||
|
||||
// guessMimeTypeFromURL 从 URL 猜测 MIME 类型
|
||||
func guessMimeTypeFromURL(url string) string {
|
||||
cleanedURL := url
|
||||
if q := strings.Index(cleanedURL, "?"); q != -1 {
|
||||
cleanedURL = cleanedURL[:q]
|
||||
}
|
||||
|
||||
if slash := strings.LastIndex(cleanedURL, "/"); slash != -1 && slash+1 < len(cleanedURL) {
|
||||
last := cleanedURL[slash+1:]
|
||||
if dot := strings.LastIndex(last, "."); dot != -1 && dot+1 < len(last) {
|
||||
ext := strings.ToLower(last[dot+1:])
|
||||
return GetMimeTypeByExtension(ext)
|
||||
}
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
@@ -346,9 +346,10 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
if req.ReasoningEffort != "" && req.ReasoningEffort != "none" {
|
||||
if req.ReasoningEffort != "" {
|
||||
out.Reasoning = &dto.Reasoning{
|
||||
Effort: req.ReasoningEffort,
|
||||
Effort: req.ReasoningEffort,
|
||||
Summary: "detailed",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"math"
|
||||
"path/filepath"
|
||||
@@ -23,8 +19,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, error) {
|
||||
if fileMeta == nil {
|
||||
func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, stream bool) (int, error) {
|
||||
if fileMeta == nil || fileMeta.Source == nil {
|
||||
return 0, fmt.Errorf("image_url_is_nil")
|
||||
}
|
||||
|
||||
@@ -99,35 +95,20 @@ func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, er
|
||||
fileMeta.Detail = "high"
|
||||
}
|
||||
|
||||
// Decode image to get dimensions
|
||||
var config image.Config
|
||||
var err error
|
||||
var format string
|
||||
var b64str string
|
||||
|
||||
if fileMeta.ParsedData != nil {
|
||||
config, format, b64str, err = DecodeBase64ImageData(fileMeta.ParsedData.Base64Data)
|
||||
} else {
|
||||
if strings.HasPrefix(fileMeta.OriginData, "http") {
|
||||
config, format, err = DecodeUrlImageData(fileMeta.OriginData)
|
||||
} else {
|
||||
common.SysLog(fmt.Sprintf("decoding image"))
|
||||
config, format, b64str, err = DecodeBase64ImageData(fileMeta.OriginData)
|
||||
}
|
||||
fileMeta.MimeType = format
|
||||
}
|
||||
|
||||
// 使用统一的文件服务获取图片配置
|
||||
config, format, err := GetImageConfig(c, fileMeta.Source)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fileMeta.MimeType = format
|
||||
|
||||
if config.Width == 0 || config.Height == 0 {
|
||||
// not an image
|
||||
if format != "" && b64str != "" {
|
||||
// not an image, but might be a valid file
|
||||
if format != "" {
|
||||
// file type
|
||||
return 3 * baseTokens, nil
|
||||
}
|
||||
return 0, errors.New(fmt.Sprintf("fail to decode base64 config: %s", fileMeta.OriginData))
|
||||
return 0, errors.New(fmt.Sprintf("fail to decode image config: %s", fileMeta.GetIdentifier()))
|
||||
}
|
||||
|
||||
width := config.Width
|
||||
@@ -269,48 +250,26 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
|
||||
shouldFetchFiles = false
|
||||
}
|
||||
|
||||
// 使用统一的文件服务获取文件类型
|
||||
for _, file := range meta.Files {
|
||||
if strings.HasPrefix(file.OriginData, "http") {
|
||||
if shouldFetchFiles {
|
||||
mineType, err := GetFileTypeFromUrl(c, file.OriginData, "token_counter")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error getting file base64 from url: %v", err)
|
||||
}
|
||||
if strings.HasPrefix(mineType, "image/") {
|
||||
file.FileType = types.FileTypeImage
|
||||
} else if strings.HasPrefix(mineType, "video/") {
|
||||
file.FileType = types.FileTypeVideo
|
||||
} else if strings.HasPrefix(mineType, "audio/") {
|
||||
file.FileType = types.FileTypeAudio
|
||||
} else {
|
||||
file.FileType = types.FileTypeFile
|
||||
}
|
||||
file.MimeType = mineType
|
||||
}
|
||||
} else if strings.HasPrefix(file.OriginData, "data:") {
|
||||
// get mime type from base64 header
|
||||
parts := strings.SplitN(file.OriginData, ",", 2)
|
||||
if len(parts) >= 1 {
|
||||
header := parts[0]
|
||||
// Extract mime type from "data:mime/type;base64" format
|
||||
if strings.Contains(header, ":") && strings.Contains(header, ";") {
|
||||
mimeStart := strings.Index(header, ":") + 1
|
||||
mimeEnd := strings.Index(header, ";")
|
||||
if mimeStart < mimeEnd {
|
||||
mineType := header[mimeStart:mimeEnd]
|
||||
if strings.HasPrefix(mineType, "image/") {
|
||||
file.FileType = types.FileTypeImage
|
||||
} else if strings.HasPrefix(mineType, "video/") {
|
||||
file.FileType = types.FileTypeVideo
|
||||
} else if strings.HasPrefix(mineType, "audio/") {
|
||||
file.FileType = types.FileTypeAudio
|
||||
} else {
|
||||
file.FileType = types.FileTypeFile
|
||||
}
|
||||
file.MimeType = mineType
|
||||
}
|
||||
if file.Source == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果文件类型未知且需要获取,通过 MIME 类型检测
|
||||
if file.FileType == "" || (file.Source.IsURL() && shouldFetchFiles) {
|
||||
// 注意:这里我们直接调用 LoadFileSource 而不是 GetMimeType
|
||||
// 因为 GetMimeType 内部可能会调用 GetFileTypeFromUrl (HEAD 请求)
|
||||
// 而我们这里既然要计算 token,通常需要完整数据
|
||||
cachedData, err := LoadFileSource(c, file.Source, "token_counter")
|
||||
if err != nil {
|
||||
if shouldFetchFiles {
|
||||
return 0, fmt.Errorf("error getting file type: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
file.MimeType = cachedData.MimeType
|
||||
file.FileType = DetectFileType(cachedData.MimeType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,9 +277,9 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
|
||||
switch file.FileType {
|
||||
case types.FileTypeImage:
|
||||
if common.IsOpenAITextModel(model) {
|
||||
token, err := getImageToken(file, model, info.IsStream)
|
||||
token, err := getImageToken(c, file, model, info.IsStream)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error counting image token, media index[%d], original data[%s], err: %v", i, file.OriginData, err)
|
||||
return 0, fmt.Errorf("error counting image token, media index[%d], identifier[%s], err: %v", i, file.GetIdentifier(), err)
|
||||
}
|
||||
tkm += token
|
||||
} else {
|
||||
|
||||
@@ -15,6 +15,15 @@ type PerformanceSetting struct {
|
||||
DiskCacheMaxSizeMB int `json:"disk_cache_max_size_mb"`
|
||||
// DiskCachePath 磁盘缓存目录
|
||||
DiskCachePath string `json:"disk_cache_path"`
|
||||
|
||||
// MonitorEnabled 是否启用性能监控
|
||||
MonitorEnabled bool `json:"monitor_enabled"`
|
||||
// MonitorCPUThreshold CPU 使用率阈值(%)
|
||||
MonitorCPUThreshold int `json:"monitor_cpu_threshold"`
|
||||
// MonitorMemoryThreshold 内存使用率阈值(%)
|
||||
MonitorMemoryThreshold int `json:"monitor_memory_threshold"`
|
||||
// MonitorDiskThreshold 磁盘使用率阈值(%)
|
||||
MonitorDiskThreshold int `json:"monitor_disk_threshold"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
@@ -23,6 +32,11 @@ var performanceSetting = PerformanceSetting{
|
||||
DiskCacheThresholdMB: 10, // 超过 10MB 使用磁盘缓存
|
||||
DiskCacheMaxSizeMB: 1024, // 最大 1GB 磁盘缓存
|
||||
DiskCachePath: "", // 空表示使用系统临时目录
|
||||
|
||||
MonitorEnabled: true,
|
||||
MonitorCPUThreshold: 90,
|
||||
MonitorMemoryThreshold: 90,
|
||||
MonitorDiskThreshold: 90,
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -40,6 +54,13 @@ func syncToCommon() {
|
||||
MaxSizeMB: performanceSetting.DiskCacheMaxSizeMB,
|
||||
Path: performanceSetting.DiskCachePath,
|
||||
})
|
||||
|
||||
common.SetPerformanceMonitorConfig(common.PerformanceMonitorConfig{
|
||||
Enabled: performanceSetting.MonitorEnabled,
|
||||
CPUThreshold: performanceSetting.MonitorCPUThreshold,
|
||||
MemoryThreshold: performanceSetting.MonitorMemoryThreshold,
|
||||
DiskThreshold: performanceSetting.MonitorDiskThreshold,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPerformanceSetting 获取性能设置
|
||||
|
||||
231
types/file_source.go
Normal file
231
types/file_source.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FileSourceType 文件来源类型
|
||||
type FileSourceType string
|
||||
|
||||
const (
|
||||
FileSourceTypeURL FileSourceType = "url" // URL 来源
|
||||
FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据
|
||||
)
|
||||
|
||||
// FileSource 统一的文件来源抽象
|
||||
// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
|
||||
type FileSource struct {
|
||||
Type FileSourceType `json:"type"` // 来源类型
|
||||
URL string `json:"url,omitempty"` // URL(当 Type 为 url 时)
|
||||
Base64Data string `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时)
|
||||
MimeType string `json:"mime_type,omitempty"` // MIME 类型(可选,会自动检测)
|
||||
|
||||
// 内部缓存(不导出,不序列化)
|
||||
cachedData *CachedFileData
|
||||
cacheLoaded bool
|
||||
registered bool // 是否已注册到清理列表
|
||||
mu sync.Mutex // 保护加载过程
|
||||
}
|
||||
|
||||
// Mu 获取内部锁
|
||||
func (f *FileSource) Mu() *sync.Mutex {
|
||||
return &f.mu
|
||||
}
|
||||
|
||||
// CachedFileData 缓存的文件数据
|
||||
// 支持内存缓存和磁盘缓存两种模式
|
||||
type CachedFileData struct {
|
||||
base64Data string // 内存中的 base64 数据(小文件)
|
||||
MimeType string // MIME 类型
|
||||
Size int64 // 文件大小(字节)
|
||||
DiskSize int64 // 磁盘缓存实际占用大小(字节,通常是 base64 长度)
|
||||
ImageConfig *image.Config // 图片配置(如果是图片)
|
||||
ImageFormat string // 图片格式(如果是图片)
|
||||
|
||||
// 磁盘缓存相关
|
||||
diskPath string // 磁盘缓存文件路径(大文件)
|
||||
isDisk bool // 是否使用磁盘缓存
|
||||
diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除)
|
||||
diskClosed bool // 是否已关闭/清理
|
||||
statDecremented bool // 是否已扣减统计
|
||||
|
||||
// 统计回调,避免循环依赖
|
||||
OnClose func(size int64)
|
||||
}
|
||||
|
||||
// NewMemoryCachedData 创建内存缓存的数据
|
||||
func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData {
|
||||
return &CachedFileData{
|
||||
base64Data: base64Data,
|
||||
MimeType: mimeType,
|
||||
Size: size,
|
||||
isDisk: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewDiskCachedData 创建磁盘缓存的数据
|
||||
func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData {
|
||||
return &CachedFileData{
|
||||
diskPath: diskPath,
|
||||
MimeType: mimeType,
|
||||
Size: size,
|
||||
isDisk: true,
|
||||
}
|
||||
}
|
||||
|
||||
// GetBase64Data 获取 base64 数据(自动处理内存/磁盘)
|
||||
func (c *CachedFileData) GetBase64Data() (string, error) {
|
||||
if !c.isDisk {
|
||||
return c.base64Data, nil
|
||||
}
|
||||
|
||||
c.diskMu.Lock()
|
||||
defer c.diskMu.Unlock()
|
||||
|
||||
if c.diskClosed {
|
||||
return "", fmt.Errorf("disk cache already closed")
|
||||
}
|
||||
|
||||
// 从磁盘读取
|
||||
data, err := os.ReadFile(c.diskPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read from disk cache: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// SetBase64Data 设置 base64 数据(仅用于内存模式)
|
||||
func (c *CachedFileData) SetBase64Data(data string) {
|
||||
if !c.isDisk {
|
||||
c.base64Data = data
|
||||
}
|
||||
}
|
||||
|
||||
// IsDisk 是否使用磁盘缓存
|
||||
func (c *CachedFileData) IsDisk() bool {
|
||||
return c.isDisk
|
||||
}
|
||||
|
||||
// Close 关闭并清理资源
|
||||
func (c *CachedFileData) Close() error {
|
||||
if !c.isDisk {
|
||||
c.base64Data = "" // 释放内存
|
||||
return nil
|
||||
}
|
||||
|
||||
c.diskMu.Lock()
|
||||
defer c.diskMu.Unlock()
|
||||
|
||||
if c.diskClosed {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.diskClosed = true
|
||||
if c.diskPath != "" {
|
||||
err := os.Remove(c.diskPath)
|
||||
// 只有在删除成功且未扣减过统计时,才执行回调
|
||||
if err == nil && !c.statDecremented && c.OnClose != nil {
|
||||
c.OnClose(c.DiskSize)
|
||||
c.statDecremented = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewURLFileSource 创建 URL 来源的 FileSource
|
||||
func NewURLFileSource(url string) *FileSource {
|
||||
return &FileSource{
|
||||
Type: FileSourceTypeURL,
|
||||
URL: url,
|
||||
}
|
||||
}
|
||||
|
||||
// NewBase64FileSource 创建 base64 来源的 FileSource
|
||||
func NewBase64FileSource(base64Data string, mimeType string) *FileSource {
|
||||
return &FileSource{
|
||||
Type: FileSourceTypeBase64,
|
||||
Base64Data: base64Data,
|
||||
MimeType: mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
// IsURL 判断是否是 URL 来源
|
||||
func (f *FileSource) IsURL() bool {
|
||||
return f.Type == FileSourceTypeURL
|
||||
}
|
||||
|
||||
// IsBase64 判断是否是 base64 来源
|
||||
func (f *FileSource) IsBase64() bool {
|
||||
return f.Type == FileSourceTypeBase64
|
||||
}
|
||||
|
||||
// GetIdentifier 获取文件标识符(用于日志和错误追踪)
|
||||
func (f *FileSource) GetIdentifier() string {
|
||||
if f.IsURL() {
|
||||
if len(f.URL) > 100 {
|
||||
return f.URL[:100] + "..."
|
||||
}
|
||||
return f.URL
|
||||
}
|
||||
if len(f.Base64Data) > 50 {
|
||||
return "base64:" + f.Base64Data[:50] + "..."
|
||||
}
|
||||
return "base64:" + f.Base64Data
|
||||
}
|
||||
|
||||
// GetRawData 获取原始数据(URL 或完整的 base64 字符串)
|
||||
func (f *FileSource) GetRawData() string {
|
||||
if f.IsURL() {
|
||||
return f.URL
|
||||
}
|
||||
return f.Base64Data
|
||||
}
|
||||
|
||||
// SetCache 设置缓存数据
|
||||
func (f *FileSource) SetCache(data *CachedFileData) {
|
||||
f.cachedData = data
|
||||
f.cacheLoaded = true
|
||||
}
|
||||
|
||||
// IsRegistered 是否已注册到清理列表
|
||||
func (f *FileSource) IsRegistered() bool {
|
||||
return f.registered
|
||||
}
|
||||
|
||||
// SetRegistered 设置注册状态
|
||||
func (f *FileSource) SetRegistered(registered bool) {
|
||||
f.registered = registered
|
||||
}
|
||||
|
||||
// GetCache 获取缓存数据
|
||||
func (f *FileSource) GetCache() *CachedFileData {
|
||||
return f.cachedData
|
||||
}
|
||||
|
||||
// HasCache 是否有缓存
|
||||
func (f *FileSource) HasCache() bool {
|
||||
return f.cacheLoaded && f.cachedData != nil
|
||||
}
|
||||
|
||||
// ClearCache 清除缓存,释放内存和磁盘文件
|
||||
func (f *FileSource) ClearCache() {
|
||||
// 如果有缓存数据,先关闭它(会清理磁盘文件)
|
||||
if f.cachedData != nil {
|
||||
f.cachedData.Close()
|
||||
}
|
||||
f.cachedData = nil
|
||||
f.cacheLoaded = false
|
||||
}
|
||||
|
||||
// ClearRawData 清除原始数据,只保留必要的元信息
|
||||
// 用于在处理完成后释放大文件的内存
|
||||
func (f *FileSource) ClearRawData() {
|
||||
// 保留 URL(通常很短),只清除大的 base64 数据
|
||||
if f.IsBase64() && len(f.Base64Data) > 1024 {
|
||||
f.Base64Data = ""
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,48 @@ type TokenCountMeta struct {
|
||||
|
||||
type FileMeta struct {
|
||||
FileType
|
||||
MimeType string
|
||||
OriginData string // url or base64 data
|
||||
Detail string
|
||||
ParsedData *LocalFileData
|
||||
MimeType string
|
||||
Source *FileSource // 统一的文件来源(URL 或 base64)
|
||||
Detail string // 图片细节级别(low/high/auto)
|
||||
}
|
||||
|
||||
// NewFileMeta 创建新的 FileMeta
|
||||
func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
|
||||
return &FileMeta{
|
||||
FileType: fileType,
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
// NewImageFileMeta 创建图片类型的 FileMeta
|
||||
func NewImageFileMeta(source *FileSource, detail string) *FileMeta {
|
||||
return &FileMeta{
|
||||
FileType: FileTypeImage,
|
||||
Source: source,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentifier 获取文件标识符(用于日志)
|
||||
func (f *FileMeta) GetIdentifier() string {
|
||||
if f.Source != nil {
|
||||
return f.Source.GetIdentifier()
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// IsURL 判断是否是 URL 来源
|
||||
func (f *FileMeta) IsURL() bool {
|
||||
return f.Source != nil && f.Source.IsURL()
|
||||
}
|
||||
|
||||
// GetRawData 获取原始数据(兼容旧代码)
|
||||
// Deprecated: 请使用 Source.GetRawData()
|
||||
func (f *FileMeta) GetRawData() string {
|
||||
if f.Source != nil {
|
||||
return f.Source.GetRawData()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type RequestMeta struct {
|
||||
|
||||
@@ -121,8 +121,8 @@ const PageLayout = () => {
|
||||
|
||||
return (
|
||||
<Layout
|
||||
className='app-layout'
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: isMobile ? 'visible' : 'hidden',
|
||||
@@ -153,6 +153,7 @@ const PageLayout = () => {
|
||||
>
|
||||
{showSider && (
|
||||
<Sider
|
||||
className='app-sider'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
@@ -160,7 +161,6 @@ const PageLayout = () => {
|
||||
zIndex: 99,
|
||||
border: 'none',
|
||||
paddingRight: '0',
|
||||
height: 'calc(100vh - 64px)',
|
||||
width: 'var(--sidebar-current-width)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import UserInfoHeader from './personal/components/UserInfoHeader';
|
||||
import AccountManagement from './personal/cards/AccountManagement';
|
||||
import NotificationSettings from './personal/cards/NotificationSettings';
|
||||
import PreferencesSettings from './personal/cards/PreferencesSettings';
|
||||
import CheckinCalendar from './personal/cards/CheckinCalendar';
|
||||
import EmailBindModal from './personal/modals/EmailBindModal';
|
||||
import WeChatBindModal from './personal/modals/WeChatBindModal';
|
||||
@@ -463,24 +464,29 @@ const PersonalSetting = () => {
|
||||
{/* 账户管理和其他设置 */}
|
||||
<div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
|
||||
{/* 左侧:账户管理设置 */}
|
||||
<AccountManagement
|
||||
t={t}
|
||||
userState={userState}
|
||||
status={status}
|
||||
systemToken={systemToken}
|
||||
setShowEmailBindModal={setShowEmailBindModal}
|
||||
setShowWeChatBindModal={setShowWeChatBindModal}
|
||||
generateAccessToken={generateAccessToken}
|
||||
handleSystemTokenClick={handleSystemTokenClick}
|
||||
setShowChangePasswordModal={setShowChangePasswordModal}
|
||||
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
||||
passkeyStatus={passkeyStatus}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyRegisterLoading={passkeyRegisterLoading}
|
||||
passkeyDeleteLoading={passkeyDeleteLoading}
|
||||
onPasskeyRegister={handleRegisterPasskey}
|
||||
onPasskeyDelete={handleRemovePasskey}
|
||||
/>
|
||||
<div className='flex flex-col gap-4 md:gap-6'>
|
||||
<AccountManagement
|
||||
t={t}
|
||||
userState={userState}
|
||||
status={status}
|
||||
systemToken={systemToken}
|
||||
setShowEmailBindModal={setShowEmailBindModal}
|
||||
setShowWeChatBindModal={setShowWeChatBindModal}
|
||||
generateAccessToken={generateAccessToken}
|
||||
handleSystemTokenClick={handleSystemTokenClick}
|
||||
setShowChangePasswordModal={setShowChangePasswordModal}
|
||||
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
||||
passkeyStatus={passkeyStatus}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyRegisterLoading={passkeyRegisterLoading}
|
||||
passkeyDeleteLoading={passkeyDeleteLoading}
|
||||
onPasskeyRegister={handleRegisterPasskey}
|
||||
onPasskeyDelete={handleRemovePasskey}
|
||||
/>
|
||||
|
||||
{/* 偏好设置(语言等) */}
|
||||
<PreferencesSettings t={t} />
|
||||
</div>
|
||||
|
||||
{/* 右侧:其他设置 */}
|
||||
<NotificationSettings
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Card, Select, Typography, Avatar } from '@douyinfe/semi-ui';
|
||||
import { Languages } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showSuccess, showError } from '../../../../helpers';
|
||||
import { UserContext } from '../../../../context/User';
|
||||
|
||||
// Language options with native names and flags
|
||||
const languageOptions = [
|
||||
{ value: 'zh', label: '中文', flag: '🇨🇳' },
|
||||
{ value: 'en', label: 'English', flag: '🇺🇸' },
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
|
||||
{ value: 'ja', label: '日本語', flag: '🇯🇵' },
|
||||
{ value: 'vi', label: 'Tiếng Việt', flag: '🇻🇳' },
|
||||
];
|
||||
|
||||
const PreferencesSettings = ({ t }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language || 'zh');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Load saved language preference from user settings
|
||||
useEffect(() => {
|
||||
if (userState?.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
if (settings.language) {
|
||||
setCurrentLanguage(settings.language);
|
||||
// Sync i18n with saved preference
|
||||
if (i18n.language !== settings.language) {
|
||||
i18n.changeLanguage(settings.language);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [userState?.user?.setting, i18n]);
|
||||
|
||||
const handleLanguagePreferenceChange = async (lang) => {
|
||||
if (lang === currentLanguage) return;
|
||||
|
||||
setLoading(true);
|
||||
const previousLang = currentLanguage;
|
||||
|
||||
try {
|
||||
// Update language immediately for responsive UX
|
||||
setCurrentLanguage(lang);
|
||||
i18n.changeLanguage(lang);
|
||||
|
||||
// Save to backend
|
||||
const res = await API.put('/api/user/self', {
|
||||
language: lang,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('语言偏好已保存'));
|
||||
// Update user context with new setting
|
||||
if (userState?.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
settings.language = lang;
|
||||
userDispatch({
|
||||
type: 'login',
|
||||
payload: {
|
||||
...userState.user,
|
||||
setting: JSON.stringify(settings),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showError(res.data.message || t('保存失败'));
|
||||
// Revert on error
|
||||
setCurrentLanguage(previousLang);
|
||||
i18n.changeLanguage(previousLang);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
// Revert on error
|
||||
setCurrentLanguage(previousLang);
|
||||
i18n.changeLanguage(previousLang);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* Card Header */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='violet' className='mr-3 shadow-md'>
|
||||
<Languages size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('偏好设置')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400'>
|
||||
{t('界面语言和其他个人偏好')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Setting Card */}
|
||||
<Card className='!rounded-xl border dark:border-gray-700'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<div className='w-12 h-12 rounded-full bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<Languages
|
||||
size={20}
|
||||
className='text-violet-600 dark:text-violet-400'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('语言偏好')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t('选择您的首选界面语言,设置将自动保存并同步到所有设备')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onChange={handleLanguagePreferenceChange}
|
||||
style={{ width: 180 }}
|
||||
loading={loading}
|
||||
optionList={languageOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{opt.flag}</span>
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
renderSelectedItem={(optionNode) => {
|
||||
const selected = languageOptions.find(
|
||||
(opt) => opt.value === optionNode.value,
|
||||
);
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{selected?.flag}</span>
|
||||
<span>{selected?.label}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Additional info */}
|
||||
<div className='mt-4 text-xs text-gray-500 dark:text-gray-400'>
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesSettings;
|
||||
@@ -3113,6 +3113,28 @@ const EditChannelModal = (props) => {
|
||||
extraText={
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex gap-2 flex-wrap items-center'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'header_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
'*': true,
|
||||
're:^X-Trace-.*$': true,
|
||||
'X-Foo': '{client_header:X-Foo}',
|
||||
Authorization: 'Bearer {api_key}',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
@@ -3120,9 +3142,7 @@ const EditChannelModal = (props) => {
|
||||
'header_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
Authorization: 'Bearer{api_key}',
|
||||
'*': true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -3130,7 +3150,7 @@ const EditChannelModal = (props) => {
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
{t('填入透传模版')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
TASK_ACTION_REMIX_GENERATE,
|
||||
} from '../../../constants/common.constant';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
||||
import { stringToColor } from '../../../helpers/render';
|
||||
import { Avatar, Space } from '@douyinfe/semi-ui';
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
@@ -288,6 +290,39 @@ export const getTaskLogsColumns = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.USERNAME,
|
||||
title: t('用户'),
|
||||
dataIndex: 'username',
|
||||
render: (text, record, index) => {
|
||||
if (!isAdminUser) {
|
||||
return <></>;
|
||||
}
|
||||
const displayName = record.display_name;
|
||||
const label = displayName || text || t('未知');
|
||||
const avatarText =
|
||||
typeof displayName === 'string' && displayName.length > 0
|
||||
? displayName[0]
|
||||
: typeof text === 'string' && text.length > 0
|
||||
? text[0]
|
||||
: '?';
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Avatar
|
||||
size='extra-small'
|
||||
color={stringToColor(label)}
|
||||
style={{ cursor: 'default' }}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
<Typography.Text ellipsis={{ showTooltip: true }}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PLATFORM,
|
||||
title: t('平台'),
|
||||
|
||||
@@ -93,6 +93,15 @@ const LogsFilters = ({
|
||||
size='small'
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='request_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('Request ID')}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
/>
|
||||
|
||||
{isAdminUser && (
|
||||
<>
|
||||
<Form.Input
|
||||
|
||||
@@ -128,7 +128,11 @@ const SubscriptionPlansCard = ({
|
||||
showSuccess(t('已打开支付页面'));
|
||||
closeBuy();
|
||||
} else {
|
||||
showError(res.data?.data || res.data?.message || t('支付失败'));
|
||||
const errorMsg =
|
||||
typeof res.data?.data === 'string'
|
||||
? res.data.data
|
||||
: res.data?.message || t('支付失败');
|
||||
showError(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
@@ -152,7 +156,11 @@ const SubscriptionPlansCard = ({
|
||||
showSuccess(t('已打开支付页面'));
|
||||
closeBuy();
|
||||
} else {
|
||||
showError(res.data?.data || res.data?.message || t('支付失败'));
|
||||
const errorMsg =
|
||||
typeof res.data?.data === 'string'
|
||||
? res.data.data
|
||||
: res.data?.message || t('支付失败');
|
||||
showError(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
@@ -177,7 +185,11 @@ const SubscriptionPlansCard = ({
|
||||
showSuccess(t('已发起支付'));
|
||||
closeBuy();
|
||||
} else {
|
||||
showError(res.data?.data || res.data?.message || t('支付失败'));
|
||||
const errorMsg =
|
||||
typeof res.data?.data === 'string'
|
||||
? res.data.data
|
||||
: res.data?.message || t('支付失败');
|
||||
showError(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
@@ -269,9 +281,13 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
</Card>
|
||||
{/* 套餐列表骨架屏 */}
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 w-full'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className='!rounded-xl' bodyStyle={{ padding: 16 }}>
|
||||
<Card
|
||||
key={i}
|
||||
className='!rounded-xl w-full h-full'
|
||||
bodyStyle={{ padding: 16 }}
|
||||
>
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: '60%', height: 24, marginBottom: 8 }}
|
||||
@@ -435,7 +451,7 @@ const SubscriptionPlansCard = ({
|
||||
|
||||
{/* 可购买套餐 - 标准定价卡片 */}
|
||||
{plans.length > 0 ? (
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 w-full'>
|
||||
{plans.map((p, index) => {
|
||||
const plan = p?.plan;
|
||||
const totalAmount = Number(plan?.total_amount || 0);
|
||||
@@ -477,15 +493,15 @@ const SubscriptionPlansCard = ({
|
||||
return (
|
||||
<Card
|
||||
key={plan?.id}
|
||||
className={`!rounded-xl transition-all hover:shadow-lg ${
|
||||
className={`!rounded-xl transition-all hover:shadow-lg w-full h-full ${
|
||||
isPopular ? 'ring-2 ring-purple-500' : ''
|
||||
}`}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className='p-4'>
|
||||
<div className='p-4 h-full flex flex-col'>
|
||||
{/* 推荐标签 */}
|
||||
{isPopular && (
|
||||
<div className='text-center mb-2'>
|
||||
<div className='mb-2'>
|
||||
<Tag color='purple' shape='circle' size='small'>
|
||||
<Sparkles size={10} className='mr-1' />
|
||||
{t('推荐')}
|
||||
@@ -493,7 +509,7 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
)}
|
||||
{/* 套餐名称 */}
|
||||
<div className='text-center mb-3'>
|
||||
<div className='mb-3'>
|
||||
<Typography.Title
|
||||
heading={5}
|
||||
ellipsis={{ rows: 1, showTooltip: true }}
|
||||
@@ -514,8 +530,8 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
|
||||
{/* 价格区域 */}
|
||||
<div className='text-center py-2'>
|
||||
<div className='flex items-baseline justify-center'>
|
||||
<div className='py-2'>
|
||||
<div className='flex items-baseline justify-start'>
|
||||
<span className='text-xl font-bold text-purple-600'>
|
||||
{symbol}
|
||||
</span>
|
||||
@@ -526,7 +542,7 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
|
||||
{/* 套餐权益描述 */}
|
||||
<div className='flex flex-col items-center gap-1 pb-2'>
|
||||
<div className='flex flex-col items-start gap-1 pb-2'>
|
||||
{planBenefits.map((item) => {
|
||||
const content = (
|
||||
<div className='flex items-center gap-2 text-xs text-gray-500'>
|
||||
@@ -538,7 +554,7 @@ const SubscriptionPlansCard = ({
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className='w-full flex justify-center'
|
||||
className='w-full flex justify-start'
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
@@ -546,7 +562,7 @@ const SubscriptionPlansCard = ({
|
||||
}
|
||||
return (
|
||||
<Tooltip key={item.label} content={item.tooltip}>
|
||||
<div className='w-full flex justify-center'>
|
||||
<div className='w-full flex justify-start'>
|
||||
{content}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -554,36 +570,38 @@ const SubscriptionPlansCard = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Divider margin={12} />
|
||||
<div className='mt-auto'>
|
||||
<Divider margin={12} />
|
||||
|
||||
{/* 购买按钮 */}
|
||||
{(() => {
|
||||
const count = getPlanPurchaseCount(p?.plan?.id);
|
||||
const reached = limit > 0 && count >= limit;
|
||||
const tip = reached
|
||||
? t('已达到购买上限') + ` (${count}/${limit})`
|
||||
: '';
|
||||
const buttonEl = (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
block
|
||||
disabled={reached}
|
||||
onClick={() => {
|
||||
if (!reached) openBuy(p);
|
||||
}}
|
||||
>
|
||||
{reached ? t('已达上限') : t('立即订阅')}
|
||||
</Button>
|
||||
);
|
||||
return reached ? (
|
||||
<Tooltip content={tip} position='top'>
|
||||
{buttonEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttonEl
|
||||
);
|
||||
})()}
|
||||
{/* 购买按钮 */}
|
||||
{(() => {
|
||||
const count = getPlanPurchaseCount(p?.plan?.id);
|
||||
const reached = limit > 0 && count >= limit;
|
||||
const tip = reached
|
||||
? t('已达到购买上限') + ` (${count}/${limit})`
|
||||
: '';
|
||||
const buttonEl = (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
block
|
||||
disabled={reached}
|
||||
onClick={() => {
|
||||
if (!reached) openBuy(p);
|
||||
}}
|
||||
>
|
||||
{reached ? t('已达上限') : t('立即订阅')}
|
||||
</Button>
|
||||
);
|
||||
return reached ? (
|
||||
<Tooltip content={tip} position='top'>
|
||||
{buttonEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttonEl
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -249,7 +249,9 @@ const TopUp = () => {
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
} else {
|
||||
showError(data);
|
||||
const errorMsg =
|
||||
typeof data === 'string' ? data : message || t('支付失败');
|
||||
showError(errorMsg);
|
||||
}
|
||||
} else {
|
||||
showError(res);
|
||||
@@ -293,7 +295,9 @@ const TopUp = () => {
|
||||
if (message === 'success') {
|
||||
processCreemCallback(data);
|
||||
} else {
|
||||
showError(data);
|
||||
const errorMsg =
|
||||
typeof data === 'string' ? data : message || t('支付失败');
|
||||
showError(errorMsg);
|
||||
}
|
||||
} else {
|
||||
showError(res);
|
||||
|
||||
@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { reducer, initialState } from './reducer';
|
||||
|
||||
export const UserContext = React.createContext({
|
||||
@@ -27,6 +28,21 @@ export const UserContext = React.createContext({
|
||||
|
||||
export const UserProvider = ({ children }) => {
|
||||
const [state, dispatch] = React.useReducer(reducer, initialState);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Sync language preference when user data is loaded
|
||||
useEffect(() => {
|
||||
if (state.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(state.user.setting);
|
||||
if (settings.language && settings.language !== i18n.language) {
|
||||
i18n.changeLanguage(settings.language);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [state.user?.setting, i18n]);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={[state, dispatch]}>
|
||||
|
||||
@@ -605,34 +605,6 @@ export function stringToColor(str) {
|
||||
return colors[i];
|
||||
}
|
||||
|
||||
// High-contrast color palette for group tags (avoids similar blue/teal shades)
|
||||
const groupColors = [
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'lime',
|
||||
'green',
|
||||
'cyan',
|
||||
'blue',
|
||||
'indigo',
|
||||
'violet',
|
||||
'purple',
|
||||
'pink',
|
||||
'amber',
|
||||
'grey',
|
||||
];
|
||||
|
||||
export function groupToColor(str) {
|
||||
// Use a better hash algorithm for more even distribution
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
hash = Math.abs(hash);
|
||||
return groupColors[hash % groupColors.length];
|
||||
}
|
||||
|
||||
// 渲染带有模型图标的标签
|
||||
export function renderModelTag(modelName, options = {}) {
|
||||
const {
|
||||
@@ -701,7 +673,7 @@ export function renderGroup(group) {
|
||||
<span key={group}>
|
||||
{groups.map((group) => (
|
||||
<Tag
|
||||
color={tagColors[group] || groupToColor(group)}
|
||||
color={tagColors[group] || stringToColor(group)}
|
||||
key={group}
|
||||
shape='circle'
|
||||
onClick={async (event) => {
|
||||
|
||||
@@ -491,7 +491,7 @@ export const useChannelsData = () => {
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
showSuccess(t('操作成功完成!'));
|
||||
let newChannels = [...channels];
|
||||
for (let i = 0; i < newChannels.length; i++) {
|
||||
if (newChannels[i].tag === tag) {
|
||||
|
||||
@@ -146,10 +146,41 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
}, [navigate, t, userDispatch]);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(lang) => {
|
||||
async (lang) => {
|
||||
// Change language immediately for responsive UX
|
||||
i18n.changeLanguage(lang);
|
||||
|
||||
// If user is logged in, save preference to backend
|
||||
if (userState?.user?.id) {
|
||||
try {
|
||||
const res = await API.put('/api/user/self', {
|
||||
language: lang,
|
||||
});
|
||||
if (res.data.success) {
|
||||
// Update user context with new setting
|
||||
if (userState?.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
settings.language = lang;
|
||||
userDispatch({
|
||||
type: 'login',
|
||||
payload: {
|
||||
...userState.user,
|
||||
setting: JSON.stringify(settings),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors - language was already changed locally
|
||||
console.error('Failed to save language preference:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[i18n],
|
||||
[i18n, userState, userDispatch],
|
||||
);
|
||||
|
||||
const handleThemeToggle = useCallback(
|
||||
|
||||
@@ -145,7 +145,7 @@ export const useRedemptionsData = () => {
|
||||
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
showSuccess(t('操作成功完成!'));
|
||||
let redemption = res.data.data;
|
||||
let newRedemptions = [...redemptions];
|
||||
if (action !== REDEMPTION_ACTIONS.DELETE) {
|
||||
|
||||
@@ -40,6 +40,7 @@ export const useTaskLogsData = () => {
|
||||
FINISH_TIME: 'finish_time',
|
||||
DURATION: 'duration',
|
||||
CHANNEL: 'channel',
|
||||
USERNAME: 'username',
|
||||
PLATFORM: 'platform',
|
||||
TYPE: 'type',
|
||||
TASK_ID: 'task_id',
|
||||
@@ -104,6 +105,7 @@ export const useTaskLogsData = () => {
|
||||
// For non-admin users, force-hide admin-only columns (does not touch admin settings)
|
||||
if (!isAdminUser) {
|
||||
merged[COLUMN_KEYS.CHANNEL] = false;
|
||||
merged[COLUMN_KEYS.USERNAME] = false;
|
||||
}
|
||||
setVisibleColumns(merged);
|
||||
} catch (e) {
|
||||
@@ -122,6 +124,7 @@ export const useTaskLogsData = () => {
|
||||
[COLUMN_KEYS.FINISH_TIME]: true,
|
||||
[COLUMN_KEYS.DURATION]: true,
|
||||
[COLUMN_KEYS.CHANNEL]: isAdminUser,
|
||||
[COLUMN_KEYS.USERNAME]: isAdminUser,
|
||||
[COLUMN_KEYS.PLATFORM]: true,
|
||||
[COLUMN_KEYS.TYPE]: true,
|
||||
[COLUMN_KEYS.TASK_ID]: true,
|
||||
@@ -151,7 +154,10 @@ export const useTaskLogsData = () => {
|
||||
const updatedColumns = {};
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) {
|
||||
if (
|
||||
(key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.USERNAME) &&
|
||||
!isAdminUser
|
||||
) {
|
||||
updatedColumns[key] = false;
|
||||
} else {
|
||||
updatedColumns[key] = checked;
|
||||
|
||||
@@ -174,7 +174,7 @@ export const useTokensData = (openFluentNotification) => {
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
showSuccess(t('操作成功完成!'));
|
||||
let token = res.data.data;
|
||||
let newTokens = [...tokens];
|
||||
if (action !== 'delete') {
|
||||
|
||||
@@ -94,6 +94,7 @@ export const useLogsData = () => {
|
||||
model_name: '',
|
||||
channel: '',
|
||||
group: '',
|
||||
request_id: '',
|
||||
dateRange: [
|
||||
timestamp2string(getTodayStartTimestamp()),
|
||||
timestamp2string(now.getTime() / 1000 + 3600),
|
||||
@@ -230,6 +231,7 @@ export const useLogsData = () => {
|
||||
end_timestamp,
|
||||
channel: formValues.channel || '',
|
||||
group: formValues.group || '',
|
||||
request_id: formValues.request_id || '',
|
||||
logType: formValues.logType ? parseInt(formValues.logType) : 0,
|
||||
};
|
||||
};
|
||||
@@ -348,6 +350,12 @@ export const useLogsData = () => {
|
||||
value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
|
||||
});
|
||||
}
|
||||
if (logs[i].request_id) {
|
||||
expandDataLocal.push({
|
||||
key: t('Request ID'),
|
||||
value: logs[i].request_id,
|
||||
});
|
||||
}
|
||||
if (other?.ws || other?.audio) {
|
||||
expandDataLocal.push({
|
||||
key: t('语音输入'),
|
||||
@@ -620,6 +628,7 @@ export const useLogsData = () => {
|
||||
end_timestamp,
|
||||
channel,
|
||||
group,
|
||||
request_id,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
|
||||
@@ -633,9 +642,9 @@ export const useLogsData = () => {
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
if (isAdminUser) {
|
||||
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
|
||||
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}&request_id=${request_id}`;
|
||||
} else {
|
||||
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
||||
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}&request_id=${request_id}`;
|
||||
}
|
||||
url = encodeURI(url);
|
||||
const res = await API.get(url);
|
||||
|
||||
@@ -132,7 +132,7 @@ export const useUsersData = () => {
|
||||
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
showSuccess(t('操作成功完成!'));
|
||||
const user = res.data.data;
|
||||
|
||||
// Create a new array and new object to ensure React detects changes
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"Gotify服务器地址": "Gotify server address",
|
||||
"Gotify服务器地址必须以http://或https://开头": "Gotify server address must start with http:// or https://",
|
||||
"Gotify通知": "Gotify notification",
|
||||
"Grok设置": "Grok Settings",
|
||||
"GPU/容器": "GPU/Container",
|
||||
"GPU数量": "Number of GPUs",
|
||||
"Homepage URL 填": "Fill in the Homepage URL",
|
||||
@@ -446,6 +447,7 @@
|
||||
"兑换人ID": "Redeemer ID",
|
||||
"兑换成功!": "Redemption successful!",
|
||||
"兑换码充值": "Redemption code recharge",
|
||||
"清理不活跃缓存": "Clean up inactive cache",
|
||||
"兑换码创建成功": "Redemption Code Created",
|
||||
"兑换码创建成功,是否下载兑换码?": "Redemption code created successfully. Do you want to download it?",
|
||||
"兑换码创建成功!": "Redemption code created successfully!",
|
||||
@@ -695,8 +697,10 @@
|
||||
"启用请求透传": "Enable request pass-through",
|
||||
"启用额度消费日志记录": "Enable quota consumption logging",
|
||||
"启用验证": "Enable Authentication",
|
||||
"启用违规扣费": "Enable violation deduction",
|
||||
"周": "week",
|
||||
"和": "and",
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Unlike Claude, Gemini thinking models automatically decide whether to think by default. They work normally even without the adapter enabled. If you need billing, set the price of models without suffix to the thinking price. Use format like gemini-2.5-pro-preview-06-05-thinking-128 to specify exact thinking budget.",
|
||||
"响应": "Response",
|
||||
"响应时间": "Response time",
|
||||
"商品价格 ID": "Product Price ID",
|
||||
@@ -739,6 +743,8 @@
|
||||
"填入": "Fill",
|
||||
"填入所有模型": "Fill in all models",
|
||||
"填入模板": "Fill Template",
|
||||
"填入透传模版": "Fill Passthrough Template",
|
||||
"填入透传完整模版": "Fill Full Passthrough Template",
|
||||
"填入相关模型": "Fill Related Models",
|
||||
"填写Gotify服务器的完整URL地址": "Fill in the complete URL address of the Gotify server",
|
||||
"填写带https的域名,逗号分隔": "Fill in domains with https, separated by commas",
|
||||
@@ -808,6 +814,7 @@
|
||||
"完整的 Base URL,支持变量{model}": "Complete Base URL, supports variable {model}",
|
||||
"官方": "Official",
|
||||
"官方文档": "Official documentation",
|
||||
"官方说明": "Official documentation",
|
||||
"官方模型同步": "Official models sync",
|
||||
"定价模式": "Pricing Mode",
|
||||
"定时测试所有通道": "Periodically test all channels",
|
||||
@@ -994,6 +1001,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "After enabling, ping data will be sent periodically to keep the connection active",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "After enabling, when the current group channel fails, it will try the next group's channel in order",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "When enabled, all requests will be directly forwarded to the upstream without any processing (redirects and channel adaptation will also be disabled). Please enable with caution.",
|
||||
"开启后,违规请求将额外扣费。": "When enabled, violation requests will incur additional charges.",
|
||||
"开启后不限制:必须设置模型倍率": "After enabling, no limit: must set model ratio",
|
||||
"开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace",
|
||||
"开启批量操作": "Enable batch selection",
|
||||
@@ -1750,6 +1758,7 @@
|
||||
"确认操作": "Confirm Operation",
|
||||
"确认新密码": "Confirm new password",
|
||||
"确认清除历史日志": "Confirm clear historical logs",
|
||||
"确认清理不活跃的磁盘缓存?": "Confirm cleanup of inactive disk cache?",
|
||||
"确认禁用": "Confirm disable",
|
||||
"确认补单": "Confirm Order Completion",
|
||||
"确认解绑": "Confirm Unbind",
|
||||
@@ -1834,6 +1843,17 @@
|
||||
"系统文档和帮助信息": "System documentation and help information",
|
||||
"系统消息": "System message",
|
||||
"系统管理功能": "System management functions",
|
||||
"系统性能监控": "System Performance Monitoring",
|
||||
"启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。": "When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests (/v1, /v1beta, etc.) will be rejected to protect system stability.",
|
||||
"启用性能监控": "Enable Performance Monitoring",
|
||||
"超过阈值时拒绝新请求": "Reject new requests when threshold is exceeded",
|
||||
"CPU 阈值 (%)": "CPU Threshold (%)",
|
||||
"CPU 使用率超过此值时拒绝请求": "Reject requests when CPU usage exceeds this value",
|
||||
"内存 阈值 (%)": "Memory Threshold (%)",
|
||||
"内存使用率超过此值时拒绝请求": "Reject requests when memory usage exceeds this value",
|
||||
"磁盘 阈值 (%)": "Disk Threshold (%)",
|
||||
"磁盘使用率超过此值时拒绝请求": "Reject requests when disk usage exceeds this value",
|
||||
"保存性能设置": "Save Performance Settings",
|
||||
"系统设置": "System Settings",
|
||||
"系统访问令牌": "System Access Token",
|
||||
"约": "Approximately",
|
||||
@@ -2316,6 +2336,45 @@
|
||||
"输入验证码完成设置": "Enter verification code to complete setup",
|
||||
"输出": "Output",
|
||||
"输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}": "Output {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}}",
|
||||
"磁盘缓存设置(磁盘换内存)": "Disk Cache Settings (Disk Swap Memory)",
|
||||
"启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。": "When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. Suitable for requests with large images/files. SSD recommended.",
|
||||
"启用磁盘缓存": "Enable Disk Cache",
|
||||
"将大请求体临时存储到磁盘": "Store large request bodies temporarily on disk",
|
||||
"磁盘缓存阈值 (MB)": "Disk Cache Threshold (MB)",
|
||||
"请求体超过此大小时使用磁盘缓存": "Use disk cache when request body exceeds this size",
|
||||
"磁盘缓存最大总量 (MB)": "Max Disk Cache Size (MB)",
|
||||
"可用空间: {{free}} / 总空间: {{total}}": "Free: {{free}} / Total: {{total}}",
|
||||
"磁盘缓存占用的最大空间": "Maximum space occupied by disk cache",
|
||||
"留空使用系统临时目录": "Leave empty to use system temp directory",
|
||||
"例如 /var/cache/new-api": "e.g. /var/cache/new-api",
|
||||
"性能监控": "Performance Monitor",
|
||||
"刷新统计": "Refresh Stats",
|
||||
"重置统计": "Reset Stats",
|
||||
"执行 GC": "Run GC",
|
||||
"请求体磁盘缓存": "Request Body Disk Cache",
|
||||
"活跃文件": "Active Files",
|
||||
"磁盘命中": "Disk Hits",
|
||||
"请求体内存缓存": "Request Body Memory Cache",
|
||||
"当前缓存大小": "Current Cache Size",
|
||||
"活跃缓存数": "Active Cache Count",
|
||||
"内存命中": "Memory Hits",
|
||||
"缓存目录磁盘空间": "Cache Directory Disk Space",
|
||||
"磁盘可用空间小于缓存最大总量设置": "Disk free space is less than max cache size setting",
|
||||
"已分配内存": "Allocated Memory",
|
||||
"总分配内存": "Total Allocated Memory",
|
||||
"系统内存": "System Memory",
|
||||
"GC 次数": "GC Count",
|
||||
"Goroutine 数": "Goroutine Count",
|
||||
"目录文件数": "Directory File Count",
|
||||
"目录总大小": "Directory Total Size",
|
||||
"磁盘缓存已清理": "Disk cache cleared",
|
||||
"清理失败": "Cleanup failed",
|
||||
"统计已重置": "Statistics reset",
|
||||
"重置失败": "Reset failed",
|
||||
"GC 已执行": "GC executed",
|
||||
"GC 执行失败": "GC execution failed",
|
||||
"缓存目录": "Cache Directory",
|
||||
"可用": "Available",
|
||||
"输出价格": "Output Price",
|
||||
"输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})": "Output price: {{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (Completion ratio: {{completionRatio}})",
|
||||
"输出倍率 {{completionRatio}}": "Output ratio {{completionRatio}}",
|
||||
@@ -2331,7 +2390,10 @@
|
||||
"运行时长(小时)": "Runtime Duration (hours)",
|
||||
"返回修改": "Go back and edit",
|
||||
"返回登录": "Return to Login",
|
||||
"违规扣费金额": "Violation deduction amount",
|
||||
"这是重复键中的最后一个,其值将被使用": "This is the last one among duplicate keys, and its value will be used",
|
||||
"这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。": "This is the base amount. Actual deduction = base amount × system group ratio.",
|
||||
"这将删除超过 10 分钟未使用的临时缓存文件": "This will delete temporary cache files that have not been used for more than 10 minutes",
|
||||
"进度": "Progress",
|
||||
"进行中": "Ongoing",
|
||||
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "When performing this operation, it may cause channel access errors. Please only use it when there is a problem with the database.",
|
||||
@@ -2687,6 +2749,52 @@
|
||||
"套餐名称": "Plan Name",
|
||||
"应付金额": "Amount Due",
|
||||
"支付": "Pay",
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "Online payment is not enabled by the admin. Please contact the administrator."
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "Online payment is not enabled by the admin. Please contact the administrator.",
|
||||
"磁盘缓存设置(磁盘换内存)": "Disk Cache Settings (Disk Swap Memory)",
|
||||
"启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。": "When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. Suitable for requests with large images/files. SSD recommended.",
|
||||
"启用磁盘缓存": "Enable Disk Cache",
|
||||
"将大请求体临时存储到磁盘": "Store large request bodies temporarily on disk",
|
||||
"磁盘缓存阈值 (MB)": "Disk Cache Threshold (MB)",
|
||||
"请求体超过此大小时使用磁盘缓存": "Use disk cache when request body exceeds this size",
|
||||
"磁盘缓存最大总量 (MB)": "Max Disk Cache Size (MB)",
|
||||
"可用空间: {{free}} / 总空间: {{total}}": "Free: {{free}} / Total: {{total}}",
|
||||
"磁盘缓存占用的最大空间": "Maximum space occupied by disk cache",
|
||||
"留空使用系统临时目录": "Leave empty to use system temp directory",
|
||||
"例如 /var/cache/new-api": "e.g. /var/cache/new-api",
|
||||
"性能监控": "Performance Monitor",
|
||||
"刷新统计": "Refresh Stats",
|
||||
"重置统计": "Reset Stats",
|
||||
"执行 GC": "Run GC",
|
||||
"请求体磁盘缓存": "Request Body Disk Cache",
|
||||
"活跃文件": "Active Files",
|
||||
"磁盘命中": "Disk Hits",
|
||||
"请求体内存缓存": "Request Body Memory Cache",
|
||||
"当前缓存大小": "Current Cache Size",
|
||||
"活跃缓存数": "Active Cache Count",
|
||||
"内存命中": "Memory Hits",
|
||||
"缓存目录磁盘空间": "Cache Directory Disk Space",
|
||||
"磁盘可用空间小于缓存最大总量设置": "Disk free space is less than max cache size setting",
|
||||
"已分配内存": "Allocated Memory",
|
||||
"总分配内存": "Total Allocated Memory",
|
||||
"系统内存": "System Memory",
|
||||
"GC 次数": "GC Count",
|
||||
"Goroutine 数": "Goroutine Count",
|
||||
"目录文件数": "Directory File Count",
|
||||
"目录总大小": "Directory Total Size",
|
||||
"磁盘缓存已清理": "Disk cache cleared",
|
||||
"清理失败": "Cleanup failed",
|
||||
"统计已重置": "Statistics reset",
|
||||
"重置失败": "Reset failed",
|
||||
"GC 已执行": "GC executed",
|
||||
"GC execution failed": "GC execution failed",
|
||||
"Cache Directory": "Cache Directory",
|
||||
"Available": "Available",
|
||||
"输出价格": "Output Price",
|
||||
"偏好设置": "Preferences",
|
||||
"界面语言和其他个人偏好": "Interface language and other personal preferences",
|
||||
"语言偏好": "Language Preference",
|
||||
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "Select your preferred interface language. Settings will be saved automatically and synced across all devices",
|
||||
"语言偏好已保存": "Language preference saved",
|
||||
"提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "Note: Language preference syncs across all your logged-in devices and affects the language of API error messages."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"Gotify服务器地址": "Adresse du serveur Gotify",
|
||||
"Gotify服务器地址必须以http://或https://开头": "L'adresse du serveur Gotify doit commencer par http:// ou https://",
|
||||
"Gotify通知": "Notification Gotify",
|
||||
"Grok设置": "Paramètres Grok",
|
||||
"GPU/容器": "GPU/Container",
|
||||
"GPU数量": "Number of GPUs",
|
||||
"Homepage URL 填": "Remplir l'URL de la page d'accueil",
|
||||
@@ -448,6 +449,7 @@
|
||||
"兑换人ID": "ID du demandeur",
|
||||
"兑换成功!": "Échange réussi !",
|
||||
"兑换码充值": "Recharge par code d'échange",
|
||||
"清理不活跃缓存": "Nettoyer le cache inactif",
|
||||
"兑换码创建成功": "Code d'échange créé",
|
||||
"兑换码创建成功,是否下载兑换码?": "Code d'échange créé avec succès. Voulez-vous le télécharger ?",
|
||||
"兑换码创建成功!": "Code d'échange créé avec succès !",
|
||||
@@ -700,8 +702,10 @@
|
||||
"启用请求透传": "Activer la transmission de la requête",
|
||||
"启用额度消费日志记录": "Activer la journalisation de la consommation de quota",
|
||||
"启用验证": "Activer l'authentification",
|
||||
"启用违规扣费": "Activer la déduction de violation",
|
||||
"周": "semaine",
|
||||
"和": "et",
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Contrairement à Claude, les modèles de réflexion Gemini décident automatiquement s'ils doivent réfléchir. Ils fonctionnent normalement même sans l'adaptateur activé. Si vous avez besoin de facturation, définissez le prix des modèles sans suffixe au prix de réflexion. Utilisez un format comme gemini-2.5-pro-preview-06-05-thinking-128 pour spécifier le budget de réflexion exact.",
|
||||
"响应": "Réponse",
|
||||
"响应时间": "Temps de réponse",
|
||||
"商品价格 ID": "ID du prix du produit",
|
||||
@@ -744,6 +748,8 @@
|
||||
"填入": "Remplir",
|
||||
"填入所有模型": "Remplir tous les modèles",
|
||||
"填入模板": "Remplir le modèle",
|
||||
"填入透传模版": "Remplir le modèle passthrough",
|
||||
"填入透传完整模版": "Remplir le modèle passthrough complet",
|
||||
"填入相关模型": "Remplir les modèles associés",
|
||||
"填写Gotify服务器的完整URL地址": "Remplir l'adresse URL complète du serveur Gotify",
|
||||
"填写带https的域名,逗号分隔": "Saisir les domaines avec https, séparés par des virgules",
|
||||
@@ -813,6 +819,7 @@
|
||||
"完整的 Base URL,支持变量{model}": "URL de base complète, prend en charge la variable {model}",
|
||||
"官方": "Officiel",
|
||||
"官方文档": "Documentation officielle",
|
||||
"官方说明": "Documentation officielle",
|
||||
"官方模型同步": "Synchronisation des modèles officiels",
|
||||
"定价模式": "Mode de tarification",
|
||||
"定时测试所有通道": "Tester périodiquement tous les canaux",
|
||||
@@ -1004,6 +1011,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "Après activation, des données ping seront envoyées périodiquement pour maintenir la connexion active",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Après activation, lorsque le canal du groupe actuel échoue, il essaiera le canal du groupe suivant dans l'ordre",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Après activation, toutes les requêtes seront directement transmises en amont sans aucun traitement (la redirection et l'adaptation de canal seront également désactivées), veuillez activer avec prudence",
|
||||
"开启后,违规请求将额外扣费。": "Lorsqu'il est activé, les requêtes en violation entraîneront des frais supplémentaires.",
|
||||
"开启后不限制:必须设置模型倍率": "Après l'activation, aucune limite : le ratio de modèle doit être défini",
|
||||
"开启后未登录用户无法访问模型广场": "Lorsqu'il est activé, les utilisateurs non authentifiés ne peuvent pas accéder à la place du marché des modèles",
|
||||
"开启批量操作": "Activer la sélection par lots",
|
||||
@@ -1762,6 +1770,7 @@
|
||||
"确认操作": "Confirm Operation",
|
||||
"确认新密码": "Confirmer le nouveau mot de passe",
|
||||
"确认清除历史日志": "Confirmer l'effacement des journaux historiques",
|
||||
"确认清理不活跃的磁盘缓存?": "Confirmer le nettoyage du cache disque inactif ?",
|
||||
"确认禁用": "Confirmer la désactivation",
|
||||
"确认补单": "Confirmer la complétion",
|
||||
"确认解绑": "Confirmer la dissociation",
|
||||
@@ -1846,6 +1855,17 @@
|
||||
"系统文档和帮助信息": "Documentation système et informations d'aide",
|
||||
"系统消息": "Messages système",
|
||||
"系统管理功能": "Fonctions de gestion du système",
|
||||
"系统性能监控": "Surveillance des performances du système",
|
||||
"启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。": "Lorsque la surveillance des performances est activée et que l'utilisation des ressources système dépasse le seuil défini, les nouvelles requêtes Relay (/v1, /v1beta, etc.) seront rejetées pour protéger la stabilité du système.",
|
||||
"启用性能监控": "Activer la surveillance des performances",
|
||||
"超过阈值时拒绝新请求": "Rejeter les nouvelles requêtes lorsque le seuil est dépassé",
|
||||
"CPU 阈值 (%)": "Seuil CPU (%)",
|
||||
"CPU 使用率超过此值时拒绝请求": "Rejeter les requêtes lorsque l'utilisation du CPU dépasse cette valeur",
|
||||
"内存 阈值 (%)": "Seuil mémoire (%)",
|
||||
"内存使用率超过此值时拒绝请求": "Rejeter les requêtes lorsque l'utilisation de la mémoire dépasse cette valeur",
|
||||
"磁盘 阈值 (%)": "Seuil disque (%)",
|
||||
"磁盘使用率超过此值时拒绝请求": "Rejeter les requêtes lorsque l'utilisation du disque dépasse cette valeur",
|
||||
"保存性能设置": "Enregistrer les paramètres de performance",
|
||||
"系统设置": "Système",
|
||||
"系统访问令牌": "Jeton d'accès au système",
|
||||
"约": "Environ",
|
||||
@@ -2334,7 +2354,10 @@
|
||||
"运行时长(小时)": "Runtime Duration (hours)",
|
||||
"返回修改": "Revenir pour modifier",
|
||||
"返回登录": "Retour à la connexion",
|
||||
"违规扣费金额": "Montant de la déduction de violation",
|
||||
"这是重复键中的最后一个,其值将被使用": "Ceci est la dernière clé dupliquée, sa valeur sera utilisée",
|
||||
"这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。": "Ceci est le montant de base. Déduction réelle = montant de base × ratio de groupe système.",
|
||||
"这将删除超过 10 分钟未使用的临时缓存文件": "Cela supprimera les fichiers de cache temporaires non utilisés depuis plus de 10 minutes",
|
||||
"进度": "calendrier",
|
||||
"进行中": "En cours",
|
||||
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "Lors de cette opération, cela peut entraîner des erreurs d'accès au canal. Veuillez ne l'utiliser que lorsqu'il y a un problème avec la base de données.",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"Gotify服务器地址": "GotifyサーバーURL",
|
||||
"Gotify服务器地址必须以http://或https://开头": "GotifyサーバーURLは、http://またはhttps://で始まることが必須です",
|
||||
"Gotify通知": "Gotify通知",
|
||||
"Grok设置": "Grok設定",
|
||||
"GPU/容器": "GPU/Container",
|
||||
"GPU数量": "Number of GPUs",
|
||||
"Homepage URL 填": "ホームページURLを入力してください",
|
||||
@@ -444,6 +445,7 @@
|
||||
"兑换人ID": "引き換えユーザーID",
|
||||
"兑换成功!": "引き換えに成功しました",
|
||||
"兑换码充值": "引き換えコードによるチャージ",
|
||||
"清理不活跃缓存": "非アクティブなキャッシュをクリーンアップ",
|
||||
"兑换码创建成功": "引き換えコードの作成に成功しました",
|
||||
"兑换码创建成功,是否下载兑换码?": "引き換えコードの作成に成功しました。ダウンロードしますか?",
|
||||
"兑换码创建成功!": "引き換えコードの作成に成功しました",
|
||||
@@ -691,8 +693,10 @@
|
||||
"启用请求透传": "リクエストパススルーを有効にする",
|
||||
"启用额度消费日志记录": "クォータ消費のログ記録を有効にする",
|
||||
"启用验证": "認証を有効にする",
|
||||
"启用违规扣费": "違反課金を有効にする",
|
||||
"周": "週",
|
||||
"和": "および",
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考価格設置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Claudeとは異なり、Geminiの思考モデルはデフォルトで思考するかどうかを自動的に決定します。アダプターを有効にしなくても正常に動作します。課金が必要な場合は、サフィックスなしモデルの価格を思考価格に設定してください。gemini-2.5-pro-preview-06-05-thinking-128のような形式を使用して、正確な思考予算を指定できます。",
|
||||
"响应": "レスポンス",
|
||||
"响应时间": "応答時間",
|
||||
"商品价格 ID": "料金ID",
|
||||
@@ -735,6 +739,8 @@
|
||||
"填入": "入力",
|
||||
"填入所有模型": "すべてのモデルを入力",
|
||||
"填入模板": "テンプレートを入力",
|
||||
"填入透传模版": "パススルーテンプレートを入力",
|
||||
"填入透传完整模版": "完全なパススルーテンプレートを入力",
|
||||
"填入相关模型": "関連モデルを入力",
|
||||
"填写Gotify服务器的完整URL地址": "Gotifyサーバーの完全なURLを入力してください",
|
||||
"填写带https的域名,逗号分隔": "https://を含むドメインをカンマ区切りで入力してください",
|
||||
@@ -804,6 +810,7 @@
|
||||
"完整的 Base URL,支持变量{model}": "完全なベースURL(変数{model}に対応)",
|
||||
"官方": "公式",
|
||||
"官方文档": "公式ドキュメント",
|
||||
"官方说明": "公式ドキュメント",
|
||||
"官方模型同步": "公式モデルの同期",
|
||||
"定价模式": "課金タイプ",
|
||||
"定时测试所有通道": "すべてのチャネルの定期テスト",
|
||||
@@ -989,6 +996,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "有効にすると、接続をアクティブに保つためにpingデータが定期的に送信されます",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "有効にすると、すべてのリクエストは直接アップストリームにパススルーされ、いかなる処理も行われません(リダイレクトとチャネルの自動調整も無効になります)。有効にする際はご注意ください",
|
||||
"开启后,违规请求将额外扣费。": "有効にすると、違反リクエストには追加料金が発生します。",
|
||||
"开启后不限制:必须设置模型倍率": "有効化後は制限なし:モデル倍率の設定が必須",
|
||||
"开启后未登录用户无法访问模型广场": "有効にすると、ログインしていないユーザーはモデルマーケットプレイスにアクセスできなくなります",
|
||||
"开启批量操作": "一括操作を有効にする",
|
||||
@@ -1745,6 +1753,7 @@
|
||||
"确认操作": "Confirm Operation",
|
||||
"确认新密码": "新しいパスワードの確認",
|
||||
"确认清除历史日志": "履歴のクリアの確認",
|
||||
"确认清理不活跃的磁盘缓存?": "非アクティブなディスクキャッシュをクリーンアップしますか?",
|
||||
"确认禁用": "無効化の確認",
|
||||
"确认补单": "手動チャージの確認",
|
||||
"确认解绑": "連携解除の確認",
|
||||
@@ -1829,6 +1838,17 @@
|
||||
"系统文档和帮助信息": "システムのドキュメントとヘルプ",
|
||||
"系统消息": "システムメッセージ",
|
||||
"系统管理功能": "システム管理機能",
|
||||
"系统性能监控": "システムパフォーマンス監視",
|
||||
"启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。": "パフォーマンス監視が有効で、システムリソース使用率が設定されたしきい値を超えた場合、システムの安定性を保護するために新しいRelayリクエスト(/v1, /v1betaなど)は拒否されます。",
|
||||
"启用性能监控": "パフォーマンス監視を有効にする",
|
||||
"超过阈值时拒绝新请求": "閾値を超えた場合に新しいリクエストを拒否する",
|
||||
"CPU 阈值 (%)": "CPUしきい値 (%)",
|
||||
"CPU 使用率超过此值时拒绝请求": "CPU使用率がこの値を超えた場合にリクエストを拒否",
|
||||
"内存 阈值 (%)": "メモリしきい値 (%)",
|
||||
"内存使用率超过此值时拒绝请求": "メモリ使用率がこの値を超えた場合にリクエストを拒否",
|
||||
"磁盘 阈值 (%)": "ディスクしきい値 (%)",
|
||||
"磁盘使用率超过此值时拒绝请求": "ディスク使用率がこの値を超えた場合にリクエストを拒否",
|
||||
"保存性能设置": "パフォーマンス設定を保存",
|
||||
"系统设置": "システム設定",
|
||||
"系统访问令牌": "システムアクセストークン",
|
||||
"约": "約",
|
||||
@@ -2317,7 +2337,10 @@
|
||||
"运行时长(小时)": "Runtime Duration (hours)",
|
||||
"返回修改": "Go back and edit",
|
||||
"返回登录": "ログインに戻る",
|
||||
"违规扣费金额": "違反課金金額",
|
||||
"这是重复键中的最后一个,其值将被使用": "重複するキーのうち、最後のキーの値が使用されます",
|
||||
"这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。": "これは基本金額です。実際の課金 = 基本金額 × システムグループ倍率。",
|
||||
"这将删除超过 10 分钟未使用的临时缓存文件": "10分以上使用されていない一時キャッシュファイルを削除します",
|
||||
"进度": "進捗",
|
||||
"进行中": "進行中",
|
||||
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "この操作の実行時、チャネルへのアクセスエラーが発生する可能性があります。データベースに問題がある場合のみ使用してください",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"Gotify服务器地址": "Адрес сервера Gotify",
|
||||
"Gotify服务器地址必须以http://或https://开头": "Адрес сервера Gotify должен начинаться с http:// или https://",
|
||||
"Gotify通知": "Уведомления Gotify",
|
||||
"Grok设置": "Настройки Grok",
|
||||
"GPU/容器": "GPU/Container",
|
||||
"GPU数量": "Number of GPUs",
|
||||
"Homepage URL 填": "URL домашней страницы:",
|
||||
@@ -451,6 +452,7 @@
|
||||
"兑换人ID": "ID обменщика",
|
||||
"兑换成功!": "Обмен успешен!",
|
||||
"兑换码充值": "Пополнение кодом купона",
|
||||
"清理不活跃缓存": "Очистить неактивный кэш",
|
||||
"兑换码创建成功": "Код купона успешно создан",
|
||||
"兑换码创建成功,是否下载兑换码?": "Код купона успешно создан, скачать код купона?",
|
||||
"兑换码创建成功!": "Код купона успешно создан!",
|
||||
@@ -706,8 +708,10 @@
|
||||
"启用请求透传": "Включить прозрачную передачу запросов",
|
||||
"启用额度消费日志记录": "Включить журналирование потребления квоты",
|
||||
"启用验证": "Включить проверку",
|
||||
"启用违规扣费": "Включить удержание за нарушения",
|
||||
"周": "Неделя",
|
||||
"和": "и",
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "В отличие от Claude, модели мышления Gemini автоматически решают, использовать ли режим мышления. Они работают нормально даже без включённого адаптера. Если нужна тарификация, установите цену моделей без суффикса на цену мышления. Используйте формат gemini-2.5-pro-preview-06-05-thinking-128 для точного указания бюджета мышления.",
|
||||
"响应": "Ответ",
|
||||
"响应时间": "Время ответа",
|
||||
"商品价格 ID": "ID цены товара",
|
||||
@@ -750,6 +754,8 @@
|
||||
"填入": "Заполнить",
|
||||
"填入所有模型": "Заполнить все модели",
|
||||
"填入模板": "Заполнить шаблон",
|
||||
"填入透传模版": "Заполнить шаблон passthrough",
|
||||
"填入透传完整模版": "Заполнить полный шаблон passthrough",
|
||||
"填入相关模型": "Заполнить связанные модели",
|
||||
"填写Gotify服务器的完整URL地址": "Введите полный URL-адрес сервера Gotify",
|
||||
"填写带https的域名,逗号分隔": "Введите домены с https, разделённые запятыми",
|
||||
@@ -819,6 +825,7 @@
|
||||
"完整的 Base URL,支持变量{model}": "Полный Base URL, поддерживает переменную {model}",
|
||||
"官方": "Официальный",
|
||||
"官方文档": "Официальная документация",
|
||||
"官方说明": "Официальная документация",
|
||||
"官方模型同步": "Синхронизация официальных моделей",
|
||||
"定价模式": "Режим ценообразования",
|
||||
"定时测试所有通道": "Периодическое тестирование всех каналов",
|
||||
@@ -1015,6 +1022,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "После включения будет периодически отправляться ping-данные для поддержания активности соединения",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "После включения все запросы будут напрямую передаваться upstream без какой-либо обработки (перенаправление и адаптация каналов также будут отключены), включайте с осторожностью",
|
||||
"开启后,违规请求将额外扣费。": "При включении за нарушающие запросы будет взиматься дополнительная плата.",
|
||||
"开启后不限制:必须设置模型倍率": "После включения без ограничений: необходимо установить множители моделей",
|
||||
"开启后未登录用户无法访问模型广场": "После включения незарегистрированные пользователи не смогут получить доступ к площади моделей",
|
||||
"开启批量操作": "Включить пакетные операции",
|
||||
@@ -1775,6 +1783,7 @@
|
||||
"确认操作": "Confirm Operation",
|
||||
"确认新密码": "Подтвердить новый пароль",
|
||||
"确认清除历史日志": "Подтвердить очистку истории логов",
|
||||
"确认清理不活跃的磁盘缓存?": "Подтвердить очистку неактивного дискового кэша?",
|
||||
"确认禁用": "Подтвердить отключение",
|
||||
"确认补单": "Подтвердить дополнение заказа",
|
||||
"确认解绑": "Подтвердить отвязку",
|
||||
@@ -1859,6 +1868,17 @@
|
||||
"系统文档和帮助信息": "Системная документация и справочная информация",
|
||||
"系统消息": "Системные сообщения",
|
||||
"系统管理功能": "Функции системного управления",
|
||||
"系统性能监控": "Мониторинг производительности системы",
|
||||
"启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。": "При включённом мониторинге производительности, когда использование системных ресурсов превышает установленный порог, новые Relay-запросы (/v1, /v1beta и т.д.) будут отклоняться для защиты стабильности системы.",
|
||||
"启用性能监控": "Включить мониторинг производительности",
|
||||
"超过阈值时拒绝新请求": "Отклонять новые запросы при превышении порога",
|
||||
"CPU 阈值 (%)": "Порог CPU (%)",
|
||||
"CPU 使用率超过此值时拒绝请求": "Отклонять запросы, когда использование CPU превышает это значение",
|
||||
"内存 阈值 (%)": "Порог памяти (%)",
|
||||
"内存使用率超过此值时拒绝请求": "Отклонять запросы, когда использование памяти превышает это значение",
|
||||
"磁盘 阈值 (%)": "Порог диска (%)",
|
||||
"磁盘使用率超过此值时拒绝请求": "Отклонять запросы, когда использование диска превышает это значение",
|
||||
"保存性能设置": "Сохранить настройки производительности",
|
||||
"系统设置": "Системные настройки",
|
||||
"系统访问令牌": "Токен доступа к системе",
|
||||
"约": "Приблизительно",
|
||||
@@ -2347,7 +2367,10 @@
|
||||
"运行时长(小时)": "Runtime Duration (hours)",
|
||||
"返回修改": "Вернуться и исправить",
|
||||
"返回登录": "Вернуться к входу",
|
||||
"违规扣费金额": "Сумма удержания за нарушение",
|
||||
"这是重复键中的最后一个,其值将被使用": "Это последний ключ в повторяющихся, его значение будет использовано",
|
||||
"这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。": "Это базовая сумма. Фактическое удержание = базовая сумма × коэффициент системной группы.",
|
||||
"这将删除超过 10 分钟未使用的临时缓存文件": "Это удалит временные файлы кэша, которые не использовались более 10 минут",
|
||||
"进度": "Прогресс",
|
||||
"进行中": "В процессе",
|
||||
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "При выполнении этой операции могут возникнуть ошибки доступа к каналам, используйте только при проблемах с базой данных",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"Gotify服务器地址": "Địa chỉ máy chủ Gotify",
|
||||
"Gotify服务器地址必须以http://或https://开头": "Địa chỉ máy chủ Gotify phải bắt đầu bằng http:// hoặc https://",
|
||||
"Gotify通知": "Thông báo Gotify",
|
||||
"Grok设置": "Cài đặt Grok",
|
||||
"GPU/容器": "GPU/Container",
|
||||
"GPU数量": "Number of GPUs",
|
||||
"Homepage URL 填": "Điền URL trang chủ",
|
||||
@@ -445,6 +446,7 @@
|
||||
"兑换人ID": "ID người đổi",
|
||||
"兑换成功!": "Đổi thành công!",
|
||||
"兑换码充值": "Nạp tiền bằng mã đổi thưởng",
|
||||
"清理不活跃缓存": "Xóa cache không hoạt động",
|
||||
"兑换码创建成功": "Đã tạo mã đổi thưởng",
|
||||
"兑换码创建成功,是否下载兑换码?": "Tạo mã đổi thưởng thành công. Bạn có muốn tải xuống không?",
|
||||
"兑换码创建成功!": "Tạo mã đổi thưởng thành công!",
|
||||
@@ -692,8 +694,10 @@
|
||||
"启用请求透传": "Bật truyền qua yêu cầu",
|
||||
"启用额度消费日志记录": "Bật ghi nhật ký tiêu thụ hạn ngạch",
|
||||
"启用验证": "Bật xác thực",
|
||||
"启用违规扣费": "Bật trừ phí vi phạm",
|
||||
"周": "tuần",
|
||||
"和": "và",
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Không giống Claude, mô hình tư duy Gemini tự động quyết định có suy nghĩ hay không. Chúng hoạt động bình thường ngay cả khi không bật adapter. Nếu cần tính phí, hãy đặt giá của mô hình không có hậu tố theo giá tư duy. Sử dụng định dạng như gemini-2.5-pro-preview-06-05-thinking-128 để chỉ định ngân sách tư duy chính xác.",
|
||||
"响应": "Phản hồi",
|
||||
"响应时间": "Thời gian phản hồi",
|
||||
"商品价格 ID": "ID giá sản phẩm",
|
||||
@@ -736,6 +740,8 @@
|
||||
"填入": "Điền",
|
||||
"填入所有模型": "Điền tất cả mô hình",
|
||||
"填入模板": "Điền mẫu",
|
||||
"填入透传模版": "Điền mẫu truyền qua",
|
||||
"填入透传完整模版": "Điền mẫu truyền qua đầy đủ",
|
||||
"填入相关模型": "Điền mô hình liên quan",
|
||||
"填写Gotify服务器的完整URL地址": "Điền địa chỉ URL đầy đủ của máy chủ Gotify",
|
||||
"填写带https的域名,逗号分隔": "Điền tên miền có https, phân tách bằng dấu phẩy",
|
||||
@@ -805,6 +811,7 @@
|
||||
"完整的 Base URL,支持变量{model}": "Base URL đầy đủ, hỗ trợ biến {model}",
|
||||
"官方": "Chính thức",
|
||||
"官方文档": "Tài liệu chính thức",
|
||||
"官方说明": "Tài liệu chính thức",
|
||||
"官方模型同步": "Đồng bộ mô hình chính thức",
|
||||
"定价模式": "Chế độ định giá",
|
||||
"定时测试所有通道": "Định kỳ kiểm tra tất cả các kênh",
|
||||
@@ -990,6 +997,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "Sau khi bật, dữ liệu ping sẽ được gửi định kỳ để giữ kết nối hoạt động",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Sau khi bật, khi kênh nhóm hiện tại thất bại, nó sẽ thử kênh của nhóm tiếp theo theo thứ tự",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Khi bật, tất cả các yêu cầu sẽ được chuyển tiếp trực tiếp đến thượng nguồn mà không cần xử lý (chuyển hướng và thích ứng kênh cũng sẽ bị vô hiệu hóa). Vui lòng bật một cách thận trọng.",
|
||||
"开启后,违规请求将额外扣费。": "Khi bật, các yêu cầu vi phạm sẽ bị tính phí bổ sung.",
|
||||
"开启后不限制:必须设置模型倍率": "Sau khi bật, không giới hạn: phải đặt tỷ lệ mô hình",
|
||||
"开启后未登录用户无法访问模型广场": "Khi bật, người dùng chưa xác thực không thể truy cập thị trường mô hình",
|
||||
"开启批量操作": "Bật chọn hàng loạt",
|
||||
@@ -1980,6 +1988,7 @@
|
||||
"确认新密码": "Xác nhận mật khẩu mới",
|
||||
"确认清除": "Xác nhận xóa",
|
||||
"确认清除历史日志": "Xác nhận xóa nhật ký lịch sử",
|
||||
"确认清理不活跃的磁盘缓存?": "Xác nhận xóa cache đĩa không hoạt động?",
|
||||
"确认禁用": "Xác nhận vô hiệu hóa",
|
||||
"确认补单": "Xác nhận hoàn thành đơn hàng",
|
||||
"确认解绑": "Xác nhận hủy liên kết",
|
||||
@@ -2109,6 +2118,17 @@
|
||||
"系统监控": "Giám sát hệ thống",
|
||||
"系统管理": "Quản lý hệ thống",
|
||||
"系统管理功能": "Chức năng quản lý hệ thống",
|
||||
"系统性能监控": "Giám sát hiệu suất hệ thống",
|
||||
"启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。": "Khi giám sát hiệu suất được bật và mức sử dụng tài nguyên hệ thống vượt quá ngưỡng đã đặt, các yêu cầu Relay mới (/v1, /v1beta, v.v.) sẽ bị từ chối để bảo vệ sự ổn định của hệ thống.",
|
||||
"启用性能监控": "Bật giám sát hiệu suất",
|
||||
"超过阈值时拒绝新请求": "Từ chối yêu cầu mới khi vượt ngưỡng",
|
||||
"CPU 阈值 (%)": "Ngưỡng CPU (%)",
|
||||
"CPU 使用率超过此值时拒绝请求": "Từ chối yêu cầu khi sử dụng CPU vượt quá giá trị này",
|
||||
"内存 阈值 (%)": "Ngưỡng bộ nhớ (%)",
|
||||
"内存使用率超过此值时拒绝请求": "Từ chối yêu cầu khi sử dụng bộ nhớ vượt quá giá trị này",
|
||||
"磁盘 阈值 (%)": "Ngưỡng đĩa (%)",
|
||||
"磁盘使用率超过此值时拒绝请求": "Từ chối yêu cầu khi sử dụng đĩa vượt quá giá trị này",
|
||||
"保存性能设置": "Lưu cài đặt hiệu suất",
|
||||
"系统设置": "Cài đặt hệ thống",
|
||||
"系统访问令牌": "Mã thông báo truy cập hệ thống",
|
||||
"系统负载": "Tải hệ thống",
|
||||
@@ -2811,7 +2831,10 @@
|
||||
"返回列表": "Quay lại danh sách",
|
||||
"返回登录": "Quay lại đăng nhập",
|
||||
"返回首页": "Quay lại trang chủ",
|
||||
"违规扣费金额": "Số tiền trừ phí vi phạm",
|
||||
"这是重复键中的最后一个,其值将被使用": "Đây là khóa cuối cùng trong số các khóa trùng lặp và giá trị của nó sẽ được sử dụng",
|
||||
"这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。": "Đây là số tiền cơ bản. Số tiền trừ thực tế = số tiền cơ bản × tỷ lệ nhóm hệ thống.",
|
||||
"这将删除超过 10 分钟未使用的临时缓存文件": "Điều này sẽ xóa các tệp cache tạm thời không được sử dụng trong hơn 10 phút",
|
||||
"进入": "Nhập",
|
||||
"进度": "Tiến độ",
|
||||
"进行中": "Đang tiến hành",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Gotify服务器地址": "Gotify服务器地址",
|
||||
"Gotify服务器地址必须以http://或https://开头": "Gotify服务器地址必须以http://或https://开头",
|
||||
"Gotify通知": "Gotify通知",
|
||||
"Grok设置": "Grok设置",
|
||||
"GPU/容器": "GPU/容器",
|
||||
"GPU数量": "GPU数量",
|
||||
"Homepage URL 填": "Homepage URL 填",
|
||||
@@ -442,6 +443,9 @@
|
||||
"兑换人ID": "兑换人ID",
|
||||
"兑换成功!": "兑换成功!",
|
||||
"兑换码充值": "兑换码充值",
|
||||
"确认清理不活跃的磁盘缓存?": "确认清理不活跃的磁盘缓存?",
|
||||
"这将删除超过 10 分钟未使用的临时缓存文件": "这将删除超过 10 分钟未使用的临时缓存文件",
|
||||
"清理不活跃缓存": "清理不活跃缓存",
|
||||
"兑换码创建成功": "兑换码创建成功",
|
||||
"兑换码创建成功,是否下载兑换码?": "兑换码创建成功,是否下载兑换码?",
|
||||
"兑换码创建成功!": "兑换码创建成功!",
|
||||
@@ -688,8 +692,10 @@
|
||||
"启用请求透传": "启用请求透传",
|
||||
"启用额度消费日志记录": "启用额度消费日志记录",
|
||||
"启用验证": "启用验证",
|
||||
"启用违规扣费": "启用违规扣费",
|
||||
"周": "周",
|
||||
"和": "和",
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。",
|
||||
"响应": "响应",
|
||||
"响应时间": "响应时间",
|
||||
"商品价格 ID": "商品价格 ID",
|
||||
@@ -732,6 +738,8 @@
|
||||
"填入": "填入",
|
||||
"填入所有模型": "填入所有模型",
|
||||
"填入模板": "填入模板",
|
||||
"填入透传模版": "填入透传模版",
|
||||
"填入透传完整模版": "填入透传完整模版",
|
||||
"填入相关模型": "填入相关模型",
|
||||
"填写Gotify服务器的完整URL地址": "填写Gotify服务器的完整URL地址",
|
||||
"填写带https的域名,逗号分隔": "填写带https的域名,逗号分隔",
|
||||
@@ -801,6 +809,7 @@
|
||||
"完整的 Base URL,支持变量{model}": "完整的 Base URL,支持变量{model}",
|
||||
"官方": "官方",
|
||||
"官方文档": "官方文档",
|
||||
"官方说明": "官方说明",
|
||||
"官方模型同步": "官方模型同步",
|
||||
"定价模式": "定价模式",
|
||||
"定时测试所有通道": "定时测试所有通道",
|
||||
@@ -982,6 +991,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "开启后,将定期发送ping数据保持连接活跃",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启",
|
||||
"开启后,违规请求将额外扣费。": "开启后,违规请求将额外扣费。",
|
||||
"开启后不限制:必须设置模型倍率": "开启后不限制:必须设置模型倍率",
|
||||
"开启后未登录用户无法访问模型广场": "开启后未登录用户无法访问模型广场",
|
||||
"开启批量操作": "开启批量操作",
|
||||
@@ -1820,6 +1830,17 @@
|
||||
"系统文档和帮助信息": "系统文档和帮助信息",
|
||||
"系统消息": "系统消息",
|
||||
"系统管理功能": "系统管理功能",
|
||||
"系统性能监控": "系统性能监控",
|
||||
"启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。": "启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。",
|
||||
"启用性能监控": "启用性能监控",
|
||||
"超过阈值时拒绝新请求": "超过阈值时拒绝新请求",
|
||||
"CPU 阈值 (%)": "CPU 阈值 (%)",
|
||||
"CPU 使用率超过此值时拒绝请求": "CPU 使用率超过此值时拒绝请求",
|
||||
"内存 阈值 (%)": "内存 阈值 (%)",
|
||||
"内存使用率超过此值时拒绝请求": "内存使用率超过此值时拒绝请求",
|
||||
"磁盘 阈值 (%)": "磁盘 阈值 (%)",
|
||||
"磁盘使用率超过此值时拒绝请求": "磁盘使用率超过此值时拒绝请求",
|
||||
"保存性能设置": "保存性能设置",
|
||||
"系统设置": "系统设置",
|
||||
"系统访问令牌": "系统访问令牌",
|
||||
"约": "约",
|
||||
@@ -2302,6 +2323,45 @@
|
||||
"输入验证码完成设置": "输入验证码完成设置",
|
||||
"输出": "输出",
|
||||
"输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}": "输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}",
|
||||
"磁盘缓存设置(磁盘换内存)": "磁盘缓存设置(磁盘换内存)",
|
||||
"启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。": "启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。",
|
||||
"启用磁盘缓存": "启用磁盘缓存",
|
||||
"将大请求体临时存储到磁盘": "将大请求体临时存储到磁盘",
|
||||
"磁盘缓存阈值 (MB)": "磁盘缓存阈值 (MB)",
|
||||
"请求体超过此大小时使用磁盘缓存": "请求体超过此大小时使用磁盘缓存",
|
||||
"磁盘缓存最大总量 (MB)": "磁盘缓存最大总量 (MB)",
|
||||
"可用空间: {{free}} / 总空间: {{total}}": "可用空间: {{free}} / 总空间: {{total}}",
|
||||
"磁盘缓存占用的最大空间": "磁盘缓存占用的最大空间",
|
||||
"留空使用系统临时目录": "留空使用系统临时目录",
|
||||
"例如 /var/cache/new-api": "例如 /var/cache/new-api",
|
||||
"性能监控": "性能监控",
|
||||
"刷新统计": "刷新统计",
|
||||
"重置统计": "重置统计",
|
||||
"执行 GC": "执行 GC",
|
||||
"请求体磁盘缓存": "请求体磁盘缓存",
|
||||
"活跃文件": "活跃文件",
|
||||
"磁盘命中": "磁盘命中",
|
||||
"请求体内存缓存": "请求体内存缓存",
|
||||
"当前缓存大小": "当前缓存大小",
|
||||
"活跃缓存数": "活跃缓存数",
|
||||
"内存命中": "内存命中",
|
||||
"缓存目录磁盘空间": "缓存目录磁盘空间",
|
||||
"磁盘可用空间小于缓存最大总量设置": "磁盘可用空间小于缓存最大总量设置",
|
||||
"已分配内存": "已分配内存",
|
||||
"总分配内存": "总分配内存",
|
||||
"系统内存": "系统内存",
|
||||
"GC 次数": "GC 次数",
|
||||
"Goroutine 数": "Goroutine 数",
|
||||
"目录文件数": "目录文件数",
|
||||
"目录总大小": "目录总大小",
|
||||
"磁盘缓存已清理": "磁盘缓存已清理",
|
||||
"清理失败": "清理失败",
|
||||
"统计已重置": "统计已重置",
|
||||
"重置失败": "重置失败",
|
||||
"GC 已执行": "GC 已执行",
|
||||
"GC 执行失败": "GC 执行失败",
|
||||
"缓存目录": "缓存目录",
|
||||
"可用": "可用",
|
||||
"输出价格": "输出价格",
|
||||
"输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})": "输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})",
|
||||
"输出倍率 {{completionRatio}}": "输出倍率 {{completionRatio}}",
|
||||
@@ -2317,7 +2377,9 @@
|
||||
"运行时长(小时)": "运行时长(小时)",
|
||||
"返回修改": "返回修改",
|
||||
"返回登录": "返回登录",
|
||||
"违规扣费金额": "违规扣费金额",
|
||||
"这是重复键中的最后一个,其值将被使用": "这是重复键中的最后一个,其值将被使用",
|
||||
"这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。": "这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。",
|
||||
"进度": "进度",
|
||||
"进行中": "进行中",
|
||||
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用",
|
||||
@@ -2672,6 +2734,12 @@
|
||||
"套餐名称": "套餐名称",
|
||||
"应付金额": "应付金额",
|
||||
"支付": "支付",
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "管理员未开启在线支付功能,请联系管理员配置。"
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "管理员未开启在线支付功能,请联系管理员配置。",
|
||||
"偏好设置": "偏好设置",
|
||||
"界面语言和其他个人偏好": "界面语言和其他个人偏好",
|
||||
"语言偏好": "语言偏好",
|
||||
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "选择您的首选界面语言,设置将自动保存并同步到所有设备",
|
||||
"语言偏好已保存": "语言偏好已保存",
|
||||
"提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,16 @@ body {
|
||||
background-color: var(--semi-color-bg-0);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100dvh - 64px);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
@@ -107,6 +117,7 @@ code {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--semi-color-bg-0);
|
||||
}
|
||||
@@ -116,9 +127,11 @@ code {
|
||||
width: 100%;
|
||||
background: var(--semi-color-bg-0);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
border-right: none;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ export default function SettingsPerformance(props) {
|
||||
'performance_setting.disk_cache_threshold_mb': 10,
|
||||
'performance_setting.disk_cache_max_size_mb': 1024,
|
||||
'performance_setting.disk_cache_path': '',
|
||||
'performance_setting.monitor_enabled': false,
|
||||
'performance_setting.monitor_cpu_threshold': 90,
|
||||
'performance_setting.monitor_memory_threshold': 90,
|
||||
'performance_setting.monitor_disk_threshold': 90,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
@@ -274,6 +278,70 @@ export default function SettingsPerformance(props) {
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('系统性能监控')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。',
|
||||
)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Form.Switch
|
||||
field={'performance_setting.monitor_enabled'}
|
||||
label={t('启用性能监控')}
|
||||
extraText={t('超过阈值时拒绝新请求')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_enabled',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Form.InputNumber
|
||||
field={'performance_setting.monitor_cpu_threshold'}
|
||||
label={t('CPU 阈值 (%)')}
|
||||
extraText={t('CPU 使用率超过此值时拒绝请求')}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_cpu_threshold',
|
||||
)}
|
||||
disabled={!inputs['performance_setting.monitor_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Form.InputNumber
|
||||
field={'performance_setting.monitor_memory_threshold'}
|
||||
label={t('内存 阈值 (%)')}
|
||||
extraText={t('内存使用率超过此值时拒绝请求')}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_memory_threshold',
|
||||
)}
|
||||
disabled={!inputs['performance_setting.monitor_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Form.InputNumber
|
||||
field={'performance_setting.monitor_disk_threshold'}
|
||||
label={t('磁盘 阈值 (%)')}
|
||||
extraText={t('磁盘使用率超过此值时拒绝请求')}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_disk_threshold',
|
||||
)}
|
||||
disabled={!inputs['performance_setting.monitor_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存性能设置')}
|
||||
@@ -291,11 +359,11 @@ export default function SettingsPerformance(props) {
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button onClick={fetchStats}>{t('刷新统计')}</Button>
|
||||
<Popconfirm
|
||||
title={t('确认清理磁盘缓存?')}
|
||||
content={t('这将删除所有临时缓存文件')}
|
||||
title={t('确认清理不活跃的磁盘缓存?')}
|
||||
content={t('这将删除超过 10 分钟未使用的临时缓存文件')}
|
||||
onConfirm={clearDiskCache}
|
||||
>
|
||||
<Button type='warning'>{t('清理磁盘缓存')}</Button>
|
||||
<Button type='warning'>{t('清理不活跃缓存')}</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={resetStats}>{t('重置统计')}</Button>
|
||||
<Button onClick={forceGC}>{t('执行 GC')}</Button>
|
||||
@@ -490,7 +558,10 @@ export default function SettingsPerformance(props) {
|
||||
key: t('Goroutine 数'),
|
||||
value: stats.memory_stats.num_goroutine,
|
||||
},
|
||||
{ key: t('缓存目录'), value: stats.disk_cache_info.path },
|
||||
{
|
||||
key: t('缓存目录'),
|
||||
value: stats.disk_cache_info.path,
|
||||
},
|
||||
{
|
||||
key: t('目录文件数'),
|
||||
value: stats.disk_cache_info.file_count,
|
||||
|
||||
Reference in New Issue
Block a user