package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) var ( ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found") ErrSoraS3ProfileNotFound = infraerrors.NotFound("SORA_S3_PROFILE_NOT_FOUND", "sora s3 profile not found") ErrSoraS3ProfileExists = infraerrors.Conflict("SORA_S3_PROFILE_EXISTS", "sora s3 profile already exists") ) type SettingRepository interface { Get(ctx context.Context, key string) (*Setting, error) GetValue(ctx context.Context, key string) (string, error) Set(ctx context.Context, key, value string) error GetMultiple(ctx context.Context, keys []string) (map[string]string, error) SetMultiple(ctx context.Context, settings map[string]string) error GetAll(ctx context.Context) (map[string]string, error) Delete(ctx context.Context, key string) error } // SettingService 系统设置服务 type SettingService struct { settingRepo SettingRepository cfg *config.Config onUpdate func() // Callback when settings are updated (for cache invalidation) onS3Update func() // Callback when Sora S3 settings are updated version string // Application version } // NewSettingService 创建系统设置服务实例 func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService { return &SettingService{ settingRepo: settingRepo, cfg: cfg, } } // GetAllSettings 获取所有系统设置 func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) { settings, err := s.settingRepo.GetAll(ctx) if err != nil { return nil, fmt.Errorf("get all settings: %w", err) } return s.parseSettings(settings), nil } // GetPublicSettings 获取公开设置(无需登录) func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) { keys := []string{ SettingKeyRegistrationEnabled, SettingKeyEmailVerifyEnabled, SettingKeyPromoCodeEnabled, SettingKeyPasswordResetEnabled, SettingKeyInvitationCodeEnabled, SettingKeyTotpEnabled, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, SettingKeySiteName, SettingKeySiteLogo, SettingKeySiteSubtitle, SettingKeyAPIBaseURL, SettingKeyContactInfo, SettingKeyDocURL, SettingKeyHomeContent, SettingKeyHideCcsImportButton, SettingKeyPurchaseSubscriptionEnabled, SettingKeyPurchaseSubscriptionURL, SettingKeySoraClientEnabled, SettingKeyLinuxDoConnectEnabled, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return nil, fmt.Errorf("get public settings: %w", err) } linuxDoEnabled := false if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { linuxDoEnabled = raw == "true" } else { linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled } // Password reset requires email verification to be enabled emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" return &PublicSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: emailVerifyEnabled, PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: passwordResetEnabled, InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], DocURL: settings[SettingKeyDocURL], HomeContent: settings[SettingKeyHomeContent], HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", LinuxDoOAuthEnabled: linuxDoEnabled, }, nil } // SetOnUpdateCallback sets a callback function to be called when settings are updated // This is used for cache invalidation (e.g., HTML cache in frontend server) func (s *SettingService) SetOnUpdateCallback(callback func()) { s.onUpdate = callback } // SetOnS3UpdateCallback 设置 Sora S3 配置变更时的回调函数(用于刷新 S3 客户端缓存)。 func (s *SettingService) SetOnS3UpdateCallback(callback func()) { s.onS3Update = callback } // SetVersion sets the application version for injection into public settings func (s *SettingService) SetVersion(version string) { s.version = version } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection // This implements the web.PublicSettingsProvider interface func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) { settings, err := s.GetPublicSettings(ctx) if err != nil { return nil, err } // Return a struct that matches the frontend's expected format return &struct { RegistrationEnabled bool `json:"registration_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` SiteName string `json:"site_name"` SiteLogo string `json:"site_logo,omitempty"` SiteSubtitle string `json:"site_subtitle,omitempty"` APIBaseURL string `json:"api_base_url,omitempty"` ContactInfo string `json:"contact_info,omitempty"` DocURL string `json:"doc_url,omitempty"` HomeContent string `json:"home_content,omitempty"` HideCcsImportButton bool `json:"hide_ccs_import_button"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` SoraClientEnabled bool `json:"sora_client_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` Version string `json:"version,omitempty"` }{ RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, APIBaseURL: settings.APIBaseURL, ContactInfo: settings.ContactInfo, DocURL: settings.DocURL, HomeContent: settings.HomeContent, HideCcsImportButton: settings.HideCcsImportButton, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, SoraClientEnabled: settings.SoraClientEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, Version: s.version, }, nil } // UpdateSettings 更新系统设置 func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { updates := make(map[string]string) // 注册设置 updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) // 邮件服务设置(只有非空才更新密码) updates[SettingKeySMTPHost] = settings.SMTPHost updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort) updates[SettingKeySMTPUsername] = settings.SMTPUsername if settings.SMTPPassword != "" { updates[SettingKeySMTPPassword] = settings.SMTPPassword } updates[SettingKeySMTPFrom] = settings.SMTPFrom updates[SettingKeySMTPFromName] = settings.SMTPFromName updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS) // Cloudflare Turnstile 设置(只有非空才更新密钥) updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled) updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey if settings.TurnstileSecretKey != "" { updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey } // LinuxDo Connect OAuth 登录 updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL if settings.LinuxDoConnectClientSecret != "" { updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret } // OEM设置 updates[SettingKeySiteName] = settings.SiteName updates[SettingKeySiteLogo] = settings.SiteLogo updates[SettingKeySiteSubtitle] = settings.SiteSubtitle updates[SettingKeyAPIBaseURL] = settings.APIBaseURL updates[SettingKeyContactInfo] = settings.ContactInfo updates[SettingKeyDocURL] = settings.DocURL updates[SettingKeyHomeContent] = settings.HomeContent updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton) updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled) updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled) // 默认配置 updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) // Model fallback configuration updates[SettingKeyEnableModelFallback] = strconv.FormatBool(settings.EnableModelFallback) updates[SettingKeyFallbackModelAnthropic] = settings.FallbackModelAnthropic updates[SettingKeyFallbackModelOpenAI] = settings.FallbackModelOpenAI updates[SettingKeyFallbackModelGemini] = settings.FallbackModelGemini updates[SettingKeyFallbackModelAntigravity] = settings.FallbackModelAntigravity // Identity patch configuration (Claude -> Gemini) updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch) updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt // Ops monitoring (vNext) updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled) updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled) updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault)) if settings.OpsMetricsIntervalSeconds > 0 { updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds) } err := s.settingRepo.SetMultiple(ctx, updates) if err == nil && s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } return err } // IsRegistrationEnabled 检查是否开放注册 func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) if err != nil { // 安全默认:如果设置不存在或查询出错,默认关闭注册 return false } return value == "true" } // IsEmailVerifyEnabled 检查是否开启邮件验证 func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) if err != nil { return false } return value == "true" } // IsPromoCodeEnabled 检查是否启用优惠码功能 func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled) if err != nil { return true // 默认启用 } return value != "false" } // IsInvitationCodeEnabled 检查是否启用邀请码注册功能 func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled) if err != nil { return false // 默认关闭 } return value == "true" } // IsPasswordResetEnabled 检查是否启用密码重置功能 // 要求:必须同时开启邮件验证 func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { // Password reset requires email verification to be enabled if !s.IsEmailVerifyEnabled(ctx) { return false } value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled) if err != nil { return false // 默认关闭 } return value == "true" } // IsTotpEnabled 检查是否启用 TOTP 双因素认证功能 func (s *SettingService) IsTotpEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled) if err != nil { return false // 默认关闭 } return value == "true" } // IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置 // 只有手动配置了密钥才允许在管理后台启用 TOTP 功能 func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { return s.cfg.Totp.EncryptionKeyConfigured } // GetSiteName 获取网站名称 func (s *SettingService) GetSiteName(ctx context.Context) string { value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) if err != nil || value == "" { return "Sub2API" } return value } // GetDefaultConcurrency 获取默认并发量 func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int { value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency) if err != nil { return s.cfg.Default.UserConcurrency } if v, err := strconv.Atoi(value); err == nil && v > 0 { return v } return s.cfg.Default.UserConcurrency } // GetDefaultBalance 获取默认余额 func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 { value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance) if err != nil { return s.cfg.Default.UserBalance } if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 { return v } return s.cfg.Default.UserBalance } // InitializeDefaultSettings 初始化默认设置 func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // 检查是否已有设置 _, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) if err == nil { // 已有设置,不需要初始化 return nil } if !errors.Is(err, ErrSettingNotFound) { return fmt.Errorf("check existing settings: %w", err) } // 初始化默认设置 defaults := map[string]string{ SettingKeyRegistrationEnabled: "true", SettingKeyEmailVerifyEnabled: "false", SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 SettingKeySiteName: "Sub2API", SettingKeySiteLogo: "", SettingKeyPurchaseSubscriptionEnabled: "false", SettingKeyPurchaseSubscriptionURL: "", SettingKeySoraClientEnabled: "false", SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeySMTPPort: "587", SettingKeySMTPUseTLS: "false", // Model fallback defaults SettingKeyEnableModelFallback: "false", SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022", SettingKeyFallbackModelOpenAI: "gpt-4o", SettingKeyFallbackModelGemini: "gemini-2.5-pro", SettingKeyFallbackModelAntigravity: "gemini-2.5-pro", // Identity patch defaults SettingKeyEnableIdentityPatch: "true", SettingKeyIdentityPatchPrompt: "", // Ops monitoring defaults (vNext) SettingKeyOpsMonitoringEnabled: "true", SettingKeyOpsRealtimeMonitoringEnabled: "true", SettingKeyOpsQueryModeDefault: "auto", SettingKeyOpsMetricsIntervalSeconds: "60", } return s.settingRepo.SetMultiple(ctx, defaults) } // parseSettings 解析设置到结构体 func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: emailVerifyEnabled, PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", SMTPHost: settings[SettingKeySMTPHost], SMTPUsername: settings[SettingKeySMTPUsername], SMTPFrom: settings[SettingKeySMTPFrom], SMTPFromName: settings[SettingKeySMTPFromName], SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], DocURL: settings[SettingKeyDocURL], HomeContent: settings[SettingKeyHomeContent], HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", } // 解析整数类型 if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil { result.SMTPPort = port } else { result.SMTPPort = 587 } if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil { result.DefaultConcurrency = concurrency } else { result.DefaultConcurrency = s.cfg.Default.UserConcurrency } // 解析浮点数类型 if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil { result.DefaultBalance = balance } else { result.DefaultBalance = s.cfg.Default.UserBalance } // 敏感信息直接返回,方便测试连接时使用 result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] // LinuxDo Connect 设置: // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) // - 支持在后台“系统设置”中覆盖并持久化(存储于 DB) linuxDoBase := config.LinuxDoConnectConfig{} if s.cfg != nil { linuxDoBase = s.cfg.LinuxDo } if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { result.LinuxDoConnectEnabled = raw == "true" } else { result.LinuxDoConnectEnabled = linuxDoBase.Enabled } if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { result.LinuxDoConnectClientID = strings.TrimSpace(v) } else { result.LinuxDoConnectClientID = linuxDoBase.ClientID } if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { result.LinuxDoConnectRedirectURL = strings.TrimSpace(v) } else { result.LinuxDoConnectRedirectURL = linuxDoBase.RedirectURL } result.LinuxDoConnectClientSecret = strings.TrimSpace(settings[SettingKeyLinuxDoConnectClientSecret]) if result.LinuxDoConnectClientSecret == "" { result.LinuxDoConnectClientSecret = strings.TrimSpace(linuxDoBase.ClientSecret) } result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != "" // Model fallback settings result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") result.FallbackModelOpenAI = s.getStringOrDefault(settings, SettingKeyFallbackModelOpenAI, "gpt-4o") result.FallbackModelGemini = s.getStringOrDefault(settings, SettingKeyFallbackModelGemini, "gemini-2.5-pro") result.FallbackModelAntigravity = s.getStringOrDefault(settings, SettingKeyFallbackModelAntigravity, "gemini-2.5-pro") // Identity patch settings (default: enabled, to preserve existing behavior) if v, ok := settings[SettingKeyEnableIdentityPatch]; ok && v != "" { result.EnableIdentityPatch = v == "true" } else { result.EnableIdentityPatch = true } result.IdentityPatchPrompt = settings[SettingKeyIdentityPatchPrompt] // Ops monitoring settings (default: enabled, fail-open) result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled]) result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled]) result.OpsQueryModeDefault = string(ParseOpsQueryMode(settings[SettingKeyOpsQueryModeDefault])) result.OpsMetricsIntervalSeconds = 60 if raw := strings.TrimSpace(settings[SettingKeyOpsMetricsIntervalSeconds]); raw != "" { if v, err := strconv.Atoi(raw); err == nil { if v < 60 { v = 60 } if v > 3600 { v = 3600 } result.OpsMetricsIntervalSeconds = v } } return result } func isFalseSettingValue(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "false", "0", "off", "disabled": return true default: return false } } // getStringOrDefault 获取字符串值或默认值 func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string { if value, ok := settings[key]; ok && value != "" { return value } return defaultValue } // IsTurnstileEnabled 检查是否启用 Turnstile 验证 func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled) if err != nil { return false } return value == "true" } // GetTurnstileSecretKey 获取 Turnstile Secret Key func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string { value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey) if err != nil { return "" } return value } // IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入) func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch) if err != nil { // 默认开启,保持兼容 return true } return value == "true" } // GetIdentityPatchPrompt 获取自定义身份补丁提示词(为空表示使用内置默认模板) func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string { value, err := s.settingRepo.GetValue(ctx, SettingKeyIdentityPatchPrompt) if err != nil { return "" } return value } // GenerateAdminAPIKey 生成新的管理员 API Key func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) { // 生成 32 字节随机数 = 64 位十六进制字符 bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("generate random bytes: %w", err) } key := AdminAPIKeyPrefix + hex.EncodeToString(bytes) // 存储到 settings 表 if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil { return "", fmt.Errorf("save admin api key: %w", err) } return key, nil } // GetAdminAPIKeyStatus 获取管理员 API Key 状态 // 返回脱敏的 key、是否存在、错误 func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) { key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) if err != nil { if errors.Is(err, ErrSettingNotFound) { return "", false, nil } return "", false, err } if key == "" { return "", false, nil } // 脱敏:显示前 10 位和后 4 位 if len(key) > 14 { maskedKey = key[:10] + "..." + key[len(key)-4:] } else { maskedKey = key } return maskedKey, true, nil } // GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用) // 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) { key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) if err != nil { if errors.Is(err, ErrSettingNotFound) { return "", nil // 未配置,返回空字符串 } return "", err // 数据库错误 } return key, nil } // DeleteAdminAPIKey 删除管理员 API Key func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error { return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey) } // IsModelFallbackEnabled 检查是否启用模型兜底机制 func (s *SettingService) IsModelFallbackEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableModelFallback) if err != nil { return false // Default: disabled } return value == "true" } // GetFallbackModel 获取指定平台的兜底模型 func (s *SettingService) GetFallbackModel(ctx context.Context, platform string) string { var key string var defaultModel string switch platform { case PlatformAnthropic: key = SettingKeyFallbackModelAnthropic defaultModel = "claude-3-5-sonnet-20241022" case PlatformOpenAI: key = SettingKeyFallbackModelOpenAI defaultModel = "gpt-4o" case PlatformGemini: key = SettingKeyFallbackModelGemini defaultModel = "gemini-2.5-pro" case PlatformAntigravity: key = SettingKeyFallbackModelAntigravity defaultModel = "gemini-2.5-pro" default: return "" } value, err := s.settingRepo.GetValue(ctx, key) if err != nil || value == "" { return defaultModel } return value } // GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。 // // 优先级: // - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 // - 否则回退到 config.yaml/env 的值 func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { if s == nil || s.cfg == nil { return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") } effective := s.cfg.LinuxDo keys := []string{ SettingKeyLinuxDoConnectEnabled, SettingKeyLinuxDoConnectClientID, SettingKeyLinuxDoConnectClientSecret, SettingKeyLinuxDoConnectRedirectURL, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err) } if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { effective.Enabled = raw == "true" } if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { effective.ClientID = strings.TrimSpace(v) } if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" { effective.ClientSecret = strings.TrimSpace(v) } if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { effective.RedirectURL = strings.TrimSpace(v) } if !effective.Enabled { return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") } // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 if strings.TrimSpace(effective.ClientID) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") } if strings.TrimSpace(effective.AuthorizeURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") } if strings.TrimSpace(effective.TokenURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") } if strings.TrimSpace(effective.UserInfoURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured") } if strings.TrimSpace(effective.RedirectURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") } if strings.TrimSpace(effective.FrontendRedirectURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") } if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") } if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") } method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) switch method { case "", "client_secret_post", "client_secret_basic": if strings.TrimSpace(effective.ClientSecret) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") } case "none": if !effective.UsePKCE { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none") } default: return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") } return effective, nil } // GetStreamTimeoutSettings 获取流超时处理配置 func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) { value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings) if err != nil { if errors.Is(err, ErrSettingNotFound) { return DefaultStreamTimeoutSettings(), nil } return nil, fmt.Errorf("get stream timeout settings: %w", err) } if value == "" { return DefaultStreamTimeoutSettings(), nil } var settings StreamTimeoutSettings if err := json.Unmarshal([]byte(value), &settings); err != nil { return DefaultStreamTimeoutSettings(), nil } // 验证并修正配置值 if settings.TempUnschedMinutes < 1 { settings.TempUnschedMinutes = 1 } if settings.TempUnschedMinutes > 60 { settings.TempUnschedMinutes = 60 } if settings.ThresholdCount < 1 { settings.ThresholdCount = 1 } if settings.ThresholdCount > 10 { settings.ThresholdCount = 10 } if settings.ThresholdWindowMinutes < 1 { settings.ThresholdWindowMinutes = 1 } if settings.ThresholdWindowMinutes > 60 { settings.ThresholdWindowMinutes = 60 } // 验证 action switch settings.Action { case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone: // valid default: settings.Action = StreamTimeoutActionTempUnsched } return &settings, nil } // SetStreamTimeoutSettings 设置流超时处理配置 func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error { if settings == nil { return fmt.Errorf("settings cannot be nil") } // 验证配置值 if settings.TempUnschedMinutes < 1 || settings.TempUnschedMinutes > 60 { return fmt.Errorf("temp_unsched_minutes must be between 1-60") } if settings.ThresholdCount < 1 || settings.ThresholdCount > 10 { return fmt.Errorf("threshold_count must be between 1-10") } if settings.ThresholdWindowMinutes < 1 || settings.ThresholdWindowMinutes > 60 { return fmt.Errorf("threshold_window_minutes must be between 1-60") } switch settings.Action { case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone: // valid default: return fmt.Errorf("invalid action: %s", settings.Action) } data, err := json.Marshal(settings) if err != nil { return fmt.Errorf("marshal stream timeout settings: %w", err) } return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data)) } type soraS3ProfilesStore struct { ActiveProfileID string `json:"active_profile_id"` Items []soraS3ProfileStoreItem `json:"items"` } type soraS3ProfileStoreItem struct { ProfileID string `json:"profile_id"` Name string `json:"name"` Enabled bool `json:"enabled"` Endpoint string `json:"endpoint"` Region string `json:"region"` Bucket string `json:"bucket"` AccessKeyID string `json:"access_key_id"` SecretAccessKey string `json:"secret_access_key"` Prefix string `json:"prefix"` ForcePathStyle bool `json:"force_path_style"` CDNURL string `json:"cdn_url"` DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"` UpdatedAt string `json:"updated_at"` } // GetSoraS3Settings 获取 Sora S3 存储配置(兼容旧单配置语义:返回当前激活配置) func (s *SettingService) GetSoraS3Settings(ctx context.Context) (*SoraS3Settings, error) { profiles, err := s.ListSoraS3Profiles(ctx) if err != nil { return nil, err } activeProfile := pickActiveSoraS3Profile(profiles.Items, profiles.ActiveProfileID) if activeProfile == nil { return &SoraS3Settings{}, nil } return &SoraS3Settings{ Enabled: activeProfile.Enabled, Endpoint: activeProfile.Endpoint, Region: activeProfile.Region, Bucket: activeProfile.Bucket, AccessKeyID: activeProfile.AccessKeyID, SecretAccessKey: activeProfile.SecretAccessKey, SecretAccessKeyConfigured: activeProfile.SecretAccessKeyConfigured, Prefix: activeProfile.Prefix, ForcePathStyle: activeProfile.ForcePathStyle, CDNURL: activeProfile.CDNURL, DefaultStorageQuotaBytes: activeProfile.DefaultStorageQuotaBytes, }, nil } // SetSoraS3Settings 更新 Sora S3 存储配置(兼容旧单配置语义:写入当前激活配置) func (s *SettingService) SetSoraS3Settings(ctx context.Context, settings *SoraS3Settings) error { if settings == nil { return fmt.Errorf("settings cannot be nil") } store, err := s.loadSoraS3ProfilesStore(ctx) if err != nil { return err } now := time.Now().UTC().Format(time.RFC3339) activeIndex := findSoraS3ProfileIndex(store.Items, store.ActiveProfileID) if activeIndex < 0 { activeID := "default" if hasSoraS3ProfileID(store.Items, activeID) { activeID = fmt.Sprintf("default-%d", time.Now().Unix()) } store.Items = append(store.Items, soraS3ProfileStoreItem{ ProfileID: activeID, Name: "Default", UpdatedAt: now, }) store.ActiveProfileID = activeID activeIndex = len(store.Items) - 1 } active := store.Items[activeIndex] active.Enabled = settings.Enabled active.Endpoint = strings.TrimSpace(settings.Endpoint) active.Region = strings.TrimSpace(settings.Region) active.Bucket = strings.TrimSpace(settings.Bucket) active.AccessKeyID = strings.TrimSpace(settings.AccessKeyID) active.Prefix = strings.TrimSpace(settings.Prefix) active.ForcePathStyle = settings.ForcePathStyle active.CDNURL = strings.TrimSpace(settings.CDNURL) active.DefaultStorageQuotaBytes = maxInt64(settings.DefaultStorageQuotaBytes, 0) if settings.SecretAccessKey != "" { active.SecretAccessKey = settings.SecretAccessKey } active.UpdatedAt = now store.Items[activeIndex] = active return s.persistSoraS3ProfilesStore(ctx, store) } // ListSoraS3Profiles 获取 Sora S3 多配置列表 func (s *SettingService) ListSoraS3Profiles(ctx context.Context) (*SoraS3ProfileList, error) { store, err := s.loadSoraS3ProfilesStore(ctx) if err != nil { return nil, err } return convertSoraS3ProfilesStore(store), nil } // CreateSoraS3Profile 创建 Sora S3 配置 func (s *SettingService) CreateSoraS3Profile(ctx context.Context, profile *SoraS3Profile, setActive bool) (*SoraS3Profile, error) { if profile == nil { return nil, fmt.Errorf("profile cannot be nil") } profileID := strings.TrimSpace(profile.ProfileID) if profileID == "" { return nil, infraerrors.BadRequest("SORA_S3_PROFILE_ID_REQUIRED", "profile_id is required") } name := strings.TrimSpace(profile.Name) if name == "" { return nil, infraerrors.BadRequest("SORA_S3_PROFILE_NAME_REQUIRED", "name is required") } store, err := s.loadSoraS3ProfilesStore(ctx) if err != nil { return nil, err } if hasSoraS3ProfileID(store.Items, profileID) { return nil, ErrSoraS3ProfileExists } now := time.Now().UTC().Format(time.RFC3339) store.Items = append(store.Items, soraS3ProfileStoreItem{ ProfileID: profileID, Name: name, Enabled: profile.Enabled, Endpoint: strings.TrimSpace(profile.Endpoint), Region: strings.TrimSpace(profile.Region), Bucket: strings.TrimSpace(profile.Bucket), AccessKeyID: strings.TrimSpace(profile.AccessKeyID), SecretAccessKey: profile.SecretAccessKey, Prefix: strings.TrimSpace(profile.Prefix), ForcePathStyle: profile.ForcePathStyle, CDNURL: strings.TrimSpace(profile.CDNURL), DefaultStorageQuotaBytes: maxInt64(profile.DefaultStorageQuotaBytes, 0), UpdatedAt: now, }) if setActive || store.ActiveProfileID == "" { store.ActiveProfileID = profileID } if err := s.persistSoraS3ProfilesStore(ctx, store); err != nil { return nil, err } profiles := convertSoraS3ProfilesStore(store) created := findSoraS3ProfileByID(profiles.Items, profileID) if created == nil { return nil, ErrSoraS3ProfileNotFound } return created, nil } // UpdateSoraS3Profile 更新 Sora S3 配置 func (s *SettingService) UpdateSoraS3Profile(ctx context.Context, profileID string, profile *SoraS3Profile) (*SoraS3Profile, error) { if profile == nil { return nil, fmt.Errorf("profile cannot be nil") } targetID := strings.TrimSpace(profileID) if targetID == "" { return nil, infraerrors.BadRequest("SORA_S3_PROFILE_ID_REQUIRED", "profile_id is required") } store, err := s.loadSoraS3ProfilesStore(ctx) if err != nil { return nil, err } targetIndex := findSoraS3ProfileIndex(store.Items, targetID) if targetIndex < 0 { return nil, ErrSoraS3ProfileNotFound } target := store.Items[targetIndex] name := strings.TrimSpace(profile.Name) if name == "" { return nil, infraerrors.BadRequest("SORA_S3_PROFILE_NAME_REQUIRED", "name is required") } target.Name = name target.Enabled = profile.Enabled target.Endpoint = strings.TrimSpace(profile.Endpoint) target.Region = strings.TrimSpace(profile.Region) target.Bucket = strings.TrimSpace(profile.Bucket) target.AccessKeyID = strings.TrimSpace(profile.AccessKeyID) target.Prefix = strings.TrimSpace(profile.Prefix) target.ForcePathStyle = profile.ForcePathStyle target.CDNURL = strings.TrimSpace(profile.CDNURL) target.DefaultStorageQuotaBytes = maxInt64(profile.DefaultStorageQuotaBytes, 0) if profile.SecretAccessKey != "" { target.SecretAccessKey = profile.SecretAccessKey } target.UpdatedAt = time.Now().UTC().Format(time.RFC3339) store.Items[targetIndex] = target if err := s.persistSoraS3ProfilesStore(ctx, store); err != nil { return nil, err } profiles := convertSoraS3ProfilesStore(store) updated := findSoraS3ProfileByID(profiles.Items, targetID) if updated == nil { return nil, ErrSoraS3ProfileNotFound } return updated, nil } // DeleteSoraS3Profile 删除 Sora S3 配置 func (s *SettingService) DeleteSoraS3Profile(ctx context.Context, profileID string) error { targetID := strings.TrimSpace(profileID) if targetID == "" { return infraerrors.BadRequest("SORA_S3_PROFILE_ID_REQUIRED", "profile_id is required") } store, err := s.loadSoraS3ProfilesStore(ctx) if err != nil { return err } targetIndex := findSoraS3ProfileIndex(store.Items, targetID) if targetIndex < 0 { return ErrSoraS3ProfileNotFound } store.Items = append(store.Items[:targetIndex], store.Items[targetIndex+1:]...) if store.ActiveProfileID == targetID { store.ActiveProfileID = "" if len(store.Items) > 0 { store.ActiveProfileID = store.Items[0].ProfileID } } return s.persistSoraS3ProfilesStore(ctx, store) } // SetActiveSoraS3Profile 设置激活的 Sora S3 配置 func (s *SettingService) SetActiveSoraS3Profile(ctx context.Context, profileID string) (*SoraS3Profile, error) { targetID := strings.TrimSpace(profileID) if targetID == "" { return nil, infraerrors.BadRequest("SORA_S3_PROFILE_ID_REQUIRED", "profile_id is required") } store, err := s.loadSoraS3ProfilesStore(ctx) if err != nil { return nil, err } targetIndex := findSoraS3ProfileIndex(store.Items, targetID) if targetIndex < 0 { return nil, ErrSoraS3ProfileNotFound } store.ActiveProfileID = targetID store.Items[targetIndex].UpdatedAt = time.Now().UTC().Format(time.RFC3339) if err := s.persistSoraS3ProfilesStore(ctx, store); err != nil { return nil, err } profiles := convertSoraS3ProfilesStore(store) active := pickActiveSoraS3Profile(profiles.Items, profiles.ActiveProfileID) if active == nil { return nil, ErrSoraS3ProfileNotFound } return active, nil } func (s *SettingService) loadSoraS3ProfilesStore(ctx context.Context) (*soraS3ProfilesStore, error) { raw, err := s.settingRepo.GetValue(ctx, SettingKeySoraS3Profiles) if err == nil { trimmed := strings.TrimSpace(raw) if trimmed == "" { return &soraS3ProfilesStore{}, nil } var store soraS3ProfilesStore if unmarshalErr := json.Unmarshal([]byte(trimmed), &store); unmarshalErr != nil { legacy, legacyErr := s.getLegacySoraS3Settings(ctx) if legacyErr != nil { return nil, fmt.Errorf("unmarshal sora s3 profiles: %w", unmarshalErr) } if isEmptyLegacySoraS3Settings(legacy) { return &soraS3ProfilesStore{}, nil } now := time.Now().UTC().Format(time.RFC3339) return &soraS3ProfilesStore{ ActiveProfileID: "default", Items: []soraS3ProfileStoreItem{ { ProfileID: "default", Name: "Default", Enabled: legacy.Enabled, Endpoint: strings.TrimSpace(legacy.Endpoint), Region: strings.TrimSpace(legacy.Region), Bucket: strings.TrimSpace(legacy.Bucket), AccessKeyID: strings.TrimSpace(legacy.AccessKeyID), SecretAccessKey: legacy.SecretAccessKey, Prefix: strings.TrimSpace(legacy.Prefix), ForcePathStyle: legacy.ForcePathStyle, CDNURL: strings.TrimSpace(legacy.CDNURL), DefaultStorageQuotaBytes: maxInt64(legacy.DefaultStorageQuotaBytes, 0), UpdatedAt: now, }, }, }, nil } normalized := normalizeSoraS3ProfilesStore(store) return &normalized, nil } if !errors.Is(err, ErrSettingNotFound) { return nil, fmt.Errorf("get sora s3 profiles: %w", err) } legacy, legacyErr := s.getLegacySoraS3Settings(ctx) if legacyErr != nil { return nil, legacyErr } if isEmptyLegacySoraS3Settings(legacy) { return &soraS3ProfilesStore{}, nil } now := time.Now().UTC().Format(time.RFC3339) return &soraS3ProfilesStore{ ActiveProfileID: "default", Items: []soraS3ProfileStoreItem{ { ProfileID: "default", Name: "Default", Enabled: legacy.Enabled, Endpoint: strings.TrimSpace(legacy.Endpoint), Region: strings.TrimSpace(legacy.Region), Bucket: strings.TrimSpace(legacy.Bucket), AccessKeyID: strings.TrimSpace(legacy.AccessKeyID), SecretAccessKey: legacy.SecretAccessKey, Prefix: strings.TrimSpace(legacy.Prefix), ForcePathStyle: legacy.ForcePathStyle, CDNURL: strings.TrimSpace(legacy.CDNURL), DefaultStorageQuotaBytes: maxInt64(legacy.DefaultStorageQuotaBytes, 0), UpdatedAt: now, }, }, }, nil } func (s *SettingService) persistSoraS3ProfilesStore(ctx context.Context, store *soraS3ProfilesStore) error { if store == nil { return fmt.Errorf("sora s3 profiles store cannot be nil") } normalized := normalizeSoraS3ProfilesStore(*store) data, err := json.Marshal(normalized) if err != nil { return fmt.Errorf("marshal sora s3 profiles: %w", err) } updates := map[string]string{ SettingKeySoraS3Profiles: string(data), } active := pickActiveSoraS3ProfileFromStore(normalized.Items, normalized.ActiveProfileID) if active == nil { updates[SettingKeySoraS3Enabled] = "false" updates[SettingKeySoraS3Endpoint] = "" updates[SettingKeySoraS3Region] = "" updates[SettingKeySoraS3Bucket] = "" updates[SettingKeySoraS3AccessKeyID] = "" updates[SettingKeySoraS3Prefix] = "" updates[SettingKeySoraS3ForcePathStyle] = "false" updates[SettingKeySoraS3CDNURL] = "" updates[SettingKeySoraDefaultStorageQuotaBytes] = "0" updates[SettingKeySoraS3SecretAccessKey] = "" } else { updates[SettingKeySoraS3Enabled] = strconv.FormatBool(active.Enabled) updates[SettingKeySoraS3Endpoint] = strings.TrimSpace(active.Endpoint) updates[SettingKeySoraS3Region] = strings.TrimSpace(active.Region) updates[SettingKeySoraS3Bucket] = strings.TrimSpace(active.Bucket) updates[SettingKeySoraS3AccessKeyID] = strings.TrimSpace(active.AccessKeyID) updates[SettingKeySoraS3Prefix] = strings.TrimSpace(active.Prefix) updates[SettingKeySoraS3ForcePathStyle] = strconv.FormatBool(active.ForcePathStyle) updates[SettingKeySoraS3CDNURL] = strings.TrimSpace(active.CDNURL) updates[SettingKeySoraDefaultStorageQuotaBytes] = strconv.FormatInt(maxInt64(active.DefaultStorageQuotaBytes, 0), 10) updates[SettingKeySoraS3SecretAccessKey] = active.SecretAccessKey } if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { return err } if s.onUpdate != nil { s.onUpdate() } if s.onS3Update != nil { s.onS3Update() } return nil } func (s *SettingService) getLegacySoraS3Settings(ctx context.Context) (*SoraS3Settings, error) { keys := []string{ SettingKeySoraS3Enabled, SettingKeySoraS3Endpoint, SettingKeySoraS3Region, SettingKeySoraS3Bucket, SettingKeySoraS3AccessKeyID, SettingKeySoraS3SecretAccessKey, SettingKeySoraS3Prefix, SettingKeySoraS3ForcePathStyle, SettingKeySoraS3CDNURL, SettingKeySoraDefaultStorageQuotaBytes, } values, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return nil, fmt.Errorf("get legacy sora s3 settings: %w", err) } result := &SoraS3Settings{ Enabled: values[SettingKeySoraS3Enabled] == "true", Endpoint: values[SettingKeySoraS3Endpoint], Region: values[SettingKeySoraS3Region], Bucket: values[SettingKeySoraS3Bucket], AccessKeyID: values[SettingKeySoraS3AccessKeyID], SecretAccessKey: values[SettingKeySoraS3SecretAccessKey], SecretAccessKeyConfigured: values[SettingKeySoraS3SecretAccessKey] != "", Prefix: values[SettingKeySoraS3Prefix], ForcePathStyle: values[SettingKeySoraS3ForcePathStyle] == "true", CDNURL: values[SettingKeySoraS3CDNURL], } if v, parseErr := strconv.ParseInt(values[SettingKeySoraDefaultStorageQuotaBytes], 10, 64); parseErr == nil { result.DefaultStorageQuotaBytes = v } return result, nil } func normalizeSoraS3ProfilesStore(store soraS3ProfilesStore) soraS3ProfilesStore { seen := make(map[string]struct{}, len(store.Items)) normalized := soraS3ProfilesStore{ ActiveProfileID: strings.TrimSpace(store.ActiveProfileID), Items: make([]soraS3ProfileStoreItem, 0, len(store.Items)), } now := time.Now().UTC().Format(time.RFC3339) for idx := range store.Items { item := store.Items[idx] item.ProfileID = strings.TrimSpace(item.ProfileID) if item.ProfileID == "" { item.ProfileID = fmt.Sprintf("profile-%d", idx+1) } if _, exists := seen[item.ProfileID]; exists { continue } seen[item.ProfileID] = struct{}{} item.Name = strings.TrimSpace(item.Name) if item.Name == "" { item.Name = item.ProfileID } item.Endpoint = strings.TrimSpace(item.Endpoint) item.Region = strings.TrimSpace(item.Region) item.Bucket = strings.TrimSpace(item.Bucket) item.AccessKeyID = strings.TrimSpace(item.AccessKeyID) item.Prefix = strings.TrimSpace(item.Prefix) item.CDNURL = strings.TrimSpace(item.CDNURL) item.DefaultStorageQuotaBytes = maxInt64(item.DefaultStorageQuotaBytes, 0) item.UpdatedAt = strings.TrimSpace(item.UpdatedAt) if item.UpdatedAt == "" { item.UpdatedAt = now } normalized.Items = append(normalized.Items, item) } if len(normalized.Items) == 0 { normalized.ActiveProfileID = "" return normalized } if findSoraS3ProfileIndex(normalized.Items, normalized.ActiveProfileID) >= 0 { return normalized } normalized.ActiveProfileID = normalized.Items[0].ProfileID return normalized } func convertSoraS3ProfilesStore(store *soraS3ProfilesStore) *SoraS3ProfileList { if store == nil { return &SoraS3ProfileList{} } items := make([]SoraS3Profile, 0, len(store.Items)) for idx := range store.Items { item := store.Items[idx] items = append(items, SoraS3Profile{ ProfileID: item.ProfileID, Name: item.Name, IsActive: item.ProfileID == store.ActiveProfileID, Enabled: item.Enabled, Endpoint: item.Endpoint, Region: item.Region, Bucket: item.Bucket, AccessKeyID: item.AccessKeyID, SecretAccessKey: item.SecretAccessKey, SecretAccessKeyConfigured: item.SecretAccessKey != "", Prefix: item.Prefix, ForcePathStyle: item.ForcePathStyle, CDNURL: item.CDNURL, DefaultStorageQuotaBytes: item.DefaultStorageQuotaBytes, UpdatedAt: item.UpdatedAt, }) } return &SoraS3ProfileList{ ActiveProfileID: store.ActiveProfileID, Items: items, } } func pickActiveSoraS3Profile(items []SoraS3Profile, activeProfileID string) *SoraS3Profile { for idx := range items { if items[idx].ProfileID == activeProfileID { return &items[idx] } } if len(items) == 0 { return nil } return &items[0] } func findSoraS3ProfileByID(items []SoraS3Profile, profileID string) *SoraS3Profile { for idx := range items { if items[idx].ProfileID == profileID { return &items[idx] } } return nil } func pickActiveSoraS3ProfileFromStore(items []soraS3ProfileStoreItem, activeProfileID string) *soraS3ProfileStoreItem { for idx := range items { if items[idx].ProfileID == activeProfileID { return &items[idx] } } if len(items) == 0 { return nil } return &items[0] } func findSoraS3ProfileIndex(items []soraS3ProfileStoreItem, profileID string) int { for idx := range items { if items[idx].ProfileID == profileID { return idx } } return -1 } func hasSoraS3ProfileID(items []soraS3ProfileStoreItem, profileID string) bool { return findSoraS3ProfileIndex(items, profileID) >= 0 } func isEmptyLegacySoraS3Settings(settings *SoraS3Settings) bool { if settings == nil { return true } if settings.Enabled { return false } if strings.TrimSpace(settings.Endpoint) != "" { return false } if strings.TrimSpace(settings.Region) != "" { return false } if strings.TrimSpace(settings.Bucket) != "" { return false } if strings.TrimSpace(settings.AccessKeyID) != "" { return false } if settings.SecretAccessKey != "" { return false } if strings.TrimSpace(settings.Prefix) != "" { return false } if strings.TrimSpace(settings.CDNURL) != "" { return false } return settings.DefaultStorageQuotaBytes == 0 } func maxInt64(value int64, min int64) int64 { if value < min { return min } return value }