From 50043b11768893c6a925532a18b5e14ebffcfbd1 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Jul 2026 23:35:47 +0800 Subject: [PATCH] =?UTF-8?q?refactor(service):=20=E7=BA=AF=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E6=8B=86=E5=88=86=20setting=5Fservice.go=EF=BC=885471?= =?UTF-8?q?=E2=86=92263=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/setting_features.go | 912 +++ .../service/setting_gateway_runtime.go | 892 +++ backend/internal/service/setting_oauth.go | 1015 ++++ backend/internal/service/setting_parse.go | 1157 ++++ backend/internal/service/setting_public.go | 688 +++ backend/internal/service/setting_service.go | 5208 ----------------- backend/internal/service/setting_update.go | 623 ++ 7 files changed, 5287 insertions(+), 5208 deletions(-) create mode 100644 backend/internal/service/setting_features.go create mode 100644 backend/internal/service/setting_gateway_runtime.go create mode 100644 backend/internal/service/setting_oauth.go create mode 100644 backend/internal/service/setting_parse.go create mode 100644 backend/internal/service/setting_public.go create mode 100644 backend/internal/service/setting_update.go diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go new file mode 100644 index 0000000000..23dc32efa9 --- /dev/null +++ b/backend/internal/service/setting_features.go @@ -0,0 +1,912 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "strconv" + "strings" +) + +// 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" +} + +// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist. +func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist) + if err != nil { + return []string{} + } + return ParseRegistrationEmailSuffixWhitelist(value) +} + +// 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" +} + +// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting. +func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems) + if err != nil { + return "[]" + } + return value +} + +// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关) +func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + +// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。 +// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错, +// 调用方只关心一个可用的数值。 +func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate) + if err != nil { + return AffiliateRebateRateDefault + } + rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) { + return AffiliateRebateRateDefault + } + return clampAffiliateRebateRate(rate) +} + +// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。 +// 返回 0 表示不冻结(向后兼容)。 +func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours) + if err != nil { + return AffiliateRebateFreezeHoursDefault + } + hours, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || hours < 0 { + return AffiliateRebateFreezeHoursDefault + } + if hours > AffiliateRebateFreezeHoursMax { + return AffiliateRebateFreezeHoursMax + } + return hours +} + +// GetAffiliateRebateDurationDays 返回返利有效期(天)。 +// 返回 0 表示永久有效。 +func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays) + if err != nil { + return AffiliateRebateDurationDaysDefault + } + days, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || days < 0 { + return AffiliateRebateDurationDaysDefault + } + if days > AffiliateRebateDurationDaysMax { + return AffiliateRebateDurationDaysMax + } + return days +} + +// GetAffiliateRebatePerInviteeCap 返回单人返利上限。 +// 返回 0 表示无上限。 +func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap) + if err != nil { + return AffiliateRebatePerInviteeCapDefault + } + cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) { + return AffiliateRebatePerInviteeCapDefault + } + return cap +} + +// 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 +} + +// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。 +func (s *SettingService) GetDefaultUserRPMLimit(ctx context.Context) int { + value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultUserRPMLimit) + if err != nil || value == "" { + return 0 + } + if v, err := strconv.Atoi(value); err == nil && v >= 0 { + return v + } + return 0 +} + +// GetDefaultSubscriptions 获取新用户默认订阅配置列表。 +func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting { + value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions) + if err != nil { + return nil + } + return parseDefaultSubscriptions(value) +} + +func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*AuthSourceDefaultSettings, error) { + keys := []string{ + SettingKeyAuthSourceDefaultEmailBalance, + SettingKeyAuthSourceDefaultEmailConcurrency, + SettingKeyAuthSourceDefaultEmailSubscriptions, + SettingKeyAuthSourceDefaultEmailGrantOnSignup, + SettingKeyAuthSourceDefaultEmailGrantOnFirstBind, + SettingKeyAuthSourceDefaultLinuxDoBalance, + SettingKeyAuthSourceDefaultLinuxDoConcurrency, + SettingKeyAuthSourceDefaultLinuxDoSubscriptions, + SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup, + SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind, + SettingKeyAuthSourceDefaultOIDCBalance, + SettingKeyAuthSourceDefaultOIDCConcurrency, + SettingKeyAuthSourceDefaultOIDCSubscriptions, + SettingKeyAuthSourceDefaultOIDCGrantOnSignup, + SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind, + SettingKeyAuthSourceDefaultWeChatBalance, + SettingKeyAuthSourceDefaultWeChatConcurrency, + SettingKeyAuthSourceDefaultWeChatSubscriptions, + SettingKeyAuthSourceDefaultWeChatGrantOnSignup, + SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind, + SettingKeyAuthSourceDefaultGitHubBalance, + SettingKeyAuthSourceDefaultGitHubConcurrency, + SettingKeyAuthSourceDefaultGitHubSubscriptions, + SettingKeyAuthSourceDefaultGitHubGrantOnSignup, + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind, + SettingKeyAuthSourceDefaultGoogleBalance, + SettingKeyAuthSourceDefaultGoogleConcurrency, + SettingKeyAuthSourceDefaultGoogleSubscriptions, + SettingKeyAuthSourceDefaultGoogleGrantOnSignup, + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, + SettingKeyAuthSourceDefaultDingTalkBalance, + SettingKeyAuthSourceDefaultDingTalkConcurrency, + SettingKeyAuthSourceDefaultDingTalkSubscriptions, + SettingKeyAuthSourceDefaultDingTalkGrantOnSignup, + SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, + SettingKeyAuthSourcePlatformQuotas("email"), + SettingKeyAuthSourcePlatformQuotas("linuxdo"), + SettingKeyAuthSourcePlatformQuotas("oidc"), + SettingKeyAuthSourcePlatformQuotas("wechat"), + SettingKeyAuthSourcePlatformQuotas("github"), + SettingKeyAuthSourcePlatformQuotas("google"), + SettingKeyAuthSourcePlatformQuotas("dingtalk"), + SettingKeyForceEmailOnThirdPartySignup, + } + + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return nil, fmt.Errorf("get auth source default settings: %w", err) + } + + return &AuthSourceDefaultSettings{ + Email: parseProviderDefaultGrantSettings(settings, emailAuthSourceDefaultKeys), + LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys), + OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys), + WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys), + GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys), + Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys), + DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys), + ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", + }, nil +} + +func (s *SettingService) ResolveAuthSourceGrantSettings(ctx context.Context, signupSource string, firstBind bool) (ProviderDefaultGrantSettings, bool, error) { + result := ProviderDefaultGrantSettings{ + Balance: s.GetDefaultBalance(ctx), + Concurrency: s.GetDefaultConcurrency(ctx), + Subscriptions: s.GetDefaultSubscriptions(ctx), + } + + defaults, err := s.GetAuthSourceDefaultSettings(ctx) + if err != nil { + return result, false, err + } + + providerDefaults, ok := authSourceSignupSettings(defaults, signupSource) + if !ok { + return result, false, nil + } + + enabled := providerDefaults.GrantOnSignup + if firstBind { + enabled = providerDefaults.GrantOnFirstBind + } + if !enabled { + return result, false, nil + } + + return mergeProviderDefaultGrantSettings(result, providerDefaults), true, nil +} + +func (s *SettingService) UpdateAuthSourceDefaultSettings(ctx context.Context, settings *AuthSourceDefaultSettings) error { + updates, err := s.buildAuthSourceDefaultUpdates(ctx, settings) + if err != nil { + return err + } + if len(updates) == 0 { + return nil + } + + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + return fmt.Errorf("update auth source default settings: %w", err) + } + return nil +} + +// 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 +} + +// GetOverloadCooldownSettings 获取529过载冷却配置 +func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultOverloadCooldownSettings(), nil + } + return nil, fmt.Errorf("get overload cooldown settings: %w", err) + } + if value == "" { + return DefaultOverloadCooldownSettings(), nil + } + + var settings OverloadCooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultOverloadCooldownSettings(), nil + } + + // 修正配置值范围 + if settings.CooldownMinutes < 1 { + settings.CooldownMinutes = 1 + } + if settings.CooldownMinutes > 120 { + settings.CooldownMinutes = 120 + } + + return &settings, nil +} + +// SetOverloadCooldownSettings 设置529过载冷却配置 +func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + // 禁用时修正为合法值即可,不拒绝请求 + if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 { + if settings.Enabled { + return fmt.Errorf("cooldown_minutes must be between 1-120") + } + settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值 + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal overload cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) +} + +// GetRateLimit429CooldownSettings 获取429默认回避配置 +func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultRateLimit429CooldownSettings(), nil + } + return nil, fmt.Errorf("get 429 cooldown settings: %w", err) + } + if value == "" { + return DefaultRateLimit429CooldownSettings(), nil + } + + var settings RateLimit429CooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultRateLimit429CooldownSettings(), nil + } + + if settings.CooldownSeconds < 1 { + settings.CooldownSeconds = 1 + } + if settings.CooldownSeconds > 7200 { + settings.CooldownSeconds = 7200 + } + + return &settings, nil +} + +// SetRateLimit429CooldownSettings 设置429默认回避配置 +func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 { + if settings.Enabled { + return fmt.Errorf("cooldown_seconds must be between 1-7200") + } + settings.CooldownSeconds = 5 + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal 429 cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data)) +} + +// 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 +} + +// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度 +func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling) + if err != nil { + return false // fail-closed: 查询失败时默认不允许 + } + return value == "true" +} + +// GetRectifierSettings 获取请求整流器配置 +func (s *SettingService) GetRectifierSettings(ctx context.Context) (*RectifierSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRectifierSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultRectifierSettings(), nil + } + return nil, fmt.Errorf("get rectifier settings: %w", err) + } + if value == "" { + return DefaultRectifierSettings(), nil + } + + var settings RectifierSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultRectifierSettings(), nil + } + + return &settings, nil +} + +// SetRectifierSettings 设置请求整流器配置 +func (s *SettingService) SetRectifierSettings(ctx context.Context, settings *RectifierSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal rectifier settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyRectifierSettings, string(data)) +} + +// IsSignatureRectifierEnabled 判断签名整流是否启用(总开关 && 签名子开关) +func (s *SettingService) IsSignatureRectifierEnabled(ctx context.Context) bool { + settings, err := s.GetRectifierSettings(ctx) + if err != nil { + return true // fail-open: 查询失败时默认启用 + } + return settings.Enabled && settings.ThinkingSignatureEnabled +} + +// IsBudgetRectifierEnabled 判断 Budget 整流是否启用(总开关 && Budget 子开关) +func (s *SettingService) IsBudgetRectifierEnabled(ctx context.Context) bool { + settings, err := s.GetRectifierSettings(ctx) + if err != nil { + return true // fail-open: 查询失败时默认启用 + } + return settings.Enabled && settings.ThinkingBudgetEnabled +} + +// GetBetaPolicySettings 获取 Beta 策略配置 +func (s *SettingService) GetBetaPolicySettings(ctx context.Context) (*BetaPolicySettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyBetaPolicySettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultBetaPolicySettings(), nil + } + return nil, fmt.Errorf("get beta policy settings: %w", err) + } + if value == "" { + return DefaultBetaPolicySettings(), nil + } + + var settings BetaPolicySettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultBetaPolicySettings(), nil + } + + return &settings, nil +} + +// SetBetaPolicySettings 设置 Beta 策略配置 +func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *BetaPolicySettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + validActions := map[string]bool{ + BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, + } + validScopes := map[string]bool{ + BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, + } + + for i, rule := range settings.Rules { + if rule.BetaToken == "" { + return fmt.Errorf("rule[%d]: beta_token cannot be empty", i) + } + if !validActions[rule.Action] { + return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) + } + if !validScopes[rule.Scope] { + return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) + } + // Validate model_whitelist patterns + for j, pattern := range rule.ModelWhitelist { + trimmed := strings.TrimSpace(pattern) + if trimmed == "" { + return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) + } + settings.Rules[i].ModelWhitelist[j] = trimmed + } + // Validate fallback_action + if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { + return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) + } + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal beta policy settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyBetaPolicySettings, string(data)) +} + +// GetOpenAIFastPolicySettings 获取 OpenAI fast 策略配置 +func (s *SettingService) GetOpenAIFastPolicySettings(ctx context.Context) (*OpenAIFastPolicySettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIFastPolicySettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultOpenAIFastPolicySettings(), nil + } + return nil, fmt.Errorf("get openai fast policy settings: %w", err) + } + if value == "" { + return DefaultOpenAIFastPolicySettings(), nil + } + + var settings OpenAIFastPolicySettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + // JSON 损坏时静默 fallback 到默认配置会让策略意外失效(管理员配 + // 置的 block/filter 规则被忽略)。记录 Warn 让运维能在出现异常 + // 行为时定位到 settings 表里的脏数据。 + slog.Warn("failed to unmarshal openai fast policy settings, falling back to defaults", + "error", err, + "key", SettingKeyOpenAIFastPolicySettings) + return DefaultOpenAIFastPolicySettings(), nil + } + + return &settings, nil +} + +// SetOpenAIFastPolicySettings 设置 OpenAI fast 策略配置 +func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settings *OpenAIFastPolicySettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + validActions := map[string]bool{ + BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, + OpenAIFastPolicyActionForcePriority: true, + } + validScopes := map[string]bool{ + BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, + } + validTiers := map[string]bool{ + OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true, + } + + for i, rule := range settings.Rules { + tier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) + if tier == "" { + tier = OpenAIFastTierAny + } + if !validTiers[tier] { + return fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier) + } + settings.Rules[i].ServiceTier = tier + if !validActions[rule.Action] { + return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) + } + if !validScopes[rule.Scope] { + return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) + } + for j, pattern := range rule.ModelWhitelist { + trimmed := strings.TrimSpace(pattern) + if trimmed == "" { + return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) + } + settings.Rules[i].ModelWhitelist[j] = trimmed + } + if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { + return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) + } + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal openai fast policy settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, string(data)) +} + +// 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)) +} + +// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回全部允许平台 x 3 window 的设置。 +// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。 +// +// 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。 +// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。 +func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) { + out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms)) + for _, platform := range AllowedQuotaPlatforms { + out[platform] = &DefaultPlatformQuotaSetting{} + } + raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas) + if err != nil || raw == "" { + return out, nil // 无配置 = 全部不限制 + } + parsed := map[string]*DefaultPlatformQuotaSetting{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + slog.Warn("[Setting] unmarshal default_platform_quotas failed (fail-open)", "error", err) + return out, nil + } + for _, platform := range AllowedQuotaPlatforms { + if v := parsed[platform]; v != nil { + out[platform] = v + } + } + return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约 +} + +// GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。 +func (s *SettingService) GetAuthSourcePlatformQuotas(ctx context.Context, source string) map[string]*DefaultPlatformQuotaSetting { + out := map[string]*DefaultPlatformQuotaSetting{} + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAuthSourcePlatformQuotas(source)) + if err != nil || raw == "" { + return out // 无 override + } + if err := json.Unmarshal([]byte(raw), &out); err != nil { + slog.Warn("[Setting] unmarshal auth source platform quotas failed (fail-open)", "source", source, "error", err) + return map[string]*DefaultPlatformQuotaSetting{} + } + return out // 仅含已配置平台,保持 override 语义 +} + +// mergePlatformQuotaDefaults 按字段级 patch:src 中非 nil 字段覆盖 dst。 +// 区分 nil("未配置",保留 dst)vs &0.0("显式禁用",覆盖 dst 为 0) +func mergePlatformQuotaDefaults(dst, src *DefaultPlatformQuotaSetting) { + if src == nil || dst == nil { + return + } + if src.DailyLimitUSD != nil { + dst.DailyLimitUSD = src.DailyLimitUSD + } + if src.WeeklyLimitUSD != nil { + dst.WeeklyLimitUSD = src.WeeklyLimitUSD + } + if src.MonthlyLimitUSD != nil { + dst.MonthlyLimitUSD = src.MonthlyLimitUSD + } +} diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go new file mode 100644 index 0000000000..ea4037d990 --- /dev/null +++ b/backend/internal/service/setting_gateway_runtime.go @@ -0,0 +1,892 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "golang.org/x/sync/singleflight" +) + +// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL) +type cachedVersionBounds struct { + min string // 空字符串 = 不检查 + max string // 空字符串 = 不检查 + expiresAt int64 // unix nano +} + +// versionBoundsCache 版本号上下限进程内缓存 +var versionBoundsCache atomic.Value // *cachedVersionBounds + +// versionBoundsSF 防止缓存过期时 thundering herd +var versionBoundsSF singleflight.Group + +// versionBoundsCacheTTL 缓存有效期 +const versionBoundsCacheTTL = 60 * time.Second + +// versionBoundsErrorTTL DB 错误时的短缓存,快速重试 +const versionBoundsErrorTTL = 5 * time.Second + +// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context +const versionBoundsDBTimeout = 5 * time.Second + +// cachedBackendMode Backend Mode cache (in-process, 60s TTL) +type cachedBackendMode struct { + value bool + expiresAt int64 // unix nano +} + +var backendModeCache atomic.Value // *cachedBackendMode +var backendModeSF singleflight.Group + +const backendModeCacheTTL = 60 * time.Second +const backendModeErrorTTL = 5 * time.Second +const backendModeDBTimeout = 5 * time.Second + +// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL) +type cachedGatewayForwardingSettings struct { + fingerprintUnification bool + metadataPassthrough bool + cchSigning bool + claudeOAuthSystemPromptInjection bool + claudeOAuthSystemPrompt string + claudeOAuthSystemPromptBlocks string + anthropicCacheTTL1hInjection bool + rewriteMessageCacheControl bool + clientDatelineNormalization bool + expiresAt int64 // unix nano +} + +var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings +var gatewayForwardingSF singleflight.Group + +const gatewayForwardingCacheTTL = 60 * time.Second +const gatewayForwardingErrorTTL = 5 * time.Second +const gatewayForwardingDBTimeout = 5 * time.Second + +// cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL) +type cachedAntigravityUserAgentVersion struct { + version string + expiresAt int64 // unix nano +} + +const antigravityUserAgentVersionCacheTTL = 60 * time.Second +const antigravityUserAgentVersionErrorTTL = 5 * time.Second +const antigravityUserAgentVersionDBTimeout = 5 * time.Second + +// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询) +const DefaultOpenAICodexUserAgent = "codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)" + +// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL) +type cachedOpenAICodexUserAgent struct { + value string + expiresAt int64 // unix nano +} + +type cachedOpenAIQuotaAutoPauseSettings struct { + settings OpsOpenAIAccountQuotaAutoPauseSettings + expiresAt int64 +} + +const openAICodexUserAgentCacheTTL = 60 * time.Second +const openAICodexUserAgentErrorTTL = 5 * time.Second +const openAICodexUserAgentDBTimeout = 5 * time.Second + +const codexRestrictionPolicyCacheTTL = 60 * time.Second +const codexRestrictionPolicyDBTimeout = 5 * time.Second + +// cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。 +// GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。 +type cachedCodexRestrictionPolicy struct { + value CodexRestrictionPolicy + expiresAt int64 // unix nano +} + +// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。 +// GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。 +type cachedCyberSessionBlockRuntime struct { + enabled bool + ttl time.Duration + expiresAt int64 // unix nano +} + +const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second +const cyberSessionBlockRuntimeErrorTTL = 5 * time.Second +const cyberSessionBlockRuntimeDBTimeout = 5 * time.Second + +const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second +const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second +const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second + +const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings" + +// GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s, +// 供网关热路径读取时避免 DB 往返。 +// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。 +// 默认值:开关 false,TTL 1h(与粘性会话对齐)。 +func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) { + if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.enabled, cached.ttl + } + } + result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) { + if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cyberSessionBlockRuntimeDBTimeout) + defer cancel() + + enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled) + ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds) + + if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) { + slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr) + entry := &cachedCyberSessionBlockRuntime{ + enabled: false, + ttl: time.Hour, + expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(), + } + s.cyberSessionBlockRuntimeCache.Store(entry) + return entry, nil + } + + enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true" + + ttl := time.Hour + if ttlErr == nil { + if n, perr := strconv.Atoi(strings.TrimSpace(ttlVal)); perr == nil && n > 0 { + ttl = time.Duration(n) * time.Second + } + } + + entry := &cachedCyberSessionBlockRuntime{ + enabled: enabled, + ttl: ttl, + expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(), + } + s.cyberSessionBlockRuntimeCache.Store(entry) + return entry, nil + }) + if entry, ok := result.(*cachedCyberSessionBlockRuntime); ok && entry != nil { + return entry.enabled, entry.ttl + } + return false, time.Hour +} + +// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。 +// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。 +func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string { + fallback := antigravity.GetDefaultUserAgentVersion() + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version + } + } + + result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) { + if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version, nil + } + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + slog.Warn("failed to get antigravity user agent version setting", "error", err) + s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ + version: fallback, + expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(), + }) + return fallback, nil + } + version := antigravity.NormalizeUserAgentVersion(value) + if version == "" { + version = fallback + } + s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ + version: version, + expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(), + }) + return version, nil + }) + if version, ok := result.(string); ok && version != "" { + return version + } + return fallback +} + +// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。 +// 后台设置优先;为空时回退到内置默认值。 +func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string { + fallback := DefaultOpenAICodexUserAgent + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + + result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) { + if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + slog.Warn("failed to get openai codex user agent setting", "error", err) + s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ + value: fallback, + expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(), + }) + return fallback, nil + } + ua := strings.TrimSpace(value) + if ua == "" { + ua = fallback + } + s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ + value: ua, + expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), + }) + return ua, nil + }) + if ua, ok := result.(string); ok && ua != "" { + return ua + } + return fallback +} + +var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{ + Originator: "Claude Code", + UAContains: []string{"Claude Code/"}, +} + +// MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code +// plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the +// same originator + UA marker pair, so runtime checks no longer need a separate flag. +func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error { + if s == nil || s.settingRepo == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return nil + } + return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err) + } + if strings.TrimSpace(legacyValue) != "true" { + return nil + } + + rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + + var entries []openai.AllowedClientEntry + if strings.TrimSpace(rawWhitelist) != "" { + if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil { + return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + } + if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) { + return nil + } + + entries = append(entries, legacyClaudeCodeCodexWhitelistEntry) + encoded, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil { + return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + return nil +} + +// MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint +// 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子, +// 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。 +func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error { + if s == nil || s.settingRepo == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" { + return nil // 已配置/已迁移 + } else if err != nil && !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + + bodyOn := false + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil { + bodyOn = strings.TrimSpace(v) == "true" + } else if !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err) + } + + seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals)) + copy(seed, openai.DefaultEngineFingerprintSignals) + if bodyOn { + for i := range seed { + if seed[i].Type == openai.FingerprintSignalBodyPath { + seed[i].Required = true + } + } + } + encoded, err := json.Marshal(seed) + if err != nil { + return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil { + return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + return nil +} + +func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool { + wantOriginator := strings.TrimSpace(want.Originator) + if wantOriginator == "" { + return false + } + wantMarkers := normalizedCodexClientMarkers(want.UAContains) + if len(wantMarkers) == 0 { + return false + } + for _, entry := range entries { + if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) { + continue + } + gotMarkers := normalizedCodexClientMarkers(entry.UAContains) + if len(gotMarkers) != len(wantMarkers) { + continue + } + matched := true + for marker := range wantMarkers { + if _, ok := gotMarkers[marker]; !ok { + matched = false + break + } + } + if matched { + return true + } + } + return false +} + +func normalizedCodexClientMarkers(markers []string) map[string]struct{} { + normalized := make(map[string]struct{}, len(markers)) + for _, marker := range markers { + marker = strings.TrimSpace(marker) + if marker == "" { + continue + } + normalized[strings.ToLower(marker)] = struct{}{} + } + return normalized +} + +// GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。 +// 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。 +// 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。 +func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy { + if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) { + if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号 + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil { + pol.MinCodexVersion = strings.TrimSpace(v) + } + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil { + pol.MaxCodexVersion = strings.TrimSpace(v) + } + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil { + pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启 + } + pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx) + pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist) + pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist) + + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{ + value: pol, + expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(), + }) + return pol, nil + }) + if pol, ok := result.(CodexRestrictionPolicy); ok { + return pol + } + return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} +} + +// loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。 +func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry { + v, err := s.settingRepo.GetValue(ctx, key) + if err != nil || strings.TrimSpace(v) == "" { + return nil + } + var entries []openai.AllowedClientEntry + if json.Unmarshal([]byte(v), &entries) != nil { + return nil + } + return entries +} + +// loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。 +func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal { + v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals) + if err != nil || strings.TrimSpace(v) == "" { + return openai.DefaultEngineFingerprintSignals + } + sigs, ok := openai.ParseEngineFingerprintSignals(v) + if !ok { + return openai.DefaultEngineFingerprintSignals + } + return sigs +} + +// ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义): +// 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny, +// 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。 +func ValidateCodexClientEntriesJSON(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var entries []openai.AllowedClientEntry + if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { + return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") + } + return nil +} + +// ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求 +// 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND: +// originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈, +// 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。 +func ValidateCodexWhitelistEntriesJSON(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var entries []openai.AllowedClientEntry + if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { + return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") + } + for i, e := range entries { + if !e.IsWhitelistable() { + return fmt.Errorf("entry %d: whitelist requires a non-empty originator and at least one non-empty ua_contains (double-factor AND; otherwise the rule never matches)", i) + } + } + return nil +} + +// ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。 +func ValidateEngineFingerprintSignalsJSON(raw string) error { + return openai.ValidateEngineFingerprintSignalsJSON(raw) +} + +// IsBackendModeEnabled checks if backend mode is enabled +// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path +func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { + if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) { + if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + // Setting not yet created (fresh install) - default to disabled with full TTL + backendModeCache.Store(&cachedBackendMode{ + value: false, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + return false, nil + } + slog.Warn("failed to get backend_mode_enabled setting", "error", err) + backendModeCache.Store(&cachedBackendMode{ + value: false, + expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(), + }) + return false, nil + } + enabled := value == "true" + backendModeCache.Store(&cachedBackendMode{ + value: enabled, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + return enabled, nil + }) + if val, ok := result.(bool); ok { + return val + } + return false +} + +type gatewayForwardingSettingsResult struct { + fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool + clientDatelineNormalization bool + claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string +} + +func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult { + if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return gatewayForwardingSettingsResult{ + fp: cached.fingerprintUnification, + mp: cached.metadataPassthrough, + cch: cached.cchSigning, + claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, + cacheTTL1h: cached.anthropicCacheTTL1hInjection, + rewriteMessageCacheControl: cached.rewriteMessageCacheControl, + clientDatelineNormalization: cached.clientDatelineNormalization, + } + } + } + val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) { + if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return gatewayForwardingSettingsResult{ + fp: cached.fingerprintUnification, + mp: cached.metadataPassthrough, + cch: cached.cchSigning, + claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, + cacheTTL1h: cached.anthropicCacheTTL1hInjection, + rewriteMessageCacheControl: cached.rewriteMessageCacheControl, + clientDatelineNormalization: cached.clientDatelineNormalization, + }, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyEnableFingerprintUnification, + SettingKeyEnableMetadataPassthrough, + SettingKeyEnableCCHSigning, + SettingKeyEnableClaudeOAuthSystemPromptInjection, + SettingKeyClaudeOAuthSystemPrompt, + SettingKeyClaudeOAuthSystemPromptBlocks, + SettingKeyEnableAnthropicCacheTTL1hInjection, + SettingKeyRewriteMessageCacheControl, + SettingKeyEnableClientDatelineNormalization, + }) + if err != nil { + slog.Warn("failed to get gateway forwarding settings", "error", err) + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: true, + metadataPassthrough: false, + cchSigning: false, + claudeOAuthSystemPromptInjection: true, + anthropicCacheTTL1hInjection: false, + rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), + clientDatelineNormalization: true, + expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), + }) + return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil + } + fp := true + if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { + fp = v == "true" + } + mp := values[SettingKeyEnableMetadataPassthrough] == "true" + cch := values[SettingKeyEnableCCHSigning] == "true" + systemPromptInjection := true + if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { + systemPromptInjection = v == "true" + } + systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt] + systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks] + cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" + rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl() + if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" { + rewriteMessageCacheControl = v == "true" + } + clientDatelineNormalization := true + if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" { + clientDatelineNormalization = v == "true" + } + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: fp, + metadataPassthrough: mp, + cchSigning: cch, + claudeOAuthSystemPromptInjection: systemPromptInjection, + claudeOAuthSystemPrompt: systemPrompt, + claudeOAuthSystemPromptBlocks: systemPromptBlocks, + anthropicCacheTTL1hInjection: cacheTTL1h, + rewriteMessageCacheControl: rewriteMessageCacheControl, + clientDatelineNormalization: clientDatelineNormalization, + expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), + }) + return gatewayForwardingSettingsResult{ + fp: fp, + mp: mp, + cch: cch, + claudeOAuthSystemPromptInjection: systemPromptInjection, + claudeOAuthSystemPrompt: systemPrompt, + claudeOAuthSystemPromptBlocks: systemPromptBlocks, + cacheTTL1h: cacheTTL1h, + rewriteMessageCacheControl: rewriteMessageCacheControl, + clientDatelineNormalization: clientDatelineNormalization, + }, nil + }) + if r, ok := val.(gatewayForwardingSettingsResult); ok { + return r + } + return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true} +} + +// GetGatewayForwardingSettings returns cached gateway forwarding settings. +// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path. +// Returns (fingerprintUnification, metadataPassthrough, cchSigning). +func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) { + result := s.getGatewayForwardingSettingsCached(ctx) + return result.fp, result.mp, result.cch +} + +// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。 +func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool { + return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h +} + +// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。 +func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool { + return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl +} + +// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体 +// 的客户端 dateline 归一化。默认开启。 +func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool { + return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization +} + +// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic +// system block switch, legacy custom expansion prompt, and configurable blocks JSON. +// Empty values mean use the built-in Claude Code default blocks. +func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) { + result := s.getGatewayForwardingSettingsCached(ctx) + return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks +} + +// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求 +// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销 +// singleflight 防止缓存过期时 thundering herd +// 返回空字符串表示不做对应方向的版本检查 +func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) { + if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { + if time.Now().UnixNano() < cached.expiresAt { + return cached.min, cached.max + } + } + // singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果 + type bounds struct{ min, max string } + result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) { + // 二次检查,避免排队的 goroutine 重复查询 + if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { + if time.Now().UnixNano() < cached.expiresAt { + return bounds{cached.min, cached.max}, nil + } + } + // 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存 + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyMinClaudeCodeVersion, + SettingKeyMaxClaudeCodeVersion, + }) + if err != nil { + // fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试 + slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err) + versionBoundsCache.Store(&cachedVersionBounds{ + min: "", + max: "", + expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(), + }) + return bounds{"", ""}, nil + } + b := bounds{ + min: values[SettingKeyMinClaudeCodeVersion], + max: values[SettingKeyMaxClaudeCodeVersion], + } + versionBoundsCache.Store(&cachedVersionBounds{ + min: b.min, + max: b.max, + expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), + }) + return b, nil + }) + if err != nil { + return "", "" + } + b, ok := result.(bounds) + if !ok { + return "", "" + } + return b.min, b.max +} + +// GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause +// settings. It is invoked on the OpenAI scheduling hot path (once per request) and is +// therefore designed to never block on the DB: +// +// - Fresh cached value → returned immediately. +// - Stale or empty cache → the last known value is returned, and a background +// goroutine refreshes the cache via singleflight (stale-while-revalidate). +// - First call with no cache yet → zero defaults are returned and the same async +// refresh is kicked off; the next call gets the freshly populated value. +// +// Callers that need the freshly persisted value synchronously (tests, post-update +// confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings. +func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { + if s == nil { + return OpsOpenAIAccountQuotaAutoPauseSettings{} + } + cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings) + now := time.Now().UnixNano() + if cached != nil && now < cached.expiresAt { + return cached.settings + } + // Stale or unset: trigger background refresh without blocking this request. + // singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the + // returned channel — the result is observable via the atomic cache. + s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) { + s.refreshOpenAIQuotaAutoPauseSettings(context.Background()) + return nil, nil + }) + if cached != nil { + return cached.settings // serve stale value while revalidating + } + return OpsOpenAIAccountQuotaAutoPauseSettings{} +} + +// WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings +// into the in-memory cache. Useful for application startup (so the first request hits +// a warm cache) and for tests that need deterministic reads immediately after +// constructing the service. +func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { + if s == nil { + return OpsOpenAIAccountQuotaAutoPauseSettings{} + } + s.refreshOpenAIQuotaAutoPauseSettings(ctx) + cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings) + if cached == nil { + return OpsOpenAIAccountQuotaAutoPauseSettings{} + } + return cached.settings +} + +// refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores +// them into the in-memory cache. On error it stores the prior value (or zero defaults +// if nothing is cached yet) with the shorter error TTL so the next refresh comes +// sooner. Always uses its own timeout-bounded context to keep refresh latency +// predictable regardless of the caller. +func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) { + if s == nil || s.settingRepo == nil { + return + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout) + defer cancel() + + settings := OpsOpenAIAccountQuotaAutoPauseSettings{} + ttl := openAIQuotaAutoPauseSettingsCacheTTL + raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings) + if err == nil { + cfg := defaultOpsAdvancedSettings() + if strings.TrimSpace(raw) != "" { + if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil { + normalizeOpsAdvancedSettings(cfg) + } + } + settings = cfg.OpenAIAccountQuotaAutoPause + } else if !errors.Is(err, ErrSettingNotFound) { + // Real error: keep serving prior value but refresh sooner. + if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil { + settings = prior.settings + } + ttl = openAIQuotaAutoPauseSettingsErrorTTL + } + + s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ + settings: settings, + expiresAt: time.Now().Add(ttl).UnixNano(), + }) +} + +// SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory +// cache. Called from settings-write code paths so that the next read reflects the new +// value immediately, without waiting for the background refresh. +func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) { + if s == nil { + return + } + s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ + settings: settings, + expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(), + }) +} diff --git a/backend/internal/service/setting_oauth.go b/backend/internal/service/setting_oauth.go new file mode 100644 index 0000000000..cb717c7743 --- /dev/null +++ b/backend/internal/service/setting_oauth.go @@ -0,0 +1,1015 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/imroc/req/v3" +) + +// CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本, +// 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce(前端 UI 虽已无 whitelist 选项, +// 但 API 可被直接调用)。 +func CoerceDingTalkCorpPolicyForWrite(policy string) string { + return coerceDeprecatedDingTalkCorpPolicy(policy) +} + +// coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。 +// 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed +// (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。 +func coerceDeprecatedDingTalkCorpPolicy(policy string) string { + if policy == "whitelist" { + slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none", + "hint", "re-save DingTalk settings in admin UI to clear this warning") + return "none" + } + return policy +} + +func normalizeWeChatConnectModeSetting(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "mp": + return "mp" + case "mobile": + return "mobile" + default: + return "open" + } +} + +func defaultWeChatConnectScopeForMode(mode string) string { + switch normalizeWeChatConnectModeSetting(mode) { + case "mp": + return "snsapi_userinfo" + case "mobile": + return "" + } + return defaultWeChatConnectScopes +} + +func normalizeWeChatConnectScopeSetting(raw, mode string) string { + switch normalizeWeChatConnectModeSetting(mode) { + case "mp": + switch strings.TrimSpace(raw) { + case "snsapi_base": + return "snsapi_base" + case "snsapi_userinfo": + return "snsapi_userinfo" + default: + return defaultWeChatConnectScopeForMode(mode) + } + case "mobile": + return "" + default: + return defaultWeChatConnectScopes + } +} + +func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) { + mode = normalizeWeChatConnectModeSetting(mode) + rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] + rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] + rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] + openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" + mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" + mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" + + if openConfigured || mpConfigured || mobileConfigured { + openEnabled := strings.TrimSpace(rawOpen) == "true" + mpEnabled := strings.TrimSpace(rawMP) == "true" + mobileEnabled := strings.TrimSpace(rawMobile) == "true" + return openEnabled, mpEnabled, mobileEnabled + } + + if !enabled { + return false, false, false + } + if mode == "mp" { + return false, true, false + } + if mode == "mobile" { + return false, false, true + } + return true, false, false +} + +func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string { + mode = normalizeWeChatConnectModeSetting(mode) + switch mode { + case "open": + if openEnabled { + return "open" + } + case "mp": + if mpEnabled { + return "mp" + } + case "mobile": + if mobileEnabled { + return "mobile" + } + } + switch { + case openEnabled: + return "open" + case mpEnabled: + return "mp" + case mobileEnabled: + return "mobile" + default: + return mode + } +} + +func mergeWeChatConnectCapabilitySettings(settings map[string]string, base config.WeChatConnectConfig, enabled bool, mode string) (bool, bool, bool) { + mode = normalizeWeChatConnectModeSetting(firstNonEmpty(mode, base.Mode)) + rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] + rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] + rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] + openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" + mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" + mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" + + if openConfigured || mpConfigured || mobileConfigured { + openEnabled := strings.TrimSpace(rawOpen) == "true" + mpEnabled := strings.TrimSpace(rawMP) == "true" + mobileEnabled := strings.TrimSpace(rawMobile) == "true" + _, enabledConfigured := settings[SettingKeyWeChatConnectEnabled] + if !enabledConfigured && + enabled && + !openEnabled && + !mpEnabled && + !mobileEnabled && + (base.OpenEnabled || base.MPEnabled || base.MobileEnabled) { + return base.OpenEnabled, base.MPEnabled, base.MobileEnabled + } + return openEnabled, mpEnabled, mobileEnabled + } + if !enabled { + return false, false, false + } + if base.OpenEnabled || base.MPEnabled || base.MobileEnabled { + return base.OpenEnabled, base.MPEnabled, base.MobileEnabled + } + return parseWeChatConnectCapabilitySettings(settings, enabled, mode) +} + +func (s *SettingService) effectiveWeChatConnectOAuthConfig(settings map[string]string) WeChatConnectOAuthConfig { + base := config.WeChatConnectConfig{} + if s != nil && s.cfg != nil { + base = s.cfg.WeChat + } + + enabled := base.Enabled + if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok { + enabled = strings.TrimSpace(raw) == "true" + } + + legacyAppID := strings.TrimSpace(firstNonEmpty( + settings[SettingKeyWeChatConnectAppID], + base.AppID, + base.OpenAppID, + base.MPAppID, + base.MobileAppID, + )) + legacyAppSecret := strings.TrimSpace(firstNonEmpty( + settings[SettingKeyWeChatConnectAppSecret], + base.AppSecret, + base.OpenAppSecret, + base.MPAppSecret, + base.MobileAppSecret, + )) + openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], base.OpenAppID, legacyAppID)) + openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], base.OpenAppSecret, legacyAppSecret)) + mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], base.MPAppID, legacyAppID)) + mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], base.MPAppSecret, legacyAppSecret)) + mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], base.MobileAppID, legacyAppID)) + mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], base.MobileAppSecret, legacyAppSecret)) + + modeRaw := firstNonEmpty(settings[SettingKeyWeChatConnectMode], base.Mode) + openEnabled, mpEnabled, mobileEnabled := mergeWeChatConnectCapabilitySettings(settings, base, enabled, modeRaw) + mode := normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, modeRaw) + + return WeChatConnectOAuthConfig{ + Enabled: enabled, + LegacyAppID: legacyAppID, + LegacyAppSecret: legacyAppSecret, + OpenAppID: openAppID, + OpenAppSecret: openAppSecret, + MPAppID: mpAppID, + MPAppSecret: mpAppSecret, + MobileAppID: mobileAppID, + MobileAppSecret: mobileAppSecret, + OpenEnabled: openEnabled, + MPEnabled: mpEnabled, + MobileEnabled: mobileEnabled, + Mode: mode, + Scopes: normalizeWeChatConnectScopeSetting(firstNonEmpty(settings[SettingKeyWeChatConnectScopes], base.Scopes), mode), + RedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectRedirectURL], base.RedirectURL)), + FrontendRedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectFrontendRedirectURL], base.FrontendRedirectURL, defaultWeChatConnectFrontend)), + } +} + +func DefaultWeChatConnectScopesForMode(mode string) string { + return defaultWeChatConnectScopeForMode(mode) +} + +func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) { + cfg := s.effectiveWeChatConnectOAuthConfig(settings) + + if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) { + return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled") + } + if cfg.OpenEnabled { + if cfg.AppIDForMode("open") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app id not configured") + } + if cfg.AppSecretForMode("open") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app secret not configured") + } + } + if cfg.MPEnabled { + if cfg.AppIDForMode("mp") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app id not configured") + } + if cfg.AppSecretForMode("mp") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app secret not configured") + } + } + if cfg.MobileEnabled { + if cfg.AppIDForMode("mobile") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app id not configured") + } + if cfg.AppSecretForMode("mobile") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured") + } + } + if v := strings.TrimSpace(cfg.RedirectURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid") + } + } + if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid") + } + return cfg, nil +} + +func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) { + cfg := s.effectiveWeChatConnectOAuthConfig(settings) + if !cfg.Enabled { + return false, false, false, false + } + + openReady := cfg.OpenEnabled && cfg.AppIDForMode("open") != "" && cfg.AppSecretForMode("open") != "" + mpReady := cfg.MPEnabled && cfg.AppIDForMode("mp") != "" && cfg.AppSecretForMode("mp") != "" + mobileReady := cfg.MobileEnabled && cfg.AppIDForMode("mobile") != "" && cfg.AppSecretForMode("mobile") != "" + + return openReady || mpReady, openReady, mpReady, mobileReady +} + +func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "github": + cfg := config.EmailOAuthProviderConfig{ + AuthorizeURL: defaultGitHubOAuthAuthorize, + TokenURL: defaultGitHubOAuthToken, + UserInfoURL: defaultGitHubOAuthUserInfo, + EmailsURL: defaultGitHubOAuthEmails, + Scopes: defaultGitHubOAuthScopes, + FrontendRedirectURL: defaultGitHubOAuthFrontend, + } + if s != nil && s.cfg != nil { + cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth) + } + return cfg + case "google": + cfg := config.EmailOAuthProviderConfig{ + AuthorizeURL: defaultGoogleOAuthAuthorize, + TokenURL: defaultGoogleOAuthToken, + UserInfoURL: defaultGoogleOAuthUserInfo, + Scopes: defaultGoogleOAuthScopes, + FrontendRedirectURL: defaultGoogleOAuthFrontend, + } + if s != nil && s.cfg != nil { + cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth) + } + return cfg + default: + return config.EmailOAuthProviderConfig{} + } +} + +func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig { + base.Enabled = override.Enabled + if strings.TrimSpace(override.ClientID) != "" { + base.ClientID = strings.TrimSpace(override.ClientID) + } + if strings.TrimSpace(override.ClientSecret) != "" { + base.ClientSecret = strings.TrimSpace(override.ClientSecret) + } + if strings.TrimSpace(override.AuthorizeURL) != "" { + base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL) + } + if strings.TrimSpace(override.TokenURL) != "" { + base.TokenURL = strings.TrimSpace(override.TokenURL) + } + if strings.TrimSpace(override.UserInfoURL) != "" { + base.UserInfoURL = strings.TrimSpace(override.UserInfoURL) + } + if strings.TrimSpace(override.EmailsURL) != "" { + base.EmailsURL = strings.TrimSpace(override.EmailsURL) + } + if strings.TrimSpace(override.Scopes) != "" { + base.Scopes = strings.TrimSpace(override.Scopes) + } + if strings.TrimSpace(override.RedirectURL) != "" { + base.RedirectURL = strings.TrimSpace(override.RedirectURL) + } + if strings.TrimSpace(override.FrontendRedirectURL) != "" { + base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL) + } + return base +} + +func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool { + cfg := s.effectiveEmailOAuthConfig(settings, provider) + return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != "" +} + +func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig { + cfg := s.emailOAuthBaseConfig(provider) + switch strings.ToLower(strings.TrimSpace(provider)) { + case "github": + if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok { + cfg.Enabled = raw == "true" + } + cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID) + cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret) + cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL) + cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend) + case "google": + if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok { + cfg.Enabled = raw == "true" + } + cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID) + cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret) + cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL) + cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend) + } + return cfg +} + +func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool { + if base.UsePKCEExplicit { + return base.UsePKCE + } + return true +} + +func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool { + if base.ValidateIDTokenExplicit { + return base.ValidateIDToken + } + return true +} + +func oidcCompatibilityWriteDefault(base config.OIDCConnectConfig, configured bool, raw string, explicit bool, explicitValue bool) bool { + if configured { + return strings.TrimSpace(raw) == "true" + } + if explicit { + return explicitValue + } + return false +} + +func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, bool, error) { + rawSettings, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyOIDCConnectUsePKCE, + SettingKeyOIDCConnectValidateIDToken, + }) + if err != nil { + return false, false, fmt.Errorf("get oidc security write defaults: %w", err) + } + + base := config.OIDCConnectConfig{} + if s != nil && s.cfg != nil { + base = s.cfg.OIDC + } + + rawUsePKCE, hasUsePKCE := rawSettings[SettingKeyOIDCConnectUsePKCE] + rawValidateIDToken, hasValidateIDToken := rawSettings[SettingKeyOIDCConnectValidateIDToken] + + return oidcCompatibilityWriteDefault(base, hasUsePKCE, rawUsePKCE, base.UsePKCEExplicit, base.UsePKCE), + oidcCompatibilityWriteDefault(base, hasValidateIDToken, rawValidateIDToken, base.ValidateIDTokenExplicit, base.ValidateIDToken), + nil +} + +func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider != "github" && provider != "google" { + return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found") + } + keys := []string{ + SettingKeyGitHubOAuthEnabled, + SettingKeyGitHubOAuthClientID, + SettingKeyGitHubOAuthClientSecret, + SettingKeyGitHubOAuthRedirectURL, + SettingKeyGitHubOAuthFrontendRedirectURL, + SettingKeyGoogleOAuthEnabled, + SettingKeyGoogleOAuthClientID, + SettingKeyGoogleOAuthClientSecret, + SettingKeyGoogleOAuthRedirectURL, + SettingKeyGoogleOAuthFrontendRedirectURL, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err) + } + cfg := s.effectiveEmailOAuthConfig(settings, provider) + if !cfg.Enabled { + return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + if strings.TrimSpace(cfg.ClientID) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(cfg.ClientSecret) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + for label, rawURL := range map[string]string{ + "authorize": cfg.AuthorizeURL, + "token": cfg.TokenURL, + "userinfo": cfg.UserInfoURL, + "redirect": cfg.RedirectURL, + } { + if strings.TrimSpace(rawURL) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured") + } + if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid") + } + } + if strings.TrimSpace(cfg.EmailsURL) != "" { + if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid") + } + } + if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") + } + return cfg, nil +} + +// 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": + default: + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") + } + + return effective, nil +} + +// GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。 +// +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 +func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := s.cfg.DingTalk + + keys := []string{ + SettingKeyDingTalkConnectEnabled, + SettingKeyDingTalkConnectClientID, + SettingKeyDingTalkConnectClientSecret, + SettingKeyDingTalkConnectRedirectURL, + SettingKeyDingTalkConnectCorpRestrictionPolicy, + SettingKeyDingTalkConnectInternalCorpID, + SettingKeyDingTalkConnectBypassRegistration, + SettingKeyDingTalkConnectSyncCorpEmail, + SettingKeyDingTalkConnectSyncDisplayName, + SettingKeyDingTalkConnectSyncDept, + SettingKeyDingTalkConnectSyncCorpEmailAttrKey, + SettingKeyDingTalkConnectSyncDisplayNameAttrKey, + SettingKeyDingTalkConnectSyncDeptAttrKey, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { + effective.ClientID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" { + effective.ClientSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { + effective.CorpRestrictionPolicy = strings.TrimSpace(v) + } + effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy) + if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { + effective.InternalCorpID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { + effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") + } + // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, + // 以保证 OAuth callback 看到的 effective config 永远是一致状态。 + if effective.CorpRestrictionPolicy != "internal_only" { + effective.BypassRegistration = false + } + + if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { + effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { + effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { + effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true") + } + // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 + if effective.CorpRestrictionPolicy != "internal_only" { + effective.SyncCorpEmail = false + effective.SyncDisplayName = false + effective.SyncDept = false + } + + // 身份同步目标 attr key(DB 空 → fallback 默认值) + if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" { + effective.SyncCorpEmailAttrKey = v + } + if effective.SyncCorpEmailAttrKey == "" { + effective.SyncCorpEmailAttrKey = "dingtalk_email" + } + if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" { + effective.SyncDisplayNameAttrKey = v + } + if effective.SyncDisplayNameAttrKey == "" { + effective.SyncDisplayNameAttrKey = "dingtalk_name" + } + if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" { + effective.SyncDeptAttrKey = v + } + if effective.SyncDeptAttrKey == "" { + effective.SyncDeptAttrKey = "dingtalk_department" + } + + if !effective.Enabled { + return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled") + } + + // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 + if strings.TrimSpace(effective.ClientID) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured") + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured") + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured") + } + + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid") + } + if strings.TrimSpace(effective.ClientSecret) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured") + } + + // 镜像 admin handler 行为:internal_only policy 隐式要求 AppType=internal + if effective.CorpRestrictionPolicy == "internal_only" { + effective.AppType = "internal" + } + + if err := config.ValidateDingTalkConfig(effective); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error()) + } + + return effective, nil +} + +// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。 +// +// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。 +func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) { + keys := []string{ + SettingKeyWeChatConnectEnabled, + SettingKeyWeChatConnectAppID, + SettingKeyWeChatConnectAppSecret, + SettingKeyWeChatConnectOpenAppID, + SettingKeyWeChatConnectOpenAppSecret, + SettingKeyWeChatConnectMPAppID, + SettingKeyWeChatConnectMPAppSecret, + SettingKeyWeChatConnectMobileAppID, + SettingKeyWeChatConnectMobileAppSecret, + SettingKeyWeChatConnectOpenEnabled, + SettingKeyWeChatConnectMPEnabled, + SettingKeyWeChatConnectMobileEnabled, + SettingKeyWeChatConnectMode, + SettingKeyWeChatConnectScopes, + SettingKeyWeChatConnectRedirectURL, + SettingKeyWeChatConnectFrontendRedirectURL, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err) + } + return s.parseWeChatConnectOAuthConfig(settings) +} + +// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 +// +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 +func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := s.cfg.OIDC + + keys := []string{ + SettingKeyOIDCConnectEnabled, + SettingKeyOIDCConnectProviderName, + SettingKeyOIDCConnectClientID, + SettingKeyOIDCConnectClientSecret, + SettingKeyOIDCConnectIssuerURL, + SettingKeyOIDCConnectDiscoveryURL, + SettingKeyOIDCConnectAuthorizeURL, + SettingKeyOIDCConnectTokenURL, + SettingKeyOIDCConnectUserInfoURL, + SettingKeyOIDCConnectJWKSURL, + SettingKeyOIDCConnectScopes, + SettingKeyOIDCConnectRedirectURL, + SettingKeyOIDCConnectFrontendRedirectURL, + SettingKeyOIDCConnectTokenAuthMethod, + SettingKeyOIDCConnectUsePKCE, + SettingKeyOIDCConnectValidateIDToken, + SettingKeyOIDCConnectAllowedSigningAlgs, + SettingKeyOIDCConnectClockSkewSeconds, + SettingKeyOIDCConnectRequireEmailVerified, + SettingKeyOIDCConnectUserInfoEmailPath, + SettingKeyOIDCConnectUserInfoIDPath, + SettingKeyOIDCConnectUserInfoUsernamePath, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { + effective.ProviderName = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { + effective.ClientID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" { + effective.ClientSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { + effective.IssuerURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { + effective.DiscoveryURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { + effective.AuthorizeURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { + effective.TokenURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { + effective.UserInfoURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { + effective.JWKSURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { + effective.Scopes = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.FrontendRedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { + effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) + } + if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { + effective.UsePKCE = raw == "true" + } else { + effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective) + } + if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { + effective.ValidateIDToken = raw == "true" + } else { + effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective) + } + if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { + effective.AllowedSigningAlgs = strings.TrimSpace(v) + } + if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { + if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil { + effective.ClockSkewSeconds = parsed + } + } + if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { + effective.RequireEmailVerified = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { + effective.UserInfoEmailPath = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { + effective.UserInfoIDPath = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { + effective.UserInfoUsernamePath = strings.TrimSpace(v) + } + + if !effective.Enabled { + return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + if strings.TrimSpace(effective.ProviderName) == "" { + effective.ProviderName = "OIDC" + } + if strings.TrimSpace(effective.ClientID) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(effective.IssuerURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") + } + if !scopesContainOpenID(effective.Scopes) { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid") + } + if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600") + } + + if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid") + } + + discoveryURL := strings.TrimSpace(effective.DiscoveryURL) + if discoveryURL == "" { + discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL) + effective.DiscoveryURL = discoveryURL + } + if discoveryURL != "" { + if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid") + } + } + + needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" || + strings.TrimSpace(effective.TokenURL) == "" || + (effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "") + if needsDiscovery && discoveryURL != "" { + metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL) + if resolveErr != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr) + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint) + } + if strings.TrimSpace(effective.TokenURL) == "" { + effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint) + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint) + } + if strings.TrimSpace(effective.JWKSURL) == "" { + effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI) + } + } + + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") + } + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") + } + if v := strings.TrimSpace(effective.UserInfoURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") + } + } + if effective.ValidateIDToken { + if strings.TrimSpace(effective.JWKSURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured") + } + if strings.TrimSpace(effective.AllowedSigningAlgs) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured") + } + } + if v := strings.TrimSpace(effective.JWKSURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid") + } + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.OIDCConnectConfig{}, 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.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + case "none": + default: + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") + } + + return effective, nil +} + +func scopesContainOpenID(scopes string) bool { + for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { + if scope == "openid" { + return true + } + } + return false +} + +type oidcProviderMetadata struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +func oidcDefaultDiscoveryURL(issuerURL string) string { + issuerURL = strings.TrimSpace(issuerURL) + if issuerURL == "" { + return "" + } + return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" +} + +func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) { + discoveryURL = strings.TrimSpace(discoveryURL) + if discoveryURL == "" { + return nil, fmt.Errorf("discovery url is empty") + } + + resp, err := req.C(). + SetTimeout(15*time.Second). + R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + Get(discoveryURL) + if err != nil { + return nil, fmt.Errorf("request discovery document: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode) + } + + metadata := &oidcProviderMetadata{} + if err := json.Unmarshal(resp.Bytes(), metadata); err != nil { + return nil, fmt.Errorf("parse discovery document: %w", err) + } + return metadata, nil +} diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go new file mode 100644 index 0000000000..bf71b559a0 --- /dev/null +++ b/backend/internal/service/setting_parse.go @@ -0,0 +1,1157 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "sort" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) + +// 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) + } + + oidcUsePKCEDefault := true + oidcValidateIDTokenDefault := true + if s != nil && s.cfg != nil { + if s.cfg.OIDC.UsePKCEExplicit { + oidcUsePKCEDefault = s.cfg.OIDC.UsePKCE + } + if s.cfg.OIDC.ValidateIDTokenExplicit { + oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken + } + } + loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments()) + if err != nil { + return err + } + + // 初始化默认设置 + defaults := map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyEmailVerifyEnabled: "false", + SettingKeyRegistrationEmailSuffixWhitelist: "[]", + SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 + SettingKeyLoginAgreementEnabled: "false", + SettingKeyLoginAgreementMode: defaultLoginAgreementMode, + SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, + SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, + SettingKeyAPIKeyACLTrustForwardedIP: "false", + SettingKeySiteName: "Sub2API", + SettingKeySiteLogo: "", + SettingKeyPurchaseSubscriptionEnabled: "false", + SettingKeyPurchaseSubscriptionURL: "", + SettingKeyTableDefaultPageSize: "20", + SettingKeyTablePageSizeOptions: "[10,20,50,100]", + SettingKeyCustomMenuItems: "[]", + SettingKeyCustomEndpoints: "[]", + SettingKeyWeChatConnectEnabled: "false", + SettingKeyWeChatConnectAppID: "", + SettingKeyWeChatConnectAppSecret: "", + SettingKeyWeChatConnectOpenAppID: "", + SettingKeyWeChatConnectOpenAppSecret: "", + SettingKeyWeChatConnectMPAppID: "", + SettingKeyWeChatConnectMPAppSecret: "", + SettingKeyWeChatConnectMobileAppID: "", + SettingKeyWeChatConnectMobileAppSecret: "", + SettingKeyWeChatConnectOpenEnabled: "false", + SettingKeyWeChatConnectMPEnabled: "false", + SettingKeyWeChatConnectMobileEnabled: "false", + SettingKeyWeChatConnectMode: "open", + SettingKeyWeChatConnectScopes: "snsapi_login", + SettingKeyWeChatConnectRedirectURL: "", + SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, + SettingKeyGitHubOAuthEnabled: "false", + SettingKeyGitHubOAuthClientID: "", + SettingKeyGitHubOAuthClientSecret: "", + SettingKeyGitHubOAuthRedirectURL: "", + SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend, + SettingKeyGoogleOAuthEnabled: "false", + SettingKeyGoogleOAuthClientID: "", + SettingKeyGoogleOAuthClientSecret: "", + SettingKeyGoogleOAuthRedirectURL: "", + SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend, + SettingKeyOIDCConnectEnabled: "false", + SettingKeyOIDCConnectProviderName: "OIDC", + SettingKeyOIDCConnectClientID: "", + SettingKeyOIDCConnectClientSecret: "", + SettingKeyOIDCConnectIssuerURL: "", + SettingKeyOIDCConnectDiscoveryURL: "", + SettingKeyOIDCConnectAuthorizeURL: "", + SettingKeyOIDCConnectTokenURL: "", + SettingKeyOIDCConnectUserInfoURL: "", + SettingKeyOIDCConnectJWKSURL: "", + SettingKeyOIDCConnectScopes: "openid email profile", + SettingKeyOIDCConnectRedirectURL: "", + SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback", + SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post", + SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault), + SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault), + SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256", + SettingKeyOIDCConnectClockSkewSeconds: "120", + SettingKeyOIDCConnectRequireEmailVerified: "false", + SettingKeyOIDCConnectUserInfoEmailPath: "", + SettingKeyOIDCConnectUserInfoIDPath: "", + SettingKeyOIDCConnectUserInfoUsernamePath: "", + SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), + SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), + SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), + SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault), + SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault), + SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64), + SettingKeyDefaultUserRPMLimit: "0", + SettingKeyDefaultSubscriptions: "[]", + SettingKeyAuthSourceDefaultEmailBalance: "0", + SettingKeyAuthSourceDefaultEmailConcurrency: "5", + SettingKeyAuthSourceDefaultEmailSubscriptions: "[]", + SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false", + SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultLinuxDoBalance: "0", + SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5", + SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]", + SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false", + SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultOIDCBalance: "0", + SettingKeyAuthSourceDefaultOIDCConcurrency: "5", + SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]", + SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false", + SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultWeChatBalance: "0", + SettingKeyAuthSourceDefaultWeChatConcurrency: "5", + SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]", + SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false", + SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultGitHubBalance: "0", + SettingKeyAuthSourceDefaultGitHubConcurrency: "5", + SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]", + SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false", + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultGoogleBalance: "0", + SettingKeyAuthSourceDefaultGoogleConcurrency: "5", + SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]", + SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false", + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultDingTalkBalance: "0", + SettingKeyAuthSourceDefaultDingTalkConcurrency: "5", + SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]", + SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false", + SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false", + SettingKeyForceEmailOnThirdPartySignup: "false", + 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", + + // Channel monitor defaults (enabled, 60s) + SettingKeyChannelMonitorEnabled: "true", + SettingKeyChannelMonitorDefaultIntervalSeconds: "60", + + // Available channels feature (default disabled; opt-in) + SettingKeyAvailableChannelsEnabled: "false", + + // Affiliate (邀请返利) feature (default disabled; opt-in) + SettingKeyAffiliateEnabled: "false", + + // 风控中心功能(默认关闭,显式启用) + SettingKeyRiskControlEnabled: "false", + + // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) + SettingKeyCyberSessionBlockEnabled: "false", + SettingKeyCyberSessionBlockTTLSeconds: "3600", + + // Claude Code version check (default: empty = disabled) + SettingKeyMinClaudeCodeVersion: "", + SettingKeyMaxClaudeCodeVersion: "", + + // codex_cli_only 加固(默认:版本不检查、名单空、默认种子指纹信号) + SettingKeyMinCodexVersion: "", + SettingKeyMaxCodexVersion: "", + SettingKeyCodexCLIOnlyBlacklist: "", + SettingKeyCodexCLIOnlyWhitelist: "", + SettingKeyCodexCLIOnlyAllowAppServerClients: "false", + SettingKeyCodexCLIOnlyEngineFingerprintSignals: openai.DefaultEngineFingerprintSignalsJSON(), + + // 分组隔离(默认不允许未分组 Key 调度) + SettingKeyAllowUngroupedKeyScheduling: "false", + SettingKeyEnableAnthropicCacheTTL1hInjection: "false", + SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()), + SettingKeyEnableClientDatelineNormalization: "true", + SettingKeyAntigravityUserAgentVersion: "", + SettingKeyOpenAICodexUserAgent: "", + SettingPaymentVisibleMethodAlipaySource: "", + SettingPaymentVisibleMethodWxpaySource: "", + SettingPaymentVisibleMethodAlipayEnabled: "false", + SettingPaymentVisibleMethodWxpayEnabled: "false", + openAIAdvancedSchedulerSettingKey: "false", + SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false", + SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false", + SettingKeyOpenAIAdvancedSchedulerLBTopK: "", + SettingKeyOpenAIAdvancedSchedulerWeightPriority: "", + SettingKeyOpenAIAdvancedSchedulerWeightLoad: "", + SettingKeyOpenAIAdvancedSchedulerWeightQueue: "", + SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: "", + SettingKeyOpenAIAdvancedSchedulerWeightTTFT: "", + SettingKeyOpenAIAdvancedSchedulerWeightReset: "", + SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: "", + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "", + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "", + + SettingKeyAllowUserViewErrorRequests: "false", + } + + return s.settingRepo.SetMultiple(ctx, defaults) +} + +// parseSettings 解析设置到结构体 +func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { + emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" + loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) + loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) + if loginAgreementUpdatedAt == "" { + loginAgreementUpdatedAt = defaultLoginAgreementDate + } + apiKeyACLTrustForwardedIP := false + if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok { + apiKeyACLTrustForwardedIP = value == "true" + } else if s != nil && s.cfg != nil { + apiKeyACLTrustForwardedIP = s.cfg.Security.TrustForwardedIPForAPIKeyACL + } + result := &SystemSettings{ + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", + FrontendURL: settings[SettingKeyFrontendURL], + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + 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] != "", + APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, + 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]), + CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + } + result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences( + settings[SettingKeyTableDefaultPageSize], + settings[SettingKeyTablePageSizeOptions], + ) + + // 解析整数类型 + 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 rpm, err := strconv.Atoi(settings[SettingKeyDefaultUserRPMLimit]); err == nil && rpm >= 0 { + result.DefaultUserRPMLimit = rpm + } + + // 解析浮点数类型 + if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil { + result.DefaultBalance = balance + } else { + result.DefaultBalance = s.cfg.Default.UserBalance + } + if rebateRate, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebateRate], 64); err == nil { + result.AffiliateRebateRate = clampAffiliateRebateRate(rebateRate) + } else { + result.AffiliateRebateRate = AffiliateRebateRateDefault + } + if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 { + if freezeHours > AffiliateRebateFreezeHoursMax { + freezeHours = AffiliateRebateFreezeHoursMax + } + result.AffiliateRebateFreezeHours = freezeHours + } + if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 { + if durationDays > AffiliateRebateDurationDaysMax { + durationDays = AffiliateRebateDurationDaysMax + } + result.AffiliateRebateDurationDays = durationDays + } + if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 { + result.AffiliateRebatePerInviteeCap = perInviteeCap + } + result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) + + // 敏感信息直接返回,方便测试连接时使用 + 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 != "" + + // DingTalk Connect 设置: + // - 兼容 config.yaml/env + // - 支持后台系统设置覆盖并持久化(存储于 DB) + dingTalkBase := config.DingTalkConnectConfig{} + if s.cfg != nil { + dingTalkBase = s.cfg.DingTalk + } + + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { + result.DingTalkConnectEnabled = raw == "true" + } else { + result.DingTalkConnectEnabled = dingTalkBase.Enabled + } + + if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectClientID = strings.TrimSpace(v) + } else { + result.DingTalkConnectClientID = dingTalkBase.ClientID + } + + if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectRedirectURL = strings.TrimSpace(v) + } else { + result.DingTalkConnectRedirectURL = dingTalkBase.RedirectURL + } + + result.DingTalkConnectClientSecret = strings.TrimSpace(settings[SettingKeyDingTalkConnectClientSecret]) + if result.DingTalkConnectClientSecret == "" { + result.DingTalkConnectClientSecret = strings.TrimSpace(dingTalkBase.ClientSecret) + } + result.DingTalkConnectClientSecretConfigured = result.DingTalkConnectClientSecret != "" + + if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(v) + } else { + result.DingTalkConnectCorpRestrictionPolicy = dingTalkBase.CorpRestrictionPolicy + } + result.DingTalkConnectCorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(result.DingTalkConnectCorpRestrictionPolicy) + + if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectInternalCorpID = strings.TrimSpace(v) + } else { + result.DingTalkConnectInternalCorpID = dingTalkBase.InternalCorpID + } + + if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectBypassRegistration = dingTalkBase.BypassRegistration + } + // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, + // 以保证加载出的 effective config 永远是一致状态。 + if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { + result.DingTalkConnectBypassRegistration = false + } + + if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncCorpEmail = dingTalkBase.SyncCorpEmail + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncDisplayName = dingTalkBase.SyncDisplayName + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncDept = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncDept = dingTalkBase.SyncDept + } + // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 + if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { + result.DingTalkConnectSyncCorpEmail = false + result.DingTalkConnectSyncDisplayName = false + result.DingTalkConnectSyncDept = false + } + + // 身份同步目标 attr key(DB 空 → fallback 默认值) + result.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]) + if result.DingTalkConnectSyncCorpEmailAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrKey); v != "" { + result.DingTalkConnectSyncCorpEmailAttrKey = v + } else { + result.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email" + } + } + result.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]) + if result.DingTalkConnectSyncDisplayNameAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrKey); v != "" { + result.DingTalkConnectSyncDisplayNameAttrKey = v + } else { + result.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name" + } + } + result.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]) + if result.DingTalkConnectSyncDeptAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrKey); v != "" { + result.DingTalkConnectSyncDeptAttrKey = v + } else { + result.DingTalkConnectSyncDeptAttrKey = "dingtalk_department" + } + } + + // 身份同步目标 attr 显示名称(DB 空 → fallback 默认中文) + result.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrName]) + if result.DingTalkConnectSyncCorpEmailAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrName); v != "" { + result.DingTalkConnectSyncCorpEmailAttrName = v + } else { + result.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱" + } + } + result.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrName]) + if result.DingTalkConnectSyncDisplayNameAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrName); v != "" { + result.DingTalkConnectSyncDisplayNameAttrName = v + } else { + result.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名" + } + } + result.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrName]) + if result.DingTalkConnectSyncDeptAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrName); v != "" { + result.DingTalkConnectSyncDeptAttrName = v + } else { + result.DingTalkConnectSyncDeptAttrName = "钉钉部门" + } + } + + // Generic OIDC 设置: + // - 兼容 config.yaml/env + // - 支持后台系统设置覆盖并持久化(存储于 DB) + oidcBase := config.OIDCConnectConfig{} + if s.cfg != nil { + oidcBase = s.cfg.OIDC + } + + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + result.OIDCConnectEnabled = raw == "true" + } else { + result.OIDCConnectEnabled = oidcBase.Enabled + } + + if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectProviderName = strings.TrimSpace(v) + } else { + result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName) + } + if result.OIDCConnectProviderName == "" { + result.OIDCConnectProviderName = "OIDC" + } + + if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectClientID = strings.TrimSpace(v) + } else { + result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID) + } + if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectIssuerURL = strings.TrimSpace(v) + } else { + result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL) + } + if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectDiscoveryURL = strings.TrimSpace(v) + } else { + result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL) + } + if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectAuthorizeURL = strings.TrimSpace(v) + } else { + result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL) + } + if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectTokenURL = strings.TrimSpace(v) + } else { + result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectUserInfoURL = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL) + } + if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectJWKSURL = strings.TrimSpace(v) + } else { + result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL) + } + if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectScopes = strings.TrimSpace(v) + } else { + result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes) + } + if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectRedirectURL = strings.TrimSpace(v) + } else { + result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL) + } + if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v) + } else { + result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL) + } + if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) + } else { + result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod)) + } + if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { + result.OIDCConnectUsePKCE = raw == "true" + } else { + result.OIDCConnectUsePKCE = oidcUsePKCECompatibilityDefault(oidcBase) + } + if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { + result.OIDCConnectValidateIDToken = raw == "true" + } else { + result.OIDCConnectValidateIDToken = oidcValidateIDTokenCompatibilityDefault(oidcBase) + } + if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v) + } else { + result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs) + } + clockSkewSet := false + if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { + if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { + result.OIDCConnectClockSkewSeconds = parsed + clockSkewSet = true + } + } + if !clockSkewSet { + result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds + } + if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 { + result.OIDCConnectClockSkewSeconds = 120 + } + if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { + result.OIDCConnectRequireEmailVerified = raw == "true" + } else { + result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { + result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { + result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { + result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath) + } + result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret]) + if result.OIDCConnectClientSecret == "" { + result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret) + } + result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" + + gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github") + result.GitHubOAuthEnabled = gitHubEffective.Enabled + result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID) + result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret) + result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != "" + result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL) + result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL) + + googleEffective := s.effectiveEmailOAuthConfig(settings, "google") + result.GoogleOAuthEnabled = googleEffective.Enabled + result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID) + result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret) + result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != "" + result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL) + result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL) + + // WeChat Connect 设置: + // - 优先读取 DB 系统设置 + // - 缺失时回退到 config/env,保持升级兼容 + weChatEffective := s.effectiveWeChatConnectOAuthConfig(settings) + result.WeChatConnectEnabled = weChatEffective.Enabled + result.WeChatConnectAppID = weChatEffective.LegacyAppID + result.WeChatConnectAppSecret = weChatEffective.LegacyAppSecret + result.WeChatConnectAppSecretConfigured = weChatEffective.LegacyAppSecret != "" + result.WeChatConnectOpenAppID = weChatEffective.OpenAppID + result.WeChatConnectOpenAppSecret = weChatEffective.OpenAppSecret + result.WeChatConnectOpenAppSecretConfigured = weChatEffective.OpenAppSecret != "" + result.WeChatConnectMPAppID = weChatEffective.MPAppID + result.WeChatConnectMPAppSecret = weChatEffective.MPAppSecret + result.WeChatConnectMPAppSecretConfigured = weChatEffective.MPAppSecret != "" + result.WeChatConnectMobileAppID = weChatEffective.MobileAppID + result.WeChatConnectMobileAppSecret = weChatEffective.MobileAppSecret + result.WeChatConnectMobileAppSecretConfigured = weChatEffective.MobileAppSecret != "" + result.WeChatConnectOpenEnabled = weChatEffective.OpenEnabled + result.WeChatConnectMPEnabled = weChatEffective.MPEnabled + result.WeChatConnectMobileEnabled = weChatEffective.MobileEnabled + result.WeChatConnectMode = weChatEffective.Mode + result.WeChatConnectScopes = weChatEffective.Scopes + result.WeChatConnectRedirectURL = weChatEffective.RedirectURL + result.WeChatConnectFrontendRedirectURL = weChatEffective.FrontendRedirectURL + + // 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 + } + } + + // Channel monitor feature (default: enabled, 60s) + result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]) + result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( + settings[SettingKeyChannelMonitorDefaultIntervalSeconds], + ) + + // Available channels feature (default: disabled; strict true) + result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" + + // Affiliate (邀请返利) feature (default: disabled; strict true) + result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true" + + // 风控中心功能(默认关闭,严格 true 才启用) + result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true" + + // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) + result.CyberSessionBlockEnabled = settings[SettingKeyCyberSessionBlockEnabled] == "true" + if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyCyberSessionBlockTTLSeconds])); err == nil && v > 0 { + result.CyberSessionBlockTTLSeconds = v + } else { + result.CyberSessionBlockTTLSeconds = 3600 + } + + // Claude Code version check + result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] + result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] + + // 分组隔离 + result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" + + // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, + // cch_signing=false, claude_oauth_system_prompt_injection=true) + if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" { + result.EnableFingerprintUnification = v == "true" + } else { + result.EnableFingerprintUnification = true // default: enabled (current behavior) + } + result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" + result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true" + if v, ok := settings[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { + result.EnableClaudeOAuthSystemPromptInjection = v == "true" + } else { + result.EnableClaudeOAuthSystemPromptInjection = true + } + result.ClaudeOAuthSystemPrompt = settings[SettingKeyClaudeOAuthSystemPrompt] + result.ClaudeOAuthSystemPromptBlocks = settings[SettingKeyClaudeOAuthSystemPromptBlocks] + result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" + if v, ok := settings[SettingKeyRewriteMessageCacheControl]; ok && v != "" { + result.RewriteMessageCacheControl = v == "true" + } else { + result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl() + } + if v, ok := settings[SettingKeyEnableClientDatelineNormalization]; ok && v != "" { + result.EnableClientDatelineNormalization = v == "true" + } else { + result.EnableClientDatelineNormalization = true + } + result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion]) + result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent]) + // codex_cli_only 加固 + result.MinCodexVersion = settings[SettingKeyMinCodexVersion] + result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] + result.CodexCLIOnlyBlacklist = settings[SettingKeyCodexCLIOnlyBlacklist] + result.CodexCLIOnlyWhitelist = settings[SettingKeyCodexCLIOnlyWhitelist] + result.CodexCLIOnlyAllowAppServerClients = settings[SettingKeyCodexCLIOnlyAllowAppServerClients] == "true" + if raw := strings.TrimSpace(settings[SettingKeyCodexCLIOnlyEngineFingerprintSignals]); raw != "" { + result.CodexCLIOnlyEngineFingerprintSignals = raw + } else { + result.CodexCLIOnlyEngineFingerprintSignals = openai.DefaultEngineFingerprintSignalsJSON() // 缺失/空 → 展示默认种子 + } + + // Web search emulation: quick enabled check from the JSON config + if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" { + var wsCfg WebSearchEmulationConfig + if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil { + result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0 + } + } + result.PaymentVisibleMethodAlipaySource = NormalizeVisibleMethodSource("alipay", settings[SettingPaymentVisibleMethodAlipaySource]) + result.PaymentVisibleMethodWxpaySource = NormalizeVisibleMethodSource("wxpay", settings[SettingPaymentVisibleMethodWxpaySource]) + result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true" + result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true" + result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true" + result.OpenAIAdvancedSchedulerStickyWeightedEnabled = settings[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] == "true" + result.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled = settings[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] == "true" + result.OpenAIAdvancedSchedulerLBTopK = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerLBTopK]) + result.OpenAIAdvancedSchedulerWeightPriority = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPriority]) + result.OpenAIAdvancedSchedulerWeightLoad = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightLoad]) + result.OpenAIAdvancedSchedulerWeightQueue = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQueue]) + result.OpenAIAdvancedSchedulerWeightErrorRate = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate]) + result.OpenAIAdvancedSchedulerWeightTTFT = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightTTFT]) + result.OpenAIAdvancedSchedulerWeightReset = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightReset]) + result.OpenAIAdvancedSchedulerWeightQuotaHeadroom = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom]) + result.OpenAIAdvancedSchedulerWeightPreviousResponse = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse]) + result.OpenAIAdvancedSchedulerWeightSessionSticky = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky]) + result.OpenAIAdvancedSchedulerEffectiveLBTopK = s.openAIAdvancedSchedulerEffectiveLBTopK() + effectiveWeights := s.openAIAdvancedSchedulerEffectiveWeights() + result.OpenAIAdvancedSchedulerEffectiveWeightPriority = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Priority) + result.OpenAIAdvancedSchedulerEffectiveWeightLoad = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Load) + result.OpenAIAdvancedSchedulerEffectiveWeightQueue = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Queue) + result.OpenAIAdvancedSchedulerEffectiveWeightErrorRate = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.ErrorRate) + result.OpenAIAdvancedSchedulerEffectiveWeightTTFT = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.TTFT) + result.OpenAIAdvancedSchedulerEffectiveWeightReset = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Reset) + result.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.QuotaHeadroom) + result.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.PreviousResponse) + result.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.SessionSticky) + + // 余额、订阅到期与账号限额通知 + result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" + if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { + result.BalanceLowNotifyThreshold = v + } + result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] + result.SubscriptionExpiryNotifyEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionExpiryNotifyEnabled]) + + // 账号限额通知 + result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true" + if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" { + result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw) + } + if result.AccountQuotaNotifyEmails == nil { + result.AccountQuotaNotifyEmails = []NotifyEmailEntry{} + } + + // 系统层默认 platform quota(修复 Bug B:parseSettings 不填充导致回显恒为 nil) + if raw := settings[SettingKeyDefaultPlatformQuotas]; raw != "" { + parsed := map[string]*DefaultPlatformQuotaSetting{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + slog.Warn("[Setting] parseSettings: unmarshal default_platform_quotas failed", "error", err) + } else { + result.DefaultPlatformQuotas = parsed + } + } + + result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false + + return result +} + +func clampAffiliateRebateRate(value float64) float64 { + if math.IsNaN(value) || math.IsInf(value, 0) { + return AffiliateRebateRateDefault + } + if value < AffiliateRebateRateMin { + return AffiliateRebateRateMin + } + if value > AffiliateRebateRateMax { + return AffiliateRebateRateMax + } + return value +} + +func isFalseSettingValue(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "false", "0", "off", "disabled": + return true + default: + return false + } +} + +func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) { + _ = enabled + source = strings.TrimSpace(source) + if source == "" { + return "", nil + } + + normalized := NormalizeVisibleMethodSource(method, source) + if normalized == "" { + return "", infraerrors.BadRequest( + "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", + fmt.Sprintf("%s source must be one of the supported payment providers", method), + ) + } + return normalized, nil +} + +func (s *SettingService) openAIAdvancedSchedulerEffectiveLBTopK() string { + if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.LBTopK > 0 { + return strconv.Itoa(s.cfg.Gateway.OpenAIWS.LBTopK) + } + return "7" +} + +func (s *SettingService) openAIAdvancedSchedulerEffectiveWeights() config.GatewayOpenAIWSSchedulerScoreWeights { + defaults := config.GatewayOpenAIWSSchedulerScoreWeights{ + Priority: 1.0, + Load: 1.0, + Queue: 0.7, + ErrorRate: 0.8, + TTFT: 0.5, + Reset: 0.0, + QuotaHeadroom: 0.0, + PreviousResponse: 5.0, + SessionSticky: 3.0, + } + if s == nil || s.cfg == nil { + return defaults + } + + weights := s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights + baseSum := weights.Priority + weights.Load + weights.Queue + weights.ErrorRate + weights.TTFT + weights.QuotaHeadroom + if baseSum <= 0 { + return defaults + } + return weights +} + +func formatOpenAIAdvancedSchedulerFloat(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error { + lbTopK, err := normalizeOptionalPositiveIntString(settings.OpenAIAdvancedSchedulerLBTopK) + if err != nil { + return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_LB_TOP_K", "openai advanced scheduler TopK must be a positive integer or empty") + } + settings.OpenAIAdvancedSchedulerLBTopK = lbTopK + + weights := []*string{ + &settings.OpenAIAdvancedSchedulerWeightPriority, + &settings.OpenAIAdvancedSchedulerWeightLoad, + &settings.OpenAIAdvancedSchedulerWeightQueue, + &settings.OpenAIAdvancedSchedulerWeightErrorRate, + &settings.OpenAIAdvancedSchedulerWeightTTFT, + &settings.OpenAIAdvancedSchedulerWeightReset, + &settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + &settings.OpenAIAdvancedSchedulerWeightPreviousResponse, + &settings.OpenAIAdvancedSchedulerWeightSessionSticky, + } + for _, target := range weights { + normalized, err := normalizeOptionalNonNegativeFloatString(*target) + if err != nil { + return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler weights must be non-negative numbers or empty") + } + *target = normalized + } + + // 与 config.Validate 的 "scheduler_score_weights must not all be zero" 保持一致: + // 覆盖值(空则回退到生效的配置值)叠加后的基础权重和不允许为 0, + // 否则调度会静默退化为 TopK 内均匀随机。 + effective := s.openAIAdvancedSchedulerEffectiveWeights() + baseSum := resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPriority, effective.Priority) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightLoad, effective.Load) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQueue, effective.Queue) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightErrorRate, effective.ErrorRate) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightTTFT, effective.TTFT) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, effective.QuotaHeadroom) + if baseSum <= 0 { + return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler base weights must not all be zero") + } + return nil +} + +// resolveOpenAIAdvancedSchedulerWeight 返回覆盖值(已归一化的非空字符串),空则回退默认值。 +func resolveOpenAIAdvancedSchedulerWeight(normalized string, fallback float64) float64 { + if normalized == "" { + return fallback + } + value, err := strconv.ParseFloat(normalized, 64) + if err != nil { + return fallback + } + return value +} + +func normalizeOptionalPositiveIntString(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return "", fmt.Errorf("invalid positive integer") + } + return strconv.Itoa(value), nil +} + +func normalizeOptionalNonNegativeFloatString(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return "", fmt.Errorf("invalid non-negative float") + } + return strconv.FormatFloat(value, 'f', -1, 64), nil +} + +func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + var items []DefaultSubscriptionSetting + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil + } + + normalized := make([]DefaultSubscriptionSetting, 0, len(items)) + for _, item := range items { + if item.GroupID <= 0 || item.ValidityDays <= 0 { + continue + } + if item.ValidityDays > MaxValidityDays { + item.ValidityDays = MaxValidityDays + } + normalized = append(normalized, item) + } + + return normalized +} + +func parseProviderDefaultGrantSettings(settings map[string]string, keys authSourceDefaultKeySet) ProviderDefaultGrantSettings { + result := ProviderDefaultGrantSettings{ + Balance: defaultAuthSourceBalance, + Concurrency: defaultAuthSourceConcurrency, + Subscriptions: []DefaultSubscriptionSetting{}, + GrantOnSignup: false, + GrantOnFirstBind: false, + } + + if v, err := strconv.ParseFloat(strings.TrimSpace(settings[keys.balance]), 64); err == nil { + result.Balance = v + } + if v, err := strconv.Atoi(strings.TrimSpace(settings[keys.concurrency])); err == nil { + result.Concurrency = v + } + if items := parseDefaultSubscriptions(settings[keys.subscriptions]); items != nil { + result.Subscriptions = items + } + if raw, ok := settings[keys.grantOnSignup]; ok { + result.GrantOnSignup = raw == "true" + } + if raw, ok := settings[keys.grantOnFirstBind]; ok { + result.GrantOnFirstBind = raw == "true" + } + + if raw := settings[keys.platformQuotas]; raw != "" { + parsed := map[string]*DefaultPlatformQuotaSetting{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + slog.Warn("[Setting] parseProviderDefaultGrantSettings: unmarshal auth source platform quotas failed", "source", keys.source, "error", err) + } else { + result.PlatformQuotas = parsed + } + } + + return result +} + +func writeProviderDefaultGrantUpdates(updates map[string]string, keys authSourceDefaultKeySet, settings ProviderDefaultGrantSettings) { + updates[keys.balance] = strconv.FormatFloat(settings.Balance, 'f', 8, 64) + updates[keys.concurrency] = strconv.Itoa(settings.Concurrency) + + subscriptions := settings.Subscriptions + if subscriptions == nil { + subscriptions = []DefaultSubscriptionSetting{} + } + raw, err := json.Marshal(subscriptions) + if err != nil { + raw = []byte("[]") + } + updates[keys.subscriptions] = string(raw) + updates[keys.grantOnSignup] = strconv.FormatBool(settings.GrantOnSignup) + updates[keys.grantOnFirstBind] = strconv.FormatBool(settings.GrantOnFirstBind) + + // auth source platform quota:整体替换语义。 + // nil = 请求未携带该字段,跳过写入以保留既有配置(与系统层 buildSystemSettingsUpdates 的 + // DefaultPlatformQuotas nil 守卫一致);非 nil(含空 map)才整体替换。二者语义不可混同。 + if keys.platformQuotas != "" && settings.PlatformQuotas != nil { + blob, err := json.Marshal(settings.PlatformQuotas) + if err != nil { + blob = []byte("{}") + } + updates[keys.platformQuotas] = string(blob) + } +} + +func mergeProviderDefaultGrantSettings(globalDefaults ProviderDefaultGrantSettings, providerDefaults ProviderDefaultGrantSettings) ProviderDefaultGrantSettings { + result := ProviderDefaultGrantSettings{ + Balance: globalDefaults.Balance, + Concurrency: globalDefaults.Concurrency, + Subscriptions: append([]DefaultSubscriptionSetting(nil), globalDefaults.Subscriptions...), + GrantOnSignup: providerDefaults.GrantOnSignup, + GrantOnFirstBind: providerDefaults.GrantOnFirstBind, + } + + // 注意:不能把 parse 默认值 (defaultAuthSourceBalance / defaultAuthSourceConcurrency) + // 当作"未配置"哨兵——admin 完全有权显式设成相同的值,那时仍应覆盖 globalDefaults。 + // 旧实现的 `!= defaultAuthSourceConcurrency` 会把 admin 设的 5 与 fallback 5 混淆, + // 导致渠道发放退回到全局默认(如 1),表现为"管理员设 5、新用户实际拿 1"。 + if providerDefaults.Balance >= 0 { + result.Balance = providerDefaults.Balance + } + if providerDefaults.Concurrency > 0 { + result.Concurrency = providerDefaults.Concurrency + } + if len(providerDefaults.Subscriptions) > 0 { + result.Subscriptions = append([]DefaultSubscriptionSetting(nil), providerDefaults.Subscriptions...) + } + + return result +} + +func parseTablePreferences(defaultPageSizeRaw, optionsRaw string) (int, []int) { + defaultPageSize := 20 + if v, err := strconv.Atoi(strings.TrimSpace(defaultPageSizeRaw)); err == nil { + defaultPageSize = v + } + + var options []int + if strings.TrimSpace(optionsRaw) != "" { + _ = json.Unmarshal([]byte(optionsRaw), &options) + } + + return normalizeTablePreferences(defaultPageSize, options) +} + +func normalizeTablePreferences(defaultPageSize int, options []int) (int, []int) { + const minPageSize = 5 + const maxPageSize = 1000 + const fallbackPageSize = 20 + + seen := make(map[int]struct{}, len(options)) + normalizedOptions := make([]int, 0, len(options)) + for _, option := range options { + if option < minPageSize || option > maxPageSize { + continue + } + if _, ok := seen[option]; ok { + continue + } + seen[option] = struct{}{} + normalizedOptions = append(normalizedOptions, option) + } + sort.Ints(normalizedOptions) + + if defaultPageSize < minPageSize || defaultPageSize > maxPageSize { + defaultPageSize = fallbackPageSize + } + + if len(normalizedOptions) == 0 { + normalizedOptions = []int{10, 20, 50} + } + + return defaultPageSize, normalizedOptions +} diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go new file mode 100644 index 0000000000..5bcc9e4c02 --- /dev/null +++ b/backend/internal/service/setting_public.go @@ -0,0 +1,688 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) + +func normalizeLoginAgreementMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "checkbox": + return "checkbox" + default: + return defaultLoginAgreementMode + } +} + +func defaultLoginAgreementDocuments() []LoginAgreementDocument { + return []LoginAgreementDocument{ + { + ID: "terms", + Title: "服务条款", + ContentMD: "", + }, + { + ID: "usage-policy", + Title: "使用政策", + ContentMD: "", + }, + { + ID: "supported-regions", + Title: "支持的国家和地区", + ContentMD: "", + }, + { + ID: "service-specific-terms", + Title: "服务特定条款", + ContentMD: "", + }, + } +} + +func normalizeLoginAgreementDocumentID(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + var b strings.Builder + lastSeparator := false + for _, r := range raw { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + _, _ = b.WriteRune(r) + lastSeparator = false + continue + } + if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' { + if !lastSeparator && b.Len() > 0 { + if r == '_' { + _, _ = b.WriteRune('_') + } else { + _, _ = b.WriteRune('-') + } + lastSeparator = true + } + } + } + return strings.Trim(b.String(), "-_") +} + +func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument { + normalized := make([]LoginAgreementDocument, 0, len(docs)) + seen := make(map[string]int, len(docs)) + for i, doc := range docs { + title := strings.TrimSpace(doc.Title) + content := strings.TrimSpace(doc.ContentMD) + if title == "" && content == "" { + continue + } + id := normalizeLoginAgreementDocumentID(doc.ID) + if id == "" { + sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content))) + id = hex.EncodeToString(sum[:])[:12] + } + baseID := id + for suffix := 2; seen[id] > 0; suffix++ { + id = fmt.Sprintf("%s-%d", baseID, suffix) + } + seen[id]++ + normalized = append(normalized, LoginAgreementDocument{ + ID: id, + Title: title, + ContentMD: content, + }) + } + return normalized +} + +func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument { + raw = strings.TrimSpace(raw) + if raw == "" { + return defaultLoginAgreementDocuments() + } + var docs []LoginAgreementDocument + if err := json.Unmarshal([]byte(raw), &docs); err != nil { + return defaultLoginAgreementDocuments() + } + docs = normalizeLoginAgreementDocuments(docs) + if len(docs) == 0 { + return defaultLoginAgreementDocuments() + } + return docs +} + +func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) { + normalized := normalizeLoginAgreementDocuments(docs) + if len(normalized) == 0 { + normalized = defaultLoginAgreementDocuments() + } + b, err := json.Marshal(normalized) + if err != nil { + return "", fmt.Errorf("marshal login agreement documents: %w", err) + } + return string(b), nil +} + +func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string { + normalized := normalizeLoginAgreementDocuments(docs) + payload, err := json.Marshal(struct { + UpdatedAt string `json:"updated_at"` + Documents []LoginAgreementDocument `json:"documents"` + }{ + UpdatedAt: strings.TrimSpace(updatedAt), + Documents: normalized, + }) + if err != nil { + payload = []byte(strings.TrimSpace(updatedAt)) + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:])[:16] +} + +// GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件) +func (s *SettingService) GetFrontendURL(ctx context.Context) string { + val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL) + if err == nil && strings.TrimSpace(val) != "" { + return strings.TrimSpace(val) + } + return s.cfg.Server.FrontendURL +} + +// GetPublicSettings 获取公开设置(无需登录) +func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) { + keys := []string{ + SettingKeyRegistrationEnabled, + SettingKeyEmailVerifyEnabled, + SettingKeyForceEmailOnThirdPartySignup, + SettingKeyRegistrationEmailSuffixWhitelist, + SettingKeyPromoCodeEnabled, + SettingKeyPasswordResetEnabled, + SettingKeyInvitationCodeEnabled, + SettingKeyTotpEnabled, + SettingKeyLoginAgreementEnabled, + SettingKeyLoginAgreementMode, + SettingKeyLoginAgreementUpdatedAt, + SettingKeyLoginAgreementDocuments, + SettingKeyTurnstileEnabled, + SettingKeyTurnstileSiteKey, + SettingKeyAPIKeyACLTrustForwardedIP, + SettingKeySiteName, + SettingKeySiteLogo, + SettingKeySiteSubtitle, + SettingKeyAPIBaseURL, + SettingKeyContactInfo, + SettingKeyDocURL, + SettingKeyHomeContent, + SettingKeyHideCcsImportButton, + SettingKeyPurchaseSubscriptionEnabled, + SettingKeyPurchaseSubscriptionURL, + SettingKeyTableDefaultPageSize, + SettingKeyTablePageSizeOptions, + SettingKeyCustomMenuItems, + SettingKeyCustomEndpoints, + SettingKeyLinuxDoConnectEnabled, + SettingKeyDingTalkConnectEnabled, + SettingKeyWeChatConnectEnabled, + SettingKeyWeChatConnectAppID, + SettingKeyWeChatConnectAppSecret, + SettingKeyWeChatConnectOpenAppID, + SettingKeyWeChatConnectOpenAppSecret, + SettingKeyWeChatConnectMPAppID, + SettingKeyWeChatConnectMPAppSecret, + SettingKeyWeChatConnectMobileAppID, + SettingKeyWeChatConnectMobileAppSecret, + SettingKeyWeChatConnectOpenEnabled, + SettingKeyWeChatConnectMPEnabled, + SettingKeyWeChatConnectMobileEnabled, + SettingKeyWeChatConnectMode, + SettingKeyWeChatConnectScopes, + SettingKeyWeChatConnectRedirectURL, + SettingKeyWeChatConnectFrontendRedirectURL, + SettingKeyBackendModeEnabled, + SettingPaymentEnabled, + SettingKeyOIDCConnectEnabled, + SettingKeyOIDCConnectProviderName, + SettingKeyGitHubOAuthEnabled, + SettingKeyGitHubOAuthClientID, + SettingKeyGitHubOAuthClientSecret, + SettingKeyGoogleOAuthEnabled, + SettingKeyGoogleOAuthClientID, + SettingKeyGoogleOAuthClientSecret, + SettingKeyBalanceLowNotifyEnabled, + SettingKeyBalanceLowNotifyThreshold, + SettingKeyBalanceLowNotifyRechargeURL, + SettingKeyAccountQuotaNotifyEnabled, + SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorDefaultIntervalSeconds, + SettingKeyAvailableChannelsEnabled, + SettingKeyAffiliateEnabled, + SettingKeyRiskControlEnabled, + SettingKeyAllowUserViewErrorRequests, + } + + 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 + } + dingTalkEnabled := false + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { + dingTalkEnabled = raw == "true" + } else { + dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled + } + oidcEnabled := false + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + oidcEnabled = raw == "true" + } else { + oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled + } + oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName]) + if oidcProviderName == "" && s.cfg != nil { + oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName) + } + if oidcProviderName == "" { + oidcProviderName = "OIDC" + } + gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github") + googleEnabled := s.emailOAuthPublicEnabled(settings, "google") + weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings) + + // Password reset requires email verification to be enabled + emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" + passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" + registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist( + settings[SettingKeyRegistrationEmailSuffixWhitelist], + ) + tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences( + settings[SettingKeyTableDefaultPageSize], + settings[SettingKeyTablePageSizeOptions], + ) + loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) + loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) + if loginAgreementUpdatedAt == "" { + loginAgreementUpdatedAt = defaultLoginAgreementDate + } + + var balanceLowNotifyThreshold float64 + if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { + balanceLowNotifyThreshold = v + } + + return &PublicSettings{ + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", + RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: passwordResetEnabled, + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), + LoginAgreementDocuments: loginAgreementDocuments, + 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]), + TableDefaultPageSize: tableDefaultPageSize, + TablePageSizeOptions: tablePageSizeOptions, + CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], + LinuxDoOAuthEnabled: linuxDoEnabled, + DingTalkOAuthEnabled: dingTalkEnabled, + WeChatOAuthEnabled: weChatEnabled, + WeChatOAuthOpenEnabled: weChatOpenEnabled, + WeChatOAuthMPEnabled: weChatMPEnabled, + WeChatOAuthMobileEnabled: weChatMobileEnabled, + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + PaymentEnabled: settings[SettingPaymentEnabled] == "true", + OIDCOAuthEnabled: oidcEnabled, + OIDCOAuthProviderName: oidcProviderName, + GitHubOAuthEnabled: gitHubEnabled, + GoogleOAuthEnabled: googleEnabled, + BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", + AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", + BalanceLowNotifyThreshold: balanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], + + ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), + ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), + + AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", + + AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", + + RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", + + AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true", + }, nil +} + +// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval +// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled). +const ( + channelMonitorIntervalMin = 15 + channelMonitorIntervalMax = 3600 + channelMonitorIntervalFallback = 60 +) + +// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600]. +// Empty / invalid input falls back to channelMonitorIntervalFallback. +func parseChannelMonitorInterval(raw string) int { + v, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return channelMonitorIntervalFallback + } + return clampChannelMonitorInterval(v) +} + +// clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided". +func clampChannelMonitorInterval(v int) int { + if v <= 0 { + return 0 + } + if v < channelMonitorIntervalMin { + return channelMonitorIntervalMin + } + if v > channelMonitorIntervalMax { + return channelMonitorIntervalMax + } + return v +} + +// ChannelMonitorRuntime is the lightweight view of the channel monitor feature +// consumed by the runner and user-facing handlers. +type ChannelMonitorRuntime struct { + Enabled bool + DefaultIntervalSeconds int +} + +// GetChannelMonitorRuntime reads the channel monitor feature flags directly from +// the settings store. Fail-open: on error returns Enabled=true with the default interval. +func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime { + vals, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorDefaultIntervalSeconds, + }) + if err != nil { + return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback} + } + return ChannelMonitorRuntime{ + Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]), + DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), + } +} + +// AvailableChannelsRuntime is the lightweight view of the available-channels feature +// switch consumed by the user-facing handler. +type AvailableChannelsRuntime struct { + Enabled bool +} + +// GetAvailableChannelsRuntime reads the available-channels feature switch directly +// from the settings store. Fail-closed: on error returns Enabled=false, matching +// the opt-in default (unknown ↔ disabled). +func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime { + vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled}) + if err != nil { + return AvailableChannelsRuntime{Enabled: false} + } + return AvailableChannelsRuntime{ + Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true", + } +} + +// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch +// directly from the settings store. Fail-closed: on error returns false (opt-in default). +func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool { + vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests}) + if err != nil { + slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err) + return false + } + return vals[SettingKeyAllowUserViewErrorRequests] == "true" +} + +// PublicSettingsInjectionPayload is the JSON shape embedded into HTML as +// `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site +// config before the first XHR finishes. +// +// INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings. +// If you forget a feature-flag field here, the frontend's +// `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the +// async `/api/v1/settings/public` call returns — which causes opt-in menus +// (strict `=== true`) to flicker off/on. See +// frontend/src/utils/featureFlags.ts for the matching registry. +// +// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch +// drift automatically (see setting_service_injection_test.go). +type PublicSettingsInjectionPayload struct { + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems json.RawMessage `json:"custom_menu_items"` + CustomEndpoints json.RawMessage `json:"custom_endpoints"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` + // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 + ServerTimezone string `json:"server_timezone"` + ServerUTCOffset string `json:"server_utc_offset"` + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + + // Feature flags — MUST match the opt-in/opt-out registry in + // frontend/src/utils/featureFlags.ts. Missing a field here is the bug + // that hid the "可用渠道" menu on page refresh. + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` + AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` +} + +// 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 &PublicSettingsInjectionPayload{ + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementRevision: settings.LoginAgreementRevision, + LoginAgreementDocuments: settings.LoginAgreementDocuments, + 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, + TableDefaultPageSize: settings.TableDefaultPageSize, + TablePageSizeOptions: settings.TablePageSizeOptions, + CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), + CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, + WeChatOAuthEnabled: settings.WeChatOAuthEnabled, + WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, + WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, + WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, + Version: s.version, + ServerTimezone: timezone.Name(), + ServerUTCOffset: timezone.UTCOffset(), + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + + ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + AvailableChannelsEnabled: settings.AvailableChannelsEnabled, + AffiliateEnabled: settings.AffiliateEnabled, + RiskControlEnabled: settings.RiskControlEnabled, + AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, + }, nil +} + +// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON +// array string, returning only items with visibility != "admin". +func filterUserVisibleMenuItems(raw string) json.RawMessage { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return json.RawMessage("[]") + } + var items []struct { + Visibility string `json:"visibility"` + } + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return json.RawMessage("[]") + } + + // Parse full items to preserve all fields + var fullItems []json.RawMessage + if err := json.Unmarshal([]byte(raw), &fullItems); err != nil { + return json.RawMessage("[]") + } + + var filtered []json.RawMessage + for i, item := range items { + if item.Visibility != "admin" { + filtered = append(filtered, fullItems[i]) + } + } + if len(filtered) == 0 { + return json.RawMessage("[]") + } + result, err := json.Marshal(filtered) + if err != nil { + return json.RawMessage("[]") + } + return result +} + +// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]". +func safeRawJSONArray(raw string) json.RawMessage { + raw = strings.TrimSpace(raw) + if raw == "" { + return json.RawMessage("[]") + } + if json.Valid([]byte(raw)) { + return json.RawMessage(raw) + } + return json.RawMessage("[]") +} + +// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL, +// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. +func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) { + settings, err := s.GetPublicSettings(ctx) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + var origins []string + + addOrigin := func(rawURL string) { + if origin := extractOriginFromURL(rawURL); origin != "" { + if _, ok := seen[origin]; !ok { + seen[origin] = struct{}{} + origins = append(origins, origin) + } + } + } + + // home content URL (when home_content is set to a URL for iframe embedding) + addOrigin(settings.HomeContent) + + // purchase subscription URL + if settings.PurchaseSubscriptionEnabled { + addOrigin(settings.PurchaseSubscriptionURL) + } + + // all custom menu items (including admin-only, since CSP must allow all iframes) + for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) { + addOrigin(item) + } + + return origins, nil +} + +// extractOriginFromURL returns the scheme+host origin from rawURL. +// Only http and https schemes are accepted. +func extractOriginFromURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "" + } + if u.Scheme != "http" && u.Scheme != "https" { + return "" + } + return u.Scheme + "://" + u.Host +} + +// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items. +func parseCustomMenuItemURLs(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return nil + } + var items []struct { + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil + } + urls := make([]string, 0, len(items)) + for _, item := range items { + if item.URL != "" { + urls = append(urls, item.URL) + } + } + return urls +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 2a6d821d3a..52acc62cf9 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -2,49 +2,15 @@ package service import ( "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" "fmt" - "log/slog" - "math" - "net/url" - "sort" - "strconv" - "strings" "sync/atomic" - "time" "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" - "github.com/imroc/req/v3" "golang.org/x/sync/singleflight" ) -// CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本, -// 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce(前端 UI 虽已无 whitelist 选项, -// 但 API 可被直接调用)。 -func CoerceDingTalkCorpPolicyForWrite(policy string) string { - return coerceDeprecatedDingTalkCorpPolicy(policy) -} - -// coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。 -// 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed -// (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。 -func coerceDeprecatedDingTalkCorpPolicy(policy string) string { - if policy == "whitelist" { - slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none", - "hint", "re-save DingTalk settings in admin UI to clear this warning") - return "none" - } - return policy -} - var ( ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found") @@ -68,118 +34,6 @@ type SettingRepository interface { Delete(ctx context.Context, key string) error } -// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL) -type cachedVersionBounds struct { - min string // 空字符串 = 不检查 - max string // 空字符串 = 不检查 - expiresAt int64 // unix nano -} - -// versionBoundsCache 版本号上下限进程内缓存 -var versionBoundsCache atomic.Value // *cachedVersionBounds - -// versionBoundsSF 防止缓存过期时 thundering herd -var versionBoundsSF singleflight.Group - -// versionBoundsCacheTTL 缓存有效期 -const versionBoundsCacheTTL = 60 * time.Second - -// versionBoundsErrorTTL DB 错误时的短缓存,快速重试 -const versionBoundsErrorTTL = 5 * time.Second - -// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context -const versionBoundsDBTimeout = 5 * time.Second - -// cachedBackendMode Backend Mode cache (in-process, 60s TTL) -type cachedBackendMode struct { - value bool - expiresAt int64 // unix nano -} - -var backendModeCache atomic.Value // *cachedBackendMode -var backendModeSF singleflight.Group - -const backendModeCacheTTL = 60 * time.Second -const backendModeErrorTTL = 5 * time.Second -const backendModeDBTimeout = 5 * time.Second - -// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL) -type cachedGatewayForwardingSettings struct { - fingerprintUnification bool - metadataPassthrough bool - cchSigning bool - claudeOAuthSystemPromptInjection bool - claudeOAuthSystemPrompt string - claudeOAuthSystemPromptBlocks string - anthropicCacheTTL1hInjection bool - rewriteMessageCacheControl bool - clientDatelineNormalization bool - expiresAt int64 // unix nano -} - -var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings -var gatewayForwardingSF singleflight.Group - -const gatewayForwardingCacheTTL = 60 * time.Second -const gatewayForwardingErrorTTL = 5 * time.Second -const gatewayForwardingDBTimeout = 5 * time.Second - -// cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL) -type cachedAntigravityUserAgentVersion struct { - version string - expiresAt int64 // unix nano -} - -const antigravityUserAgentVersionCacheTTL = 60 * time.Second -const antigravityUserAgentVersionErrorTTL = 5 * time.Second -const antigravityUserAgentVersionDBTimeout = 5 * time.Second - -// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询) -const DefaultOpenAICodexUserAgent = "codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)" - -// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL) -type cachedOpenAICodexUserAgent struct { - value string - expiresAt int64 // unix nano -} - -type cachedOpenAIQuotaAutoPauseSettings struct { - settings OpsOpenAIAccountQuotaAutoPauseSettings - expiresAt int64 -} - -const openAICodexUserAgentCacheTTL = 60 * time.Second -const openAICodexUserAgentErrorTTL = 5 * time.Second -const openAICodexUserAgentDBTimeout = 5 * time.Second - -const codexRestrictionPolicyCacheTTL = 60 * time.Second -const codexRestrictionPolicyDBTimeout = 5 * time.Second - -// cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。 -// GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。 -type cachedCodexRestrictionPolicy struct { - value CodexRestrictionPolicy - expiresAt int64 // unix nano -} - -// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。 -// GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。 -type cachedCyberSessionBlockRuntime struct { - enabled bool - ttl time.Duration - expiresAt int64 // unix nano -} - -const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second -const cyberSessionBlockRuntimeErrorTTL = 5 * time.Second -const cyberSessionBlockRuntimeDBTimeout = 5 * time.Second - -const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second -const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second -const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second - -const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings" - // DefaultSubscriptionGroupReader validates group references used by default subscriptions. type DefaultSubscriptionGroupReader interface { GetByID(ctx context.Context, id int64) (*Group, error) @@ -344,320 +198,6 @@ const ( defaultLoginAgreementDate = "2026-03-31" ) -func normalizeLoginAgreementMode(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "checkbox": - return "checkbox" - default: - return defaultLoginAgreementMode - } -} - -func defaultLoginAgreementDocuments() []LoginAgreementDocument { - return []LoginAgreementDocument{ - { - ID: "terms", - Title: "服务条款", - ContentMD: "", - }, - { - ID: "usage-policy", - Title: "使用政策", - ContentMD: "", - }, - { - ID: "supported-regions", - Title: "支持的国家和地区", - ContentMD: "", - }, - { - ID: "service-specific-terms", - Title: "服务特定条款", - ContentMD: "", - }, - } -} - -func normalizeLoginAgreementDocumentID(raw string) string { - raw = strings.ToLower(strings.TrimSpace(raw)) - var b strings.Builder - lastSeparator := false - for _, r := range raw { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - _, _ = b.WriteRune(r) - lastSeparator = false - continue - } - if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' { - if !lastSeparator && b.Len() > 0 { - if r == '_' { - _, _ = b.WriteRune('_') - } else { - _, _ = b.WriteRune('-') - } - lastSeparator = true - } - } - } - return strings.Trim(b.String(), "-_") -} - -func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument { - normalized := make([]LoginAgreementDocument, 0, len(docs)) - seen := make(map[string]int, len(docs)) - for i, doc := range docs { - title := strings.TrimSpace(doc.Title) - content := strings.TrimSpace(doc.ContentMD) - if title == "" && content == "" { - continue - } - id := normalizeLoginAgreementDocumentID(doc.ID) - if id == "" { - sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content))) - id = hex.EncodeToString(sum[:])[:12] - } - baseID := id - for suffix := 2; seen[id] > 0; suffix++ { - id = fmt.Sprintf("%s-%d", baseID, suffix) - } - seen[id]++ - normalized = append(normalized, LoginAgreementDocument{ - ID: id, - Title: title, - ContentMD: content, - }) - } - return normalized -} - -func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument { - raw = strings.TrimSpace(raw) - if raw == "" { - return defaultLoginAgreementDocuments() - } - var docs []LoginAgreementDocument - if err := json.Unmarshal([]byte(raw), &docs); err != nil { - return defaultLoginAgreementDocuments() - } - docs = normalizeLoginAgreementDocuments(docs) - if len(docs) == 0 { - return defaultLoginAgreementDocuments() - } - return docs -} - -func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) { - normalized := normalizeLoginAgreementDocuments(docs) - if len(normalized) == 0 { - normalized = defaultLoginAgreementDocuments() - } - b, err := json.Marshal(normalized) - if err != nil { - return "", fmt.Errorf("marshal login agreement documents: %w", err) - } - return string(b), nil -} - -func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string { - normalized := normalizeLoginAgreementDocuments(docs) - payload, err := json.Marshal(struct { - UpdatedAt string `json:"updated_at"` - Documents []LoginAgreementDocument `json:"documents"` - }{ - UpdatedAt: strings.TrimSpace(updatedAt), - Documents: normalized, - }) - if err != nil { - payload = []byte(strings.TrimSpace(updatedAt)) - } - sum := sha256.Sum256(payload) - return hex.EncodeToString(sum[:])[:16] -} - -func normalizeWeChatConnectModeSetting(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "mp": - return "mp" - case "mobile": - return "mobile" - default: - return "open" - } -} - -func defaultWeChatConnectScopeForMode(mode string) string { - switch normalizeWeChatConnectModeSetting(mode) { - case "mp": - return "snsapi_userinfo" - case "mobile": - return "" - } - return defaultWeChatConnectScopes -} - -func normalizeWeChatConnectScopeSetting(raw, mode string) string { - switch normalizeWeChatConnectModeSetting(mode) { - case "mp": - switch strings.TrimSpace(raw) { - case "snsapi_base": - return "snsapi_base" - case "snsapi_userinfo": - return "snsapi_userinfo" - default: - return defaultWeChatConnectScopeForMode(mode) - } - case "mobile": - return "" - default: - return defaultWeChatConnectScopes - } -} - -func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) { - mode = normalizeWeChatConnectModeSetting(mode) - rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] - rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] - rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] - openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" - mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" - mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" - - if openConfigured || mpConfigured || mobileConfigured { - openEnabled := strings.TrimSpace(rawOpen) == "true" - mpEnabled := strings.TrimSpace(rawMP) == "true" - mobileEnabled := strings.TrimSpace(rawMobile) == "true" - return openEnabled, mpEnabled, mobileEnabled - } - - if !enabled { - return false, false, false - } - if mode == "mp" { - return false, true, false - } - if mode == "mobile" { - return false, false, true - } - return true, false, false -} - -func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string { - mode = normalizeWeChatConnectModeSetting(mode) - switch mode { - case "open": - if openEnabled { - return "open" - } - case "mp": - if mpEnabled { - return "mp" - } - case "mobile": - if mobileEnabled { - return "mobile" - } - } - switch { - case openEnabled: - return "open" - case mpEnabled: - return "mp" - case mobileEnabled: - return "mobile" - default: - return mode - } -} - -func mergeWeChatConnectCapabilitySettings(settings map[string]string, base config.WeChatConnectConfig, enabled bool, mode string) (bool, bool, bool) { - mode = normalizeWeChatConnectModeSetting(firstNonEmpty(mode, base.Mode)) - rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] - rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] - rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] - openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" - mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" - mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" - - if openConfigured || mpConfigured || mobileConfigured { - openEnabled := strings.TrimSpace(rawOpen) == "true" - mpEnabled := strings.TrimSpace(rawMP) == "true" - mobileEnabled := strings.TrimSpace(rawMobile) == "true" - _, enabledConfigured := settings[SettingKeyWeChatConnectEnabled] - if !enabledConfigured && - enabled && - !openEnabled && - !mpEnabled && - !mobileEnabled && - (base.OpenEnabled || base.MPEnabled || base.MobileEnabled) { - return base.OpenEnabled, base.MPEnabled, base.MobileEnabled - } - return openEnabled, mpEnabled, mobileEnabled - } - if !enabled { - return false, false, false - } - if base.OpenEnabled || base.MPEnabled || base.MobileEnabled { - return base.OpenEnabled, base.MPEnabled, base.MobileEnabled - } - return parseWeChatConnectCapabilitySettings(settings, enabled, mode) -} - -func (s *SettingService) effectiveWeChatConnectOAuthConfig(settings map[string]string) WeChatConnectOAuthConfig { - base := config.WeChatConnectConfig{} - if s != nil && s.cfg != nil { - base = s.cfg.WeChat - } - - enabled := base.Enabled - if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok { - enabled = strings.TrimSpace(raw) == "true" - } - - legacyAppID := strings.TrimSpace(firstNonEmpty( - settings[SettingKeyWeChatConnectAppID], - base.AppID, - base.OpenAppID, - base.MPAppID, - base.MobileAppID, - )) - legacyAppSecret := strings.TrimSpace(firstNonEmpty( - settings[SettingKeyWeChatConnectAppSecret], - base.AppSecret, - base.OpenAppSecret, - base.MPAppSecret, - base.MobileAppSecret, - )) - openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], base.OpenAppID, legacyAppID)) - openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], base.OpenAppSecret, legacyAppSecret)) - mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], base.MPAppID, legacyAppID)) - mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], base.MPAppSecret, legacyAppSecret)) - mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], base.MobileAppID, legacyAppID)) - mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], base.MobileAppSecret, legacyAppSecret)) - - modeRaw := firstNonEmpty(settings[SettingKeyWeChatConnectMode], base.Mode) - openEnabled, mpEnabled, mobileEnabled := mergeWeChatConnectCapabilitySettings(settings, base, enabled, modeRaw) - mode := normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, modeRaw) - - return WeChatConnectOAuthConfig{ - Enabled: enabled, - LegacyAppID: legacyAppID, - LegacyAppSecret: legacyAppSecret, - OpenAppID: openAppID, - OpenAppSecret: openAppSecret, - MPAppID: mpAppID, - MPAppSecret: mpAppSecret, - MobileAppID: mobileAppID, - MobileAppSecret: mobileAppSecret, - OpenEnabled: openEnabled, - MPEnabled: mpEnabled, - MobileEnabled: mobileEnabled, - Mode: mode, - Scopes: normalizeWeChatConnectScopeSetting(firstNonEmpty(settings[SettingKeyWeChatConnectScopes], base.Scopes), mode), - RedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectRedirectURL], base.RedirectURL)), - FrontendRedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectFrontendRedirectURL], base.FrontendRedirectURL, defaultWeChatConnectFrontend)), - } -} - // NewSettingService 创建系统设置服务实例 func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService { return &SettingService{ @@ -703,699 +243,6 @@ func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, e return s.parseSettings(settings), nil } -// GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件) -func (s *SettingService) GetFrontendURL(ctx context.Context) string { - val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL) - if err == nil && strings.TrimSpace(val) != "" { - return strings.TrimSpace(val) - } - return s.cfg.Server.FrontendURL -} - -// GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s, -// 供网关热路径读取时避免 DB 往返。 -// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。 -// 默认值:开关 false,TTL 1h(与粘性会话对齐)。 -func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) { - if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.enabled, cached.ttl - } - } - result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) { - if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached, nil - } - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cyberSessionBlockRuntimeDBTimeout) - defer cancel() - - enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled) - ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds) - - if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) { - slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr) - entry := &cachedCyberSessionBlockRuntime{ - enabled: false, - ttl: time.Hour, - expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(), - } - s.cyberSessionBlockRuntimeCache.Store(entry) - return entry, nil - } - - enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true" - - ttl := time.Hour - if ttlErr == nil { - if n, perr := strconv.Atoi(strings.TrimSpace(ttlVal)); perr == nil && n > 0 { - ttl = time.Duration(n) * time.Second - } - } - - entry := &cachedCyberSessionBlockRuntime{ - enabled: enabled, - ttl: ttl, - expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(), - } - s.cyberSessionBlockRuntimeCache.Store(entry) - return entry, nil - }) - if entry, ok := result.(*cachedCyberSessionBlockRuntime); ok && entry != nil { - return entry.enabled, entry.ttl - } - return false, time.Hour -} - -// GetPublicSettings 获取公开设置(无需登录) -func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) { - keys := []string{ - SettingKeyRegistrationEnabled, - SettingKeyEmailVerifyEnabled, - SettingKeyForceEmailOnThirdPartySignup, - SettingKeyRegistrationEmailSuffixWhitelist, - SettingKeyPromoCodeEnabled, - SettingKeyPasswordResetEnabled, - SettingKeyInvitationCodeEnabled, - SettingKeyTotpEnabled, - SettingKeyLoginAgreementEnabled, - SettingKeyLoginAgreementMode, - SettingKeyLoginAgreementUpdatedAt, - SettingKeyLoginAgreementDocuments, - SettingKeyTurnstileEnabled, - SettingKeyTurnstileSiteKey, - SettingKeyAPIKeyACLTrustForwardedIP, - SettingKeySiteName, - SettingKeySiteLogo, - SettingKeySiteSubtitle, - SettingKeyAPIBaseURL, - SettingKeyContactInfo, - SettingKeyDocURL, - SettingKeyHomeContent, - SettingKeyHideCcsImportButton, - SettingKeyPurchaseSubscriptionEnabled, - SettingKeyPurchaseSubscriptionURL, - SettingKeyTableDefaultPageSize, - SettingKeyTablePageSizeOptions, - SettingKeyCustomMenuItems, - SettingKeyCustomEndpoints, - SettingKeyLinuxDoConnectEnabled, - SettingKeyDingTalkConnectEnabled, - SettingKeyWeChatConnectEnabled, - SettingKeyWeChatConnectAppID, - SettingKeyWeChatConnectAppSecret, - SettingKeyWeChatConnectOpenAppID, - SettingKeyWeChatConnectOpenAppSecret, - SettingKeyWeChatConnectMPAppID, - SettingKeyWeChatConnectMPAppSecret, - SettingKeyWeChatConnectMobileAppID, - SettingKeyWeChatConnectMobileAppSecret, - SettingKeyWeChatConnectOpenEnabled, - SettingKeyWeChatConnectMPEnabled, - SettingKeyWeChatConnectMobileEnabled, - SettingKeyWeChatConnectMode, - SettingKeyWeChatConnectScopes, - SettingKeyWeChatConnectRedirectURL, - SettingKeyWeChatConnectFrontendRedirectURL, - SettingKeyBackendModeEnabled, - SettingPaymentEnabled, - SettingKeyOIDCConnectEnabled, - SettingKeyOIDCConnectProviderName, - SettingKeyGitHubOAuthEnabled, - SettingKeyGitHubOAuthClientID, - SettingKeyGitHubOAuthClientSecret, - SettingKeyGoogleOAuthEnabled, - SettingKeyGoogleOAuthClientID, - SettingKeyGoogleOAuthClientSecret, - SettingKeyBalanceLowNotifyEnabled, - SettingKeyBalanceLowNotifyThreshold, - SettingKeyBalanceLowNotifyRechargeURL, - SettingKeyAccountQuotaNotifyEnabled, - SettingKeyChannelMonitorEnabled, - SettingKeyChannelMonitorDefaultIntervalSeconds, - SettingKeyAvailableChannelsEnabled, - SettingKeyAffiliateEnabled, - SettingKeyRiskControlEnabled, - SettingKeyAllowUserViewErrorRequests, - } - - 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 - } - dingTalkEnabled := false - if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { - dingTalkEnabled = raw == "true" - } else { - dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled - } - oidcEnabled := false - if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { - oidcEnabled = raw == "true" - } else { - oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled - } - oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName]) - if oidcProviderName == "" && s.cfg != nil { - oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName) - } - if oidcProviderName == "" { - oidcProviderName = "OIDC" - } - gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github") - googleEnabled := s.emailOAuthPublicEnabled(settings, "google") - weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings) - - // Password reset requires email verification to be enabled - emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" - passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" - registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist( - settings[SettingKeyRegistrationEmailSuffixWhitelist], - ) - tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences( - settings[SettingKeyTableDefaultPageSize], - settings[SettingKeyTablePageSizeOptions], - ) - loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) - loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) - if loginAgreementUpdatedAt == "" { - loginAgreementUpdatedAt = defaultLoginAgreementDate - } - - var balanceLowNotifyThreshold float64 - if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { - balanceLowNotifyThreshold = v - } - - return &PublicSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: emailVerifyEnabled, - ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", - RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - PasswordResetEnabled: passwordResetEnabled, - InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", - TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, - LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), - LoginAgreementDocuments: loginAgreementDocuments, - 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]), - TableDefaultPageSize: tableDefaultPageSize, - TablePageSizeOptions: tablePageSizeOptions, - CustomMenuItems: settings[SettingKeyCustomMenuItems], - CustomEndpoints: settings[SettingKeyCustomEndpoints], - LinuxDoOAuthEnabled: linuxDoEnabled, - DingTalkOAuthEnabled: dingTalkEnabled, - WeChatOAuthEnabled: weChatEnabled, - WeChatOAuthOpenEnabled: weChatOpenEnabled, - WeChatOAuthMPEnabled: weChatMPEnabled, - WeChatOAuthMobileEnabled: weChatMobileEnabled, - BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", - PaymentEnabled: settings[SettingPaymentEnabled] == "true", - OIDCOAuthEnabled: oidcEnabled, - OIDCOAuthProviderName: oidcProviderName, - GitHubOAuthEnabled: gitHubEnabled, - GoogleOAuthEnabled: googleEnabled, - BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", - AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", - BalanceLowNotifyThreshold: balanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], - - ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), - ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), - - AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", - - AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", - - RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", - - AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true", - }, nil -} - -// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval -// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled). -const ( - channelMonitorIntervalMin = 15 - channelMonitorIntervalMax = 3600 - channelMonitorIntervalFallback = 60 -) - -// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600]. -// Empty / invalid input falls back to channelMonitorIntervalFallback. -func parseChannelMonitorInterval(raw string) int { - v, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil { - return channelMonitorIntervalFallback - } - return clampChannelMonitorInterval(v) -} - -// clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided". -func clampChannelMonitorInterval(v int) int { - if v <= 0 { - return 0 - } - if v < channelMonitorIntervalMin { - return channelMonitorIntervalMin - } - if v > channelMonitorIntervalMax { - return channelMonitorIntervalMax - } - return v -} - -// ChannelMonitorRuntime is the lightweight view of the channel monitor feature -// consumed by the runner and user-facing handlers. -type ChannelMonitorRuntime struct { - Enabled bool - DefaultIntervalSeconds int -} - -// GetChannelMonitorRuntime reads the channel monitor feature flags directly from -// the settings store. Fail-open: on error returns Enabled=true with the default interval. -func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime { - vals, err := s.settingRepo.GetMultiple(ctx, []string{ - SettingKeyChannelMonitorEnabled, - SettingKeyChannelMonitorDefaultIntervalSeconds, - }) - if err != nil { - return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback} - } - return ChannelMonitorRuntime{ - Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]), - DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), - } -} - -// AvailableChannelsRuntime is the lightweight view of the available-channels feature -// switch consumed by the user-facing handler. -type AvailableChannelsRuntime struct { - Enabled bool -} - -// GetAvailableChannelsRuntime reads the available-channels feature switch directly -// from the settings store. Fail-closed: on error returns Enabled=false, matching -// the opt-in default (unknown ↔ disabled). -func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime { - vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled}) - if err != nil { - return AvailableChannelsRuntime{Enabled: false} - } - return AvailableChannelsRuntime{ - Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true", - } -} - -// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch -// directly from the settings store. Fail-closed: on error returns false (opt-in default). -func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool { - vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests}) - if err != nil { - slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err) - return false - } - return vals[SettingKeyAllowUserViewErrorRequests] == "true" -} - -// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。 -// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。 -func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string { - fallback := antigravity.GetDefaultUserAgentVersion() - if s == nil || s.settingRepo == nil { - return fallback - } - if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.version - } - } - - result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) { - if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.version, nil - } - } - if ctx == nil { - ctx = context.Background() - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout) - defer cancel() - value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion) - if err != nil && !errors.Is(err, ErrSettingNotFound) { - slog.Warn("failed to get antigravity user agent version setting", "error", err) - s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ - version: fallback, - expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(), - }) - return fallback, nil - } - version := antigravity.NormalizeUserAgentVersion(value) - if version == "" { - version = fallback - } - s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ - version: version, - expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(), - }) - return version, nil - }) - if version, ok := result.(string); ok && version != "" { - return version - } - return fallback -} - -// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。 -// 后台设置优先;为空时回退到内置默认值。 -func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string { - fallback := DefaultOpenAICodexUserAgent - if s == nil || s.settingRepo == nil { - return fallback - } - if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value - } - } - - result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) { - if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value, nil - } - } - if ctx == nil { - ctx = context.Background() - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout) - defer cancel() - value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent) - if err != nil && !errors.Is(err, ErrSettingNotFound) { - slog.Warn("failed to get openai codex user agent setting", "error", err) - s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ - value: fallback, - expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(), - }) - return fallback, nil - } - ua := strings.TrimSpace(value) - if ua == "" { - ua = fallback - } - s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ - value: ua, - expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), - }) - return ua, nil - }) - if ua, ok := result.(string); ok && ua != "" { - return ua - } - return fallback -} - -var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{ - Originator: "Claude Code", - UAContains: []string{"Claude Code/"}, -} - -// MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code -// plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the -// same originator + UA marker pair, so runtime checks no longer need a separate flag. -func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error { - if s == nil || s.settingRepo == nil { - return nil - } - if ctx == nil { - ctx = context.Background() - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) - defer cancel() - - legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return nil - } - return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err) - } - if strings.TrimSpace(legacyValue) != "true" { - return nil - } - - rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist) - if err != nil && !errors.Is(err, ErrSettingNotFound) { - return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) - } - - var entries []openai.AllowedClientEntry - if strings.TrimSpace(rawWhitelist) != "" { - if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil { - return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) - } - } - if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) { - return nil - } - - entries = append(entries, legacyClaudeCodeCodexWhitelistEntry) - encoded, err := json.Marshal(entries) - if err != nil { - return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) - } - if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil { - return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) - } - s.codexRestrictionPolicySF.Forget("codex_restriction_policy") - s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) - return nil -} - -// MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint -// 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子, -// 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。 -func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error { - if s == nil || s.settingRepo == nil { - return nil - } - if ctx == nil { - ctx = context.Background() - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) - defer cancel() - - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" { - return nil // 已配置/已迁移 - } else if err != nil && !errors.Is(err, ErrSettingNotFound) { - return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) - } - - bodyOn := false - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil { - bodyOn = strings.TrimSpace(v) == "true" - } else if !errors.Is(err, ErrSettingNotFound) { - return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err) - } - - seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals)) - copy(seed, openai.DefaultEngineFingerprintSignals) - if bodyOn { - for i := range seed { - if seed[i].Type == openai.FingerprintSignalBodyPath { - seed[i].Required = true - } - } - } - encoded, err := json.Marshal(seed) - if err != nil { - return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) - } - if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil { - return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) - } - s.codexRestrictionPolicySF.Forget("codex_restriction_policy") - s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) - return nil -} - -func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool { - wantOriginator := strings.TrimSpace(want.Originator) - if wantOriginator == "" { - return false - } - wantMarkers := normalizedCodexClientMarkers(want.UAContains) - if len(wantMarkers) == 0 { - return false - } - for _, entry := range entries { - if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) { - continue - } - gotMarkers := normalizedCodexClientMarkers(entry.UAContains) - if len(gotMarkers) != len(wantMarkers) { - continue - } - matched := true - for marker := range wantMarkers { - if _, ok := gotMarkers[marker]; !ok { - matched = false - break - } - } - if matched { - return true - } - } - return false -} - -func normalizedCodexClientMarkers(markers []string) map[string]struct{} { - normalized := make(map[string]struct{}, len(markers)) - for _, marker := range markers { - marker = strings.TrimSpace(marker) - if marker == "" { - continue - } - normalized[strings.ToLower(marker)] = struct{}{} - } - return normalized -} - -// GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。 -// 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。 -// 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。 -func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy { - if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value - } - } - result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) { - if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value, nil - } - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) - defer cancel() - - pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号 - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil { - pol.MinCodexVersion = strings.TrimSpace(v) - } - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil { - pol.MaxCodexVersion = strings.TrimSpace(v) - } - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil { - pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启 - } - pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx) - pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist) - pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist) - - s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{ - value: pol, - expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(), - }) - return pol, nil - }) - if pol, ok := result.(CodexRestrictionPolicy); ok { - return pol - } - return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} -} - -// loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。 -func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry { - v, err := s.settingRepo.GetValue(ctx, key) - if err != nil || strings.TrimSpace(v) == "" { - return nil - } - var entries []openai.AllowedClientEntry - if json.Unmarshal([]byte(v), &entries) != nil { - return nil - } - return entries -} - -// loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。 -func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal { - v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals) - if err != nil || strings.TrimSpace(v) == "" { - return openai.DefaultEngineFingerprintSignals - } - sigs, ok := openai.ParseEngineFingerprintSignals(v) - if !ok { - return openai.DefaultEngineFingerprintSignals - } - return sigs -} - -// ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义): -// 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny, -// 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。 -func ValidateCodexClientEntriesJSON(raw string) error { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - var entries []openai.AllowedClientEntry - if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { - return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") - } - return nil -} - -// ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求 -// 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND: -// originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈, -// 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。 -func ValidateCodexWhitelistEntriesJSON(raw string) error { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - var entries []openai.AllowedClientEntry - if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { - return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") - } - for i, e := range entries { - if !e.IsWhitelistable() { - return fmt.Errorf("entry %d: whitelist requires a non-empty originator and at least one non-empty ua_contains (double-factor AND; otherwise the rule never matches)", i) - } - } - return nil -} - -// ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。 -func ValidateEngineFingerprintSignalsJSON(raw string) error { - return openai.ValidateEngineFingerprintSignalsJSON(raw) -} - // 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()) { @@ -1407,2777 +254,6 @@ func (s *SettingService) SetVersion(version string) { s.version = version } -// PublicSettingsInjectionPayload is the JSON shape embedded into HTML as -// `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site -// config before the first XHR finishes. -// -// INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings. -// If you forget a feature-flag field here, the frontend's -// `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the -// async `/api/v1/settings/public` call returns — which causes opt-in menus -// (strict `=== true`) to flicker off/on. See -// frontend/src/utils/featureFlags.ts for the matching registry. -// -// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch -// drift automatically (see setting_service_injection_test.go). -type PublicSettingsInjectionPayload struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementRevision string `json:"login_agreement_revision"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems json.RawMessage `json:"custom_menu_items"` - CustomEndpoints json.RawMessage `json:"custom_endpoints"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - GitHubOAuthEnabled bool `json:"github_oauth_enabled"` - GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` - // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 - ServerTimezone string `json:"server_timezone"` - ServerUTCOffset string `json:"server_utc_offset"` - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` - - // Feature flags — MUST match the opt-in/opt-out registry in - // frontend/src/utils/featureFlags.ts. Missing a field here is the bug - // that hid the "可用渠道" menu on page refresh. - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` - AvailableChannelsEnabled bool `json:"available_channels_enabled"` - AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` - AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` -} - -// 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 &PublicSettingsInjectionPayload{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: settings.PromoCodeEnabled, - PasswordResetEnabled: settings.PasswordResetEnabled, - InvitationCodeEnabled: settings.InvitationCodeEnabled, - TotpEnabled: settings.TotpEnabled, - LoginAgreementEnabled: settings.LoginAgreementEnabled, - LoginAgreementMode: settings.LoginAgreementMode, - LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, - LoginAgreementRevision: settings.LoginAgreementRevision, - LoginAgreementDocuments: settings.LoginAgreementDocuments, - 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, - TableDefaultPageSize: settings.TableDefaultPageSize, - TablePageSizeOptions: settings.TablePageSizeOptions, - CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), - CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, - WeChatOAuthEnabled: settings.WeChatOAuthEnabled, - WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, - WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, - WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, - OIDCOAuthEnabled: settings.OIDCOAuthEnabled, - OIDCOAuthProviderName: settings.OIDCOAuthProviderName, - GitHubOAuthEnabled: settings.GitHubOAuthEnabled, - GoogleOAuthEnabled: settings.GoogleOAuthEnabled, - BackendModeEnabled: settings.BackendModeEnabled, - PaymentEnabled: settings.PaymentEnabled, - Version: s.version, - ServerTimezone: timezone.Name(), - ServerUTCOffset: timezone.UTCOffset(), - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, - - ChannelMonitorEnabled: settings.ChannelMonitorEnabled, - ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, - AvailableChannelsEnabled: settings.AvailableChannelsEnabled, - AffiliateEnabled: settings.AffiliateEnabled, - RiskControlEnabled: settings.RiskControlEnabled, - AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, - }, nil -} - -func DefaultWeChatConnectScopesForMode(mode string) string { - return defaultWeChatConnectScopeForMode(mode) -} - -func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) { - cfg := s.effectiveWeChatConnectOAuthConfig(settings) - - if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) { - return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled") - } - if cfg.OpenEnabled { - if cfg.AppIDForMode("open") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app id not configured") - } - if cfg.AppSecretForMode("open") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app secret not configured") - } - } - if cfg.MPEnabled { - if cfg.AppIDForMode("mp") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app id not configured") - } - if cfg.AppSecretForMode("mp") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app secret not configured") - } - } - if cfg.MobileEnabled { - if cfg.AppIDForMode("mobile") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app id not configured") - } - if cfg.AppSecretForMode("mobile") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured") - } - } - if v := strings.TrimSpace(cfg.RedirectURL); v != "" { - if err := config.ValidateAbsoluteHTTPURL(v); err != nil { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid") - } - } - if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid") - } - return cfg, nil -} - -func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) { - cfg := s.effectiveWeChatConnectOAuthConfig(settings) - if !cfg.Enabled { - return false, false, false, false - } - - openReady := cfg.OpenEnabled && cfg.AppIDForMode("open") != "" && cfg.AppSecretForMode("open") != "" - mpReady := cfg.MPEnabled && cfg.AppIDForMode("mp") != "" && cfg.AppSecretForMode("mp") != "" - mobileReady := cfg.MobileEnabled && cfg.AppIDForMode("mobile") != "" && cfg.AppSecretForMode("mobile") != "" - - return openReady || mpReady, openReady, mpReady, mobileReady -} - -func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "github": - cfg := config.EmailOAuthProviderConfig{ - AuthorizeURL: defaultGitHubOAuthAuthorize, - TokenURL: defaultGitHubOAuthToken, - UserInfoURL: defaultGitHubOAuthUserInfo, - EmailsURL: defaultGitHubOAuthEmails, - Scopes: defaultGitHubOAuthScopes, - FrontendRedirectURL: defaultGitHubOAuthFrontend, - } - if s != nil && s.cfg != nil { - cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth) - } - return cfg - case "google": - cfg := config.EmailOAuthProviderConfig{ - AuthorizeURL: defaultGoogleOAuthAuthorize, - TokenURL: defaultGoogleOAuthToken, - UserInfoURL: defaultGoogleOAuthUserInfo, - Scopes: defaultGoogleOAuthScopes, - FrontendRedirectURL: defaultGoogleOAuthFrontend, - } - if s != nil && s.cfg != nil { - cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth) - } - return cfg - default: - return config.EmailOAuthProviderConfig{} - } -} - -func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig { - base.Enabled = override.Enabled - if strings.TrimSpace(override.ClientID) != "" { - base.ClientID = strings.TrimSpace(override.ClientID) - } - if strings.TrimSpace(override.ClientSecret) != "" { - base.ClientSecret = strings.TrimSpace(override.ClientSecret) - } - if strings.TrimSpace(override.AuthorizeURL) != "" { - base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL) - } - if strings.TrimSpace(override.TokenURL) != "" { - base.TokenURL = strings.TrimSpace(override.TokenURL) - } - if strings.TrimSpace(override.UserInfoURL) != "" { - base.UserInfoURL = strings.TrimSpace(override.UserInfoURL) - } - if strings.TrimSpace(override.EmailsURL) != "" { - base.EmailsURL = strings.TrimSpace(override.EmailsURL) - } - if strings.TrimSpace(override.Scopes) != "" { - base.Scopes = strings.TrimSpace(override.Scopes) - } - if strings.TrimSpace(override.RedirectURL) != "" { - base.RedirectURL = strings.TrimSpace(override.RedirectURL) - } - if strings.TrimSpace(override.FrontendRedirectURL) != "" { - base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL) - } - return base -} - -func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool { - cfg := s.effectiveEmailOAuthConfig(settings, provider) - return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != "" -} - -func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig { - cfg := s.emailOAuthBaseConfig(provider) - switch strings.ToLower(strings.TrimSpace(provider)) { - case "github": - if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok { - cfg.Enabled = raw == "true" - } - cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID) - cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret) - cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL) - cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend) - case "google": - if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok { - cfg.Enabled = raw == "true" - } - cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID) - cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret) - cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL) - cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend) - } - return cfg -} - -// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON -// array string, returning only items with visibility != "admin". -func filterUserVisibleMenuItems(raw string) json.RawMessage { - raw = strings.TrimSpace(raw) - if raw == "" || raw == "[]" { - return json.RawMessage("[]") - } - var items []struct { - Visibility string `json:"visibility"` - } - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return json.RawMessage("[]") - } - - // Parse full items to preserve all fields - var fullItems []json.RawMessage - if err := json.Unmarshal([]byte(raw), &fullItems); err != nil { - return json.RawMessage("[]") - } - - var filtered []json.RawMessage - for i, item := range items { - if item.Visibility != "admin" { - filtered = append(filtered, fullItems[i]) - } - } - if len(filtered) == 0 { - return json.RawMessage("[]") - } - result, err := json.Marshal(filtered) - if err != nil { - return json.RawMessage("[]") - } - return result -} - -// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]". -func safeRawJSONArray(raw string) json.RawMessage { - raw = strings.TrimSpace(raw) - if raw == "" { - return json.RawMessage("[]") - } - if json.Valid([]byte(raw)) { - return json.RawMessage(raw) - } - return json.RawMessage("[]") -} - -// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL, -// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. -func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) { - settings, err := s.GetPublicSettings(ctx) - if err != nil { - return nil, err - } - - seen := make(map[string]struct{}) - var origins []string - - addOrigin := func(rawURL string) { - if origin := extractOriginFromURL(rawURL); origin != "" { - if _, ok := seen[origin]; !ok { - seen[origin] = struct{}{} - origins = append(origins, origin) - } - } - } - - // home content URL (when home_content is set to a URL for iframe embedding) - addOrigin(settings.HomeContent) - - // purchase subscription URL - if settings.PurchaseSubscriptionEnabled { - addOrigin(settings.PurchaseSubscriptionURL) - } - - // all custom menu items (including admin-only, since CSP must allow all iframes) - for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) { - addOrigin(item) - } - - return origins, nil -} - -// extractOriginFromURL returns the scheme+host origin from rawURL. -// Only http and https schemes are accepted. -func extractOriginFromURL(rawURL string) string { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return "" - } - u, err := url.Parse(rawURL) - if err != nil || u.Host == "" { - return "" - } - if u.Scheme != "http" && u.Scheme != "https" { - return "" - } - return u.Scheme + "://" + u.Host -} - -// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items. -func parseCustomMenuItemURLs(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" || raw == "[]" { - return nil - } - var items []struct { - URL string `json:"url"` - } - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil - } - urls := make([]string, 0, len(items)) - for _, item := range items { - if item.URL != "" { - urls = append(urls, item.URL) - } - } - return urls -} - -func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool { - if base.UsePKCEExplicit { - return base.UsePKCE - } - return true -} - -func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool { - if base.ValidateIDTokenExplicit { - return base.ValidateIDToken - } - return true -} - -func oidcCompatibilityWriteDefault(base config.OIDCConnectConfig, configured bool, raw string, explicit bool, explicitValue bool) bool { - if configured { - return strings.TrimSpace(raw) == "true" - } - if explicit { - return explicitValue - } - return false -} - -// UpdateSettings 更新系统设置 -func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { - updates, err := s.buildSystemSettingsUpdates(ctx, settings) - if err != nil { - return err - } - - err = s.settingRepo.SetMultiple(ctx, updates) - if err == nil { - s.refreshCachedSettings(settings) - } - return err -} - -func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, bool, error) { - rawSettings, err := s.settingRepo.GetMultiple(ctx, []string{ - SettingKeyOIDCConnectUsePKCE, - SettingKeyOIDCConnectValidateIDToken, - }) - if err != nil { - return false, false, fmt.Errorf("get oidc security write defaults: %w", err) - } - - base := config.OIDCConnectConfig{} - if s != nil && s.cfg != nil { - base = s.cfg.OIDC - } - - rawUsePKCE, hasUsePKCE := rawSettings[SettingKeyOIDCConnectUsePKCE] - rawValidateIDToken, hasValidateIDToken := rawSettings[SettingKeyOIDCConnectValidateIDToken] - - return oidcCompatibilityWriteDefault(base, hasUsePKCE, rawUsePKCE, base.UsePKCEExplicit, base.UsePKCE), - oidcCompatibilityWriteDefault(base, hasValidateIDToken, rawValidateIDToken, base.ValidateIDTokenExplicit, base.ValidateIDToken), - nil -} - -// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write. -func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error { - updates, err := s.buildSystemSettingsUpdates(ctx, settings) - if err != nil { - return err - } - - authSourceUpdates, err := s.buildAuthSourceDefaultUpdates(ctx, authDefaults) - if err != nil { - return err - } - for key, value := range authSourceUpdates { - updates[key] = value - } - - err = s.settingRepo.SetMultiple(ctx, updates) - if err == nil { - s.refreshCachedSettings(settings) - } - return err -} - -func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) { - if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil { - return nil, err - } - normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist) - if err != nil { - return nil, infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error()) - } - if normalizedWhitelist == nil { - normalizedWhitelist = []string{} - } - settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist - alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled) - if err != nil { - return nil, err - } - wxpaySource, err := normalizeVisibleMethodSettingSource("wxpay", settings.PaymentVisibleMethodWxpaySource, settings.PaymentVisibleMethodWxpayEnabled) - if err != nil { - return nil, err - } - if err := s.normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil { - return nil, err - } - settings.PaymentVisibleMethodAlipaySource = alipaySource - settings.PaymentVisibleMethodWxpaySource = wxpaySource - settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID) - settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret) - settings.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppID, settings.WeChatConnectAppID)) - settings.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppSecret, settings.WeChatConnectAppSecret)) - settings.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppID, settings.WeChatConnectAppID)) - settings.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppSecret, settings.WeChatConnectAppSecret)) - settings.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppID, settings.WeChatConnectAppID)) - settings.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppSecret, settings.WeChatConnectAppSecret)) - settings.WeChatConnectMode = normalizeWeChatConnectStoredMode( - settings.WeChatConnectOpenEnabled, - settings.WeChatConnectMPEnabled, - settings.WeChatConnectMobileEnabled, - settings.WeChatConnectMode, - ) - settings.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings.WeChatConnectScopes, settings.WeChatConnectMode) - settings.WeChatConnectRedirectURL = strings.TrimSpace(settings.WeChatConnectRedirectURL) - settings.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings.WeChatConnectFrontendRedirectURL) - if settings.WeChatConnectFrontendRedirectURL == "" { - settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend - } - settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL) - settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL) - if settings.GitHubOAuthFrontendRedirectURL == "" { - settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend - } - settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL) - settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL) - if settings.GoogleOAuthFrontendRedirectURL == "" { - settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend - } - - updates := make(map[string]string) - - // 注册设置 - updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) - updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) - registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist) - if err != nil { - return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err) - } - updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON) - updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) - updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) - updates[SettingKeyFrontendURL] = settings.FrontendURL - updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) - updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) - settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode) - settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt) - if settings.LoginAgreementUpdatedAt == "" { - settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate - } - loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments) - if err != nil { - return nil, err - } - updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled) - updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode - updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt - updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON - - // 邮件服务设置(只有非空才更新密码) - 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 - } - updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) - - // LinuxDo Connect OAuth 登录 - updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) - updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID - updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL - if settings.LinuxDoConnectClientSecret != "" { - updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret - } - - // DingTalk Connect OAuth 登录 - updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled) - updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID - updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL - if settings.DingTalkConnectClientSecret != "" { - updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret - } - updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy - updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID - updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration) - updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail) - updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName) - updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept) - updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey - updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey - updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey - updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName - updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName - updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName - - // Generic OIDC OAuth 登录 - updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled) - updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName - updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID - updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL - updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL - updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL - updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL - updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL - updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL - updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes - updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL - updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL - updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod - updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE) - updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken) - updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs - updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds) - updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified) - updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath - updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath - updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath - if settings.OIDCConnectClientSecret != "" { - updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret - } - - // GitHub / Google 邮箱快捷登录 - updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled) - updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID) - updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL - updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL - if settings.GitHubOAuthClientSecret != "" { - updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret) - } - updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled) - updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID) - updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL - updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL - if settings.GoogleOAuthClientSecret != "" { - updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret) - } - - // WeChat Connect OAuth 登录 - updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled) - updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID - updates[SettingKeyWeChatConnectOpenAppID] = settings.WeChatConnectOpenAppID - updates[SettingKeyWeChatConnectMPAppID] = settings.WeChatConnectMPAppID - updates[SettingKeyWeChatConnectMobileAppID] = settings.WeChatConnectMobileAppID - updates[SettingKeyWeChatConnectOpenEnabled] = strconv.FormatBool(settings.WeChatConnectOpenEnabled) - updates[SettingKeyWeChatConnectMPEnabled] = strconv.FormatBool(settings.WeChatConnectMPEnabled) - updates[SettingKeyWeChatConnectMobileEnabled] = strconv.FormatBool(settings.WeChatConnectMobileEnabled) - updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode - updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes - updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL - updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL - if settings.WeChatConnectAppSecret != "" { - updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret - } - if settings.WeChatConnectOpenAppSecret != "" { - updates[SettingKeyWeChatConnectOpenAppSecret] = settings.WeChatConnectOpenAppSecret - } - if settings.WeChatConnectMPAppSecret != "" { - updates[SettingKeyWeChatConnectMPAppSecret] = settings.WeChatConnectMPAppSecret - } - if settings.WeChatConnectMobileAppSecret != "" { - updates[SettingKeyWeChatConnectMobileAppSecret] = settings.WeChatConnectMobileAppSecret - } - - // 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) - tableDefaultPageSize, tablePageSizeOptions := normalizeTablePreferences( - settings.TableDefaultPageSize, - settings.TablePageSizeOptions, - ) - updates[SettingKeyTableDefaultPageSize] = strconv.Itoa(tableDefaultPageSize) - tablePageSizeOptionsJSON, err := json.Marshal(tablePageSizeOptions) - if err != nil { - return nil, fmt.Errorf("marshal table page size options: %w", err) - } - updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON) - updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems - updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints - - // 默认配置 - updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) - updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) - settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate) - updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64) - if settings.AffiliateRebateFreezeHours < 0 { - settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault - } - if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax { - settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax - } - updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours) - if settings.AffiliateRebateDurationDays < 0 { - settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault - } - if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax { - settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax - } - updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays) - if settings.AffiliateRebatePerInviteeCap < 0 { - settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault - } - updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64) - updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit) - defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) - if err != nil { - return nil, fmt.Errorf("marshal default subscriptions: %w", err) - } - updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON) - - // 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) - } - - // Channel monitor feature switch - updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled) - if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 { - updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) - } - - // Available channels feature switch - updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) - - // Affiliate (邀请返利) feature switch - updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled) - - // 风控中心功能开关 - updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled) - - // cyber 会话屏蔽开关 + TTL - updates[SettingKeyCyberSessionBlockEnabled] = strconv.FormatBool(settings.CyberSessionBlockEnabled) - if settings.CyberSessionBlockTTLSeconds > 0 { - updates[SettingKeyCyberSessionBlockTTLSeconds] = strconv.Itoa(settings.CyberSessionBlockTTLSeconds) - } - - // Claude Code version check - updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion - updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion - - // 分组隔离 - updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling) - - // Backend Mode - updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled) - - // Gateway forwarding behavior - updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification) - updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) - updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning) - updates[SettingKeyEnableClaudeOAuthSystemPromptInjection] = strconv.FormatBool(settings.EnableClaudeOAuthSystemPromptInjection) - updates[SettingKeyClaudeOAuthSystemPrompt] = settings.ClaudeOAuthSystemPrompt - if err := ValidateClaudeOAuthSystemPromptBlocksConfig(settings.ClaudeOAuthSystemPromptBlocks); err != nil { - return nil, err - } - updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks - updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection) - updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl) - updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization) - updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) - updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent) - // codex_cli_only 加固 - updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) - updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) - updates[SettingKeyCodexCLIOnlyBlacklist] = strings.TrimSpace(settings.CodexCLIOnlyBlacklist) - updates[SettingKeyCodexCLIOnlyWhitelist] = strings.TrimSpace(settings.CodexCLIOnlyWhitelist) - updates[SettingKeyCodexCLIOnlyAllowAppServerClients] = strconv.FormatBool(settings.CodexCLIOnlyAllowAppServerClients) - updates[SettingKeyCodexCLIOnlyEngineFingerprintSignals] = strings.TrimSpace(settings.CodexCLIOnlyEngineFingerprintSignals) - updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource - updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource - updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) - updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled) - updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled) - updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled) - updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled) - updates[SettingKeyOpenAIAdvancedSchedulerLBTopK] = settings.OpenAIAdvancedSchedulerLBTopK - updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority] = settings.OpenAIAdvancedSchedulerWeightPriority - updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad] = settings.OpenAIAdvancedSchedulerWeightLoad - updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue] = settings.OpenAIAdvancedSchedulerWeightQueue - updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate] = settings.OpenAIAdvancedSchedulerWeightErrorRate - updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT] = settings.OpenAIAdvancedSchedulerWeightTTFT - updates[SettingKeyOpenAIAdvancedSchedulerWeightReset] = settings.OpenAIAdvancedSchedulerWeightReset - updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom] = settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom - updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse] = settings.OpenAIAdvancedSchedulerWeightPreviousResponse - updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky] = settings.OpenAIAdvancedSchedulerWeightSessionSticky - - // 余额、订阅到期与账号限额通知 - updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) - updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64) - updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL - updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled) - updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled) - updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails) - - // 系统全局 platform quota:整体替换语义(null/缺省 = 不限制)。 - if settings.DefaultPlatformQuotas != nil { - if err := validateDefaultPlatformQuotaMap(settings.DefaultPlatformQuotas); err != nil { - return nil, err - } - blob, err := json.Marshal(settings.DefaultPlatformQuotas) - if err != nil { - return nil, fmt.Errorf("marshal default platform quotas: %w", err) - } - updates[SettingKeyDefaultPlatformQuotas] = string(blob) - } - - updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests) - - return updates, nil -} - -// validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性: -// 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。 -// 系统层和 auth-source 层共用此 helper。 -func validateDefaultPlatformQuotaMap(m map[string]*DefaultPlatformQuotaSetting) error { - for platform, pq := range m { - if !IsAllowedQuotaPlatform(platform) { - return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", fmt.Sprintf("unknown platform %q", platform)) - } - if pq == nil { - continue - } - for _, v := range []*float64{pq.DailyLimitUSD, pq.WeeklyLimitUSD, pq.MonthlyLimitUSD} { - if v != nil && (*v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0)) { - return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", "platform quota limit must be a finite non-negative number") - } - } - } - return nil -} - -func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, settings *AuthSourceDefaultSettings) (map[string]string, error) { - if settings == nil { - return nil, nil - } - - for _, subscriptions := range [][]DefaultSubscriptionSetting{ - settings.Email.Subscriptions, - settings.LinuxDo.Subscriptions, - settings.OIDC.Subscriptions, - settings.WeChat.Subscriptions, - settings.GitHub.Subscriptions, - settings.Google.Subscriptions, - settings.DingTalk.Subscriptions, - } { - if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil { - return nil, err - } - } - - // 校验各 auth source 的 platform quota map(改动 C:对等系统层校验) - for _, pgs := range []struct { - name string - pq map[string]*DefaultPlatformQuotaSetting - }{ - {"email", settings.Email.PlatformQuotas}, - {"linuxdo", settings.LinuxDo.PlatformQuotas}, - {"oidc", settings.OIDC.PlatformQuotas}, - {"wechat", settings.WeChat.PlatformQuotas}, - {"github", settings.GitHub.PlatformQuotas}, - {"google", settings.Google.PlatformQuotas}, - {"dingtalk", settings.DingTalk.PlatformQuotas}, - } { - if pgs.pq != nil { - if err := validateDefaultPlatformQuotaMap(pgs.pq); err != nil { - return nil, err - } - } - } - - updates := make(map[string]string, 36) - writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email) - writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo) - writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC) - writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat) - writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub) - writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google) - writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk) - updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup) - return updates, nil -} - -func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { - if settings == nil { - return - } - - // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 - versionBoundsSF.Forget("version_bounds") - versionBoundsCache.Store(&cachedVersionBounds{ - min: settings.MinClaudeCodeVersion, - max: settings.MaxClaudeCodeVersion, - expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), - }) - backendModeSF.Forget("backend_mode") - backendModeCache.Store(&cachedBackendMode{ - value: settings.BackendModeEnabled, - expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), - }) - gatewayForwardingSF.Forget("gateway_forwarding") - gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: settings.EnableFingerprintUnification, - metadataPassthrough: settings.EnableMetadataPassthrough, - cchSigning: settings.EnableCCHSigning, - claudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, - claudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, - claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, - anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, - rewriteMessageCacheControl: settings.RewriteMessageCacheControl, - clientDatelineNormalization: settings.EnableClientDatelineNormalization, - expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), - }) - s.antigravityUAVersionSF.Forget("antigravity_user_agent_version") - antigravityUserAgentVersion := antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) - if antigravityUserAgentVersion == "" { - antigravityUserAgentVersion = antigravity.GetDefaultUserAgentVersion() - } - s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ - version: antigravityUserAgentVersion, - expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(), - }) - s.openAICodexUASF.Forget("openai_codex_user_agent") - codexUA := strings.TrimSpace(settings.OpenAICodexUserAgent) - if codexUA == "" { - codexUA = DefaultOpenAICodexUserAgent - } - s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ - value: codexUA, - expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), - }) - openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) - openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ - enabled: settings.OpenAIAdvancedSchedulerEnabled, - stickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled, - subscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, - lbTopKOverride: parsePositiveIntOverride(settings.OpenAIAdvancedSchedulerLBTopK), - weightOverrides: parseOpenAIAdvancedSchedulerWeightOverrides(map[string]string{ - SettingKeyOpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority, - SettingKeyOpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad, - SettingKeyOpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue, - SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate, - SettingKeyOpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT, - SettingKeyOpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset, - SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, - SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse, - SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky, - }), - expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), - }) - // Invalidate the quota auto-pause cache and let the next read trigger a fresh load. - // We can't know from here whether ops_advanced_settings was also touched, so be - // defensive: store an expired entry — GetOpenAIQuotaAutoPauseSettings will serve - // stale and kick off an async refresh, never blocking the request that follows. - s.openAIQuotaAutoPauseSettingsSF.Forget(openAIQuotaAutoPauseSettingsRefreshKey) - if cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); cached != nil { - s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ - settings: cached.settings, - expiresAt: 0, - }) - } - if s.cfg != nil { - s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP) - } - // codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。 - s.codexRestrictionPolicySF.Forget("codex_restriction_policy") - s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) - if s.onUpdate != nil { - s.onUpdate() // Invalidate cache after settings update - } -} - -func (s *SettingService) defaultRewriteMessageCacheControl() bool { - return false -} - -func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error { - if len(items) == 0 { - return nil - } - - checked := make(map[int64]struct{}, len(items)) - for _, item := range items { - if item.GroupID <= 0 { - continue - } - if _, ok := checked[item.GroupID]; ok { - return ErrDefaultSubGroupDuplicate.WithMetadata(map[string]string{ - "group_id": strconv.FormatInt(item.GroupID, 10), - }) - } - checked[item.GroupID] = struct{}{} - if s.defaultSubGroupReader == nil { - continue - } - - group, err := s.defaultSubGroupReader.GetByID(ctx, item.GroupID) - if err != nil { - if errors.Is(err, ErrGroupNotFound) { - return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{ - "group_id": strconv.FormatInt(item.GroupID, 10), - }) - } - return fmt.Errorf("get default subscription group %d: %w", item.GroupID, err) - } - if !group.IsSubscriptionType() { - return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{ - "group_id": strconv.FormatInt(item.GroupID, 10), - }) - } - } - - return nil -} - -func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) { - provider = strings.ToLower(strings.TrimSpace(provider)) - if provider != "github" && provider != "google" { - return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found") - } - keys := []string{ - SettingKeyGitHubOAuthEnabled, - SettingKeyGitHubOAuthClientID, - SettingKeyGitHubOAuthClientSecret, - SettingKeyGitHubOAuthRedirectURL, - SettingKeyGitHubOAuthFrontendRedirectURL, - SettingKeyGoogleOAuthEnabled, - SettingKeyGoogleOAuthClientID, - SettingKeyGoogleOAuthClientSecret, - SettingKeyGoogleOAuthRedirectURL, - SettingKeyGoogleOAuthFrontendRedirectURL, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err) - } - cfg := s.effectiveEmailOAuthConfig(settings, provider) - if !cfg.Enabled { - return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") - } - if strings.TrimSpace(cfg.ClientID) == "" { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") - } - if strings.TrimSpace(cfg.ClientSecret) == "" { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") - } - for label, rawURL := range map[string]string{ - "authorize": cfg.AuthorizeURL, - "token": cfg.TokenURL, - "userinfo": cfg.UserInfoURL, - "redirect": cfg.RedirectURL, - } { - if strings.TrimSpace(rawURL) == "" { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured") - } - if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid") - } - } - if strings.TrimSpace(cfg.EmailsURL) != "" { - if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid") - } - } - if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") - } - return cfg, nil -} - -// IsRegistrationEnabled 检查是否开放注册 -func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) - if err != nil { - // 安全默认:如果设置不存在或查询出错,默认关闭注册 - return false - } - return value == "true" -} - -// IsBackendModeEnabled checks if backend mode is enabled -// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path -func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { - if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value - } - } - result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) { - if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value, nil - } - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout) - defer cancel() - value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - // Setting not yet created (fresh install) - default to disabled with full TTL - backendModeCache.Store(&cachedBackendMode{ - value: false, - expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), - }) - return false, nil - } - slog.Warn("failed to get backend_mode_enabled setting", "error", err) - backendModeCache.Store(&cachedBackendMode{ - value: false, - expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(), - }) - return false, nil - } - enabled := value == "true" - backendModeCache.Store(&cachedBackendMode{ - value: enabled, - expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), - }) - return enabled, nil - }) - if val, ok := result.(bool); ok { - return val - } - return false -} - -type gatewayForwardingSettingsResult struct { - fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool - clientDatelineNormalization bool - claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string -} - -func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult { - if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return gatewayForwardingSettingsResult{ - fp: cached.fingerprintUnification, - mp: cached.metadataPassthrough, - cch: cached.cchSigning, - claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, - claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, - claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, - cacheTTL1h: cached.anthropicCacheTTL1hInjection, - rewriteMessageCacheControl: cached.rewriteMessageCacheControl, - clientDatelineNormalization: cached.clientDatelineNormalization, - } - } - } - val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) { - if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return gatewayForwardingSettingsResult{ - fp: cached.fingerprintUnification, - mp: cached.metadataPassthrough, - cch: cached.cchSigning, - claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, - claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, - claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, - cacheTTL1h: cached.anthropicCacheTTL1hInjection, - rewriteMessageCacheControl: cached.rewriteMessageCacheControl, - clientDatelineNormalization: cached.clientDatelineNormalization, - }, nil - } - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) - defer cancel() - values, err := s.settingRepo.GetMultiple(dbCtx, []string{ - SettingKeyEnableFingerprintUnification, - SettingKeyEnableMetadataPassthrough, - SettingKeyEnableCCHSigning, - SettingKeyEnableClaudeOAuthSystemPromptInjection, - SettingKeyClaudeOAuthSystemPrompt, - SettingKeyClaudeOAuthSystemPromptBlocks, - SettingKeyEnableAnthropicCacheTTL1hInjection, - SettingKeyRewriteMessageCacheControl, - SettingKeyEnableClientDatelineNormalization, - }) - if err != nil { - slog.Warn("failed to get gateway forwarding settings", "error", err) - gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: true, - metadataPassthrough: false, - cchSigning: false, - claudeOAuthSystemPromptInjection: true, - anthropicCacheTTL1hInjection: false, - rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), - clientDatelineNormalization: true, - expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), - }) - return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil - } - fp := true - if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { - fp = v == "true" - } - mp := values[SettingKeyEnableMetadataPassthrough] == "true" - cch := values[SettingKeyEnableCCHSigning] == "true" - systemPromptInjection := true - if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { - systemPromptInjection = v == "true" - } - systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt] - systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks] - cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" - rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl() - if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" { - rewriteMessageCacheControl = v == "true" - } - clientDatelineNormalization := true - if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" { - clientDatelineNormalization = v == "true" - } - gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: fp, - metadataPassthrough: mp, - cchSigning: cch, - claudeOAuthSystemPromptInjection: systemPromptInjection, - claudeOAuthSystemPrompt: systemPrompt, - claudeOAuthSystemPromptBlocks: systemPromptBlocks, - anthropicCacheTTL1hInjection: cacheTTL1h, - rewriteMessageCacheControl: rewriteMessageCacheControl, - clientDatelineNormalization: clientDatelineNormalization, - expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), - }) - return gatewayForwardingSettingsResult{ - fp: fp, - mp: mp, - cch: cch, - claudeOAuthSystemPromptInjection: systemPromptInjection, - claudeOAuthSystemPrompt: systemPrompt, - claudeOAuthSystemPromptBlocks: systemPromptBlocks, - cacheTTL1h: cacheTTL1h, - rewriteMessageCacheControl: rewriteMessageCacheControl, - clientDatelineNormalization: clientDatelineNormalization, - }, nil - }) - if r, ok := val.(gatewayForwardingSettingsResult); ok { - return r - } - return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true} -} - -// GetGatewayForwardingSettings returns cached gateway forwarding settings. -// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path. -// Returns (fingerprintUnification, metadataPassthrough, cchSigning). -func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) { - result := s.getGatewayForwardingSettingsCached(ctx) - return result.fp, result.mp, result.cch -} - -// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。 -func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool { - return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h -} - -// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。 -func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool { - return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl -} - -// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体 -// 的客户端 dateline 归一化。默认开启。 -func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool { - return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization -} - -// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic -// system block switch, legacy custom expansion prompt, and configurable blocks JSON. -// Empty values mean use the built-in Claude Code default blocks. -func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) { - result := s.getGatewayForwardingSettingsCached(ctx) - return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks -} - -// IsEmailVerifyEnabled 检查是否开启邮件验证 -func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) - if err != nil { - return false - } - return value == "true" -} - -// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist. -func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string { - value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist) - if err != nil { - return []string{} - } - return ParseRegistrationEmailSuffixWhitelist(value) -} - -// 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" -} - -// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting. -func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string { - value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems) - if err != nil { - return "[]" - } - return value -} - -// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关) -func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled) - if err != nil { - return false // 默认关闭 - } - return value == "true" -} - -// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。 -// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错, -// 调用方只关心一个可用的数值。 -func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 { - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate) - if err != nil { - return AffiliateRebateRateDefault - } - rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) - if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) { - return AffiliateRebateRateDefault - } - return clampAffiliateRebateRate(rate) -} - -// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。 -// 返回 0 表示不冻结(向后兼容)。 -func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int { - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours) - if err != nil { - return AffiliateRebateFreezeHoursDefault - } - hours, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || hours < 0 { - return AffiliateRebateFreezeHoursDefault - } - if hours > AffiliateRebateFreezeHoursMax { - return AffiliateRebateFreezeHoursMax - } - return hours -} - -// GetAffiliateRebateDurationDays 返回返利有效期(天)。 -// 返回 0 表示永久有效。 -func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int { - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays) - if err != nil { - return AffiliateRebateDurationDaysDefault - } - days, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || days < 0 { - return AffiliateRebateDurationDaysDefault - } - if days > AffiliateRebateDurationDaysMax { - return AffiliateRebateDurationDaysMax - } - return days -} - -// GetAffiliateRebatePerInviteeCap 返回单人返利上限。 -// 返回 0 表示无上限。 -func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 { - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap) - if err != nil { - return AffiliateRebatePerInviteeCapDefault - } - cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) - if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) { - return AffiliateRebatePerInviteeCapDefault - } - return cap -} - -// 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 -} - -// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。 -func (s *SettingService) GetDefaultUserRPMLimit(ctx context.Context) int { - value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultUserRPMLimit) - if err != nil || value == "" { - return 0 - } - if v, err := strconv.Atoi(value); err == nil && v >= 0 { - return v - } - return 0 -} - -// GetDefaultSubscriptions 获取新用户默认订阅配置列表。 -func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting { - value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions) - if err != nil { - return nil - } - return parseDefaultSubscriptions(value) -} - -func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*AuthSourceDefaultSettings, error) { - keys := []string{ - SettingKeyAuthSourceDefaultEmailBalance, - SettingKeyAuthSourceDefaultEmailConcurrency, - SettingKeyAuthSourceDefaultEmailSubscriptions, - SettingKeyAuthSourceDefaultEmailGrantOnSignup, - SettingKeyAuthSourceDefaultEmailGrantOnFirstBind, - SettingKeyAuthSourceDefaultLinuxDoBalance, - SettingKeyAuthSourceDefaultLinuxDoConcurrency, - SettingKeyAuthSourceDefaultLinuxDoSubscriptions, - SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup, - SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind, - SettingKeyAuthSourceDefaultOIDCBalance, - SettingKeyAuthSourceDefaultOIDCConcurrency, - SettingKeyAuthSourceDefaultOIDCSubscriptions, - SettingKeyAuthSourceDefaultOIDCGrantOnSignup, - SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind, - SettingKeyAuthSourceDefaultWeChatBalance, - SettingKeyAuthSourceDefaultWeChatConcurrency, - SettingKeyAuthSourceDefaultWeChatSubscriptions, - SettingKeyAuthSourceDefaultWeChatGrantOnSignup, - SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind, - SettingKeyAuthSourceDefaultGitHubBalance, - SettingKeyAuthSourceDefaultGitHubConcurrency, - SettingKeyAuthSourceDefaultGitHubSubscriptions, - SettingKeyAuthSourceDefaultGitHubGrantOnSignup, - SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind, - SettingKeyAuthSourceDefaultGoogleBalance, - SettingKeyAuthSourceDefaultGoogleConcurrency, - SettingKeyAuthSourceDefaultGoogleSubscriptions, - SettingKeyAuthSourceDefaultGoogleGrantOnSignup, - SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, - SettingKeyAuthSourceDefaultDingTalkBalance, - SettingKeyAuthSourceDefaultDingTalkConcurrency, - SettingKeyAuthSourceDefaultDingTalkSubscriptions, - SettingKeyAuthSourceDefaultDingTalkGrantOnSignup, - SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, - SettingKeyAuthSourcePlatformQuotas("email"), - SettingKeyAuthSourcePlatformQuotas("linuxdo"), - SettingKeyAuthSourcePlatformQuotas("oidc"), - SettingKeyAuthSourcePlatformQuotas("wechat"), - SettingKeyAuthSourcePlatformQuotas("github"), - SettingKeyAuthSourcePlatformQuotas("google"), - SettingKeyAuthSourcePlatformQuotas("dingtalk"), - SettingKeyForceEmailOnThirdPartySignup, - } - - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return nil, fmt.Errorf("get auth source default settings: %w", err) - } - - return &AuthSourceDefaultSettings{ - Email: parseProviderDefaultGrantSettings(settings, emailAuthSourceDefaultKeys), - LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys), - OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys), - WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys), - GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys), - Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys), - DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys), - ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", - }, nil -} - -func (s *SettingService) ResolveAuthSourceGrantSettings(ctx context.Context, signupSource string, firstBind bool) (ProviderDefaultGrantSettings, bool, error) { - result := ProviderDefaultGrantSettings{ - Balance: s.GetDefaultBalance(ctx), - Concurrency: s.GetDefaultConcurrency(ctx), - Subscriptions: s.GetDefaultSubscriptions(ctx), - } - - defaults, err := s.GetAuthSourceDefaultSettings(ctx) - if err != nil { - return result, false, err - } - - providerDefaults, ok := authSourceSignupSettings(defaults, signupSource) - if !ok { - return result, false, nil - } - - enabled := providerDefaults.GrantOnSignup - if firstBind { - enabled = providerDefaults.GrantOnFirstBind - } - if !enabled { - return result, false, nil - } - - return mergeProviderDefaultGrantSettings(result, providerDefaults), true, nil -} - -func (s *SettingService) UpdateAuthSourceDefaultSettings(ctx context.Context, settings *AuthSourceDefaultSettings) error { - updates, err := s.buildAuthSourceDefaultUpdates(ctx, settings) - if err != nil { - return err - } - if len(updates) == 0 { - return nil - } - - if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { - return fmt.Errorf("update auth source default settings: %w", err) - } - return nil -} - -// 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) - } - - oidcUsePKCEDefault := true - oidcValidateIDTokenDefault := true - if s != nil && s.cfg != nil { - if s.cfg.OIDC.UsePKCEExplicit { - oidcUsePKCEDefault = s.cfg.OIDC.UsePKCE - } - if s.cfg.OIDC.ValidateIDTokenExplicit { - oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken - } - } - loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments()) - if err != nil { - return err - } - - // 初始化默认设置 - defaults := map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyEmailVerifyEnabled: "false", - SettingKeyRegistrationEmailSuffixWhitelist: "[]", - SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 - SettingKeyLoginAgreementEnabled: "false", - SettingKeyLoginAgreementMode: defaultLoginAgreementMode, - SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, - SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, - SettingKeyAPIKeyACLTrustForwardedIP: "false", - SettingKeySiteName: "Sub2API", - SettingKeySiteLogo: "", - SettingKeyPurchaseSubscriptionEnabled: "false", - SettingKeyPurchaseSubscriptionURL: "", - SettingKeyTableDefaultPageSize: "20", - SettingKeyTablePageSizeOptions: "[10,20,50,100]", - SettingKeyCustomMenuItems: "[]", - SettingKeyCustomEndpoints: "[]", - SettingKeyWeChatConnectEnabled: "false", - SettingKeyWeChatConnectAppID: "", - SettingKeyWeChatConnectAppSecret: "", - SettingKeyWeChatConnectOpenAppID: "", - SettingKeyWeChatConnectOpenAppSecret: "", - SettingKeyWeChatConnectMPAppID: "", - SettingKeyWeChatConnectMPAppSecret: "", - SettingKeyWeChatConnectMobileAppID: "", - SettingKeyWeChatConnectMobileAppSecret: "", - SettingKeyWeChatConnectOpenEnabled: "false", - SettingKeyWeChatConnectMPEnabled: "false", - SettingKeyWeChatConnectMobileEnabled: "false", - SettingKeyWeChatConnectMode: "open", - SettingKeyWeChatConnectScopes: "snsapi_login", - SettingKeyWeChatConnectRedirectURL: "", - SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, - SettingKeyGitHubOAuthEnabled: "false", - SettingKeyGitHubOAuthClientID: "", - SettingKeyGitHubOAuthClientSecret: "", - SettingKeyGitHubOAuthRedirectURL: "", - SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend, - SettingKeyGoogleOAuthEnabled: "false", - SettingKeyGoogleOAuthClientID: "", - SettingKeyGoogleOAuthClientSecret: "", - SettingKeyGoogleOAuthRedirectURL: "", - SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend, - SettingKeyOIDCConnectEnabled: "false", - SettingKeyOIDCConnectProviderName: "OIDC", - SettingKeyOIDCConnectClientID: "", - SettingKeyOIDCConnectClientSecret: "", - SettingKeyOIDCConnectIssuerURL: "", - SettingKeyOIDCConnectDiscoveryURL: "", - SettingKeyOIDCConnectAuthorizeURL: "", - SettingKeyOIDCConnectTokenURL: "", - SettingKeyOIDCConnectUserInfoURL: "", - SettingKeyOIDCConnectJWKSURL: "", - SettingKeyOIDCConnectScopes: "openid email profile", - SettingKeyOIDCConnectRedirectURL: "", - SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback", - SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post", - SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault), - SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault), - SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256", - SettingKeyOIDCConnectClockSkewSeconds: "120", - SettingKeyOIDCConnectRequireEmailVerified: "false", - SettingKeyOIDCConnectUserInfoEmailPath: "", - SettingKeyOIDCConnectUserInfoIDPath: "", - SettingKeyOIDCConnectUserInfoUsernamePath: "", - SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), - SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), - SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), - SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault), - SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault), - SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64), - SettingKeyDefaultUserRPMLimit: "0", - SettingKeyDefaultSubscriptions: "[]", - SettingKeyAuthSourceDefaultEmailBalance: "0", - SettingKeyAuthSourceDefaultEmailConcurrency: "5", - SettingKeyAuthSourceDefaultEmailSubscriptions: "[]", - SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false", - SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultLinuxDoBalance: "0", - SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5", - SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]", - SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false", - SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultOIDCBalance: "0", - SettingKeyAuthSourceDefaultOIDCConcurrency: "5", - SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]", - SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false", - SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultWeChatBalance: "0", - SettingKeyAuthSourceDefaultWeChatConcurrency: "5", - SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]", - SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false", - SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultGitHubBalance: "0", - SettingKeyAuthSourceDefaultGitHubConcurrency: "5", - SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]", - SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false", - SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultGoogleBalance: "0", - SettingKeyAuthSourceDefaultGoogleConcurrency: "5", - SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]", - SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false", - SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultDingTalkBalance: "0", - SettingKeyAuthSourceDefaultDingTalkConcurrency: "5", - SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]", - SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false", - SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false", - SettingKeyForceEmailOnThirdPartySignup: "false", - 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", - - // Channel monitor defaults (enabled, 60s) - SettingKeyChannelMonitorEnabled: "true", - SettingKeyChannelMonitorDefaultIntervalSeconds: "60", - - // Available channels feature (default disabled; opt-in) - SettingKeyAvailableChannelsEnabled: "false", - - // Affiliate (邀请返利) feature (default disabled; opt-in) - SettingKeyAffiliateEnabled: "false", - - // 风控中心功能(默认关闭,显式启用) - SettingKeyRiskControlEnabled: "false", - - // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) - SettingKeyCyberSessionBlockEnabled: "false", - SettingKeyCyberSessionBlockTTLSeconds: "3600", - - // Claude Code version check (default: empty = disabled) - SettingKeyMinClaudeCodeVersion: "", - SettingKeyMaxClaudeCodeVersion: "", - - // codex_cli_only 加固(默认:版本不检查、名单空、默认种子指纹信号) - SettingKeyMinCodexVersion: "", - SettingKeyMaxCodexVersion: "", - SettingKeyCodexCLIOnlyBlacklist: "", - SettingKeyCodexCLIOnlyWhitelist: "", - SettingKeyCodexCLIOnlyAllowAppServerClients: "false", - SettingKeyCodexCLIOnlyEngineFingerprintSignals: openai.DefaultEngineFingerprintSignalsJSON(), - - // 分组隔离(默认不允许未分组 Key 调度) - SettingKeyAllowUngroupedKeyScheduling: "false", - SettingKeyEnableAnthropicCacheTTL1hInjection: "false", - SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()), - SettingKeyEnableClientDatelineNormalization: "true", - SettingKeyAntigravityUserAgentVersion: "", - SettingKeyOpenAICodexUserAgent: "", - SettingPaymentVisibleMethodAlipaySource: "", - SettingPaymentVisibleMethodWxpaySource: "", - SettingPaymentVisibleMethodAlipayEnabled: "false", - SettingPaymentVisibleMethodWxpayEnabled: "false", - openAIAdvancedSchedulerSettingKey: "false", - SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false", - SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false", - SettingKeyOpenAIAdvancedSchedulerLBTopK: "", - SettingKeyOpenAIAdvancedSchedulerWeightPriority: "", - SettingKeyOpenAIAdvancedSchedulerWeightLoad: "", - SettingKeyOpenAIAdvancedSchedulerWeightQueue: "", - SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: "", - SettingKeyOpenAIAdvancedSchedulerWeightTTFT: "", - SettingKeyOpenAIAdvancedSchedulerWeightReset: "", - SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: "", - SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "", - SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "", - - SettingKeyAllowUserViewErrorRequests: "false", - } - - return s.settingRepo.SetMultiple(ctx, defaults) -} - -// parseSettings 解析设置到结构体 -func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { - emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" - loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) - loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) - if loginAgreementUpdatedAt == "" { - loginAgreementUpdatedAt = defaultLoginAgreementDate - } - apiKeyACLTrustForwardedIP := false - if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok { - apiKeyACLTrustForwardedIP = value == "true" - } else if s != nil && s.cfg != nil { - apiKeyACLTrustForwardedIP = s.cfg.Security.TrustForwardedIPForAPIKeyACL - } - result := &SystemSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: emailVerifyEnabled, - RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", - FrontendURL: settings[SettingKeyFrontendURL], - InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", - TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", - LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - 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] != "", - APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, - 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]), - CustomMenuItems: settings[SettingKeyCustomMenuItems], - CustomEndpoints: settings[SettingKeyCustomEndpoints], - BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", - } - result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences( - settings[SettingKeyTableDefaultPageSize], - settings[SettingKeyTablePageSizeOptions], - ) - - // 解析整数类型 - 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 rpm, err := strconv.Atoi(settings[SettingKeyDefaultUserRPMLimit]); err == nil && rpm >= 0 { - result.DefaultUserRPMLimit = rpm - } - - // 解析浮点数类型 - if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil { - result.DefaultBalance = balance - } else { - result.DefaultBalance = s.cfg.Default.UserBalance - } - if rebateRate, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebateRate], 64); err == nil { - result.AffiliateRebateRate = clampAffiliateRebateRate(rebateRate) - } else { - result.AffiliateRebateRate = AffiliateRebateRateDefault - } - if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 { - if freezeHours > AffiliateRebateFreezeHoursMax { - freezeHours = AffiliateRebateFreezeHoursMax - } - result.AffiliateRebateFreezeHours = freezeHours - } - if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 { - if durationDays > AffiliateRebateDurationDaysMax { - durationDays = AffiliateRebateDurationDaysMax - } - result.AffiliateRebateDurationDays = durationDays - } - if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 { - result.AffiliateRebatePerInviteeCap = perInviteeCap - } - result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) - - // 敏感信息直接返回,方便测试连接时使用 - 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 != "" - - // DingTalk Connect 设置: - // - 兼容 config.yaml/env - // - 支持后台系统设置覆盖并持久化(存储于 DB) - dingTalkBase := config.DingTalkConnectConfig{} - if s.cfg != nil { - dingTalkBase = s.cfg.DingTalk - } - - if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { - result.DingTalkConnectEnabled = raw == "true" - } else { - result.DingTalkConnectEnabled = dingTalkBase.Enabled - } - - if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectClientID = strings.TrimSpace(v) - } else { - result.DingTalkConnectClientID = dingTalkBase.ClientID - } - - if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectRedirectURL = strings.TrimSpace(v) - } else { - result.DingTalkConnectRedirectURL = dingTalkBase.RedirectURL - } - - result.DingTalkConnectClientSecret = strings.TrimSpace(settings[SettingKeyDingTalkConnectClientSecret]) - if result.DingTalkConnectClientSecret == "" { - result.DingTalkConnectClientSecret = strings.TrimSpace(dingTalkBase.ClientSecret) - } - result.DingTalkConnectClientSecretConfigured = result.DingTalkConnectClientSecret != "" - - if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(v) - } else { - result.DingTalkConnectCorpRestrictionPolicy = dingTalkBase.CorpRestrictionPolicy - } - result.DingTalkConnectCorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(result.DingTalkConnectCorpRestrictionPolicy) - - if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectInternalCorpID = strings.TrimSpace(v) - } else { - result.DingTalkConnectInternalCorpID = dingTalkBase.InternalCorpID - } - - if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") - } else { - result.DingTalkConnectBypassRegistration = dingTalkBase.BypassRegistration - } - // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, - // 以保证加载出的 effective config 永远是一致状态。 - if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { - result.DingTalkConnectBypassRegistration = false - } - - if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectSyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") - } else { - result.DingTalkConnectSyncCorpEmail = dingTalkBase.SyncCorpEmail - } - if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") - } else { - result.DingTalkConnectSyncDisplayName = dingTalkBase.SyncDisplayName - } - if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectSyncDept = strings.EqualFold(strings.TrimSpace(v), "true") - } else { - result.DingTalkConnectSyncDept = dingTalkBase.SyncDept - } - // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 - if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { - result.DingTalkConnectSyncCorpEmail = false - result.DingTalkConnectSyncDisplayName = false - result.DingTalkConnectSyncDept = false - } - - // 身份同步目标 attr key(DB 空 → fallback 默认值) - result.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]) - if result.DingTalkConnectSyncCorpEmailAttrKey == "" { - if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrKey); v != "" { - result.DingTalkConnectSyncCorpEmailAttrKey = v - } else { - result.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email" - } - } - result.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]) - if result.DingTalkConnectSyncDisplayNameAttrKey == "" { - if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrKey); v != "" { - result.DingTalkConnectSyncDisplayNameAttrKey = v - } else { - result.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name" - } - } - result.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]) - if result.DingTalkConnectSyncDeptAttrKey == "" { - if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrKey); v != "" { - result.DingTalkConnectSyncDeptAttrKey = v - } else { - result.DingTalkConnectSyncDeptAttrKey = "dingtalk_department" - } - } - - // 身份同步目标 attr 显示名称(DB 空 → fallback 默认中文) - result.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrName]) - if result.DingTalkConnectSyncCorpEmailAttrName == "" { - if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrName); v != "" { - result.DingTalkConnectSyncCorpEmailAttrName = v - } else { - result.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱" - } - } - result.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrName]) - if result.DingTalkConnectSyncDisplayNameAttrName == "" { - if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrName); v != "" { - result.DingTalkConnectSyncDisplayNameAttrName = v - } else { - result.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名" - } - } - result.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrName]) - if result.DingTalkConnectSyncDeptAttrName == "" { - if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrName); v != "" { - result.DingTalkConnectSyncDeptAttrName = v - } else { - result.DingTalkConnectSyncDeptAttrName = "钉钉部门" - } - } - - // Generic OIDC 设置: - // - 兼容 config.yaml/env - // - 支持后台系统设置覆盖并持久化(存储于 DB) - oidcBase := config.OIDCConnectConfig{} - if s.cfg != nil { - oidcBase = s.cfg.OIDC - } - - if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { - result.OIDCConnectEnabled = raw == "true" - } else { - result.OIDCConnectEnabled = oidcBase.Enabled - } - - if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectProviderName = strings.TrimSpace(v) - } else { - result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName) - } - if result.OIDCConnectProviderName == "" { - result.OIDCConnectProviderName = "OIDC" - } - - if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectClientID = strings.TrimSpace(v) - } else { - result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID) - } - if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectIssuerURL = strings.TrimSpace(v) - } else { - result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL) - } - if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectDiscoveryURL = strings.TrimSpace(v) - } else { - result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL) - } - if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectAuthorizeURL = strings.TrimSpace(v) - } else { - result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL) - } - if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectTokenURL = strings.TrimSpace(v) - } else { - result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectUserInfoURL = strings.TrimSpace(v) - } else { - result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL) - } - if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectJWKSURL = strings.TrimSpace(v) - } else { - result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL) - } - if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectScopes = strings.TrimSpace(v) - } else { - result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes) - } - if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectRedirectURL = strings.TrimSpace(v) - } else { - result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL) - } - if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v) - } else { - result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL) - } - if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) - } else { - result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod)) - } - if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { - result.OIDCConnectUsePKCE = raw == "true" - } else { - result.OIDCConnectUsePKCE = oidcUsePKCECompatibilityDefault(oidcBase) - } - if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { - result.OIDCConnectValidateIDToken = raw == "true" - } else { - result.OIDCConnectValidateIDToken = oidcValidateIDTokenCompatibilityDefault(oidcBase) - } - if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v) - } else { - result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs) - } - clockSkewSet := false - if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { - if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { - result.OIDCConnectClockSkewSeconds = parsed - clockSkewSet = true - } - } - if !clockSkewSet { - result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds - } - if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 { - result.OIDCConnectClockSkewSeconds = 120 - } - if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { - result.OIDCConnectRequireEmailVerified = raw == "true" - } else { - result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { - result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v) - } else { - result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { - result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v) - } else { - result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { - result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v) - } else { - result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath) - } - result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret]) - if result.OIDCConnectClientSecret == "" { - result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret) - } - result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" - - gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github") - result.GitHubOAuthEnabled = gitHubEffective.Enabled - result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID) - result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret) - result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != "" - result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL) - result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL) - - googleEffective := s.effectiveEmailOAuthConfig(settings, "google") - result.GoogleOAuthEnabled = googleEffective.Enabled - result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID) - result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret) - result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != "" - result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL) - result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL) - - // WeChat Connect 设置: - // - 优先读取 DB 系统设置 - // - 缺失时回退到 config/env,保持升级兼容 - weChatEffective := s.effectiveWeChatConnectOAuthConfig(settings) - result.WeChatConnectEnabled = weChatEffective.Enabled - result.WeChatConnectAppID = weChatEffective.LegacyAppID - result.WeChatConnectAppSecret = weChatEffective.LegacyAppSecret - result.WeChatConnectAppSecretConfigured = weChatEffective.LegacyAppSecret != "" - result.WeChatConnectOpenAppID = weChatEffective.OpenAppID - result.WeChatConnectOpenAppSecret = weChatEffective.OpenAppSecret - result.WeChatConnectOpenAppSecretConfigured = weChatEffective.OpenAppSecret != "" - result.WeChatConnectMPAppID = weChatEffective.MPAppID - result.WeChatConnectMPAppSecret = weChatEffective.MPAppSecret - result.WeChatConnectMPAppSecretConfigured = weChatEffective.MPAppSecret != "" - result.WeChatConnectMobileAppID = weChatEffective.MobileAppID - result.WeChatConnectMobileAppSecret = weChatEffective.MobileAppSecret - result.WeChatConnectMobileAppSecretConfigured = weChatEffective.MobileAppSecret != "" - result.WeChatConnectOpenEnabled = weChatEffective.OpenEnabled - result.WeChatConnectMPEnabled = weChatEffective.MPEnabled - result.WeChatConnectMobileEnabled = weChatEffective.MobileEnabled - result.WeChatConnectMode = weChatEffective.Mode - result.WeChatConnectScopes = weChatEffective.Scopes - result.WeChatConnectRedirectURL = weChatEffective.RedirectURL - result.WeChatConnectFrontendRedirectURL = weChatEffective.FrontendRedirectURL - - // 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 - } - } - - // Channel monitor feature (default: enabled, 60s) - result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]) - result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( - settings[SettingKeyChannelMonitorDefaultIntervalSeconds], - ) - - // Available channels feature (default: disabled; strict true) - result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" - - // Affiliate (邀请返利) feature (default: disabled; strict true) - result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true" - - // 风控中心功能(默认关闭,严格 true 才启用) - result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true" - - // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) - result.CyberSessionBlockEnabled = settings[SettingKeyCyberSessionBlockEnabled] == "true" - if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyCyberSessionBlockTTLSeconds])); err == nil && v > 0 { - result.CyberSessionBlockTTLSeconds = v - } else { - result.CyberSessionBlockTTLSeconds = 3600 - } - - // Claude Code version check - result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] - result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] - - // 分组隔离 - result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" - - // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, - // cch_signing=false, claude_oauth_system_prompt_injection=true) - if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" { - result.EnableFingerprintUnification = v == "true" - } else { - result.EnableFingerprintUnification = true // default: enabled (current behavior) - } - result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" - result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true" - if v, ok := settings[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { - result.EnableClaudeOAuthSystemPromptInjection = v == "true" - } else { - result.EnableClaudeOAuthSystemPromptInjection = true - } - result.ClaudeOAuthSystemPrompt = settings[SettingKeyClaudeOAuthSystemPrompt] - result.ClaudeOAuthSystemPromptBlocks = settings[SettingKeyClaudeOAuthSystemPromptBlocks] - result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" - if v, ok := settings[SettingKeyRewriteMessageCacheControl]; ok && v != "" { - result.RewriteMessageCacheControl = v == "true" - } else { - result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl() - } - if v, ok := settings[SettingKeyEnableClientDatelineNormalization]; ok && v != "" { - result.EnableClientDatelineNormalization = v == "true" - } else { - result.EnableClientDatelineNormalization = true - } - result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion]) - result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent]) - // codex_cli_only 加固 - result.MinCodexVersion = settings[SettingKeyMinCodexVersion] - result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] - result.CodexCLIOnlyBlacklist = settings[SettingKeyCodexCLIOnlyBlacklist] - result.CodexCLIOnlyWhitelist = settings[SettingKeyCodexCLIOnlyWhitelist] - result.CodexCLIOnlyAllowAppServerClients = settings[SettingKeyCodexCLIOnlyAllowAppServerClients] == "true" - if raw := strings.TrimSpace(settings[SettingKeyCodexCLIOnlyEngineFingerprintSignals]); raw != "" { - result.CodexCLIOnlyEngineFingerprintSignals = raw - } else { - result.CodexCLIOnlyEngineFingerprintSignals = openai.DefaultEngineFingerprintSignalsJSON() // 缺失/空 → 展示默认种子 - } - - // Web search emulation: quick enabled check from the JSON config - if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" { - var wsCfg WebSearchEmulationConfig - if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil { - result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0 - } - } - result.PaymentVisibleMethodAlipaySource = NormalizeVisibleMethodSource("alipay", settings[SettingPaymentVisibleMethodAlipaySource]) - result.PaymentVisibleMethodWxpaySource = NormalizeVisibleMethodSource("wxpay", settings[SettingPaymentVisibleMethodWxpaySource]) - result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true" - result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true" - result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true" - result.OpenAIAdvancedSchedulerStickyWeightedEnabled = settings[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] == "true" - result.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled = settings[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] == "true" - result.OpenAIAdvancedSchedulerLBTopK = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerLBTopK]) - result.OpenAIAdvancedSchedulerWeightPriority = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPriority]) - result.OpenAIAdvancedSchedulerWeightLoad = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightLoad]) - result.OpenAIAdvancedSchedulerWeightQueue = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQueue]) - result.OpenAIAdvancedSchedulerWeightErrorRate = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate]) - result.OpenAIAdvancedSchedulerWeightTTFT = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightTTFT]) - result.OpenAIAdvancedSchedulerWeightReset = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightReset]) - result.OpenAIAdvancedSchedulerWeightQuotaHeadroom = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom]) - result.OpenAIAdvancedSchedulerWeightPreviousResponse = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse]) - result.OpenAIAdvancedSchedulerWeightSessionSticky = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky]) - result.OpenAIAdvancedSchedulerEffectiveLBTopK = s.openAIAdvancedSchedulerEffectiveLBTopK() - effectiveWeights := s.openAIAdvancedSchedulerEffectiveWeights() - result.OpenAIAdvancedSchedulerEffectiveWeightPriority = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Priority) - result.OpenAIAdvancedSchedulerEffectiveWeightLoad = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Load) - result.OpenAIAdvancedSchedulerEffectiveWeightQueue = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Queue) - result.OpenAIAdvancedSchedulerEffectiveWeightErrorRate = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.ErrorRate) - result.OpenAIAdvancedSchedulerEffectiveWeightTTFT = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.TTFT) - result.OpenAIAdvancedSchedulerEffectiveWeightReset = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Reset) - result.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.QuotaHeadroom) - result.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.PreviousResponse) - result.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.SessionSticky) - - // 余额、订阅到期与账号限额通知 - result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" - if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { - result.BalanceLowNotifyThreshold = v - } - result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] - result.SubscriptionExpiryNotifyEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionExpiryNotifyEnabled]) - - // 账号限额通知 - result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true" - if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" { - result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw) - } - if result.AccountQuotaNotifyEmails == nil { - result.AccountQuotaNotifyEmails = []NotifyEmailEntry{} - } - - // 系统层默认 platform quota(修复 Bug B:parseSettings 不填充导致回显恒为 nil) - if raw := settings[SettingKeyDefaultPlatformQuotas]; raw != "" { - parsed := map[string]*DefaultPlatformQuotaSetting{} - if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - slog.Warn("[Setting] parseSettings: unmarshal default_platform_quotas failed", "error", err) - } else { - result.DefaultPlatformQuotas = parsed - } - } - - result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false - - return result -} - -func clampAffiliateRebateRate(value float64) float64 { - if math.IsNaN(value) || math.IsInf(value, 0) { - return AffiliateRebateRateDefault - } - if value < AffiliateRebateRateMin { - return AffiliateRebateRateMin - } - if value > AffiliateRebateRateMax { - return AffiliateRebateRateMax - } - return value -} - -func isFalseSettingValue(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "false", "0", "off", "disabled": - return true - default: - return false - } -} - -func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) { - _ = enabled - source = strings.TrimSpace(source) - if source == "" { - return "", nil - } - - normalized := NormalizeVisibleMethodSource(method, source) - if normalized == "" { - return "", infraerrors.BadRequest( - "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", - fmt.Sprintf("%s source must be one of the supported payment providers", method), - ) - } - return normalized, nil -} - -func (s *SettingService) openAIAdvancedSchedulerEffectiveLBTopK() string { - if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.LBTopK > 0 { - return strconv.Itoa(s.cfg.Gateway.OpenAIWS.LBTopK) - } - return "7" -} - -func (s *SettingService) openAIAdvancedSchedulerEffectiveWeights() config.GatewayOpenAIWSSchedulerScoreWeights { - defaults := config.GatewayOpenAIWSSchedulerScoreWeights{ - Priority: 1.0, - Load: 1.0, - Queue: 0.7, - ErrorRate: 0.8, - TTFT: 0.5, - Reset: 0.0, - QuotaHeadroom: 0.0, - PreviousResponse: 5.0, - SessionSticky: 3.0, - } - if s == nil || s.cfg == nil { - return defaults - } - - weights := s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights - baseSum := weights.Priority + weights.Load + weights.Queue + weights.ErrorRate + weights.TTFT + weights.QuotaHeadroom - if baseSum <= 0 { - return defaults - } - return weights -} - -func formatOpenAIAdvancedSchedulerFloat(value float64) string { - return strconv.FormatFloat(value, 'f', -1, 64) -} - -func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error { - lbTopK, err := normalizeOptionalPositiveIntString(settings.OpenAIAdvancedSchedulerLBTopK) - if err != nil { - return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_LB_TOP_K", "openai advanced scheduler TopK must be a positive integer or empty") - } - settings.OpenAIAdvancedSchedulerLBTopK = lbTopK - - weights := []*string{ - &settings.OpenAIAdvancedSchedulerWeightPriority, - &settings.OpenAIAdvancedSchedulerWeightLoad, - &settings.OpenAIAdvancedSchedulerWeightQueue, - &settings.OpenAIAdvancedSchedulerWeightErrorRate, - &settings.OpenAIAdvancedSchedulerWeightTTFT, - &settings.OpenAIAdvancedSchedulerWeightReset, - &settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, - &settings.OpenAIAdvancedSchedulerWeightPreviousResponse, - &settings.OpenAIAdvancedSchedulerWeightSessionSticky, - } - for _, target := range weights { - normalized, err := normalizeOptionalNonNegativeFloatString(*target) - if err != nil { - return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler weights must be non-negative numbers or empty") - } - *target = normalized - } - - // 与 config.Validate 的 "scheduler_score_weights must not all be zero" 保持一致: - // 覆盖值(空则回退到生效的配置值)叠加后的基础权重和不允许为 0, - // 否则调度会静默退化为 TopK 内均匀随机。 - effective := s.openAIAdvancedSchedulerEffectiveWeights() - baseSum := resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPriority, effective.Priority) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightLoad, effective.Load) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQueue, effective.Queue) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightErrorRate, effective.ErrorRate) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightTTFT, effective.TTFT) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, effective.QuotaHeadroom) - if baseSum <= 0 { - return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler base weights must not all be zero") - } - return nil -} - -// resolveOpenAIAdvancedSchedulerWeight 返回覆盖值(已归一化的非空字符串),空则回退默认值。 -func resolveOpenAIAdvancedSchedulerWeight(normalized string, fallback float64) float64 { - if normalized == "" { - return fallback - } - value, err := strconv.ParseFloat(normalized, 64) - if err != nil { - return fallback - } - return value -} - -func normalizeOptionalPositiveIntString(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", nil - } - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - return "", fmt.Errorf("invalid positive integer") - } - return strconv.Itoa(value), nil -} - -func normalizeOptionalNonNegativeFloatString(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", nil - } - value, err := strconv.ParseFloat(raw, 64) - if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { - return "", fmt.Errorf("invalid non-negative float") - } - return strconv.FormatFloat(value, 'f', -1, 64), nil -} - -func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - - var items []DefaultSubscriptionSetting - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil - } - - normalized := make([]DefaultSubscriptionSetting, 0, len(items)) - for _, item := range items { - if item.GroupID <= 0 || item.ValidityDays <= 0 { - continue - } - if item.ValidityDays > MaxValidityDays { - item.ValidityDays = MaxValidityDays - } - normalized = append(normalized, item) - } - - return normalized -} - -func parseProviderDefaultGrantSettings(settings map[string]string, keys authSourceDefaultKeySet) ProviderDefaultGrantSettings { - result := ProviderDefaultGrantSettings{ - Balance: defaultAuthSourceBalance, - Concurrency: defaultAuthSourceConcurrency, - Subscriptions: []DefaultSubscriptionSetting{}, - GrantOnSignup: false, - GrantOnFirstBind: false, - } - - if v, err := strconv.ParseFloat(strings.TrimSpace(settings[keys.balance]), 64); err == nil { - result.Balance = v - } - if v, err := strconv.Atoi(strings.TrimSpace(settings[keys.concurrency])); err == nil { - result.Concurrency = v - } - if items := parseDefaultSubscriptions(settings[keys.subscriptions]); items != nil { - result.Subscriptions = items - } - if raw, ok := settings[keys.grantOnSignup]; ok { - result.GrantOnSignup = raw == "true" - } - if raw, ok := settings[keys.grantOnFirstBind]; ok { - result.GrantOnFirstBind = raw == "true" - } - - if raw := settings[keys.platformQuotas]; raw != "" { - parsed := map[string]*DefaultPlatformQuotaSetting{} - if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - slog.Warn("[Setting] parseProviderDefaultGrantSettings: unmarshal auth source platform quotas failed", "source", keys.source, "error", err) - } else { - result.PlatformQuotas = parsed - } - } - - return result -} - -func writeProviderDefaultGrantUpdates(updates map[string]string, keys authSourceDefaultKeySet, settings ProviderDefaultGrantSettings) { - updates[keys.balance] = strconv.FormatFloat(settings.Balance, 'f', 8, 64) - updates[keys.concurrency] = strconv.Itoa(settings.Concurrency) - - subscriptions := settings.Subscriptions - if subscriptions == nil { - subscriptions = []DefaultSubscriptionSetting{} - } - raw, err := json.Marshal(subscriptions) - if err != nil { - raw = []byte("[]") - } - updates[keys.subscriptions] = string(raw) - updates[keys.grantOnSignup] = strconv.FormatBool(settings.GrantOnSignup) - updates[keys.grantOnFirstBind] = strconv.FormatBool(settings.GrantOnFirstBind) - - // auth source platform quota:整体替换语义。 - // nil = 请求未携带该字段,跳过写入以保留既有配置(与系统层 buildSystemSettingsUpdates 的 - // DefaultPlatformQuotas nil 守卫一致);非 nil(含空 map)才整体替换。二者语义不可混同。 - if keys.platformQuotas != "" && settings.PlatformQuotas != nil { - blob, err := json.Marshal(settings.PlatformQuotas) - if err != nil { - blob = []byte("{}") - } - updates[keys.platformQuotas] = string(blob) - } -} - -func mergeProviderDefaultGrantSettings(globalDefaults ProviderDefaultGrantSettings, providerDefaults ProviderDefaultGrantSettings) ProviderDefaultGrantSettings { - result := ProviderDefaultGrantSettings{ - Balance: globalDefaults.Balance, - Concurrency: globalDefaults.Concurrency, - Subscriptions: append([]DefaultSubscriptionSetting(nil), globalDefaults.Subscriptions...), - GrantOnSignup: providerDefaults.GrantOnSignup, - GrantOnFirstBind: providerDefaults.GrantOnFirstBind, - } - - // 注意:不能把 parse 默认值 (defaultAuthSourceBalance / defaultAuthSourceConcurrency) - // 当作"未配置"哨兵——admin 完全有权显式设成相同的值,那时仍应覆盖 globalDefaults。 - // 旧实现的 `!= defaultAuthSourceConcurrency` 会把 admin 设的 5 与 fallback 5 混淆, - // 导致渠道发放退回到全局默认(如 1),表现为"管理员设 5、新用户实际拿 1"。 - if providerDefaults.Balance >= 0 { - result.Balance = providerDefaults.Balance - } - if providerDefaults.Concurrency > 0 { - result.Concurrency = providerDefaults.Concurrency - } - if len(providerDefaults.Subscriptions) > 0 { - result.Subscriptions = append([]DefaultSubscriptionSetting(nil), providerDefaults.Subscriptions...) - } - - return result -} - -func parseTablePreferences(defaultPageSizeRaw, optionsRaw string) (int, []int) { - defaultPageSize := 20 - if v, err := strconv.Atoi(strings.TrimSpace(defaultPageSizeRaw)); err == nil { - defaultPageSize = v - } - - var options []int - if strings.TrimSpace(optionsRaw) != "" { - _ = json.Unmarshal([]byte(optionsRaw), &options) - } - - return normalizeTablePreferences(defaultPageSize, options) -} - -func normalizeTablePreferences(defaultPageSize int, options []int) (int, []int) { - const minPageSize = 5 - const maxPageSize = 1000 - const fallbackPageSize = 20 - - seen := make(map[int]struct{}, len(options)) - normalizedOptions := make([]int, 0, len(options)) - for _, option := range options { - if option < minPageSize || option > maxPageSize { - continue - } - if _, ok := seen[option]; ok { - continue - } - seen[option] = struct{}{} - normalizedOptions = append(normalizedOptions, option) - } - sort.Ints(normalizedOptions) - - if defaultPageSize < minPageSize || defaultPageSize > maxPageSize { - defaultPageSize = fallbackPageSize - } - - if len(normalizedOptions) == 0 { - normalizedOptions = []int{10, 20, 50} - } - - return defaultPageSize, normalizedOptions -} - // getStringOrDefault 获取字符串值或默认值 func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string { if value, ok := settings[key]; ok && value != "" { @@ -4185,1287 +261,3 @@ func (s *SettingService) getStringOrDefault(settings map[string]string, key, def } 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": - default: - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") - } - - return effective, nil -} - -// GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。 -// -// 优先级: -// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 -// - 否则回退到 config.yaml/env 的值 -func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) { - if s == nil || s.cfg == nil { - return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") - } - - effective := s.cfg.DingTalk - - keys := []string{ - SettingKeyDingTalkConnectEnabled, - SettingKeyDingTalkConnectClientID, - SettingKeyDingTalkConnectClientSecret, - SettingKeyDingTalkConnectRedirectURL, - SettingKeyDingTalkConnectCorpRestrictionPolicy, - SettingKeyDingTalkConnectInternalCorpID, - SettingKeyDingTalkConnectBypassRegistration, - SettingKeyDingTalkConnectSyncCorpEmail, - SettingKeyDingTalkConnectSyncDisplayName, - SettingKeyDingTalkConnectSyncDept, - SettingKeyDingTalkConnectSyncCorpEmailAttrKey, - SettingKeyDingTalkConnectSyncDisplayNameAttrKey, - SettingKeyDingTalkConnectSyncDeptAttrKey, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err) - } - - if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { - effective.Enabled = raw == "true" - } - if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { - effective.ClientID = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" { - effective.ClientSecret = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - effective.RedirectURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { - effective.CorpRestrictionPolicy = strings.TrimSpace(v) - } - effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy) - if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { - effective.InternalCorpID = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { - effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") - } - // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, - // 以保证 OAuth callback 看到的 effective config 永远是一致状态。 - if effective.CorpRestrictionPolicy != "internal_only" { - effective.BypassRegistration = false - } - - if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { - effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") - } - if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { - effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") - } - if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { - effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true") - } - // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 - if effective.CorpRestrictionPolicy != "internal_only" { - effective.SyncCorpEmail = false - effective.SyncDisplayName = false - effective.SyncDept = false - } - - // 身份同步目标 attr key(DB 空 → fallback 默认值) - if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" { - effective.SyncCorpEmailAttrKey = v - } - if effective.SyncCorpEmailAttrKey == "" { - effective.SyncCorpEmailAttrKey = "dingtalk_email" - } - if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" { - effective.SyncDisplayNameAttrKey = v - } - if effective.SyncDisplayNameAttrKey == "" { - effective.SyncDisplayNameAttrKey = "dingtalk_name" - } - if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" { - effective.SyncDeptAttrKey = v - } - if effective.SyncDeptAttrKey == "" { - effective.SyncDeptAttrKey = "dingtalk_department" - } - - if !effective.Enabled { - return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled") - } - - // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 - if strings.TrimSpace(effective.ClientID) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured") - } - if strings.TrimSpace(effective.AuthorizeURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured") - } - if strings.TrimSpace(effective.TokenURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured") - } - if strings.TrimSpace(effective.UserInfoURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured") - } - if strings.TrimSpace(effective.RedirectURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured") - } - if strings.TrimSpace(effective.FrontendRedirectURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured") - } - - if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid") - } - if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid") - } - if strings.TrimSpace(effective.ClientSecret) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured") - } - - // 镜像 admin handler 行为:internal_only policy 隐式要求 AppType=internal - if effective.CorpRestrictionPolicy == "internal_only" { - effective.AppType = "internal" - } - - if err := config.ValidateDingTalkConfig(effective); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error()) - } - - return effective, nil -} - -// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。 -// -// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。 -func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) { - keys := []string{ - SettingKeyWeChatConnectEnabled, - SettingKeyWeChatConnectAppID, - SettingKeyWeChatConnectAppSecret, - SettingKeyWeChatConnectOpenAppID, - SettingKeyWeChatConnectOpenAppSecret, - SettingKeyWeChatConnectMPAppID, - SettingKeyWeChatConnectMPAppSecret, - SettingKeyWeChatConnectMobileAppID, - SettingKeyWeChatConnectMobileAppSecret, - SettingKeyWeChatConnectOpenEnabled, - SettingKeyWeChatConnectMPEnabled, - SettingKeyWeChatConnectMobileEnabled, - SettingKeyWeChatConnectMode, - SettingKeyWeChatConnectScopes, - SettingKeyWeChatConnectRedirectURL, - SettingKeyWeChatConnectFrontendRedirectURL, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err) - } - return s.parseWeChatConnectOAuthConfig(settings) -} - -// GetOverloadCooldownSettings 获取529过载冷却配置 -func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultOverloadCooldownSettings(), nil - } - return nil, fmt.Errorf("get overload cooldown settings: %w", err) - } - if value == "" { - return DefaultOverloadCooldownSettings(), nil - } - - var settings OverloadCooldownSettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultOverloadCooldownSettings(), nil - } - - // 修正配置值范围 - if settings.CooldownMinutes < 1 { - settings.CooldownMinutes = 1 - } - if settings.CooldownMinutes > 120 { - settings.CooldownMinutes = 120 - } - - return &settings, nil -} - -// SetOverloadCooldownSettings 设置529过载冷却配置 -func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - // 禁用时修正为合法值即可,不拒绝请求 - if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 { - if settings.Enabled { - return fmt.Errorf("cooldown_minutes must be between 1-120") - } - settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值 - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal overload cooldown settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) -} - -// GetRateLimit429CooldownSettings 获取429默认回避配置 -func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultRateLimit429CooldownSettings(), nil - } - return nil, fmt.Errorf("get 429 cooldown settings: %w", err) - } - if value == "" { - return DefaultRateLimit429CooldownSettings(), nil - } - - var settings RateLimit429CooldownSettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultRateLimit429CooldownSettings(), nil - } - - if settings.CooldownSeconds < 1 { - settings.CooldownSeconds = 1 - } - if settings.CooldownSeconds > 7200 { - settings.CooldownSeconds = 7200 - } - - return &settings, nil -} - -// SetRateLimit429CooldownSettings 设置429默认回避配置 -func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 { - if settings.Enabled { - return fmt.Errorf("cooldown_seconds must be between 1-7200") - } - settings.CooldownSeconds = 5 - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal 429 cooldown settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data)) -} - -// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 -// -// 优先级: -// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 -// - 否则回退到 config.yaml/env 的值 -func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { - if s == nil || s.cfg == nil { - return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") - } - - effective := s.cfg.OIDC - - keys := []string{ - SettingKeyOIDCConnectEnabled, - SettingKeyOIDCConnectProviderName, - SettingKeyOIDCConnectClientID, - SettingKeyOIDCConnectClientSecret, - SettingKeyOIDCConnectIssuerURL, - SettingKeyOIDCConnectDiscoveryURL, - SettingKeyOIDCConnectAuthorizeURL, - SettingKeyOIDCConnectTokenURL, - SettingKeyOIDCConnectUserInfoURL, - SettingKeyOIDCConnectJWKSURL, - SettingKeyOIDCConnectScopes, - SettingKeyOIDCConnectRedirectURL, - SettingKeyOIDCConnectFrontendRedirectURL, - SettingKeyOIDCConnectTokenAuthMethod, - SettingKeyOIDCConnectUsePKCE, - SettingKeyOIDCConnectValidateIDToken, - SettingKeyOIDCConnectAllowedSigningAlgs, - SettingKeyOIDCConnectClockSkewSeconds, - SettingKeyOIDCConnectRequireEmailVerified, - SettingKeyOIDCConnectUserInfoEmailPath, - SettingKeyOIDCConnectUserInfoIDPath, - SettingKeyOIDCConnectUserInfoUsernamePath, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err) - } - - if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { - effective.Enabled = raw == "true" - } - if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { - effective.ProviderName = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { - effective.ClientID = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" { - effective.ClientSecret = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { - effective.IssuerURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { - effective.DiscoveryURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { - effective.AuthorizeURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { - effective.TokenURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { - effective.UserInfoURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { - effective.JWKSURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { - effective.Scopes = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - effective.RedirectURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { - effective.FrontendRedirectURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { - effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) - } - if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { - effective.UsePKCE = raw == "true" - } else { - effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective) - } - if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { - effective.ValidateIDToken = raw == "true" - } else { - effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective) - } - if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { - effective.AllowedSigningAlgs = strings.TrimSpace(v) - } - if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { - if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil { - effective.ClockSkewSeconds = parsed - } - } - if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { - effective.RequireEmailVerified = raw == "true" - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { - effective.UserInfoEmailPath = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { - effective.UserInfoIDPath = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { - effective.UserInfoUsernamePath = strings.TrimSpace(v) - } - - if !effective.Enabled { - return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") - } - if strings.TrimSpace(effective.ProviderName) == "" { - effective.ProviderName = "OIDC" - } - if strings.TrimSpace(effective.ClientID) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") - } - if strings.TrimSpace(effective.IssuerURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured") - } - if strings.TrimSpace(effective.RedirectURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") - } - if strings.TrimSpace(effective.FrontendRedirectURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") - } - if !scopesContainOpenID(effective.Scopes) { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid") - } - if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600") - } - - if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid") - } - - discoveryURL := strings.TrimSpace(effective.DiscoveryURL) - if discoveryURL == "" { - discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL) - effective.DiscoveryURL = discoveryURL - } - if discoveryURL != "" { - if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid") - } - } - - needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" || - strings.TrimSpace(effective.TokenURL) == "" || - (effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "") - if needsDiscovery && discoveryURL != "" { - metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL) - if resolveErr != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr) - } - if strings.TrimSpace(effective.AuthorizeURL) == "" { - effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint) - } - if strings.TrimSpace(effective.TokenURL) == "" { - effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint) - } - if strings.TrimSpace(effective.UserInfoURL) == "" { - effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint) - } - if strings.TrimSpace(effective.JWKSURL) == "" { - effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI) - } - } - - if strings.TrimSpace(effective.AuthorizeURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") - } - if strings.TrimSpace(effective.TokenURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") - } - if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") - } - if v := strings.TrimSpace(effective.UserInfoURL); v != "" { - if err := config.ValidateAbsoluteHTTPURL(v); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") - } - } - if effective.ValidateIDToken { - if strings.TrimSpace(effective.JWKSURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured") - } - if strings.TrimSpace(effective.AllowedSigningAlgs) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured") - } - } - if v := strings.TrimSpace(effective.JWKSURL); v != "" { - if err := config.ValidateAbsoluteHTTPURL(v); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid") - } - } - if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") - } - if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { - return config.OIDCConnectConfig{}, 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.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") - } - case "none": - default: - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") - } - - return effective, nil -} - -func scopesContainOpenID(scopes string) bool { - for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { - if scope == "openid" { - return true - } - } - return false -} - -type oidcProviderMetadata struct { - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - UserInfoEndpoint string `json:"userinfo_endpoint"` - JWKSURI string `json:"jwks_uri"` -} - -func oidcDefaultDiscoveryURL(issuerURL string) string { - issuerURL = strings.TrimSpace(issuerURL) - if issuerURL == "" { - return "" - } - return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" -} - -func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) { - discoveryURL = strings.TrimSpace(discoveryURL) - if discoveryURL == "" { - return nil, fmt.Errorf("discovery url is empty") - } - - resp, err := req.C(). - SetTimeout(15*time.Second). - R(). - SetContext(ctx). - SetHeader("Accept", "application/json"). - Get(discoveryURL) - if err != nil { - return nil, fmt.Errorf("request discovery document: %w", err) - } - if !resp.IsSuccessState() { - return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode) - } - - metadata := &oidcProviderMetadata{} - if err := json.Unmarshal(resp.Bytes(), metadata); err != nil { - return nil, fmt.Errorf("parse discovery document: %w", err) - } - return metadata, 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 -} - -// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度 -func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling) - if err != nil { - return false // fail-closed: 查询失败时默认不允许 - } - return value == "true" -} - -// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求 -// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销 -// singleflight 防止缓存过期时 thundering herd -// 返回空字符串表示不做对应方向的版本检查 -func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) { - if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { - if time.Now().UnixNano() < cached.expiresAt { - return cached.min, cached.max - } - } - // singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果 - type bounds struct{ min, max string } - result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) { - // 二次检查,避免排队的 goroutine 重复查询 - if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { - if time.Now().UnixNano() < cached.expiresAt { - return bounds{cached.min, cached.max}, nil - } - } - // 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存 - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout) - defer cancel() - values, err := s.settingRepo.GetMultiple(dbCtx, []string{ - SettingKeyMinClaudeCodeVersion, - SettingKeyMaxClaudeCodeVersion, - }) - if err != nil { - // fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试 - slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err) - versionBoundsCache.Store(&cachedVersionBounds{ - min: "", - max: "", - expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(), - }) - return bounds{"", ""}, nil - } - b := bounds{ - min: values[SettingKeyMinClaudeCodeVersion], - max: values[SettingKeyMaxClaudeCodeVersion], - } - versionBoundsCache.Store(&cachedVersionBounds{ - min: b.min, - max: b.max, - expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), - }) - return b, nil - }) - if err != nil { - return "", "" - } - b, ok := result.(bounds) - if !ok { - return "", "" - } - return b.min, b.max -} - -// GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause -// settings. It is invoked on the OpenAI scheduling hot path (once per request) and is -// therefore designed to never block on the DB: -// -// - Fresh cached value → returned immediately. -// - Stale or empty cache → the last known value is returned, and a background -// goroutine refreshes the cache via singleflight (stale-while-revalidate). -// - First call with no cache yet → zero defaults are returned and the same async -// refresh is kicked off; the next call gets the freshly populated value. -// -// Callers that need the freshly persisted value synchronously (tests, post-update -// confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings. -func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { - if s == nil { - return OpsOpenAIAccountQuotaAutoPauseSettings{} - } - cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings) - now := time.Now().UnixNano() - if cached != nil && now < cached.expiresAt { - return cached.settings - } - // Stale or unset: trigger background refresh without blocking this request. - // singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the - // returned channel — the result is observable via the atomic cache. - s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) { - s.refreshOpenAIQuotaAutoPauseSettings(context.Background()) - return nil, nil - }) - if cached != nil { - return cached.settings // serve stale value while revalidating - } - return OpsOpenAIAccountQuotaAutoPauseSettings{} -} - -// WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings -// into the in-memory cache. Useful for application startup (so the first request hits -// a warm cache) and for tests that need deterministic reads immediately after -// constructing the service. -func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { - if s == nil { - return OpsOpenAIAccountQuotaAutoPauseSettings{} - } - s.refreshOpenAIQuotaAutoPauseSettings(ctx) - cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings) - if cached == nil { - return OpsOpenAIAccountQuotaAutoPauseSettings{} - } - return cached.settings -} - -// refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores -// them into the in-memory cache. On error it stores the prior value (or zero defaults -// if nothing is cached yet) with the shorter error TTL so the next refresh comes -// sooner. Always uses its own timeout-bounded context to keep refresh latency -// predictable regardless of the caller. -func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) { - if s == nil || s.settingRepo == nil { - return - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout) - defer cancel() - - settings := OpsOpenAIAccountQuotaAutoPauseSettings{} - ttl := openAIQuotaAutoPauseSettingsCacheTTL - raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings) - if err == nil { - cfg := defaultOpsAdvancedSettings() - if strings.TrimSpace(raw) != "" { - if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil { - normalizeOpsAdvancedSettings(cfg) - } - } - settings = cfg.OpenAIAccountQuotaAutoPause - } else if !errors.Is(err, ErrSettingNotFound) { - // Real error: keep serving prior value but refresh sooner. - if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil { - settings = prior.settings - } - ttl = openAIQuotaAutoPauseSettingsErrorTTL - } - - s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ - settings: settings, - expiresAt: time.Now().Add(ttl).UnixNano(), - }) -} - -// SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory -// cache. Called from settings-write code paths so that the next read reflects the new -// value immediately, without waiting for the background refresh. -func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) { - if s == nil { - return - } - s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ - settings: settings, - expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(), - }) -} - -// GetRectifierSettings 获取请求整流器配置 -func (s *SettingService) GetRectifierSettings(ctx context.Context) (*RectifierSettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyRectifierSettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultRectifierSettings(), nil - } - return nil, fmt.Errorf("get rectifier settings: %w", err) - } - if value == "" { - return DefaultRectifierSettings(), nil - } - - var settings RectifierSettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultRectifierSettings(), nil - } - - return &settings, nil -} - -// SetRectifierSettings 设置请求整流器配置 -func (s *SettingService) SetRectifierSettings(ctx context.Context, settings *RectifierSettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal rectifier settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyRectifierSettings, string(data)) -} - -// IsSignatureRectifierEnabled 判断签名整流是否启用(总开关 && 签名子开关) -func (s *SettingService) IsSignatureRectifierEnabled(ctx context.Context) bool { - settings, err := s.GetRectifierSettings(ctx) - if err != nil { - return true // fail-open: 查询失败时默认启用 - } - return settings.Enabled && settings.ThinkingSignatureEnabled -} - -// IsBudgetRectifierEnabled 判断 Budget 整流是否启用(总开关 && Budget 子开关) -func (s *SettingService) IsBudgetRectifierEnabled(ctx context.Context) bool { - settings, err := s.GetRectifierSettings(ctx) - if err != nil { - return true // fail-open: 查询失败时默认启用 - } - return settings.Enabled && settings.ThinkingBudgetEnabled -} - -// GetBetaPolicySettings 获取 Beta 策略配置 -func (s *SettingService) GetBetaPolicySettings(ctx context.Context) (*BetaPolicySettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyBetaPolicySettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultBetaPolicySettings(), nil - } - return nil, fmt.Errorf("get beta policy settings: %w", err) - } - if value == "" { - return DefaultBetaPolicySettings(), nil - } - - var settings BetaPolicySettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultBetaPolicySettings(), nil - } - - return &settings, nil -} - -// SetBetaPolicySettings 设置 Beta 策略配置 -func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *BetaPolicySettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - validActions := map[string]bool{ - BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, - } - validScopes := map[string]bool{ - BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, - } - - for i, rule := range settings.Rules { - if rule.BetaToken == "" { - return fmt.Errorf("rule[%d]: beta_token cannot be empty", i) - } - if !validActions[rule.Action] { - return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) - } - if !validScopes[rule.Scope] { - return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) - } - // Validate model_whitelist patterns - for j, pattern := range rule.ModelWhitelist { - trimmed := strings.TrimSpace(pattern) - if trimmed == "" { - return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) - } - settings.Rules[i].ModelWhitelist[j] = trimmed - } - // Validate fallback_action - if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { - return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) - } - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal beta policy settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyBetaPolicySettings, string(data)) -} - -// GetOpenAIFastPolicySettings 获取 OpenAI fast 策略配置 -func (s *SettingService) GetOpenAIFastPolicySettings(ctx context.Context) (*OpenAIFastPolicySettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIFastPolicySettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultOpenAIFastPolicySettings(), nil - } - return nil, fmt.Errorf("get openai fast policy settings: %w", err) - } - if value == "" { - return DefaultOpenAIFastPolicySettings(), nil - } - - var settings OpenAIFastPolicySettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - // JSON 损坏时静默 fallback 到默认配置会让策略意外失效(管理员配 - // 置的 block/filter 规则被忽略)。记录 Warn 让运维能在出现异常 - // 行为时定位到 settings 表里的脏数据。 - slog.Warn("failed to unmarshal openai fast policy settings, falling back to defaults", - "error", err, - "key", SettingKeyOpenAIFastPolicySettings) - return DefaultOpenAIFastPolicySettings(), nil - } - - return &settings, nil -} - -// SetOpenAIFastPolicySettings 设置 OpenAI fast 策略配置 -func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settings *OpenAIFastPolicySettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - validActions := map[string]bool{ - BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, - OpenAIFastPolicyActionForcePriority: true, - } - validScopes := map[string]bool{ - BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, - } - validTiers := map[string]bool{ - OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true, - } - - for i, rule := range settings.Rules { - tier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) - if tier == "" { - tier = OpenAIFastTierAny - } - if !validTiers[tier] { - return fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier) - } - settings.Rules[i].ServiceTier = tier - if !validActions[rule.Action] { - return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) - } - if !validScopes[rule.Scope] { - return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) - } - for j, pattern := range rule.ModelWhitelist { - trimmed := strings.TrimSpace(pattern) - if trimmed == "" { - return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) - } - settings.Rules[i].ModelWhitelist[j] = trimmed - } - if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { - return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) - } - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal openai fast policy settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, string(data)) -} - -// 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)) -} - -// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回全部允许平台 x 3 window 的设置。 -// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。 -// -// 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。 -// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。 -func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) { - out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms)) - for _, platform := range AllowedQuotaPlatforms { - out[platform] = &DefaultPlatformQuotaSetting{} - } - raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas) - if err != nil || raw == "" { - return out, nil // 无配置 = 全部不限制 - } - parsed := map[string]*DefaultPlatformQuotaSetting{} - if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - slog.Warn("[Setting] unmarshal default_platform_quotas failed (fail-open)", "error", err) - return out, nil - } - for _, platform := range AllowedQuotaPlatforms { - if v := parsed[platform]; v != nil { - out[platform] = v - } - } - return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约 -} - -// GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。 -func (s *SettingService) GetAuthSourcePlatformQuotas(ctx context.Context, source string) map[string]*DefaultPlatformQuotaSetting { - out := map[string]*DefaultPlatformQuotaSetting{} - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAuthSourcePlatformQuotas(source)) - if err != nil || raw == "" { - return out // 无 override - } - if err := json.Unmarshal([]byte(raw), &out); err != nil { - slog.Warn("[Setting] unmarshal auth source platform quotas failed (fail-open)", "source", source, "error", err) - return map[string]*DefaultPlatformQuotaSetting{} - } - return out // 仅含已配置平台,保持 override 语义 -} - -// mergePlatformQuotaDefaults 按字段级 patch:src 中非 nil 字段覆盖 dst。 -// 区分 nil("未配置",保留 dst)vs &0.0("显式禁用",覆盖 dst 为 0) -func mergePlatformQuotaDefaults(dst, src *DefaultPlatformQuotaSetting) { - if src == nil || dst == nil { - return - } - if src.DailyLimitUSD != nil { - dst.DailyLimitUSD = src.DailyLimitUSD - } - if src.WeeklyLimitUSD != nil { - dst.WeeklyLimitUSD = src.WeeklyLimitUSD - } - if src.MonthlyLimitUSD != nil { - dst.MonthlyLimitUSD = src.MonthlyLimitUSD - } -} diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go new file mode 100644 index 0000000000..ba819a5ce6 --- /dev/null +++ b/backend/internal/service/setting_update.go @@ -0,0 +1,623 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// UpdateSettings 更新系统设置 +func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { + updates, err := s.buildSystemSettingsUpdates(ctx, settings) + if err != nil { + return err + } + + err = s.settingRepo.SetMultiple(ctx, updates) + if err == nil { + s.refreshCachedSettings(settings) + } + return err +} + +// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write. +func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error { + updates, err := s.buildSystemSettingsUpdates(ctx, settings) + if err != nil { + return err + } + + authSourceUpdates, err := s.buildAuthSourceDefaultUpdates(ctx, authDefaults) + if err != nil { + return err + } + for key, value := range authSourceUpdates { + updates[key] = value + } + + err = s.settingRepo.SetMultiple(ctx, updates) + if err == nil { + s.refreshCachedSettings(settings) + } + return err +} + +func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) { + if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil { + return nil, err + } + normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist) + if err != nil { + return nil, infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error()) + } + if normalizedWhitelist == nil { + normalizedWhitelist = []string{} + } + settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist + alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled) + if err != nil { + return nil, err + } + wxpaySource, err := normalizeVisibleMethodSettingSource("wxpay", settings.PaymentVisibleMethodWxpaySource, settings.PaymentVisibleMethodWxpayEnabled) + if err != nil { + return nil, err + } + if err := s.normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil { + return nil, err + } + settings.PaymentVisibleMethodAlipaySource = alipaySource + settings.PaymentVisibleMethodWxpaySource = wxpaySource + settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID) + settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret) + settings.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppID, settings.WeChatConnectAppID)) + settings.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppSecret, settings.WeChatConnectAppSecret)) + settings.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppID, settings.WeChatConnectAppID)) + settings.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppSecret, settings.WeChatConnectAppSecret)) + settings.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppID, settings.WeChatConnectAppID)) + settings.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppSecret, settings.WeChatConnectAppSecret)) + settings.WeChatConnectMode = normalizeWeChatConnectStoredMode( + settings.WeChatConnectOpenEnabled, + settings.WeChatConnectMPEnabled, + settings.WeChatConnectMobileEnabled, + settings.WeChatConnectMode, + ) + settings.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings.WeChatConnectScopes, settings.WeChatConnectMode) + settings.WeChatConnectRedirectURL = strings.TrimSpace(settings.WeChatConnectRedirectURL) + settings.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings.WeChatConnectFrontendRedirectURL) + if settings.WeChatConnectFrontendRedirectURL == "" { + settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend + } + settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL) + settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL) + if settings.GitHubOAuthFrontendRedirectURL == "" { + settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend + } + settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL) + settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL) + if settings.GoogleOAuthFrontendRedirectURL == "" { + settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend + } + + updates := make(map[string]string) + + // 注册设置 + updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) + updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) + registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist) + if err != nil { + return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err) + } + updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON) + updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) + updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) + updates[SettingKeyFrontendURL] = settings.FrontendURL + updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) + updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) + settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode) + settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt) + if settings.LoginAgreementUpdatedAt == "" { + settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate + } + loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments) + if err != nil { + return nil, err + } + updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled) + updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode + updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt + updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON + + // 邮件服务设置(只有非空才更新密码) + 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 + } + updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) + + // LinuxDo Connect OAuth 登录 + updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) + updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID + updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL + if settings.LinuxDoConnectClientSecret != "" { + updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret + } + + // DingTalk Connect OAuth 登录 + updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled) + updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID + updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL + if settings.DingTalkConnectClientSecret != "" { + updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret + } + updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy + updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID + updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration) + updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail) + updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName) + updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept) + updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey + updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey + updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey + updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName + updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName + updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName + + // Generic OIDC OAuth 登录 + updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled) + updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName + updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID + updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL + updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL + updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL + updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL + updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL + updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL + updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes + updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL + updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL + updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod + updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE) + updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken) + updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs + updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds) + updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified) + updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath + updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath + updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath + if settings.OIDCConnectClientSecret != "" { + updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret + } + + // GitHub / Google 邮箱快捷登录 + updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled) + updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID) + updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL + updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL + if settings.GitHubOAuthClientSecret != "" { + updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret) + } + updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled) + updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID) + updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL + updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL + if settings.GoogleOAuthClientSecret != "" { + updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret) + } + + // WeChat Connect OAuth 登录 + updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled) + updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID + updates[SettingKeyWeChatConnectOpenAppID] = settings.WeChatConnectOpenAppID + updates[SettingKeyWeChatConnectMPAppID] = settings.WeChatConnectMPAppID + updates[SettingKeyWeChatConnectMobileAppID] = settings.WeChatConnectMobileAppID + updates[SettingKeyWeChatConnectOpenEnabled] = strconv.FormatBool(settings.WeChatConnectOpenEnabled) + updates[SettingKeyWeChatConnectMPEnabled] = strconv.FormatBool(settings.WeChatConnectMPEnabled) + updates[SettingKeyWeChatConnectMobileEnabled] = strconv.FormatBool(settings.WeChatConnectMobileEnabled) + updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode + updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes + updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL + updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL + if settings.WeChatConnectAppSecret != "" { + updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret + } + if settings.WeChatConnectOpenAppSecret != "" { + updates[SettingKeyWeChatConnectOpenAppSecret] = settings.WeChatConnectOpenAppSecret + } + if settings.WeChatConnectMPAppSecret != "" { + updates[SettingKeyWeChatConnectMPAppSecret] = settings.WeChatConnectMPAppSecret + } + if settings.WeChatConnectMobileAppSecret != "" { + updates[SettingKeyWeChatConnectMobileAppSecret] = settings.WeChatConnectMobileAppSecret + } + + // 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) + tableDefaultPageSize, tablePageSizeOptions := normalizeTablePreferences( + settings.TableDefaultPageSize, + settings.TablePageSizeOptions, + ) + updates[SettingKeyTableDefaultPageSize] = strconv.Itoa(tableDefaultPageSize) + tablePageSizeOptionsJSON, err := json.Marshal(tablePageSizeOptions) + if err != nil { + return nil, fmt.Errorf("marshal table page size options: %w", err) + } + updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON) + updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems + updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints + + // 默认配置 + updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) + updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) + settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate) + updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64) + if settings.AffiliateRebateFreezeHours < 0 { + settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault + } + if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax { + settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax + } + updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours) + if settings.AffiliateRebateDurationDays < 0 { + settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault + } + if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax { + settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax + } + updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays) + if settings.AffiliateRebatePerInviteeCap < 0 { + settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault + } + updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64) + updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit) + defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) + if err != nil { + return nil, fmt.Errorf("marshal default subscriptions: %w", err) + } + updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON) + + // 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) + } + + // Channel monitor feature switch + updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled) + if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 { + updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) + } + + // Available channels feature switch + updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) + + // Affiliate (邀请返利) feature switch + updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled) + + // 风控中心功能开关 + updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled) + + // cyber 会话屏蔽开关 + TTL + updates[SettingKeyCyberSessionBlockEnabled] = strconv.FormatBool(settings.CyberSessionBlockEnabled) + if settings.CyberSessionBlockTTLSeconds > 0 { + updates[SettingKeyCyberSessionBlockTTLSeconds] = strconv.Itoa(settings.CyberSessionBlockTTLSeconds) + } + + // Claude Code version check + updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion + updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion + + // 分组隔离 + updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling) + + // Backend Mode + updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled) + + // Gateway forwarding behavior + updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification) + updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) + updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning) + updates[SettingKeyEnableClaudeOAuthSystemPromptInjection] = strconv.FormatBool(settings.EnableClaudeOAuthSystemPromptInjection) + updates[SettingKeyClaudeOAuthSystemPrompt] = settings.ClaudeOAuthSystemPrompt + if err := ValidateClaudeOAuthSystemPromptBlocksConfig(settings.ClaudeOAuthSystemPromptBlocks); err != nil { + return nil, err + } + updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks + updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection) + updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl) + updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization) + updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) + updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent) + // codex_cli_only 加固 + updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) + updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) + updates[SettingKeyCodexCLIOnlyBlacklist] = strings.TrimSpace(settings.CodexCLIOnlyBlacklist) + updates[SettingKeyCodexCLIOnlyWhitelist] = strings.TrimSpace(settings.CodexCLIOnlyWhitelist) + updates[SettingKeyCodexCLIOnlyAllowAppServerClients] = strconv.FormatBool(settings.CodexCLIOnlyAllowAppServerClients) + updates[SettingKeyCodexCLIOnlyEngineFingerprintSignals] = strings.TrimSpace(settings.CodexCLIOnlyEngineFingerprintSignals) + updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource + updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource + updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) + updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled) + updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerLBTopK] = settings.OpenAIAdvancedSchedulerLBTopK + updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority] = settings.OpenAIAdvancedSchedulerWeightPriority + updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad] = settings.OpenAIAdvancedSchedulerWeightLoad + updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue] = settings.OpenAIAdvancedSchedulerWeightQueue + updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate] = settings.OpenAIAdvancedSchedulerWeightErrorRate + updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT] = settings.OpenAIAdvancedSchedulerWeightTTFT + updates[SettingKeyOpenAIAdvancedSchedulerWeightReset] = settings.OpenAIAdvancedSchedulerWeightReset + updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom] = settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom + updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse] = settings.OpenAIAdvancedSchedulerWeightPreviousResponse + updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky] = settings.OpenAIAdvancedSchedulerWeightSessionSticky + + // 余额、订阅到期与账号限额通知 + updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) + updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64) + updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL + updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled) + updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled) + updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails) + + // 系统全局 platform quota:整体替换语义(null/缺省 = 不限制)。 + if settings.DefaultPlatformQuotas != nil { + if err := validateDefaultPlatformQuotaMap(settings.DefaultPlatformQuotas); err != nil { + return nil, err + } + blob, err := json.Marshal(settings.DefaultPlatformQuotas) + if err != nil { + return nil, fmt.Errorf("marshal default platform quotas: %w", err) + } + updates[SettingKeyDefaultPlatformQuotas] = string(blob) + } + + updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests) + + return updates, nil +} + +// validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性: +// 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。 +// 系统层和 auth-source 层共用此 helper。 +func validateDefaultPlatformQuotaMap(m map[string]*DefaultPlatformQuotaSetting) error { + for platform, pq := range m { + if !IsAllowedQuotaPlatform(platform) { + return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", fmt.Sprintf("unknown platform %q", platform)) + } + if pq == nil { + continue + } + for _, v := range []*float64{pq.DailyLimitUSD, pq.WeeklyLimitUSD, pq.MonthlyLimitUSD} { + if v != nil && (*v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0)) { + return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", "platform quota limit must be a finite non-negative number") + } + } + } + return nil +} + +func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, settings *AuthSourceDefaultSettings) (map[string]string, error) { + if settings == nil { + return nil, nil + } + + for _, subscriptions := range [][]DefaultSubscriptionSetting{ + settings.Email.Subscriptions, + settings.LinuxDo.Subscriptions, + settings.OIDC.Subscriptions, + settings.WeChat.Subscriptions, + settings.GitHub.Subscriptions, + settings.Google.Subscriptions, + settings.DingTalk.Subscriptions, + } { + if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil { + return nil, err + } + } + + // 校验各 auth source 的 platform quota map(改动 C:对等系统层校验) + for _, pgs := range []struct { + name string + pq map[string]*DefaultPlatformQuotaSetting + }{ + {"email", settings.Email.PlatformQuotas}, + {"linuxdo", settings.LinuxDo.PlatformQuotas}, + {"oidc", settings.OIDC.PlatformQuotas}, + {"wechat", settings.WeChat.PlatformQuotas}, + {"github", settings.GitHub.PlatformQuotas}, + {"google", settings.Google.PlatformQuotas}, + {"dingtalk", settings.DingTalk.PlatformQuotas}, + } { + if pgs.pq != nil { + if err := validateDefaultPlatformQuotaMap(pgs.pq); err != nil { + return nil, err + } + } + } + + updates := make(map[string]string, 36) + writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email) + writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo) + writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC) + writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat) + writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub) + writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google) + writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk) + updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup) + return updates, nil +} + +func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { + if settings == nil { + return + } + + // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 + versionBoundsSF.Forget("version_bounds") + versionBoundsCache.Store(&cachedVersionBounds{ + min: settings.MinClaudeCodeVersion, + max: settings.MaxClaudeCodeVersion, + expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), + }) + backendModeSF.Forget("backend_mode") + backendModeCache.Store(&cachedBackendMode{ + value: settings.BackendModeEnabled, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + gatewayForwardingSF.Forget("gateway_forwarding") + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: settings.EnableFingerprintUnification, + metadataPassthrough: settings.EnableMetadataPassthrough, + cchSigning: settings.EnableCCHSigning, + claudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, + anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, + rewriteMessageCacheControl: settings.RewriteMessageCacheControl, + clientDatelineNormalization: settings.EnableClientDatelineNormalization, + expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), + }) + s.antigravityUAVersionSF.Forget("antigravity_user_agent_version") + antigravityUserAgentVersion := antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) + if antigravityUserAgentVersion == "" { + antigravityUserAgentVersion = antigravity.GetDefaultUserAgentVersion() + } + s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ + version: antigravityUserAgentVersion, + expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(), + }) + s.openAICodexUASF.Forget("openai_codex_user_agent") + codexUA := strings.TrimSpace(settings.OpenAICodexUserAgent) + if codexUA == "" { + codexUA = DefaultOpenAICodexUserAgent + } + s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ + value: codexUA, + expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), + }) + openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) + openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ + enabled: settings.OpenAIAdvancedSchedulerEnabled, + stickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled, + subscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + lbTopKOverride: parsePositiveIntOverride(settings.OpenAIAdvancedSchedulerLBTopK), + weightOverrides: parseOpenAIAdvancedSchedulerWeightOverrides(map[string]string{ + SettingKeyOpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority, + SettingKeyOpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad, + SettingKeyOpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue, + SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate, + SettingKeyOpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT, + SettingKeyOpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset, + SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse, + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky, + }), + expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), + }) + // Invalidate the quota auto-pause cache and let the next read trigger a fresh load. + // We can't know from here whether ops_advanced_settings was also touched, so be + // defensive: store an expired entry — GetOpenAIQuotaAutoPauseSettings will serve + // stale and kick off an async refresh, never blocking the request that follows. + s.openAIQuotaAutoPauseSettingsSF.Forget(openAIQuotaAutoPauseSettingsRefreshKey) + if cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); cached != nil { + s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ + settings: cached.settings, + expiresAt: 0, + }) + } + if s.cfg != nil { + s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP) + } + // codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。 + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + if s.onUpdate != nil { + s.onUpdate() // Invalidate cache after settings update + } +} + +func (s *SettingService) defaultRewriteMessageCacheControl() bool { + return false +} + +func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error { + if len(items) == 0 { + return nil + } + + checked := make(map[int64]struct{}, len(items)) + for _, item := range items { + if item.GroupID <= 0 { + continue + } + if _, ok := checked[item.GroupID]; ok { + return ErrDefaultSubGroupDuplicate.WithMetadata(map[string]string{ + "group_id": strconv.FormatInt(item.GroupID, 10), + }) + } + checked[item.GroupID] = struct{}{} + if s.defaultSubGroupReader == nil { + continue + } + + group, err := s.defaultSubGroupReader.GetByID(ctx, item.GroupID) + if err != nil { + if errors.Is(err, ErrGroupNotFound) { + return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{ + "group_id": strconv.FormatInt(item.GroupID, 10), + }) + } + return fmt.Errorf("get default subscription group %d: %w", item.GroupID, err) + } + if !group.IsSubscriptionType() { + return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{ + "group_id": strconv.FormatInt(item.GroupID, 10), + }) + } + } + + return nil +}