Files
sub2api/backend/internal/service/setting_service.go
T
zhiyu 2eb24814fe fix(codex): 强制统一出站身份并让客户端版本号跟随官方发布
上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载,被降载的请求
HTTP 200 后立刻推流内 server_is_overloaded。此前网关对配不出官方身份的客户端
整体回退到硬编码的 codex_cli_rs/0.144.1(落后官方 4 个发布),这些请求稳定
落在被优先丢弃的一侧。

- 强制统一出口:所有 OAuth 出站的 User-Agent / originator / version 一律改写
  为网关规范身份,客户端自报身份不参与构造;HTTP / 透传 / WS / alpha-search /
  探针全覆盖。compat 桥接故意删除 originator 的路径保持 no-op。
- 版本号收敛为单一来源,运行时优先级为面板覆写 → 自动同步值 → 内置常量;
  UA 与 version 头同源派生,不再各自硬编码。
- 新增 3 小时自动同步官方客户端最新稳定版,面板可关闭,无需为跟版本而发版。
- 流内 server_is_overloaded / slow_down 改为先在同账号有界重试再切号,并标记为
  请求级瞬时故障,不再据此临时封禁账号。
- 移除被取代的降载身份黑名单、浏览器 UA 兜底及其辅助函数。
2026-08-03 20:14:58 +08:00

317 lines
13 KiB
Go

package service
import (
"context"
"encoding/json"
"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
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
}
// 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
}
// 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
}