package service import ( "context" "errors" "fmt" "sync/atomic" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "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 } // 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 antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion antigravityUAVersionSF singleflight.Group openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent openAICodexUASF singleflight.Group codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy codexRestrictionPolicySF singleflight.Group cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime cyberSessionBlockRuntimeSF singleflight.Group // openAIQuotaAutoPauseSettingsCache holds the most recently observed quota auto-pause // settings. GetOpenAIQuotaAutoPauseSettings reads this atomic.Value on the request hot // path without ever blocking on the DB; when the cached entry expires, a background // goroutine refreshes it via openAIQuotaAutoPauseSettingsSF (stale-while-revalidate). // This per-service field also gives tests natural isolation — each SettingService // instance owns its own cache, no shared package-level state. openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings openAIQuotaAutoPauseSettingsSF singleflight.Group } // DefaultPlatformQuotaSetting 单 platform 三档限额(nil = 沿用上层;0 = 显式禁用;>0 = 上限) type DefaultPlatformQuotaSetting struct { DailyLimitUSD *float64 `json:"daily"` WeeklyLimitUSD *float64 `json:"weekly"` MonthlyLimitUSD *float64 `json:"monthly"` } type ProviderDefaultGrantSettings struct { Balance float64 Concurrency int Subscriptions []DefaultSubscriptionSetting GrantOnSignup bool GrantOnFirstBind bool PlatformQuotas map[string]*DefaultPlatformQuotaSetting // key = platform name } type AuthSourceDefaultSettings struct { Email ProviderDefaultGrantSettings LinuxDo ProviderDefaultGrantSettings OIDC ProviderDefaultGrantSettings WeChat ProviderDefaultGrantSettings GitHub ProviderDefaultGrantSettings Google ProviderDefaultGrantSettings DingTalk ProviderDefaultGrantSettings ForceEmailOnThirdPartySignup bool } type authSourceDefaultKeySet struct { // source 是 auth source 标识(如 "email"、"github"),仅用于 parse 时 // slog.Warn 诊断输出,不再参与 key 拼接(platformQuotas 字段已存完整 key)。 source string balance string concurrency string subscriptions string grantOnSignup string grantOnFirstBind string platformQuotas string // SettingKeyAuthSourcePlatformQuotas(source) } var ( emailAuthSourceDefaultKeys = authSourceDefaultKeySet{ source: "email", balance: SettingKeyAuthSourceDefaultEmailBalance, concurrency: SettingKeyAuthSourceDefaultEmailConcurrency, subscriptions: SettingKeyAuthSourceDefaultEmailSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultEmailGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultEmailGrantOnFirstBind, platformQuotas: SettingKeyAuthSourcePlatformQuotas("email"), } linuxDoAuthSourceDefaultKeys = authSourceDefaultKeySet{ source: "linuxdo", balance: SettingKeyAuthSourceDefaultLinuxDoBalance, concurrency: SettingKeyAuthSourceDefaultLinuxDoConcurrency, subscriptions: SettingKeyAuthSourceDefaultLinuxDoSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind, platformQuotas: SettingKeyAuthSourcePlatformQuotas("linuxdo"), } oidcAuthSourceDefaultKeys = authSourceDefaultKeySet{ source: "oidc", balance: SettingKeyAuthSourceDefaultOIDCBalance, concurrency: SettingKeyAuthSourceDefaultOIDCConcurrency, subscriptions: SettingKeyAuthSourceDefaultOIDCSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultOIDCGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind, platformQuotas: SettingKeyAuthSourcePlatformQuotas("oidc"), } weChatAuthSourceDefaultKeys = authSourceDefaultKeySet{ source: "wechat", balance: SettingKeyAuthSourceDefaultWeChatBalance, concurrency: SettingKeyAuthSourceDefaultWeChatConcurrency, subscriptions: SettingKeyAuthSourceDefaultWeChatSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind, platformQuotas: SettingKeyAuthSourcePlatformQuotas("wechat"), } gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{ source: "github", balance: SettingKeyAuthSourceDefaultGitHubBalance, concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency, subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind, platformQuotas: SettingKeyAuthSourcePlatformQuotas("github"), } googleAuthSourceDefaultKeys = authSourceDefaultKeySet{ source: "google", balance: SettingKeyAuthSourceDefaultGoogleBalance, concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency, subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, platformQuotas: SettingKeyAuthSourcePlatformQuotas("google"), } dingTalkAuthSourceDefaultKeys = authSourceDefaultKeySet{ source: "dingtalk", balance: SettingKeyAuthSourceDefaultDingTalkBalance, concurrency: SettingKeyAuthSourceDefaultDingTalkConcurrency, subscriptions: SettingKeyAuthSourceDefaultDingTalkSubscriptions, grantOnSignup: SettingKeyAuthSourceDefaultDingTalkGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, platformQuotas: SettingKeyAuthSourcePlatformQuotas("dingtalk"), } ) const ( defaultAuthSourceBalance = 0 defaultAuthSourceConcurrency = 5 defaultWeChatConnectMode = "open" defaultWeChatConnectScopes = "snsapi_login" defaultWeChatConnectFrontend = "/auth/wechat/callback" defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize" defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token" defaultGitHubOAuthUserInfo = "https://api.github.com/user" defaultGitHubOAuthEmails = "https://api.github.com/user/emails" defaultGitHubOAuthScopes = "read:user user:email" defaultGitHubOAuthFrontend = "/auth/oauth/callback" defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth" defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token" defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo" defaultGoogleOAuthScopes = "openid email profile" defaultGoogleOAuthFrontend = "/auth/oauth/callback" defaultLoginAgreementMode = "modal" defaultLoginAgreementDate = "2026-03-31" ) // 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 } func (s *SettingService) LoadAPIKeyACLTrustForwardedIPSetting(ctx context.Context) error { if s == nil || s.cfg == nil || s.settingRepo == nil { return nil } value, err := s.settingRepo.GetValue(ctx, SettingKeyAPIKeyACLTrustForwardedIP) if err != nil { if errors.Is(err, ErrSettingNotFound) { s.cfg.SetTrustForwardedIPForAPIKeyACL(s.cfg.Security.TrustForwardedIPForAPIKeyACL) return nil } return fmt.Errorf("get api key acl forwarded ip setting: %w", err) } enabled := value == "true" s.cfg.SetTrustForwardedIPForAPIKeyACL(enabled) return nil } // 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 } // 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 } // getStringOrDefault 获取字符串值或默认值 func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string { if value, ok := settings[key]; ok && value != "" { return value } return defaultValue }