Files
sub2api/backend/internal/service/setting_gateway_runtime.go
T

1011 lines
41 KiB
Go
Raw Normal View History

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"golang.org/x/sync/singleflight"
)
// 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
claudeOAuthSystemPromptInjection bool
claudeOAuthSystemPrompt string
claudeOAuthSystemPromptBlocks string
anthropicCacheTTL1hInjection bool
rewriteMessageCacheControl bool
clientDatelineNormalization 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
// cachedAccountSchedulingThresholds 缓存平台自动停调阈值(进程内缓存,60s TTL)
type cachedAccountSchedulingThresholds struct {
thresholds map[string]int
expiresAt int64 // unix nano
}
var accountSchedulingThresholdsCache atomic.Value // *cachedAccountSchedulingThresholds
var accountSchedulingThresholdsSF singleflight.Group
const accountSchedulingThresholdsCacheTTL = 60 * time.Second
const accountSchedulingThresholdsErrorTTL = 5 * time.Second
const accountSchedulingThresholdsDBTimeout = 5 * time.Second
// cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL)
type cachedAntigravityUserAgentVersion struct {
version string
expiresAt int64 // unix nano
}
const antigravityUserAgentVersionCacheTTL = 60 * time.Second
const antigravityUserAgentVersionErrorTTL = 5 * time.Second
const antigravityUserAgentVersionDBTimeout = 5 * time.Second
// DefaultOpenAICodexUserAgent 是 OpenAI Codex 默认 User-Agent,用于规避浏览器 UA 的质询。
// 默认采用 codex-tui 身份,版本段随 codexCLIVersion 一起更新。
const DefaultOpenAICodexUserAgent = codexCLIUserAgent
// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL
type cachedOpenAICodexUserAgent struct {
value string
expiresAt int64 // unix nano
}
// cachedOpenAICodexClientVersion 缓存出站 Codex 客户端版本号(进程内缓存,60s TTL)
type cachedOpenAICodexClientVersion struct {
version string
expiresAt int64 // unix nano
}
const openAICodexClientVersionCacheTTL = 60 * time.Second
const openAICodexClientVersionErrorTTL = 5 * time.Second
const openAICodexClientVersionDBTimeout = 5 * time.Second
// openAICodexClientVersionSFKey singleflight 键。
const openAICodexClientVersionSFKey = "openai_codex_client_version"
type cachedOpenAIQuotaAutoPauseSettings struct {
settings OpsOpenAIAccountQuotaAutoPauseSettings
expiresAt int64
}
const openAICodexUserAgentCacheTTL = 60 * time.Second
const openAICodexUserAgentErrorTTL = 5 * time.Second
const openAICodexUserAgentDBTimeout = 5 * time.Second
const codexRestrictionPolicyCacheTTL = 60 * time.Second
const codexRestrictionPolicyDBTimeout = 5 * time.Second
// cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。
// GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。
type cachedCodexRestrictionPolicy struct {
value CodexRestrictionPolicy
expiresAt int64 // unix nano
}
// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。
// GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。
type cachedCyberSessionBlockRuntime struct {
enabled bool
ttl time.Duration
expiresAt int64 // unix nano
}
const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second
const cyberSessionBlockRuntimeErrorTTL = 5 * time.Second
const cyberSessionBlockRuntimeDBTimeout = 5 * time.Second
const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second
const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second
const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second
const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings"
// GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s
// 供网关热路径读取时避免 DB 往返。
// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。
// 默认值:开关 false,TTL 1h(与粘性会话对齐)。
func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) {
if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.enabled, cached.ttl
}
}
result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) {
if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached, nil
}
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cyberSessionBlockRuntimeDBTimeout)
defer cancel()
enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled)
ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds)
if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) {
slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr)
entry := &cachedCyberSessionBlockRuntime{
enabled: false,
ttl: time.Hour,
expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(),
}
s.cyberSessionBlockRuntimeCache.Store(entry)
return entry, nil
}
enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true"
ttl := time.Hour
if ttlErr == nil {
if n, perr := strconv.Atoi(strings.TrimSpace(ttlVal)); perr == nil && n > 0 {
ttl = time.Duration(n) * time.Second
}
}
entry := &cachedCyberSessionBlockRuntime{
enabled: enabled,
ttl: ttl,
expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(),
}
s.cyberSessionBlockRuntimeCache.Store(entry)
return entry, nil
})
if entry, ok := result.(*cachedCyberSessionBlockRuntime); ok && entry != nil {
return entry.enabled, entry.ttl
}
return false, time.Hour
}
// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。
// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。
func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string {
fallback := antigravity.GetDefaultUserAgentVersion()
if s == nil || s.settingRepo == nil {
return fallback
}
if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.version
}
}
result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) {
if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.version, nil
}
}
if ctx == nil {
ctx = context.Background()
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout)
defer cancel()
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion)
if err != nil && !errors.Is(err, ErrSettingNotFound) {
slog.Warn("failed to get antigravity user agent version setting", "error", err)
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
version: fallback,
expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(),
})
return fallback, nil
}
version := antigravity.NormalizeUserAgentVersion(value)
if version == "" {
version = fallback
}
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
version: version,
expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(),
})
return version, nil
})
if version, ok := result.(string); ok && version != "" {
return version
}
return fallback
}
// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。
// 后台设置优先;为空时回退到内置默认值。
func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string {
fallback := DefaultOpenAICodexUserAgent
if s == nil || s.settingRepo == nil {
return fallback
}
if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.value
}
}
result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) {
if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.value, nil
}
}
if ctx == nil {
ctx = context.Background()
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout)
defer cancel()
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent)
if err != nil && !errors.Is(err, ErrSettingNotFound) {
slog.Warn("failed to get openai codex user agent setting", "error", err)
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
value: fallback,
expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(),
})
return fallback, nil
}
ua := strings.TrimSpace(value)
if ua == "" {
ua = fallback
}
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
value: ua,
expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
})
return ua, nil
})
if ua, ok := result.(string); ok && ua != "" {
return ua
}
return fallback
}
// GetOpenAICodexClientVersion 返回出站声明的 Codex 客户端版本号。
// 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新稳定版 → 内置常量。
// 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布;
// 自动同步让运维不必为了跟版本而发新版本。
func (s *SettingService) GetOpenAICodexClientVersion(ctx context.Context) string {
fallback := codexCLIVersion
if s == nil || s.settingRepo == nil {
return fallback
}
if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.version
}
}
result, _, _ := s.openAICodexVersionSF.Do(openAICodexClientVersionSFKey, func() (any, error) {
if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.version, nil
}
}
if ctx == nil {
ctx = context.Background()
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexClientVersionDBTimeout)
defer cancel()
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
SettingKeyOpenAICodexClientVersion,
SettingKeyOpenAICodexClientVersionSynced,
})
if err != nil {
slog.Warn("failed to get openai codex client version setting", "error", err)
s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{
version: fallback,
expiresAt: time.Now().Add(openAICodexClientVersionErrorTTL).UnixNano(),
})
return fallback, nil
}
version := NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersion])
if version == "" {
version = NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersionSynced])
}
if version == "" {
version = fallback
}
s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{
version: version,
expiresAt: time.Now().Add(openAICodexClientVersionCacheTTL).UnixNano(),
})
return version, nil
})
if version, ok := result.(string); ok && version != "" {
return version
}
return fallback
}
// InvalidateOpenAICodexClientVersionCache 丢弃版本号缓存,下次读取回源。
// 面板保存与自动同步写入后调用。
func (s *SettingService) InvalidateOpenAICodexClientVersionCache() {
if s == nil {
return
}
s.openAICodexVersionSF.Forget(openAICodexClientVersionSFKey)
s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil))
}
// GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。
// 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。
//
// 面板 UA 只贡献客户端名与 OS / 架构 / 终端指纹,版本段一律用生效版本重建:该输入框是
// 唯一能改 UA 后缀的地方,但它填写于某个历史版本,逐字沿用会把出站身份永久钉死在陈旧
// 版本上并绕过自动同步——而陈旧身份正是上游优先降载的那一侧。
// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。
func (s *SettingService) GetOpenAICodexCanonicalUserAgent(ctx context.Context) string {
if s == nil {
return codexCLIUserAgent
}
version := s.GetOpenAICodexClientVersion(ctx)
ua := strings.TrimSpace(s.GetOpenAICodexUserAgent(ctx))
if ua == "" {
return buildCodexCLIUserAgent(version)
}
if rebuilt := openai.SetCodexUserAgentVersion(ua, version); rebuilt != "" {
return rebuilt
}
// 非 `{client}/{version}` 形态:交给 PairCodexClientIdentity 判定,
// 推导不出官方身份时由收口整体回退规范身份。
return ua
}
var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{
Originator: "Claude Code",
UAContains: []string{"Claude Code/"},
}
// MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code
// plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the
// same originator + UA marker pair, so runtime checks no longer need a separate flag.
func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error {
if s == nil || s.settingRepo == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
defer cancel()
legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return nil
}
return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err)
}
if strings.TrimSpace(legacyValue) != "true" {
return nil
}
rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist)
if err != nil && !errors.Is(err, ErrSettingNotFound) {
return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
}
var entries []openai.AllowedClientEntry
if strings.TrimSpace(rawWhitelist) != "" {
if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil {
return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
}
}
if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) {
return nil
}
entries = append(entries, legacyClaudeCodeCodexWhitelistEntry)
encoded, err := json.Marshal(entries)
if err != nil {
return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
}
if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil {
return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
}
s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
return nil
}
// MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint
// 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子,
// 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。
func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error {
if s == nil || s.settingRepo == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
defer cancel()
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" {
return nil // 已配置/已迁移
} else if err != nil && !errors.Is(err, ErrSettingNotFound) {
return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
}
bodyOn := false
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil {
bodyOn = strings.TrimSpace(v) == "true"
} else if !errors.Is(err, ErrSettingNotFound) {
return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err)
}
seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals))
copy(seed, openai.DefaultEngineFingerprintSignals)
if bodyOn {
for i := range seed {
if seed[i].Type == openai.FingerprintSignalBodyPath {
seed[i].Required = true
}
}
}
encoded, err := json.Marshal(seed)
if err != nil {
return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
}
if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil {
return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
}
s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
return nil
}
func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool {
wantOriginator := strings.TrimSpace(want.Originator)
if wantOriginator == "" {
return false
}
wantMarkers := normalizedCodexClientMarkers(want.UAContains)
if len(wantMarkers) == 0 {
return false
}
for _, entry := range entries {
if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) {
continue
}
gotMarkers := normalizedCodexClientMarkers(entry.UAContains)
if len(gotMarkers) != len(wantMarkers) {
continue
}
matched := true
for marker := range wantMarkers {
if _, ok := gotMarkers[marker]; !ok {
matched = false
break
}
}
if matched {
return true
}
}
return false
}
func normalizedCodexClientMarkers(markers []string) map[string]struct{} {
normalized := make(map[string]struct{}, len(markers))
for _, marker := range markers {
marker = strings.TrimSpace(marker)
if marker == "" {
continue
}
normalized[strings.ToLower(marker)] = struct{}{}
}
return normalized
}
// GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。
// 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。
// 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。
func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy {
if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.value
}
}
result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) {
if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.value, nil
}
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
defer cancel()
pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil {
pol.MinCodexVersion = strings.TrimSpace(v)
}
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil {
pol.MaxCodexVersion = strings.TrimSpace(v)
}
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil {
pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启
}
pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx)
pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist)
pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist)
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{
value: pol,
expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(),
})
return pol, nil
})
if pol, ok := result.(CodexRestrictionPolicy); ok {
return pol
}
return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}
}
// loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。
func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry {
v, err := s.settingRepo.GetValue(ctx, key)
if err != nil || strings.TrimSpace(v) == "" {
return nil
}
var entries []openai.AllowedClientEntry
if json.Unmarshal([]byte(v), &entries) != nil {
return nil
}
return entries
}
// loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。
func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal {
v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals)
if err != nil || strings.TrimSpace(v) == "" {
return openai.DefaultEngineFingerprintSignals
}
sigs, ok := openai.ParseEngineFingerprintSignals(v)
if !ok {
return openai.DefaultEngineFingerprintSignals
}
return sigs
}
// ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义):
// 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny,
// 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。
func ValidateCodexClientEntriesJSON(raw string) error {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
var entries []openai.AllowedClientEntry
if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}")
}
return nil
}
// ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求
// 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND:
// originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈,
// 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。
func ValidateCodexWhitelistEntriesJSON(raw string) error {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
var entries []openai.AllowedClientEntry
if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}")
}
for i, e := range entries {
if !e.IsWhitelistable() {
return fmt.Errorf("entry %d: whitelist requires a non-empty originator and at least one non-empty ua_contains (double-factor AND; otherwise the rule never matches)", i)
}
}
return nil
}
// ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。
func ValidateEngineFingerprintSignalsJSON(raw string) error {
return openai.ValidateEngineFingerprintSignalsJSON(raw)
}
// 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
}
type gatewayForwardingSettingsResult struct {
fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool
clientDatelineNormalization bool
claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string
}
func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult {
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return gatewayForwardingSettingsResult{
fp: cached.fingerprintUnification,
mp: cached.metadataPassthrough,
cch: cached.cchSigning,
claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection,
claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt,
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
clientDatelineNormalization: cached.clientDatelineNormalization,
}
}
}
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return gatewayForwardingSettingsResult{
fp: cached.fingerprintUnification,
mp: cached.metadataPassthrough,
cch: cached.cchSigning,
claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection,
claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt,
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
clientDatelineNormalization: cached.clientDatelineNormalization,
}, nil
}
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
defer cancel()
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
SettingKeyEnableFingerprintUnification,
SettingKeyEnableMetadataPassthrough,
SettingKeyEnableCCHSigning,
SettingKeyEnableClaudeOAuthSystemPromptInjection,
SettingKeyClaudeOAuthSystemPrompt,
SettingKeyClaudeOAuthSystemPromptBlocks,
SettingKeyEnableAnthropicCacheTTL1hInjection,
SettingKeyRewriteMessageCacheControl,
SettingKeyEnableClientDatelineNormalization,
})
if err != nil {
slog.Warn("failed to get gateway forwarding settings", "error", err)
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
fingerprintUnification: true,
metadataPassthrough: false,
cchSigning: false,
claudeOAuthSystemPromptInjection: true,
anthropicCacheTTL1hInjection: false,
rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(),
clientDatelineNormalization: true,
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
})
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
}
fp := true
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
fp = v == "true"
}
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
cch := values[SettingKeyEnableCCHSigning] == "true"
systemPromptInjection := true
if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" {
systemPromptInjection = v == "true"
}
systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt]
systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks]
cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl()
if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
rewriteMessageCacheControl = v == "true"
}
clientDatelineNormalization := true
if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
clientDatelineNormalization = v == "true"
}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
fingerprintUnification: fp,
metadataPassthrough: mp,
cchSigning: cch,
claudeOAuthSystemPromptInjection: systemPromptInjection,
claudeOAuthSystemPrompt: systemPrompt,
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
anthropicCacheTTL1hInjection: cacheTTL1h,
rewriteMessageCacheControl: rewriteMessageCacheControl,
clientDatelineNormalization: clientDatelineNormalization,
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
})
return gatewayForwardingSettingsResult{
fp: fp,
mp: mp,
cch: cch,
claudeOAuthSystemPromptInjection: systemPromptInjection,
claudeOAuthSystemPrompt: systemPrompt,
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
cacheTTL1h: cacheTTL1h,
rewriteMessageCacheControl: rewriteMessageCacheControl,
clientDatelineNormalization: clientDatelineNormalization,
}, nil
})
if r, ok := val.(gatewayForwardingSettingsResult); ok {
return r
}
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true}
}
// 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) {
result := s.getGatewayForwardingSettingsCached(ctx)
return result.fp, result.mp, result.cch
}
// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。
func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool {
return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h
}
// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。
func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool {
return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl
}
// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体
// 的客户端 dateline 归一化。默认开启。
func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool {
return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization
}
// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic
// system block switch, legacy custom expansion prompt, and configurable blocks JSON.
// Empty values mean use the built-in Claude Code default blocks.
func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) {
result := s.getGatewayForwardingSettingsCached(ctx)
return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks
}
// 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
}
// GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause
// settings. It is invoked on the OpenAI scheduling hot path (once per request) and is
// therefore designed to never block on the DB:
//
// - Fresh cached value → returned immediately.
// - Stale or empty cache → the last known value is returned, and a background
// goroutine refreshes the cache via singleflight (stale-while-revalidate).
// - First call with no cache yet → zero defaults are returned and the same async
// refresh is kicked off; the next call gets the freshly populated value.
//
// Callers that need the freshly persisted value synchronously (tests, post-update
// confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings.
func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
if s == nil {
return OpsOpenAIAccountQuotaAutoPauseSettings{}
}
cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
now := time.Now().UnixNano()
if cached != nil && now < cached.expiresAt {
return cached.settings
}
// Stale or unset: trigger background refresh without blocking this request.
// singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the
// returned channel — the result is observable via the atomic cache.
s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) {
s.refreshOpenAIQuotaAutoPauseSettings(context.Background())
return nil, nil
})
if cached != nil {
return cached.settings // serve stale value while revalidating
}
return OpsOpenAIAccountQuotaAutoPauseSettings{}
}
// WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings
// into the in-memory cache. Useful for application startup (so the first request hits
// a warm cache) and for tests that need deterministic reads immediately after
// constructing the service.
func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
if s == nil {
return OpsOpenAIAccountQuotaAutoPauseSettings{}
}
s.refreshOpenAIQuotaAutoPauseSettings(ctx)
cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
if cached == nil {
return OpsOpenAIAccountQuotaAutoPauseSettings{}
}
return cached.settings
}
// refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores
// them into the in-memory cache. On error it stores the prior value (or zero defaults
// if nothing is cached yet) with the shorter error TTL so the next refresh comes
// sooner. Always uses its own timeout-bounded context to keep refresh latency
// predictable regardless of the caller.
func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) {
if s == nil || s.settingRepo == nil {
return
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout)
defer cancel()
settings := OpsOpenAIAccountQuotaAutoPauseSettings{}
ttl := openAIQuotaAutoPauseSettingsCacheTTL
raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings)
if err == nil {
cfg := defaultOpsAdvancedSettings()
if strings.TrimSpace(raw) != "" {
if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil {
normalizeOpsAdvancedSettings(cfg)
}
}
settings = cfg.OpenAIAccountQuotaAutoPause
} else if !errors.Is(err, ErrSettingNotFound) {
// Real error: keep serving prior value but refresh sooner.
if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil {
settings = prior.settings
}
ttl = openAIQuotaAutoPauseSettingsErrorTTL
}
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
settings: settings,
expiresAt: time.Now().Add(ttl).UnixNano(),
})
}
// SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory
// cache. Called from settings-write code paths so that the next read reflects the new
// value immediately, without waiting for the background refresh.
func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) {
if s == nil {
return
}
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
settings: settings,
expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(),
})
}