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
1011 lines
41 KiB
Go
1011 lines
41 KiB
Go
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(),
|
||
})
|
||
}
|