setting_gateway_runtime.go 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "strconv"
  9. "strings"
  10. "sync/atomic"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  14. "golang.org/x/sync/singleflight"
  15. )
  16. // cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL)
  17. type cachedVersionBounds struct {
  18. min string // 空字符串 = 不检查
  19. max string // 空字符串 = 不检查
  20. expiresAt int64 // unix nano
  21. }
  22. // versionBoundsCache 版本号上下限进程内缓存
  23. var versionBoundsCache atomic.Value // *cachedVersionBounds
  24. // versionBoundsSF 防止缓存过期时 thundering herd
  25. var versionBoundsSF singleflight.Group
  26. // versionBoundsCacheTTL 缓存有效期
  27. const versionBoundsCacheTTL = 60 * time.Second
  28. // versionBoundsErrorTTL DB 错误时的短缓存,快速重试
  29. const versionBoundsErrorTTL = 5 * time.Second
  30. // versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context
  31. const versionBoundsDBTimeout = 5 * time.Second
  32. // cachedBackendMode Backend Mode cache (in-process, 60s TTL)
  33. type cachedBackendMode struct {
  34. value bool
  35. expiresAt int64 // unix nano
  36. }
  37. var backendModeCache atomic.Value // *cachedBackendMode
  38. var backendModeSF singleflight.Group
  39. const backendModeCacheTTL = 60 * time.Second
  40. const backendModeErrorTTL = 5 * time.Second
  41. const backendModeDBTimeout = 5 * time.Second
  42. // cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
  43. type cachedGatewayForwardingSettings struct {
  44. openAITTFTMode string
  45. fingerprintUnification bool
  46. metadataPassthrough bool
  47. cchSigning bool
  48. claudeOAuthSystemPromptInjection bool
  49. claudeOAuthSystemPrompt string
  50. claudeOAuthSystemPromptBlocks string
  51. anthropicCacheTTL1hInjection bool
  52. rewriteMessageCacheControl bool
  53. clientDatelineNormalization bool
  54. expiresAt int64 // unix nano
  55. }
  56. var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings
  57. var gatewayForwardingSF singleflight.Group
  58. const gatewayForwardingCacheTTL = 60 * time.Second
  59. const gatewayForwardingErrorTTL = 5 * time.Second
  60. const gatewayForwardingDBTimeout = 5 * time.Second
  61. // cachedAccountSchedulingThresholds 缓存平台自动停调阈值(进程内缓存,60s TTL)
  62. type cachedAccountSchedulingThresholds struct {
  63. thresholds map[string]int
  64. expiresAt int64 // unix nano
  65. }
  66. var accountSchedulingThresholdsCache atomic.Value // *cachedAccountSchedulingThresholds
  67. var accountSchedulingThresholdsSF singleflight.Group
  68. const accountSchedulingThresholdsCacheTTL = 60 * time.Second
  69. const accountSchedulingThresholdsErrorTTL = 5 * time.Second
  70. const accountSchedulingThresholdsDBTimeout = 5 * time.Second
  71. // cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL)
  72. type cachedAntigravityUserAgentVersion struct {
  73. version string
  74. expiresAt int64 // unix nano
  75. }
  76. const antigravityUserAgentVersionCacheTTL = 60 * time.Second
  77. const antigravityUserAgentVersionErrorTTL = 5 * time.Second
  78. const antigravityUserAgentVersionDBTimeout = 5 * time.Second
  79. // DefaultOpenAICodexUserAgent 是 OpenAI Codex 默认 User-Agent,用于规避浏览器 UA 的质询。
  80. // 默认采用 codex-tui 身份,版本段随 codexCLIVersion 一起更新。
  81. const DefaultOpenAICodexUserAgent = codexCLIUserAgent
  82. // cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL)
  83. type cachedOpenAICodexUserAgent struct {
  84. value string
  85. expiresAt int64 // unix nano
  86. }
  87. // cachedOpenAICodexClientVersion 缓存出站 Codex 客户端版本号(进程内缓存,60s TTL)
  88. type cachedOpenAICodexClientVersion struct {
  89. version string
  90. expiresAt int64 // unix nano
  91. }
  92. const openAICodexClientVersionCacheTTL = 60 * time.Second
  93. const openAICodexClientVersionErrorTTL = 5 * time.Second
  94. const openAICodexClientVersionDBTimeout = 5 * time.Second
  95. // openAICodexClientVersionSFKey singleflight 键。
  96. const openAICodexClientVersionSFKey = "openai_codex_client_version"
  97. type cachedOpenAIQuotaAutoPauseSettings struct {
  98. settings OpsOpenAIAccountQuotaAutoPauseSettings
  99. expiresAt int64
  100. }
  101. const openAICodexUserAgentCacheTTL = 60 * time.Second
  102. const openAICodexUserAgentErrorTTL = 5 * time.Second
  103. const openAICodexUserAgentDBTimeout = 5 * time.Second
  104. const codexRestrictionPolicyCacheTTL = 60 * time.Second
  105. const codexRestrictionPolicyDBTimeout = 5 * time.Second
  106. // cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。
  107. // GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。
  108. type cachedCodexRestrictionPolicy struct {
  109. value CodexRestrictionPolicy
  110. expiresAt int64 // unix nano
  111. }
  112. // cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。
  113. // GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。
  114. type cachedCyberSessionBlockRuntime struct {
  115. enabled bool
  116. ttl time.Duration
  117. expiresAt int64 // unix nano
  118. }
  119. const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second
  120. const cyberSessionBlockRuntimeErrorTTL = 5 * time.Second
  121. const cyberSessionBlockRuntimeDBTimeout = 5 * time.Second
  122. const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second
  123. const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second
  124. const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second
  125. const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings"
  126. // GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s,
  127. // 供网关热路径读取时避免 DB 往返。
  128. // 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。
  129. // 默认值:开关 false,TTL 1h(与粘性会话对齐)。
  130. func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) {
  131. if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil {
  132. if time.Now().UnixNano() < cached.expiresAt {
  133. return cached.enabled, cached.ttl
  134. }
  135. }
  136. result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) {
  137. if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil {
  138. if time.Now().UnixNano() < cached.expiresAt {
  139. return cached, nil
  140. }
  141. }
  142. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cyberSessionBlockRuntimeDBTimeout)
  143. defer cancel()
  144. enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled)
  145. ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds)
  146. if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) {
  147. slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr)
  148. entry := &cachedCyberSessionBlockRuntime{
  149. enabled: false,
  150. ttl: time.Hour,
  151. expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(),
  152. }
  153. s.cyberSessionBlockRuntimeCache.Store(entry)
  154. return entry, nil
  155. }
  156. enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true"
  157. ttl := time.Hour
  158. if ttlErr == nil {
  159. if n, perr := strconv.Atoi(strings.TrimSpace(ttlVal)); perr == nil && n > 0 {
  160. ttl = time.Duration(n) * time.Second
  161. }
  162. }
  163. entry := &cachedCyberSessionBlockRuntime{
  164. enabled: enabled,
  165. ttl: ttl,
  166. expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(),
  167. }
  168. s.cyberSessionBlockRuntimeCache.Store(entry)
  169. return entry, nil
  170. })
  171. if entry, ok := result.(*cachedCyberSessionBlockRuntime); ok && entry != nil {
  172. return entry.enabled, entry.ttl
  173. }
  174. return false, time.Hour
  175. }
  176. // GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。
  177. // 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。
  178. func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string {
  179. fallback := antigravity.GetDefaultUserAgentVersion()
  180. if s == nil || s.settingRepo == nil {
  181. return fallback
  182. }
  183. if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
  184. if time.Now().UnixNano() < cached.expiresAt {
  185. return cached.version
  186. }
  187. }
  188. result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) {
  189. if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
  190. if time.Now().UnixNano() < cached.expiresAt {
  191. return cached.version, nil
  192. }
  193. }
  194. if ctx == nil {
  195. ctx = context.Background()
  196. }
  197. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout)
  198. defer cancel()
  199. value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion)
  200. if err != nil && !errors.Is(err, ErrSettingNotFound) {
  201. slog.Warn("failed to get antigravity user agent version setting", "error", err)
  202. s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
  203. version: fallback,
  204. expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(),
  205. })
  206. return fallback, nil
  207. }
  208. version := antigravity.NormalizeUserAgentVersion(value)
  209. if version == "" {
  210. version = fallback
  211. }
  212. s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
  213. version: version,
  214. expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(),
  215. })
  216. return version, nil
  217. })
  218. if version, ok := result.(string); ok && version != "" {
  219. return version
  220. }
  221. return fallback
  222. }
  223. // GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。
  224. // 后台设置优先;为空时回退到内置默认值。
  225. func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string {
  226. fallback := DefaultOpenAICodexUserAgent
  227. if s == nil || s.settingRepo == nil {
  228. return fallback
  229. }
  230. if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
  231. if time.Now().UnixNano() < cached.expiresAt {
  232. return cached.value
  233. }
  234. }
  235. result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) {
  236. if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
  237. if time.Now().UnixNano() < cached.expiresAt {
  238. return cached.value, nil
  239. }
  240. }
  241. if ctx == nil {
  242. ctx = context.Background()
  243. }
  244. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout)
  245. defer cancel()
  246. value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent)
  247. if err != nil && !errors.Is(err, ErrSettingNotFound) {
  248. slog.Warn("failed to get openai codex user agent setting", "error", err)
  249. s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
  250. value: fallback,
  251. expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(),
  252. })
  253. return fallback, nil
  254. }
  255. ua := strings.TrimSpace(value)
  256. if ua == "" {
  257. ua = fallback
  258. }
  259. s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
  260. value: ua,
  261. expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
  262. })
  263. return ua, nil
  264. })
  265. if ua, ok := result.(string); ok && ua != "" {
  266. return ua
  267. }
  268. return fallback
  269. }
  270. // GetOpenAICodexClientVersion 返回出站声明的 Codex 客户端版本号。
  271. // 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新稳定版 → 内置常量。
  272. // 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布;
  273. // 自动同步让运维不必为了跟版本而发新版本。
  274. func (s *SettingService) GetOpenAICodexClientVersion(ctx context.Context) string {
  275. fallback := codexCLIVersion
  276. if s == nil || s.settingRepo == nil {
  277. return fallback
  278. }
  279. if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil {
  280. if time.Now().UnixNano() < cached.expiresAt {
  281. return cached.version
  282. }
  283. }
  284. result, _, _ := s.openAICodexVersionSF.Do(openAICodexClientVersionSFKey, func() (any, error) {
  285. if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil {
  286. if time.Now().UnixNano() < cached.expiresAt {
  287. return cached.version, nil
  288. }
  289. }
  290. if ctx == nil {
  291. ctx = context.Background()
  292. }
  293. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexClientVersionDBTimeout)
  294. defer cancel()
  295. values, err := s.settingRepo.GetMultiple(dbCtx, []string{
  296. SettingKeyOpenAICodexClientVersion,
  297. SettingKeyOpenAICodexClientVersionSynced,
  298. })
  299. if err != nil {
  300. slog.Warn("failed to get openai codex client version setting", "error", err)
  301. s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{
  302. version: fallback,
  303. expiresAt: time.Now().Add(openAICodexClientVersionErrorTTL).UnixNano(),
  304. })
  305. return fallback, nil
  306. }
  307. version := NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersion])
  308. if version == "" {
  309. version = NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersionSynced])
  310. }
  311. if version == "" {
  312. version = fallback
  313. }
  314. s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{
  315. version: version,
  316. expiresAt: time.Now().Add(openAICodexClientVersionCacheTTL).UnixNano(),
  317. })
  318. return version, nil
  319. })
  320. if version, ok := result.(string); ok && version != "" {
  321. return version
  322. }
  323. return fallback
  324. }
  325. // InvalidateOpenAICodexClientVersionCache 丢弃版本号缓存,下次读取回源。
  326. // 面板保存与自动同步写入后调用。
  327. func (s *SettingService) InvalidateOpenAICodexClientVersionCache() {
  328. if s == nil {
  329. return
  330. }
  331. s.openAICodexVersionSF.Forget(openAICodexClientVersionSFKey)
  332. s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil))
  333. }
  334. // GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。
  335. // 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。
  336. //
  337. // 面板 UA 只贡献客户端名与 OS / 架构 / 终端指纹,版本段一律用生效版本重建:该输入框是
  338. // 唯一能改 UA 后缀的地方,但它填写于某个历史版本,逐字沿用会把出站身份永久钉死在陈旧
  339. // 版本上并绕过自动同步——而陈旧身份正是上游优先降载的那一侧。
  340. // 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。
  341. func (s *SettingService) GetOpenAICodexCanonicalUserAgent(ctx context.Context) string {
  342. if s == nil {
  343. return codexCLIUserAgent
  344. }
  345. version := s.GetOpenAICodexClientVersion(ctx)
  346. ua := strings.TrimSpace(s.GetOpenAICodexUserAgent(ctx))
  347. if ua == "" {
  348. return buildCodexCLIUserAgent(version)
  349. }
  350. if rebuilt := openai.SetCodexUserAgentVersion(ua, version); rebuilt != "" {
  351. return rebuilt
  352. }
  353. // 非 `{client}/{version}` 形态:交给 PairCodexClientIdentity 判定,
  354. // 推导不出官方身份时由收口整体回退规范身份。
  355. return ua
  356. }
  357. var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{
  358. Originator: "Claude Code",
  359. UAContains: []string{"Claude Code/"},
  360. }
  361. // MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code
  362. // plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the
  363. // same originator + UA marker pair, so runtime checks no longer need a separate flag.
  364. func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error {
  365. if s == nil || s.settingRepo == nil {
  366. return nil
  367. }
  368. if ctx == nil {
  369. ctx = context.Background()
  370. }
  371. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
  372. defer cancel()
  373. legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin)
  374. if err != nil {
  375. if errors.Is(err, ErrSettingNotFound) {
  376. return nil
  377. }
  378. return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err)
  379. }
  380. if strings.TrimSpace(legacyValue) != "true" {
  381. return nil
  382. }
  383. rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist)
  384. if err != nil && !errors.Is(err, ErrSettingNotFound) {
  385. return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
  386. }
  387. var entries []openai.AllowedClientEntry
  388. if strings.TrimSpace(rawWhitelist) != "" {
  389. if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil {
  390. return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
  391. }
  392. }
  393. if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) {
  394. return nil
  395. }
  396. entries = append(entries, legacyClaudeCodeCodexWhitelistEntry)
  397. encoded, err := json.Marshal(entries)
  398. if err != nil {
  399. return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
  400. }
  401. if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil {
  402. return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
  403. }
  404. s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
  405. s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
  406. return nil
  407. }
  408. // MigrateGrokDefaultTextModel upgrades the pre-4.6 built-in default for
  409. // existing installations. Explicit operator choices are left untouched.
  410. func (s *SettingService) MigrateGrokDefaultTextModel(ctx context.Context) error {
  411. if s == nil || s.settingRepo == nil {
  412. return nil
  413. }
  414. if ctx == nil {
  415. ctx = context.Background()
  416. }
  417. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
  418. defer cancel()
  419. value, err := s.settingRepo.GetValue(dbCtx, SettingKeyGrokDefaultTextModel)
  420. if err != nil {
  421. if errors.Is(err, ErrSettingNotFound) {
  422. return nil
  423. }
  424. return fmt.Errorf("get %s setting: %w", SettingKeyGrokDefaultTextModel, err)
  425. }
  426. // Only migrate the value that was previously shipped as the built-in
  427. // default. Any other value is an explicit operator choice or a future
  428. // default and must remain unchanged.
  429. if strings.TrimSpace(value) != "grok-4.5" {
  430. return nil
  431. }
  432. if err := s.settingRepo.Set(dbCtx, SettingKeyGrokDefaultTextModel, "grok-4.6"); err != nil {
  433. return fmt.Errorf("set %s setting: %w", SettingKeyGrokDefaultTextModel, err)
  434. }
  435. return nil
  436. }
  437. // MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint
  438. // 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子,
  439. // 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。
  440. func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error {
  441. if s == nil || s.settingRepo == nil {
  442. return nil
  443. }
  444. if ctx == nil {
  445. ctx = context.Background()
  446. }
  447. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
  448. defer cancel()
  449. if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" {
  450. return nil // 已配置/已迁移
  451. } else if err != nil && !errors.Is(err, ErrSettingNotFound) {
  452. return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
  453. }
  454. bodyOn := false
  455. if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil {
  456. bodyOn = strings.TrimSpace(v) == "true"
  457. } else if !errors.Is(err, ErrSettingNotFound) {
  458. return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err)
  459. }
  460. seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals))
  461. copy(seed, openai.DefaultEngineFingerprintSignals)
  462. if bodyOn {
  463. for i := range seed {
  464. if seed[i].Type == openai.FingerprintSignalBodyPath {
  465. seed[i].Required = true
  466. }
  467. }
  468. }
  469. encoded, err := json.Marshal(seed)
  470. if err != nil {
  471. return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
  472. }
  473. if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil {
  474. return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
  475. }
  476. s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
  477. s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
  478. return nil
  479. }
  480. func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool {
  481. wantOriginator := strings.TrimSpace(want.Originator)
  482. if wantOriginator == "" {
  483. return false
  484. }
  485. wantMarkers := normalizedCodexClientMarkers(want.UAContains)
  486. if len(wantMarkers) == 0 {
  487. return false
  488. }
  489. for _, entry := range entries {
  490. if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) {
  491. continue
  492. }
  493. gotMarkers := normalizedCodexClientMarkers(entry.UAContains)
  494. if len(gotMarkers) != len(wantMarkers) {
  495. continue
  496. }
  497. matched := true
  498. for marker := range wantMarkers {
  499. if _, ok := gotMarkers[marker]; !ok {
  500. matched = false
  501. break
  502. }
  503. }
  504. if matched {
  505. return true
  506. }
  507. }
  508. return false
  509. }
  510. func normalizedCodexClientMarkers(markers []string) map[string]struct{} {
  511. normalized := make(map[string]struct{}, len(markers))
  512. for _, marker := range markers {
  513. marker = strings.TrimSpace(marker)
  514. if marker == "" {
  515. continue
  516. }
  517. normalized[strings.ToLower(marker)] = struct{}{}
  518. }
  519. return normalized
  520. }
  521. // GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。
  522. // 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。
  523. // 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。
  524. func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy {
  525. if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil {
  526. if time.Now().UnixNano() < cached.expiresAt {
  527. return cached.value
  528. }
  529. }
  530. result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) {
  531. if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil {
  532. if time.Now().UnixNano() < cached.expiresAt {
  533. return cached.value, nil
  534. }
  535. }
  536. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
  537. defer cancel()
  538. pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号
  539. if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil {
  540. pol.MinCodexVersion = strings.TrimSpace(v)
  541. }
  542. if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil {
  543. pol.MaxCodexVersion = strings.TrimSpace(v)
  544. }
  545. if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil {
  546. pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启
  547. }
  548. pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx)
  549. pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist)
  550. pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist)
  551. s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{
  552. value: pol,
  553. expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(),
  554. })
  555. return pol, nil
  556. })
  557. if pol, ok := result.(CodexRestrictionPolicy); ok {
  558. return pol
  559. }
  560. return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}
  561. }
  562. // loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。
  563. func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry {
  564. v, err := s.settingRepo.GetValue(ctx, key)
  565. if err != nil || strings.TrimSpace(v) == "" {
  566. return nil
  567. }
  568. var entries []openai.AllowedClientEntry
  569. if json.Unmarshal([]byte(v), &entries) != nil {
  570. return nil
  571. }
  572. return entries
  573. }
  574. // loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。
  575. func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal {
  576. v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals)
  577. if err != nil || strings.TrimSpace(v) == "" {
  578. return openai.DefaultEngineFingerprintSignals
  579. }
  580. sigs, ok := openai.ParseEngineFingerprintSignals(v)
  581. if !ok {
  582. return openai.DefaultEngineFingerprintSignals
  583. }
  584. return sigs
  585. }
  586. // ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义):
  587. // 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny,
  588. // 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。
  589. func ValidateCodexClientEntriesJSON(raw string) error {
  590. trimmed := strings.TrimSpace(raw)
  591. if trimmed == "" {
  592. return nil
  593. }
  594. var entries []openai.AllowedClientEntry
  595. if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
  596. return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}")
  597. }
  598. return nil
  599. }
  600. // ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求
  601. // 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND:
  602. // originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈,
  603. // 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。
  604. func ValidateCodexWhitelistEntriesJSON(raw string) error {
  605. trimmed := strings.TrimSpace(raw)
  606. if trimmed == "" {
  607. return nil
  608. }
  609. var entries []openai.AllowedClientEntry
  610. if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
  611. return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}")
  612. }
  613. for i, e := range entries {
  614. if !e.IsWhitelistable() {
  615. 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)
  616. }
  617. }
  618. return nil
  619. }
  620. // ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。
  621. func ValidateEngineFingerprintSignalsJSON(raw string) error {
  622. return openai.ValidateEngineFingerprintSignalsJSON(raw)
  623. }
  624. // IsBackendModeEnabled checks if backend mode is enabled
  625. // Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path
  626. func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
  627. if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
  628. if time.Now().UnixNano() < cached.expiresAt {
  629. return cached.value
  630. }
  631. }
  632. result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) {
  633. if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
  634. if time.Now().UnixNano() < cached.expiresAt {
  635. return cached.value, nil
  636. }
  637. }
  638. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout)
  639. defer cancel()
  640. value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled)
  641. if err != nil {
  642. if errors.Is(err, ErrSettingNotFound) {
  643. // Setting not yet created (fresh install) - default to disabled with full TTL
  644. backendModeCache.Store(&cachedBackendMode{
  645. value: false,
  646. expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
  647. })
  648. return false, nil
  649. }
  650. slog.Warn("failed to get backend_mode_enabled setting", "error", err)
  651. backendModeCache.Store(&cachedBackendMode{
  652. value: false,
  653. expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(),
  654. })
  655. return false, nil
  656. }
  657. enabled := value == "true"
  658. backendModeCache.Store(&cachedBackendMode{
  659. value: enabled,
  660. expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
  661. })
  662. return enabled, nil
  663. })
  664. if val, ok := result.(bool); ok {
  665. return val
  666. }
  667. return false
  668. }
  669. type gatewayForwardingSettingsResult struct {
  670. openAITTFTMode string
  671. fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool
  672. clientDatelineNormalization bool
  673. claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string
  674. }
  675. func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult {
  676. if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
  677. if time.Now().UnixNano() < cached.expiresAt {
  678. return gatewayForwardingSettingsResult{
  679. openAITTFTMode: cached.openAITTFTMode,
  680. fp: cached.fingerprintUnification,
  681. mp: cached.metadataPassthrough,
  682. cch: cached.cchSigning,
  683. claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection,
  684. claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt,
  685. claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
  686. cacheTTL1h: cached.anthropicCacheTTL1hInjection,
  687. rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
  688. clientDatelineNormalization: cached.clientDatelineNormalization,
  689. }
  690. }
  691. }
  692. val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
  693. if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
  694. if time.Now().UnixNano() < cached.expiresAt {
  695. return gatewayForwardingSettingsResult{
  696. openAITTFTMode: cached.openAITTFTMode,
  697. fp: cached.fingerprintUnification,
  698. mp: cached.metadataPassthrough,
  699. cch: cached.cchSigning,
  700. claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection,
  701. claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt,
  702. claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
  703. cacheTTL1h: cached.anthropicCacheTTL1hInjection,
  704. rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
  705. clientDatelineNormalization: cached.clientDatelineNormalization,
  706. }, nil
  707. }
  708. }
  709. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
  710. defer cancel()
  711. values, err := s.settingRepo.GetMultiple(dbCtx, []string{
  712. SettingKeyOpenAITTFTMode,
  713. SettingKeyEnableFingerprintUnification,
  714. SettingKeyEnableMetadataPassthrough,
  715. SettingKeyEnableCCHSigning,
  716. SettingKeyEnableClaudeOAuthSystemPromptInjection,
  717. SettingKeyClaudeOAuthSystemPrompt,
  718. SettingKeyClaudeOAuthSystemPromptBlocks,
  719. SettingKeyEnableAnthropicCacheTTL1hInjection,
  720. SettingKeyRewriteMessageCacheControl,
  721. SettingKeyEnableClientDatelineNormalization,
  722. })
  723. if err != nil {
  724. slog.Warn("failed to get gateway forwarding settings", "error", err)
  725. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
  726. openAITTFTMode: OpenAITTFTModeSemantic,
  727. fingerprintUnification: true,
  728. metadataPassthrough: false,
  729. cchSigning: false,
  730. claudeOAuthSystemPromptInjection: true,
  731. anthropicCacheTTL1hInjection: false,
  732. rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(),
  733. clientDatelineNormalization: true,
  734. expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
  735. })
  736. return gatewayForwardingSettingsResult{openAITTFTMode: OpenAITTFTModeSemantic, fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
  737. }
  738. ttftMode := normalizeOpenAITTFTMode(values[SettingKeyOpenAITTFTMode])
  739. fp := true
  740. if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
  741. fp = v == "true"
  742. }
  743. mp := values[SettingKeyEnableMetadataPassthrough] == "true"
  744. cch := values[SettingKeyEnableCCHSigning] == "true"
  745. systemPromptInjection := true
  746. if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" {
  747. systemPromptInjection = v == "true"
  748. }
  749. systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt]
  750. systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks]
  751. cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
  752. rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl()
  753. if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
  754. rewriteMessageCacheControl = v == "true"
  755. }
  756. clientDatelineNormalization := true
  757. if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
  758. clientDatelineNormalization = v == "true"
  759. }
  760. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
  761. openAITTFTMode: ttftMode,
  762. fingerprintUnification: fp,
  763. metadataPassthrough: mp,
  764. cchSigning: cch,
  765. claudeOAuthSystemPromptInjection: systemPromptInjection,
  766. claudeOAuthSystemPrompt: systemPrompt,
  767. claudeOAuthSystemPromptBlocks: systemPromptBlocks,
  768. anthropicCacheTTL1hInjection: cacheTTL1h,
  769. rewriteMessageCacheControl: rewriteMessageCacheControl,
  770. clientDatelineNormalization: clientDatelineNormalization,
  771. expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
  772. })
  773. return gatewayForwardingSettingsResult{
  774. openAITTFTMode: ttftMode,
  775. fp: fp,
  776. mp: mp,
  777. cch: cch,
  778. claudeOAuthSystemPromptInjection: systemPromptInjection,
  779. claudeOAuthSystemPrompt: systemPrompt,
  780. claudeOAuthSystemPromptBlocks: systemPromptBlocks,
  781. cacheTTL1h: cacheTTL1h,
  782. rewriteMessageCacheControl: rewriteMessageCacheControl,
  783. clientDatelineNormalization: clientDatelineNormalization,
  784. }, nil
  785. })
  786. if r, ok := val.(gatewayForwardingSettingsResult); ok {
  787. return r
  788. }
  789. return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true}
  790. }
  791. // GetOpenAITTFTMode 返回 Responses first_token_ms 的统计口径。
  792. func (s *SettingService) GetOpenAITTFTMode(ctx context.Context) string {
  793. return s.getGatewayForwardingSettingsCached(ctx).openAITTFTMode
  794. }
  795. // GetGatewayForwardingSettings returns cached gateway forwarding settings.
  796. // Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
  797. // Returns (fingerprintUnification, metadataPassthrough, cchSigning).
  798. func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
  799. result := s.getGatewayForwardingSettingsCached(ctx)
  800. return result.fp, result.mp, result.cch
  801. }
  802. // IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。
  803. func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool {
  804. return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h
  805. }
  806. // IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。
  807. func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool {
  808. return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl
  809. }
  810. // IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体
  811. // 的客户端 dateline 归一化。默认开启。
  812. func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool {
  813. return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization
  814. }
  815. // GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic
  816. // system block switch, legacy custom expansion prompt, and configurable blocks JSON.
  817. // Empty values mean use the built-in Claude Code default blocks.
  818. func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) {
  819. result := s.getGatewayForwardingSettingsCached(ctx)
  820. return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks
  821. }
  822. // GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求
  823. // 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销
  824. // singleflight 防止缓存过期时 thundering herd
  825. // 返回空字符串表示不做对应方向的版本检查
  826. func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) {
  827. if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
  828. if time.Now().UnixNano() < cached.expiresAt {
  829. return cached.min, cached.max
  830. }
  831. }
  832. // singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果
  833. type bounds struct{ min, max string }
  834. result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) {
  835. // 二次检查,避免排队的 goroutine 重复查询
  836. if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
  837. if time.Now().UnixNano() < cached.expiresAt {
  838. return bounds{cached.min, cached.max}, nil
  839. }
  840. }
  841. // 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存
  842. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout)
  843. defer cancel()
  844. values, err := s.settingRepo.GetMultiple(dbCtx, []string{
  845. SettingKeyMinClaudeCodeVersion,
  846. SettingKeyMaxClaudeCodeVersion,
  847. })
  848. if err != nil {
  849. // fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试
  850. slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err)
  851. versionBoundsCache.Store(&cachedVersionBounds{
  852. min: "",
  853. max: "",
  854. expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(),
  855. })
  856. return bounds{"", ""}, nil
  857. }
  858. b := bounds{
  859. min: values[SettingKeyMinClaudeCodeVersion],
  860. max: values[SettingKeyMaxClaudeCodeVersion],
  861. }
  862. versionBoundsCache.Store(&cachedVersionBounds{
  863. min: b.min,
  864. max: b.max,
  865. expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
  866. })
  867. return b, nil
  868. })
  869. if err != nil {
  870. return "", ""
  871. }
  872. b, ok := result.(bounds)
  873. if !ok {
  874. return "", ""
  875. }
  876. return b.min, b.max
  877. }
  878. // GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause
  879. // settings. It is invoked on the OpenAI scheduling hot path (once per request) and is
  880. // therefore designed to never block on the DB:
  881. //
  882. // - Fresh cached value → returned immediately.
  883. // - Stale or empty cache → the last known value is returned, and a background
  884. // goroutine refreshes the cache via singleflight (stale-while-revalidate).
  885. // - First call with no cache yet → zero defaults are returned and the same async
  886. // refresh is kicked off; the next call gets the freshly populated value.
  887. //
  888. // Callers that need the freshly persisted value synchronously (tests, post-update
  889. // confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings.
  890. func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
  891. if s == nil {
  892. return OpsOpenAIAccountQuotaAutoPauseSettings{}
  893. }
  894. cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
  895. now := time.Now().UnixNano()
  896. if cached != nil && now < cached.expiresAt {
  897. return cached.settings
  898. }
  899. // Stale or unset: trigger background refresh without blocking this request.
  900. // singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the
  901. // returned channel — the result is observable via the atomic cache.
  902. s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) {
  903. s.refreshOpenAIQuotaAutoPauseSettings(context.Background())
  904. return nil, nil
  905. })
  906. if cached != nil {
  907. return cached.settings // serve stale value while revalidating
  908. }
  909. return OpsOpenAIAccountQuotaAutoPauseSettings{}
  910. }
  911. // WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings
  912. // into the in-memory cache. Useful for application startup (so the first request hits
  913. // a warm cache) and for tests that need deterministic reads immediately after
  914. // constructing the service.
  915. func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
  916. if s == nil {
  917. return OpsOpenAIAccountQuotaAutoPauseSettings{}
  918. }
  919. s.refreshOpenAIQuotaAutoPauseSettings(ctx)
  920. cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
  921. if cached == nil {
  922. return OpsOpenAIAccountQuotaAutoPauseSettings{}
  923. }
  924. return cached.settings
  925. }
  926. // refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores
  927. // them into the in-memory cache. On error it stores the prior value (or zero defaults
  928. // if nothing is cached yet) with the shorter error TTL so the next refresh comes
  929. // sooner. Always uses its own timeout-bounded context to keep refresh latency
  930. // predictable regardless of the caller.
  931. func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) {
  932. if s == nil || s.settingRepo == nil {
  933. return
  934. }
  935. dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout)
  936. defer cancel()
  937. settings := OpsOpenAIAccountQuotaAutoPauseSettings{}
  938. ttl := openAIQuotaAutoPauseSettingsCacheTTL
  939. raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings)
  940. if err == nil {
  941. cfg := defaultOpsAdvancedSettings()
  942. if strings.TrimSpace(raw) != "" {
  943. if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil {
  944. normalizeOpsAdvancedSettings(cfg)
  945. }
  946. }
  947. settings = cfg.OpenAIAccountQuotaAutoPause
  948. } else if !errors.Is(err, ErrSettingNotFound) {
  949. // Real error: keep serving prior value but refresh sooner.
  950. if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil {
  951. settings = prior.settings
  952. }
  953. ttl = openAIQuotaAutoPauseSettingsErrorTTL
  954. }
  955. s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
  956. settings: settings,
  957. expiresAt: time.Now().Add(ttl).UnixNano(),
  958. })
  959. }
  960. // SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory
  961. // cache. Called from settings-write code paths so that the next read reflects the new
  962. // value immediately, without waiting for the background refresh.
  963. func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) {
  964. if s == nil {
  965. return
  966. }
  967. s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
  968. settings: settings,
  969. expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(),
  970. })
  971. }