setting_service.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "strings"
  8. "sync/atomic"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  12. "golang.org/x/sync/singleflight"
  13. "sync"
  14. )
  15. const (
  16. GrokDefaultBaseURLModeAPI = "api"
  17. GrokDefaultBaseURLModeUSEast1 = "us-east-1"
  18. GrokDefaultBaseURLModeUSWest2 = "us-west-2"
  19. GrokDefaultBaseURLModeEUWest1 = "eu-west-1"
  20. GrokDefaultBaseURLModeCLI = "cli"
  21. )
  22. func normalizeGrokDefaultBaseURLMode(mode string) string {
  23. switch strings.ToLower(strings.TrimSpace(mode)) {
  24. case GrokDefaultBaseURLModeAPI:
  25. return GrokDefaultBaseURLModeAPI
  26. case GrokDefaultBaseURLModeUSEast1:
  27. return GrokDefaultBaseURLModeUSEast1
  28. case GrokDefaultBaseURLModeUSWest2:
  29. return GrokDefaultBaseURLModeUSWest2
  30. case GrokDefaultBaseURLModeEUWest1:
  31. return GrokDefaultBaseURLModeEUWest1
  32. case GrokDefaultBaseURLModeCLI:
  33. return GrokDefaultBaseURLModeCLI
  34. default:
  35. return GrokDefaultBaseURLModeCLI
  36. }
  37. }
  38. func GrokBaseURLForMode(mode string) string {
  39. switch normalizeGrokDefaultBaseURLMode(mode) {
  40. case GrokDefaultBaseURLModeAPI:
  41. return xai.DefaultBaseURL
  42. case GrokDefaultBaseURLModeUSEast1:
  43. return xai.DefaultUSEast1BaseURL
  44. case GrokDefaultBaseURLModeUSWest2:
  45. return xai.DefaultUSWest2BaseURL
  46. case GrokDefaultBaseURLModeEUWest1:
  47. return xai.DefaultEUWest1BaseURL
  48. default:
  49. return xai.DefaultCLIBaseURL
  50. }
  51. }
  52. func (s *SettingService) GetGrokDefaultBaseURLMode(ctx context.Context) string {
  53. if s == nil || s.settingRepo == nil {
  54. return GrokDefaultBaseURLModeCLI
  55. }
  56. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
  57. defer cancel()
  58. raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyGrokDefaultBaseURLMode)
  59. if err != nil {
  60. return GrokDefaultBaseURLModeCLI
  61. }
  62. return normalizeGrokDefaultBaseURLMode(raw)
  63. }
  64. func (s *SettingService) GetGrokDefaultBaseURL(ctx context.Context) string {
  65. return GrokBaseURLForMode(s.GetGrokDefaultBaseURLMode(ctx))
  66. }
  67. func (s *SettingService) ResolveGrokBaseURL(ctx context.Context, account *Account) string {
  68. def := xai.DefaultCLIBaseURL
  69. if s != nil {
  70. def = s.GetGrokDefaultBaseURL(ctx)
  71. }
  72. if account == nil {
  73. return def
  74. }
  75. return account.GetGrokBaseURLOr(def)
  76. }
  77. var (
  78. ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
  79. ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found")
  80. ErrDefaultSubGroupInvalid = infraerrors.BadRequest(
  81. "DEFAULT_SUBSCRIPTION_GROUP_INVALID",
  82. "default subscription group must exist and be subscription type",
  83. )
  84. ErrDefaultSubGroupDuplicate = infraerrors.BadRequest(
  85. "DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE",
  86. "default subscription group cannot be duplicated",
  87. )
  88. )
  89. type SettingRepository interface {
  90. Get(ctx context.Context, key string) (*Setting, error)
  91. GetValue(ctx context.Context, key string) (string, error)
  92. Set(ctx context.Context, key, value string) error
  93. GetMultiple(ctx context.Context, keys []string) (map[string]string, error)
  94. SetMultiple(ctx context.Context, settings map[string]string) error
  95. GetAll(ctx context.Context) (map[string]string, error)
  96. Delete(ctx context.Context, key string) error
  97. }
  98. // DefaultSubscriptionGroupReader validates group references used by default subscriptions.
  99. type DefaultSubscriptionGroupReader interface {
  100. GetByID(ctx context.Context, id int64) (*Group, error)
  101. }
  102. // WebSearchManagerBuilder creates a websearch.Manager from config (injected by infra layer).
  103. // proxyURLs maps proxy ID to resolved URL for provider-level proxy support.
  104. type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[int64]string)
  105. // SettingService 系统设置服务
  106. type SettingService struct {
  107. settingRepo SettingRepository
  108. defaultSubGroupReader DefaultSubscriptionGroupReader
  109. proxyRepo ProxyRepository // for resolving websearch provider proxy URLs
  110. cfg *config.Config
  111. onUpdate func() // Callback when settings are updated (for cache invalidation)
  112. version string // Application version
  113. webSearchManagerBuilder WebSearchManagerBuilder
  114. antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion
  115. antigravityUAVersionSF singleflight.Group
  116. openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent
  117. openAICodexUASF singleflight.Group
  118. openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion
  119. openAICodexVersionSF singleflight.Group
  120. codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy
  121. codexRestrictionPolicySF singleflight.Group
  122. cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime
  123. cyberSessionBlockRuntimeSF singleflight.Group
  124. // panelRateLimitCache 面板 API 限流配置进程内缓存(*cachedPanelRateLimitSettings)。
  125. // 面板每个认证请求都会读取,禁止在热路径上直接访问 DB。
  126. panelRateLimitCache atomic.Value
  127. panelRateLimitSF singleflight.Group
  128. // openAIQuotaAutoPauseSettingsCache holds the most recently observed quota auto-pause
  129. // settings. GetOpenAIQuotaAutoPauseSettings reads this atomic.Value on the request hot
  130. // path without ever blocking on the DB; when the cached entry expires, a background
  131. // goroutine refreshes it via openAIQuotaAutoPauseSettingsSF (stale-while-revalidate).
  132. // This per-service field also gives tests natural isolation — each SettingService
  133. // instance owns its own cache, no shared package-level state.
  134. openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings
  135. openAIQuotaAutoPauseSettingsSF singleflight.Group
  136. openAIAPIKeyHealthBreakerCache atomic.Value // *cachedOpenAIAPIKeyHealthBreakerSettings
  137. channelMonitorRuntimeListenersMu sync.Mutex
  138. channelMonitorRuntimeListeners []func()
  139. }
  140. // DefaultPlatformQuotaSetting 单 platform 三档限额(nil = 沿用上层;0 = 显式禁用;>0 = 上限)
  141. type DefaultPlatformQuotaSetting struct {
  142. DailyLimitUSD *float64 `json:"daily"`
  143. WeeklyLimitUSD *float64 `json:"weekly"`
  144. MonthlyLimitUSD *float64 `json:"monthly"`
  145. }
  146. type ProviderDefaultGrantSettings struct {
  147. Balance float64
  148. Concurrency int
  149. Subscriptions []DefaultSubscriptionSetting
  150. GrantOnSignup bool
  151. GrantOnFirstBind bool
  152. PlatformQuotas map[string]*DefaultPlatformQuotaSetting // key = platform name
  153. }
  154. type AuthSourceDefaultSettings struct {
  155. Email ProviderDefaultGrantSettings
  156. LinuxDo ProviderDefaultGrantSettings
  157. OIDC ProviderDefaultGrantSettings
  158. WeChat ProviderDefaultGrantSettings
  159. GitHub ProviderDefaultGrantSettings
  160. Google ProviderDefaultGrantSettings
  161. DingTalk ProviderDefaultGrantSettings
  162. ForceEmailOnThirdPartySignup bool
  163. }
  164. type authSourceDefaultKeySet struct {
  165. // source 是 auth source 标识(如 "email"、"github"),仅用于 parse 时
  166. // slog.Warn 诊断输出,不再参与 key 拼接(platformQuotas 字段已存完整 key)。
  167. source string
  168. balance string
  169. concurrency string
  170. subscriptions string
  171. grantOnSignup string
  172. grantOnFirstBind string
  173. platformQuotas string // SettingKeyAuthSourcePlatformQuotas(source)
  174. }
  175. var (
  176. emailAuthSourceDefaultKeys = authSourceDefaultKeySet{
  177. source: "email",
  178. balance: SettingKeyAuthSourceDefaultEmailBalance,
  179. concurrency: SettingKeyAuthSourceDefaultEmailConcurrency,
  180. subscriptions: SettingKeyAuthSourceDefaultEmailSubscriptions,
  181. grantOnSignup: SettingKeyAuthSourceDefaultEmailGrantOnSignup,
  182. grantOnFirstBind: SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
  183. platformQuotas: SettingKeyAuthSourcePlatformQuotas("email"),
  184. }
  185. linuxDoAuthSourceDefaultKeys = authSourceDefaultKeySet{
  186. source: "linuxdo",
  187. balance: SettingKeyAuthSourceDefaultLinuxDoBalance,
  188. concurrency: SettingKeyAuthSourceDefaultLinuxDoConcurrency,
  189. subscriptions: SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
  190. grantOnSignup: SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
  191. grantOnFirstBind: SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
  192. platformQuotas: SettingKeyAuthSourcePlatformQuotas("linuxdo"),
  193. }
  194. oidcAuthSourceDefaultKeys = authSourceDefaultKeySet{
  195. source: "oidc",
  196. balance: SettingKeyAuthSourceDefaultOIDCBalance,
  197. concurrency: SettingKeyAuthSourceDefaultOIDCConcurrency,
  198. subscriptions: SettingKeyAuthSourceDefaultOIDCSubscriptions,
  199. grantOnSignup: SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
  200. grantOnFirstBind: SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
  201. platformQuotas: SettingKeyAuthSourcePlatformQuotas("oidc"),
  202. }
  203. weChatAuthSourceDefaultKeys = authSourceDefaultKeySet{
  204. source: "wechat",
  205. balance: SettingKeyAuthSourceDefaultWeChatBalance,
  206. concurrency: SettingKeyAuthSourceDefaultWeChatConcurrency,
  207. subscriptions: SettingKeyAuthSourceDefaultWeChatSubscriptions,
  208. grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
  209. grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
  210. platformQuotas: SettingKeyAuthSourcePlatformQuotas("wechat"),
  211. }
  212. gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{
  213. source: "github",
  214. balance: SettingKeyAuthSourceDefaultGitHubBalance,
  215. concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency,
  216. subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions,
  217. grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
  218. grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
  219. platformQuotas: SettingKeyAuthSourcePlatformQuotas("github"),
  220. }
  221. googleAuthSourceDefaultKeys = authSourceDefaultKeySet{
  222. source: "google",
  223. balance: SettingKeyAuthSourceDefaultGoogleBalance,
  224. concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency,
  225. subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions,
  226. grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
  227. grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
  228. platformQuotas: SettingKeyAuthSourcePlatformQuotas("google"),
  229. }
  230. dingTalkAuthSourceDefaultKeys = authSourceDefaultKeySet{
  231. source: "dingtalk",
  232. balance: SettingKeyAuthSourceDefaultDingTalkBalance,
  233. concurrency: SettingKeyAuthSourceDefaultDingTalkConcurrency,
  234. subscriptions: SettingKeyAuthSourceDefaultDingTalkSubscriptions,
  235. grantOnSignup: SettingKeyAuthSourceDefaultDingTalkGrantOnSignup,
  236. grantOnFirstBind: SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind,
  237. platformQuotas: SettingKeyAuthSourcePlatformQuotas("dingtalk"),
  238. }
  239. )
  240. const (
  241. defaultAuthSourceBalance = 0
  242. defaultAuthSourceConcurrency = 5
  243. defaultWeChatConnectMode = "open"
  244. defaultWeChatConnectScopes = "snsapi_login"
  245. defaultWeChatConnectFrontend = "/auth/wechat/callback"
  246. defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize"
  247. defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token"
  248. defaultGitHubOAuthUserInfo = "https://api.github.com/user"
  249. defaultGitHubOAuthEmails = "https://api.github.com/user/emails"
  250. defaultGitHubOAuthScopes = "read:user user:email"
  251. defaultGitHubOAuthFrontend = "/auth/oauth/callback"
  252. defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth"
  253. defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token"
  254. defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
  255. defaultGoogleOAuthScopes = "openid email profile"
  256. defaultGoogleOAuthFrontend = "/auth/oauth/callback"
  257. defaultLoginAgreementMode = "modal"
  258. defaultLoginAgreementDate = "2026-03-31"
  259. )
  260. // NewSettingService 创建系统设置服务实例
  261. func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService {
  262. return &SettingService{
  263. settingRepo: settingRepo,
  264. cfg: cfg,
  265. }
  266. }
  267. // SetDefaultSubscriptionGroupReader injects an optional group reader for default subscription validation.
  268. func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscriptionGroupReader) {
  269. s.defaultSubGroupReader = reader
  270. }
  271. // SetProxyRepository injects a proxy repo for resolving websearch provider proxy URLs.
  272. func (s *SettingService) SetProxyRepository(repo ProxyRepository) {
  273. s.proxyRepo = repo
  274. }
  275. func (s *SettingService) LoadForwardedClientIPSettings(ctx context.Context) error {
  276. if s == nil || s.cfg == nil || s.settingRepo == nil {
  277. return nil
  278. }
  279. values, err := s.settingRepo.GetMultiple(ctx, []string{
  280. SettingKeyAPIKeyACLTrustForwardedIP,
  281. SettingKeyForwardedClientIPHeaders,
  282. settingKeyForwardedClientIPModeV2,
  283. })
  284. if err != nil {
  285. s.cfg.SetForwardedClientIPSettings(false, nil)
  286. return fmt.Errorf("get forwarded client ip settings: %w", err)
  287. }
  288. enabled := s.cfg.Security.TrustForwardedIPForAPIKeyACL
  289. headers := s.cfg.ForwardedClientIPSettings().Headers
  290. storedValue, hasStoredValue := values[SettingKeyAPIKeyACLTrustForwardedIP]
  291. if hasStoredValue {
  292. enabled = storedValue == "true"
  293. }
  294. var headersErr error
  295. if storedHeaders, ok := values[SettingKeyForwardedClientIPHeaders]; ok {
  296. headers, headersErr = parseForwardedClientIPHeadersSetting(storedHeaders)
  297. if headersErr != nil {
  298. enabled = false
  299. headers = []string{}
  300. headersErr = fmt.Errorf("load forwarded client ip headers: %w", headersErr)
  301. }
  302. }
  303. updates := make(map[string]string)
  304. if _, hasStoredHeaders := values[SettingKeyForwardedClientIPHeaders]; !hasStoredHeaders {
  305. headersJSON, marshalErr := json.Marshal(headers)
  306. if marshalErr != nil {
  307. headers = []string{}
  308. headersErr = errors.Join(headersErr, fmt.Errorf("marshal forwarded client ip headers: %w", marshalErr))
  309. headersJSON = []byte("[]")
  310. }
  311. updates[SettingKeyForwardedClientIPHeaders] = string(headersJSON)
  312. }
  313. if values[settingKeyForwardedClientIPModeV2] != "true" {
  314. updates[settingKeyForwardedClientIPModeV2] = "true"
  315. // Before this migration, new installations persisted false by default.
  316. // Restore compatibility only when no trusted-proxy policy was configured.
  317. if headersErr == nil && hasStoredValue && !enabled && !s.cfg.Server.TrustedProxiesConfigured {
  318. enabled = true
  319. updates[SettingKeyAPIKeyACLTrustForwardedIP] = "true"
  320. }
  321. }
  322. if len(updates) > 0 {
  323. if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
  324. s.cfg.SetForwardedClientIPSettings(enabled, headers)
  325. return errors.Join(headersErr, fmt.Errorf("migrate forwarded client ip setting: %w", err))
  326. }
  327. }
  328. s.cfg.SetForwardedClientIPSettings(enabled, headers)
  329. return headersErr
  330. }
  331. // GetAllSettings 获取所有系统设置
  332. func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) {
  333. settings, err := s.settingRepo.GetAll(ctx)
  334. if err != nil {
  335. return nil, fmt.Errorf("get all settings: %w", err)
  336. }
  337. return s.parseSettings(settings), nil
  338. }
  339. // SetOnUpdateCallback sets a callback function to be called when settings are updated
  340. // This is used for cache invalidation (e.g., HTML cache in frontend server)
  341. func (s *SettingService) SetOnUpdateCallback(callback func()) {
  342. s.onUpdate = callback
  343. }
  344. // SubscribeChannelMonitorRuntime registers a listener that is invoked after
  345. // settings are successfully persisted (and process caches refreshed).
  346. // Used by ChannelMonitorRunner / ChannelMonitorV2Aggregator for immediate
  347. // mode flips without waiting for poll intervals.
  348. func (s *SettingService) SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func()) {
  349. if s == nil || listener == nil {
  350. return func() {}
  351. }
  352. s.channelMonitorRuntimeListenersMu.Lock()
  353. s.channelMonitorRuntimeListeners = append(s.channelMonitorRuntimeListeners, listener)
  354. idx := len(s.channelMonitorRuntimeListeners) - 1
  355. s.channelMonitorRuntimeListenersMu.Unlock()
  356. return func() {
  357. s.channelMonitorRuntimeListenersMu.Lock()
  358. defer s.channelMonitorRuntimeListenersMu.Unlock()
  359. if idx < 0 || idx >= len(s.channelMonitorRuntimeListeners) {
  360. return
  361. }
  362. s.channelMonitorRuntimeListeners[idx] = nil
  363. }
  364. }
  365. func (s *SettingService) notifyChannelMonitorRuntimeListeners() {
  366. if s == nil {
  367. return
  368. }
  369. s.channelMonitorRuntimeListenersMu.Lock()
  370. listeners := make([]func(), 0, len(s.channelMonitorRuntimeListeners))
  371. for _, l := range s.channelMonitorRuntimeListeners {
  372. if l != nil {
  373. listeners = append(listeners, l)
  374. }
  375. }
  376. s.channelMonitorRuntimeListenersMu.Unlock()
  377. for _, l := range listeners {
  378. func(fn func()) {
  379. defer func() {
  380. if recovered := recover(); recovered != nil {
  381. _ = recovered // keep settings path healthy
  382. }
  383. }()
  384. fn()
  385. }(l)
  386. }
  387. }
  388. // SetVersion sets the application version for injection into public settings
  389. func (s *SettingService) SetVersion(version string) {
  390. s.version = version
  391. }
  392. // getStringOrDefault 获取字符串值或默认值
  393. func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string {
  394. if value, ok := settings[key]; ok && value != "" {
  395. return value
  396. }
  397. return defaultValue
  398. }