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" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) // 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 } forwardedClientIPHeaders := []string{} if s != nil && s.cfg != nil { forwardedClientIPHeaders = s.cfg.ForwardedClientIPSettings().Headers } forwardedClientIPHeadersJSON, err := json.Marshal(forwardedClientIPHeaders) if err != nil { return fmt.Errorf("marshal default forwarded client IP headers: %w", err) } // 初始化默认设置 defaults := map[string]string{ SettingKeyRegistrationEnabled: "true", SettingKeyEmailVerifyEnabled: "false", SettingKeyRegistrationEmailSuffixWhitelist: "[]", SettingKeyRegistrationEmailDomainQuotaEnabled: "false", SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 SettingKeyLoginAgreementEnabled: "false", SettingKeyLoginAgreementMode: defaultLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, SettingKeyAPIKeyACLTrustForwardedIP: "true", SettingKeyForwardedClientIPHeaders: string(forwardedClientIPHeadersJSON), settingKeyForwardedClientIPModeV2: "true", 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", SettingKeyChannelMonitorMode: ChannelMonitorModeV1, SettingKeyChannelMonitorDefaultIntervalSeconds: "60", SettingKeyChannelMonitorHideThroughput: "true", SettingKeyChannelMonitorShowQuota: "false", // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. SettingKeyGrokDefaultTextModel: "grok-4.5", SettingKeyGrokCrossClientModelMapEnabled: "true", SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeCLI, // Available channels feature (default disabled; opt-in) SettingKeyAvailableChannelsEnabled: "false", // Model plaza feature (default disabled; opt-in, public unless require_auth) SettingKeyModelPlazaEnabled: "false", SettingKeyModelPlazaRequireAuth: "false", SettingKeyModelPlazaDescription: "", // Affiliate (邀请返利) feature (default disabled; opt-in) SettingKeyAffiliateEnabled: "false", SettingKeyAffiliateAdminRechargeEnabled: strconv.FormatBool(AdminRechargeRebateEnabledDefault), // 风控中心功能(默认关闭,显式启用) 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", SettingKeyOpenAILowUpstreamRatePriorityEnabled: "false", SettingKeyOpenAIOAuthSchedulingRateMultiplier: "1", SettingKeyEnableAnthropicCacheTTL1hInjection: "false", SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()), SettingKeyEnableClientDatelineNormalization: "true", SettingKeyAntigravityUserAgentVersion: "", SettingKeyOpenAICodexUserAgent: "", SettingKeyOpenAICodexClientVersion: "", SettingKeyOpenAICodexClientVersionSynced: "", SettingKeyOpenAICodexVersionAutoSyncEnabled: "true", SettingPaymentVisibleMethodAlipaySource: "", SettingPaymentVisibleMethodWxpaySource: "", SettingPaymentVisibleMethodAlipayEnabled: "false", SettingPaymentVisibleMethodWxpayEnabled: "false", openAIAdvancedSchedulerSettingKey: "false", SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false", SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false", SettingKeyOpenAIAdvancedSchedulerLBTopK: "", SettingKeyOpenAIAdvancedSchedulerWeightPriority: "", SettingKeyOpenAIAdvancedSchedulerWeightLoad: "", SettingKeyOpenAIAdvancedSchedulerWeightQueue: "", SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: "", SettingKeyOpenAIAdvancedSchedulerWeightTTFT: "", SettingKeyOpenAIAdvancedSchedulerWeightReset: "", SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: "", SettingKeyOpenAIAdvancedSchedulerWeightUpstreamCost: "", SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "", SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "", SettingKeyAllowUserViewErrorRequests: "false", } return s.settingRepo.SetMultiple(ctx, defaults) } func parseForwardedClientIPHeadersSetting(value string) ([]string, error) { var headers []string if err := json.Unmarshal([]byte(value), &headers); err != nil { return nil, fmt.Errorf("parse forwarded_client_ip_headers: %w", err) } if headers == nil { return nil, fmt.Errorf("parse forwarded_client_ip_headers: value must be a JSON array") } normalized, err := config.NormalizeForwardedClientIPHeaders(headers) if err != nil { return nil, fmt.Errorf("parse forwarded_client_ip_headers: %w", err) } return normalized, nil } // 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 forwardedClientIPHeaders := []string{} if s != nil && s.cfg != nil { runtimeSettings := s.cfg.ForwardedClientIPSettings() apiKeyACLTrustForwardedIP = runtimeSettings.TrustForwardedIP forwardedClientIPHeaders = runtimeSettings.Headers } if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok { apiKeyACLTrustForwardedIP = value == "true" } if value, ok := settings[SettingKeyForwardedClientIPHeaders]; ok { parsed, err := parseForwardedClientIPHeadersSetting(value) if err != nil { slog.Error("invalid persisted forwarded client IP headers; forwarded trust disabled", "error", err) apiKeyACLTrustForwardedIP = false forwardedClientIPHeaders = []string{} } else { forwardedClientIPHeaders = parsed } } result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: emailVerifyEnabled, RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true", PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", PasskeyEnabled: s.passkeySettingEnabled(settings), SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), 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] != "", TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], TencentCaptchaAppSecretKeyConfigured: settings[SettingKeyTencentCaptchaAppSecretKey] != "", TencentCaptchaCloudSecretIDConfigured: settings[SettingKeyTencentCaptchaCloudSecretID] != "", TencentCaptchaCloudSecretKeyConfigured: settings[SettingKeyTencentCaptchaCloudSecretKey] != "", TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]), AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true", AliyunCaptchaAccessKeyID: settings[SettingKeyAliyunCaptchaAccessKeyID], AliyunCaptchaAccessKeySecretConfigured: settings[SettingKeyAliyunCaptchaAccessKeySecret] != "", AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID], AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix], AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]), APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, ForwardedClientIPHeaders: forwardedClientIPHeaders, 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], CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true", 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.AdminRechargeRebateEnabled = settings[SettingKeyAffiliateAdminRechargeEnabled] == "true" result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) // 敏感信息直接返回,方便测试连接时使用 result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] result.TencentCaptchaAppSecretKey = settings[SettingKeyTencentCaptchaAppSecretKey] result.TencentCaptchaCloudSecretID = settings[SettingKeyTencentCaptchaCloudSecretID] result.TencentCaptchaCloudSecretKey = settings[SettingKeyTencentCaptchaCloudSecretKey] result.AliyunCaptchaAccessKeySecret = settings[SettingKeyAliyunCaptchaAccessKeySecret] // 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.ChannelMonitorMode = normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]) result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( settings[SettingKeyChannelMonitorDefaultIntervalSeconds], ) // 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的 // 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。 result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]) // 配额展示默认关闭且 fail-closed:仅字面 "true" 视为开启 // (与 setting_public.go 公开读取路径保持一致)。 result.ChannelMonitorShowQuota = settings[SettingKeyChannelMonitorShowQuota] == "true" // Grok default mapping policy result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel]) if result.GrokDefaultTextModel == "" { result.GrokDefaultTextModel = "grok-4.5" } // Default true (missing/empty → enabled) so Claude/Codex→Grok mapping keeps working. // Operators can set false to disable silent cross-client rewrite. result.GrokCrossClientModelMapEnabled = !isFalseSettingValue(settings[SettingKeyGrokCrossClientModelMapEnabled]) result.GrokDefaultBaseURLMode = normalizeGrokDefaultBaseURLMode(settings[SettingKeyGrokDefaultBaseURLMode]) // Available channels feature (default: disabled; strict true) result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" // Model plaza feature (default: disabled; strict true) result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true" result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true" result.ModelPlazaDescription = settings[SettingKeyModelPlazaDescription] // 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]) result.OpenAICodexClientVersion = NormalizeCodexClientVersion(settings[SettingKeyOpenAICodexClientVersion]) result.OpenAICodexClientVersionSynced = NormalizeCodexClientVersion(settings[SettingKeyOpenAICodexClientVersionSynced]) // 自动同步默认开启:缺失/空值一律视为开启,与 enable_client_dateline_normalization 同一惯例。 if v, ok := settings[SettingKeyOpenAICodexVersionAutoSyncEnabled]; ok && v != "" { result.OpenAICodexVersionAutoSyncEnabled = v == "true" } else { result.OpenAICodexVersionAutoSyncEnabled = true } // 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.OpenAILowUpstreamRatePriorityEnabled = settings[SettingKeyOpenAILowUpstreamRatePriorityEnabled] == "true" result.OpenAIOAuthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(settings[SettingKeyOpenAIOAuthSchedulingRateMultiplier]) 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.OpenAIAdvancedSchedulerWeightUpstreamCost = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightUpstreamCost]) 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.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.UpstreamCost) 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.AccountSchedulingThresholds = defaultAccountSchedulingThresholds() if raw := strings.TrimSpace(settings[SettingKeyAccountSchedulingThresholds]); raw != "" { if thresholds, err := parseAccountSchedulingThresholdsSetting(raw); err != nil { slog.Warn("[Setting] parseSettings: unmarshal account_scheduling_thresholds failed", "error", err) } else { result.AccountSchedulingThresholds = thresholds } } result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false // Publish Grok default model_mapping options for accounts with empty mapping. xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{ DefaultText: result.GrokDefaultTextModel, EnableCrossClientMap: result.GrokCrossClientModelMapEnabled, }) 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, UpstreamCost: 0.0, PreviousResponse: 5.0, SessionSticky: 3.0, } if s == nil || s.cfg == nil { return defaults } weights := s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights if !weights.IsValid() { return defaults } return weights } func formatOpenAIAdvancedSchedulerFloat(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) } func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error { if rate := settings.OpenAIOAuthSchedulingRateMultiplier; rate < 0 || math.IsNaN(rate) || math.IsInf(rate, 0) { return infraerrors.BadRequest("INVALID_OPENAI_OAUTH_SCHEDULING_RATE_MULTIPLIER", "OpenAI OAuth scheduling rate multiplier must be a finite non-negative number") } 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.OpenAIAdvancedSchedulerWeightUpstreamCost, &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() resolved := config.GatewayOpenAIWSSchedulerScoreWeights{ Priority: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPriority, effective.Priority), Load: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightLoad, effective.Load), Queue: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQueue, effective.Queue), ErrorRate: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightErrorRate, effective.ErrorRate), TTFT: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightTTFT, effective.TTFT), Reset: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightReset, effective.Reset), QuotaHeadroom: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, effective.QuotaHeadroom), UpstreamCost: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightUpstreamCost, effective.UpstreamCost), PreviousResponse: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPreviousResponse, effective.PreviousResponse), SessionSticky: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightSessionSticky, effective.SessionSticky), } if !resolved.IsValid() { return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler weights must have finite non-zero base and total sums") } return nil } func parseOpenAIOAuthSchedulingRateMultiplier(raw string) float64 { value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { return defaultOpenAIOAuthSchedulingRateMultiplier } return value } // 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 }