├── web ├── favicon.ico ├── favicon-192.png ├── favicon-512.png ├── apple-touch-icon.png ├── assets │ ├── fonts │ │ ├── inter-400.ttf │ │ ├── inter-500.ttf │ │ ├── inter-600.ttf │ │ └── inter-700.ttf │ ├── css │ │ ├── inter.css │ │ ├── logs.css │ │ ├── tokens.css │ │ └── channels.css │ └── js │ │ ├── date-range-selector.js │ │ ├── template-engine.js │ │ ├── channels-import-export.js │ │ ├── channels-data.js │ │ ├── login.js │ │ ├── channels-init.js │ │ ├── channels-state.js │ │ └── settings.js ├── manifest.json ├── favicon.svg └── settings.html ├── internal ├── app │ ├── socket_unix.go │ ├── socket_windows.go │ ├── admin_settings_response_test.go │ ├── admin_cooldown.go │ ├── proxy_gemini.go │ ├── request_context.go │ ├── admin_response_contract_test.go │ ├── token_stats_shutdown_test.go │ ├── proxy_forward_soft_error_test.go │ ├── proxy_stream.go │ ├── health_cache.go │ ├── admin_testing_test.go │ ├── admin_auth_tokens_test.go │ ├── proxy_util_test.go │ ├── config_service.go │ ├── static.go │ ├── key_selector.go │ └── proxy_handler_test.go ├── util │ ├── serialize.go │ ├── apikeys.go │ ├── apikeys_test.go │ ├── serialize_test.go │ ├── time_additional_test.go │ ├── time_bench_test.go │ ├── channel_types_bench_test.go │ ├── time.go │ ├── channel_types.go │ ├── classifier_1308_test.go │ └── rate_limiter.go ├── storage │ ├── sqlite │ │ └── test_store_helpers_test.go │ ├── schema │ │ ├── builder_test.go │ │ └── builder.go │ ├── sql │ │ ├── metrics_finalize.go │ │ ├── admin_sessions.go │ │ ├── system_settings.go │ │ ├── metrics_aggregate_rows.go │ │ ├── store_impl.go │ │ ├── transaction_deadline_test.go │ │ └── auth_token_stats.go │ ├── health_success_rate_test.go │ └── cache_metrics_test.go ├── version │ ├── version.go │ └── banner.go ├── model │ ├── health.go │ ├── system_setting.go │ ├── config.go │ ├── log.go │ ├── auth_token.go │ └── stats.go ├── testutil │ └── types.go ├── config │ ├── defaults.go │ └── defaults_test.go └── validator │ └── validator.go ├── .dockerignore ├── .gitignore ├── docker-compose.yml ├── com.ccload.service.plist.template ├── test └── integration │ ├── setup_test.go │ └── csv_import_export_test.go ├── docker-compose.build.yml ├── .env.docker.example ├── .env.example ├── go.mod ├── CLAUDE.md ├── .github └── workflows │ └── docker.yml └── Dockerfile /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caidaoli/ccLoad/HEAD/web/favicon.ico -------------------------------------------------------------------------------- /web/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caidaoli/ccLoad/HEAD/web/favicon-192.png -------------------------------------------------------------------------------- /web/favicon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caidaoli/ccLoad/HEAD/web/favicon-512.png -------------------------------------------------------------------------------- /web/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caidaoli/ccLoad/HEAD/web/apple-touch-icon.png -------------------------------------------------------------------------------- /web/assets/fonts/inter-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caidaoli/ccLoad/HEAD/web/assets/fonts/inter-400.ttf -------------------------------------------------------------------------------- /web/assets/fonts/inter-500.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caidaoli/ccLoad/HEAD/web/assets/fonts/inter-500.ttf -------------------------------------------------------------------------------- /web/assets/fonts/inter-600.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caidaoli/ccLoad/HEAD/web/assets/fonts/inter-600.ttf -------------------------------------------------------------------------------- /web/assets/fonts/inter-700.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caidaoli/ccLoad/HEAD/web/assets/fonts/inter-700.ttf -------------------------------------------------------------------------------- /internal/app/socket_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package app 4 | 5 | import "syscall" 6 | 7 | // setTCPNoDelay 在 Unix 系统上设置 TCP_NODELAY 8 | func setTCPNoDelay(fd uintptr) error { 9 | return syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, syscall.TCP_NODELAY, 1) 10 | } 11 | -------------------------------------------------------------------------------- /internal/app/socket_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package app 4 | 5 | import "syscall" 6 | 7 | // setTCPNoDelay 在 Windows 上设置 TCP_NODELAY 8 | func setTCPNoDelay(fd uintptr) error { 9 | return syscall.SetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_TCP, syscall.TCP_NODELAY, 1) 10 | } 11 | -------------------------------------------------------------------------------- /internal/util/serialize.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "github.com/bytedance/sonic" 4 | 5 | // SerializeJSON 序列化任意类型为JSON字符串,失败时返回默认值 6 | // 自动处理空值:nil返回默认值,空切片/map正常序列化为[]或{} 7 | func SerializeJSON(v any, defaultValue string) (string, error) { 8 | // 检查空值 9 | if v == nil { 10 | return defaultValue, nil 11 | } 12 | 13 | bytes, err := sonic.Marshal(v) 14 | if err != nil { 15 | return defaultValue, err 16 | } 17 | return string(bytes), nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/util/apikeys.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | // ParseAPIKeys 解析 API Key 字符串(支持逗号分隔的多个 Key) 6 | // 设计原则(DRY):统一的Key解析逻辑,供多个模块复用 7 | func ParseAPIKeys(apiKey string) []string { 8 | if apiKey == "" { 9 | return []string{} 10 | } 11 | parts := strings.Split(apiKey, ",") 12 | keys := make([]string, 0, len(parts)) 13 | for _, k := range parts { 14 | k = strings.TrimSpace(k) 15 | if k != "" { 16 | keys = append(keys, k) 17 | } 18 | } 19 | return keys 20 | } 21 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 相关 2 | .git 3 | .gitignore 4 | 5 | # 开发工具 6 | .vscode 7 | .idea 8 | *.swp 9 | *.swo 10 | 11 | # 日志文件 12 | logs/ 13 | *.log 14 | 15 | # 数据文件 16 | data/ 17 | *.db 18 | *.db-journal 19 | 20 | # 构建产物 21 | ccload 22 | /tmp/ 23 | 24 | # macOS LaunchAgent 相关 25 | *.plist 26 | *.plist.template 27 | com.ccload.service.plist 28 | 29 | # 测试文件 30 | *_test.go 31 | test_* 32 | 33 | # 文档 34 | README.md 35 | CLAUDE.md 36 | 37 | # 依赖和模块缓存 38 | vendor/ 39 | 40 | # 环境配置 41 | .env 42 | .env.local 43 | .env.*.local 44 | 45 | # Makefile(容器内不需要) 46 | Makefile -------------------------------------------------------------------------------- /internal/storage/sqlite/test_store_helpers_test.go: -------------------------------------------------------------------------------- 1 | package sqlite_test 2 | 3 | import ( 4 | "ccLoad/internal/storage" 5 | "testing" 6 | ) 7 | 8 | func setupSQLiteTestStore(t *testing.T, dbFile string) (storage.Store, func()) { 9 | t.Helper() 10 | 11 | tmpDB := t.TempDir() + "/" + dbFile 12 | store, err := storage.CreateSQLiteStore(tmpDB, nil) 13 | if err != nil { 14 | t.Fatalf("创建测试数据库失败: %v", err) 15 | } 16 | 17 | cleanup := func() { 18 | if err := store.Close(); err != nil { 19 | t.Logf("关闭测试数据库失败: %v", err) 20 | } 21 | } 22 | 23 | return store, cleanup 24 | } 25 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | // Package version 提供应用版本信息 2 | // 版本号通过 go build -ldflags 注入,用于静态资源缓存控制 3 | package version 4 | 5 | // 构建信息变量,通过 ldflags 注入 6 | // 构建命令示例: 7 | // go build -ldflags "-X ccLoad/internal/version.Version=$(git describe --tags --always) \ 8 | // -X ccLoad/internal/version.Commit=$(git rev-parse --short HEAD) \ 9 | // -X 'ccLoad/internal/version.BuildTime=$(date +%Y-%m-%d\ %H:%M:%S\ %z)' \ 10 | // -X ccLoad/internal/version.BuiltBy=$(whoami)" 11 | var ( 12 | Version = "dev" 13 | Commit = "unknown" 14 | BuildTime = "unknown" 15 | BuiltBy = "unknown" 16 | ) 17 | -------------------------------------------------------------------------------- /web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Claude Code & Codex Proxy", 3 | "short_name": "ccLoad", 4 | "description": "Claude API代理管理服务", 5 | "start_url": "/web/index.html", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#3b82f6", 9 | "icons": [ 10 | { 11 | "src": "/web/favicon-192.png", 12 | "sizes": "192x192", 13 | "type": "image/png", 14 | "purpose": "any maskable" 15 | }, 16 | { 17 | "src": "/web/favicon-512.png", 18 | "sizes": "512x512", 19 | "type": "image/png", 20 | "purpose": "any maskable" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /internal/model/health.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // HealthScoreConfig 健康度排序配置 4 | type HealthScoreConfig struct { 5 | Enabled bool // 是否启用健康度排序 6 | SuccessRatePenaltyWeight float64 // 成功率惩罚权重(乘以失败率) 7 | WindowMinutes int // 成功率统计时间窗口(分钟) 8 | UpdateIntervalSeconds int // 成功率缓存更新间隔(秒) 9 | } 10 | 11 | // DefaultHealthScoreConfig 返回默认健康度配置 12 | func DefaultHealthScoreConfig() HealthScoreConfig { 13 | return HealthScoreConfig{ 14 | Enabled: false, 15 | SuccessRatePenaltyWeight: 100, 16 | WindowMinutes: 5, 17 | UpdateIntervalSeconds: 30, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /internal/model/system_setting.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "errors" 4 | 5 | // ErrSettingNotFound 系统设置未找到错误 6 | var ErrSettingNotFound = errors.New("setting not found") 7 | 8 | // SystemSetting 系统配置项 9 | type SystemSetting struct { 10 | Key string `json:"key"` // 配置键(如log_retention_days) 11 | Value string `json:"value"` // 配置值(字符串存储,运行时解析) 12 | ValueType string `json:"value_type"` // 值类型(int/bool/string/duration) 13 | Description string `json:"description"` // 配置说明(用于前端显示) 14 | DefaultValue string `json:"default_value"` // 默认值(用于重置功能) 15 | UpdatedAt int64 `json:"updated_at"` // 更新时间(Unix秒) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE and editor files 2 | .gocache 3 | .idea 4 | .vscode 5 | *.swp 6 | *.swo 7 | *~ 8 | 9 | # Data and database files 10 | data/ 11 | *.db 12 | *.sqlite 13 | *.sqlite3 14 | 15 | # Build artifacts 16 | ccLoad 17 | ccload 18 | /tmp/ccload* 19 | *.exe 20 | *.dll 21 | *.so 22 | *.dylib 23 | 24 | # Test files 25 | *.test 26 | *.out 27 | test*.sh 28 | 29 | # Environment files 30 | .env 31 | .env.local 32 | .env.*.local 33 | 34 | # OS files 35 | .DS_Store 36 | Thumbs.db 37 | 38 | # Temporary files 39 | *.tmp 40 | *.temp 41 | /tmp/ 42 | 43 | # Log files 44 | *.log 45 | 46 | # Playwright MCP 47 | .playwright-mcp 48 | .claude 49 | com.ccload.service.plist 50 | .dataX 51 | .serena 52 | .gocache 53 | .gomodcache 54 | AGENTS.md 55 | dist 56 | *.bak 57 | .docs -------------------------------------------------------------------------------- /web/assets/css/inter.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter'; 3 | font-style: normal; 4 | font-weight: 400; 5 | font-display: swap; 6 | src: url(../fonts/inter-400.ttf) format('truetype'); 7 | } 8 | @font-face { 9 | font-family: 'Inter'; 10 | font-style: normal; 11 | font-weight: 500; 12 | font-display: swap; 13 | src: url(../fonts/inter-500.ttf) format('truetype'); 14 | } 15 | @font-face { 16 | font-family: 'Inter'; 17 | font-style: normal; 18 | font-weight: 600; 19 | font-display: swap; 20 | src: url(../fonts/inter-600.ttf) format('truetype'); 21 | } 22 | @font-face { 23 | font-family: 'Inter'; 24 | font-style: normal; 25 | font-weight: 700; 26 | font-display: swap; 27 | src: url(../fonts/inter-700.ttf) format('truetype'); 28 | } 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | ccload: 5 | image: ghcr.io/caidaoli/ccload:latest 6 | container_name: ccload 7 | user: root 8 | restart: unless-stopped 9 | ports: 10 | - "8080:8080" 11 | environment: 12 | - PORT=8080 13 | - SQLITE_PATH=/app/data/ccload.db 14 | - GIN_MODE=release 15 | # 必填:未设置将无法启动 16 | - CCLOAD_PASS=your_admin_password 17 | - TZ=Asia/Shanghai 18 | # API访问令牌通过Web界面管理: http://localhost:8080/web/tokens.html 19 | volumes: 20 | - ./data:/app/data 21 | healthcheck: 22 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] 23 | interval: 30s 24 | timeout: 10s 25 | retries: 3 26 | start_period: 40s 27 | 28 | 29 | -------------------------------------------------------------------------------- /com.ccload.service.plist.template: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.ccload.service 7 | ProgramArguments 8 | 9 | {{PROJECT_DIR}}/ccload 10 | 11 | WorkingDirectory 12 | {{PROJECT_DIR}} 13 | RunAtLoad 14 | 15 | KeepAlive 16 | 17 | StandardOutPath 18 | /dev/null 19 | StandardErrorPath 20 | /dev/null 21 | EnvironmentVariables 22 | 23 | PATH 24 | /usr/local/bin:/usr/bin:/bin 25 | 26 | 27 | -------------------------------------------------------------------------------- /internal/testutil/types.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import "fmt" 4 | 5 | // TestChannelRequest 渠道测试请求结构 6 | type TestChannelRequest struct { 7 | Model string `json:"model" binding:"required"` 8 | MaxTokens int `json:"max_tokens,omitempty"` // 可选,默认512 9 | Stream bool `json:"stream,omitempty"` // 可选,流式响应 10 | Content string `json:"content,omitempty"` // 可选,测试内容,默认"test" 11 | Headers map[string]string `json:"headers,omitempty"` // 可选,自定义请求头 12 | ChannelType string `json:"channel_type,omitempty"` // 可选,渠道类型:anthropic(默认)、codex、gemini 13 | KeyIndex int `json:"key_index,omitempty"` // 可选,指定测试的Key索引,默认0(第一个) 14 | } 15 | 16 | // Validate 实现RequestValidator接口 17 | func (tr *TestChannelRequest) Validate() error { 18 | if tr.Model == "" { 19 | return fmt.Errorf("model cannot be empty") 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /test/integration/setup_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "ccLoad/internal/storage" 5 | "context" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | // setupTestStore 创建测试用的 SQLite Store 11 | func setupTestStore(t *testing.T) (storage.Store, func()) { 12 | t.Helper() 13 | 14 | // 创建临时目录和数据库文件 15 | tmpDir := t.TempDir() 16 | dbPath := filepath.Join(tmpDir, "test.db") 17 | 18 | store, err := storage.CreateSQLiteStore(dbPath, nil) 19 | if err != nil { 20 | t.Fatalf("创建测试数据库失败: %v", err) 21 | } 22 | 23 | // 返回清理函数 24 | cleanup := func() { 25 | if err := store.Close(); err != nil { 26 | t.Logf("⚠️ 关闭数据库失败: %v", err) 27 | } 28 | } 29 | 30 | return store, cleanup 31 | } 32 | 33 | // setupTestStoreWithContext 创建测试用的 Store 和 Context 34 | func setupTestStoreWithContext(t *testing.T) (storage.Store, context.Context, func()) { 35 | t.Helper() 36 | 37 | store, cleanup := setupTestStore(t) 38 | ctx := context.Background() 39 | 40 | return store, ctx, cleanup 41 | } 42 | 43 | -------------------------------------------------------------------------------- /docker-compose.build.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | ccload: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | args: 9 | # 版本号:用于静态资源缓存控制 10 | # - dev(默认):开发环境,静态资源不缓存 11 | # - v1.x.x:生产环境,静态资源长缓存 12 | # 生产构建: VERSION=$(git describe --tags --always) docker-compose -f docker-compose.build.yml build 13 | VERSION: ${VERSION:-dev} 14 | image: ccload:local 15 | container_name: ccload 16 | restart: unless-stopped 17 | ports: 18 | - "8080:8080" 19 | environment: 20 | - PORT=8080 21 | - SQLITE_PATH=/app/data/ccload.db 22 | - GIN_MODE=release 23 | # 必填:未设置将无法启动 24 | - CCLOAD_PASS=your_admin_password 25 | # API访问令牌通过Web界面管理: http://localhost:8080/web/tokens.html 26 | volumes: 27 | - ccload_data:/app/data 28 | healthcheck: 29 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] 30 | interval: 30s 31 | timeout: 10s 32 | retries: 3 33 | start_period: 40s 34 | 35 | volumes: 36 | ccload_data: 37 | driver: local 38 | -------------------------------------------------------------------------------- /web/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /internal/app/admin_settings_response_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func TestAdminAPI_ListSettings_ResponseShape(t *testing.T) { 13 | server, store, cleanup := setupAdminTestServer(t) 14 | defer cleanup() 15 | 16 | server.configService = NewConfigService(store) 17 | 18 | w := httptest.NewRecorder() 19 | c, _ := gin.CreateTestContext(w) 20 | c.Request = httptest.NewRequest(http.MethodGet, "/admin/settings", nil) 21 | 22 | server.AdminListSettings(c) 23 | 24 | if w.Code != http.StatusOK { 25 | t.Fatalf("Expected 200, got %d", w.Code) 26 | } 27 | 28 | var resp map[string]any 29 | if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 30 | t.Fatalf("Parse error: %v", err) 31 | } 32 | 33 | if resp["success"] != true { 34 | t.Fatalf("Expected success=true, got %v", resp["success"]) 35 | } 36 | 37 | data := resp["data"] 38 | if data == nil { 39 | t.Fatalf("Expected data to be [], got null") 40 | } 41 | if _, ok := data.([]any); !ok { 42 | t.Fatalf("Expected data to be array, got %T", data) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/util/apikeys_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestParseAPIKeys 测试API Key解析 8 | func TestParseAPIKeys(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | input string 12 | expected []string 13 | }{ 14 | { 15 | name: "单个Key", 16 | input: "sk-test-key", 17 | expected: []string{"sk-test-key"}, 18 | }, 19 | { 20 | name: "多个Key (逗号分隔)", 21 | input: "sk-key1,sk-key2,sk-key3", 22 | expected: []string{"sk-key1", "sk-key2", "sk-key3"}, 23 | }, 24 | { 25 | name: "带空格的Key", 26 | input: " sk-key1 , sk-key2 , sk-key3 ", 27 | expected: []string{"sk-key1", "sk-key2", "sk-key3"}, 28 | }, 29 | { 30 | name: "空字符串", 31 | input: "", 32 | expected: []string{}, 33 | }, 34 | { 35 | name: "仅空格", 36 | input: " ", 37 | expected: []string{}, 38 | }, 39 | { 40 | name: "包含空项", 41 | input: "sk-key1,,sk-key3", 42 | expected: []string{"sk-key1", "sk-key3"}, 43 | }, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | result := ParseAPIKeys(tt.input) 49 | if len(result) != len(tt.expected) { 50 | t.Errorf("期望 %d 个key, 实际 %d 个", len(tt.expected), len(result)) 51 | return 52 | } 53 | for i, key := range result { 54 | if key != tt.expected[i] { 55 | t.Errorf("索引 %d: 期望 %q, 实际 %q", i, tt.expected[i], key) 56 | } 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/util/serialize_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestSerializeJSON 测试JSON序列化 8 | func TestSerializeJSON(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | input any 12 | defaultValue string 13 | expected string 14 | expectError bool 15 | }{ 16 | { 17 | name: "空数组", 18 | input: []string{}, 19 | defaultValue: "[]", 20 | expected: "[]", 21 | expectError: false, 22 | }, 23 | { 24 | name: "单个元素", 25 | input: []string{"test"}, 26 | defaultValue: "[]", 27 | expected: `["test"]`, 28 | expectError: false, 29 | }, 30 | { 31 | name: "多个元素", 32 | input: []string{"a", "b", "c"}, 33 | defaultValue: "[]", 34 | expected: `["a","b","c"]`, 35 | expectError: false, 36 | }, 37 | { 38 | name: "nil值返回默认值", 39 | input: nil, 40 | defaultValue: "default", 41 | expected: "default", 42 | expectError: false, 43 | }, 44 | { 45 | name: "map对象", 46 | input: map[string]string{"key": "value"}, 47 | defaultValue: "{}", 48 | expected: `{"key":"value"}`, 49 | expectError: false, 50 | }, 51 | } 52 | 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | result, err := SerializeJSON(tt.input, tt.defaultValue) 56 | if (err != nil) != tt.expectError { 57 | t.Errorf("期望错误=%v, 实际错误=%v", tt.expectError, err) 58 | } 59 | if result != tt.expected { 60 | t.Errorf("期望 %q, 实际 %q", tt.expected, result) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/storage/schema/builder_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestChannelsTableGeneration(t *testing.T) { 8 | channels := DefineChannelsTable() 9 | 10 | t.Run("MySQL DDL", func(t *testing.T) { 11 | sql := channels.BuildMySQL() 12 | t.Logf("MySQL DDL:\n%s", sql) 13 | 14 | // 验证关键字 15 | if !contains(sql, "INT PRIMARY KEY AUTO_INCREMENT") { 16 | t.Error("Missing AUTO_INCREMENT") 17 | } 18 | if !contains(sql, "VARCHAR(191)") { 19 | t.Error("Missing VARCHAR") 20 | } 21 | }) 22 | 23 | t.Run("SQLite DDL", func(t *testing.T) { 24 | sql := channels.BuildSQLite() 25 | t.Logf("SQLite DDL:\n%s", sql) 26 | 27 | // 验证类型转换 28 | if !contains(sql, "INTEGER PRIMARY KEY AUTOINCREMENT") { 29 | t.Error("Missing AUTOINCREMENT") 30 | } 31 | if !contains(sql, "TEXT") { 32 | t.Error("Missing TEXT type") 33 | } 34 | if contains(sql, "VARCHAR") { 35 | t.Error("VARCHAR not converted to TEXT") 36 | } 37 | }) 38 | 39 | t.Run("Indexes", func(t *testing.T) { 40 | mysqlIndexes := channels.GetIndexesMySQL() 41 | sqliteIndexes := channels.GetIndexesSQLite() 42 | 43 | if len(mysqlIndexes) != 4 { 44 | t.Errorf("Expected 4 MySQL indexes, got %d", len(mysqlIndexes)) 45 | } 46 | 47 | // 验证SQLite索引包含IF NOT EXISTS 48 | for _, idx := range sqliteIndexes { 49 | if !contains(idx.SQL, "IF NOT EXISTS") { 50 | t.Errorf("SQLite index missing IF NOT EXISTS: %s", idx.SQL) 51 | } 52 | } 53 | 54 | t.Logf("MySQL indexes: %d", len(mysqlIndexes)) 55 | t.Logf("SQLite indexes: %d", len(sqliteIndexes)) 56 | }) 57 | } 58 | 59 | func contains(s, substr string) bool { 60 | return len(s) > 0 && len(substr) > 0 && stringContains(s, substr) 61 | } 62 | 63 | func stringContains(s, substr string) bool { 64 | for i := 0; i <= len(s)-len(substr); i++ { 65 | if s[i:i+len(substr)] == substr { 66 | return true 67 | } 68 | } 69 | return false 70 | } 71 | -------------------------------------------------------------------------------- /internal/util/time_additional_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | // TestCalculateCooldownDuration 测试冷却持续时间计算 9 | func TestCalculateCooldownDuration(t *testing.T) { 10 | now := time.Now() 11 | 12 | tests := []struct { 13 | name string 14 | until time.Time 15 | now time.Time 16 | expected int64 17 | }{ 18 | { 19 | name: "正常冷却(60秒)", 20 | until: now.Add(60 * time.Second), 21 | now: now, 22 | expected: 60000, // 60秒 = 60000毫秒 23 | }, 24 | { 25 | name: "已过期冷却", 26 | until: now.Add(-10 * time.Second), 27 | now: now, 28 | expected: 0, 29 | }, 30 | { 31 | name: "零时间", 32 | until: time.Time{}, 33 | now: now, 34 | expected: 0, 35 | }, 36 | { 37 | name: "1分钟冷却", 38 | until: now.Add(1 * time.Minute), 39 | now: now, 40 | expected: 60000, 41 | }, 42 | { 43 | name: "30分钟冷却", 44 | until: now.Add(30 * time.Minute), 45 | now: now, 46 | expected: 1800000, 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | result := CalculateCooldownDuration(tt.until, tt.now) 53 | 54 | // 允许小幅误差(±100毫秒) 55 | diff := result - tt.expected 56 | if diff < 0 { 57 | diff = -diff 58 | } 59 | if diff > 100 { 60 | t.Errorf("期望 %d 毫秒, 实际 %d 毫秒", tt.expected, result) 61 | } 62 | }) 63 | } 64 | } 65 | 66 | // TestCalculateCooldownDuration_Precision 测试精度 67 | func TestCalculateCooldownDuration_Precision(t *testing.T) { 68 | now := time.Now() 69 | 70 | // 测试毫秒级精度 71 | until := now.Add(1234 * time.Millisecond) 72 | result := CalculateCooldownDuration(until, now) 73 | 74 | expected := int64(1234) 75 | diff := result - expected 76 | if diff < 0 { 77 | diff = -diff 78 | } 79 | 80 | // 允许±1毫秒误差 81 | if diff > 1 { 82 | t.Errorf("精度测试失败: 期望 %d ms, 实际 %d ms", expected, result) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /internal/version/banner.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "golang.org/x/term" 8 | ) 9 | 10 | const banner = ` 11 | ██████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ 12 | ██╔════╝ ██╔════╝ ██║ ██╔═══██╗ ██╔══██╗ ██╔══██╗ 13 | ██║ ██║ ██║ ██║ ██║ ███████║ ██║ ██║ 14 | ██║ ██║ ██║ ██║ ██║ ██╔══██║ ██║ ██║ 15 | ╚██████╗ ╚██████╗ ███████╗ ╚██████╔╝ ██║ ██║ ██████╔╝ 16 | ╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ 17 | ` 18 | 19 | const repoURL = "https://github.com/caidaoli/ccLoad" 20 | 21 | // ANSI 颜色码 22 | const ( 23 | colorReset = "\033[0m" 24 | colorCyan = "\033[36m" 25 | colorGreen = "\033[32m" 26 | colorYellow = "\033[33m" 27 | colorBlue = "\033[34m" 28 | ) 29 | 30 | // PrintBanner 打印启动 Banner 和版本信息到 stderr 31 | func PrintBanner() { 32 | // 检测是否为终端,非终端不输出颜色 33 | isTTY := term.IsTerminal(int(os.Stderr.Fd())) 34 | 35 | if isTTY { 36 | fmt.Fprintf(os.Stderr, "%s%s%s", colorCyan, banner, colorReset) 37 | fmt.Fprintf(os.Stderr, " %sAPI Load Balancer & Proxy%s\n\n", colorYellow, colorReset) 38 | fmt.Fprintf(os.Stderr, "%-14s %s%s%s\n", "Version:", colorGreen, Version, colorReset) 39 | fmt.Fprintf(os.Stderr, "%-14s %s%s%s\n", "Commit:", colorGreen, Commit, colorReset) 40 | fmt.Fprintf(os.Stderr, "%-14s %s%s%s\n", "Build Time:", colorGreen, BuildTime, colorReset) 41 | fmt.Fprintf(os.Stderr, "%-14s %s%s%s\n", "Built By:", colorGreen, BuiltBy, colorReset) 42 | fmt.Fprintf(os.Stderr, "%-14s %s%s%s\n\n", "Repo:", colorBlue, repoURL, colorReset) 43 | } else { 44 | fmt.Fprint(os.Stderr, banner) 45 | fmt.Fprintf(os.Stderr, " API Load Balancer & Proxy\n\n") 46 | fmt.Fprintf(os.Stderr, "%-14s %s\n", "Version:", Version) 47 | fmt.Fprintf(os.Stderr, "%-14s %s\n", "Commit:", Commit) 48 | fmt.Fprintf(os.Stderr, "%-14s %s\n", "Build Time:", BuildTime) 49 | fmt.Fprintf(os.Stderr, "%-14s %s\n", "Built By:", BuiltBy) 50 | fmt.Fprintf(os.Stderr, "%-14s %s\n\n", "Repo:", repoURL) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.env.docker.example: -------------------------------------------------------------------------------- 1 | # ccLoad Docker 环境配置示例 2 | # 复制此文件为 .env 并根据需要修改配置 3 | 4 | # ======================================== 5 | # 核心配置(必需) 6 | # ======================================== 7 | 8 | # 管理后台密码(必需,未设置将导致程序退出) 9 | CCLOAD_PASS=your_secure_admin_password 10 | 11 | # API 访问令牌通过 Web 管理界面动态配置 12 | # 访问 http://localhost:8080/web/tokens.html 进行令牌管理 13 | 14 | # ======================================== 15 | # 数据库配置 16 | # ======================================== 17 | 18 | # 数据库文件路径(容器内路径,通常不需要修改) 19 | SQLITE_PATH=/app/data/ccload.db 20 | 21 | # SQLite Journal 模式(可选,默认: WAL) 22 | # 可选值: WAL | DELETE | TRUNCATE | PERSIST | MEMORY | OFF 23 | # - WAL(默认):Write-Ahead Logging,高性能,适合本地文件系统 24 | # - TRUNCATE:传统回滚日志,适合 Docker/K8s 环境或网络存储(NFS等) 25 | # - DELETE:与 TRUNCATE 类似,但删除日志文件而非截断 26 | # ⚠️ 容器环境建议:SQLITE_JOURNAL_MODE=TRUNCATE(避免WAL文件损坏风险) 27 | # SQLITE_JOURNAL_MODE=TRUNCATE 28 | 29 | # ======================================== 30 | # 网络配置 31 | # ======================================== 32 | 33 | # HTTP 服务端口(容器内端口,通常不需要修改) 34 | PORT=8080 35 | 36 | # TLS 证书验证(可选,默认: false) 37 | # 仅开发环境使用,生产环境严禁禁用 TLS 验证 38 | # CCLOAD_SKIP_TLS_VERIFY=true 39 | 40 | # ======================================== 41 | # 性能优化配置 42 | # ======================================== 43 | 44 | # 单个渠道内最大 Key 重试次数(可选,默认: 3) 45 | # CCLOAD_MAX_KEY_RETRIES=3 46 | 47 | # 最大并发请求数(可选,默认: 1000) 48 | # 限制同时处理的代理请求数量,防止goroutine爆炸 49 | # CCLOAD_MAX_CONCURRENCY=1000 50 | 51 | # 请求体最大字节数(可选,默认: 2097152,即 2MB) 52 | # 限制单个API请求体的大小,防止大包打爆内存 53 | # CCLOAD_MAX_BODY_BYTES=2097152 54 | 55 | # 上游首字节超时(可选,单位: 秒,默认: 不设置) 56 | # 设置后,如果上游API在指定时间内未返回首字节,则请求超时 57 | # CCLOAD_UPSTREAM_FIRST_BYTE_TIMEOUT=30 58 | 59 | 60 | 61 | # ======================================== 62 | # Redis 同步配置(推荐) 63 | # ======================================== 64 | 65 | # Redis 连接 URL(推荐配置,用于渠道数据同步) 66 | # Docker Compose 环境下可使用服务名 67 | # REDIS_URL=redis://redis:6379 68 | 69 | # ======================================== 70 | # 运行模式配置 71 | # ======================================== 72 | 73 | # Gin 运行模式(release/debug) 74 | GIN_MODE=release 75 | -------------------------------------------------------------------------------- /internal/app/admin_cooldown.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // ==================== 冷却管理 ==================== 13 | // 从admin.go拆分冷却管理,遵循SRP原则 14 | 15 | // handleSetChannelCooldown 设置渠道级别冷却 16 | func (s *Server) HandleSetChannelCooldown(c *gin.Context) { 17 | id, err := ParseInt64Param(c, "id") 18 | if err != nil { 19 | RespondErrorMsg(c, http.StatusBadRequest, "invalid channel ID") 20 | return 21 | } 22 | 23 | var req CooldownRequest 24 | if err := c.ShouldBindJSON(&req); err != nil { 25 | RespondError(c, http.StatusBadRequest, err) 26 | return 27 | } 28 | 29 | until := time.Now().Add(time.Duration(req.DurationMs) * time.Millisecond) 30 | err = s.store.SetChannelCooldown(c.Request.Context(), id, until) 31 | if err != nil { 32 | RespondError(c, http.StatusInternalServerError, err) 33 | return 34 | } 35 | 36 | // 精确计数(手动设置渠道冷却 37 | 38 | RespondJSON(c, http.StatusOK, gin.H{"message": fmt.Sprintf("渠道已冷却 %d 毫秒", req.DurationMs)}) 39 | } 40 | 41 | // handleSetKeyCooldown 设置Key级别冷却 42 | func (s *Server) HandleSetKeyCooldown(c *gin.Context) { 43 | id, err := ParseInt64Param(c, "id") 44 | if err != nil { 45 | RespondErrorMsg(c, http.StatusBadRequest, "invalid channel ID") 46 | return 47 | } 48 | 49 | keyIndexStr := c.Param("keyIndex") 50 | keyIndex, err := strconv.Atoi(keyIndexStr) 51 | if err != nil || keyIndex < 0 { 52 | RespondErrorMsg(c, http.StatusBadRequest, "invalid key index") 53 | return 54 | } 55 | 56 | var req CooldownRequest 57 | if err := c.ShouldBindJSON(&req); err != nil { 58 | RespondError(c, http.StatusBadRequest, err) 59 | return 60 | } 61 | 62 | until := time.Now().Add(time.Duration(req.DurationMs) * time.Millisecond) 63 | err = s.store.SetKeyCooldown(c.Request.Context(), id, keyIndex, until) 64 | if err != nil { 65 | RespondError(c, http.StatusInternalServerError, err) 66 | return 67 | } 68 | 69 | // [INFO] 修复:使API Keys缓存失效,确保前端能立即看到冷却状态 70 | s.InvalidateAPIKeysCache(id) 71 | 72 | RespondJSON(c, http.StatusOK, gin.H{"message": fmt.Sprintf("Key #%d 已冷却 %d 毫秒", keyIndex+1, req.DurationMs)}) 73 | } 74 | -------------------------------------------------------------------------------- /internal/util/time_bench_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | // BenchmarkCalculateBackoffDuration_AuthError 基准测试:401认证错误首次冷却 9 | func BenchmarkCalculateBackoffDuration_AuthError(b *testing.B) { 10 | statusCode := 401 11 | now := time.Now() 12 | 13 | b.ReportAllocs() 14 | for b.Loop() { 15 | _ = CalculateBackoffDuration(0, time.Time{}, now, &statusCode) 16 | } 17 | } 18 | 19 | // BenchmarkCalculateBackoffDuration_OtherError 基准测试:500服务器错误首次冷却 20 | func BenchmarkCalculateBackoffDuration_OtherError(b *testing.B) { 21 | statusCode := 500 22 | now := time.Now() 23 | 24 | b.ReportAllocs() 25 | for b.Loop() { 26 | _ = CalculateBackoffDuration(0, time.Time{}, now, &statusCode) 27 | } 28 | } 29 | 30 | // BenchmarkCalculateBackoffDuration_ExponentialBackoff 基准测试:指数退避计算 31 | func BenchmarkCalculateBackoffDuration_ExponentialBackoff(b *testing.B) { 32 | statusCode := 401 33 | now := time.Now() 34 | prevMs := int64(5 * time.Minute / time.Millisecond) 35 | 36 | b.ReportAllocs() 37 | for b.Loop() { 38 | _ = CalculateBackoffDuration(prevMs, time.Unix(0, 0), now, &statusCode) 39 | } 40 | } 41 | 42 | // BenchmarkCalculateBackoffDuration_NilStatusCode 基准测试:无状态码场景(网络错误) 43 | func BenchmarkCalculateBackoffDuration_NilStatusCode(b *testing.B) { 44 | now := time.Now() 45 | 46 | b.ResetTimer() 47 | b.ReportAllocs() 48 | for i := 0; i < b.N; i++ { 49 | _ = CalculateBackoffDuration(0, time.Time{}, now, nil) 50 | } 51 | } 52 | 53 | // BenchmarkCalculateBackoffDuration_MaxLimit 基准测试:达到上限30分钟场景 54 | func BenchmarkCalculateBackoffDuration_MaxLimit(b *testing.B) { 55 | statusCode := 401 56 | now := time.Now() 57 | prevMs := int64(20 * time.Minute / time.Millisecond) // 20分钟 * 2 = 40分钟(超过上限) 58 | 59 | b.ReportAllocs() 60 | for b.Loop() { 61 | _ = CalculateBackoffDuration(prevMs, time.Unix(0, 0), now, &statusCode) 62 | } 63 | } 64 | 65 | // BenchmarkCalculateCooldownDuration 基准测试:计算冷却持续时间 66 | func BenchmarkCalculateCooldownDuration(b *testing.B) { 67 | now := time.Now() 68 | until := now.Add(5 * time.Minute) 69 | 70 | b.ReportAllocs() 71 | for b.Loop() { 72 | _ = CalculateCooldownDuration(until, now) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/app/proxy_gemini.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // ============================================================================ 10 | // Gemini API 特殊处理 11 | // ============================================================================ 12 | 13 | // handleListGeminiModels 处理 GET /v1beta/models 请求,返回本地 Gemini 模型列表 14 | // 从proxy.go提取,遵循SRP原则 15 | func (s *Server) handleListGeminiModels(c *gin.Context) { 16 | ctx := c.Request.Context() 17 | 18 | // 获取所有 gemini 渠道的去重模型列表 19 | models, err := s.getModelsByChannelType(ctx, "gemini") 20 | if err != nil { 21 | c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"}) 22 | return 23 | } 24 | 25 | // 构造 Gemini API 响应格式 26 | type ModelInfo struct { 27 | Name string `json:"name"` 28 | DisplayName string `json:"displayName"` 29 | } 30 | 31 | modelList := make([]ModelInfo, 0, len(models)) 32 | for _, model := range models { 33 | modelList = append(modelList, ModelInfo{ 34 | Name: "models/" + model, 35 | DisplayName: formatModelDisplayName(model), 36 | }) 37 | } 38 | 39 | c.JSON(http.StatusOK, gin.H{ 40 | "models": modelList, 41 | }) 42 | } 43 | 44 | // handleListOpenAIModels 处理 GET /v1/models 请求,返回本地 OpenAI 模型列表 45 | func (s *Server) handleListOpenAIModels(c *gin.Context) { 46 | ctx := c.Request.Context() 47 | 48 | // 获取所有 openai 渠道的去重模型列表 49 | models, err := s.getModelsByChannelType(ctx, "openai") 50 | if err != nil { 51 | c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"}) 52 | return 53 | } 54 | 55 | // 构造 OpenAI API 响应格式 56 | type ModelInfo struct { 57 | ID string `json:"id"` 58 | Object string `json:"object"` 59 | Created int64 `json:"created"` 60 | OwnedBy string `json:"owned_by"` 61 | } 62 | 63 | modelList := make([]ModelInfo, 0, len(models)) 64 | for _, model := range models { 65 | modelList = append(modelList, ModelInfo{ 66 | ID: model, 67 | Object: "model", 68 | Created: 0, 69 | OwnedBy: "system", 70 | }) 71 | } 72 | 73 | c.JSON(http.StatusOK, gin.H{ 74 | "object": "list", 75 | "data": modelList, 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ccLoad 环境配置示例文件 2 | # 复制此文件为 .env 并根据需要修改配置值 3 | 4 | # ======================================== 5 | # 核心配置(必需) 6 | # ======================================== 7 | 8 | # 管理后台密码(必需,未设置将导致程序退出) 9 | CCLOAD_PASS=your_strong_password_here 10 | 11 | # API 访问令牌通过 Web 管理界面动态配置 12 | # 访问 http://localhost:8080/web/tokens.html 进行令牌管理 13 | 14 | # ======================================== 15 | # 数据库配置 16 | # ======================================== 17 | 18 | # SQLite 数据库路径(可选,默认: data/ccload.db) 19 | SQLITE_PATH=./data/ccload.db 20 | 21 | # SQLite Journal 模式(可选,默认: WAL) 22 | # 可选值: WAL | DELETE | TRUNCATE | PERSIST | MEMORY | OFF 23 | # - WAL(默认):Write-Ahead Logging,高性能,适合本地文件系统 24 | # - TRUNCATE:传统回滚日志,适合 Docker/K8s 环境或网络存储(NFS等) 25 | # - DELETE:与 TRUNCATE 类似,但删除日志文件而非截断 26 | # ⚠️ 容器环境建议:SQLITE_JOURNAL_MODE=TRUNCATE(避免WAL文件损坏风险) 27 | # SQLITE_JOURNAL_MODE=WAL 28 | 29 | # ======================================== 30 | # 网络配置 31 | # ======================================== 32 | 33 | # HTTP 服务端口(可选,默认: 8080) 34 | PORT=8080 35 | 36 | # ======================================== 37 | # 性能优化配置 38 | # ======================================== 39 | 40 | # 最大并发请求数(可选,默认: 1000) 41 | # 限制同时处理的代理请求数量,防止goroutine爆炸 42 | # CCLOAD_MAX_CONCURRENCY=1000 43 | 44 | # 请求体最大字节数(可选,默认: 2097152,即 2MB) 45 | # 限制单个API请求体的大小,防止大包打爆内存 46 | # CCLOAD_MAX_BODY_BYTES=2097152 47 | 48 | # ======================================== 49 | # Redis 同步配置(可选) 50 | # ======================================== 51 | 52 | # Redis 连接 URL(可选,用于渠道数据同步备份) 53 | # 格式: redis://localhost:6379 或 redis://user:password@localhost:6379/0 54 | # 启用内存数据库模式时强烈推荐配置,用于故障恢复 55 | # REDIS_URL=redis://localhost:6379 56 | 57 | # ======================================== 58 | # 运行模式配置 59 | # ======================================== 60 | 61 | # Gin 运行模式(可选,默认: release) 62 | # 生产环境建议设置为 release 63 | # GIN_MODE=release 64 | 65 | # ======================================== 66 | # 系统配置(已迁移到 Web 管理界面) 67 | # ======================================== 68 | # 以下配置项已迁移到数据库,通过 Web 界面管理,支持热重载: 69 | # - 日志保留天数 (log_retention_days) 70 | # - 单渠道最大Key重试次数 (max_key_retries) 71 | # - 上游首字节超时 (upstream_first_byte_timeout) 72 | # - 88code免费套餐限制 (88code_free_only) 73 | # - TLS证书验证 (skip_tls_verify) 74 | # 75 | # 访问 http://localhost:8080/web/settings.html 进行配置管理 76 | -------------------------------------------------------------------------------- /internal/storage/sql/metrics_finalize.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "ccLoad/internal/model" 5 | "context" 6 | "fmt" 7 | "log" 8 | "time" 9 | ) 10 | 11 | type metricAggregationHelper struct { 12 | totalFirstByteTime float64 13 | firstByteCount int 14 | totalDuration float64 15 | durationCount int 16 | } 17 | 18 | func (s *SQLStore) finalizeMetricPoints(ctx context.Context, mapp map[int64]*model.MetricPoint, helperMap map[int64]*metricAggregationHelper, channelIDsToFetch map[int64]bool, since, until time.Time, bucket time.Duration) []model.MetricPoint { 19 | channelNames, err := s.fetchChannelNamesBatch(ctx, channelIDsToFetch) 20 | if err != nil { 21 | log.Printf("[WARN] 批量查询渠道名称失败: %v", err) 22 | channelNames = make(map[int64]string) 23 | } 24 | 25 | for bucketTs, mp := range mapp { 26 | newChannels := make(map[string]model.ChannelMetric) 27 | for key, metric := range mp.Channels { 28 | if key == "未知渠道" { 29 | newChannels[key] = metric 30 | continue 31 | } 32 | var channelID int64 33 | fmt.Sscanf(key, "ch_%d", &channelID) 34 | if name, ok := channelNames[channelID]; ok { 35 | newChannels[name] = metric 36 | } else { 37 | newChannels["未知渠道"] = metric 38 | } 39 | } 40 | mp.Channels = newChannels 41 | 42 | if helper, ok := helperMap[bucketTs]; ok { 43 | if helper.firstByteCount > 0 { 44 | avgFBT := helper.totalFirstByteTime / float64(helper.firstByteCount) 45 | mp.AvgFirstByteTimeSeconds = new(float64) 46 | *mp.AvgFirstByteTimeSeconds = avgFBT 47 | mp.FirstByteSampleCount = helper.firstByteCount 48 | } 49 | if helper.durationCount > 0 { 50 | avgDur := helper.totalDuration / float64(helper.durationCount) 51 | mp.AvgDurationSeconds = new(float64) 52 | *mp.AvgDurationSeconds = avgDur 53 | mp.DurationSampleCount = helper.durationCount 54 | } 55 | } 56 | } 57 | 58 | out := []model.MetricPoint{} 59 | endTime := until.Truncate(bucket).Add(bucket) 60 | startTime := since.Truncate(bucket) 61 | 62 | for t := startTime; t.Before(endTime); t = t.Add(bucket) { 63 | ts := t.Unix() 64 | if mp, ok := mapp[ts]; ok { 65 | out = append(out, *mp) 66 | } else { 67 | out = append(out, model.MetricPoint{ 68 | Ts: t, 69 | Channels: make(map[string]model.ChannelMetric), 70 | }) 71 | } 72 | } 73 | 74 | return out 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module ccLoad 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/bytedance/sonic v1.14.1 7 | github.com/gin-gonic/gin v1.10.1 8 | github.com/redis/go-redis/v9 v9.7.0 9 | modernc.org/sqlite v1.38.2 10 | ) 11 | 12 | require golang.org/x/term v0.38.0 13 | 14 | require ( 15 | filippo.io/edwards25519 v1.1.0 // indirect 16 | github.com/go-sql-driver/mysql v1.9.3 17 | ) 18 | 19 | require ( 20 | github.com/bytedance/gopkg v0.1.3 // indirect 21 | github.com/bytedance/sonic/loader v0.3.0 // indirect 22 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 23 | github.com/cloudwego/base64x v0.1.6 // indirect 24 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 25 | github.com/gabriel-vasile/mimetype v1.4.10 // indirect 26 | github.com/gin-contrib/sse v1.1.0 // indirect 27 | github.com/go-playground/locales v0.14.1 // indirect 28 | github.com/go-playground/universal-translator v0.18.1 // indirect 29 | github.com/go-playground/validator/v10 v10.27.0 // indirect 30 | github.com/goccy/go-json v0.10.5 // indirect 31 | github.com/google/uuid v1.6.0 // indirect 32 | github.com/json-iterator/go v1.1.12 // indirect 33 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 34 | github.com/leodido/go-urn v1.4.0 // indirect 35 | github.com/mattn/go-isatty v0.0.20 // indirect 36 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 37 | github.com/modern-go/reflect2 v1.0.2 // indirect 38 | github.com/ncruces/go-strftime v0.1.9 // indirect 39 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 40 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 41 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 42 | github.com/ugorji/go/codec v1.3.0 // indirect 43 | golang.org/x/arch v0.21.0 // indirect 44 | golang.org/x/crypto v0.46.0 45 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 46 | golang.org/x/net v0.47.0 // indirect 47 | golang.org/x/text v0.32.0 // indirect 48 | google.golang.org/protobuf v1.36.8 // indirect 49 | gopkg.in/yaml.v3 v3.0.1 // indirect 50 | modernc.org/libc v1.66.3 // indirect 51 | modernc.org/mathutil v1.7.1 // indirect 52 | modernc.org/memory v1.11.0 // indirect 53 | ) 54 | 55 | require ( 56 | github.com/dustin/go-humanize v1.0.1 // indirect 57 | github.com/joho/godotenv v1.5.1 58 | golang.org/x/sys v0.39.0 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | ## 构建与测试 4 | 5 | ```bash 6 | # 构建(必须 -tags go_json,注入版本号用于静态资源缓存) 7 | go build -tags go_json -ldflags "\ 8 | -X ccLoad/internal/version.Version=$(git describe --tags --always) \ 9 | -X ccLoad/internal/version.Commit=$(git rev-parse --short HEAD) \ 10 | -X 'ccLoad/internal/version.BuildTime=$(date '+%Y-%m-%d %H:%M:%S %z')' \ 11 | -X ccLoad/internal/version.BuiltBy=$(whoami)" -o ccload . 12 | 13 | # 测试(必须 -tags go_json) 14 | go test -tags go_json ./internal/... -v 15 | go test -tags go_json -race ./internal/... # 竞态检测 16 | 17 | # 开发运行(版本号为dev) 18 | export CCLOAD_PASS=test123 # 必填 19 | go run -tags go_json . 20 | ``` 21 | 22 | ## 核心架构 23 | 24 | ``` 25 | internal/ 26 | ├── app/ # HTTP层+业务逻辑 (proxy_*.go, admin_*.go, selector.go, key_selector.go) 27 | ├── cooldown/ # 冷却决策引擎 (manager.go) 28 | ├── storage/sql/ # 数据持久层 (SQLite/MySQL统一实现) 29 | ├── validator/ # 渠道验证器 30 | └── util/ # 工具库 (classifier.go错误分类, models_fetcher.go) 31 | ``` 32 | 33 | **故障切换策略**: 34 | - Key级错误(401/403/429) → 重试同渠道其他Key 35 | - 渠道级错误(5xx/520/524) → 切换到其他渠道 36 | - 客户端错误(404/405) → 不重试,直接返回 37 | - 指数退避: 2min → 4min → 8min → 30min(上限) 38 | 39 | **关键入口**: 40 | - `cooldown.Manager.HandleError()` - 冷却决策引擎 41 | - `util.ClassifyHTTPStatus()` - 错误分类 42 | - `app.KeySelector.SelectAvailableKey()` - Key负载均衡 43 | 44 | ## 开发指南 45 | 46 | ### Serena MCP 工具规范 47 | 48 | **代码浏览**: 49 | - 优先用符号化工具: `get_symbols_overview` → `find_symbol` 50 | - **禁止**直接读取整文件,先了解结构 51 | - 查找引用: `find_referencing_symbols` 52 | 53 | **代码编辑**: 54 | - 替换符号: `replace_symbol_body` 55 | - 插入代码: `insert_after_symbol` / `insert_before_symbol` 56 | - 小改动用 `Edit` 工具 57 | 58 | ### Playwright MCP 工具策略 59 | 60 | - 截图**必须** JPEG: `type: "jpeg"` 61 | - 优先 `browser_snapshot`(文本),视觉验证才截图 62 | - **避免** `fullPage: true` 63 | 64 | ### 添加 Admin API 65 | 1. `admin_types.go` - 定义类型 66 | 2. `admin_.go` - 实现Handler 67 | 3. `server.go:SetupRoutes()` - 注册路由 68 | 69 | ### 数据库操作 70 | - Schema更新: `storage/migrate.go` 启动自动执行 71 | - 事务: `(*SQLStore).WithTransaction(ctx, func(tx) error)` 72 | - 缓存失效: `InvalidateChannelListCache()` / `InvalidateAPIKeysCache()` 73 | 74 | ## 代码规范 75 | 76 | - **必须** `-tags go_json` 构建和测试 77 | - **必须** `any` 替代 `interface{}` 78 | - **禁止** 过度工程,YAGNI原则 79 | - **Fail-Fast**: 配置错误直接 `log.Fatal()` 退出 80 | - **Context**: `defer cancel()` 必须无条件调用,用 `context.AfterFunc` 监听取消 81 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: '手动指定镜像标签' 11 | required: false 12 | default: 'manual' 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | build-and-push: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | packages: write 24 | attestations: write 25 | id-token: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Log in to Container Registry 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ${{ env.REGISTRY }} 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Extract metadata 42 | id: meta 43 | uses: docker/metadata-action@v5 44 | with: 45 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 46 | tags: | 47 | type=semver,pattern={{version}} 48 | type=semver,pattern={{major}}.{{minor}} 49 | type=semver,pattern={{major}} 50 | type=raw,value=latest,enable=${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }} 51 | type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }} 52 | 53 | - name: Build and push Docker image 54 | id: push 55 | uses: docker/build-push-action@v6 56 | with: 57 | context: . 58 | platforms: linux/amd64,linux/arm64 59 | push: true 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | cache-from: type=gha 63 | cache-to: type=gha,mode=max 64 | build-args: | 65 | VERSION=${{ github.ref_name }} 66 | COMMIT=${{ github.sha }} 67 | 68 | - name: Generate artifact attestation 69 | uses: actions/attest-build-provenance@v1 70 | with: 71 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 72 | subject-digest: ${{ steps.push.outputs.digest }} 73 | push-to-registry: true -------------------------------------------------------------------------------- /internal/app/request_context.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "time" 7 | ) 8 | 9 | // requestContext 封装单次请求的上下文和超时控制 10 | // 从 forwardOnceAsync 提取,遵循SRP原则 11 | // 补充首字节超时管控(可选) 12 | type requestContext struct { 13 | ctx context.Context 14 | cancel context.CancelFunc // [INFO] 总是非 nil(即使是 noop),调用方无需检查 15 | startTime time.Time 16 | isStreaming bool 17 | firstByteTimer *time.Timer 18 | firstByteTimedOut atomic.Bool 19 | } 20 | 21 | // newRequestContext 创建请求上下文(处理超时控制) 22 | // 设计原则: 23 | // - 流式请求:使用 firstByteTimeout(首字节超时),之后不限制 24 | // - 非流式请求:使用 nonStreamTimeout(整体超时),超时主动关闭上游连接 25 | // [INFO] Go 1.21+ 改进:总是返回非 nil 的 cancel,调用方无需检查(符合 Go 惯用法) 26 | func (s *Server) newRequestContext(parentCtx context.Context, requestPath string, body []byte) *requestContext { 27 | isStreaming := isStreamingRequest(requestPath, body) 28 | 29 | // [INFO] 关键改动:总是使用 WithCancel 包裹(即使无超时配置也能正常取消) 30 | ctx, cancel := context.WithCancel(parentCtx) 31 | 32 | // 非流式请求:在基础 cancel 之上叠加整体超时 33 | if !isStreaming && s.nonStreamTimeout > 0 { 34 | var timeoutCancel context.CancelFunc 35 | ctx, timeoutCancel = context.WithTimeout(ctx, s.nonStreamTimeout) 36 | // 链式 cancel:timeout 触发时也会取消父 context 37 | originalCancel := cancel 38 | cancel = func() { 39 | timeoutCancel() 40 | originalCancel() 41 | } 42 | } 43 | 44 | reqCtx := &requestContext{ 45 | ctx: ctx, 46 | cancel: cancel, // [INFO] 总是非 nil,无需检查 47 | startTime: time.Now(), 48 | isStreaming: isStreaming, 49 | } 50 | 51 | // 流式请求的首字节超时定时器 52 | if isStreaming && s.firstByteTimeout > 0 { 53 | reqCtx.firstByteTimer = time.AfterFunc(s.firstByteTimeout, func() { 54 | reqCtx.firstByteTimedOut.Store(true) 55 | cancel() // [INFO] 直接调用,无需检查 56 | }) 57 | } 58 | 59 | return reqCtx 60 | } 61 | 62 | func (rc *requestContext) stopFirstByteTimer() { 63 | if rc.firstByteTimer != nil { 64 | rc.firstByteTimer.Stop() 65 | } 66 | } 67 | 68 | func (rc *requestContext) firstByteTimeoutTriggered() bool { 69 | return rc.firstByteTimedOut.Load() 70 | } 71 | 72 | // Duration 返回从请求开始到现在的时间(秒) 73 | func (rc *requestContext) Duration() float64 { 74 | return time.Since(rc.startTime).Seconds() 75 | } 76 | 77 | // cleanup 统一清理请求上下文资源(定时器 + context) 78 | // [INFO] 符合 Go 惯用法:defer reqCtx.cleanup() 一行搞定 79 | func (rc *requestContext) cleanup() { 80 | rc.stopFirstByteTimer() // 停止首字节超时定时器 81 | rc.cancel() // 取消 context(总是非 nil,无需检查) 82 | } 83 | -------------------------------------------------------------------------------- /internal/util/channel_types_bench_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "testing" 4 | 5 | // BenchmarkDetectChannelTypeFromPath 测试路径检测性能 6 | func BenchmarkDetectChannelTypeFromPath(b *testing.B) { 7 | testCases := []struct { 8 | name string 9 | path string 10 | }{ 11 | {"Anthropic", "/v1/messages"}, 12 | {"Codex", "/v1/responses"}, 13 | {"OpenAI_Chat", "/v1/chat/completions"}, 14 | {"OpenAI_Embeddings", "/v1/embeddings"}, 15 | {"Gemini", "/v1beta/models/gemini-pro:streamGenerateContent"}, 16 | {"Unknown", "/unknown/path"}, 17 | } 18 | 19 | for _, tc := range testCases { 20 | b.Run(tc.name, func(b *testing.B) { 21 | b.ReportAllocs() 22 | for i := 0; i < b.N; i++ { 23 | _ = DetectChannelTypeFromPath(tc.path) 24 | } 25 | }) 26 | } 27 | } 28 | 29 | // BenchmarkDetectChannelTypeFromPath_Parallel 并发性能测试 30 | func BenchmarkDetectChannelTypeFromPath_Parallel(b *testing.B) { 31 | path := "/v1/messages" 32 | b.RunParallel(func(pb *testing.PB) { 33 | for pb.Next() { 34 | _ = DetectChannelTypeFromPath(path) 35 | } 36 | }) 37 | } 38 | 39 | // BenchmarkNormalizeChannelType 测试渠道类型规范化性能 40 | func BenchmarkNormalizeChannelType(b *testing.B) { 41 | testCases := []struct { 42 | name string 43 | value string 44 | }{ 45 | {"Lowercase", "anthropic"}, 46 | {"Uppercase", "ANTHROPIC"}, 47 | {"MixedCase", "AnThRoPiC"}, 48 | {"WithSpaces", " anthropic "}, 49 | {"Empty", ""}, 50 | } 51 | 52 | for _, tc := range testCases { 53 | b.Run(tc.name, func(b *testing.B) { 54 | b.ReportAllocs() 55 | for i := 0; i < b.N; i++ { 56 | _ = NormalizeChannelType(tc.value) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | // BenchmarkMatchPath 测试路径匹配性能 63 | func BenchmarkMatchPath(b *testing.B) { 64 | testCases := []struct { 65 | name string 66 | path string 67 | patterns []string 68 | matchType string 69 | }{ 70 | {"Prefix_Match", "/v1/messages", []string{"/v1/messages"}, MatchTypePrefix}, 71 | {"Prefix_NoMatch", "/v2/messages", []string{"/v1/messages"}, MatchTypePrefix}, 72 | {"Contains_Match", "/v1beta/models/gemini", []string{"/v1beta/"}, MatchTypeContains}, 73 | {"Contains_NoMatch", "/v1/models/gemini", []string{"/v1beta/"}, MatchTypeContains}, 74 | {"MultiPattern", "/v1/embeddings", []string{"/v1/chat", "/v1/completions", "/v1/embeddings"}, MatchTypePrefix}, 75 | } 76 | 77 | for _, tc := range testCases { 78 | b.Run(tc.name, func(b *testing.B) { 79 | b.ReportAllocs() 80 | for i := 0; i < b.N; i++ { 81 | _ = matchPath(tc.path, tc.patterns, tc.matchType) 82 | } 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/storage/sql/admin_sessions.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "time" 8 | 9 | "ccLoad/internal/model" 10 | ) 11 | 12 | // CreateAdminSession 创建管理员会话 13 | // [INFO] 安全修复:存储token的SHA256哈希而非明文(2025-12) 14 | func (s *SQLStore) CreateAdminSession(ctx context.Context, token string, expiresAt time.Time) error { 15 | tokenHash := model.HashToken(token) 16 | now := timeToUnix(time.Now()) 17 | _, err := s.db.ExecContext(ctx, ` 18 | REPLACE INTO admin_sessions (token, expires_at, created_at) 19 | VALUES (?, ?, ?) 20 | `, tokenHash, timeToUnix(expiresAt), now) 21 | return err 22 | } 23 | 24 | // GetAdminSession 获取管理员会话 25 | // [INFO] 安全修复:通过token哈希查询(2025-12) 26 | func (s *SQLStore) GetAdminSession(ctx context.Context, token string) (expiresAt time.Time, exists bool, err error) { 27 | tokenHash := model.HashToken(token) 28 | var expiresUnix int64 29 | err = s.db.QueryRowContext(ctx, ` 30 | SELECT expires_at FROM admin_sessions WHERE token = ? 31 | `, tokenHash).Scan(&expiresUnix) 32 | 33 | if err != nil { 34 | if errors.Is(err, sql.ErrNoRows) { 35 | return time.Time{}, false, nil 36 | } 37 | return time.Time{}, false, err 38 | } 39 | 40 | return unixToTime(expiresUnix), true, nil 41 | } 42 | 43 | // DeleteAdminSession 删除管理员会话 44 | // [INFO] 安全修复:通过token哈希删除(2025-12) 45 | func (s *SQLStore) DeleteAdminSession(ctx context.Context, token string) error { 46 | tokenHash := model.HashToken(token) 47 | _, err := s.db.ExecContext(ctx, `DELETE FROM admin_sessions WHERE token = ?`, tokenHash) 48 | return err 49 | } 50 | 51 | // CleanExpiredSessions 清理过期的会话 52 | func (s *SQLStore) CleanExpiredSessions(ctx context.Context) error { 53 | now := timeToUnix(time.Now()) 54 | _, err := s.db.ExecContext(ctx, `DELETE FROM admin_sessions WHERE expires_at < ?`, now) 55 | return err 56 | } 57 | 58 | // LoadAllSessions 加载所有未过期的会话(启动时调用) 59 | // [INFO] 安全修复:返回tokenHash→expiry映射(2025-12) 60 | func (s *SQLStore) LoadAllSessions(ctx context.Context) (map[string]time.Time, error) { 61 | now := timeToUnix(time.Now()) 62 | rows, err := s.db.QueryContext(ctx, ` 63 | SELECT token, expires_at FROM admin_sessions WHERE expires_at > ? 64 | `, now) 65 | if err != nil { 66 | return nil, err 67 | } 68 | defer rows.Close() 69 | 70 | sessions := make(map[string]time.Time) 71 | for rows.Next() { 72 | var tokenHash string 73 | var expiresUnix int64 74 | if err := rows.Scan(&tokenHash, &expiresUnix); err != nil { 75 | return nil, err 76 | } 77 | sessions[tokenHash] = unixToTime(expiresUnix) 78 | } 79 | 80 | return sessions, rows.Err() 81 | } 82 | -------------------------------------------------------------------------------- /internal/model/config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Config 渠道配置 8 | type Config struct { 9 | ID int64 `json:"id"` 10 | Name string `json:"name"` 11 | ChannelType string `json:"channel_type"` // 渠道类型: "anthropic" | "codex" | "openai" | "gemini",默认anthropic 12 | URL string `json:"url"` 13 | Priority int `json:"priority"` 14 | Models []string `json:"models"` 15 | ModelRedirects map[string]string `json:"model_redirects,omitempty"` // 模型重定向映射:请求模型 -> 实际转发模型 16 | Enabled bool `json:"enabled"` 17 | 18 | // 渠道级冷却(从cooldowns表迁移) 19 | CooldownUntil int64 `json:"cooldown_until"` // Unix秒时间戳,0表示无冷却 20 | CooldownDurationMs int64 `json:"cooldown_duration_ms"` // 冷却持续时间(毫秒) 21 | 22 | CreatedAt JSONTime `json:"created_at"` // 使用JSONTime确保序列化格式一致(RFC3339) 23 | UpdatedAt JSONTime `json:"updated_at"` // 使用JSONTime确保序列化格式一致(RFC3339) 24 | 25 | // 缓存Key数量,避免冷却判断时的N+1查询 26 | KeyCount int `json:"key_count"` // API Key数量(查询时JOIN计算) 27 | } 28 | 29 | // GetChannelType 默认返回"anthropic"(Claude API) 30 | func (c *Config) GetChannelType() string { 31 | if c.ChannelType == "" { 32 | return "anthropic" 33 | } 34 | return c.ChannelType 35 | } 36 | 37 | func (c *Config) IsCoolingDown(now time.Time) bool { 38 | return c.CooldownUntil > now.Unix() 39 | } 40 | 41 | // KeyStrategy 常量定义 42 | const ( 43 | KeyStrategySequential = "sequential" // 顺序选择:按索引顺序尝试Key 44 | KeyStrategyRoundRobin = "round_robin" // 轮询选择:均匀分布请求到各个Key 45 | ) 46 | 47 | // IsValidKeyStrategy 验证KeyStrategy是否有效 48 | func IsValidKeyStrategy(s string) bool { 49 | return s == "" || s == KeyStrategySequential || s == KeyStrategyRoundRobin 50 | } 51 | 52 | type APIKey struct { 53 | ID int64 `json:"id"` 54 | ChannelID int64 `json:"channel_id"` 55 | KeyIndex int `json:"key_index"` 56 | APIKey string `json:"api_key"` 57 | 58 | KeyStrategy string `json:"key_strategy"` // "sequential" | "round_robin" 59 | 60 | // Key级冷却(从key_cooldowns表迁移) 61 | CooldownUntil int64 `json:"cooldown_until"` 62 | CooldownDurationMs int64 `json:"cooldown_duration_ms"` 63 | 64 | CreatedAt JSONTime `json:"created_at"` 65 | UpdatedAt JSONTime `json:"updated_at"` 66 | } 67 | 68 | func (k *APIKey) IsCoolingDown(now time.Time) bool { 69 | return k.CooldownUntil > now.Unix() 70 | } 71 | 72 | // ChannelWithKeys 用于Redis完整同步 73 | // 设计目标:解决Redis恢复后渠道缺少API Keys的问题 74 | type ChannelWithKeys struct { 75 | Config *Config `json:"config"` 76 | APIKeys []APIKey `json:"api_keys"` // 不使用指针避免额外分配 77 | } 78 | -------------------------------------------------------------------------------- /internal/model/log.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | // JSONTime 自定义时间类型,使用Unix时间戳进行JSON序列化 9 | // 设计原则:与数据库格式统一,减少转换复杂度(KISS原则) 10 | type JSONTime struct { 11 | time.Time 12 | } 13 | 14 | // MarshalJSON 实现JSON序列化 15 | func (jt JSONTime) MarshalJSON() ([]byte, error) { 16 | if jt.Time.IsZero() { 17 | return []byte("0"), nil 18 | } 19 | return []byte(strconv.FormatInt(jt.Time.Unix(), 10)), nil 20 | } 21 | 22 | // UnmarshalJSON 实现JSON反序列化 23 | func (jt *JSONTime) UnmarshalJSON(data []byte) error { 24 | if string(data) == "null" || string(data) == "0" { 25 | jt.Time = time.Time{} 26 | return nil 27 | } 28 | ts, err := strconv.ParseInt(string(data), 10, 64) 29 | if err != nil { 30 | return err 31 | } 32 | jt.Time = time.Unix(ts, 0) 33 | return nil 34 | } 35 | 36 | // LogEntry 请求日志条目 37 | type LogEntry struct { 38 | ID int64 `json:"id"` 39 | Time JSONTime `json:"time"` 40 | Model string `json:"model"` 41 | ChannelID int64 `json:"channel_id"` 42 | ChannelName string `json:"channel_name,omitempty"` 43 | StatusCode int `json:"status_code"` 44 | Message string `json:"message"` 45 | Duration float64 `json:"duration"` // 总耗时(秒) 46 | IsStreaming bool `json:"is_streaming"` // 是否为流式请求 47 | FirstByteTime float64 `json:"first_byte_time"` // 首字节响应时间(秒) 48 | APIKeyUsed string `json:"api_key_used"` // 使用的API Key(查询时自动脱敏为 abcd...klmn 格式) 49 | AuthTokenID int64 `json:"auth_token_id"` // 客户端使用的API令牌ID(新增2025-12,0表示未使用token) 50 | ClientIP string `json:"client_ip"` // 客户端IP地址(新增2025-12) 51 | 52 | // Token统计(2025-11新增,支持Claude API usage字段) 53 | InputTokens int `json:"input_tokens"` 54 | OutputTokens int `json:"output_tokens"` 55 | CacheReadInputTokens int `json:"cache_read_input_tokens"` 56 | CacheCreationInputTokens int `json:"cache_creation_input_tokens"` // 5m+1h缓存总和(兼容字段) 57 | Cache5mInputTokens int `json:"cache_5m_input_tokens"` // 5分钟缓存写入Token数(新增2025-12) 58 | Cache1hInputTokens int `json:"cache_1h_input_tokens"` // 1小时缓存写入Token数(新增2025-12) 59 | Cost float64 `json:"cost"` // 请求成本(美元) 60 | } 61 | 62 | // LogFilter 日志查询过滤条件 63 | type LogFilter struct { 64 | ChannelID *int64 65 | ChannelName string 66 | ChannelNameLike string 67 | Model string 68 | ModelLike string 69 | StatusCode *int 70 | ChannelType string // 渠道类型过滤(anthropic/openai/gemini/codex) 71 | AuthTokenID *int64 // API令牌ID过滤 72 | } 73 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ccLoad Docker镜像构建文件 2 | # 多平台构建:使用 tonistiigi/xx + Clang/LLVM 交叉编译 3 | # syntax=docker/dockerfile:1.4 4 | 5 | # 构建阶段 - 使用 BUILDPLATFORM 在原生架构执行 6 | FROM --platform=$BUILDPLATFORM golang:alpine AS builder 7 | 8 | # 版本号参数(优先使用 --build-arg,否则尝试从 git 获取) 9 | ARG VERSION 10 | ARG COMMIT 11 | 12 | # 安装交叉编译工具链 13 | # tonistiigi/xx 提供跨架构编译辅助工具 14 | COPY --from=tonistiigi/xx:1.6.1 / / 15 | RUN apk add --no-cache git ca-certificates tzdata clang lld 16 | 17 | # 设置工作目录 18 | WORKDIR /app 19 | 20 | # 配置目标平台的交叉编译工具链 21 | ARG TARGETPLATFORM 22 | RUN xx-apk add musl-dev gcc 23 | 24 | # 设置Go模块代理 25 | ENV GOPROXY=https://proxy.golang.org,direct 26 | 27 | # 复制go mod文件 28 | COPY go.mod go.sum ./ 29 | 30 | # 下载依赖(在原生平台执行,速度快) 31 | RUN --mount=type=cache,target=/root/.cache/go-mod \ 32 | go mod download 33 | 34 | # 复制源代码 35 | COPY . . 36 | 37 | # 交叉编译二进制文件(启用 CGO 以支持 bytedance/sonic) 38 | # xx-go 自动设置 GOOS/GOARCH/CC 等环境变量 39 | # VERSION 为空时从 git tag 获取,都没有则默认 "dev" 40 | ENV CGO_ENABLED=1 41 | RUN --mount=type=cache,target=/root/.cache/go-build \ 42 | --mount=type=cache,target=/root/.cache/go-mod \ 43 | BUILD_VERSION=${VERSION:-$(git describe --tags --always 2>/dev/null || echo "dev")} && \ 44 | BUILD_COMMIT=${COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")} && \ 45 | BUILD_COMMIT=$(echo $BUILD_COMMIT | cut -c1-7) && \ 46 | BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S %z') && \ 47 | xx-go build \ 48 | -tags go_json \ 49 | -ldflags="-s -w \ 50 | -X ccLoad/internal/version.Version=${BUILD_VERSION} \ 51 | -X ccLoad/internal/version.Commit=${BUILD_COMMIT} \ 52 | -X 'ccLoad/internal/version.BuildTime=${BUILD_TIME}' \ 53 | -X ccLoad/internal/version.BuiltBy=docker" \ 54 | -o ccload . && \ 55 | xx-verify ccload 56 | 57 | # 运行阶段 (使用固定版本避免 QEMU 模拟兼容性问题) 58 | FROM alpine:3.20 59 | 60 | # 安装运行时依赖 61 | RUN apk --no-cache add ca-certificates tzdata 62 | 63 | # 创建非root用户 64 | RUN addgroup -g 1001 -S ccload && \ 65 | adduser -u 1001 -S ccload -G ccload 66 | 67 | # 设置工作目录 68 | WORKDIR /app 69 | 70 | # 从构建阶段复制二进制文件 71 | COPY --from=builder /app/ccload . 72 | 73 | # 复制Web静态文件 74 | COPY --from=builder /app/web ./web 75 | 76 | # 创建数据目录并设置权限 77 | RUN mkdir -p /app/data && \ 78 | chown -R ccload:ccload /app 79 | 80 | # 切换到非root用户 81 | USER ccload 82 | 83 | # 暴露端口 84 | EXPOSE 8080 85 | 86 | # 设置环境变量 87 | ENV PORT=8080 88 | ENV SQLITE_PATH=/app/data/ccload.db 89 | ENV GIN_MODE=release 90 | 91 | # 健康检查(轻量级端点,<5ms响应) 92 | HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 93 | CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 94 | 95 | # 启动应用 96 | CMD ["./ccload"] 97 | -------------------------------------------------------------------------------- /internal/app/admin_response_contract_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "os" 9 | "sort" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | func TestAdminHandlers_DoNotUseGinJSONDirectly(t *testing.T) { 15 | t.Helper() 16 | 17 | // 这些调用会绕过 APIResponse 统一格式(success/data/error/count)。 18 | banned := map[string]bool{ 19 | "JSON": true, 20 | "IndentedJSON": true, 21 | "SecureJSON": true, 22 | "AsciiJSON": true, 23 | "PureJSON": true, 24 | "JSONP": true, 25 | "AbortWithStatusJSON": true, 26 | } 27 | 28 | var files []string 29 | entries, err := os.ReadDir(".") 30 | if err != nil { 31 | t.Fatalf("ReadDir: %v", err) 32 | } 33 | for _, e := range entries { 34 | name := e.Name() 35 | if e.IsDir() { 36 | continue 37 | } 38 | if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { 39 | continue 40 | } 41 | if strings.HasPrefix(name, "admin_") { 42 | files = append(files, name) 43 | } 44 | } 45 | 46 | // RequireTokenAuth 属于 Admin API 认证链路;RequireAPIAuth 属于代理API(不强制APIResponse格式)。 47 | files = append(files, "auth_service.go") 48 | sort.Strings(files) 49 | 50 | var offenders []string 51 | for _, filename := range files { 52 | allowInFunc := map[string]bool{} 53 | if filename == "auth_service.go" { 54 | allowInFunc["RequireAPIAuth"] = true 55 | } 56 | 57 | fset := token.NewFileSet() 58 | f, err := parser.ParseFile(fset, filename, nil, 0) 59 | if err != nil { 60 | t.Fatalf("ParseFile %s: %v", filename, err) 61 | } 62 | 63 | for _, decl := range f.Decls { 64 | fn, ok := decl.(*ast.FuncDecl) 65 | if !ok || fn.Body == nil { 66 | continue 67 | } 68 | 69 | funcName := "" 70 | if fn.Name != nil { 71 | funcName = fn.Name.Name 72 | } 73 | 74 | ast.Inspect(fn.Body, func(n ast.Node) bool { 75 | call, ok := n.(*ast.CallExpr) 76 | if !ok { 77 | return true 78 | } 79 | sel, ok := call.Fun.(*ast.SelectorExpr) 80 | if !ok || sel.Sel == nil { 81 | return true 82 | } 83 | if !banned[sel.Sel.Name] { 84 | return true 85 | } 86 | if allowInFunc[funcName] { 87 | return true 88 | } 89 | pos := fset.Position(sel.Sel.Pos()) 90 | offenders = append(offenders, fmt.Sprintf("%s:%d:%d %s.%s()", filename, pos.Line, pos.Column, funcName, sel.Sel.Name)) 91 | return true 92 | }) 93 | } 94 | } 95 | 96 | if len(offenders) > 0 { 97 | t.Fatalf("发现绕过APIResponse的直接JSON输出(请改用 RespondJSON/RespondError*):\n- %s", strings.Join(offenders, "\n- ")) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/app/token_stats_shutdown_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "ccLoad/internal/model" 10 | "ccLoad/internal/storage" 11 | ) 12 | 13 | func TestUpdateTokenStatsDuringShutdown(t *testing.T) { 14 | store, err := storage.CreateSQLiteStore(":memory:", nil) 15 | if err != nil { 16 | t.Fatalf("CreateSQLiteStore failed: %v", err) 17 | } 18 | 19 | srv := NewServer(store) 20 | 21 | ctx := context.Background() 22 | tokenHash := strings.Repeat("a", 64) 23 | if err := store.CreateAuthToken(ctx, &model.AuthToken{ 24 | Token: tokenHash, 25 | Description: "test", 26 | IsActive: true, 27 | }); err != nil { 28 | t.Fatalf("CreateAuthToken failed: %v", err) 29 | } 30 | 31 | // 阻塞wg.Wait,避免Shutdown过快走到store.Close,从而与“在途请求结束后写入统计”的场景失真 32 | blockCh := make(chan struct{}) 33 | srv.wg.Add(1) 34 | go func() { 35 | defer srv.wg.Done() 36 | <-blockCh 37 | }() 38 | 39 | shutdownErrCh := make(chan error, 1) 40 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 41 | defer cancel() 42 | go func() { 43 | shutdownErrCh <- srv.Shutdown(shutdownCtx) 44 | }() 45 | defer func() { 46 | close(blockCh) 47 | <-shutdownErrCh 48 | }() 49 | 50 | // 等待Shutdown进入“shutting down”状态 51 | deadline := time.Now().Add(1 * time.Second) 52 | for !srv.isShuttingDown.Load() { 53 | if time.Now().After(deadline) { 54 | t.Fatal("server did not enter shutting down state in time") 55 | } 56 | time.Sleep(1 * time.Millisecond) 57 | } 58 | 59 | // 模拟:shutdown开始后,一个在途请求完成并尝试写入计费/用量统计 60 | srv.updateTokenStatsAsync(tokenHash, true, 1.23, false, &fwResult{ 61 | FirstByteTime: 0.2, 62 | InputTokens: 10, 63 | OutputTokens: 20, 64 | CacheReadInputTokens: 5, 65 | CacheCreationInputTokens: 3, 66 | }, "gpt-5.1-codex") 67 | 68 | got, err := store.GetAuthTokenByValue(ctx, tokenHash) 69 | if err != nil { 70 | t.Fatalf("GetAuthTokenByValue failed: %v", err) 71 | } 72 | if got.SuccessCount != 1 { 73 | t.Fatalf("SuccessCount = %d, want %d", got.SuccessCount, 1) 74 | } 75 | if got.PromptTokensTotal != 10 { 76 | t.Fatalf("PromptTokensTotal = %d, want %d", got.PromptTokensTotal, 10) 77 | } 78 | if got.CompletionTokensTotal != 20 { 79 | t.Fatalf("CompletionTokensTotal = %d, want %d", got.CompletionTokensTotal, 20) 80 | } 81 | if got.CacheReadTokensTotal != 5 { 82 | t.Fatalf("CacheReadTokensTotal = %d, want %d", got.CacheReadTokensTotal, 5) 83 | } 84 | if got.CacheCreationTokensTotal != 3 { 85 | t.Fatalf("CacheCreationTokensTotal = %d, want %d", got.CacheCreationTokensTotal, 3) 86 | } 87 | if got.TotalCostUSD <= 0 { 88 | t.Fatalf("TotalCostUSD = %f, want > 0", got.TotalCostUSD) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /web/assets/js/date-range-selector.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 时间范围选择器 - 共享组件 3 | * 用于 logs/stats/trend 页面的统一时间范围选择 4 | * 5 | * 使用方式: 6 | * 1. 在HTML中引入: 7 | * 2. 调用 initDateRangeSelector(elementId, defaultRange, onChangeCallback) 8 | * 9 | * 后端API参数: range=today|yesterday|day_before_yesterday|this_week|last_week|this_month|last_month 10 | */ 11 | 12 | (function(window) { 13 | 'use strict'; 14 | 15 | // 时间范围预设 (key → 显示标签) 16 | // key与后端GetTimeRange()支持的range参数一致 17 | const DATE_RANGES = { 18 | 'today': { label: '本日' }, 19 | 'yesterday': { label: '昨日' }, 20 | 'day_before_yesterday': { label: '前日' }, 21 | 'this_week': { label: '本周' }, 22 | 'last_week': { label: '上周' }, 23 | 'this_month': { label: '本月' }, 24 | 'last_month': { label: '上月' } 25 | }; 26 | 27 | /** 28 | * 初始化时间范围选择器 29 | * @param {string} elementId - select元素的ID 30 | * @param {string} defaultRange - 默认选中的范围key (如'today') 31 | * @param {function} onChangeCallback - 值变化时的回调函数,接收range key参数 32 | */ 33 | window.initDateRangeSelector = function(elementId, defaultRange, onChangeCallback) { 34 | const selectEl = document.getElementById(elementId); 35 | if (!selectEl) { 36 | console.error(`时间范围选择器初始化失败: 未找到元素 #${elementId}`); 37 | return; 38 | } 39 | 40 | // 清空并重新生成选项 41 | selectEl.innerHTML = ''; 42 | Object.keys(DATE_RANGES).forEach(key => { 43 | const range = DATE_RANGES[key]; 44 | const option = document.createElement('option'); 45 | option.value = key; // 使用range key作为value 46 | option.textContent = range.label; 47 | selectEl.appendChild(option); 48 | }); 49 | 50 | // 设置默认值 51 | const validDefault = DATE_RANGES[defaultRange] ? defaultRange : 'today'; 52 | selectEl.value = validDefault; 53 | 54 | // 绑定change事件 55 | if (typeof onChangeCallback === 'function') { 56 | selectEl.addEventListener('change', function() { 57 | onChangeCallback(this.value); 58 | }); 59 | } 60 | }; 61 | 62 | /** 63 | * 获取范围的显示标签 64 | * @param {string} rangeKey - 范围key 65 | * @returns {string} 显示标签 66 | */ 67 | window.getRangeLabel = function(rangeKey) { 68 | return DATE_RANGES[rangeKey]?.label || '本日'; 69 | }; 70 | 71 | /** 72 | * 获取范围对应的大致小时数(用于metrics API的分桶计算) 73 | * @param {string} rangeKey - 范围key 74 | * @returns {number} 小时数 75 | */ 76 | window.getRangeHours = function(rangeKey) { 77 | const hoursMap = { 78 | 'today': 24, 79 | 'yesterday': 24, 80 | 'day_before_yesterday': 24, 81 | 'this_week': 168, 82 | 'last_week': 168, 83 | 'this_month': 720, 84 | 'last_month': 720 85 | }; 86 | return hoursMap[rangeKey] || 24; 87 | }; 88 | 89 | })(window); 90 | -------------------------------------------------------------------------------- /internal/util/time.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // 冷却时间常量定义 8 | const ( 9 | // AuthErrorInitialCooldown 认证错误(401/403)的初始冷却时间 10 | // 设计目标:减少认证失败的无效重试,避免API配额浪费 11 | AuthErrorInitialCooldown = 5 * time.Minute 12 | 13 | // TimeoutErrorCooldown 超时错误的固定冷却时间 14 | // 设计目标:上游服务响应超时或完全无响应时,直接冷却避免资源浪费和级联故障 15 | // 适用场景:网络超时、上游服务无响应等(状态码598) 16 | TimeoutErrorCooldown = time.Minute 17 | 18 | // ServerErrorInitialCooldown 服务器错误(500/502/503/504)的初始冷却时间 19 | // 设计目标:指数退避策略,起始2分钟(2min → 4min → 8min → 16min → 30min上限) 20 | ServerErrorInitialCooldown = 2 * time.Minute 21 | 22 | // OtherErrorInitialCooldown 其他错误(429等)的初始冷却时间 23 | OtherErrorInitialCooldown = 10 * time.Second 24 | 25 | // MaxCooldownDuration 最大冷却时长(指数退避上限) 26 | MaxCooldownDuration = 30 * time.Minute 27 | 28 | // MinCooldownDuration 最小冷却时长(指数退避下限) 29 | MinCooldownDuration = 10 * time.Second 30 | ) 31 | 32 | // calculateBackoffDuration 计算指数退避冷却时间 33 | // 统一冷却策略: 34 | // - 认证错误(401/402/403): 起始5分钟,后续翻倍,上限30分钟 35 | // - 服务器错误(500/502/503/504): 起始2分钟,后续翻倍,上限30分钟 36 | // - 其他错误(429等): 起始10秒,后续翻倍,上限30分钟 37 | // 38 | // 参数: 39 | // - prevMs: 上次冷却持续时间(毫秒) 40 | // - until: 上次冷却截止时间 41 | // - now: 当前时间 42 | // - statusCode: HTTP状态码(可选,用于首次错误时确定初始冷却时间) 43 | // 44 | // 返回: 新的冷却持续时间 45 | // CalculateBackoffDuration 计算指数退避冷却时间 46 | func CalculateBackoffDuration(prevMs int64, until time.Time, now time.Time, statusCode *int) time.Duration { 47 | // 转换上次冷却持续时间 48 | prev := time.Duration(prevMs) * time.Millisecond 49 | 50 | // 如果没有历史记录,检查until字段 51 | if prev <= 0 { 52 | if !until.IsZero() && until.After(now) { 53 | prev = until.Sub(now) 54 | } else { 55 | // 首次错误:根据状态码确定初始冷却时间(直接返回,不翻倍) 56 | // 597/598:SSE错误/首字节超时,1分钟冷却,指数退避(1min → 2min → 4min → ...) 57 | if statusCode != nil && (*statusCode == 597 || *statusCode == 598) { 58 | return TimeoutErrorCooldown 59 | } 60 | // 服务器错误(500/502/503/504/520/521/524/599):2分钟冷却,指数退避(2min → 4min → 8min → ...) 61 | // 599:流式响应不完整,归类为服务器错误(上游服务问题) 62 | if statusCode != nil && (*statusCode == 500 || *statusCode == 502 || *statusCode == 503 || *statusCode == 504 || *statusCode == 520 || *statusCode == 521 || *statusCode == 524 || *statusCode == 599) { 63 | return ServerErrorInitialCooldown 64 | } 65 | // 认证错误(401/402/403):5分钟冷却,减少无效重试 66 | if statusCode != nil && (*statusCode == 401 || *statusCode == 402 || *statusCode == 403) { 67 | return AuthErrorInitialCooldown 68 | } 69 | // 其他错误(429等):10秒冷却,允许快速恢复 70 | return OtherErrorInitialCooldown 71 | } 72 | } 73 | 74 | // 后续错误:指数退避翻倍 // 边界限制(使用常量) 75 | 76 | next := min(max(prev*2, MinCooldownDuration), MaxCooldownDuration) 77 | 78 | return next 79 | } 80 | 81 | // CalculateCooldownDuration 计算冷却持续时间(毫秒) 82 | func CalculateCooldownDuration(until time.Time, now time.Time) int64 { 83 | if until.IsZero() || !until.After(now) { 84 | return 0 85 | } 86 | return int64(until.Sub(now) / time.Millisecond) 87 | } 88 | -------------------------------------------------------------------------------- /internal/app/proxy_forward_soft_error_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "testing" 4 | 5 | func TestCheckSoftError(t *testing.T) { 6 | t.Parallel() 7 | 8 | tests := []struct { 9 | name string 10 | contentType string 11 | data []byte 12 | want bool 13 | }{ 14 | { 15 | name: "json_top_level_type_error", 16 | contentType: "application/json; charset=utf-8", 17 | data: []byte(`{"type":"error","message":"boom"}`), 18 | want: true, 19 | }, 20 | { 21 | name: "json_top_level_error_field", 22 | contentType: "application/json", 23 | data: []byte(`{"error":{"message":"boom"}}`), 24 | want: true, 25 | }, 26 | { 27 | name: "json_success_contains_keywords_should_not_match", 28 | contentType: "application/json", 29 | data: []byte(`{"type":"message","content":"api_error 当前模型负载过高"}`), 30 | want: false, 31 | }, 32 | { 33 | name: "json_truncated_object_should_not_guess", 34 | contentType: "application/json", 35 | data: []byte(`{"type":"error"`), 36 | want: false, 37 | }, 38 | { 39 | name: "json_content_type_but_plain_text_prefix_can_match", 40 | contentType: "application/json", 41 | data: []byte("当前模型负载过高,请稍后再试"), 42 | want: true, 43 | }, 44 | { 45 | name: "text_plain_prefix_short_match", 46 | contentType: "text/plain; charset=utf-8", 47 | data: []byte("当前模型负载过高,请稍后再试"), 48 | want: true, 49 | }, 50 | { 51 | name: "text_plain_contains_but_not_prefix_should_not_match", 52 | contentType: "text/plain", 53 | data: []byte("回答里提到 当前模型负载过高 但这不是错误"), 54 | want: false, 55 | }, 56 | { 57 | name: "text_plain_sse_should_not_match", 58 | contentType: "text/plain", 59 | data: []byte("data: {\"type\":\"message\"}\n\n"), 60 | want: false, 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | t.Parallel() 67 | if got := checkSoftError(tt.data, tt.contentType); got != tt.want { 68 | t.Fatalf("checkSoftError()=%v, want %v", got, tt.want) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func TestShouldCheckSoftErrorForChannelType(t *testing.T) { 75 | t.Parallel() 76 | 77 | tests := []struct { 78 | name string 79 | channelType string 80 | want bool 81 | }{ 82 | {name: "anthropic", channelType: "anthropic", want: true}, 83 | {name: "codex", channelType: "codex", want: true}, 84 | {name: "anthropic_default_empty", channelType: "", want: true}, 85 | {name: "openai", channelType: "openai", want: false}, 86 | {name: "gemini", channelType: "gemini", want: false}, 87 | {name: "unknown", channelType: "something", want: false}, 88 | } 89 | 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | t.Parallel() 93 | if got := shouldCheckSoftErrorForChannelType(tt.channelType); got != tt.want { 94 | t.Fatalf("shouldCheckSoftErrorForChannelType()=%v, want %v", got, tt.want) 95 | } 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/app/proxy_stream.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | // ============================================================================ 10 | // 流式传输数据结构 11 | // ============================================================================ 12 | 13 | // streamReadStats 流式传输统计信息 14 | type streamReadStats struct { 15 | readCount int 16 | totalBytes int64 17 | } 18 | 19 | // firstByteDetector 检测首字节读取时间和传输统计的Reader包装器 20 | type firstByteDetector struct { 21 | io.ReadCloser 22 | stats *streamReadStats 23 | onFirstRead func() 24 | } 25 | 26 | // Read 实现io.Reader接口,记录读取统计 27 | func (r *firstByteDetector) Read(p []byte) (n int, err error) { 28 | n, err = r.ReadCloser.Read(p) 29 | if n > 0 { 30 | // 记录统计信息 31 | if r.stats != nil { 32 | r.stats.readCount++ 33 | r.stats.totalBytes += int64(n) 34 | } 35 | // 触发首次读取回调 36 | if r.onFirstRead != nil { 37 | r.onFirstRead() 38 | r.onFirstRead = nil // 只触发一次 39 | } 40 | } 41 | return 42 | } 43 | 44 | // ============================================================================ 45 | // 流式传输核心函数 46 | // ============================================================================ 47 | 48 | func streamCopyWithBufferSize(ctx context.Context, src io.Reader, dst http.ResponseWriter, onData func([]byte) error, bufSize int) error { 49 | buf := make([]byte, bufSize) 50 | for { 51 | select { 52 | case <-ctx.Done(): 53 | return ctx.Err() 54 | default: 55 | } 56 | 57 | n, err := src.Read(buf) 58 | if n > 0 { 59 | if _, writeErr := dst.Write(buf[:n]); writeErr != nil { 60 | return writeErr 61 | } 62 | if flusher, ok := dst.(http.Flusher); ok { 63 | flusher.Flush() 64 | } 65 | if onData != nil { 66 | if hookErr := onData(buf[:n]); hookErr != nil { 67 | // 钩子错误不中断流传输(容错设计) 68 | // 错误日志由钩子内部自行处理 69 | } 70 | } 71 | } 72 | if err != nil { 73 | if err == io.EOF { 74 | return nil 75 | } 76 | // [FIX] 检查 context 是否在 Read 期间被取消 77 | // 场景:客户端取消请求 → HTTP/2 流关闭 → Read 返回 "http2: response body closed" 78 | // 此时应返回 context.Canceled,让上层正确识别为客户端断开(499)而非上游错误(502) 79 | if ctx.Err() != nil { 80 | return ctx.Err() 81 | } 82 | return err 83 | } 84 | } 85 | } 86 | 87 | // streamCopy 流式复制(支持flusher与ctx取消) 88 | // 从proxy.go提取,遵循SRP原则 89 | // 简化实现:直接循环读取与写入,避免为每次读取创建goroutine导致泄漏 90 | // 首字节超时依赖于上游握手/响应头阶段的超时控制(Transport 配置),此处不再重复实现 91 | func streamCopy(ctx context.Context, src io.Reader, dst http.ResponseWriter, onData func([]byte) error) error { 92 | return streamCopyWithBufferSize(ctx, src, dst, onData, StreamBufferSize) 93 | } 94 | 95 | // streamCopySSE SSE专用流式复制(使用小缓冲区优化延迟) 96 | // [INFO] SSE优化(2025-10-17):4KB缓冲区降低首Token延迟60~80% 97 | // [INFO] 支持数据钩子(2025-11):允许SSE usage解析器增量处理数据流 98 | // 设计原则:SSE事件通常200B-2KB,小缓冲区避免事件积压 99 | func streamCopySSE(ctx context.Context, src io.Reader, dst http.ResponseWriter, onData func([]byte) error) error { 100 | return streamCopyWithBufferSize(ctx, src, dst, onData, SSEBufferSize) 101 | } 102 | -------------------------------------------------------------------------------- /internal/config/defaults.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | // HTTP服务器配置常量 6 | const ( 7 | // DefaultMaxConcurrency 默认最大并发请求数 8 | DefaultMaxConcurrency = 1000 9 | 10 | // DefaultMaxKeyRetries 单个渠道内最大Key重试次数 11 | DefaultMaxKeyRetries = 3 12 | 13 | // DefaultMaxBodyBytes 默认最大请求体字节数(用于代理入口的解析) 14 | DefaultMaxBodyBytes = 2 * 1024 * 1024 // 2MB 15 | ) 16 | 17 | // HTTP客户端配置常量 18 | const ( 19 | // HTTPDialTimeout DNS解析+TCP连接建立超时 20 | HTTPDialTimeout = 30 * time.Second 21 | 22 | // HTTPKeepAliveInterval TCP keepalive间隔 23 | // 15秒:快速检测僵死连接(上游进程崩溃、网络中断) 24 | // 配合Linux默认重试(9次×3s),总检测时间42秒 25 | HTTPKeepAliveInterval = 15 * time.Second 26 | 27 | // HTTPTLSHandshakeTimeout TLS握手超时 28 | HTTPTLSHandshakeTimeout = 30 * time.Second 29 | 30 | // HTTPMaxIdleConns 全局空闲连接池大小 31 | HTTPMaxIdleConns = 100 32 | 33 | // HTTPMaxIdleConnsPerHost 单host空闲连接数 34 | HTTPMaxIdleConnsPerHost = 5 35 | 36 | // HTTPMaxConnsPerHost 单host最大连接数 37 | HTTPMaxConnsPerHost = 50 38 | 39 | // TLSSessionCacheSize TLS会话缓存大小 40 | TLSSessionCacheSize = 1024 41 | ) 42 | 43 | // 日志系统配置常量 44 | const ( 45 | // DefaultLogBufferSize 默认日志缓冲区大小(条数) 46 | DefaultLogBufferSize = 1000 47 | 48 | // DefaultLogWorkers 默认日志Worker协程数 49 | // 改为1以保证日志写入顺序(FIFO) 50 | // 多worker会导致竞争消费logChan,打乱日志顺序 51 | // 性能影响: 单worker仍支持批量写入,性能足够(1000条/秒+) 52 | DefaultLogWorkers = 1 53 | 54 | // LogBatchSize 批量写入日志的大小(条数) 55 | LogBatchSize = 100 56 | 57 | // LogBatchTimeout 批量写入超时时间 58 | LogBatchTimeout = 1 * time.Second 59 | 60 | // LogFlushTimeoutMs 单次日志刷盘的超时时间(毫秒) 61 | // 关停期间需要尽快完成,避免测试和生产关停卡顿 62 | LogFlushTimeoutMs = 300 63 | ) 64 | 65 | // Token认证配置常量 66 | const ( 67 | // TokenRandomBytes Token随机字节数(生成64字符十六进制) 68 | TokenRandomBytes = 32 69 | 70 | // TokenExpiry Token有效期 71 | TokenExpiry = 24 * time.Hour 72 | 73 | // TokenCleanupInterval Token清理间隔 74 | TokenCleanupInterval = 1 * time.Hour 75 | ) 76 | 77 | // Token统计配置常量 78 | const ( 79 | // DefaultTokenStatsBufferSize 默认Token统计更新队列大小(条数) 80 | // 设计原则:有界队列,避免每请求起goroutine导致资源失控 81 | DefaultTokenStatsBufferSize = 1000 82 | ) 83 | 84 | // SQLite连接池配置常量 85 | const ( 86 | // SQLiteMaxOpenConnsFile 文件模式最大连接数(WAL写并发瓶颈) 87 | // 保持5:1写 + 4读 = 充分利用WAL模式并发能力 88 | SQLiteMaxOpenConnsFile = 5 89 | 90 | // SQLiteMaxIdleConnsFile 文件模式最大空闲连接数 91 | // [INFO] 从2提升到5:避免高并发时频繁创建/销毁连接 92 | // 设计原则:空闲连接数 = 最大连接数,减少连接重建开销 93 | SQLiteMaxIdleConnsFile = 5 94 | 95 | // SQLiteConnMaxLifetime 连接最大生命周期 96 | // [INFO] 从1分钟提升到5分钟:降低连接过期频率 97 | // 权衡:更长的生命周期 vs 更低的连接重建开销 98 | SQLiteConnMaxLifetime = 5 * time.Minute 99 | ) 100 | 101 | // 性能优化配置常量 102 | const ( 103 | // LogCleanupInterval 日志清理间隔 104 | LogCleanupInterval = 1 * time.Hour 105 | ) 106 | 107 | // Redis同步配置常量 108 | const ( 109 | // RedisSyncShutdownTimeoutMs 优雅关闭等待时间(毫秒) 110 | RedisSyncShutdownTimeoutMs = 100 111 | ) 112 | 113 | // 启动超时配置(Fail-Fast:启动阶段网络问题应快速失败,避免卡死) 114 | const ( 115 | // StartupDBPingTimeout 数据库连接测试超时 116 | StartupDBPingTimeout = 10 * time.Second 117 | // StartupMigrationTimeout 数据库迁移超时 118 | StartupMigrationTimeout = 30 * time.Second 119 | // StartupRedisRestoreTimeout Redis数据恢复超时 120 | StartupRedisRestoreTimeout = 30 * time.Second 121 | ) 122 | -------------------------------------------------------------------------------- /internal/util/channel_types.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | // ChannelTypeConfig 渠道类型配置(元数据定义) 6 | type ChannelTypeConfig struct { 7 | Value string `json:"value"` // 内部值(数据库存储) 8 | DisplayName string `json:"display_name"` // 显示名称(前端展示) 9 | Description string `json:"description"` // 描述信息 10 | PathPatterns []string `json:"path_patterns"` // 路径匹配模式列表 11 | MatchType string `json:"match_type"` // 匹配类型: "prefix"(前缀) 或 "contains"(包含) 12 | } 13 | 14 | // ChannelTypes 全局渠道类型配置(单一数据源 - Single Source of Truth) 15 | var ChannelTypes = []ChannelTypeConfig{ 16 | { 17 | Value: ChannelTypeAnthropic, 18 | DisplayName: "Claude Code", 19 | Description: "Claude Code兼容API", 20 | PathPatterns: []string{"/v1/messages"}, 21 | MatchType: MatchTypePrefix, 22 | }, 23 | { 24 | Value: ChannelTypeCodex, 25 | DisplayName: "Codex", 26 | Description: "Codex兼容API", 27 | PathPatterns: []string{"/v1/responses"}, 28 | MatchType: MatchTypePrefix, 29 | }, 30 | { 31 | Value: ChannelTypeOpenAI, 32 | DisplayName: "OpenAI", 33 | Description: "OpenAI API (GPT系列)", 34 | PathPatterns: []string{"/v1/chat/completions", "/v1/completions", "/v1/embeddings"}, 35 | MatchType: MatchTypePrefix, 36 | }, 37 | { 38 | Value: ChannelTypeGemini, 39 | DisplayName: "Google Gemini", 40 | Description: "Google Gemini API", 41 | PathPatterns: []string{"/v1beta/"}, 42 | MatchType: MatchTypeContains, 43 | }, 44 | } 45 | 46 | // IsValidChannelType 验证渠道类型是否有效(替代models.go中的硬编码) 47 | func IsValidChannelType(value string) bool { 48 | for _, ct := range ChannelTypes { 49 | if ct.Value == value { 50 | return true 51 | } 52 | } 53 | return false 54 | } 55 | 56 | // NormalizeChannelType 规范化渠道类型(兼容性处理) 57 | // - 去除首尾空格 58 | // - 转小写 59 | // - 空值 → "anthropic" (默认值) 60 | func NormalizeChannelType(value string) string { 61 | // 去除首尾空格 62 | value = strings.TrimSpace(value) 63 | 64 | // 空值返回默认值 65 | if value == "" { 66 | return "anthropic" 67 | } 68 | 69 | // 转小写 70 | return strings.ToLower(value) 71 | } 72 | 73 | // 渠道类型常量(导出供其他包使用,遵循DRY原则) 74 | const ( 75 | ChannelTypeAnthropic = "anthropic" 76 | ChannelTypeCodex = "codex" 77 | ChannelTypeOpenAI = "openai" 78 | ChannelTypeGemini = "gemini" 79 | ) 80 | 81 | // 匹配类型常量(路径匹配方式) 82 | const ( 83 | MatchTypePrefix = "prefix" // 前缀匹配(strings.HasPrefix) 84 | MatchTypeContains = "contains" // 包含匹配(strings.Contains) 85 | ) 86 | 87 | // DetectChannelTypeFromPath 根据请求路径自动检测渠道类型 88 | // 使用 ChannelTypes 配置进行统一检测,遵循DRY原则 89 | func DetectChannelTypeFromPath(path string) string { 90 | for _, ct := range ChannelTypes { 91 | if matchPath(path, ct.PathPatterns, ct.MatchType) { 92 | return ct.Value 93 | } 94 | } 95 | return "" // 未匹配到任何类型 96 | } 97 | 98 | // matchPath 辅助函数:根据匹配类型检查路径是否匹配模式列表 99 | func matchPath(path string, patterns []string, matchType string) bool { 100 | for _, pattern := range patterns { 101 | switch matchType { 102 | case MatchTypePrefix: 103 | if strings.HasPrefix(path, pattern) { 104 | return true 105 | } 106 | case MatchTypeContains: 107 | if strings.Contains(path, pattern) { 108 | return true 109 | } 110 | } 111 | } 112 | return false 113 | } 114 | -------------------------------------------------------------------------------- /web/assets/js/template-engine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 轻量级模板引擎 3 | * 使用原生 HTML