| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "strings"
- "sync/atomic"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- "golang.org/x/sync/singleflight"
- "sync"
- )
- const (
- GrokDefaultBaseURLModeAPI = "api"
- GrokDefaultBaseURLModeUSEast1 = "us-east-1"
- GrokDefaultBaseURLModeUSWest2 = "us-west-2"
- GrokDefaultBaseURLModeEUWest1 = "eu-west-1"
- GrokDefaultBaseURLModeCLI = "cli"
- )
- func normalizeGrokDefaultBaseURLMode(mode string) string {
- switch strings.ToLower(strings.TrimSpace(mode)) {
- case GrokDefaultBaseURLModeAPI:
- return GrokDefaultBaseURLModeAPI
- case GrokDefaultBaseURLModeUSEast1:
- return GrokDefaultBaseURLModeUSEast1
- case GrokDefaultBaseURLModeUSWest2:
- return GrokDefaultBaseURLModeUSWest2
- case GrokDefaultBaseURLModeEUWest1:
- return GrokDefaultBaseURLModeEUWest1
- case GrokDefaultBaseURLModeCLI:
- return GrokDefaultBaseURLModeCLI
- default:
- return GrokDefaultBaseURLModeCLI
- }
- }
- func GrokBaseURLForMode(mode string) string {
- switch normalizeGrokDefaultBaseURLMode(mode) {
- case GrokDefaultBaseURLModeAPI:
- return xai.DefaultBaseURL
- case GrokDefaultBaseURLModeUSEast1:
- return xai.DefaultUSEast1BaseURL
- case GrokDefaultBaseURLModeUSWest2:
- return xai.DefaultUSWest2BaseURL
- case GrokDefaultBaseURLModeEUWest1:
- return xai.DefaultEUWest1BaseURL
- default:
- return xai.DefaultCLIBaseURL
- }
- }
- func (s *SettingService) GetGrokDefaultBaseURLMode(ctx context.Context) string {
- if s == nil || s.settingRepo == nil {
- return GrokDefaultBaseURLModeCLI
- }
- dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
- defer cancel()
- raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyGrokDefaultBaseURLMode)
- if err != nil {
- return GrokDefaultBaseURLModeCLI
- }
- return normalizeGrokDefaultBaseURLMode(raw)
- }
- func (s *SettingService) GetGrokDefaultBaseURL(ctx context.Context) string {
- return GrokBaseURLForMode(s.GetGrokDefaultBaseURLMode(ctx))
- }
- func (s *SettingService) ResolveGrokBaseURL(ctx context.Context, account *Account) string {
- def := xai.DefaultCLIBaseURL
- if s != nil {
- def = s.GetGrokDefaultBaseURL(ctx)
- }
- if account == nil {
- return def
- }
- return account.GetGrokBaseURLOr(def)
- }
- 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
- openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion
- openAICodexVersionSF singleflight.Group
- codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy
- codexRestrictionPolicySF singleflight.Group
- cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime
- cyberSessionBlockRuntimeSF singleflight.Group
- // panelRateLimitCache 面板 API 限流配置进程内缓存(*cachedPanelRateLimitSettings)。
- // 面板每个认证请求都会读取,禁止在热路径上直接访问 DB。
- panelRateLimitCache atomic.Value
- panelRateLimitSF 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
- openAIAPIKeyHealthBreakerCache atomic.Value // *cachedOpenAIAPIKeyHealthBreakerSettings
- channelMonitorRuntimeListenersMu sync.Mutex
- channelMonitorRuntimeListeners []func()
- }
- // 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) LoadForwardedClientIPSettings(ctx context.Context) error {
- if s == nil || s.cfg == nil || s.settingRepo == nil {
- return nil
- }
- values, err := s.settingRepo.GetMultiple(ctx, []string{
- SettingKeyAPIKeyACLTrustForwardedIP,
- SettingKeyForwardedClientIPHeaders,
- settingKeyForwardedClientIPModeV2,
- })
- if err != nil {
- s.cfg.SetForwardedClientIPSettings(false, nil)
- return fmt.Errorf("get forwarded client ip settings: %w", err)
- }
- enabled := s.cfg.Security.TrustForwardedIPForAPIKeyACL
- headers := s.cfg.ForwardedClientIPSettings().Headers
- storedValue, hasStoredValue := values[SettingKeyAPIKeyACLTrustForwardedIP]
- if hasStoredValue {
- enabled = storedValue == "true"
- }
- var headersErr error
- if storedHeaders, ok := values[SettingKeyForwardedClientIPHeaders]; ok {
- headers, headersErr = parseForwardedClientIPHeadersSetting(storedHeaders)
- if headersErr != nil {
- enabled = false
- headers = []string{}
- headersErr = fmt.Errorf("load forwarded client ip headers: %w", headersErr)
- }
- }
- updates := make(map[string]string)
- if _, hasStoredHeaders := values[SettingKeyForwardedClientIPHeaders]; !hasStoredHeaders {
- headersJSON, marshalErr := json.Marshal(headers)
- if marshalErr != nil {
- headers = []string{}
- headersErr = errors.Join(headersErr, fmt.Errorf("marshal forwarded client ip headers: %w", marshalErr))
- headersJSON = []byte("[]")
- }
- updates[SettingKeyForwardedClientIPHeaders] = string(headersJSON)
- }
- if values[settingKeyForwardedClientIPModeV2] != "true" {
- updates[settingKeyForwardedClientIPModeV2] = "true"
- // Before this migration, new installations persisted false by default.
- // Restore compatibility only when no trusted-proxy policy was configured.
- if headersErr == nil && hasStoredValue && !enabled && !s.cfg.Server.TrustedProxiesConfigured {
- enabled = true
- updates[SettingKeyAPIKeyACLTrustForwardedIP] = "true"
- }
- }
- if len(updates) > 0 {
- if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
- s.cfg.SetForwardedClientIPSettings(enabled, headers)
- return errors.Join(headersErr, fmt.Errorf("migrate forwarded client ip setting: %w", err))
- }
- }
- s.cfg.SetForwardedClientIPSettings(enabled, headers)
- return headersErr
- }
- // 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
- }
- // SubscribeChannelMonitorRuntime registers a listener that is invoked after
- // settings are successfully persisted (and process caches refreshed).
- // Used by ChannelMonitorRunner / ChannelMonitorV2Aggregator for immediate
- // mode flips without waiting for poll intervals.
- func (s *SettingService) SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func()) {
- if s == nil || listener == nil {
- return func() {}
- }
- s.channelMonitorRuntimeListenersMu.Lock()
- s.channelMonitorRuntimeListeners = append(s.channelMonitorRuntimeListeners, listener)
- idx := len(s.channelMonitorRuntimeListeners) - 1
- s.channelMonitorRuntimeListenersMu.Unlock()
- return func() {
- s.channelMonitorRuntimeListenersMu.Lock()
- defer s.channelMonitorRuntimeListenersMu.Unlock()
- if idx < 0 || idx >= len(s.channelMonitorRuntimeListeners) {
- return
- }
- s.channelMonitorRuntimeListeners[idx] = nil
- }
- }
- func (s *SettingService) notifyChannelMonitorRuntimeListeners() {
- if s == nil {
- return
- }
- s.channelMonitorRuntimeListenersMu.Lock()
- listeners := make([]func(), 0, len(s.channelMonitorRuntimeListeners))
- for _, l := range s.channelMonitorRuntimeListeners {
- if l != nil {
- listeners = append(listeners, l)
- }
- }
- s.channelMonitorRuntimeListenersMu.Unlock()
- for _, l := range listeners {
- func(fn func()) {
- defer func() {
- if recovered := recover(); recovered != nil {
- _ = recovered // keep settings path healthy
- }
- }()
- fn()
- }(l)
- }
- }
- // 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
- }
|