| package storage |
|
|
| import ( |
| "context" |
| "database/sql" |
| "fmt" |
| "strings" |
|
|
| "ccLoad/internal/storage/schema" |
| ) |
|
|
| const ( |
| channelModelsRedirectMigrationVersion = "v1_channel_models_redirect" |
| channelModelsOrderRepairVersion = "v2_channel_models_created_at_order" |
| ) |
|
|
| |
| type Dialect int |
|
|
| |
| const ( |
| |
| DialectSQLite Dialect = iota |
| |
| DialectMySQL |
| ) |
|
|
| |
| func migrateSQLite(ctx context.Context, db *sql.DB) error { |
| return migrate(ctx, db, DialectSQLite) |
| } |
|
|
| |
| func migrateMySQL(ctx context.Context, db *sql.DB) error { |
| return migrate(ctx, db, DialectMySQL) |
| } |
|
|
| |
| func migrate(ctx context.Context, db *sql.DB, dialect Dialect) error { |
| |
| tables := []func() *schema.TableBuilder{ |
| schema.DefineSchemaMigrationsTable, |
| schema.DefineChannelsTable, |
| schema.DefineAPIKeysTable, |
| schema.DefineChannelModelsTable, |
| schema.DefineChannelProtocolTransformsTable, |
| schema.DefineChannelURLStatesTable, |
| schema.DefineAuthTokensTable, |
| schema.DefineSystemSettingsTable, |
| schema.DefineAdminSessionsTable, |
| schema.DefineLogsTable, |
| schema.DefineDebugLogsTable, |
| } |
|
|
| |
| allIndexes, err := loadAllExistingIndexes(ctx, db, dialect) |
| if err != nil { |
| return fmt.Errorf("load all existing indexes: %w", err) |
| } |
|
|
| |
| for _, defineTable := range tables { |
| tb := defineTable() |
|
|
| |
| if tb.Name() == "debug_logs" { |
| if err := rebuildDebugLogsPrimaryKey(ctx, db, dialect); err != nil { |
| return fmt.Errorf("rebuild debug_logs primary key: %w", err) |
| } |
| if err := relaxDebugLogsRespBodyNullable(ctx, db, dialect); err != nil { |
| return fmt.Errorf("relax debug_logs.resp_body nullability: %w", err) |
| } |
| delete(allIndexes, "debug_logs") |
| } |
|
|
| |
| |
| if tb.Name() == "channel_url_states" { |
| if err := rebuildChannelURLStatesPrimaryKey(ctx, db, dialect); err != nil { |
| return fmt.Errorf("rebuild channel_url_states primary key: %w", err) |
| } |
| delete(allIndexes, "channel_url_states") |
| } |
|
|
| |
| if _, err := db.ExecContext(ctx, buildDDL(tb, dialect)); err != nil { |
| return fmt.Errorf("create %s table: %w", tb.Name(), err) |
| } |
|
|
| |
| if tb.Name() == "logs" { |
| if err := ensureLogsNewColumns(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate logs new columns: %w", err) |
| } |
| if err := ensureLogsCostMultiplier(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate logs cost_multiplier: %w", err) |
| } |
| } |
|
|
| |
| if tb.Name() == "channels" { |
| if err := ensureChannelsDailyCostLimit(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channels daily_cost_limit: %w", err) |
| } |
| if err := ensureChannelsRPMLimit(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channels rpm_limit: %w", err) |
| } |
| if err := ensureChannelsProtocolTransformMode(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channels protocol_transform_mode: %w", err) |
| } |
| if err := ensureChannelsScheduledCheckEnabled(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channels scheduled_check_enabled: %w", err) |
| } |
| if err := ensureChannelsScheduledCheckModel(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channels scheduled_check_model: %w", err) |
| } |
| if err := ensureChannelsCustomRequestRules(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channels custom_request_rules: %w", err) |
| } |
| if err := ensureChannelsCostMultiplier(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channels cost_multiplier: %w", err) |
| } |
| |
| if err := migrateChannelsURLToText(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channels url to text: %w", err) |
| } |
| } |
|
|
| |
| if tb.Name() == "api_keys" { |
| if err := ensureAPIKeysAPIKeyLength(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate api_keys api_key column: %w", err) |
| } |
| if err := ensureAPIKeysDisabled(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate api_keys disabled: %w", err) |
| } |
| } |
|
|
| |
| if tb.Name() == "auth_tokens" { |
| if err := ensureAuthTokensCacheFields(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate auth_tokens cache fields: %w", err) |
| } |
| if err := ensureAuthTokensAllowedModels(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate auth_tokens allowed_models: %w", err) |
| } |
| if err := validateAuthTokensAllowedModelsJSON(ctx, db); err != nil { |
| return fmt.Errorf("validate auth_tokens allowed_models: %w", err) |
| } |
| if err := ensureAuthTokensAllowedChannelIDs(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate auth_tokens allowed_channel_ids: %w", err) |
| } |
| if err := validateAuthTokensAllowedChannelIDsJSON(ctx, db); err != nil { |
| return fmt.Errorf("validate auth_tokens allowed_channel_ids: %w", err) |
| } |
| if err := ensureAuthTokensCostLimit(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate auth_tokens cost_limit: %w", err) |
| } |
| if err := ensureAuthTokensMaxConcurrency(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate auth_tokens max_concurrency: %w", err) |
| } |
| if err := validateAuthTokensMaxConcurrency(ctx, db); err != nil { |
| return fmt.Errorf("validate auth_tokens max_concurrency: %w", err) |
| } |
| } |
|
|
| |
| if tb.Name() == "channel_models" { |
| if err := migrateChannelModelsSchema(ctx, db, dialect); err != nil { |
| return fmt.Errorf("migrate channel_models schema: %w", err) |
| } |
| if err := repairLegacyChannelModelOrder(ctx, db, dialect); err != nil { |
| return fmt.Errorf("repair legacy channel_models order: %w", err) |
| } |
| } |
|
|
| |
| existingIdx := allIndexes[tb.Name()] |
| for _, idx := range buildIndexes(tb, dialect) { |
| if existingIdx[idx.Name] { |
| continue |
| } |
| if err := createIndex(ctx, db, idx, dialect); err != nil { |
| return err |
| } |
| } |
| } |
|
|
| |
| if err := initDefaultSettings(ctx, db, dialect); err != nil { |
| return err |
| } |
|
|
| |
| if err := cleanupRemovedSettings(ctx, db, dialect); err != nil { |
| return err |
| } |
|
|
| return nil |
| } |
|
|
| func cleanupRemovedSettings(ctx context.Context, db *sql.DB, dialect Dialect) error { |
| |
| if err := deleteSystemSetting(ctx, db, dialect, "skip_tls_verify"); err != nil { |
| return err |
| } |
| |
| if err := deleteSystemSetting(ctx, db, dialect, "model_lookup_strip_date_suffix"); err != nil { |
| return err |
| } |
| return nil |
| } |
|
|
| func deleteSystemSetting(ctx context.Context, db *sql.DB, dialect Dialect, key string) error { |
| query := "DELETE FROM system_settings WHERE key = ?" |
| if dialect == DialectMySQL { |
| query = "DELETE FROM system_settings WHERE `key` = ?" |
| } |
| if _, err := db.ExecContext(ctx, query, key); err != nil { |
| return fmt.Errorf("delete system setting %s: %w", key, err) |
| } |
| return nil |
| } |
|
|
| |
| func hasSystemSetting(ctx context.Context, db *sql.DB, dialect Dialect, key string) bool { |
| query := "SELECT 1 FROM system_settings WHERE key = ? LIMIT 1" |
| if dialect == DialectMySQL { |
| query = "SELECT 1 FROM system_settings WHERE `key` = ? LIMIT 1" |
| } |
| var exists int |
| err := db.QueryRowContext(ctx, query, key).Scan(&exists) |
| return err == nil |
| } |
|
|
| |
| func loadAllExistingIndexes(ctx context.Context, db *sql.DB, dialect Dialect) (map[string]map[string]bool, error) { |
| var query string |
| if dialect == DialectMySQL { |
| query = "SELECT DISTINCT TABLE_NAME, INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()" |
| } else { |
| query = "SELECT tbl_name, name FROM sqlite_master WHERE type='index' AND tbl_name IS NOT NULL" |
| } |
| rows, err := db.QueryContext(ctx, query) |
| if err != nil { |
| return nil, fmt.Errorf("query all indexes: %w", err) |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| result := make(map[string]map[string]bool) |
| for rows.Next() { |
| var tbl, idx string |
| if err := rows.Scan(&tbl, &idx); err != nil { |
| return nil, fmt.Errorf("scan index row: %w", err) |
| } |
| if result[tbl] == nil { |
| result[tbl] = make(map[string]bool) |
| } |
| result[tbl][idx] = true |
| } |
| if err := rows.Err(); err != nil { |
| return nil, fmt.Errorf("iterate indexes: %w", err) |
| } |
| return result, nil |
| } |
|
|
| func buildDDL(tb *schema.TableBuilder, dialect Dialect) string { |
| if dialect == DialectMySQL { |
| return tb.BuildMySQL() |
| } |
| return tb.BuildSQLite() |
| } |
|
|
| func buildIndexes(tb *schema.TableBuilder, dialect Dialect) []schema.IndexDef { |
| if dialect == DialectMySQL { |
| return tb.GetIndexesMySQL() |
| } |
| return tb.GetIndexesSQLite() |
| } |
|
|
| func createIndex(ctx context.Context, db *sql.DB, idx schema.IndexDef, dialect Dialect) error { |
| _, err := db.ExecContext(ctx, idx.SQL) |
| if err == nil { |
| return nil |
| } |
|
|
| |
| if dialect == DialectMySQL { |
| errMsg := err.Error() |
| if strings.Contains(errMsg, "1061") || |
| strings.Contains(errMsg, "Duplicate key name") || |
| strings.Contains(errMsg, "already exist") { |
| return nil |
| } |
| } |
|
|
| return fmt.Errorf("create index: %w", err) |
| } |
|
|
| func initDefaultSettings(ctx context.Context, db *sql.DB, dialect Dialect) error { |
| settings := []struct { |
| key, value, valueType, desc, defaultVal string |
| }{ |
| {"log_retention_days", "7", "int", "日志保留天数(-1永久保留,1-365天)", "7"}, |
| {"max_key_retries", "3", "int", "单渠道最大Key重试次数", "3"}, |
| {"upstream_first_byte_timeout", "0", "duration", "上游首个有效流内容超时(秒,0=禁用,仅流式)", "0"}, |
| {"non_stream_timeout", "120", "duration", "非流式请求超时(秒,0=禁用)", "120"}, |
| {"model_fuzzy_match", "false", "bool", "模型匹配失败时,使用子串模糊匹配(多匹配时选最新版本)", "false"}, |
| {"channel_test_content", "sonnet 4.0的发布日期是什么", "string", "渠道测试默认内容", "sonnet 4.0的发布日期是什么"}, |
| {"channel_check_interval_hours", "5", "int", "渠道定时检测间隔(小时,0=关闭,修改后重启生效)", "5"}, |
| {"log_channel_click_action", "edit", "string", "日志页点击渠道名行为(edit=打开编辑器,navigate=跳转到渠道管理定位)", "edit"}, |
| {"channel_stats_range", "today", "string", "渠道管理费用统计范围", "today"}, |
| |
| {"enable_health_score", "false", "bool", "启用基于健康度的渠道动态排序", "false"}, |
| {"success_rate_penalty_weight", "100", "int", "成功率惩罚权重(乘以失败率)", "100"}, |
| {"health_score_window_minutes", "30", "int", "成功率统计时间窗口(分钟)", "30"}, |
| {"health_score_update_interval", "30", "int", "成功率缓存更新间隔(秒)", "30"}, |
| {"health_min_confident_sample", "20", "int", "置信样本量阈值(样本量达到此值时惩罚全额生效)", "20"}, |
| |
| {"cooldown_fallback_enabled", "true", "bool", "所有渠道冷却时选最优渠道兜底(关闭则直接拒绝请求)", "true"}, |
| |
| {"debug_log_enabled", "false", "bool", "启用Debug日志(记录上游请求/响应原始数据)", "false"}, |
| {"debug_log_retention_minutes", "2", "int", "Debug日志保留时长(分钟,1-1440)", "2"}, |
| |
| {"auto_refresh_interval_seconds", "0", "int", "页面自动刷新间隔(秒,0=禁用,建议≥30;有对话框打开时跳过本次刷新)", "0"}, |
| } |
|
|
| var query string |
| if dialect == DialectMySQL { |
| query = "INSERT IGNORE INTO system_settings (`key`, value, value_type, description, default_value, updated_at) VALUES (?, ?, ?, ?, ?, UNIX_TIMESTAMP())" |
| } else { |
| query = "INSERT OR IGNORE INTO system_settings (key, value, value_type, description, default_value, updated_at) VALUES (?, ?, ?, ?, ?, unixepoch())" |
| } |
|
|
| for _, s := range settings { |
| if _, err := db.ExecContext(ctx, query, s.key, s.value, s.valueType, s.desc, s.defaultVal); err != nil { |
| return fmt.Errorf("insert default setting %s: %w", s.key, err) |
| } |
| } |
|
|
| |
| { |
| keyCol := "key" |
| if dialect == DialectMySQL { |
| keyCol = "`key`" |
| } |
| |
| metaSQL := fmt.Sprintf("UPDATE system_settings SET description = ?, default_value = ?, value_type = ? WHERE %s = ?", keyCol) |
| if _, err := db.ExecContext(ctx, metaSQL, |
| "上游首个有效流内容超时(秒,0=禁用,仅流式)", |
| "0", |
| "duration", |
| "upstream_first_byte_timeout", |
| ); err != nil { |
| return fmt.Errorf("refresh setting metadata upstream_first_byte_timeout: %w", err) |
| } |
| if _, err := db.ExecContext(ctx, metaSQL, |
| "Debug日志保留时长(分钟,1-1440)", |
| "2", |
| "int", |
| "debug_log_retention_minutes", |
| ); err != nil { |
| return fmt.Errorf("refresh setting metadata debug_log_retention_minutes: %w", err) |
| } |
| } |
|
|
| |
| { |
| keyCol := "key" |
| if dialect == DialectMySQL { |
| keyCol = "`key`" |
| } |
| |
| typeSQL := fmt.Sprintf("UPDATE system_settings SET value_type = 'int' WHERE %s = 'success_rate_penalty_weight' AND value_type = 'float'", keyCol) |
| if _, err := db.ExecContext(ctx, typeSQL); err != nil { |
| return fmt.Errorf("migrate success_rate_penalty_weight type: %w", err) |
| } |
| } |
|
|
| |
| obsoleteKeys := []string{ |
| "88code_free_only", |
| } |
| for _, key := range obsoleteKeys { |
| _ = deleteSystemSetting(ctx, db, dialect, key) |
| } |
|
|
| |
| legacyMigrationMarkers := []string{ |
| "minute_bucket_backfill_done", |
| } |
| for _, marker := range legacyMigrationMarkers { |
| if hasSystemSetting(ctx, db, dialect, marker) { |
| _ = recordMigration(ctx, db, marker, dialect) |
| _ = deleteSystemSetting(ctx, db, dialect, marker) |
| } |
| } |
|
|
| |
| if hasSystemSetting(ctx, db, dialect, "cooldown_fallback_threshold") { |
| const oldKey = "cooldown_fallback_threshold" |
| const newKey = "cooldown_fallback_enabled" |
|
|
| keyCol := "key" |
| if dialect == DialectMySQL { |
| keyCol = "`key`" |
| } |
|
|
| |
| valueMigrateSQL := fmt.Sprintf(`UPDATE system_settings SET value = CASE WHEN value = '0' THEN 'false' ELSE 'true' END WHERE %s = ? AND value_type = 'int'`, keyCol) |
| if _, err := db.ExecContext(ctx, valueMigrateSQL, oldKey); err != nil { |
| return fmt.Errorf("migrate setting value %s: %w", oldKey, err) |
| } |
|
|
| if hasSystemSetting(ctx, db, dialect, newKey) { |
| if err := deleteSystemSetting(ctx, db, dialect, oldKey); err != nil { |
| return err |
| } |
| } else { |
| |
| renameSQL := fmt.Sprintf("UPDATE system_settings SET %s = ?, description = ?, default_value = ?, value_type = ? WHERE %s = ?", keyCol, keyCol) |
| if _, err := db.ExecContext(ctx, renameSQL, newKey, "所有渠道冷却时选最优渠道兜底(关闭则直接拒绝请求)", "true", "bool", oldKey); err != nil { |
| return fmt.Errorf("rename setting %s to %s: %w", oldKey, newKey, err) |
| } |
| } |
| } |
|
|
| return nil |
| } |
|
|
| |
| func isMigrationApplied(ctx context.Context, db *sql.DB, version string) (bool, error) { |
| var count int |
| err := db.QueryRowContext(ctx, |
| "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version, |
| ).Scan(&count) |
| if err != nil { |
| |
| return false, nil |
| } |
| return count > 0, nil |
| } |
|
|
| |
| func hasMigration(ctx context.Context, db *sql.DB, version string) bool { |
| applied, _ := isMigrationApplied(ctx, db, version) |
| return applied |
| } |
|
|
| |
| func recordMigration(ctx context.Context, db *sql.DB, version string, dialect Dialect) error { |
| var insertSQL string |
| if dialect == DialectMySQL { |
| insertSQL = `INSERT IGNORE INTO schema_migrations (version, applied_at) VALUES (?, UNIX_TIMESTAMP())` |
| } else { |
| insertSQL = `INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, unixepoch())` |
| } |
| _, err := db.ExecContext(ctx, insertSQL, version) |
| return err |
| } |
|
|
| func migrationAppliedAt(ctx context.Context, db *sql.DB, version string) (int64, bool, error) { |
| var appliedAt int64 |
| err := db.QueryRowContext(ctx, `SELECT applied_at FROM schema_migrations WHERE version = ?`, version).Scan(&appliedAt) |
| if err == nil { |
| return appliedAt, true, nil |
| } |
| if err == sql.ErrNoRows { |
| return 0, false, nil |
| } |
| return 0, false, fmt.Errorf("query migration %s applied_at: %w", version, err) |
| } |
|
|
| func recordMigrationTx(ctx context.Context, tx *sql.Tx, version string, dialect Dialect) error { |
| var insertSQL string |
| if dialect == DialectMySQL { |
| insertSQL = `INSERT IGNORE INTO schema_migrations (version, applied_at) VALUES (?, UNIX_TIMESTAMP())` |
| } else { |
| insertSQL = `INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, unixepoch())` |
| } |
| _, err := tx.ExecContext(ctx, insertSQL, version) |
| return err |
| } |
|
|