Files
sub2api/backend/internal/service/setting_gateway_runtime.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1011 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
})
}