package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "net/url" "sort" "strconv" "strings" "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/imroc/req/v3" "golang.org/x/sync/singleflight" ) var ( ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found") ErrDefaultSubGroupInvalid = infraerrors.BadRequest( "DEFAULT_SUBSCRIPTION_GROUP_INVALID", "default subscription group must exist and be subscription type", ) ErrDefaultSubGroupDuplicate = infraerrors.BadRequest( "DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE", "default subscription group cannot be duplicated", ) ) type SettingRepository interface { Get(ctx context.Context, key string) (*Setting, error) GetValue(ctx context.Context, key string) (string, error) Set(ctx context.Context, key, value string) error GetMultiple(ctx context.Context, keys []string) (map[string]string, error) SetMultiple(ctx context.Context, settings map[string]string) error GetAll(ctx context.Context) (map[string]string, error) Delete(ctx context.Context, key string) error } // 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 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 // DefaultSubscriptionGroupReader validates group references used by default subscriptions. type DefaultSubscriptionGroupReader interface { GetByID(ctx context.Context, id int64) (*Group, error) } // WebSearchManagerBuilder creates a websearch.Manager from config (injected by infra layer). // proxyURLs maps proxy ID to resolved URL for provider-level proxy support. type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[int64]string) // SettingService 系统设置服务 type SettingService struct { settingRepo SettingRepository defaultSubGroupReader DefaultSubscriptionGroupReader proxyRepo ProxyRepository // for resolving websearch provider proxy URLs cfg *config.Config onUpdate func() // Callback when settings are updated (for cache invalidation) version string // Application version webSearchManagerBuilder WebSearchManagerBuilder } type ProviderDefaultGrantSettings struct { Balance float64 Concurrency int Subscriptions []DefaultSubscriptionSetting GrantOnSignup bool GrantOnFirstBind bool } type AuthSourceDefaultSettings struct { Email ProviderDefaultGrantSettings LinuxDo ProviderDefaultGrantSettings OIDC ProviderDefaultGrantSettings WeChat ProviderDefaultGrantSettings ForceEmailOnThirdPartySignup bool } type authSourceDefaultKeySet struct { balance string concurrency string subscriptions string grantOnSignup string grantOnFirstBind string } var ( emailAuthSourceDefaultKeys = authSourceDefaultKeySet{ balance: SettingKeyAuthSourceDefaultEmailBalance, concurrency: SettingKeyAuthSourceDefaultEmailConcurrency, subscriptions: SettingKeyAuthSourceDefaultEmailSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultEmailGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultEmailGrantOnFirstBind, } linuxDoAuthSourceDefaultKeys = authSourceDefaultKeySet{ balance: SettingKeyAuthSourceDefaultLinuxDoBalance, concurrency: SettingKeyAuthSourceDefaultLinuxDoConcurrency, subscriptions: SettingKeyAuthSourceDefaultLinuxDoSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind, } oidcAuthSourceDefaultKeys = authSourceDefaultKeySet{ balance: SettingKeyAuthSourceDefaultOIDCBalance, concurrency: SettingKeyAuthSourceDefaultOIDCConcurrency, subscriptions: SettingKeyAuthSourceDefaultOIDCSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultOIDCGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind, } weChatAuthSourceDefaultKeys = authSourceDefaultKeySet{ balance: SettingKeyAuthSourceDefaultWeChatBalance, concurrency: SettingKeyAuthSourceDefaultWeChatConcurrency, subscriptions: SettingKeyAuthSourceDefaultWeChatSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind, } ) const ( defaultAuthSourceBalance = 0 defaultAuthSourceConcurrency = 5 defaultWeChatConnectMode = "open" defaultWeChatConnectScopes = "snsapi_login" defaultWeChatConnectFrontend = "/auth/wechat/callback" ) 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{ settingRepo: settingRepo, cfg: cfg, } } // SetDefaultSubscriptionGroupReader injects an optional group reader for default subscription validation. func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscriptionGroupReader) { s.defaultSubGroupReader = reader } // SetProxyRepository injects a proxy repo for resolving websearch provider proxy URLs. func (s *SettingService) SetProxyRepository(repo ProxyRepository) { s.proxyRepo = repo } // GetAllSettings 获取所有系统设置 func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) { settings, err := s.settingRepo.GetAll(ctx) if err != nil { return nil, fmt.Errorf("get all settings: %w", err) } return s.parseSettings(settings), nil } // 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, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, SettingKeySiteName, SettingKeySiteLogo, SettingKeySiteSubtitle, SettingKeyAPIBaseURL, SettingKeyContactInfo, SettingKeyDocURL, SettingKeyHomeContent, SettingKeyHideCcsImportButton, SettingKeyPurchaseSubscriptionEnabled, SettingKeyPurchaseSubscriptionURL, SettingKeyTableDefaultPageSize, SettingKeyTablePageSizeOptions, SettingKeyCustomMenuItems, SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, SettingKeyWeChatConnectEnabled, SettingKeyWeChatConnectAppID, SettingKeyWeChatConnectAppSecret, SettingKeyWeChatConnectOpenAppID, SettingKeyWeChatConnectOpenAppSecret, SettingKeyWeChatConnectMPAppID, SettingKeyWeChatConnectMPAppSecret, SettingKeyWeChatConnectMobileAppID, SettingKeyWeChatConnectMobileAppSecret, SettingKeyWeChatConnectOpenEnabled, SettingKeyWeChatConnectMPEnabled, SettingKeyWeChatConnectMobileEnabled, SettingKeyWeChatConnectMode, SettingKeyWeChatConnectScopes, SettingKeyWeChatConnectRedirectURL, SettingKeyWeChatConnectFrontendRedirectURL, SettingKeyBackendModeEnabled, SettingPaymentEnabled, SettingKeyOIDCConnectEnabled, SettingKeyOIDCConnectProviderName, SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL, SettingKeyAccountQuotaNotifyEnabled, SettingKeyChannelMonitorEnabled, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyAvailableChannelsEnabled, } 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 } 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" } 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], ) 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", 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, WeChatOAuthEnabled: weChatEnabled, WeChatOAuthOpenEnabled: weChatOpenEnabled, WeChatOAuthMPEnabled: weChatMPEnabled, WeChatOAuthMobileEnabled: weChatMobileEnabled, BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", PaymentEnabled: settings[SettingPaymentEnabled] == "true", OIDCOAuthEnabled: oidcEnabled, OIDCOAuthProviderName: oidcProviderName, 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", }, 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", } } // SetOnUpdateCallback sets a callback function to be called when settings are updated // This is used for cache invalidation (e.g., HTML cache in frontend server) func (s *SettingService) SetOnUpdateCallback(callback func()) { s.onUpdate = callback } // SetVersion sets the application version for injection into public settings 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"` 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"` 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"` BackendModeEnabled bool `json:"backend_mode_enabled"` PaymentEnabled bool `json:"payment_enabled"` Version string `json:"version"` 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"` } // 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, 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, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthProviderName: settings.OIDCOAuthProviderName, BackendModeEnabled: settings.BackendModeEnabled, PaymentEnabled: settings.PaymentEnabled, Version: s.version, BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, }, 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 } // 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 } 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 } 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) // 邮件服务设置(只有非空才更新密码) updates[SettingKeySMTPHost] = settings.SMTPHost updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort) updates[SettingKeySMTPUsername] = settings.SMTPUsername if settings.SMTPPassword != "" { updates[SettingKeySMTPPassword] = settings.SMTPPassword } updates[SettingKeySMTPFrom] = settings.SMTPFrom updates[SettingKeySMTPFromName] = settings.SMTPFromName updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS) // Cloudflare Turnstile 设置(只有非空才更新密钥) updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled) updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey if settings.TurnstileSecretKey != "" { updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey } // LinuxDo Connect OAuth 登录 updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL if settings.LinuxDoConnectClientSecret != "" { updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret } // 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 } // 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) 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) // 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[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) // Balance low notification updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64) updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled) updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails) return updates, 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, } { if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil { return nil, err } } updates := make(map[string]string, 21) writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email) writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo) writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC) writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat) 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, expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ enabled: settings.OpenAIAdvancedSchedulerEnabled, expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), }) if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } } 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 } // 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 } // 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) { if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning } } type gwfResult struct { fp, mp, cch bool } val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) { if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning}, nil } } dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) defer cancel() values, err := s.settingRepo.GetMultiple(dbCtx, []string{ SettingKeyEnableFingerprintUnification, SettingKeyEnableMetadataPassthrough, SettingKeyEnableCCHSigning, }) if err != nil { slog.Warn("failed to get gateway forwarding settings", "error", err) gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ fingerprintUnification: true, metadataPassthrough: false, cchSigning: false, expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), }) return gwfResult{true, false, false}, nil } fp := true if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { fp = v == "true" } mp := values[SettingKeyEnableMetadataPassthrough] == "true" cch := values[SettingKeyEnableCCHSigning] == "true" gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ fingerprintUnification: fp, metadataPassthrough: mp, cchSigning: cch, expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) return gwfResult{fp, mp, cch}, nil }) if r, ok := val.(gwfResult); ok { return r.fp, r.mp, r.cch } return true, false, false // fail-open defaults } // 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" } // 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, 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), 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 } } // 初始化默认设置 defaults := map[string]string{ SettingKeyRegistrationEnabled: "true", SettingKeyEmailVerifyEnabled: "false", SettingKeyRegistrationEmailSuffixWhitelist: "[]", SettingKeyPromoCodeEnabled: "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, 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), 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", 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", // Claude Code version check (default: empty = disabled) SettingKeyMinClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "", // 分组隔离(默认不允许未分组 Key 调度) SettingKeyAllowUngroupedKeyScheduling: "false", SettingPaymentVisibleMethodAlipaySource: "", SettingPaymentVisibleMethodWxpaySource: "", SettingPaymentVisibleMethodAlipayEnabled: "false", SettingPaymentVisibleMethodWxpayEnabled: "false", openAIAdvancedSchedulerSettingKey: "false", } return s.settingRepo.SetMultiple(ctx, defaults) } // parseSettings 解析设置到结构体 func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: emailVerifyEnabled, RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", SMTPHost: settings[SettingKeySMTPHost], SMTPUsername: settings[SettingKeySMTPUsername], SMTPFrom: settings[SettingKeySMTPFrom], SMTPFromName: settings[SettingKeySMTPFromName], SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], DocURL: settings[SettingKeyDocURL], HomeContent: settings[SettingKeyHomeContent], HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), 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 } 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 != "" // 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 != "" // 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" // 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) 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" // 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" // Balance low notification result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { result.BalanceLowNotifyThreshold = v } result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] // Account quota notification result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true" if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" { result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw) } if result.AccountQuotaNotifyEmails == nil { result.AccountQuotaNotifyEmails = []NotifyEmailEntry{} } return result } 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 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" } 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) } 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, } if providerDefaults.Balance != defaultAuthSourceBalance { result.Balance = providerDefaults.Balance } if providerDefaults.Concurrency > 0 && providerDefaults.Concurrency != defaultAuthSourceConcurrency { 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 != "" { return value } return defaultValue } // IsTurnstileEnabled 检查是否启用 Turnstile 验证 func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled) if err != nil { return false } return value == "true" } // GetTurnstileSecretKey 获取 Turnstile Secret Key func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string { value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey) if err != nil { return "" } return value } // IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入) func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch) if err != nil { // 默认开启,保持兼容 return true } return value == "true" } // GetIdentityPatchPrompt 获取自定义身份补丁提示词(为空表示使用内置默认模板) func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string { value, err := s.settingRepo.GetValue(ctx, SettingKeyIdentityPatchPrompt) if err != nil { return "" } return value } // GenerateAdminAPIKey 生成新的管理员 API Key func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) { // 生成 32 字节随机数 = 64 位十六进制字符 bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("generate random bytes: %w", err) } key := AdminAPIKeyPrefix + hex.EncodeToString(bytes) // 存储到 settings 表 if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil { return "", fmt.Errorf("save admin api key: %w", err) } return key, nil } // GetAdminAPIKeyStatus 获取管理员 API Key 状态 // 返回脱敏的 key、是否存在、错误 func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) { key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) if err != nil { if errors.Is(err, ErrSettingNotFound) { return "", false, nil } return "", false, err } if key == "" { return "", false, nil } // 脱敏:显示前 10 位和后 4 位 if len(key) > 14 { maskedKey = key[:10] + "..." + key[len(key)-4:] } else { maskedKey = key } return maskedKey, true, nil } // GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用) // 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) { key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) if err != nil { if errors.Is(err, ErrSettingNotFound) { return "", nil // 未配置,返回空字符串 } return "", err // 数据库错误 } return key, nil } // DeleteAdminAPIKey 删除管理员 API Key func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error { return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey) } // IsModelFallbackEnabled 检查是否启用模型兜底机制 func (s *SettingService) IsModelFallbackEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableModelFallback) if err != nil { return false // Default: disabled } return value == "true" } // GetFallbackModel 获取指定平台的兜底模型 func (s *SettingService) GetFallbackModel(ctx context.Context, platform string) string { var key string var defaultModel string switch platform { case PlatformAnthropic: key = SettingKeyFallbackModelAnthropic defaultModel = "claude-3-5-sonnet-20241022" case PlatformOpenAI: key = SettingKeyFallbackModelOpenAI defaultModel = "gpt-4o" case PlatformGemini: key = SettingKeyFallbackModelGemini defaultModel = "gemini-2.5-pro" case PlatformAntigravity: key = SettingKeyFallbackModelAntigravity defaultModel = "gemini-2.5-pro" default: return "" } value, err := s.settingRepo.GetValue(ctx, key) if err != nil || value == "" { return defaultModel } return value } // GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。 // // 优先级: // - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 // - 否则回退到 config.yaml/env 的值 func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { if s == nil || s.cfg == nil { return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") } effective := s.cfg.LinuxDo keys := []string{ SettingKeyLinuxDoConnectEnabled, SettingKeyLinuxDoConnectClientID, SettingKeyLinuxDoConnectClientSecret, SettingKeyLinuxDoConnectRedirectURL, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err) } if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { effective.Enabled = raw == "true" } if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { effective.ClientID = strings.TrimSpace(v) } if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" { effective.ClientSecret = strings.TrimSpace(v) } if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { effective.RedirectURL = strings.TrimSpace(v) } if !effective.Enabled { return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") } // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 if strings.TrimSpace(effective.ClientID) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") } if strings.TrimSpace(effective.AuthorizeURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") } if strings.TrimSpace(effective.TokenURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") } if strings.TrimSpace(effective.UserInfoURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured") } if strings.TrimSpace(effective.RedirectURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") } if strings.TrimSpace(effective.FrontendRedirectURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") } if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") } if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") } method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) switch method { case "", "client_secret_post", "client_secret_basic": if strings.TrimSpace(effective.ClientSecret) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") } case "none": default: return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") } 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)) } // 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 } // 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)) } // 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)) }