mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-31 07:26:05 +00:00
Compare commits
13 Commits
v0.9.16-pa
...
logger
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ff448049 | ||
|
|
3dc4d6c39e | ||
|
|
019412c27a | ||
|
|
96a2b81aaa | ||
|
|
fb610e62a0 | ||
|
|
736f7b55b7 | ||
|
|
2fd33ea294 | ||
|
|
53123aaf94 | ||
|
|
f8f5d26600 | ||
|
|
c86bc94d9d | ||
|
|
50e8639a40 | ||
|
|
424325162e | ||
|
|
14295f0035 |
260
LOGGING.md
Normal file
260
LOGGING.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# 日志系统说明
|
||||
|
||||
本项目使用 Go 标准库的 `log/slog` 实现结构化日志记录。
|
||||
|
||||
## 📋 功能特性
|
||||
|
||||
### 1. 标准的文件存储结构
|
||||
|
||||
- **当前日志文件**: `oneapi.log` - 实时写入的日志文件
|
||||
- **归档日志文件**: `oneapi.2024-01-02-153045.log` - 自动轮转后的历史日志
|
||||
|
||||
### 2. 自动日志轮转
|
||||
|
||||
日志文件会在以下情况自动轮转:
|
||||
|
||||
- **按大小轮转**: 当日志文件超过指定大小时(默认 100MB)
|
||||
- **启动时日期检查**: 程序启动时如果检测到日志文件是旧日期创建的,会自动轮转
|
||||
- **自动清理**: 只保留最近 N 个日志文件(默认 7 个)
|
||||
|
||||
### 3. 结构化日志
|
||||
|
||||
所有日志都包含以下结构化字段:
|
||||
|
||||
```
|
||||
time=2024-01-02T15:30:45 level=INFO msg="user logged in" request_id=abc123 user_id=1001
|
||||
```
|
||||
|
||||
### 4. 多种输出格式
|
||||
|
||||
- **Text 格式** (默认): 人类可读的文本格式
|
||||
- **JSON 格式**: 便于日志分析工具解析
|
||||
|
||||
### 5. 灵活的日志级别
|
||||
|
||||
支持四个日志级别:
|
||||
- `DEBUG`: 调试信息
|
||||
- `INFO`: 一般信息
|
||||
- `WARN`: 警告信息
|
||||
- `ERROR`: 错误信息
|
||||
|
||||
## ⚙️ 配置方式
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# 日志目录(必需,否则只输出到控制台)
|
||||
--log-dir=./logs
|
||||
|
||||
# 日志级别(可选,默认: INFO,DEBUG 模式除外)
|
||||
export LOG_LEVEL=DEBUG # 可选值: DEBUG, INFO, WARN, ERROR
|
||||
|
||||
# 日志格式(可选,默认: text)
|
||||
export LOG_FORMAT=json # 可选值: text, json
|
||||
|
||||
# 单个日志文件最大大小(可选,默认: 100,单位: MB)
|
||||
export LOG_MAX_SIZE_MB=200
|
||||
|
||||
# 保留的日志文件数量(可选,默认: 7)
|
||||
export LOG_MAX_FILES=14
|
||||
|
||||
# 启用调试模式(会自动将日志级别设为 DEBUG)
|
||||
export DEBUG=true
|
||||
```
|
||||
|
||||
### 命令行参数
|
||||
|
||||
```bash
|
||||
# 启动时指定日志目录
|
||||
./new-api --log-dir=./logs
|
||||
|
||||
# 如果不指定日志目录,日志只输出到控制台
|
||||
./new-api
|
||||
```
|
||||
|
||||
## 📝 使用示例
|
||||
|
||||
### 基础使用
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
)
|
||||
|
||||
// 记录信息日志
|
||||
logger.LogInfo(ctx, "user registered successfully")
|
||||
|
||||
// 记录警告日志
|
||||
logger.LogWarn(ctx, "API rate limit approaching")
|
||||
|
||||
// 记录错误日志
|
||||
logger.LogError(ctx, "failed to connect to database")
|
||||
|
||||
// 记录调试日志(只在 DEBUG 模式下输出)
|
||||
logger.LogDebug(ctx, "processing request with params: %v", params)
|
||||
|
||||
// 记录系统日志(无 context)
|
||||
logger.LogSystemInfo("application started")
|
||||
logger.LogSystemError("critical system error")
|
||||
```
|
||||
|
||||
### 日志输出示例
|
||||
|
||||
**Text 格式** (易读格式):
|
||||
```
|
||||
[INFO] 2024/01/02 - 15:30:45 | SYSTEM | application started
|
||||
[INFO] 2024/01/02 - 15:30:46 | abc123 | user registered successfully
|
||||
[WARN] 2024/01/02 - 15:30:47 | def456 | API rate limit approaching | remaining=10, limit=100
|
||||
[ERROR] 2024/01/02 - 15:30:48 | ghi789 | failed to connect to database | error="connection timeout"
|
||||
```
|
||||
|
||||
格式说明:`[级别] 时间 | 请求ID/组件 | 消息 | 额外属性(如有)`
|
||||
|
||||
**JSON 格式**:
|
||||
```json
|
||||
{"time":"2024-01-02 15:30:45","level":"INFO","msg":"application started","request_id":"SYSTEM"}
|
||||
{"time":"2024-01-02 15:30:46","level":"INFO","msg":"user registered successfully","request_id":"abc123"}
|
||||
{"time":"2024-01-02 15:30:47","level":"WARN","msg":"API rate limit approaching","request_id":"def456"}
|
||||
```
|
||||
|
||||
## 📂 日志文件结构
|
||||
|
||||
```
|
||||
logs/
|
||||
├── oneapi.log # 当前活动日志文件
|
||||
├── oneapi.2024-01-01-090000.log # 昨天的日志
|
||||
├── oneapi.2024-01-01-150000.log # 昨天下午的日志(如果超过大小限制)
|
||||
├── oneapi.2023-12-31-090000.log # 更早的日志
|
||||
└── ... # 最多保留配置数量的历史文件
|
||||
```
|
||||
|
||||
## 🔄 日志轮转机制
|
||||
|
||||
### 轮转触发条件
|
||||
|
||||
1. **文件大小检查**: 每写入 1000 条日志后检查一次文件大小
|
||||
2. **启动时日期检查**: 程序启动时检查日志文件的修改日期,如果不是今天则轮转
|
||||
3. **自动清理**: 轮转时自动删除超过保留数量的旧日志文件
|
||||
|
||||
> **注意**: 日志不会在运行时动态检查日期变化。如果需要每天自动轮转日志,建议:
|
||||
> - 使用定时任务(如 cron)每天重启服务
|
||||
> - 或者配置较小的日志文件大小,让它自动按大小轮转
|
||||
|
||||
### 轮转流程
|
||||
|
||||
1. 检测到需要轮转时,关闭当前日志文件
|
||||
2. 将 `oneapi.log` 重命名为 `oneapi.YYYY-MM-DD-HHmmss.log`
|
||||
3. 创建新的 `oneapi.log` 文件
|
||||
4. 异步清理超过数量限制的旧日志文件
|
||||
5. 记录轮转事件到新日志文件
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 1. 生产环境配置
|
||||
|
||||
```bash
|
||||
# 使用 INFO 级别,避免过多调试信息
|
||||
export LOG_LEVEL=INFO
|
||||
|
||||
# 使用 JSON 格式,便于日志分析工具处理
|
||||
export LOG_FORMAT=json
|
||||
|
||||
# 设置合适的文件大小和保留数量
|
||||
export LOG_MAX_SIZE_MB=500
|
||||
export LOG_MAX_FILES=30
|
||||
|
||||
# 指定日志目录
|
||||
./new-api --log-dir=/var/log/oneapi
|
||||
```
|
||||
|
||||
### 2. 开发环境配置
|
||||
|
||||
```bash
|
||||
# 使用 DEBUG 级别查看详细信息
|
||||
export DEBUG=true
|
||||
|
||||
# 使用 Text 格式,便于阅读
|
||||
export LOG_FORMAT=text
|
||||
|
||||
# 较小的文件大小和保留数量
|
||||
export LOG_MAX_SIZE_MB=50
|
||||
export LOG_MAX_FILES=7
|
||||
|
||||
./new-api --log-dir=./logs
|
||||
```
|
||||
|
||||
### 3. 容器环境配置
|
||||
|
||||
```bash
|
||||
# 只输出到标准输出,由容器运行时管理日志
|
||||
./new-api
|
||||
|
||||
# 或者使用 JSON 格式便于日志收集系统处理
|
||||
export LOG_FORMAT=json
|
||||
./new-api
|
||||
```
|
||||
|
||||
## 🔍 日志分析
|
||||
|
||||
### 使用 grep 分析文本日志
|
||||
|
||||
```bash
|
||||
# 查找错误日志
|
||||
grep '\[ERROR\]' logs/oneapi.log
|
||||
|
||||
# 查找特定请求的所有日志
|
||||
grep 'abc123' logs/*.log
|
||||
|
||||
# 查看最近的警告和错误
|
||||
tail -f logs/oneapi.log | grep -E '\[(WARN|ERROR)\]'
|
||||
|
||||
# 查找包含特定关键词的日志
|
||||
grep 'database' logs/oneapi.log
|
||||
|
||||
# 查看今天的所有错误
|
||||
grep "\[ERROR\] $(date +%Y/%m/%d)" logs/oneapi.log
|
||||
```
|
||||
|
||||
### 使用 jq 分析 JSON 日志
|
||||
|
||||
```bash
|
||||
# 提取所有错误日志
|
||||
cat logs/oneapi.log | jq 'select(.level=="ERROR")'
|
||||
|
||||
# 统计各级别日志数量
|
||||
cat logs/oneapi.log | jq -r '.level' | sort | uniq -c
|
||||
|
||||
# 查找特定时间范围的日志
|
||||
cat logs/oneapi.log | jq 'select(.time >= "2024-01-02 15:00:00" and .time <= "2024-01-02 16:00:00")'
|
||||
```
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
1. **异步日志轮转**: 轮转操作在后台 goroutine 中执行,不阻塞主程序
|
||||
2. **批量写入检查**: 每 1000 次写入才检查一次轮转条件,减少 I/O 开销
|
||||
3. **读写锁**: 使用 `sync.RWMutex` 保护日志器,提高并发性能
|
||||
4. **零分配**: `slog` 库在大多数情况下实现零内存分配
|
||||
|
||||
## 🚨 故障排查
|
||||
|
||||
### 日志文件未创建
|
||||
|
||||
- 检查日志目录是否存在且有写入权限
|
||||
- 确认启动时指定了 `--log-dir` 参数
|
||||
|
||||
### 日志文件过多
|
||||
|
||||
- 调整 `LOG_MAX_FILES` 环境变量
|
||||
- 手动清理不需要的旧日志文件
|
||||
|
||||
### 日志级别不正确
|
||||
|
||||
- 检查 `LOG_LEVEL` 环境变量是否正确设置
|
||||
- 确认 `DEBUG` 环境变量的值(会覆盖 LOG_LEVEL)
|
||||
|
||||
## 📖 相关文档
|
||||
|
||||
- [Go slog 官方文档](https://pkg.go.dev/log/slog)
|
||||
- [结构化日志最佳实践](https://go.dev/blog/slog)
|
||||
|
||||
@@ -141,6 +141,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟
|
||||
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
|
||||
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
|
||||
- `TASK_PRICE_PATCH=sora-2-all,sora-2-pro-all`: 异步任务设置某些模型按次计费,多个模型用逗号分隔,例如`sora-2-all,sora-2-pro-all`,表示sora-2-all和sora-2-pro-all模型异步任务仅按次计费,不按秒等计费。
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
@@ -159,14 +159,15 @@ var (
|
||||
GlobalWebRateLimitNum int
|
||||
GlobalWebRateLimitDuration int64
|
||||
|
||||
CriticalRateLimitEnable bool
|
||||
CriticalRateLimitNum = 20
|
||||
CriticalRateLimitDuration int64 = 20 * 60
|
||||
|
||||
UploadRateLimitNum = 10
|
||||
UploadRateLimitDuration int64 = 60
|
||||
|
||||
DownloadRateLimitNum = 10
|
||||
DownloadRateLimitDuration int64 = 60
|
||||
|
||||
CriticalRateLimitNum = 20
|
||||
CriticalRateLimitDuration int64 = 20 * 60
|
||||
)
|
||||
|
||||
var RateLimitKeyExpirationDuration = 20 * time.Minute
|
||||
|
||||
@@ -3,7 +3,7 @@ package common
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -43,9 +43,10 @@ func InitEnv() {
|
||||
if os.Getenv("SESSION_SECRET") != "" {
|
||||
ss := os.Getenv("SESSION_SECRET")
|
||||
if ss == "random_string" {
|
||||
log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
|
||||
log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
|
||||
log.Fatal("Please set SESSION_SECRET to a random string.")
|
||||
slog.Warn("SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
|
||||
slog.Warn("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
|
||||
slog.Error("Please set SESSION_SECRET to a random string.")
|
||||
os.Exit(1)
|
||||
} else {
|
||||
SessionSecret = ss
|
||||
}
|
||||
@@ -62,12 +63,14 @@ func InitEnv() {
|
||||
var err error
|
||||
*LogDir, err = filepath.Abs(*LogDir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
slog.Error("failed to get absolute path for log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
|
||||
err = os.Mkdir(*LogDir, 0777)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
slog.Error("failed to create log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +102,9 @@ func InitEnv() {
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
||||
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
||||
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
|
||||
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
|
||||
initConstantEnv()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -9,18 +10,16 @@ import (
|
||||
)
|
||||
|
||||
func SysLog(s string) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
||||
slog.Info(s, "component", "system")
|
||||
}
|
||||
|
||||
func SysError(s string) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
||||
slog.Error(s, "component", "system")
|
||||
}
|
||||
|
||||
func FatalLog(v ...any) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
|
||||
msg := fmt.Sprint(v...)
|
||||
slog.Error(msg, "component", "system", "level", "fatal")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -617,6 +617,10 @@ func TestAllChannels(c *gin.Context) {
|
||||
var autoTestChannelsOnce sync.Once
|
||||
|
||||
func AutomaticallyTestChannels() {
|
||||
// 只在Master节点定时测试渠道
|
||||
if !common.IsMasterNode {
|
||||
return
|
||||
}
|
||||
autoTestChannelsOnce.Do(func() {
|
||||
for {
|
||||
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
|
||||
|
||||
@@ -649,13 +649,15 @@ func DeleteDisabledChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
type ChannelTag struct {
|
||||
Tag string `json:"tag"`
|
||||
NewTag *string `json:"new_tag"`
|
||||
Priority *int64 `json:"priority"`
|
||||
Weight *uint `json:"weight"`
|
||||
ModelMapping *string `json:"model_mapping"`
|
||||
Models *string `json:"models"`
|
||||
Groups *string `json:"groups"`
|
||||
Tag string `json:"tag"`
|
||||
NewTag *string `json:"new_tag"`
|
||||
Priority *int64 `json:"priority"`
|
||||
Weight *uint `json:"weight"`
|
||||
ModelMapping *string `json:"model_mapping"`
|
||||
Models *string `json:"models"`
|
||||
Groups *string `json:"groups"`
|
||||
ParamOverride *string `json:"param_override"`
|
||||
HeaderOverride *string `json:"header_override"`
|
||||
}
|
||||
|
||||
func DisableTagChannels(c *gin.Context) {
|
||||
@@ -721,7 +723,29 @@ func EditTagChannels(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight)
|
||||
if channelTag.ParamOverride != nil {
|
||||
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
|
||||
if trimmed != "" && !json.Valid([]byte(trimmed)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数覆盖必须是合法的 JSON 格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
channelTag.ParamOverride = common.GetPointer[string](trimmed)
|
||||
}
|
||||
if channelTag.HeaderOverride != nil {
|
||||
trimmed := strings.TrimSpace(*channelTag.HeaderOverride)
|
||||
if trimmed != "" && !json.Valid([]byte(trimmed)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请求头覆盖必须是合法的 JSON 格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
channelTag.HeaderOverride = common.GetPointer[string](trimmed)
|
||||
}
|
||||
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight, channelTag.ParamOverride, channelTag.HeaderOverride)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
@@ -232,10 +232,13 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
|
||||
return "system"
|
||||
}
|
||||
|
||||
const CustomType = "custom"
|
||||
|
||||
type ToolCallRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionRequest `json:"function"`
|
||||
Function FunctionRequest `json:"function,omitempty"`
|
||||
Custom json.RawMessage `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
type FunctionRequest struct {
|
||||
|
||||
584
logger/logger.go
584
logger/logger.go
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -19,81 +21,561 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
loggerINFO = "INFO"
|
||||
loggerWarn = "WARN"
|
||||
loggerError = "ERR"
|
||||
loggerDebug = "DEBUG"
|
||||
// 日志轮转配置
|
||||
defaultMaxLogSize = 100 * 1024 * 1024 // 100MB
|
||||
defaultMaxLogFiles = 7 // 保留最近7个日志文件
|
||||
defaultLogFileName = "newapi.log"
|
||||
checkRotateInterval = 1000 // 每1000次写入检查一次是否需要轮转
|
||||
)
|
||||
|
||||
const maxLogCount = 1000000
|
||||
var (
|
||||
logMutex sync.RWMutex
|
||||
rotateCheckLock sync.Mutex
|
||||
defaultLogger *slog.Logger
|
||||
logFile *os.File
|
||||
logFilePath string
|
||||
logDirPath string
|
||||
writeCount int64
|
||||
maxLogSize int64 = defaultMaxLogSize
|
||||
maxLogFiles int = defaultMaxLogFiles
|
||||
useJSONFormat bool
|
||||
)
|
||||
|
||||
var logCount int
|
||||
var setupLogLock sync.Mutex
|
||||
var setupLogWorking bool
|
||||
func init() {
|
||||
// Initialize with a text handler to stdout
|
||||
handler := createHandler(os.Stdout)
|
||||
defaultLogger = slog.New(handler)
|
||||
slog.SetDefault(defaultLogger)
|
||||
}
|
||||
|
||||
// SetupLogger 初始化日志系统
|
||||
func SetupLogger() {
|
||||
defer func() {
|
||||
setupLogWorking = false
|
||||
}()
|
||||
if *common.LogDir != "" {
|
||||
ok := setupLogLock.TryLock()
|
||||
if !ok {
|
||||
log.Println("setup log is already working")
|
||||
return
|
||||
logMutex.Lock()
|
||||
defer logMutex.Unlock()
|
||||
|
||||
// 读取环境变量配置
|
||||
if maxSize := os.Getenv("LOG_MAX_SIZE_MB"); maxSize != "" {
|
||||
if size, err := fmt.Sscanf(maxSize, "%d", &maxLogSize); err == nil && size > 0 {
|
||||
maxLogSize = maxLogSize * 1024 * 1024 // 转换为字节
|
||||
}
|
||||
defer func() {
|
||||
setupLogLock.Unlock()
|
||||
}()
|
||||
logPath := filepath.Join(*common.LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405")))
|
||||
fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Fatal("failed to open log file")
|
||||
}
|
||||
if maxFiles := os.Getenv("LOG_MAX_FILES"); maxFiles != "" {
|
||||
fmt.Sscanf(maxFiles, "%d", &maxLogFiles)
|
||||
}
|
||||
if os.Getenv("LOG_FORMAT") == "json" {
|
||||
useJSONFormat = true
|
||||
}
|
||||
|
||||
if *common.LogDir == "" {
|
||||
// 如果没有配置日志目录,只输出到标准输出
|
||||
handler := createHandler(os.Stdout)
|
||||
defaultLogger = slog.New(handler)
|
||||
slog.SetDefault(defaultLogger)
|
||||
return
|
||||
}
|
||||
|
||||
logDirPath = *common.LogDir
|
||||
logFilePath = filepath.Join(logDirPath, defaultLogFileName)
|
||||
|
||||
// 检查日志文件是否需要按日期轮转(仅在启动时检查)
|
||||
if err := checkAndRotateOnStartup(); err != nil {
|
||||
slog.Error("failed to check log file on startup", "error", err)
|
||||
}
|
||||
|
||||
// 打开或创建日志文件
|
||||
if err := openLogFile(); err != nil {
|
||||
slog.Error("failed to open log file", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建多路输出(控制台 + 文件)
|
||||
multiWriter := io.MultiWriter(os.Stdout, logFile)
|
||||
|
||||
// 更新 gin 的默认输出
|
||||
gin.DefaultWriter = multiWriter
|
||||
gin.DefaultErrorWriter = multiWriter
|
||||
|
||||
// 更新 slog handler
|
||||
handler := createHandler(multiWriter)
|
||||
defaultLogger = slog.New(handler)
|
||||
slog.SetDefault(defaultLogger)
|
||||
|
||||
slog.Info("logger initialized",
|
||||
"log_dir", logDirPath,
|
||||
"max_size_mb", maxLogSize/(1024*1024),
|
||||
"max_files", maxLogFiles,
|
||||
"format", getLogFormat())
|
||||
}
|
||||
|
||||
// createHandler 创建日志处理器
|
||||
func createHandler(w io.Writer) slog.Handler {
|
||||
if useJSONFormat {
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: getLogLevel(),
|
||||
}
|
||||
gin.DefaultWriter = io.MultiWriter(os.Stdout, fd)
|
||||
gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd)
|
||||
return slog.NewJSONHandler(w, opts)
|
||||
}
|
||||
return NewReadableTextHandler(w, getLogLevel())
|
||||
}
|
||||
|
||||
// ReadableTextHandler 自定义的易读文本处理器
|
||||
type ReadableTextHandler struct {
|
||||
w io.Writer
|
||||
level slog.Level
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewReadableTextHandler 创建一个新的易读文本处理器
|
||||
func NewReadableTextHandler(w io.Writer, level slog.Level) *ReadableTextHandler {
|
||||
return &ReadableTextHandler{
|
||||
w: w,
|
||||
level: level,
|
||||
}
|
||||
}
|
||||
|
||||
func LogInfo(ctx context.Context, msg string) {
|
||||
logHelper(ctx, loggerINFO, msg)
|
||||
// Enabled 检查是否启用该级别
|
||||
func (h *ReadableTextHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level
|
||||
}
|
||||
|
||||
func LogWarn(ctx context.Context, msg string) {
|
||||
logHelper(ctx, loggerWarn, msg)
|
||||
// Handle 处理日志记录
|
||||
func (h *ReadableTextHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// 格式: [LEVEL] YYYY/MM/DD - HH:mm:ss | request_id | message | key=value ...
|
||||
buf := make([]byte, 0, 256)
|
||||
|
||||
// 日志级别
|
||||
level := r.Level.String()
|
||||
switch r.Level {
|
||||
case slog.LevelDebug:
|
||||
level = "DEBUG"
|
||||
case slog.LevelInfo:
|
||||
level = "INFO"
|
||||
case slog.LevelWarn:
|
||||
level = "WARN"
|
||||
case slog.LevelError:
|
||||
level = "ERROR"
|
||||
}
|
||||
buf = append(buf, '[')
|
||||
buf = append(buf, level...)
|
||||
buf = append(buf, "] "...)
|
||||
|
||||
// 时间
|
||||
buf = append(buf, r.Time.Format("2006/01/02 - 15:04:05")...)
|
||||
buf = append(buf, " | "...)
|
||||
|
||||
// 提取 request_id 和 component
|
||||
var requestID, component string
|
||||
otherAttrs := make([]slog.Attr, 0)
|
||||
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
switch a.Key {
|
||||
case "request_id":
|
||||
requestID = a.Value.String()
|
||||
case "component":
|
||||
component = a.Value.String()
|
||||
default:
|
||||
otherAttrs = append(otherAttrs, a)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 输出 request_id 或 component
|
||||
if requestID != "" {
|
||||
buf = append(buf, requestID...)
|
||||
buf = append(buf, " | "...)
|
||||
} else if component != "" {
|
||||
buf = append(buf, component...)
|
||||
buf = append(buf, " | "...)
|
||||
}
|
||||
|
||||
// 消息
|
||||
buf = append(buf, r.Message...)
|
||||
|
||||
// 其他属性
|
||||
if len(otherAttrs) > 0 {
|
||||
buf = append(buf, " | "...)
|
||||
for i, a := range otherAttrs {
|
||||
if i > 0 {
|
||||
buf = append(buf, ", "...)
|
||||
}
|
||||
buf = append(buf, a.Key...)
|
||||
buf = append(buf, '=')
|
||||
buf = appendValue(buf, a.Value)
|
||||
}
|
||||
}
|
||||
|
||||
buf = append(buf, '\n')
|
||||
_, err := h.w.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func LogError(ctx context.Context, msg string) {
|
||||
logHelper(ctx, loggerError, msg)
|
||||
// appendValue 追加值到缓冲区
|
||||
func appendValue(buf []byte, v slog.Value) []byte {
|
||||
switch v.Kind() {
|
||||
case slog.KindString:
|
||||
s := v.String()
|
||||
// 如果字符串包含空格或特殊字符,加引号
|
||||
if strings.ContainsAny(s, " \t\n\r,=") {
|
||||
buf = append(buf, '"')
|
||||
buf = append(buf, s...)
|
||||
buf = append(buf, '"')
|
||||
} else {
|
||||
buf = append(buf, s...)
|
||||
}
|
||||
case slog.KindInt64:
|
||||
buf = append(buf, fmt.Sprintf("%d", v.Int64())...)
|
||||
case slog.KindUint64:
|
||||
buf = append(buf, fmt.Sprintf("%d", v.Uint64())...)
|
||||
case slog.KindFloat64:
|
||||
buf = append(buf, fmt.Sprintf("%g", v.Float64())...)
|
||||
case slog.KindBool:
|
||||
buf = append(buf, fmt.Sprintf("%t", v.Bool())...)
|
||||
case slog.KindDuration:
|
||||
buf = append(buf, v.Duration().String()...)
|
||||
case slog.KindTime:
|
||||
buf = append(buf, v.Time().Format("2006-01-02 15:04:05")...)
|
||||
default:
|
||||
buf = append(buf, fmt.Sprintf("%v", v.Any())...)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func LogDebug(ctx context.Context, msg string, args ...any) {
|
||||
// WithAttrs 返回一个新的处理器,包含指定的属性
|
||||
func (h *ReadableTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
// 简化实现:不支持 With
|
||||
return h
|
||||
}
|
||||
|
||||
// WithGroup 返回一个新的处理器,使用指定的组
|
||||
func (h *ReadableTextHandler) WithGroup(name string) slog.Handler {
|
||||
// 简化实现:不支持组
|
||||
return h
|
||||
}
|
||||
|
||||
// checkAndRotateOnStartup 启动时检查日志文件是否需要按日期轮转
|
||||
func checkAndRotateOnStartup() error {
|
||||
// 检查日志文件是否存在
|
||||
fileInfo, err := os.Stat(logFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// 文件不存在,不需要轮转
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to stat log file: %w", err)
|
||||
}
|
||||
|
||||
// 获取文件的修改时间
|
||||
modTime := fileInfo.ModTime()
|
||||
modDate := modTime.Format("2006-01-02")
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// 如果文件的日期和今天不同,进行轮转
|
||||
if modDate != today {
|
||||
// 生成归档文件名(使用文件的修改日期)
|
||||
timestamp := modTime.Format("2006-01-02-150405")
|
||||
archivePath := filepath.Join(logDirPath, fmt.Sprintf("newapi.%s.log", timestamp))
|
||||
|
||||
// 重命名日志文件
|
||||
if err := os.Rename(logFilePath, archivePath); err != nil {
|
||||
return fmt.Errorf("failed to archive old log file: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("rotated old log file on startup",
|
||||
"archive", archivePath,
|
||||
"reason", "date changed")
|
||||
|
||||
// 清理旧的日志文件
|
||||
gopool.Go(func() {
|
||||
cleanOldLogFiles()
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// openLogFile 打开日志文件
|
||||
func openLogFile() error {
|
||||
// 关闭旧的日志文件
|
||||
if logFile != nil {
|
||||
logFile.Close()
|
||||
}
|
||||
|
||||
// 打开新的日志文件
|
||||
fd, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
logFile = fd
|
||||
writeCount = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// rotateLogFile 轮转日志文件
|
||||
func rotateLogFile() error {
|
||||
if logFile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rotateCheckLock.Lock()
|
||||
defer rotateCheckLock.Unlock()
|
||||
|
||||
// 获取当前日志文件信息
|
||||
fileInfo, err := logFile.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat log file: %w", err)
|
||||
}
|
||||
|
||||
// 检查文件大小是否需要轮转
|
||||
if fileInfo.Size() < maxLogSize {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 关闭当前日志文件
|
||||
logFile.Close()
|
||||
|
||||
// 生成归档文件名
|
||||
timestamp := time.Now().Format("2006-01-02-150405")
|
||||
archivePath := filepath.Join(logDirPath, fmt.Sprintf("newapi.%s.log", timestamp))
|
||||
|
||||
// 重命名当前日志文件为归档文件
|
||||
if err := os.Rename(logFilePath, archivePath); err != nil {
|
||||
// 如果重命名失败,尝试复制
|
||||
if copyErr := copyFile(logFilePath, archivePath); copyErr != nil {
|
||||
return fmt.Errorf("failed to archive log file: %w", err)
|
||||
}
|
||||
os.Truncate(logFilePath, 0)
|
||||
}
|
||||
|
||||
// 清理旧的日志文件
|
||||
gopool.Go(func() {
|
||||
cleanOldLogFiles()
|
||||
})
|
||||
|
||||
// 打开新的日志文件
|
||||
if err := openLogFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重新设置日志输出
|
||||
multiWriter := io.MultiWriter(os.Stdout, logFile)
|
||||
gin.DefaultWriter = multiWriter
|
||||
gin.DefaultErrorWriter = multiWriter
|
||||
|
||||
handler := createHandler(multiWriter)
|
||||
logMutex.Lock()
|
||||
defaultLogger = slog.New(handler)
|
||||
slog.SetDefault(defaultLogger)
|
||||
logMutex.Unlock()
|
||||
|
||||
slog.Info("log file rotated",
|
||||
"reason", "size limit reached",
|
||||
"archive", archivePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanOldLogFiles 清理旧的日志文件
|
||||
func cleanOldLogFiles() {
|
||||
if logDirPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(logDirPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to read log directory", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 收集所有归档日志文件
|
||||
var logFiles []os.DirEntry
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && strings.HasPrefix(file.Name(), "newapi.") &&
|
||||
strings.HasSuffix(file.Name(), ".log") &&
|
||||
file.Name() != defaultLogFileName {
|
||||
logFiles = append(logFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果归档文件数量超过限制,删除最旧的
|
||||
if len(logFiles) > maxLogFiles {
|
||||
// 按名称排序(文件名包含时间戳)
|
||||
sort.Slice(logFiles, func(i, j int) bool {
|
||||
return logFiles[i].Name() < logFiles[j].Name()
|
||||
})
|
||||
|
||||
// 删除最旧的文件
|
||||
deleteCount := len(logFiles) - maxLogFiles
|
||||
for i := 0; i < deleteCount; i++ {
|
||||
filePath := filepath.Join(logDirPath, logFiles[i].Name())
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
slog.Error("failed to remove old log file",
|
||||
"file", filePath,
|
||||
"error", err)
|
||||
} else {
|
||||
slog.Info("removed old log file", "file", logFiles[i].Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyFile 复制文件
|
||||
func copyFile(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
return err
|
||||
}
|
||||
|
||||
// getLogLevel 获取日志级别
|
||||
func getLogLevel() slog.Level {
|
||||
// 支持环境变量配置
|
||||
if level := os.Getenv("LOG_LEVEL"); level != "" {
|
||||
switch strings.ToUpper(level) {
|
||||
case "DEBUG":
|
||||
return slog.LevelDebug
|
||||
case "INFO":
|
||||
return slog.LevelInfo
|
||||
case "WARN", "WARNING":
|
||||
return slog.LevelWarn
|
||||
case "ERROR":
|
||||
return slog.LevelError
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
if len(args) > 0 {
|
||||
msg = fmt.Sprintf(msg, args...)
|
||||
}
|
||||
logHelper(ctx, loggerDebug, msg)
|
||||
return slog.LevelDebug
|
||||
}
|
||||
return slog.LevelInfo
|
||||
}
|
||||
|
||||
// getLogFormat 获取日志格式
|
||||
func getLogFormat() string {
|
||||
if useJSONFormat {
|
||||
return "json"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
|
||||
// checkAndRotateLog 检查并轮转日志
|
||||
func checkAndRotateLog() {
|
||||
if logFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
writeCount++
|
||||
if writeCount%checkRotateInterval == 0 {
|
||||
gopool.Go(func() {
|
||||
if err := rotateLogFile(); err != nil {
|
||||
slog.Error("failed to rotate log file", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func logHelper(ctx context.Context, level string, msg string) {
|
||||
writer := gin.DefaultErrorWriter
|
||||
if level == loggerINFO {
|
||||
writer = gin.DefaultWriter
|
||||
// LogInfo 记录信息级别日志
|
||||
func LogInfo(ctx context.Context, msg string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
id := getRequestID(ctx)
|
||||
logMutex.RLock()
|
||||
logger := defaultLogger
|
||||
logMutex.RUnlock()
|
||||
logger.InfoContext(ctx, msg, "request_id", id)
|
||||
checkAndRotateLog()
|
||||
}
|
||||
|
||||
// LogWarn 记录警告级别日志
|
||||
func LogWarn(ctx context.Context, msg string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
id := getRequestID(ctx)
|
||||
logMutex.RLock()
|
||||
logger := defaultLogger
|
||||
logMutex.RUnlock()
|
||||
logger.WarnContext(ctx, msg, "request_id", id)
|
||||
checkAndRotateLog()
|
||||
}
|
||||
|
||||
// LogError 记录错误级别日志
|
||||
func LogError(ctx context.Context, msg string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
id := getRequestID(ctx)
|
||||
logMutex.RLock()
|
||||
logger := defaultLogger
|
||||
logMutex.RUnlock()
|
||||
logger.ErrorContext(ctx, msg, "request_id", id)
|
||||
checkAndRotateLog()
|
||||
}
|
||||
|
||||
// LogSystemInfo 记录系统信息
|
||||
func LogSystemInfo(msg string) {
|
||||
logMutex.RLock()
|
||||
logger := defaultLogger
|
||||
logMutex.RUnlock()
|
||||
logger.Info(msg, "request_id", "SYSTEM")
|
||||
checkAndRotateLog()
|
||||
}
|
||||
|
||||
// LogSystemError 记录系统错误
|
||||
func LogSystemError(msg string) {
|
||||
logMutex.RLock()
|
||||
logger := defaultLogger
|
||||
logMutex.RUnlock()
|
||||
logger.Error(msg, "request_id", "SYSTEM")
|
||||
checkAndRotateLog()
|
||||
}
|
||||
|
||||
// LogDebug 记录调试级别日志
|
||||
func LogDebug(ctx context.Context, msg string, args ...any) {
|
||||
if !common.DebugEnabled && getLogLevel() > slog.LevelDebug {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
id := getRequestID(ctx)
|
||||
if len(args) > 0 {
|
||||
msg = fmt.Sprintf(msg, args...)
|
||||
}
|
||||
logMutex.RLock()
|
||||
logger := defaultLogger
|
||||
logMutex.RUnlock()
|
||||
logger.DebugContext(ctx, msg, "request_id", id)
|
||||
checkAndRotateLog()
|
||||
}
|
||||
|
||||
// getRequestID 从上下文中获取请求ID
|
||||
func getRequestID(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return "SYSTEM"
|
||||
}
|
||||
id := ctx.Value(common.RequestIdKey)
|
||||
if id == nil {
|
||||
id = "SYSTEM"
|
||||
return "SYSTEM"
|
||||
}
|
||||
now := time.Now()
|
||||
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)
|
||||
logCount++ // we don't need accurate count, so no lock here
|
||||
if logCount > maxLogCount && !setupLogWorking {
|
||||
logCount = 0
|
||||
setupLogWorking = true
|
||||
gopool.Go(func() {
|
||||
SetupLogger()
|
||||
})
|
||||
if strID, ok := id.(string); ok {
|
||||
return strID
|
||||
}
|
||||
return "SYSTEM"
|
||||
}
|
||||
|
||||
func LogQuota(quota int) string {
|
||||
|
||||
8
main.go
8
main.go
@@ -4,7 +4,7 @@ import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -118,7 +118,9 @@ func main() {
|
||||
|
||||
if os.Getenv("ENABLE_PPROF") == "true" {
|
||||
gopool.Go(func() {
|
||||
log.Println(http.ListenAndServe("0.0.0.0:8005", nil))
|
||||
if err := http.ListenAndServe("0.0.0.0:8005", nil); err != nil {
|
||||
slog.Error("pprof server failed", "error", err)
|
||||
}
|
||||
})
|
||||
go common.Monitor()
|
||||
common.SysLog("pprof enabled")
|
||||
@@ -127,7 +129,7 @@ func main() {
|
||||
// Initialize HTTP server
|
||||
server := gin.New()
|
||||
server.Use(gin.CustomRecovery(func(c *gin.Context, err any) {
|
||||
common.SysLog(fmt.Sprintf("panic detected: %v", err))
|
||||
logger.LogSystemError(fmt.Sprintf("panic detected: %v", err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
|
||||
|
||||
@@ -102,7 +102,10 @@ func GlobalAPIRateLimit() func(c *gin.Context) {
|
||||
}
|
||||
|
||||
func CriticalRateLimit() func(c *gin.Context) {
|
||||
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
|
||||
if common.CriticalRateLimitEnable {
|
||||
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
|
||||
}
|
||||
return defNext
|
||||
}
|
||||
|
||||
func DownloadRateLimit() func(c *gin.Context) {
|
||||
|
||||
@@ -688,7 +688,7 @@ func DisableChannelByTag(tag string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint) error {
|
||||
func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint, paramOverride *string, headerOverride *string) error {
|
||||
updateData := Channel{}
|
||||
shouldReCreateAbilities := false
|
||||
updatedTag := tag
|
||||
@@ -714,6 +714,12 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
|
||||
if weight != nil {
|
||||
updateData.Weight = weight
|
||||
}
|
||||
if paramOverride != nil {
|
||||
updateData.ParamOverride = paramOverride
|
||||
}
|
||||
if headerOverride != nil {
|
||||
updateData.HeaderOverride = headerOverride
|
||||
}
|
||||
|
||||
err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
@@ -155,8 +156,51 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
|
||||
return bytes.NewReader(bodyBytes), nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq) (*AliVideoRequest, error) {
|
||||
otherRatios := map[string]map[string]float64{
|
||||
var (
|
||||
size480p = []string{
|
||||
"832*480",
|
||||
"480*832",
|
||||
"624*624",
|
||||
}
|
||||
size720p = []string{
|
||||
"1280*720",
|
||||
"720*1280",
|
||||
"960*960",
|
||||
"1088*832",
|
||||
"832*1088",
|
||||
}
|
||||
size1080p = []string{
|
||||
"1920*1080",
|
||||
"1080*1920",
|
||||
"1440*1440",
|
||||
"1632*1248",
|
||||
"1248*1632",
|
||||
}
|
||||
)
|
||||
|
||||
func sizeToResolution(size string) (string, error) {
|
||||
if lo.Contains(size480p, size) {
|
||||
return "480P", nil
|
||||
} else if lo.Contains(size720p, size) {
|
||||
return "720P", nil
|
||||
} else if lo.Contains(size1080p, size) {
|
||||
return "1080P", nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid size: %s", size)
|
||||
}
|
||||
|
||||
func ProcessAliOtherRatios(aliReq *AliVideoRequest) (map[string]float64, error) {
|
||||
otherRatios := make(map[string]float64)
|
||||
aliRatios := map[string]map[string]float64{
|
||||
"wan2.5-t2v-preview": {
|
||||
"480P": 1,
|
||||
"720P": 2,
|
||||
"1080P": 1 / 0.3,
|
||||
},
|
||||
"wan2.2-t2v-plus": {
|
||||
"480P": 1,
|
||||
"1080P": 0.7 / 0.14,
|
||||
},
|
||||
"wan2.5-i2v-preview": {
|
||||
"480P": 1,
|
||||
"720P": 2,
|
||||
@@ -180,6 +224,30 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay
|
||||
"720P": 0.9 / 0.5,
|
||||
},
|
||||
}
|
||||
var resolution string
|
||||
|
||||
// size match
|
||||
if aliReq.Parameters.Size != "" {
|
||||
toResolution, err := sizeToResolution(aliReq.Parameters.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolution = toResolution
|
||||
} else {
|
||||
resolution = strings.ToUpper(aliReq.Parameters.Resolution)
|
||||
if !strings.HasSuffix(resolution, "P") {
|
||||
resolution = resolution + "P"
|
||||
}
|
||||
}
|
||||
if otherRatio, ok := aliRatios[aliReq.Model]; ok {
|
||||
if ratio, ok := otherRatio[resolution]; ok {
|
||||
otherRatios[fmt.Sprintf("resolution-%s", resolution)] = ratio
|
||||
}
|
||||
}
|
||||
return otherRatios, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq) (*AliVideoRequest, error) {
|
||||
aliReq := &AliVideoRequest{
|
||||
Model: req.Model,
|
||||
Input: AliVideoInput{
|
||||
@@ -194,22 +262,40 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay
|
||||
|
||||
// 处理分辨率映射
|
||||
if req.Size != "" {
|
||||
resolution := strings.ToUpper(req.Size)
|
||||
// 支持 480p, 720p, 1080p 或 480P, 720P, 1080P
|
||||
if !strings.HasSuffix(resolution, "P") {
|
||||
resolution = resolution + "P"
|
||||
// text to video size must be contained *
|
||||
if strings.Contains(req.Model, "t2v") && !strings.Contains(req.Size, "*") {
|
||||
return nil, fmt.Errorf("invalid size: %s, example: %s", req.Size, "1920*1080")
|
||||
}
|
||||
if strings.Contains(req.Size, "*") {
|
||||
aliReq.Parameters.Size = req.Size
|
||||
} else {
|
||||
resolution := strings.ToUpper(req.Size)
|
||||
// 支持 480p, 720p, 1080p 或 480P, 720P, 1080P
|
||||
if !strings.HasSuffix(resolution, "P") {
|
||||
resolution = resolution + "P"
|
||||
}
|
||||
aliReq.Parameters.Resolution = resolution
|
||||
}
|
||||
aliReq.Parameters.Resolution = resolution
|
||||
} else {
|
||||
// 根据模型设置默认分辨率
|
||||
if strings.HasPrefix(req.Model, "wan2.5") {
|
||||
aliReq.Parameters.Resolution = "1080P"
|
||||
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-flash") {
|
||||
aliReq.Parameters.Resolution = "720P"
|
||||
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-plus") {
|
||||
aliReq.Parameters.Resolution = "1080P"
|
||||
if strings.Contains(req.Model, "t2v") { // image to video
|
||||
if strings.HasPrefix(req.Model, "wan2.5") {
|
||||
aliReq.Parameters.Size = "1920*1080"
|
||||
} else if strings.HasPrefix(req.Model, "wan2.2") {
|
||||
aliReq.Parameters.Size = "1920*1080"
|
||||
} else {
|
||||
aliReq.Parameters.Size = "1280*720"
|
||||
}
|
||||
} else {
|
||||
aliReq.Parameters.Resolution = "720P"
|
||||
if strings.HasPrefix(req.Model, "wan2.5") {
|
||||
aliReq.Parameters.Resolution = "1080P"
|
||||
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-flash") {
|
||||
aliReq.Parameters.Resolution = "720P"
|
||||
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-plus") {
|
||||
aliReq.Parameters.Resolution = "1080P"
|
||||
} else {
|
||||
aliReq.Parameters.Resolution = "720P"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,13 +333,13 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay
|
||||
"seconds": float64(aliReq.Parameters.Duration),
|
||||
}
|
||||
|
||||
if otherRatio, ok := otherRatios[req.Model]; ok {
|
||||
if ratio, ok := otherRatio[aliReq.Parameters.Resolution]; ok {
|
||||
info.PriceData.OtherRatios[fmt.Sprintf("resolution-%s", aliReq.Parameters.Resolution)] = ratio
|
||||
}
|
||||
ratios, err := ProcessAliOtherRatios(aliReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for s, f := range ratios {
|
||||
info.PriceData.OtherRatios[s] = f
|
||||
}
|
||||
|
||||
// println(fmt.Sprintf("other ratios: %v", info.PriceData.OtherRatios))
|
||||
|
||||
return aliReq, nil
|
||||
}
|
||||
|
||||
@@ -411,10 +411,10 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
|
||||
r.ReqKey = "jimeng_ti2v_v30_pro"
|
||||
} else if len(req.Images) > 1 {
|
||||
// 多张图片:首尾帧生成
|
||||
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1)
|
||||
r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1), "p")
|
||||
} else if len(req.Images) == 1 {
|
||||
// 单张图片:图生视频
|
||||
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1)
|
||||
r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1), "p")
|
||||
} else {
|
||||
// 无图片:文生视频
|
||||
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
IconBookmark,
|
||||
IconUser,
|
||||
IconCode,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { getChannelModels } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -69,6 +70,8 @@ const EditTagModal = (props) => {
|
||||
model_mapping: null,
|
||||
groups: [],
|
||||
models: [],
|
||||
param_override: null,
|
||||
header_override: null,
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const formApiRef = useRef(null);
|
||||
@@ -190,12 +193,48 @@ const EditTagModal = (props) => {
|
||||
if (formVals.models && formVals.models.length > 0) {
|
||||
data.models = formVals.models.join(',');
|
||||
}
|
||||
if (
|
||||
formVals.param_override !== undefined &&
|
||||
formVals.param_override !== null
|
||||
) {
|
||||
if (typeof formVals.param_override !== 'string') {
|
||||
showInfo('参数覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const trimmedParamOverride = formVals.param_override.trim();
|
||||
if (trimmedParamOverride !== '' && !verifyJSON(trimmedParamOverride)) {
|
||||
showInfo('参数覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.param_override = trimmedParamOverride;
|
||||
}
|
||||
if (
|
||||
formVals.header_override !== undefined &&
|
||||
formVals.header_override !== null
|
||||
) {
|
||||
if (typeof formVals.header_override !== 'string') {
|
||||
showInfo('请求头覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const trimmedHeaderOverride = formVals.header_override.trim();
|
||||
if (trimmedHeaderOverride !== '' && !verifyJSON(trimmedHeaderOverride)) {
|
||||
showInfo('请求头覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.header_override = trimmedHeaderOverride;
|
||||
}
|
||||
data.new_tag = formVals.new_tag;
|
||||
if (
|
||||
data.model_mapping === undefined &&
|
||||
data.groups === undefined &&
|
||||
data.models === undefined &&
|
||||
data.new_tag === undefined
|
||||
data.new_tag === undefined &&
|
||||
data.param_override === undefined &&
|
||||
data.header_override === undefined
|
||||
) {
|
||||
showWarning('没有任何修改!');
|
||||
setLoading(false);
|
||||
@@ -491,6 +530,157 @@ const EditTagModal = (props) => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Advanced Settings */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
|
||||
<IconSetting size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('高级设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('渠道的高级配置选项')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Form.TextArea
|
||||
field='param_override'
|
||||
label={t('参数覆盖')}
|
||||
placeholder={
|
||||
t(
|
||||
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
|
||||
) +
|
||||
'\n' +
|
||||
t('旧格式(直接覆盖):') +
|
||||
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
||||
'\n\n' +
|
||||
t('新格式(支持条件判断与json自定义):') +
|
||||
'\n{\n "operations": [\n {\n "path": "temperature",\n "mode": "set",\n "value": 0.7,\n "conditions": [\n {\n "path": "model",\n "mode": "prefix",\n "value": "gpt"\n }\n ]\n }\n ]\n}'
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
handleInputChange('param_override', value)
|
||||
}
|
||||
extraText={
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify({ temperature: 0 }, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('旧格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
path: 'temperature',
|
||||
mode: 'set',
|
||||
value: 0.7,
|
||||
conditions: [
|
||||
{
|
||||
path: 'model',
|
||||
mode: 'prefix',
|
||||
value: 'gpt',
|
||||
},
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('新格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange('param_override', null)
|
||||
}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='header_override'
|
||||
label={t('请求头覆盖')}
|
||||
placeholder={
|
||||
t('此项可选,用于覆盖请求头参数') +
|
||||
'\n' +
|
||||
t('格式示例:') +
|
||||
'\n{\n "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",\n "Authorization": "Bearer {api_key}"\n}'
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
handleInputChange('header_override', value)
|
||||
}
|
||||
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(
|
||||
{
|
||||
'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}',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange('header_override', null)
|
||||
}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('支持变量:')}
|
||||
</Text>
|
||||
<div className='text-xs text-tertiary ml-2'>
|
||||
<div>
|
||||
{t('渠道密钥')}: {'{api_key}'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* Header: Group Settings */}
|
||||
<div className='flex items-center mb-2'>
|
||||
|
||||
@@ -66,9 +66,9 @@ const EditTokenModal = (props) => {
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
remain_quota: 500000,
|
||||
remain_quota: 0,
|
||||
expired_time: -1,
|
||||
unlimited_quota: false,
|
||||
unlimited_quota: true,
|
||||
model_limits_enabled: false,
|
||||
model_limits: [],
|
||||
allow_ips: '',
|
||||
|
||||
56
web/src/helpers/base64.js
Normal file
56
web/src/helpers/base64.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
const toBinaryString = (text) => {
|
||||
if (typeof TextEncoder !== 'undefined') {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let binary = '';
|
||||
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
|
||||
return binary;
|
||||
}
|
||||
|
||||
return encodeURIComponent(text).replace(/%([0-9A-F]{2})/g, (_, hex) =>
|
||||
String.fromCharCode(parseInt(hex, 16)),
|
||||
);
|
||||
};
|
||||
|
||||
export const encodeToBase64 = (value) => {
|
||||
const input = value == null ? '' : String(value);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(input, 'utf-8').toString('base64');
|
||||
}
|
||||
if (
|
||||
typeof globalThis !== 'undefined' &&
|
||||
typeof globalThis.btoa === 'function'
|
||||
) {
|
||||
return globalThis.btoa(toBinaryString(input));
|
||||
}
|
||||
throw new Error(
|
||||
'Base64 encoding is unavailable in the current environment',
|
||||
);
|
||||
}
|
||||
|
||||
return window.btoa(toBinaryString(input));
|
||||
};
|
||||
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
export * from './history';
|
||||
export * from './auth';
|
||||
export * from './utils';
|
||||
export * from './base64';
|
||||
export * from './api';
|
||||
export * from './render';
|
||||
export * from './log';
|
||||
|
||||
@@ -20,7 +20,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { API, copy, showError, showSuccess } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showSuccess,
|
||||
encodeToBase64,
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
@@ -136,7 +142,7 @@ export const useTokensData = (openFluentNotification) => {
|
||||
apiKey: 'sk-' + record.key,
|
||||
};
|
||||
let encodedConfig = encodeURIComponent(
|
||||
btoa(JSON.stringify(cherryConfig)),
|
||||
encodeToBase64(JSON.stringify(cherryConfig)),
|
||||
);
|
||||
url = url.replaceAll('{cherryConfig}', encodedConfig);
|
||||
} else {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
createLoadingAssistantMessage,
|
||||
getTextContent,
|
||||
buildApiPayload,
|
||||
encodeToBase64,
|
||||
} from '../../helpers';
|
||||
|
||||
// Components
|
||||
@@ -72,7 +73,7 @@ const generateAvatarDataUrl = (username) => {
|
||||
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="16" fill="#ffffff" font-family="sans-serif">${firstLetter}</text>
|
||||
</svg>
|
||||
`;
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
return `data:image/svg+xml;base64,${encodeToBase64(svg)}`;
|
||||
};
|
||||
|
||||
const Playground = () => {
|
||||
|
||||
Reference in New Issue
Block a user