Compare commits

..
314 Commits
Author SHA1 Message Date
Wesley Liddick 27f094e096 Merge pull request #4638 from superman2003/fix/s3-secret-ephemeral-encryption-key
fix(backup): 拒绝用自动生成的临时密钥持久化 S3 SecretAccessKey,修复重启后解密失败
2026-07-20 16:21:46 +08:00
shaw fa402b909a feat(branding): 启用新版 SVG logo 并在 README 头部展示
- 新增 assets/logo.svg 作为项目默认 logo(512x512 深色圆角底 + 渐变 S 标)
- 前端 favicon 与组件兜底 logo 由 logo.png 切换为 frontend/public/logo.svg
- 三个 README(EN/CN/JA)头部新增居中 logo 与标题布局
- 删除旧 logo.png 及多余的 lobe 变体 SVG
2026-07-20 16:06:59 +08:00
Wesley Liddick 2e5973b165 Merge pull request #4575 from Astrenix/feat/batch-image-guide-i18n
feat(frontend): 批量生图页面完整迁移至 vue-i18n
2026-07-20 15:32:21 +08:00
Wesley Liddick ecf199d29d Merge pull request #4573 from Astrenix/fix/dark-palette-slate-consistency
style(frontend): 统一暗色模式表面色板为项目 slate 色系
2026-07-20 15:32:16 +08:00
Wesley Liddick 7cbb0d8552 Merge pull request #4629 from yan9651688/fix/issue-4609-plan-currency-symbol
fix(payment): show plan prices with configured currency symbols
2026-07-20 15:32:11 +08:00
Wesley Liddick c4a8878f1f Merge pull request #4626 from put-go/fix/grok-chained-video-content
fix(grok): support chained video content proxies
2026-07-20 15:30:46 +08:00
Wesley Liddick 95c8b2401e Merge pull request #4616 from zhangxaochen/codex/openai-quota-error
fix(openai): return standard insufficient quota errors
2026-07-20 15:30:41 +08:00
Wesley Liddick e669e51b92 Merge pull request #4565 from abbzbb/fix/prompt-audit-fail-closed-4560
fix(security-audit): 仅在 blocking 意图下 fail-closed,修复无法关闭审计
2026-07-20 15:30:36 +08:00
Wesley Liddick ce9e70d5cf Merge pull request #4569 from Astrenix/fix/codex-models-openai-list-envelope
fix(openai): accept standard OpenAI model list for Codex model discovery
2026-07-20 15:30:31 +08:00
Wesley Liddick deb5e8756a Merge pull request #4572 from catoncat/fix/openai-agent-identity-team-isolation
fix(openai): 按 Team 隔离 Agent Identity 导入
2026-07-20 15:30:26 +08:00
Wesley Liddick 2ee66c401d Merge pull request #4574 from Astrenix/fix/i18n-hardcoded-strings
fix(frontend): 补齐零星硬编码 UI 文案的国际化
2026-07-20 15:29:05 +08:00
Wesley Liddick 3071d41443 Merge pull request #4570 from Astrenix/fix/available-channels-scroll
fix(frontend): 修复可用渠道页面内容过多时无法滚动的问题
2026-07-20 15:29:00 +08:00
Wesley Liddick 58ee0c8d75 Merge pull request #4571 from Astrenix/fix/user-balance-modal-dark-text
fix(frontend): 修复余额弹窗暗色模式下用户邮箱不可读
2026-07-20 15:28:56 +08:00
Wesley Liddick 78f85583ea Merge pull request #4630 from feitianbubu/fix/api-key-update-ip-list-clear
fix: 部分更新 API Key 时不再静默清空 IP 白/黑名单
2026-07-20 15:28:51 +08:00
superman2003 7a7f51a534 fix(backup): 拒绝用自动生成的临时密钥持久化 S3 SecretAccessKey
totp.encryption_key 未配置时,加密密钥每次进程启动都会随机重新生成。
备份/图床把 S3 SecretAccessKey 用这把临时密钥加密落库后,重启或升级
即无法解密(decrypt: cipher: message authentication failed),S3 备份
与图床静默失效(#4524)。

对齐支付(payment.ProvideEncryptionKey)与 TOTP 启用已有的护栏:在
持久化新 secret 之前,若密钥非固定配置则返回可操作的
SECRET_ENCRYPTION_KEY_NOT_CONFIGURED(400),提示配置固定
TOTP_ENCRYPTION_KEY。不带 secret 的更新(沿用已存值)与
ReuseBackupS3 模式不受影响;已配置固定密钥的部署无感。
2026-07-20 15:20:51 +08:00
Wesley Liddick db4295d646 Merge pull request #4591 from huangjunsen0406/fix/grok-claude-messages-prompt-cache
fix(grok): improve Claude Messages prompt-cache round-trip
2026-07-20 11:57:36 +08:00
shaw 3c41984733 style(apicompat): gofmt types.go after merge 2026-07-20 11:47:13 +08:00
shaw 04617cc0ff Merge branch 'main' into fix/grok-claude-messages-prompt-cache
Resolve const-block conflict in openai_gateway_grok_cache.go:
keep #4590 client tool cache constants and #4585 xai-package limit
(grokFreeRolling24hTokenLimit removed), add claudeCodeSessionHeader.
2026-07-20 11:39:37 +08:00
Wesley Liddick dabca7ca79 Merge pull request #4585 from superman2003/fix/issues-4561-4562-4566-4582
fix(grok): improve token counting, quota probes, and proxy checks
2026-07-20 11:37:02 +08:00
shaw a90c18cbea Merge branch 'main' into fix/issues-4561-4562-4566-4582
Resolve const-block conflict in openai_gateway_grok_cache.go:
keep #4590's client tool cache constants, drop grokFreeRolling24hTokenLimit
(moved to pkg/xai as IsGrokFreeRolling24hTokenLimit with legacy 2M support).
2026-07-20 11:25:11 +08:00
feitianbubu e502766170 fix: 部分更新 API Key 时不再静默清空 IP 白/黑名单 2026-07-20 11:15:37 +08:00
Wesley Liddick b8e844f4ee Merge pull request #4612 from catoncat/fix/codex-call-id-length
fix(openai): 限制 Codex call_id 归一化后的长度
2026-07-20 11:05:18 +08:00
Wesley Liddick 80a533c890 Merge pull request #4606 from orchief/codex/issue-4603-dedupe-oauth-system-prompt
fix(openai): 避免 OAuth Chat 转译重复 system 前缀
2026-07-20 11:03:00 +08:00
Wesley Liddick 6d152893ff Merge pull request #4590 from superman2003/fix/grok-client-cache-codex-trae
fix(grok): cache client tools from Codex, Trae, and Claude Desktop
2026-07-20 10:47:05 +08:00
shaw 97afb4089c fix(lint): check strings.Builder WriteString return value (errcheck) 2026-07-20 10:46:37 +08:00
put-go 48fad26433 fix(grok): support chained video content proxies 2026-07-20 10:40:18 +08:00
yan9651688 a05b873215 fix(payment): prevent configured currencies from looking like USD
Subscription plans can carry a display-only ISO currency code, but the admin plan table and user plan cards still used a hard-coded dollar sign. Resolve the symbol through the shared currency helper while retaining existing number formatting, the currency code label, and legacy blank-currency behavior.

Constraint: Plans without a currency must keep the legacy USD display

Rejected: Reformat complete prices with Intl.NumberFormat | it would change existing decimals and remove the explicit currency code

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Keep plan currency symbols aligned across admin and user displays

Tested: targeted Vitest 4 tests; targeted ESLint; vue-tsc; Vite production build; full Vitest 1222 tests passed

Not-tested: Full Vitest has 2 unrelated upstream failures in admin.system.rollback.spec.ts

Related: https://github.com/Wei-Shaw/sub2api/issues/4609
2026-07-20 10:33:04 +08:00
Wesley Liddick 1b4029b050 Merge pull request #4595 from kinsolee/fix/http-bridge-failover-terminal-safety
fix(openai): make HTTP bridge failover safe
2026-07-20 10:29:00 +08:00
Wesley Liddick 139a3d4346 Merge pull request #4589 from huangjunsen0406/fix/anthropic-message-start-stop-reason-null
fix(apicompat): emit null stop_reason on Anthropic message_start
2026-07-20 10:28:52 +08:00
Wesley Liddick 8c9cda1803 Merge pull request #4583 from BenjaminAaron196/codex/ops-report-email-template
(feat) 运维定时报表支持可编辑摘要模板与多语言展示
2026-07-20 10:28:44 +08:00
Wesley Liddick 0a8831777b Merge pull request #4619 from superman2003/fix/grok-manual-test-scheduling-gate
fix(grok): let manual connection tests bypass the scheduling gate
2026-07-20 10:26:55 +08:00
Wesley Liddick 34c8dbd604 Merge pull request #4618 from superman2003/fix/system-update-detach-request-ctx
fix(update): detach in-place update from the HTTP request lifetime
2026-07-20 10:26:47 +08:00
Wesley Liddick e311d368d3 Merge pull request #4611 from superman2003/fix/codex-models-manifest-401-unschedulable
fix(openai): mark OAuth accounts unschedulable on Codex models manifest 401
2026-07-20 10:26:39 +08:00
Wesley Liddick f133fde643 Merge pull request #4602 from jianjianai/new/perf-responses-image-intent
perf(openai): 复用 Responses 生图意图判定
2026-07-20 10:26:31 +08:00
Wesley Liddick c0b8f6b0a8 Merge pull request #4601 from jianjianai/new/perf-responses-sse-type
perf(openai): 复用 Responses SSE 事件类型解析
2026-07-20 10:26:23 +08:00
Wesley Liddick 9ccc9077cc Merge pull request #4581 from wucm667/feat/issue-4375-github-release-token
feat: support GitHub token for update checks
2026-07-20 10:26:15 +08:00
Wesley Liddick f5e484aa70 Merge pull request #4625 from wucm667/fix/issue-4624-sticky-force-cache-billing
fix(handler): avoid cache billing on same-account retry
2026-07-20 10:24:38 +08:00
Wesley Liddick aa615c2677 Merge pull request #4620 from cugxuan/codex/show-subscription-expiry-minute
feat(subscription): show expiry times to minute
2026-07-20 10:24:30 +08:00
Wesley Liddick 032001598a Merge pull request #4617 from wucm667/fix/issue-4610-anthropic-json-content-type
fix(openai): correct buffered messages content type
2026-07-20 10:24:22 +08:00
Wesley Liddick 6def36a171 Merge pull request #4614 from cugxuan/codex/fix-subscription-expiry-days
fix(subscription): round remaining days up
2026-07-20 10:24:14 +08:00
Wesley Liddick d2ef0cb151 Merge pull request #4587 from coo1white/fix-compose-redis-dev-local
fix(deploy): make the redis command flags take effect in dev and local compose
2026-07-20 10:24:07 +08:00
Wesley Liddick 25bd4956f0 Merge pull request #4588 from coo1white/wire-postgres-tuning-vars
fix(deploy): pass the documented postgres tuning values to postgres
2026-07-20 10:23:59 +08:00
shaw 2518691dbc fix(config): 为 trusted_proxies 与 forwarded_client_ip_headers 注册 BindEnv,修复 env 可达性守卫测试
#4593 引入的 TestConfigKeysAreEnvReachable 与 #4604 新增的两个配置键语义相撞:
两键在 setDefaults 中无注册,守卫测试判定其环境变量不可达。

不能按守卫注释直接 SetDefault:viper 的 IsSet 会连同 defaults 一起上报,
任何已注册默认值都会让 load() 中 trustedProxiesConfigured 的显式配置探测
永远为真,摧毁 #4600 的 absent/empty 区分。改用 BindEnv 注册——绑定键同样
进入 AllKeys()(守卫测试与 Unmarshal 均可见),而变量缺席时不影响 IsSet。
两个环境变量本就由 load() 中 os.LookupEnv 手工解析,行为不变。
2026-07-20 09:41:02 +08:00
Wesley Liddick bfabfe60c8 Merge pull request #4593 from StarryKira/fix/image-storage-env-unreachable
fix: 异步生图开关配了却不生效(环境变量被静默丢弃 + 迁移到后台开关)
2026-07-20 09:20:15 +08:00
Wesley Liddick f3312cf7d9 Merge pull request #4604 from BenjaminAaron196/codex/fix-issue-4600-client-ip
fix: 修复客户端 IP 回退并支持自定义 CDN 请求头
2026-07-20 09:20:02 +08:00
shaw 30202f8266 chore: update sponsors 2026-07-20 08:55:15 +08:00
wucm667 a2acbf553b fix(handler): avoid cache billing on same-account retry 2026-07-20 02:20:58 +08:00
JlypxandSisyphus dd0cbe91c9 fix: 避免复制客户端 IP 原子状态
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:22:45 +08:00
JlypxandSisyphus 0cc382e331 docs: 补充客户端 IP 请求头配置说明
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:10:41 +08:00
JlypxandSisyphus 6becd11e39 docs: 更新客户端 IP 边缘安全配置
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:10:32 +08:00
JlypxandSisyphus 80dd2929a3 docs: 本地化客户端 IP 请求头设置
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:10:18 +08:00
JlypxandSisyphus 4990ca0b2c feat: 添加客户端 IP 请求头设置界面
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:10:05 +08:00
JlypxandSisyphus 344f303919 feat: 扩展前端客户端 IP 设置类型
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:09:57 +08:00
JlypxandSisyphus ff5b0e6254 test: 更新客户端 IP 设置响应契约
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:09:47 +08:00
JlypxandSisyphus fedeba2568 feat: 审计客户端 IP 请求头变更
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:09:37 +08:00
JlypxandSisyphus 3c86e249f4 feat: 接入客户端 IP 请求头管理接口
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:09:21 +08:00
JlypxandSisyphus f72958d530 feat: 持久化客户端 IP 请求头设置
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:09:11 +08:00
JlypxandSisyphus 8b8b6b3132 feat: 定义客户端 IP 请求头系统设置
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:09:00 +08:00
JlypxandSisyphus 9bc7d10c08 fix: 快照自定义客户端 IP 请求头
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:08:48 +08:00
JlypxandSisyphus 496005d688 fix: 按自定义请求头解析客户端 IP
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:08:36 +08:00
JlypxandSisyphus 041db5d824 feat: 支持自定义客户端 IP 请求头配置
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 00:08:26 +08:00
cugxuan 3379603022 feat(subscription): show expiry times to minute 2026-07-19 22:43:35 +08:00
superman2003 4a97491be6 fix(grok): let manual connection tests bypass the scheduling gate
Grok OAuth 'test connection' reused the production GrokTokenProvider
request path, whose eligibility gate rejects any account that is not
currently schedulable (manual switch off, rate limited, overloaded,
temporarily cooled down). Every such test failed before reaching xAI
with the generic 'oauth refresh account state changed', while
Codex/OpenAI account tests read credentials regardless of scheduling
state (#4598).

Add GetAccessTokenForManualTest and use it from the account test
service:

- skips the request-path scheduling eligibility gate so admins can
  probe excluded accounts; credential integrity checks (configured
  proxy present, refresh token present) still apply
- refreshes expired tokens through the shared OAuthRefreshAPI without
  the request-path marker, so the lock protocol and DB re-read keep
  working while scheduling state no longer blocks the refresh;
  credential rotation for non-active accounts stays blocked inside
  RefreshIfNeeded
- falls back to the still-valid current token when a refresh attempt
  fails or another worker holds the refresh lock, and otherwise
  surfaces the concrete refresh error instead of the generic message

Fixes #4598
2026-07-19 22:40:58 +08:00
superman2003 35b5edb24c fix(update): detach in-place update from the HTTP request lifetime
The in-place update ran entirely inside c.Request.Context(). Browsers and
reverse proxies commonly abort long-idle requests (axios global timeout
30s, nginx proxy_read_timeout 60s by default), which canceled the request
context mid-download and killed every slow update with
'download failed: context canceled' while the version stayed unchanged.
Users behind slow GitHub links saw the update button fail at a wall-clock
ceiling (~60s) on every attempt (#4504).

- Run PerformUpdate and RollbackToVersion on a context detached from the
  request (context.WithoutCancel) and bounded by a 15-minute deadline so
  the 10-minute GitHub download client owns its own timeout. A client
  disconnect no longer aborts the binary swap; retries then hit the
  system operation lock or report 'Already up to date'.
- Raise the frontend timeout for the update/rollback calls from the
  global 30s axios default to 15 minutes so the browser can actually
  wait for the result.

Fixes #4504
2026-07-19 22:40:44 +08:00
wucm667 01062c3244 fix(openai): correct buffered messages content type 2026-07-19 22:28:49 +08:00
zhangxaochen 604b764cf5 fix(openai): return standard insufficient quota errors 2026-07-19 21:59:45 +08:00
JlypxandSisyphus f69042ca6e docs: 更新客户端 IP 配置索引
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-19 21:42:54 +08:00
JlypxandSisyphus 41b58b640a docs: 更新可信代理部署说明
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-19 21:42:33 +08:00
JlypxandSisyphus 5da3325a30 feat: 在安全设置中配置客户端 IP 模式
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-19 21:42:12 +08:00
JlypxandSisyphus 93717394b2 fix: 迁移客户端 IP 兼容开关
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-19 21:41:45 +08:00
JlypxandSisyphus 6aa6b3a968 fix: 将客户端 IP 模式注入请求上下文
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-19 21:41:12 +08:00
JlypxandSisyphus 39107ca457 fix: 安全初始化 Gin 可信代理
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-19 21:40:32 +08:00
JlypxandSisyphus 6e2ba0e4cd fix: 统一请求级客户端 IP 模式
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-19 21:39:49 +08:00
JlypxandSisyphus 8a147fcc51 fix: 解析显式可信代理配置
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-19 21:39:24 +08:00
cugxuan d0fa8c63f1 fix(subscription): round remaining days up 2026-07-19 21:37:26 +08:00
cat 3a4966f786 fix(openai): 限制 Codex call_id 归一化长度 2026-07-19 21:28:47 +08:00
superman2003andCursor 3ed2873f14 fix(openai): mark OAuth accounts unschedulable on Codex models manifest 401
The Codex models manifest path returned upstream 401s straight to the
client without feeding them into the account state machinery. A revoked
or invalidated OAuth account therefore stayed active and schedulable,
kept being selected for subsequent /models requests, and produced
repeated 502s until an admin ran a manual connection test (#4544).

- Route ChatGPT-backend manifest 401s through the shared upstream-error
  handling: token cache invalidation, temp-unschedulable cooldown for
  refreshable OAuth accounts, permanent disable for
  token_revoked/token_invalidated, plus the runtime scheduling block.
- Treat ChatGPT-backend manifest 401s as failover-eligible so the
  current /models request can switch to a healthy account instead of
  returning 502. Custom API key upstream 401s keep the existing
  no-failover, no-disable behavior since their /models auth is not
  authoritative for the account.
- Skip Agent Identity accounts: their 401s can be task-scoped and have
  a dedicated recovery flow.
- Attach the selected account to the ops error-log context so /models
  failures record the account_id.

Fixes #4544

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 21:13:27 +08:00
orchief c044d5c1f1 fix(openai): avoid duplicate OAuth system prompts
Root cause: Chat bridge system messages were mirrored to instructions while remaining in input as developer messages.

Fix: omit only losslessly promoted system messages for non-JSON-object OAuth Chat requests while preserving compatibility paths.

Closes #4603
2026-07-19 19:48:25 +08:00
Jlypx 732aeef880 fix: 兼容反代和 Docker 客户端 IP 解析 2026-07-19 19:41:01 +08:00
superman2003 3dcf412b65 fix(grok): preserve Trae cache routing across turns 2026-07-19 19:00:08 +08:00
superman2003 c8ff478876 fix(grok): default client tool caching on for Free accounts 2026-07-19 18:59:50 +08:00
jjaw f7979ae8ff 优化 Responses SSE 事件类型解析 2026-07-19 18:50:33 +08:00
jjaw 8fd6f91b7e 优化 Responses 生图意图复用 2026-07-19 18:49:08 +08:00
Kinso 7840eb1c49 [verified] fix(openai): make HTTP bridge failover safe
Route first-turn pre-response transport, HTTP, and SSE failures through the existing account failover path without replaying later turns. Require a real Responses terminal event and preserve platform-specific OpenAI/Grok error side effects.
2026-07-19 17:13:27 +08:00
harukaandClaude Opus 4.8 6102d64274 fix(wire): 补上 PromptAdminService 的绑定,恢复 wire 代码生成
ProviderSet 提供了 NewPromptAdminHandler,也绑定了 PromptEngine,却没有为
PromptAdminService 绑定实现,导致 go generate ./cmd/server 直接失败:

  no provider found for securityaudit.PromptAdminService
  needed by *securityaudit.PromptAdminHandler

*PromptService 本就实现了该接口的全部方法,wire_gen.go 也一直是手工维护到位的
(补上绑定后重新生成的结果与现有文件逐字节一致),只是生成这条路被堵死了,
后续任何依赖改动都无法再靠 go generate 同步。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y
2026-07-19 01:03:41 -07:00
superman2003 eea02c38fc fix(grok): cache Claude Desktop client tools 2026-07-19 16:03:18 +08:00
harukaandClaude Opus 4.8 0343dba916 feat(admin): 备份页新增异步生图对象存储配置卡片
对应后端 /admin/backups/image-storage。放在备份页而非系统设置页,是因为它与
数据库备份共用同一套 S3 客户端与凭证——用户的心智就是"在备份的 S3 上加个开关"。

- 默认勾选"复用备份 S3",此时隐藏端点/密钥字段,只留 bucket 与 prefix,
  bucket 留空则沿用备份桶。
- 保存走 useStepUp,与备份 S3 配置的保存路径一致。
- 补中英文案。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y
2026-07-19 00:56:33 -07:00
harukaandClaude Opus 4.8 b08cab91a9 feat(image-storage): 异步生图对象存储改为后台配置,保存即生效
此前开启异步生图必须改服务器上的 config.yaml 并重启容器(#4542),且若想
复用已配置的备份 S3,还得把同一套凭证再填一遍(#4458)。

- 新增 ImageStorageSettingService:配置存 settings 表,SecretAccessKey 经
  SecretEncryptor 加密落库、读回脱敏、留空表示沿用旧值,与备份 S3 配置同一套做法。
- reuse_backup_s3(默认开)直接借用 backup_s3_config 的端点与密钥,只用自己的
  bucket/prefix 区分对象,因此备份走 backups/、图片走 images/,且密钥不会在库里存两份。
- ImageTaskService 的启用状态改由 ImageStorageResolver 在运行时解析并缓存,
  保存设置后 Invalidate 使下次请求重建客户端——不再需要重启。
- repository 侧由提供实例改为提供工厂,客户端才可能在运行期重建。
- 轮询接口的门控从 enabled() 放宽为 Pollable():关掉开关只拒绝新提交,
  已受理的任务仍可取回结果,不再被中途吞掉。
- config.yaml 的 image_storage 保留为回落,后台从未保存过时沿用,
  升级前已用配置文件开启的部署不受影响。
- 管理端 GET/PUT/POST /admin/backups/image-storage,PUT 与备份 S3 配置一样要求
  step-up 2FA:改写存储目标同样能把生成内容导向外部账号。

注:go generate ./cmd/server 在当前 upstream 基线上即失败(securityaudit.
PromptAdminService 缺 provider),故 wire_gen.go 为手工同步。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y
2026-07-19 00:56:21 -07:00
huangjunsen ef88cf3f89 fix(grok): improve Claude Messages prompt-cache round-trip
Round-trip Grok encrypted reasoning via Anthropic thinking.signature and
seed cache identity from Claude Code session IDs, so multi-turn /v1/messages
traffic can hit xAI prompt cache more like CLIProxyAPI. Also strip invalid
encrypted_content and retry on the Messages path when decrypt fails after
account switches.
2026-07-19 15:00:09 +08:00
superman2003 9d79b42729 feat(admin): expose Grok client tool cache opt-in 2026-07-19 14:42:06 +08:00
cat 7a05a7cdcc test(openai): 检查 Agent Identity 测试类型断言 2026-07-19 14:42:01 +08:00
superman2003 752b2eaa9b fix(grok): bridge Trae tool requests through responses 2026-07-19 14:41:55 +08:00
superman2003 026d2a8083 fix(grok): promote Codex Lite tools for caching 2026-07-19 14:41:47 +08:00
superman2003 5af38ccf41 fix(grok): add guarded client tool cache routing 2026-07-19 14:41:38 +08:00
NickandClaude Fable 5 340dc99e02 fix(deploy): pass the documented postgres tuning values to postgres
deploy/.env.example documents POSTGRES_MAX_CONNECTIONS,
POSTGRES_SHARED_BUFFERS, POSTGRES_EFFECTIVE_CACHE_SIZE and
POSTGRES_MAINTENANCE_WORK_MEM, with notes on how to size them — but no
compose file ever passes them to the postgres container. A user who sets
them in .env gets nothing, silently.

Wire them into the postgres command in deploy/docker-compose.yml. The
fallbacks are the postgres:18 stock defaults (100 / 128MB / 4GB / 64MB),
so a deploy that does not set the variables behaves exactly as before.

Checked with postgres:18-alpine: with the variables unset, SHOW gives
the stock values; with them set (1024 / 1GB / 6GB / 128MB), SHOW gives
the set values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:17:31 +07:00
NickandClaude Fable 5 8a2d7c5bd3 fix(deploy): make the redis command flags take effect in dev and local compose
PR #4506 fixed this in deploy/docker-compose.yml, but the same broken
form is still in docker-compose.dev.yml and docker-compose.local.yml.
The redis command is one quoted script given to the inner sh -c, and
compose keeps the newlines inside the quoted string, so redis-server on
the first line runs as a complete command with no flags at all. The
--save / --appendonly / --appendfsync lines are silently never applied,
and ${REDIS_PASSWORD:+--requirepass ...} is dead too — redis takes no
password even when REDIS_PASSWORD is set.

The fix is the same trailing `\` line continuations as #4506, with the
same comment, so the three compose files read the same way.

Checked with both files on redis:8-alpine, REDIS_PASSWORD set. Before:
PING with no auth said PONG, appendonly was "no", save was the stock
"3600 1 300 100 60 10000". After: no-auth PING gets NOAUTH, appendonly
is "yes", save is "60 1". With REDIS_PASSWORD unset the server still
starts open, as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:16:27 +07:00
huangjunsen ba2b0044e3 fix(apicompat): emit null stop_reason on Anthropic message_start
Use *string for AnthropicResponse.StopReason so streaming message_start
serializes as JSON null instead of "", matching official Anthropic wire
format and avoiding strict client turn-finalization failures.
2026-07-19 13:05:24 +08:00
superman2003 d2753dc2e2 fix(grok): serve current free quota policy 2026-07-19 12:17:52 +08:00
superman2003 c99e479bcc fix(grok): retry transient billing probes 2026-07-19 12:17:42 +08:00
superman2003 b8c640d218 fix(proxy): include Grok in quality checks 2026-07-19 12:17:31 +08:00
superman2003 1433fbc44d fix(grok): estimate messages count tokens locally 2026-07-19 12:17:23 +08:00
Jlypx 2cff4bf169 fix: 修复 golangci-lint 检查错误 2026-07-19 11:39:20 +08:00
Jlypx 361983cf4d feat: 优化运维定时报表邮件模板 2026-07-19 11:23:26 +08:00
wucm667 510ee451bd feat: support GitHub token for update checks 2026-07-19 11:02:56 +08:00
AstrenixandClaude Fable 5 7bbe170405 feat(frontend): migrate batch image page to vue-i18n
BatchImageGuideView carried ~140 user-visible strings outside vue-i18n:
hardcoded Chinese template strings (buttons, placeholders, tooltips,
table headers, dialog titles, empty states) plus an inline bilingual
batchImageText dictionary switched by isZhLocale() in script.

Move all of it into a new batchImage locale namespace (180 leaf keys
per language, zh/en verified key-aligned) registered in both locale
indexes, reusing 15 existing common.*/pagination.* keys. Column and
filter-option definitions become computed so language switches take
effect immediately; countable strings use vue-i18n plurals and the
three rich-text counters keep their inner bold spans via <i18n-t>
slots.

The Codex agent-instruction prompt template intentionally stays as a
plain string: it is an AI-facing prompt document containing literal
JSON braces that conflict with vue-i18n message syntax.

Verified: eslint, vue-tsc --noEmit, vite build, and the full vitest
suite (175 files / 1219 tests) all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 03:12:39 +08:00
AstrenixandClaude Fable 5 3401a971a8 fix(frontend): localize stray hardcoded UI strings
Move the last few user-visible hardcoded strings into vue-i18n:

- AppHeader: "Toggle Menu" / "User Menu" aria-labels (screen readers
  read these in English regardless of locale)
- CustomPageView: "Page not found" error body
- PaymentProviderDialog: display-name example placeholder hardcoded in
  Chinese for all locales

Adds common.toggleMenu / common.userMenu / common.pageNotFound and
admin.settings.payment.customMethodDisplayNamePlaceholder to both zh
and en locales, and registers the new placeholder key in the
PaymentProviderDialog spec's i18n mock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:55:50 +08:00
AstrenixandClaude Fable 5 ed280b81fc style(frontend): align dark-mode surfaces with the slate dark palette
The project's dark theme uses the custom slate-based dark-* palette
(928 dark:bg-dark-* / 787 dark:border-dark-* occurrences), but a
minority of surfaces still used Tailwind's neutral gray-* scale, which
has a visibly cooler hue next to slate cards — most noticeably the
filter dropdown panels on the usage, accounts and subscriptions pages.

Migrate the remaining dark:bg-gray-* / dark:border-gray-* (and their
hover: variants) to the dark-* palette across 12 components: filter
dropdowns, chart card borders, menu separators, and small badges.
49 class-string substitutions, no structural or logic changes;
dark:text-gray-* is untouched since gray text on dark surfaces is the
established project-wide convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:45:41 +08:00
AstrenixandClaude Fable 5 841544345f fix(frontend): make user email readable in balance modal dark mode
The user info banner in UserBalanceModal renders on a dark:bg-dark-700
container, but the email line kept text-gray-900 and the balance line
text-gray-500 with no dark variants, leaving near-black text on a dark
background. Align both with the pattern already used elsewhere in the
same dialog (text-gray-900 dark:text-gray-100 / text-gray-500
dark:text-gray-400).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:41:32 +08:00
AstrenixandClaude Fable 5 4c73149af8 fix(frontend): restore scrolling on the available channels page
The available channels table rendered inside its own
"card overflow-hidden" wrapper instead of the ".table-wrapper" element
that TablePageLayout's scroll chain expects. The outer
.table-scroll-container is overflow-hidden, so when the channel list
grew taller than the viewport the content was simply clipped with no
scrollbar. The extra card wrapper also drew a double border inside the
layout's own card chrome.

Mount the table on .table-wrapper like every other table page so it
receives overflow-y-auto from the layout, and add a source-convention
regression test in the style of TablePageLayout.spec.ts.

Fixes #4555

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:18:41 +08:00
AstrenixandClaude Fable 5 a1e1997632 fix(openai): accept standard OpenAI model list for Codex model discovery
Custom API key upstreams that only implement the standard OpenAI
GET /v1/models endpoint return {"object":"list","data":[{"id":...}]},
which the Codex models manifest validation rejected with
"missing top-level models array", surfacing HTTP 502 to Codex clients.

Convert the standard list envelope into the Codex manifest shape
({"models":[{"slug":...}]}) for API key upstreams before validation.
Bodies that already carry a top-level models field, are not the
standard list shape, or yield no usable model IDs are left unchanged
so existing validation and account failover behavior is preserved.

Fixes #4490

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:09:57 +08:00
cat a67351caec fix(openai): 按 Team 隔离 Agent Identity 导入 2026-07-19 01:40:49 +08:00
abbzbb 62846bb074 fix(security-audit): 仅在 blocking 意图下 fail-closed,修复无法关闭审计
configUntrusted 不再单独强制 ModeBlocking,避免默认关闭部署在配置
加载失败时对全部请求返回 prompt_guard_unavailable;成功保存配置后
清除 untrusted,确保管理员关闭提示词审计能立即生效。

Fixes #4560
2026-07-18 23:57:45 +08:00
github-actions[bot] d4b9797ff7 chore: sync VERSION to 0.1.161 [skip ci] 2026-07-18 14:18:28 +00:00
Wesley Liddick 19149ca196 Merge pull request #4558 from fengshao1227/fix/antigravity-plan-type-preserve
fix(antigravity): 保留付费 tier 的 PlanType,IneligibleTiers 仅标记异常状态
2026-07-18 21:48:44 +08:00
Wesley Liddick 831812b39d Merge pull request #4556 from superman2003/fix/grok-free-probe-encrypted-recovery
fix(grok): stabilize Free probes and encrypted reasoning recovery
2026-07-18 21:48:31 +08:00
Wesley Liddick d98ac48eb9 Merge pull request #4543 from heathermhuang/codex/secure-protected-video-content-4498
fix(grok): securely proxy protected video content
2026-07-18 21:46:31 +08:00
li 72b29a7d4e fix(antigravity): 保留付费 tier 的 PlanType,IneligibleTiers 仅标记异常状态
Fixes #4519
2026-07-18 21:43:54 +08:00
shaw 14608dc6d4 Merge origin/main into codex/secure-protected-video-content-4498
Reconcile with #4539 (grok media account model mapping), now on main:
- handler/grok_media.go non-failover error path keeps both changes —
  #4539's grokMediaScheduleModel(account, routingModel, nil) schedule
  attribution and this branch's IsResponseCommitted guard
- auto-merged sections verified: routing/classify use #4539's routingModel,
  video lookup owner-binding and no-failover semantics intact, ForwardGrokMedia
  keeps mapping block (skipped for lookup endpoints via RequiresRequestBody),
  empty-image failover, and video-status URL rewrite in order
2026-07-18 21:38:17 +08:00
Wesley Liddick daa4989124 Merge pull request #4539 from heathermhuang/codex/fix-grok-media-model-mapping-4503
fix(grok): apply account model mapping to media
2026-07-18 21:34:10 +08:00
shaw 8a95a46a69 Merge origin/main into codex/secure-protected-video-content-4498
Resolve conflicts with main:
- service/grok_media.go: keep both post-response blocks — #4497's empty
  image-output failover (main) runs first for image endpoints, then this
  branch's video-status content-URL rewrite; the endpoint conditions are
  mutually exclusive
- handler/openai_gateway_credential_failover_loop_test.go: mark the stub
  OAuth accounts media-eligible via the grok_media_eligible extra override,
  because this branch moved grok media failover coverage to the generation
  endpoint, which is now gated by #4540's paid-eligibility probe on main
2026-07-18 21:31:28 +08:00
shaw 9d498c2474 Merge origin/main into codex/fix-grok-media-model-mapping-4503
Resolve conflicts with main:
- handler/grok_media_test.go: keep both new tests (schedule model test from
  this branch, eligibility gating tests from #4540)
- service/openai_gateway_grok_test.go: keep both new tests; update the image
  cases of the mapping table test to return a non-empty image payload because
  #4497 (already on main) now converts empty image responses into an upstream
  failover error
2026-07-18 21:25:43 +08:00
Wesley Liddick a2f802d409 Merge pull request #4541 from wucm667/fix/issue-4532-renew-expired-subscription
fix(subscription): renew expired admin assignments
2026-07-18 21:14:23 +08:00
Wesley Liddick b01196a7a8 Merge pull request #4553 from wp-a/fix/openai-ws-turn-lifecycle
[codex] enforce websocket passthrough turn lifecycle
2026-07-18 21:10:48 +08:00
superman2003 e14fb2b6ff fix(grok): recover invalid encrypted content once 2026-07-18 21:05:50 +08:00
superman2003 dd7a2b22f0 fix(grok): align Free probes with health checks 2026-07-18 21:05:50 +08:00
shaw 4e8ea7d568 fix(test): 池模式临时规则测试适配 #4547 模型级隔离语义
#4496 与 #4547 文本无冲突但语义相撞:#4496 的测试断言规则命中后账号级
跨模型封锁,#4547(issue 4527 第4点)将已知模型的临时不可调度改为按
模型隔离(SetModelRateLimit,不再触发账号级 runtime block)。

按 #4547 的语义更新断言:命中模型 gpt-5.4 记模型级封锁、gpt-5.5 不受
影响、不再调用账号级 SetTempUnschedulable;池模式规则仍生效(停止同
账号重试),issue 4470 的诉求不受影响。未知模型的账号级兜底已由
TestOpenAITempUnschedulable_UnknownModelKeepsAccountRuntimeBlock 覆盖。
2026-07-18 21:02:23 +08:00
Wesley Liddick a3f2b8fd87 Merge pull request #4540 from heathermhuang/codex/investigate-grok-oauth-test-4525
fix(grok): retry CLI chat permission denial
2026-07-18 20:50:07 +08:00
Wesley Liddick 23cdd20606 Merge pull request #4537 from heathermhuang/codex/refresh-anthropic-monitor-1953
fix(monitor): refresh Anthropic text-block extraction
2026-07-18 20:49:40 +08:00
Wesley Liddick a62b821b5f Merge pull request #4478 from yardbirds0/codex/fix-upstream-billing-probe-refresh
fix: 完善上游 Sub2API 倍率探测刷新、展示与账号配置
2026-07-18 20:49:14 +08:00
Wesley Liddick 774ff5d8c8 Merge pull request #4515 from BenjaminAaron196/feat/filter-noise-rejected-requests
(fix) 过滤入口拒绝日志并强化鉴权安全边界
2026-07-18 20:46:50 +08:00
Wesley Liddick d2667393b3 Merge pull request #4522 from wucm667/docs/issue-4518-http-bridge-prerequisite
docs: clarify OpenAI WS mode router prerequisite
2026-07-18 20:43:01 +08:00
Wesley Liddick e002fbb349 Merge pull request #4508 from wucm667/fix/model-not-found-transient-misclassification
fix: 临时账号耗尽时保留 503 错误分类
2026-07-18 20:41:35 +08:00
Wesley Liddick 005bc5c8d4 Merge pull request #4497 from heathermhuang/agent/fix-grok-media-fallback-4471
fix(grok): fail closed for ineligible OAuth media
2026-07-18 20:41:24 +08:00
Wesley Liddick bc4bd118d8 Merge pull request #4547 from heathermhuang/codex/fix-model-scoped-temp-cooldown-4527
fix(routing): isolate temporary cooldowns by model
2026-07-18 20:41:12 +08:00
Wesley Liddick f3925db11b Merge pull request #4496 from StarryKira/agent/fix-4470-pool-temp-unschedulable
fix: honor temp unschedulable rules in pool mode
2026-07-18 20:40:12 +08:00
Wesley Liddick f9a467a4c0 Merge pull request #4520 from StarryKira/codex/fix-4487
fix: report Chat Completions stream transport failures
2026-07-18 20:39:49 +08:00
Wesley Liddick 8ce9288a6a Merge pull request #4489 from fengshao1227/fix/grok-free-cache-tool-injection
fix(grok): 纯客户端函数工具不再注入原生搜索工具
2026-07-18 20:39:37 +08:00
Wesley Liddick c1e702be5e Merge pull request #4505 from cyhhao/fix/claude-1m-model-suffix
fix(gateway): normalize Claude Code 1m model suffix
2026-07-18 20:39:27 +08:00
Wesley Liddick bc6b69289c Merge pull request #4468 from docooler/fix/responses-stream-content-part
fix(apicompat): emit content_part events and full output in Responses stream
2026-07-18 20:39:15 +08:00
Wesley Liddick 5a0492bf88 Merge pull request #4517 from weiness/fix/custom-branding-flash
fix: prevent custom branding flash on initial load
2026-07-18 20:39:00 +08:00
Wesley Liddick fdc9d92fdb Merge pull request #4528 from feitianbubu/fix/plan-validity-label-unit
fix(i18n): 套餐有效期表头与表单标签去掉写死的"天",与动态单位一致
2026-07-18 20:38:47 +08:00
Wesley Liddick 8cdd372b7e Merge pull request #4507 from coo1white/fix-dockerfile-cross-compile
fix(docker): cross-compile the image instead of running Go under QEMU
2026-07-18 20:38:37 +08:00
Wesley Liddick 080a52121a Merge pull request #4506 from coo1white/fix-compose-redis-command
fix(deploy): make the redis command flags take effect
2026-07-18 20:38:27 +08:00
harukaandClaude Opus 4.8 37db8d031b fix(config): 让环境变量能真正配置 image_storage 等凭证
viper.Unmarshal 只解码 AllKeys() 返回的键,而 AllKeys() 只汇总 SetDefault、
配置文件和显式 BindEnv 三个来源。AutomaticEnv 仅能覆盖已在其中的键,无法引入
新键;能兜底的 viper_bind_struct 又被 build tag 排除(我们只用 -tags embed)。

因此任何「没有注册默认值、且不在 config.yaml 里」的配置项,其环境变量会被静默
丢弃。image_storage 的 endpoint/bucket/access_key_id/secret_access_key/
public_base_url 正属此列,于是纯环境变量部署落到最坏组合:IMAGE_STORAGE_ENABLED
生效使 Enabled=true,四个凭证却为空 → Active()=false → 异步生图接口整体 404,
运维看到的却是"凭证不完整"。deploy/docker-compose.yml 默认就是纯环境变量驱动,
且自动生成的 config.yaml 从不写 image_storage 段,必然踩中(见 #4458、#4542)。

同类缺口不止于此:github_oauth、google_oauth、dingtalk_connect 三组第三方登录
配置(含 client_secret)同样完全无法用环境变量设置。

- 为这些键注册零值默认,使其进入 AllKeys() 而可被环境变量覆盖。零值与"键缺失"
  时的解码结果一致,故行为不变。
- sticky_escape_enabled 例外:它的实际默认是 true(靠 IsSet 守卫在解码后补上),
  注册 false 会让 IsSet 恒真而永久关闭该特性,故直接注册 true。
- 启动告警补上 missing_keys 字段,指明到底哪个凭证为空。
- 新增反射守卫测试:Config 结构体上每个可由环境变量表达的字段都必须已注册默认值。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y
2026-07-18 05:24:32 -07:00
王鹏 f0e0b7e6d8 fix(openai-ws): enforce passthrough turn lifecycle 2026-07-18 19:17:53 +08:00
Heatherm Huang 4c845ca697 test(grok): sanitize security fixtures 2026-07-18 17:50:53 +08:00
Heatherm Huang 95d27f2dce fix(grok): fetch validated signed video content 2026-07-18 17:49:09 +08:00
Heatherm Huang f2e7a55703 fix(grok): proxy signed video status URLs 2026-07-18 17:21:53 +08:00
Heatherm Huang 1ed9f59599 test(grok): keep media failover coverage on generation 2026-07-18 15:54:43 +08:00
Heatherm Huang b4ad7fd363 test(grok): configure multipart media mapping 2026-07-18 15:49:21 +08:00
Heatherm Huang ef2a22be70 fix(routing): isolate temporary cooldowns by model 2026-07-18 15:35:30 +08:00
Heatherm Huang c831bb979f fix(grok): scope video content to request owner 2026-07-18 14:56:15 +08:00
Heatherm Huang 3edcbfd120 fix(grok): keep media content proxy URLs same-origin 2026-07-18 14:43:08 +08:00
wucm667 1db10dc559 fix(subscription): renew expired admin assignments 2026-07-18 14:42:22 +08:00
Vz7797 3f6b5c7bd7 fix(grok): proxy protected video content through upstream account
(cherry picked from commit a01177e294)
2026-07-18 14:37:25 +08:00
Heatherm Huang 2946281a43 fix(grok): retry CLI chat permission denial 2026-07-18 14:32:56 +08:00
Heatherm Huang 335edde9c8 fix(grok): apply account model mapping to media 2026-07-18 14:21:47 +08:00
mark-ly-wang 1a855d3e0e fix(monitor): extract anthropic text blocks
(cherry picked from commit 76cf1ee834)
2026-07-18 13:53:36 +08:00
Wesley Liddick b1a6b80267 Merge pull request #4526 from Wei-Shaw/feat/security-switches-default-off
feat(security): 敏感操作 step-up 2FA 开关化,安全开关默认关闭
2026-07-18 11:32:17 +08:00
feitianbubu c3f759d13a fix(i18n): 套餐有效期表头与表单标签去掉写死的"天",与动态单位一致 2026-07-18 10:59:11 +08:00
shaw 539bfc8bad feat(security): 敏感操作 step-up 2FA 开关化,安全开关默认关闭
新增系统设置 step_up_enabled(默认关闭),把敏感操作 2FA 门控做成可开关;
同时将会话 IP/UA 绑定默认值从开启改为关闭,避免用户因 IP 变动登录后掉线。

## 新增功能
- 敏感操作 step-up 2FA 总开关 step_up_enabled(默认关闭):关闭时账号/代理导出、
  备份创建/下载、S3 配置修改、提升管理员等操作恢复门控引入前的直接放行行为;
  开启后要求当前会话在 15 分钟内完成过 TOTP step-up 验证。

## 优化改进
- 会话 IP/UA 绑定默认改为关闭(功能保留,可在设置页按需开启)。
- 开启 step-up 开关需操作者本人已启用 TOTP(防自锁);关闭开关本身作为敏感操作,
  需通过 step-up 验证(防止攻击者拿到会话后先关闸再导出/备份)。
- 两个安全开关请求字段改为可空指针(省略=保持现值),避免旧客户端全量保存时
  静默重置安全开关。
- 备份恢复(整库覆盖可回滚安全设置)纳入 step-up 门控。
- 审计摘要 diffSettings 补记 step_up_enabled / session_binding_enabled 变更。

## Bug 修复
- 修复 BackupView 恢复操作 409(恢复进行中)判断未适配 apiClient 扁平化错误对象。
2026-07-18 10:46:42 +08:00
wucm667 8b75dd5576 docs: clarify OpenAI WS mode router prerequisite 2026-07-18 08:37:15 +08:00
haruka bd0f2d6405 fix: report chat stream transport failures 2026-07-18 03:24:04 +08:00
benjamin 71afe24710 fix: 修复CI检查问题 2026-07-18 00:42:16 +08:00
weiness 500f241692 fix: prevent custom branding flash on initial load 2026-07-18 00:24:06 +08:00
benjamin b92bbf0299 fix: 过滤入口拒绝日志并强化鉴权边界 2026-07-18 00:11:18 +08:00
wucm667 2594950993 fix: classify transient account exhaustion as 503 instead of 404 2026-07-17 22:10:18 +08:00
Nick 9386ce8a13 fix(docker): cross-compile the image instead of running Go under QEMU
Building the image for linux/amd64 on an arm64 host (Apple Silicon)
kept failing at 'go mod download' with 'unexpected EOF'.

Two changes:

1. The builder stages had no --platform, so the Node and Go toolchains
   ran under QEMU emulation of the target arch. Pin frontend-builder
   and backend-builder to $BUILDPLATFORM and cross-compile: add
   TARGETOS/TARGETARCH args and set them on 'go build'. The binary is
   CGO_ENABLED=0, so this is a clean pure-Go cross-compile - much
   faster, and the emulated networking that dropped module fetches
   with EOF is gone. The frontend output is JS (arch-neutral), so it
   is safe to build on the host arch too.

2. Add go module and build cache mounts, so a retry after a network
   blip goes on from where it stopped instead of starting over.

The runtime image and app behavior are unchanged.
2026-07-17 21:05:42 +07:00
Nick be74deae73 fix(deploy): make the redis command flags take effect
The redis command is one quoted script given to the inner `sh -c`.
Docker compose keeps the newlines inside the quoted string, so
`redis-server` on the first line ran as a complete command with no
flags at all, and --save / --appendonly / --appendfsync after it were
silently never applied (`redis-cli CONFIG GET appendonly` said "no").

Trailing `\` line continuations fold the script back into one command.
Checked with redis:7-alpine: appendonly is now "yes" and save is
"60 1".
2026-07-17 21:04:47 +07:00
cyh 2264a33085 fix(gateway): normalize Claude Code 1m model suffix 2026-07-17 21:59:57 +08:00
Tian Lee d2585097f3 fix: 完善上游 Sub2API 计费倍率探测与账号展示 2026-07-17 20:59:29 +08:00
Heatherm Huang e86063155f fix(grok): gate OAuth media on paid eligibility 2026-07-17 19:15:16 +08:00
haruka 1f7b7b9132 fix: honor temp unschedulable rules in pool mode 2026-07-17 18:12:33 +08:00
li 7043982ae3 fix(grok): 纯客户端函数工具不再注入原生搜索工具
Fixes #4486
2026-07-17 16:59:40 +08:00
github-actions[bot] 57914967cb chore: sync VERSION to 0.1.160 [skip ci] 2026-07-17 08:48:10 +00:00
Wesley Liddick 8bfbc5ca99 Merge pull request #4485 from Sub2API-Devs/dev
feat(security-audit): 新增 OpenAI 兼容提示词审计能力与安全审计控制台
2026-07-17 16:15:26 +08:00
Wesley Liddick cb40288fb8 Merge pull request #4479 from fengshao1227/fix/backup-s3-stepup-totp
fix(frontend): saveS3Config 接入 step-up TOTP 门控
2026-07-17 16:14:49 +08:00
Wesley Liddick 33766c299f Merge pull request #4481 from fengshao1227/fix/passive-image-namespace-capability
fix(gateway): 被动 image_gen namespace 不再强制要求 Responses capability
2026-07-17 16:14:35 +08:00
Wesley Liddick 13468b3fa5 Merge pull request #4484 from heathermhuang/codex/fix-grok-scheduler-media-cache
fix(grok): preserve media eligibility in scheduler cache
2026-07-17 16:13:31 +08:00
mt21625457 e69e46ba02 feat: allow one-click audit event filter deletion 2026-07-17 15:50:31 +08:00
Heatherm Huang eaf0691752 fix(grok): preserve media eligibility in scheduler cache 2026-07-17 15:45:19 +08:00
li 5fd030a01b test: 更新 image permission 测试适配 explicit intent 检查
被动 namespace 不再触发 403,新增 PassiveNamespaceDoesNotTrigger403
测试和 OpenAI native/image model 的正向用例。
2026-07-17 15:29:14 +08:00
li fc3c5a1e0c fix: 权限检查和并发槽也使用显式检查,修复被动 namespace 触发 403
Fixes #4447
2026-07-17 15:15:54 +08:00
li e375623abf fix(gateway): 被动 image_gen namespace 不再强制要求 Responses capability
Fixes #4476
2026-07-17 15:07:41 +08:00
li 288d3a1cce fix(frontend): saveS3Config 接入 step-up TOTP 门控
Fixes #4445
2026-07-17 14:52:54 +08:00
mt21625457 ac685ccaf5 feat(security-audit): persist full prompts on audit events and polish event review UI
- Add prompt_audit_events.full_prompt (migration 182) so admins can review
  the exact unredacted prompt that triggered a finding; blocking mode writes
  it from the snapshot, async mode reconstructs it from the Redis scan
  payload so jobs rows stay redaction-only
- Event detail API returns full_prompt (list endpoint stays lean); text is
  NUL-stripped and capped at 65536 runes
- Detail dialog shows the full prompt in a scrollable pane with fallback to
  the legacy redacted preview; page copy updated to match the new behavior
- Rework filter deletion into a dedicated dialog with time-range presets and
  criteria-change preview invalidation; localize decision/risk/category
  labels across the events workspace
- Fix pre-existing i18n message-compile spec by declaring the
  @intlify/message-compiler dev dependency
2026-07-17 14:38:10 +08:00
shaw bc50c9d013 chore: update sponsors 2026-07-17 14:05:59 +08:00
Wesley Liddick dda20ae9a6 Merge pull request #4474 from heathermhuang/codex/fix-grok-media-image-url-4420
v0.1.159 fix(grok): normalize media payloads and quarantine ineligible accounts
2026-07-17 13:49:09 +08:00
Heatherm HuangandOpenAI Codex c34dcd3700 docs: document Grok media eligibility
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-07-17 12:18:46 +08:00
mt21625457 7ed4e7e5e3 fix(security-audit): isolate latest user prompt chunk 2026-07-17 12:12:41 +08:00
Heatherm Huang d2ac6b2c97 fix(grok): harden media quarantine edge cases 2026-07-17 12:08:50 +08:00
Heatherm Huang 8f9296140f Merge remote-tracking branch 'origin/main' into codex/fix-grok-media-image-url-4420 2026-07-17 11:51:46 +08:00
Heatherm Huang 8bc0a277f2 fix(grok): quarantine ineligible media accounts 2026-07-17 11:51:16 +08:00
Heatherm Huang 456c619379 fix(grok): normalize media reference image payloads 2026-07-17 11:51:08 +08:00
mt21625457andCursor 18e698bed6 feat(security-audit): allow admin-managed audit node targets and polish pool UI
Let admins configure private/intranet Guard endpoints without destination-class blocking, and fix prompt-audit switch layout so thumbs and labels no longer overlap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 11:45:14 +08:00
docooler 26b5e87457 fix(apicompat): emit content_part events and full output in Responses stream
The Anthropic→Responses streaming converter omits two things the OpenAI
Responses wire format requires, which breaks clients that reconstruct the
response from the event stream (rather than just reading deltas).

1. response.content_part.added is never emitted.

   A message item is opened with content: [], and the OpenAI SDK's
   accumulating stream helper (client.responses.stream) only appends a
   content part when it sees content_part.added. Without it, the next
   output_text.delta indexes output.content[content_index] and raises:

     File "openai/lib/streaming/responses/_responses.py", line 352,
       in accumulate_event
         content = output.content[event.content_index]
     IndexError: list index out of range

   Raw iteration (responses.create(stream=True)) does not accumulate and is
   unaffected, which is why this went unnoticed.

2. response.completed carries Output: []ResponsesOutput{}.

   get_final_response() and tracing integrations parse the terminal event's
   response directly, so callers see an empty output_text even though the
   deltas streamed correctly. This one is invisible when only watching the
   stream render.

Also carries the full text on output_text.done / content_part.done (deltas
carry increments only, done events carry the whole part) and fills in
content/arguments/summary on output_item.done, which had the same
empty-payload issue.

Reproduced against a live Anthropic-platform group with the openai Python
SDK 2.46.0; Arize Phoenix's playground hits the same path. Verified before
(IndexError) and after (full text via get_final_response()).

Adds regression tests covering event ordering, done-event payloads, and the
terminal event's output for both text and tool calls.
2026-07-17 03:35:26 +00:00
github-actions[bot] c2c19a7cbe chore: sync VERSION to 0.1.159 [skip ci] 2026-07-17 02:00:12 +00:00
Wesley Liddick 2a75d7d238 Merge pull request #4462 from Wei-Shaw/fix/unify-security-client-ip
fix(security): unify audit log & session binding client IP with API key ACL trust toggle
2026-07-17 09:42:43 +08:00
Wesley Liddick 1c3f2810af Merge pull request #4461 from yardbirds0/feat/account-homepage-link
feat: 支持从 API Key 账号名称跳转上游站点主页
2026-07-17 09:34:50 +08:00
shawandClaude 7c48f9a85f fix(security): unify audit log & session binding client IP with API key ACL trust toggle
Behind a reverse proxy (e.g. nginx with X-Real-IP), admin audit logs and
session IP/UA binding always recorded 127.0.0.1 because they hardcoded
the gin trusted_proxies chain, while API key IP restriction already
honored the "trust forwarded client IP" system setting.

- add ip.GetSecurityClientIP(c, trustForwarded) as the single source of
  truth for security-sensitive client IP selection; API key auth
  middlewares (main + google) refactored onto it with zero behavior change
- SessionBindingContext(cfg) now resolves the client IP via the same
  toggle and injects it into the request context; token issuance,
  binding enforcement and its mismatch audit record all read the
  injected value, so issue/verify can never diverge
- audit log middleware and audit-log clear trace record the same
  security client IP (middleware.SecurityClientIP), falling back to the
  trusted proxy chain when the injection is absent
- settings UI hint (zh/en) documents the broadened toggle scope and the
  one-time re-login after toggling while session binding is enabled

With the toggle off (default) behavior is byte-for-byte unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 09:33:18 +08:00
shawandClaude 980a46ffb0 fix(i18n): add missing OpenAI account entry labels
- admin.accounts.oauth.openai.mobileRefreshTokenAuth was referenced by
  OAuthAuthorizationFlow.vue since 9f8cffe88 (Mobile RT entry) but never
  added to zh/en locales, rendering the raw key in the add-account wizard
- admin.accounts.oauth.openai.accessTokenAuth has the same latent issue
  since 26060e702 (Sora AT import); currently hidden but fixed alongside

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 09:32:48 +08:00
yardbirds0 824f763a77 feat: 支持从 API Key 账号名称跳转上游站点 2026-07-17 09:15:32 +08:00
Wesley Liddick 9b3c96dc68 Merge pull request #4451 from rurucxk/fix/grok-free-responses-function-tool-cache
fix(grok): enable free prompt cache for Responses function tools
2026-07-17 09:04:47 +08:00
Wesley Liddick 52f2bc69fb Merge pull request #4450 from wucm667/fix/issue-4442-lazy-stripe-loader
fix(frontend): lazy-load Stripe payment dependency
2026-07-17 09:04:33 +08:00
Wesley Liddick 067cf8b46e Merge pull request #4459 from Wei-Shaw/fix/openai-alpha-search-apikey-scheduling
fix(openai): restore APIKey account scheduling for alpha/search
2026-07-17 09:04:17 +08:00
mt21625457andCursor df9d9e2e40 fix(security-audit): harden role scan, startup, probe, and localhost dial
Scan client-injected assistant/tool/model turns, fail closed when config cannot
be trusted after startup or stale invalidation, reuse probe tokens only for the
same base URL, and restrict localhost dials to loopback addresses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 09:00:14 +08:00
mt21625457andCursor 0f7f8a317e fix(security-audit): close prompt-audit bypass and privacy gaps
Stop WebSocket follow-up turns from reusing a request-wide audit cache, scan
client-controlled instruction fields, fail closed on stale weaker configs, and
tighten preview/SSRF controls including persisted request stage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 08:46:57 +08:00
shaw d2b080e88b fix(openai): restore APIKey account scheduling for alpha/search
PR #4394 (776f3f0de) 在新增 alpha_search 调度能力时加入了 OAuth-only
门控,把 APIKey 账号从 /alpha/search 候选池整体剔除;但转发层自
52071d391 起就支持 APIKey 走 {base_url}/v1/alpha/search,门控注释中
「API key 会被发往 chatgpt.com 导致 401」与实际路由不符。结果是纯
APIKey 分组自 v0.1.157 起独立搜索在选号阶段即失败(无可用账号)。

修复:
- SupportsOpenAIEndpointCapability 的 alpha_search 门控放行
  AccountTypeAPIKey(OAuth/APIKey 均可,Grok 等仍拒绝);显式能力集
  语义不变,chat_completions 继续隐含放行 alpha_search。
- ForwardAlphaSearch 对 APIKey 账号的 404/405 按端点级 failover 处理
  (换号、不写账号错误状态),避免混合分组里请求死在不支持该端点的
  APIKey 上游;OAuth 账号 404 透传行为保持不变。
- 更新固化旧门控行为的用例,并在调度层新增 APIKey 放行回归锁。
2026-07-17 08:44:23 +08:00
mt21625457 d11bdb13f5 feat(security-audit): add OpenAI-compatible prompt auditing 2026-07-17 00:39:39 +08:00
rurucxk 29e39b96e5 fix(grok): enable free prompt cache for Responses function tools
Reuse applyGrokFreeMessagesFunctionToolCacheRoute on native /v1/responses
and the Grok WS HTTP bridge so Free OAuth requests with client function
tools get the same mixed-tools cache route as the Messages bridge
(append/convert web_search and x_search).

Also dedupe: Grok Build already declares function tools named web_search,
so naive append caused "Duplicate tool names: web_search". Convert those
function entries to native tool types and skip duplicates.

Only Free OAuth accounts (isKnownGrokFreeAccount); paid/unknown unchanged.
2026-07-17 00:23:19 +08:00
wucm667 08e994ad86 fix(frontend): lazy-load Stripe payment dependency 2026-07-16 23:21:58 +08:00
github-actions[bot] bc2244c83f chore: sync VERSION to 0.1.158 [skip ci] 2026-07-16 12:37:21 +00:00
Wesley Liddick 26abd19a28 Merge pull request #4433 from heathermhuang/codex/fix-grok-regressions-4412-4421
fix(grok): repair OAuth media and compatibility regressions
2026-07-16 20:16:46 +08:00
shaw c29b50394f Merge main (fix batch-limits test typecheck breaking CI) 2026-07-16 20:05:48 +08:00
shaw 3d23cc399f fix(admin): align batch-limits test with expanded NewUserHandler signature
PR #4425 was authored before #4429 widened NewUserHandler with the
step-up TOTP and user services, and merged without a rebase, breaking
typecheck on main.
2026-07-16 20:05:31 +08:00
shaw c1fd1cb44e Merge origin/main into codex/fix-grok-regressions-4412-4421
Reconcile the OAuth media route with the manual endpoint-switch redesign
(7f5d067af): media leaves for api.x.ai only when text traffic resolves to
the CLI gateway host; manually selected official/regional/custom endpoints
keep serving media as-is.
2026-07-16 19:57:48 +08:00
Wesley Liddick 7e13b6d039 Merge pull request #4425 from AdrianZhaoDev/agent/admin-users-batch-limits
feat(admin): batch update user concurrency and RPM
2026-07-16 19:33:32 +08:00
Wesley Liddick c993490a82 Merge pull request #4434 from yan9651688/feat/group-one-click-copy
feat(group): add safe one-click duplication
2026-07-16 19:33:18 +08:00
Wesley Liddick 4d91f39474 Merge pull request #4431 from linyqh/codex/fix-codex-image-handoff
fix(openai): 修复 Codex 生图网关侧交接问题
2026-07-16 19:32:33 +08:00
Wesley Liddick af6bd44d2a Merge pull request #4435 from superman2003/fix/grok-codex-wsv2-template
fix(grok): enable Codex Responses WebSocket v2 in setup template
2026-07-16 19:32:14 +08:00
Wesley Liddick 541ca3bc27 Merge pull request #4430 from feitianbubu/fix/proxy-fallback-label
fix(i18n): 代理"失败回退"改名"到期回退",与实际行为一致
2026-07-16 19:32:04 +08:00
shaw 7f5d067af2 feat(grok): 支持上游端点手动切换与快捷端点,修复 SSO 建号自定义地址被覆盖
官方端点(api.x.ai / cli-chat-proxy.grok.com)偶发不可用,运营方需要在
端点间手动切换。旧语义把 OAuth 账号存储的官方 host 一律视同"未定制"并
回落默认 CLI 网关:填了官方地址保存成功却不生效、重新编辑开关回到关闭、
再次保存直接删值,形成"修改不生效"的静默循环。

后端:
- GetGrokBaseURL OAuth 分支改为"存了什么用什么":官方 API / 区域 API /
  第三方转发地址一律按填写值转发与探测,仅空值或无法解析的脏数据回落
  默认 CLI 网关;删除官方变体运行时迁移逻辑
- *.api.x.ai 区域端点(us-east-1/us-west-2/eu-west-1 等)纳入可信 host,
  OAuth 使用时不受运营方 URL 白名单限制,官方 host 仍强制 /v1 path
- 修复 SSO 批量建号 MergeCredentials 方向缺陷:BuildAccountCredentials
  恒写官方 base_url,会覆盖导入请求指定的自定义转发地址;抽出
  grokSSOImportCredentials 显式保留请求值(与 RefreshAccountToken 对齐)

前端:
- isCustomGrokBaseUrl 仅默认 CLI 网关 host 视同未定制:api.x.ai 与区域
  端点保存后正常回显(开关开启 + 显示地址),不再被静默吞掉
- 新增 GrokBaseUrlPresets 快捷端点组件(Grok Build CLI / 官方 API /
  us-east-1 / us-west-2 / eu-west-1),接入编辑(OAuth 自定义区 + apikey
  Base URL)、新增(同前)与批量编辑(所选平台全为 grok 时显示,点击
  自动勾选 base_url);仅快速填充,输入框仍可自由填写任意第三方地址
2026-07-16 19:10:21 +08:00
superman2003 174ea22eeb fix(grok): enable Codex Responses WebSocket v2 2026-07-16 18:30:59 +08:00
yan9651688 9fc006546c Make repeated group setup safer
Admins often recreate groups with the same pricing, routing, and account membership. A server-side duplicate creates an inactive copy for review, preserves eligible account priorities, and recovers ambiguous retries without creating extra groups.

Constraint: Group has no neutral JSON metadata field for durable operation recovery
Constraint: Model routing references account IDs, so copied configuration requires matching bindings
Rejected: Rebuild from the list response | it omits configuration and account priority details
Rejected: Store operation identity in business configuration | it would pollute real group settings
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep duplicated groups inactive until an administrator reviews the copied configuration
Tested: Go unit and full tests, go vet, integration-tag compile, frontend Vitest, lint, typecheck, production build, and Playwright duplicate flow
Not-tested: PostgreSQL container integration locally because Docker is unavailable; CI will execute the database-backed suite
2026-07-16 18:18:28 +08:00
shaw b960ec1980 chore: update sponsors 2026-07-16 17:45:13 +08:00
github-actions[bot] 60732a2e8c chore: sync VERSION to 0.1.157 [skip ci] 2026-07-16 09:12:44 +00:00
viccyandOmX 9d1064badd 澄清 Codex 图片桥接与本地执行器边界
配置界面现在区分非 Responses Lite 的 hosted 桥接与客户端本地 image_gen,并准确描述账号 strip 策略。API Key Mode 同时提示完全重启 Codex 后新建 task,以重建客户端工具注册表。

Constraint: 兼容模式继续保持默认,Responses Lite 的本地工具由客户端与账号策略决定
Rejected: 写入 features.image_generation | 当前 Codex 已默认启用且仓库既有配置刻意省略该项
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: 图片桥接文案必须区分 hosted 注入、客户端声明和 image-only 路由
Tested: 前端 lint、typecheck、关键套件 93 项、定向套件 43 项
Not-tested: Codex Desktop 真实工具注册表重建

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-07-16 17:11:17 +08:00
viccyandOmX 2b5944fd6c 防止 WebSocket 生图结果停留在进行中状态
HTTP/SSE 已会在终态事件中修正带结果的图片 item,但 WebSocket 下行路径缺少同一处理。现在四条文本帧出口复用现有归一化,并原样保留 result 数据。

Constraint: 仅修改终态事件中带非空 result 的 image_generation_call
Rejected: 解码并改写二进制帧 | 二进制帧不属于 JSON 事件契约
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: 保持 HTTP、SSE 与 WebSocket 的图片终态规则一致
Tested: 后端 unit、integration、go vet
Not-tested: 真实 Codex WebSocket 生图会话

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-07-16 17:11:00 +08:00
viccyandOmX 43e95dfa45 避免无效模型清单破坏 Codex 能力发现
自定义 API-key 上游可能将普通 OpenAI 模型列表作为 2xx 响应返回。现在只校验稳定 envelope,并把结构错误归类为可重试,以便冷缓存时换号且不污染缓存。

Constraint: Codex manifest schema 会随客户端演进,仅校验顶层 models 数组
Rejected: 在网关合成完整 manifest | 容易与上游 schema 漂移
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: 不要在此处校验 models 元素的易变字段
Tested: 后端 unit、integration、go vet
Not-tested: 真实第三方 API-key 上游的在线 failover

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-07-16 17:10:44 +08:00
feitianbubu ca82fd6e98 fix(i18n): rename proxy fallback label to expiry fallback 2026-07-16 17:09:23 +08:00
Wesley Liddick a2779cd5f3 Merge pull request #4428 from alfadb/fix/anthropic-haiku-full-mimicry
fix(gateway): 修复 Haiku OAuth 请求被归入额外用量
2026-07-16 16:55:11 +08:00
Wesley Liddick dbef64bb45 Merge pull request #4427 from yan9651688/feat/channel-monitor-one-click-copy
feat(channel-monitor): add safe one-click duplication
2026-07-16 16:54:22 +08:00
Wesley Liddick 3ecfcc48ae Merge pull request #4429 from Wei-Shaw/feat/role-stepup-admin-2fa-password
feat(security): 角色提升纳入 step-up 2FA + 管理员 2FA 密码验证 + 审计日志页优化
2026-07-16 16:53:51 +08:00
Heatherm Huang 115116e8bf fix(grok): repair OAuth routing regressions 2026-07-16 16:52:25 +08:00
shaw 35748d8c51 feat(security): gate admin role promotion behind step-up 2FA and harden admin TOTP verification
- 提升用户为管理员 / 创建管理员账号纳入敏感操作:handler 级 EnforceStepUp 门控
  (admin API key 拒绝、未启用 TOTP 拒绝、无 grant 返回 STEP_UP_REQUIRED),
  目标已是管理员的日常编辑不触发
- 管理员启用/停用 2FA 一律使用密码验证(默认通知邮箱常收不到验证码),
  verification-method 按用户角色返回;普通用户行为不变
- 用户编辑/创建弹窗接入 useStepUp:命中 STEP_UP_REQUIRED 弹 TOTP 验证并自动重试
- 审计日志清理入口与其他敏感操作对齐:未启用 2FA 时直接提示先启用 TOTP,
  不再弹出无法完成的验证码输入框(后端强制现场 TOTP 语义不变)
- 审计日志页重构:DataTable 布局、详情弹窗分区展示、时间范围改为 ops 同款
  下拉(预设窗口 + 自定义起止支持时分)
2026-07-16 16:49:08 +08:00
yan9651688 e2e375d694 Make repeated channel-monitor setup safer
Admins often recreate monitors with the same endpoint, model, and request settings. A server-side duplicate keeps the stored API key out of the browser, creates a disabled copy for review, and uses stable operation identity to recover ambiguous retries without creating extra rows.

Constraint: Stored monitor API keys must never be returned to the browser
Constraint: Applying a request template must preserve internal duplicate recovery metadata
Rejected: Rebuild the monitor from list data | list responses only contain a masked API key
Rejected: Copy runtime state and history | a duplicate should start as an unverified configuration
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep duplicated monitors disabled until an administrator reviews and enables them
Tested: Go unit tests for repository, service, and admin handler; integration-tag compile; go vet; golangci-lint v2.9; frontend Vitest, ESLint, typecheck, production build; Playwright duplicate flow
Not-tested: PostgreSQL container integration locally because Docker is unavailable; CI will execute the database-backed suite
2026-07-16 16:30:19 +08:00
alfadb 1f5ee8123f fix(gateway): apply full Claude Code mimicry to Haiku 2026-07-16 15:45:22 +08:00
zhaozewu 7947619cc3 feat(admin): batch update user limits 2026-07-16 15:37:35 +08:00
Wesley Liddick 393a8fe56a Merge pull request #4424 from StarryKira/fix/4417-responses-image-account-capability
fix(gateway): route image-intent /v1/responses only to Responses-capable accounts (#4417)
2026-07-16 15:35:45 +08:00
Wesley Liddick 09729ba54c Merge pull request #4406 from StarryKira/agent/fix-4326-async-image-object-storage
feat: 异步生图任务与结果轮询(重新引入 #4381)+ 结果落对象存储
2026-07-16 15:35:04 +08:00
shaw 590efe29a5 Merge remote-tracking branch 'origin/main' into agent/fix-4326-async-image-object-storage
# Conflicts:
#	backend/cmd/server/wire_gen.go
2026-07-16 15:32:38 +08:00
Wesley Liddick 813920607e Merge pull request #4418 from Wei-Shaw/feat/audit-log-and-credential-hardening
feat(security): 操作审计日志 + 会话IP/UA绑定 + 敏感操作 step-up 2FA
2026-07-16 15:27:13 +08:00
harukaandClaude Opus 4.8 605b026cc4 fix(gateway): route image-intent /v1/responses only to Responses-capable accounts (#4417)
For OpenAI-compatible API-key accounts, /v1/responses requests with
image-generation intent could be scheduled to accounts whose upstream
does not support the Responses API (extra.openai_responses_supported=false).
The flag was only consulted at forward time, where such accounts are
silently downgraded to a Chat-Completions path that cannot produce images,
causing upstream 4xx/5xx or canceled requests.

Fix:
- Add endpoint capability OpenAIEndpointCapabilityResponses. Its check in
  SupportsOpenAIEndpointCapability excludes only OpenAI API-key accounts
  probed as unsupported (mirroring the forward-time downgrade condition);
  OAuth/Grok/unprobed accounts keep existing behavior, and a responses-
  capable upstream must still pass the chat_completions gate. Reusing the
  existing requiredCapability plumbing makes every scheduler filter path
  enforce it with no scheduler signature changes.
- Request the responses capability at the HTTP Responses and
  ResponsesWebSocket call sites only when imageIntent && platform==openai,
  so non-image requests keep the downgrade path and Grok's own image path
  is untouched.
- Normalize max_tokens -> max_output_tokens on the native responses
  forward path (PlatformOpenAI), and strip prompt_cache_options alongside
  prompt_cache_retention/safety_identifier.

/v1/images/generations continues to use native image capability (unchanged).

Tests: capability truth table, scheduler exclusion of unsupported accounts,
and forward-path transform behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXpzKKvsb5jW2GvgBQqnnZ
2026-07-16 00:23:40 -07:00
shaw 2d97207d41 fix(settings): 审计日志保留天数清空时回退默认值避免保存 400
audit_log_retention_days 用 v-model.number 绑定数字框,管理员清空输入框时
得到空串,后端 int 字段 ShouldBindJSON 解析空串失败返回 400,导致整次系统
设置保存被拒。payload 构建时对空/非法值回退默认 180(与后端
parseAuditLogRetentionDays("") 语义一致;显式 0 仍表示永久保留)。
2026-07-16 15:19:19 +08:00
shaw d67a2cad2b fix(lint): 检查 audit 键归一化中 WriteRune 返回值 (errcheck)
golangci-lint errcheck 报 audit_log.go:115 未检查 (*strings.Builder).WriteRune
返回值。Builder.WriteRune 永不返回错误,按仓库惯例以 _, _ = 显式丢弃。
2026-07-16 15:05:19 +08:00
shaw 2de6ccb071 fix(security): 补齐审计日志脱敏缺口 + step-up 下载/取消体验修复
审计发现修复:

高危——审计日志请求体脱敏不全(audit_logs 沦为明文凭证聚合点):
- 键名归一化比对(小写+去分隔符),覆盖 privateKey/apiv3key 等无分隔符与 camelCase 写法
- 程序化并入 SensitiveCredentialKeys 与 providerSensitiveConfigFields 两份权威敏感表,防清单漂移
- 补齐 proxy_key(内嵌代理密码)、custom_key(自设 API Key 明文)精确键
- Codex session 导入路由 body 整体由粘贴的 auth JSON 构成,键级脱敏无法覆盖,整体不入库
- 新增守卫测试:两份权威表的每个键必须被审计脱敏命中;provider_key 等渠道标识保留以便追责

中危——step-up 前端体验:
- 备份下载改同页 anchor 导航(预签名 URL 后端强制 attachment disposition),
  避免 step-up 弹窗 await 耗尽瞬态激活后 window.open 被浏览器拦截
- useStepUp.run 用户取消时抛 StepUpCancelledError sentinel,
  三个调用点静默处理,不再把取消误报为红色错误 toast

低危:
- 修正审计中间件挂载位置的陈旧注释(实际挂在认证之后)
- 审计 body 捕获改 LimitReader 按 256KB 上限截断读取,超出部分拼接回填,
  避免大体积导入请求被完整复制进内存两次
- TOTP step-up 弹窗验证成功后即时清空验证码输入
2026-07-16 14:38:16 +08:00
shaw a1af031969 fix(test): gofmt 审计日志测试 + 补齐 settings 契约 golden 两新字段
- audit_log_test.go: gofmt map 对齐
- api_contract_test.go: GET /admin/settings 两处 golden 补 session_binding_enabled/audit_log_retention_days
2026-07-16 13:58:50 +08:00
shaw 0ddd58aaf9 feat(security): 操作审计日志 + 会话IP/UA绑定 + 敏感操作 step-up 2FA
应对管理员访问凭证失守导致的数据外泄风险,新增三层防护:

审计日志(admin-only 可见,用户不可见)
- 新增 append-only audit_logs 表(migration 180)+ 异步批量写入 + 保留期清理
- 审计中间件挂在 admin/user/auth/admin-payment 组认证之后:记录所有变更类
  请求 + 白名单敏感读取(账号/代理导出、备份下载、admin/user API key 读取)
- 请求头凭证首尾掩码;请求体 JSON 递归脱敏(api_key/password 等擦除,base_url
  保留以便追责);非 JSON body 不入库
- 无单条删除;全量清空需现场 TOTP 校验、拒绝 admin API key、未启用 2FA 不允许,
  清空后同步写入留痕记录

会话 IP/UA 绑定(默认开启,可在系统设置关闭)
- JWT 携带 session id + IP/UA 指纹哈希;IP 或 UA 任一变化即撤销会话家族并要求
  重新登录;旧 token 无指纹时放行以平滑升级

敏感操作 step-up 2FA(sudo 窗口 15 分钟)
- 账号/代理导出、DB 备份创建/下载、S3 目标修改要求近期 TOTP 二次验证;admin API
  key 一律拒绝;前端 useStepUp 组合式 + TotpStepUpDialog 弹码后自动重试
- API key 查看按需求暂不加强管控

前端:新增 /admin/audit-logs 操作日志页面(筛选/详情/2FA 清空)、侧边栏入口、
step-up 弹窗接入导出与备份流程、安全设置项(绑定开关 + 日志保留天数)、zh/en i18n
2026-07-16 13:47:50 +08:00
harukaandClaude Opus 4.8 0eb6e21aaa feat: 异步图片任务结果落对象存储
为异步生图任务增加 S3 兼容对象存储支持,任务结果不再把大图内联存进 Redis:

- 新增可插拔接口 service.ImageStorage(Save -> url),适配别的厂商只需实现它
- S3 实现 S3ImageStorage(AWS S3 / R2 / 阿里云 OSS / MinIO),与备份共用 S3 客户端构造
- 新增 image_storage 配置(config.yaml + IMAGE_STORAGE_* 环境变量),默认关闭
- enabled 同时作为总开关:关闭或未配置对象存储时,异步生图接口返回 404 且不写
  Redis,从根上避免几 MB 的 b64_json 结果撑爆 Redis
- 完成时把图片上传对象存储并把结果改写为短链接(公开直链或 presigned),
  上传失败则任务标记为失败

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM1tf3CFVRzC7guuhBXvMd
2026-07-15 19:57:37 -07:00
haruka e5e94d1eb1 docs: document async image task API 2026-07-15 19:57:37 -07:00
haruka 1fb942dd77 feat: add async image generation tasks 2026-07-15 19:57:37 -07:00
Wesley Liddick 09c6c6d740 Merge pull request #4387 from yardbirds0/feat/upstream-rate-scheduling
feat: 按上游计费倍率调度 OpenAI 账号
2026-07-16 10:46:29 +08:00
shaw 0408bdb34f Merge remote-tracking branch 'origin/main' into feat/upstream-rate-scheduling
# Conflicts:
#	backend/internal/handler/openai_gateway_handler.go
2026-07-16 10:31:21 +08:00
Wesley Liddick ab978e615e Merge pull request #4385 from yardbirds0/feat/upstream-billing-probe
feat: 增加上游 Sub2API 计费倍率探测与账号展示
2026-07-16 10:27:45 +08:00
Wesley Liddick 8d36ce3d20 Merge pull request #4108 from yardbirds0/feat/key-billing-info
feat: 增加 API Key 计费倍率自省接口
2026-07-16 10:27:35 +08:00
shaw e2bb90a80d fix(test): tls.peet.ws 站点故障时跳过 TLS 指纹集成测试
skipIfExternalServiceUnavailable 的不可用特征清单漏掉 EOF/connection
reset/broken pipe,站点半死返回空响应断开时测试走 Fatalf 导致 CI 必红。
补齐特征清单,并把读体失败、非 200 状态码、响应体非法 JSON 一并归为
外部服务不可用跳过;站点健康时指纹校验行为不变。
2026-07-16 09:51:41 +08:00
Wesley Liddick 08828293b7 Merge pull request #4404 from Wei-Shaw/revert-4381-agent/fix-4326-async-image-tasks
Revert "feat: 支持异步生图任务与结果轮询"
2026-07-16 09:48:09 +08:00
Wesley Liddick 502097026f Revert "feat: 支持异步生图任务与结果轮询" 2026-07-16 09:47:27 +08:00
Wesley Liddick a3a227c858 Merge pull request #4381 from StarryKira/agent/fix-4326-async-image-tasks
feat: 支持异步生图任务与结果轮询
2026-07-16 09:44:26 +08:00
Wesley Liddick 51f2b0e3ee Merge pull request #4382 from wp-a/fix/openai-responses-rejected-field-retries
[codex] retry explicitly rejected Responses fields
2026-07-16 09:37:36 +08:00
Wesley Liddick 531ae04a0b Merge pull request #4395 from wp-a/fix/openai-body-limit-failover
[codex] fail over account-specific OpenAI body limits
2026-07-16 09:33:48 +08:00
Wesley Liddick e4329a04e8 Merge pull request #4393 from superman2003/fix/grok-codex-compatibility
fix(grok): improve Codex compatibility and key setup
2026-07-16 09:33:20 +08:00
Wesley Liddick f3138ceb43 Merge pull request #4384 from wp-a/fix/openai-model-scoped-transient-cooldown
[codex] scope OpenAI transient cooldowns by model
2026-07-16 09:33:09 +08:00
Wesley Liddick 1c5c42c183 Merge pull request #4394 from siyuan-123/codex/pr-gpt56-pat-alpha-search
fix(openai): route PAT alpha search via responses web_search
2026-07-16 09:32:59 +08:00
Wesley Liddick 3fc0336e1d Merge pull request #4377 from wp-a/fix/openai-ws-ingress-lifecycle
[codex] close OpenAI WS ingress reads cleanly
2026-07-16 09:32:50 +08:00
Wesley Liddick 807850769f Merge pull request #4396 from StarryKira/fix/4386-image-input-pricing
fix(billing): 图像输入 token 按独立单价计费(gpt-image-2 图片编辑)
2026-07-16 09:32:29 +08:00
Wesley Liddick 8852194670 Merge pull request #4402 from wucm667/fix/issue-4399-oauth-image-usage
fix(openai): 修复 OAuth 生图工具用量计费
2026-07-16 09:32:18 +08:00
Wesley Liddick 4226ff8e3a Merge pull request #4392 from jianjianai/new/perf-bulk-account-platform-scope
perf(scheduler): 按平台收窄批量账户事件重建
2026-07-16 09:31:59 +08:00
Wesley Liddick ff08694d79 Merge pull request #4400 from jianjianai/new/perf-scheduler-payload-reuse
perf: 复用同批次调度快照账号载荷写入
2026-07-16 09:31:51 +08:00
Wesley Liddick 7e43ebc792 Merge pull request #4369 from wp-a/fix/openai-wsv2-malformed-json
[codex] reject malformed OpenAI WS v2 events
2026-07-16 09:31:39 +08:00
Wesley Liddick 4a50d05393 Merge pull request #4380 from wucm667/fix/issue-4357-responses-lite-context
fix(openai): normalize Responses Lite reasoning context
2026-07-16 09:31:29 +08:00
Wesley Liddick ba01199812 Merge pull request #4376 from drgnchan/hotfix/find-n6-table-scroll
fix(frontend): preserve horizontal table scrolling on tablets
2026-07-16 09:31:16 +08:00
Wesley Liddick 25d7797d83 Merge pull request #4379 from wucm667/fix/issue-4348-reject-image-chat-models
fix(openai): reject image models on chat completions
2026-07-16 09:31:05 +08:00
wucm667 d22f4d9b5b fix(openai): use image tool usage for OAuth billing 2026-07-16 08:48:43 +08:00
jjaw f552448fb2 复用调度快照账号载荷写入 2026-07-16 04:07:15 +08:00
harukaandClaude Opus 4.8 62d57c02d8 feat(billing): usage_logs 单独记录图片输入 token 与费用
图片编辑/图生图请求的图片输入 token 此前并入 input_tokens/input_cost,
无法对账。拆分上报口径,total_cost 保持不变。

后端:
- CostBreakdown 新增 ImageInputCost;computeTokenBreakdown 将图片输入费用
  从 InputCost 拆出(InputCost 从此仅含文本输入),并纳入 tier 倍率与总额;
  长上下文合并路径同步携带 ImageInputCost
- 迁移 179:usage_logs 新增 image_input_tokens / image_input_cost 列
- UsageLog、insert/query 仓储(含定位参数数组、CTE 列表、扫描顺序)、
  DTO 与 mapper 补齐两列
- openai_gateway_usage 从 usage 与 cost 落库图片输入 token/费用

前端:
- UsageLog 类型、imageUsage 工具(hasImageInputTokens/Cost、textInputTokens)
- 用量表 token 徽标、Token/费用 tooltip 与单价行按图/文输入拆分展示
- zh/en usage.* i18n

测试:
- 新增 gpt-image-2 图片编辑复现用例(复现 #4386 的 $0.016081 期望值)
- 新增 usage 提取器图片输入 token 解析用例(input_tokens_details.image_tokens)
- 更新 doubao 图文分价用例与仓储/契约测试以匹配新的 input/image 拆分口径

相关 #4386。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wcJTKDddxXSQrepSs3wrU
2026-07-15 10:03:38 -07:00
王鹏 ad5e2a85b7 test(openai): validate body-limit error envelope 2026-07-16 00:52:26 +08:00
Tian Lee 90ee85f3ef feat: 按上游计费倍率调度 OpenAI 账号 2026-07-16 00:40:46 +08:00
siyuan 72fada40f6 fix(lint): check alpha search builder writes 2026-07-16 00:36:38 +08:00
siyuan 695665cbc8 fix(openai): fallback PAT alpha search to responses web_search 2026-07-16 00:36:38 +08:00
siyuan 776f3f0de7 fix(openai): align alpha search PAT forwarding 2026-07-16 00:36:38 +08:00
harukaandClaude Opus 4.8 06e03f467a feat(billing): 渠道自定义定价支持图片输入 token 单价 image_input_price
渠道 token 计费模式此前无法为图片输入 token 单独定价,gpt-image-2
图片编辑等请求的图像输入被按文本 input_price 计费。新增
channel_model_pricing.image_input_price 列及全链路支持。

后端:
- 迁移 178:channel_model_pricing 新增 image_input_price 列
- ChannelModelPricing 新增 ImageInputPrice 字段,repo 读写、校验补齐
- model_pricing_resolver / GetModelPricingWithChannel 映射到
  ImageInputPricePerToken;未配置时归零,由 computeTokenBreakdown
  回退文本输入价(向后兼容,与 image_output_price 的渠道权威规则一致)
- admin / 用户侧定价 DTO 与 model-pricing 自动填充接口补充该字段

前端:
- 渠道定价表单新增「图片输入」价格输入(token 模式)
- API 类型、表单模型、form↔API 换算、自动填充、用户侧模型定价卡展示
- zh/en i18n 标签

相关 #4386。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wcJTKDddxXSQrepSs3wrU
2026-07-15 09:35:34 -07:00
harukaandClaude Opus 4.8 7c43b7327d feat(billing): 解析 usage.input_tokens_details 拆分图/文输入 token
/v1/images/edits 等带图请求,上游在 input_tokens_details.image_tokens
单独回传图片输入 token,此前被合并进 input_tokens 整体按文本输入价计费。

- openAIUsageFromGJSON 读取 input_tokens_details.image_tokens
  (回退 prompt_tokens_details.image_tokens)写入 OpenAIUsage.ImageInputTokens
- mergeOpenAIUsage 补充 ImageInputTokens 的流式合并

配合全局定价的 input_cost_per_image_token,gpt-image-2 图片编辑请求的
图像输入即按独立单价计费。相关 #4386。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wcJTKDddxXSQrepSs3wrU
2026-07-15 09:24:44 -07:00
harukaandClaude Opus 4.8 10cb2ca42c feat(billing): 全局定价解析 input_cost_per_image_token
LiteLLM 定价数据中 gpt-image-2 等模型已含 input_cost_per_image_token
(图片输入 token 单价),但此前未被解析,导致图片编辑/图生图请求的
图像输入 token 被按文本输入价计费。

- LiteLLMModelPricing / LiteLLMRawEntry 新增 InputCostPerImageToken 字段并解析
- GetModelPricing 将其映射到 ModelPricing.ImageInputPricePerToken

计费拆分算术(computeTokenBreakdown)已支持图/文输入分价,本提交补齐
全局定价路径的数据来源。相关 #4386。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wcJTKDddxXSQrepSs3wrU
2026-07-15 09:23:35 -07:00
王鹏 3db00d3fee fix(openai): fail over account-specific body limits 2026-07-16 00:19:20 +08:00
superman2003 e8cc1fe26b feat(frontend): add Grok key setup templates 2026-07-16 00:16:21 +08:00
superman2003 410ea8490c fix(grok): allow passive Codex image tool declarations 2026-07-16 00:16:21 +08:00
jjaw a8a8a58f65 优化批量账户事件的调度重建范围 2026-07-16 00:11:17 +08:00
Tian Lee 0765d10c1d feat: 增加上游 Sub2API 计费倍率探测与账号展示 2026-07-15 23:58:46 +08:00
王鹏 40b8f04a6a fix(openai): scope transient cooldowns by model 2026-07-15 22:52:35 +08:00
Tian Lee f59a6ed74c feat: 增加 API Key 计费倍率自省接口 2026-07-15 22:42:53 +08:00
王鹏 5e4da92dec fix(openai): retry explicitly rejected Responses fields 2026-07-15 22:37:30 +08:00
wucm667 56650d6aed fix(openai): normalize Responses Lite reasoning context 2026-07-15 22:23:57 +08:00
wucm667 2fe7df9b81 fix(openai): reject image models on chat completions 2026-07-15 22:22:51 +08:00
王鹏 4f641208a0 fix(openai-ws): close ingress reads cleanly 2026-07-15 22:15:44 +08:00
haruka df247b4364 docs: document async image task API 2026-07-15 22:14:34 +08:00
haruka 134179085c feat: add async image generation tasks 2026-07-15 22:13:37 +08:00
shaw eb2b8632de fix(frontend): 修复 Grok OAuth 建号缺上游配置入口与 JSON 导入渲染崩溃
- CreateAccountModal 为 Grok OAuth 新增自定义上游地址与请求头覆写区块,
  经统一校验注入授权码兑换/RT 批量/SSO 批量三条创建路径(授权码路径
  在兑换前校验,避免烧掉一次性 code)
- JSON 导入示例文案内嵌花括号被 vue-i18n 消息编译器当占位符解析,
  渲染时抛 Invalid token in placeholder 炸掉面板:示例移出 i18n 硬编码;
  同类修复 settings 的死键 claudeOAuthSystemPromptBlocksPlaceholder(删)
  与 Hint 中被吞掉的 {billing_header}(字面量转义)
- 新增 localesMessageCompile 守卫测试:预编译中英全部文案,拦截非法占位符
- 面板打开后 scrollIntoView + 聚焦,避免在弹窗滚动容器视野外展开
- 删除「填入模板」按钮及三平台模板常量(产品决策:无意义)
- 抽取 HeaderOverrideEditor 共享组件,消除 Create/Edit/BulkEdit 三处重复
2026-07-15 22:05:59 +08:00
drgnchan 858f3e4a78 fix(frontend): preserve horizontal table scrolling on tablets 2026-07-15 21:57:08 +08:00
王鹏 716fcc6f3d fix(openai): reject malformed WS v2 events 2026-07-15 21:20:41 +08:00
Wesley Liddick be6cd1250c Merge pull request #4367 from Wei-Shaw/feat/grok-custom-base-url-and-headers
feat(grok): 支持账号级自定义上游地址与请求头覆写
2026-07-15 20:55:16 +08:00
Wesley Liddick 34015a1791 Merge pull request #4359 from catoncat/agent/fix-agent-identity-import-expiry
fix(openai): make Agent Identity usable end to end
2026-07-15 20:55:05 +08:00
Wesley Liddick a733e66330 Merge pull request #4358 from DanisJiang/feat/admin-recharge-affiliate-rebate
feat(affiliate): support rebates for admin balance deposits
2026-07-15 20:54:48 +08:00
Wesley Liddick 960d1886f3 Merge pull request #4323 from turingcat/feat/plan-currency-label
feat(payment): 订阅套餐支持币种标注,区分 $ 符号对应币种
2026-07-15 20:54:34 +08:00
shaw 221581400b feat(grok): 支持账号级自定义上游地址与请求头覆写
将账号级请求头覆写从 anthropic/openai 的 api_key 账号扩展到 Grok 的
api_key 与 oauth 账号,并放开 Grok OAuth 账号的自定义上游地址(仅作用于
转发端点,授权与 token 刷新链路不变)。

后端:
- IsHeaderOverrideEligible 扩展到 Grok(api_key+oauth),禁止名单新增
  x-grok-conv-id(逐请求会话路由头)。
- 所有 Grok 上游请求路径接线 ApplyHeaderOverrides(Responses/Chat 桥/
  媒体/配额探测/billing 探测/连通性测试),统一置于内置默认头之后。
- GetGrokBaseURL/GetGrokMediaBaseURL 放开 OAuth 自定义地址:官方地址
  视同未定制回落官方网关,仅显式第三方 host 改发转发流量。
- 非官方 host 允许任意 path 前缀,官方 host 仍强制 /v1。
- OAuth base_url 校验按 host 判定官方/自定义,自定义 host 恒受运营方
  URL 策略约束,不受 XAI_ALLOW_UNSAFE_URL_OVERRIDES 调试开关放宽。
- 给 GrokQuotaService 注入 config,使配额/billing 探测与转发共用同一
  URL 策略。

前端:
- Edit 模态为 Grok OAuth 账号新增「自定义上游地址」开关。
- 请求头表单新增 JSON 快速导入与按 JSON 一键复制(复用 useClipboard,
  兼容非安全上下文 HTTP 页面)。
- 门控改为平台×类型判定,Bulk 批量 base_url 增加格式校验,zh/en 文案同步。
2026-07-15 20:30:23 +08:00
cat c352d99d92 fix(openai): 调整 Agent Identity 授权入口层级 2026-07-15 20:03:47 +08:00
cat 2147da682b fix(openai): 修复 Agent Identity 审查问题 2026-07-15 18:30:16 +08:00
cat 12e6e70775 fix(openai): 补全 Agent Identity Codex 能力 2026-07-15 17:52:10 +08:00
cat 3b5072187a feat(openai): 增加 Agent Identity 独立导入入口 2026-07-15 17:52:10 +08:00
cat 529744c7c7 fix(openai): 修复 Agent Identity 导入过期校验 2026-07-15 17:52:10 +08:00
Yuhao Jiang 736824dd7a feat(affiliate): add admin recharge rebate option 2026-07-15 04:28:55 -05:00
turingcatandClaude Fable 5 705da4f610 feat(payment): 补充订阅套餐币种字段的 SQL 迁移文件
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:59:00 +08:00
github-actions[bot] d515c3045c chore: sync VERSION to 0.1.156 [skip ci] 2026-07-15 08:51:27 +00:00
turingcatandClaude Fable 5 2bbdd47002 feat(payment): 订阅套餐支持币种标注,区分 $ 符号对应币种
后台配置与用户端展示的套餐价格统一使用 $ 符号,而实际扣款币种随
支付渠道配置漂移(如 Stripe 配置 NZD 时按新西兰元扣款),用户易
误认为美元。本次为套餐新增可选的展示性币种标注:

- SubscriptionPlan 新增 currency 字段(ISO 4217 三字母码,默认空)
- 空值不展示任何标注,存量套餐行为完全不变
- 非空值复用 payment.NormalizePaymentCurrency 校验并规范化为大写
- 后台套餐编辑弹窗新增可选币种输入,列表价格列展示币种小字
- 用户端套餐卡片价格与划线原价旁展示币种小字
- 纯展示性质,不影响任何支付/扣款逻辑

Closes #4315

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:17:45 +08:00
729 changed files with 71667 additions and 3880 deletions
+1
View File
@@ -135,6 +135,7 @@ docs/*
!docs/PAYMENT.md
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
!docs/ASYNC_IMAGE_TASKS.md
!docs/legal/
!docs/legal/*.md
.serena/
+19 -5
View File
@@ -18,7 +18,9 @@ ARG NPM_CONFIG_REGISTRY=
# -----------------------------------------------------------------------------
# Stage 1: Frontend Builder
# -----------------------------------------------------------------------------
FROM ${NODE_IMAGE} AS frontend-builder
# --platform=$BUILDPLATFORM: the frontend output is JS (arch-neutral), so build
# it on the native host arch instead of under QEMU emulation for the target.
FROM --platform=${BUILDPLATFORM} ${NODE_IMAGE} AS frontend-builder
ARG NPM_CONFIG_REGISTRY
WORKDIR /app/frontend
@@ -44,7 +46,11 @@ RUN pnpm run build
# -----------------------------------------------------------------------------
# Stage 2: Backend Builder
# -----------------------------------------------------------------------------
FROM ${GOLANG_IMAGE} AS backend-builder
# --platform=$BUILDPLATFORM: run the Go toolchain on the native host arch and
# cross-compile to the target arch below. The binary is CGO_ENABLED=0, so this
# is a clean pure-Go cross-compile — no QEMU emulation of go mod download / go
# build (emulated networking here was dropping module fetches with EOF).
FROM --platform=${BUILDPLATFORM} ${GOLANG_IMAGE} AS backend-builder
# Build arguments for version info (set by CI)
ARG VERSION=
@@ -52,6 +58,9 @@ ARG COMMIT=docker
ARG DATE
ARG GOPROXY
ARG GOSUMDB
# Populated by buildx from the --platform target (e.g. linux/amd64).
ARG TARGETOS
ARG TARGETARCH
ENV GOPROXY=${GOPROXY}
ENV GOSUMDB=${GOSUMDB}
@@ -63,7 +72,10 @@ WORKDIR /app/backend
# Copy go mod files first (better caching)
COPY backend/go.mod backend/go.sum ./
RUN go mod download
# Cache mount keeps the module cache across builds so a transient CDN blip on
# retry resumes instead of re-fetching every zip from scratch.
RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \
go mod download
# Copy backend source first
COPY backend/ ./
@@ -73,10 +85,12 @@ COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
# Build the binary (BuildType=release for CI builds, embed frontend)
# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
RUN VERSION_VALUE="${VERSION}" && \
RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \
--mount=type=cache,id=sub2api-gobuild,target=/root/.cache/go-build \
VERSION_VALUE="${VERSION}" && \
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
CGO_ENABLED=0 GOOS=linux go build \
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
-tags embed \
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
-trimpath \
+53 -7
View File
@@ -1,7 +1,9 @@
# Sub2API
<div align="center">
<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
# Sub2API
[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
@@ -77,8 +79,8 @@ Please read the following carefully before using this project:
</tr>
<tr>
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
<td>Thanks to PPToken.org for sponsoring this project! <a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> specializes in GPT model API relay services, supporting Codex, Claude Code, OpenAI-compatible clients, and Gemini CLI integration. Top-ups are 1:1 (¥1 = $1 credit); GPT models start at 0.16x rate multiplier, with overall cost at roughly 2.2% of official pricing and first-token latency around 1 second — ideal for developers seeking low-cost, high-speed access to GPT model capabilities. Technical support: 24/7 real human responses (no bots), @tech in the group chat and get a reply within 10 minutes. Sponsor benefit: the first 200 users who register via the <a href="https://api.pptoken.org/register?promo=SUB2API">exclusive registration link</a> and enter promo code `SUB2API` can claim free Codex / Claude Code trial credits — no minimum spend, no card required.
<td width="180"><a href="https://api.pptoken.cc/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
<td>Thanks to PPToken.cc for sponsoring this project! <a href="https://api.pptoken.cc/register?promo=SUB2API">PPToken.cc</a> specializes in GPT model API relay services, supporting Codex, Claude Code, OpenAI-compatible clients, and Gemini CLI integration. Top-ups are 1:1 (¥1 = $1 credit); GPT models start at 0.16x rate multiplier, with overall cost at roughly 2.2% of official pricing and first-token latency around 1 second — ideal for developers seeking low-cost, high-speed access to GPT model capabilities. Technical support: 24/7 real human responses (no bots), @tech in the group chat and get a reply within 10 minutes. Sponsor benefit: the first 200 users who register via the <a href="https://api.pptoken.cc/register?promo=SUB2API">exclusive registration link</a> and enter promo code `SUB2API` can claim free Codex / Claude Code trial credits — no minimum spend, no card required.
</td>
</tr>
@@ -169,6 +171,18 @@ Model authenticity: no content intervention or secondary filtering — experienc
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>Thanks to code0.ai for sponsoring this project! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits and experience a more efficient AI coding workflow.
</td>
</tr>
</table>
## Overview
@@ -559,9 +573,18 @@ Additional security-related options are available in `config.yaml`:
- `security.response_headers.enabled` to enable configurable response header filtering (disabled uses default allowlist)
- `security.csp` to control Content-Security-Policy headers
- `billing.circuit_breaker` to fail closed on billing errors
- `server.trusted_proxies` to enable X-Forwarded-For parsing
- `security.trust_forwarded_ip_for_api_key_acl` enables legacy raw forwarded-header takeover (enabled by default for upgrade compatibility); disable it to enforce `server.trusted_proxies`, which should contain only the exact proxy CIDRs that connect directly to Sub2API
- `security.forwarded_client_ip_headers` configures up to 16 third-party CDN client-IP header names; they are checked in order before the built-in headers only while legacy takeover is enabled
- `turnstile.required` to require Turnstile in release mode
Custom client-IP headers can be set in YAML or as a comma-separated environment variable:
```bash
SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP
```
Header names are validated, canonicalized, and de-duplicated. The admin security settings can update the list without a restart; new installations persist YAML/environment defaults and existing installations backfill a missing database value. When legacy takeover is disabled, all custom and built-in raw forwarding headers are ignored and Gin uses only `server.trusted_proxies`. While takeover is enabled, firewall the origin to CDN/proxy addresses and make the edge overwrite every trusted client-IP header. See [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md) for the complete migration and trust-boundary rules.
**⚠️ Security Warning: HTTP URL Configuration**
When `security.url_allowlist.enabled=false`, the system performs minimal URL validation and **allows HTTP URLs by default** (dev-friendly mode; Docker Compose deployments use the same default). For production, explicitly tighten this to HTTPS-only:
@@ -627,8 +650,20 @@ override this limit.
The connection cap is coordinated through Redis using a 60-second lease that
is refreshed every 20 seconds. A process that cannot confirm a lease for a
full lease lifetime closes its local WebSocket rather than continuing outside
the global cap. Use `http_bridge` for client-WebSocket/upstream-HTTP operation
when rolling out or mitigating upstream WebSocket issues.
the global cap.
Enable the v2 mode router before selecting an account-level WS mode such as
`http_bridge`:
```yaml
gateway:
openai_ws:
mode_router_v2_enabled: true
```
Or set `GATEWAY_OPENAI_WS_MODE_ROUTER_V2_ENABLED=true` in the environment.
Use `http_bridge` for client-WebSocket/upstream-HTTP operation when rolling out
or mitigating upstream WebSocket issues.
#### ⚠️ Important: Creating the Admin Account
@@ -688,6 +723,12 @@ Simple Mode is designed for individual developers or internal teams who want qui
---
## Asynchronous Image Tasks
Long-running OpenAI/Grok image generation and editing can be submitted through `/v1/images/generations/async` or `/v1/images/edits/async`, then polled at `/v1/images/tasks/{task_id}` without holding a CDN connection open. See [Asynchronous Image Tasks](docs/ASYNC_IMAGE_TASKS.md) for request and response examples.
---
## Grok / xAI Support
Sub2API supports both Grok subscription accounts through xAI OAuth and standard xAI API-key accounts. Both account types forward OpenAI-compatible Responses traffic to xAI.
@@ -703,6 +744,7 @@ Sub2API supports both Grok subscription accounts through xAI OAuth and standard
- Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/edits`, `/videos/edits`, `/v1/videos/extensions`, `/videos/extensions`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation, editing, and extension requests require the group image-generation permission.
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
- JSON image-edit and video-generation requests accept image references in `image`, `images`, `reference_images`, and `mask` objects. Use `url` for xAI-compatible payloads; the legacy `image_url` field remains accepted and is normalized to `url` before forwarding.
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
### OAuth Configuration
@@ -769,6 +811,10 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco
`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations.
---
## Antigravity Support
+28 -5
View File
@@ -1,7 +1,9 @@
# Sub2API
<div align="center">
<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
# Sub2API
[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
@@ -80,8 +82,8 @@
</tr>
<tr>
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
<td>感谢 PPToken.org 赞助本项目! <a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> 主打 GPT 系列模型 API 中转服务,支持 Codex、Claude Code、OpenAI 兼容客户端及 Gemini CLI 等工具接入。充值 1:1,1 元=1 美元额度;GPT 模型最低 0.16 倍倍率,综合成本约为官方价格的 0.22 折,最快首字 Token 约 1 秒,适合开发者低成本、高响应速度接入 GPT 模型能力。技术支持: 7×24 小时真人响应(不是机器人),群内@技术,10 分钟内有回复 。赞助商福利:前 200 名用户通过 <a href="https://api.pptoken.org/register?promo=SUB2API">[专属注册链接]</a> 注册,输入优惠码 `SUB2API`,可领取 Codex / Claude Code 免费试用额度,无门槛、不绑卡。
<td width="180"><a href="https://api.pptoken.cc/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
<td>感谢 PPToken.cc 赞助本项目! <a href="https://api.pptoken.cc/register?promo=SUB2API">PPToken.cc</a> 主打 GPT 系列模型 API 中转服务,支持 Codex、Claude Code、OpenAI 兼容客户端及 Gemini CLI 等工具接入。充值 1:1,1 元=1 美元额度;GPT 模型最低 0.16 倍倍率,综合成本约为官方价格的 0.22 折,最快首字 Token 约 1 秒,适合开发者低成本、高响应速度接入 GPT 模型能力。技术支持: 7×24 小时真人响应(不是机器人),群内@技术,10 分钟内有回复 。赞助商福利:前 200 名用户通过 <a href="https://api.pptoken.cc/register?promo=SUB2API">[专属注册链接]</a> 注册,输入优惠码 `SUB2API`,可领取 Codex / Claude Code 免费试用额度,无门槛、不绑卡。
</td>
</tr>
@@ -172,6 +174,18 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>感谢 code0.ai 对本项目的赞助! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。
</td>
</tr>
</table>
## 项目概述
@@ -595,9 +609,18 @@ gateway:
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
- `security.csp` 配置 Content-Security-Policy
- `billing.circuit_breaker` 计费异常时 fail-closed
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
- `security.trust_forwarded_ip_for_api_key_acl` 控制旧版原始转发头接管(为升级兼容默认开启);关闭后严格使用 `server.trusted_proxies`,其中只应填写直接连接 Sub2API 的精确代理 CIDR
- `security.forwarded_client_ip_headers` 最多配置 16 个第三方 CDN 客户端 IP 请求头;仅在旧版接管开启时按顺序优先于内置请求头解析
- `turnstile.required` 在 release 模式强制启用 Turnstile
自定义客户端 IP 请求头可通过 YAML 配置,也可使用逗号分隔的环境变量:
```bash
SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP
```
请求头名称会经过合法性校验、规范化和大小写无关去重。管理员可在安全设置中动态更新列表,无需重启;新安装会持久化 YAML/环境变量默认值,旧安装缺少数据库字段时会自动回填。关闭旧版接管后,自定义头和内置原始转发头均被忽略,只使用 `server.trusted_proxies`。开启接管时必须限制源站仅允许 CDN/代理访问,并确保边缘代理覆盖所有受信客户端 IP 请求头。完整迁移规则和信任边界见 [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md)。
**网关防御纵深建议(重点)**
- `gateway.upstream_response_read_max_bytes`:限制非流式上游响应读取大小(默认 `8MB`),用于防止异常响应导致内存放大。
+28 -5
View File
@@ -1,7 +1,9 @@
# Sub2API
<div align="center">
<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
# Sub2API
[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
@@ -79,8 +81,8 @@
</tr>
<tr>
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
<td>PPToken.org のご支援に感謝します!<a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> は GPT シリーズモデルの API 中継サービスを専門としており、Codex、Claude Code、OpenAI 互換クライアント、Gemini CLI などのツール接続をサポートしています。チャージは 1:1(1元=1ドル分のクレジット)、GPT モデルは最低 0.16 倍のレート倍率で、総合コストは公式価格の約 2.2% 、最速ファーストトークンは約1秒 — 開発者が低コスト・高速レスポンスで GPT モデル機能にアクセスするのに最適です。テクニカルサポート:24時間365日リアルな人間が対応(ボットではありません)、グループ内で @技術 すれば 10 分以内に返信。スポンサー特典:先着 200 名のユーザーが<a href="https://api.pptoken.org/register?promo=SUB2API">専用登録リンク</a>から登録し、プロモコード `SUB2API` を入力すると、Codex / Claude Code の無料トライアルクレジットを獲得できます — 最低利用額なし、カード登録不要。
<td width="180"><a href="https://api.pptoken.cc/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
<td>PPToken.cc のご支援に感謝します!<a href="https://api.pptoken.cc/register?promo=SUB2API">PPToken.cc</a> は GPT シリーズモデルの API 中継サービスを専門としており、Codex、Claude Code、OpenAI 互換クライアント、Gemini CLI などのツール接続をサポートしています。チャージは 1:1(1元=1ドル分のクレジット)、GPT モデルは最低 0.16 倍のレート倍率で、総合コストは公式価格の約 2.2% 、最速ファーストトークンは約1秒 — 開発者が低コスト・高速レスポンスで GPT モデル機能にアクセスするのに最適です。テクニカルサポート:24時間365日リアルな人間が対応(ボットではありません)、グループ内で @技術 すれば 10 分以内に返信。スポンサー特典:先着 200 名のユーザーが<a href="https://api.pptoken.cc/register?promo=SUB2API">専用登録リンク</a>から登録し、プロモコード `SUB2API` を入力すると、Codex / Claude Code の無料トライアルクレジットを獲得できます — 最低利用額なし、カード登録不要。
</td>
</tr>
@@ -167,6 +169,18 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>code0.ai のご支援に感謝します!<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。
</td>
</tr>
</table>
## 概要
@@ -557,9 +571,18 @@ default:
- `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用)
- `security.csp` - Content-Security-Policy ヘッダーの制御
- `billing.circuit_breaker` - 課金エラー時にフェイルクローズ
- `server.trusted_proxies` - X-Forwarded-For パースの有効化
- `security.trust_forwarded_ip_for_api_key_acl` - 従来の生転送ヘッダーによる上書きを制御(アップグレード互換性のため既定で有効)。無効にすると `server.trusted_proxies` を厳格に使用し、Sub2API に直接接続するプロキシの正確な CIDR のみを指定
- `security.forwarded_client_ip_headers` - サードパーティ CDN のクライアント IP ヘッダーを最大 16 個指定。従来モードが有効な場合のみ、設定順で組み込みヘッダーより先に評価
- `turnstile.required` - リリースモードでの Turnstile 必須化
カスタムクライアント IP ヘッダーは YAML またはカンマ区切りの環境変数で設定できます:
```bash
SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP
```
ヘッダー名は検証、正規化、大小文字を区別しない重複排除が行われます。管理画面のセキュリティ設定から再起動せずに更新でき、新規インストールでは YAML/環境変数の既定値を保存し、既存環境ではデータベース値がない場合に補完します。従来モードを無効にするとカスタムおよび組み込みの生転送ヘッダーはすべて無視され、`server.trusted_proxies` のみを使用します。有効にする場合はオリジンへの接続元を CDN/プロキシに制限し、エッジで信頼する全クライアント IP ヘッダーを上書きしてください。移行規則と信頼境界の詳細は [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md) を参照してください。
**⚠️ セキュリティ警告: HTTP URL 設定**
`security.url_allowlist.enabled=false` の場合、システムは最小限の URL バリデーションのみを行い、**デフォルトで HTTP URL を許可**します(開発フレンドリーモード。Docker Compose デプロイのデフォルトも同じです)。本番環境では、以下のように明示的に HTTPS のみに制限することを推奨します:
+32
View File
@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title desc">
<title id="title">Sub2API</title>
<desc id="desc">An interlocking S symbol representing subscription routing into APIs.</desc>
<defs>
<linearGradient id="s2a-bg" x1="72" y1="44" x2="442" y2="478" gradientUnits="userSpaceOnUse">
<stop stop-color="#142B56"/>
<stop offset=".52" stop-color="#0A1A39"/>
<stop offset="1" stop-color="#061127"/>
</linearGradient>
<radialGradient id="s2a-ambient" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(168 92) rotate(51) scale(342 382)">
<stop stop-color="#3E68B0" stop-opacity=".28"/>
<stop offset="1" stop-color="#3E68B0" stop-opacity="0"/>
</radialGradient>
<linearGradient id="s2a-brand" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#79F4BD"/>
<stop offset=".48" stop-color="#39D9E7"/>
<stop offset="1" stop-color="#3875F6"/>
</linearGradient>
<filter id="s2a-shadow" x="-25%" y="-25%" width="150%" height="165%" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy=".55" stdDeviation=".65" flood-color="#020817" flood-opacity=".34"/>
</filter>
</defs>
<rect x="16" y="16" width="480" height="480" rx="120" fill="url(#s2a-bg)"/>
<rect x="16" y="16" width="480" height="480" rx="120" fill="url(#s2a-ambient)"/>
<rect x="16.75" y="16.75" width="478.5" height="478.5" rx="119.25" fill="none" stroke="#A5BFFF" stroke-opacity=".16" stroke-width="1.5"/>
<g transform="translate(52 52) scale(17)" fill="none" stroke="url(#s2a-brand)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.7" filter="url(#s2a-shadow)">
<path d="m19.25 7.65-2.55-3.2a1.33 1.33 0 0 0-1.03-.5H8.58c-.34 0-.67.13-.91.37L4.15 7.65c-.93.88-.6 1.52.65 2.3l9.55 5.97"/>
<path d="m4.75 16.35 2.55 3.2c.25.31.63.5 1.03.5h7.09c.34 0 .67-.13.91-.37l3.52-3.33c.93-.88.6-1.52-.65-2.3L9.65 8.08"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,20 @@
# Ingress rejection log cleanup
This maintenance command removes historical admission rejections from
`ops_error_logs` without matching unrelated authentication or upstream errors.
It is a dry run unless `--execute` is supplied, and always requires an explicit
RFC3339 cutoff.
```sh
go run ./cmd/cleanup-ingress-reject-logs --before 2026-07-17T00:00:00Z
go run ./cmd/cleanup-ingress-reject-logs --before 2026-07-17T00:00:00Z --execute
```
Run the execute form only after every application instance has been upgraded so
older instances cannot add new ingress rejection rows below the chosen cutoff.
The classifier intentionally retains invariant failures such as
`USER_NOT_FOUND`, database errors, quota/billing errors, and upstream failures.
After the rollout and cleanup are verified, run
`backend/scripts/finalize-ingress-reject-cleanup.sql` in a maintenance window to
remove the deprecated plaintext-key audit table and attribution columns.
@@ -0,0 +1,218 @@
package main
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"log"
"sort"
"strings"
"time"
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/lib/pq"
)
const classifierVersion = "ingress-reject-v1"
type candidate struct {
id int64
statusCode int
message string
body string
}
func main() {
beforeRaw := flag.String("before", "", "required RFC3339 cutoff; only older rows are considered")
execute := flag.Bool("execute", false, "delete matched rows (default is dry-run)")
batchSize := flag.Int("batch-size", 5000, "scan/delete batch size (1-5000)")
flag.Parse()
if *beforeRaw == "" {
log.Fatal("--before is required")
}
before, err := time.Parse(time.RFC3339, *beforeRaw)
if err != nil {
log.Fatalf("invalid --before: %v", err)
}
if *batchSize < 1 || *batchSize > 5000 {
log.Fatal("--batch-size must be between 1 and 5000")
}
cfg, err := config.LoadForBootstrap()
if err != nil {
log.Fatalf("load config: %v", err)
}
client, db, err := repository.InitEnt(cfg)
if err != nil {
log.Fatalf("initialize database: %v", err)
}
defer func() { _ = client.Close() }()
ctx := context.Background()
counts, scanned, matched, deleted, err := cleanup(ctx, db, before, *batchSize, *execute)
if err != nil {
log.Fatalf("cleanup failed: %v", err)
}
digest := sha256.Sum256([]byte(classifierVersion))
mode := "dry-run"
if *execute {
mode = "execute"
}
fmt.Printf("mode=%s before=%s classifier=%s scanned=%d matched=%d deleted=%d\n",
mode, before.UTC().Format(time.RFC3339), hex.EncodeToString(digest[:]), scanned, matched, deleted)
reasons := make([]string, 0, len(counts))
for reason := range counts {
reasons = append(reasons, reason)
}
sort.Strings(reasons)
for _, reason := range reasons {
fmt.Printf("reason=%s count=%d\n", reason, counts[reason])
}
if *execute && deleted > 0 {
fmt.Println("cleanup complete; schedule VACUUM (ANALYZE) ops_error_logs during normal maintenance")
}
}
func cleanup(ctx context.Context, db *sql.DB, before time.Time, batchSize int, execute bool) (map[string]int64, int64, int64, int64, error) {
counts := make(map[string]int64)
var cursor, scanned, matched, deleted int64
for {
rows, err := db.QueryContext(ctx, `
SELECT id, COALESCE(status_code, 0), COALESCE(error_message, ''), COALESCE(error_body, '')
FROM ops_error_logs
WHERE id > $1
AND created_at < $2
AND error_phase = 'auth'
AND account_id IS NULL
AND upstream_status_code IS NULL
AND COALESCE(upstream_error_message, '') = ''
AND COALESCE(upstream_error_detail, '') = ''
ORDER BY id ASC
LIMIT $3`, cursor, before, batchSize)
if err != nil {
return nil, scanned, matched, deleted, err
}
batch := make([]candidate, 0, batchSize)
for rows.Next() {
var item candidate
if err := rows.Scan(&item.id, &item.statusCode, &item.message, &item.body); err != nil {
_ = rows.Close()
return nil, scanned, matched, deleted, err
}
batch = append(batch, item)
cursor = item.id
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, scanned, matched, deleted, err
}
_ = rows.Close()
if len(batch) == 0 {
break
}
ids := make([]int64, 0, len(batch))
for _, item := range batch {
scanned++
if reason, ok := historicalIngressRejectReason(item); ok {
matched++
counts[reason]++
ids = append(ids, item.id)
}
}
if execute && len(ids) > 0 {
result, err := db.ExecContext(ctx,
`DELETE FROM ops_error_logs WHERE id = ANY($1) AND created_at < $2`, pq.Array(ids), before)
if err != nil {
return nil, scanned, matched, deleted, err
}
n, err := result.RowsAffected()
if err != nil {
return nil, scanned, matched, deleted, err
}
deleted += n
}
}
return counts, scanned, matched, deleted, nil
}
func historicalIngressRejectReason(item candidate) (string, bool) {
code, message := parseErrorIdentity(item.body, item.message)
switch code {
case "API_KEY_REQUIRED":
return "missing_key", true
case "INVALID_API_KEY":
return "invalid_key", true
case "API_KEY_DISABLED":
return "key_disabled", true
case "USER_INACTIVE":
return "user_inactive", true
case "GROUP_DELETED":
return "group_deleted", true
case "GROUP_DISABLED":
return "group_disabled", true
case "GROUP_NOT_ALLOWED":
return "group_forbidden", true
case "ACCESS_DENIED":
return "ip_acl_denied", true
case "api_key_in_query_deprecated":
return "query_key_deprecated", true
}
normalized := strings.TrimSpace(message)
switch {
case normalized == "API key is required":
return "missing_key", true
case normalized == "Invalid API key":
return "invalid_key", true
case normalized == "API key is disabled":
return "key_disabled", true
case normalized == "User account is not active":
return "user_inactive", true
case normalized == "API Key 所属分组已删除":
return "group_deleted", true
case normalized == "API Key 所属分组已停用":
return "group_disabled", true
case normalized == "API Key 所属专属分组不再允许当前用户使用":
return "group_forbidden", true
case normalized == "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.":
return "group_unassigned", true
case strings.HasPrefix(normalized, "Access denied. Your IP is "):
return "ip_acl_denied", true
case normalized == "Query parameter api_key is deprecated. Use Authorization header or key instead.":
return "query_key_deprecated", true
default:
return "", false
}
}
func parseErrorIdentity(body, fallbackMessage string) (string, string) {
var payload struct {
Code string `json:"code"`
Message string `json:"message"`
Error struct {
Code json.RawMessage `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(body), &payload); err != nil {
return "", fallbackMessage
}
message := payload.Message
if message == "" {
message = payload.Error.Message
}
if message == "" {
message = fallbackMessage
}
return strings.TrimSpace(payload.Code), message
}
@@ -0,0 +1,29 @@
package main
import "testing"
func TestHistoricalIngressRejectReason(t *testing.T) {
tests := []struct {
name string
item candidate
reason string
match bool
}{
{name: "standard invalid key", item: candidate{body: `{"code":"INVALID_API_KEY","message":"Invalid API key"}`}, reason: "invalid_key", match: true},
{name: "google missing key", item: candidate{body: `{"error":{"code":401,"message":"API key is required","status":"UNAUTHENTICATED"}}`}, reason: "missing_key", match: true},
{name: "google group deleted", item: candidate{body: `{"error":{"code":403,"message":"API Key 所属分组已删除","status":"PERMISSION_DENIED"}}`}, reason: "group_deleted", match: true},
{name: "ip acl", item: candidate{body: `{"code":"ACCESS_DENIED","message":"Access denied. Your IP is 192.0.2.1"}`}, reason: "ip_acl_denied", match: true},
{name: "user not found remains", item: candidate{body: `{"code":"USER_NOT_FOUND","message":"User associated with API key not found"}`}, match: false},
{name: "quota remains", item: candidate{body: `{"code":"API_KEY_QUOTA_EXHAUSTED","message":"quota"}`}, match: false},
{name: "database failure remains", item: candidate{statusCode: 500, message: "Failed to validate API key", body: `{"code":"INTERNAL_ERROR","message":"Failed to validate API key"}`}, match: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reason, ok := historicalIngressRejectReason(tt.item)
if ok != tt.match || reason != tt.reason {
t.Fatalf("got (%q, %v), want (%q, %v)", reason, ok, tt.reason, tt.match)
}
})
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ func main() {
log.Fatalf("failed to resolve admin user: %v", err)
}
token, err := authService.GenerateToken(user)
token, err := authService.GenerateToken(ctx, user)
if err != nil {
log.Fatalf("failed to generate token: %v", err)
}
+1 -1
View File
@@ -1 +1 @@
0.1.155
0.1.161
+9
View File
@@ -153,6 +153,15 @@ func runMainServer() {
log.Fatalf("Failed to initialize application: %v", err)
}
defer app.Cleanup()
if app.PromptAudit != nil {
if err := app.PromptAudit.Start(context.Background()); err != nil {
// Startup continues so unrelated APIs stay up. Fail-closed (unavailable)
// applies only when a persisted blocking policy was observed; without
// blocking intent, Prompt Audit stays ModeOff so the gateway remains
// usable and administrators can still disable the feature (#4560).
log.Printf("Prompt Audit started in degraded state: %v", err)
}
}
// 启动服务器
go func() {
+55 -3
View File
@@ -15,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
"github.com/Wei-Shaw/sub2api/internal/server"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -24,8 +25,9 @@ import (
)
type Application struct {
Server *http.Server
Cleanup func()
Server *http.Server
PromptAudit *securityaudit.PromptService
Cleanup func()
}
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
@@ -36,6 +38,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// Business layer ProviderSets
repository.ProviderSet,
service.ProviderSet,
securityaudit.ProviderSet,
payment.ProviderSet,
middleware.ProviderSet,
handler.ProviderSet,
@@ -53,7 +56,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
provideCleanup,
// Application struct
wire.Struct(new(Application), "Server", "Cleanup"),
wire.Struct(new(Application), "Server", "PromptAudit", "Cleanup"),
)
return nil, nil
}
@@ -78,6 +81,10 @@ func provideCleanup(
opsCleanup *service.OpsCleanupService,
opsScheduledReport *service.OpsScheduledReportService,
opsSystemLogSink *service.OpsSystemLogSink,
opsService *service.OpsService,
opsIngressReject *service.OpsIngressRejectAggregator,
apiKeyService *service.APIKeyService,
authCacheInvalidationWorker *service.AuthCacheInvalidationWorker,
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
@@ -103,6 +110,9 @@ func provideCleanup(
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
auditLog *service.AuditLogService,
promptAudit *securityaudit.PromptService,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -115,6 +125,36 @@ func provideCleanup(
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
parallelSteps := []cleanupStep{
{"OpsIngressRejectAggregator", func() error {
if opsIngressReject != nil {
opsIngressReject.Stop()
}
return nil
}},
{"AuthCacheInvalidationWorker", func() error {
if authCacheInvalidationWorker != nil {
authCacheInvalidationWorker.Stop()
}
return nil
}},
{"AuthCacheInvalidationSubscriber", func() error {
if apiKeyService != nil {
apiKeyService.StopAuthCacheInvalidationSubscriber()
}
return nil
}},
{"OpsRuntimeSettingsRefresh", func() error {
if opsService != nil {
opsService.StopRuntimeSettingsRefresh()
}
return nil
}},
{"PromptAuditService", func() error {
if promptAudit != nil {
return promptAudit.Shutdown(ctx)
}
return nil
}},
{"OpsScheduledReportService", func() error {
if opsScheduledReport != nil {
opsScheduledReport.Stop()
@@ -133,6 +173,12 @@ func provideCleanup(
}
return nil
}},
{"AuditLogService", func() error {
if auditLog != nil {
auditLog.Stop()
}
return nil
}},
{"OpsAlertEvaluatorService", func() error {
if opsAlertEvaluator != nil {
opsAlertEvaluator.Stop()
@@ -279,6 +325,12 @@ func provideCleanup(
}
return nil
}},
{"UpstreamBillingProbeService", func() error {
if upstreamBillingProbe != nil {
upstreamBillingProbe.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{
+96 -20
View File
@@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
"github.com/Wei-Shaw/sub2api/internal/server"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -70,7 +71,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig)
accountRepository := repository.NewAccountRepository(client, db, schedulerCache)
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
apiKeyService := service.ProvideAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig, billingCacheService, concurrencyService)
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
@@ -157,7 +157,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService)
authCacheInvalidationOutboxRepository := repository.NewAuthCacheInvalidationOutboxRepository(db)
authCacheInvalidationWorker := service.ProvideAuthCacheInvalidationWorker(authCacheInvalidationOutboxRepository, apiKeyCache, apiKeyService)
opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService, authCacheInvalidationWorker, apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService, opsService, settingService)
redeemHandler := handler.NewRedeemHandler(redeemService)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
@@ -174,16 +176,18 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
leaderLockCache := repository.NewLeaderLockCache(redisClient)
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
adminGroupRepository := repository.NewAdminGroupRepository(client, db)
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, usageLogRepository)
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository)
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
usageCache := service.NewUsageCache()
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
@@ -196,7 +200,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
dbDumper := repository.NewPgDumper(configConfig)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
backupHandler := admin.NewBackupHandler(backupService, userService)
imageStorageFactory := repository.ProvideImageStorageFactory()
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService)
oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
@@ -214,7 +220,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
registry := payment.ProvideRegistry()
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService)
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService)
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService)
opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
@@ -247,20 +253,36 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor)
postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db)
redisPayloadStore := securityaudit.NewRedisPayloadStore(redisClient)
openAICompatibleScanner := securityaudit.NewOpenAICompatibleScanner()
atomicMetrics := securityaudit.NewAtomicMetrics()
promptService := securityaudit.NewPromptService(configManager, postgreSQLRepository, redisPayloadStore, openAICompatibleScanner, atomicMetrics)
promptAdminHandler := securityaudit.NewPromptAdminHandler(promptService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
complianceHandler := admin.NewComplianceHandler(settingService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
auditLogRepository := repository.NewAuditLogRepository(db)
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, configConfig)
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
imageTaskStore := repository.NewImageTaskStore(redisClient)
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
batchImageRepository := repository.NewBatchImageRepository(db)
batchImageQueue := repository.NewBatchImageQueue(redisClient, configConfig)
batchImageModelPricingResolver := service.ProvideBatchImageModelPricingResolver(modelPricingResolver)
@@ -268,20 +290,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageDownloadLimiter := repository.NewBatchImageDownloadLimiter(redisClient, configConfig)
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
batchImageHandler := handler.NewBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService)
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient)
auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService)
stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient)
httpServer := server.ProvideHTTPServer(configConfig, engine)
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig, proxyRepository)
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService, settingRepository, opsService)
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
@@ -290,10 +315,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, auditLogService, promptService)
application := &Application{
Server: httpServer,
Cleanup: v,
Server: httpServer,
PromptAudit: promptService,
Cleanup: v,
}
return application, nil
}
@@ -301,8 +327,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// wire.go:
type Application struct {
Server *http.Server
Cleanup func()
Server *http.Server
PromptAudit *securityaudit.PromptService
Cleanup func()
}
func providePrivacyClientFactory() service.PrivacyClientFactory {
@@ -325,6 +352,10 @@ func provideCleanup(
opsCleanup *service.OpsCleanupService,
opsScheduledReport *service.OpsScheduledReportService,
opsSystemLogSink *service.OpsSystemLogSink,
opsService *service.OpsService,
opsIngressReject *service.OpsIngressRejectAggregator,
apiKeyService *service.APIKeyService,
authCacheInvalidationWorker *service.AuthCacheInvalidationWorker,
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
@@ -350,6 +381,9 @@ func provideCleanup(
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
auditLog *service.AuditLogService,
promptAudit *securityaudit.PromptService,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -361,6 +395,36 @@ func provideCleanup(
}
parallelSteps := []cleanupStep{
{"OpsIngressRejectAggregator", func() error {
if opsIngressReject != nil {
opsIngressReject.Stop()
}
return nil
}},
{"AuthCacheInvalidationWorker", func() error {
if authCacheInvalidationWorker != nil {
authCacheInvalidationWorker.Stop()
}
return nil
}},
{"AuthCacheInvalidationSubscriber", func() error {
if apiKeyService != nil {
apiKeyService.StopAuthCacheInvalidationSubscriber()
}
return nil
}},
{"OpsRuntimeSettingsRefresh", func() error {
if opsService != nil {
opsService.StopRuntimeSettingsRefresh()
}
return nil
}},
{"PromptAuditService", func() error {
if promptAudit != nil {
return promptAudit.Shutdown(ctx)
}
return nil
}},
{"OpsScheduledReportService", func() error {
if opsScheduledReport != nil {
opsScheduledReport.Stop()
@@ -379,6 +443,12 @@ func provideCleanup(
}
return nil
}},
{"AuditLogService", func() error {
if auditLog != nil {
auditLog.Stop()
}
return nil
}},
{"OpsAlertEvaluatorService", func() error {
if opsAlertEvaluator != nil {
opsAlertEvaluator.Stop()
@@ -525,6 +595,12 @@ func provideCleanup(
}
return nil
}},
{"UpstreamBillingProbeService", func() error {
if upstreamBillingProbe != nil {
upstreamBillingProbe.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{
+7
View File
@@ -58,6 +58,10 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
&service.OpsCleanupService{},
&service.OpsScheduledReportService{},
opsSystemLogSinkSvc,
nil, // opsService
nil, // opsIngressRejectAggregator
nil, // apiKeyService
nil, // authCacheInvalidationWorker
schedulerSnapshotSvc,
tokenRefreshSvc,
accountExpirySvc,
@@ -83,6 +87,9 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // quotaFlusher
nil, // upstreamBillingProbe
nil, // auditLog
nil, // promptAudit
)
require.NotPanics(t, func() {
+15 -1
View File
@@ -43,6 +43,8 @@ type Group struct {
IsExclusive bool `json:"is_exclusive,omitempty"`
// Status holds the value of the "status" field.
Status string `json:"status,omitempty"`
// 内部幂等恢复标识,不对 API 暴露
DuplicateOperationID *string `json:"duplicate_operation_id,omitempty"`
// Platform holds the value of the "platform" field.
Platform string `json:"platform,omitempty"`
// SubscriptionType holds the value of the "subscription_type" field.
@@ -229,7 +231,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullFloat64)
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
values[i] = new(sql.NullInt64)
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
values[i] = new(sql.NullString)
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
values[i] = new(sql.NullTime)
@@ -328,6 +330,13 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.Status = value.String
}
case group.FieldDuplicateOperationID:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field duplicate_operation_id", values[i])
} else if value.Valid {
_m.DuplicateOperationID = new(string)
*_m.DuplicateOperationID = value.String
}
case group.FieldPlatform:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field platform", values[i])
@@ -680,6 +689,11 @@ func (_m *Group) String() string {
builder.WriteString("status=")
builder.WriteString(_m.Status)
builder.WriteString(", ")
if v := _m.DuplicateOperationID; v != nil {
builder.WriteString("duplicate_operation_id=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("platform=")
builder.WriteString(_m.Platform)
builder.WriteString(", ")
+10
View File
@@ -40,6 +40,8 @@ const (
FieldIsExclusive = "is_exclusive"
// FieldStatus holds the string denoting the status field in the database.
FieldStatus = "status"
// FieldDuplicateOperationID holds the string denoting the duplicate_operation_id field in the database.
FieldDuplicateOperationID = "duplicate_operation_id"
// FieldPlatform holds the string denoting the platform field in the database.
FieldPlatform = "platform"
// FieldSubscriptionType holds the string denoting the subscription_type field in the database.
@@ -199,6 +201,7 @@ var Columns = []string{
FieldPeakRateMultiplier,
FieldIsExclusive,
FieldStatus,
FieldDuplicateOperationID,
FieldPlatform,
FieldSubscriptionType,
FieldDailyLimitUsd,
@@ -292,6 +295,8 @@ var (
DefaultStatus string
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
StatusValidator func(string) error
// DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save.
DuplicateOperationIDValidator func(string) error
// DefaultPlatform holds the default value on creation for the "platform" field.
DefaultPlatform string
// PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
@@ -414,6 +419,11 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStatus, opts...).ToFunc()
}
// ByDuplicateOperationID orders the results by the duplicate_operation_id field.
func ByDuplicateOperationID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDuplicateOperationID, opts...).ToFunc()
}
// ByPlatform orders the results by the platform field.
func ByPlatform(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPlatform, opts...).ToFunc()
+80
View File
@@ -115,6 +115,11 @@ func Status(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldStatus, v))
}
// DuplicateOperationID applies equality check predicate on the "duplicate_operation_id" field. It's identical to DuplicateOperationIDEQ.
func DuplicateOperationID(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v))
}
// Platform applies equality check predicate on the "platform" field. It's identical to PlatformEQ.
func Platform(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPlatform, v))
@@ -840,6 +845,81 @@ func StatusContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldStatus, v))
}
// DuplicateOperationIDEQ applies the EQ predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDEQ(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDNEQ applies the NEQ predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDNEQ(v string) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDIn applies the In predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldIn(FieldDuplicateOperationID, vs...))
}
// DuplicateOperationIDNotIn applies the NotIn predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDNotIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldDuplicateOperationID, vs...))
}
// DuplicateOperationIDGT applies the GT predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDGT(v string) predicate.Group {
return predicate.Group(sql.FieldGT(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDGTE applies the GTE predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDGTE(v string) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDLT applies the LT predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDLT(v string) predicate.Group {
return predicate.Group(sql.FieldLT(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDLTE applies the LTE predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDLTE(v string) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDContains applies the Contains predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDContains(v string) predicate.Group {
return predicate.Group(sql.FieldContains(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDHasPrefix applies the HasPrefix predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDHasPrefix(v string) predicate.Group {
return predicate.Group(sql.FieldHasPrefix(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDHasSuffix applies the HasSuffix predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDHasSuffix(v string) predicate.Group {
return predicate.Group(sql.FieldHasSuffix(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDIsNil applies the IsNil predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldDuplicateOperationID))
}
// DuplicateOperationIDNotNil applies the NotNil predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldDuplicateOperationID))
}
// DuplicateOperationIDEqualFold applies the EqualFold predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDEqualFold(v string) predicate.Group {
return predicate.Group(sql.FieldEqualFold(FieldDuplicateOperationID, v))
}
// DuplicateOperationIDContainsFold applies the ContainsFold predicate on the "duplicate_operation_id" field.
func DuplicateOperationIDContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldDuplicateOperationID, v))
}
// PlatformEQ applies the EQ predicate on the "platform" field.
func PlatformEQ(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPlatform, v))
+29
View File
@@ -189,6 +189,20 @@ func (_c *GroupCreate) SetNillableStatus(v *string) *GroupCreate {
return _c
}
// SetDuplicateOperationID sets the "duplicate_operation_id" field.
func (_c *GroupCreate) SetDuplicateOperationID(v string) *GroupCreate {
_c.mutation.SetDuplicateOperationID(v)
return _c
}
// SetNillableDuplicateOperationID sets the "duplicate_operation_id" field if the given value is not nil.
func (_c *GroupCreate) SetNillableDuplicateOperationID(v *string) *GroupCreate {
if v != nil {
_c.SetDuplicateOperationID(*v)
}
return _c
}
// SetPlatform sets the "platform" field.
func (_c *GroupCreate) SetPlatform(v string) *GroupCreate {
_c.mutation.SetPlatform(v)
@@ -993,6 +1007,11 @@ func (_c *GroupCreate) check() error {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)}
}
}
if v, ok := _c.mutation.DuplicateOperationID(); ok {
if err := group.DuplicateOperationIDValidator(v); err != nil {
return &ValidationError{Name: "duplicate_operation_id", err: fmt.Errorf(`ent: validator failed for field "Group.duplicate_operation_id": %w`, err)}
}
}
if _, ok := _c.mutation.Platform(); !ok {
return &ValidationError{Name: "platform", err: errors.New(`ent: missing required field "Group.platform"`)}
}
@@ -1152,6 +1171,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldStatus, field.TypeString, value)
_node.Status = value
}
if value, ok := _c.mutation.DuplicateOperationID(); ok {
_spec.SetField(group.FieldDuplicateOperationID, field.TypeString, value)
_node.DuplicateOperationID = &value
}
if value, ok := _c.mutation.Platform(); ok {
_spec.SetField(group.FieldPlatform, field.TypeString, value)
_node.Platform = value
@@ -2246,6 +2269,9 @@ func (u *GroupUpsertOne) UpdateNewValues() *GroupUpsertOne {
if _, exists := u.create.mutation.CreatedAt(); exists {
s.SetIgnore(group.FieldCreatedAt)
}
if _, exists := u.create.mutation.DuplicateOperationID(); exists {
s.SetIgnore(group.FieldDuplicateOperationID)
}
}))
return u
}
@@ -3366,6 +3392,9 @@ func (u *GroupUpsertBulk) UpdateNewValues() *GroupUpsertBulk {
if _, exists := b.mutation.CreatedAt(); exists {
s.SetIgnore(group.FieldCreatedAt)
}
if _, exists := b.mutation.DuplicateOperationID(); exists {
s.SetIgnore(group.FieldDuplicateOperationID)
}
}
}))
return u
+6
View File
@@ -1273,6 +1273,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.Status(); ok {
_spec.SetField(group.FieldStatus, field.TypeString, value)
}
if _u.mutation.DuplicateOperationIDCleared() {
_spec.ClearField(group.FieldDuplicateOperationID, field.TypeString)
}
if value, ok := _u.mutation.Platform(); ok {
_spec.SetField(group.FieldPlatform, field.TypeString, value)
}
@@ -3063,6 +3066,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.Status(); ok {
_spec.SetField(group.FieldStatus, field.TypeString, value)
}
if _u.mutation.DuplicateOperationIDCleared() {
_spec.ClearField(group.FieldDuplicateOperationID, field.TypeString)
}
if value, ok := _u.mutation.Platform(); ok {
_spec.SetField(group.FieldPlatform, field.TypeString, value)
}
+14 -4
View File
@@ -845,6 +845,7 @@ var (
{Name: "peak_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "is_exclusive", Type: field.TypeBool, Default: false},
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
{Name: "duplicate_operation_id", Type: field.TypeString, Nullable: true, Size: 64},
{Name: "platform", Type: field.TypeString, Size: 50, Default: "anthropic"},
{Name: "subscription_type", Type: field.TypeString, Size: 20, Default: "standard"},
{Name: "daily_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
@@ -896,12 +897,12 @@ var (
{
Name: "group_platform",
Unique: false,
Columns: []*schema.Column{GroupsColumns[13]},
Columns: []*schema.Column{GroupsColumns[14]},
},
{
Name: "group_subscription_type",
Unique: false,
Columns: []*schema.Column{GroupsColumns[14]},
Columns: []*schema.Column{GroupsColumns[15]},
},
{
Name: "group_is_exclusive",
@@ -916,7 +917,15 @@ var (
{
Name: "group_sort_order",
Unique: false,
Columns: []*schema.Column{GroupsColumns[41]},
Columns: []*schema.Column{GroupsColumns[42]},
},
{
Name: "idx_groups_duplicate_operation_id_active",
Unique: true,
Columns: []*schema.Column{GroupsColumns[13]},
Annotation: &entsql.IndexAnnotation{
Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL",
},
},
},
}
@@ -1447,6 +1456,7 @@ var (
{Name: "description", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
{Name: "price", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
{Name: "original_price", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
{Name: "currency", Type: field.TypeString, Size: 3, Default: ""},
{Name: "validity_days", Type: field.TypeInt, Default: 30},
{Name: "validity_unit", Type: field.TypeString, Size: 10, Default: "day"},
{Name: "features", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
@@ -1470,7 +1480,7 @@ var (
{
Name: "subscriptionplan_for_sale",
Unique: false,
Columns: []*schema.Column{SubscriptionPlansColumns[10]},
Columns: []*schema.Column{SubscriptionPlansColumns[11]},
},
},
}
+129 -2
View File
@@ -20808,6 +20808,7 @@ type GroupMutation struct {
addpeak_rate_multiplier *float64
is_exclusive *bool
status *string
duplicate_operation_id *string
platform *string
subscription_type *string
daily_limit_usd *float64
@@ -21484,6 +21485,55 @@ func (m *GroupMutation) ResetStatus() {
m.status = nil
}
// SetDuplicateOperationID sets the "duplicate_operation_id" field.
func (m *GroupMutation) SetDuplicateOperationID(s string) {
m.duplicate_operation_id = &s
}
// DuplicateOperationID returns the value of the "duplicate_operation_id" field in the mutation.
func (m *GroupMutation) DuplicateOperationID() (r string, exists bool) {
v := m.duplicate_operation_id
if v == nil {
return
}
return *v, true
}
// OldDuplicateOperationID returns the old "duplicate_operation_id" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldDuplicateOperationID(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldDuplicateOperationID is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldDuplicateOperationID requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldDuplicateOperationID: %w", err)
}
return oldValue.DuplicateOperationID, nil
}
// ClearDuplicateOperationID clears the value of the "duplicate_operation_id" field.
func (m *GroupMutation) ClearDuplicateOperationID() {
m.duplicate_operation_id = nil
m.clearedFields[group.FieldDuplicateOperationID] = struct{}{}
}
// DuplicateOperationIDCleared returns if the "duplicate_operation_id" field was cleared in this mutation.
func (m *GroupMutation) DuplicateOperationIDCleared() bool {
_, ok := m.clearedFields[group.FieldDuplicateOperationID]
return ok
}
// ResetDuplicateOperationID resets all changes to the "duplicate_operation_id" field.
func (m *GroupMutation) ResetDuplicateOperationID() {
m.duplicate_operation_id = nil
delete(m.clearedFields, group.FieldDuplicateOperationID)
}
// SetPlatform sets the "platform" field.
func (m *GroupMutation) SetPlatform(s string) {
m.platform = &s
@@ -23714,7 +23764,7 @@ func (m *GroupMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *GroupMutation) Fields() []string {
fields := make([]string, 0, 48)
fields := make([]string, 0, 49)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -23751,6 +23801,9 @@ func (m *GroupMutation) Fields() []string {
if m.status != nil {
fields = append(fields, group.FieldStatus)
}
if m.duplicate_operation_id != nil {
fields = append(fields, group.FieldDuplicateOperationID)
}
if m.platform != nil {
fields = append(fields, group.FieldPlatform)
}
@@ -23891,6 +23944,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.IsExclusive()
case group.FieldStatus:
return m.Status()
case group.FieldDuplicateOperationID:
return m.DuplicateOperationID()
case group.FieldPlatform:
return m.Platform()
case group.FieldSubscriptionType:
@@ -23996,6 +24051,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldIsExclusive(ctx)
case group.FieldStatus:
return m.OldStatus(ctx)
case group.FieldDuplicateOperationID:
return m.OldDuplicateOperationID(ctx)
case group.FieldPlatform:
return m.OldPlatform(ctx)
case group.FieldSubscriptionType:
@@ -24161,6 +24218,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetStatus(v)
return nil
case group.FieldDuplicateOperationID:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetDuplicateOperationID(v)
return nil
case group.FieldPlatform:
v, ok := value.(string)
if !ok {
@@ -24704,6 +24768,9 @@ func (m *GroupMutation) ClearedFields() []string {
if m.FieldCleared(group.FieldDescription) {
fields = append(fields, group.FieldDescription)
}
if m.FieldCleared(group.FieldDuplicateOperationID) {
fields = append(fields, group.FieldDuplicateOperationID)
}
if m.FieldCleared(group.FieldDailyLimitUsd) {
fields = append(fields, group.FieldDailyLimitUsd)
}
@@ -24763,6 +24830,9 @@ func (m *GroupMutation) ClearField(name string) error {
case group.FieldDescription:
m.ClearDescription()
return nil
case group.FieldDuplicateOperationID:
m.ClearDuplicateOperationID()
return nil
case group.FieldDailyLimitUsd:
m.ClearDailyLimitUsd()
return nil
@@ -24846,6 +24916,9 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldStatus:
m.ResetStatus()
return nil
case group.FieldDuplicateOperationID:
m.ResetDuplicateOperationID()
return nil
case group.FieldPlatform:
m.ResetPlatform()
return nil
@@ -38152,6 +38225,7 @@ type SubscriptionPlanMutation struct {
addprice *float64
original_price *float64
addoriginal_price *float64
currency *string
validity_days *int
addvalidity_days *int
validity_unit *string
@@ -38520,6 +38594,42 @@ func (m *SubscriptionPlanMutation) ResetOriginalPrice() {
delete(m.clearedFields, subscriptionplan.FieldOriginalPrice)
}
// SetCurrency sets the "currency" field.
func (m *SubscriptionPlanMutation) SetCurrency(s string) {
m.currency = &s
}
// Currency returns the value of the "currency" field in the mutation.
func (m *SubscriptionPlanMutation) Currency() (r string, exists bool) {
v := m.currency
if v == nil {
return
}
return *v, true
}
// OldCurrency returns the old "currency" field's value of the SubscriptionPlan entity.
// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *SubscriptionPlanMutation) OldCurrency(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldCurrency is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldCurrency requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldCurrency: %w", err)
}
return oldValue.Currency, nil
}
// ResetCurrency resets all changes to the "currency" field.
func (m *SubscriptionPlanMutation) ResetCurrency() {
m.currency = nil
}
// SetValidityDays sets the "validity_days" field.
func (m *SubscriptionPlanMutation) SetValidityDays(i int) {
m.validity_days = &i
@@ -38882,7 +38992,7 @@ func (m *SubscriptionPlanMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *SubscriptionPlanMutation) Fields() []string {
fields := make([]string, 0, 13)
fields := make([]string, 0, 14)
if m.group_id != nil {
fields = append(fields, subscriptionplan.FieldGroupID)
}
@@ -38898,6 +39008,9 @@ func (m *SubscriptionPlanMutation) Fields() []string {
if m.original_price != nil {
fields = append(fields, subscriptionplan.FieldOriginalPrice)
}
if m.currency != nil {
fields = append(fields, subscriptionplan.FieldCurrency)
}
if m.validity_days != nil {
fields = append(fields, subscriptionplan.FieldValidityDays)
}
@@ -38940,6 +39053,8 @@ func (m *SubscriptionPlanMutation) Field(name string) (ent.Value, bool) {
return m.Price()
case subscriptionplan.FieldOriginalPrice:
return m.OriginalPrice()
case subscriptionplan.FieldCurrency:
return m.Currency()
case subscriptionplan.FieldValidityDays:
return m.ValidityDays()
case subscriptionplan.FieldValidityUnit:
@@ -38975,6 +39090,8 @@ func (m *SubscriptionPlanMutation) OldField(ctx context.Context, name string) (e
return m.OldPrice(ctx)
case subscriptionplan.FieldOriginalPrice:
return m.OldOriginalPrice(ctx)
case subscriptionplan.FieldCurrency:
return m.OldCurrency(ctx)
case subscriptionplan.FieldValidityDays:
return m.OldValidityDays(ctx)
case subscriptionplan.FieldValidityUnit:
@@ -39035,6 +39152,13 @@ func (m *SubscriptionPlanMutation) SetField(name string, value ent.Value) error
}
m.SetOriginalPrice(v)
return nil
case subscriptionplan.FieldCurrency:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetCurrency(v)
return nil
case subscriptionplan.FieldValidityDays:
v, ok := value.(int)
if !ok {
@@ -39227,6 +39351,9 @@ func (m *SubscriptionPlanMutation) ResetField(name string) error {
case subscriptionplan.FieldOriginalPrice:
m.ResetOriginalPrice()
return nil
case subscriptionplan.FieldCurrency:
m.ResetCurrency()
return nil
case subscriptionplan.FieldValidityDays:
m.ResetValidityDays()
return nil
+41 -31
View File
@@ -995,102 +995,106 @@ func init() {
group.DefaultStatus = groupDescStatus.Default.(string)
// group.StatusValidator is a validator for the "status" field. It is called by the builders before save.
group.StatusValidator = groupDescStatus.Validators[0].(func(string) error)
// groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field.
groupDescDuplicateOperationID := groupFields[9].Descriptor()
// group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save.
group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error)
// groupDescPlatform is the schema descriptor for platform field.
groupDescPlatform := groupFields[9].Descriptor()
groupDescPlatform := groupFields[10].Descriptor()
// group.DefaultPlatform holds the default value on creation for the platform field.
group.DefaultPlatform = groupDescPlatform.Default.(string)
// group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error)
// groupDescSubscriptionType is the schema descriptor for subscription_type field.
groupDescSubscriptionType := groupFields[10].Descriptor()
groupDescSubscriptionType := groupFields[11].Descriptor()
// group.DefaultSubscriptionType holds the default value on creation for the subscription_type field.
group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string)
// group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save.
group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error)
// groupDescDefaultValidityDays is the schema descriptor for default_validity_days field.
groupDescDefaultValidityDays := groupFields[14].Descriptor()
groupDescDefaultValidityDays := groupFields[15].Descriptor()
// group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field.
group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int)
// groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field.
groupDescAllowImageGeneration := groupFields[15].Descriptor()
groupDescAllowImageGeneration := groupFields[16].Descriptor()
// group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field.
group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool)
// groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field.
groupDescAllowBatchImageGeneration := groupFields[16].Descriptor()
groupDescAllowBatchImageGeneration := groupFields[17].Descriptor()
// group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field.
group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool)
// groupDescImageRateIndependent is the schema descriptor for image_rate_independent field.
groupDescImageRateIndependent := groupFields[17].Descriptor()
groupDescImageRateIndependent := groupFields[18].Descriptor()
// group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field.
group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool)
// groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field.
groupDescImageRateMultiplier := groupFields[18].Descriptor()
groupDescImageRateMultiplier := groupFields[19].Descriptor()
// group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field.
group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64)
// groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field.
groupDescBatchImageDiscountMultiplier := groupFields[22].Descriptor()
groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor()
// group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field.
group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64)
// groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field.
groupDescBatchImageHoldMultiplier := groupFields[23].Descriptor()
groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor()
// group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field.
group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64)
// groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field.
groupDescVideoRateIndependent := groupFields[24].Descriptor()
groupDescVideoRateIndependent := groupFields[25].Descriptor()
// group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field.
group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool)
// groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field.
groupDescVideoRateMultiplier := groupFields[25].Descriptor()
groupDescVideoRateMultiplier := groupFields[26].Descriptor()
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
groupDescClaudeCodeOnly := groupFields[30].Descriptor()
groupDescClaudeCodeOnly := groupFields[31].Descriptor()
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
groupDescModelRoutingEnabled := groupFields[34].Descriptor()
groupDescModelRoutingEnabled := groupFields[35].Descriptor()
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
groupDescMcpXMLInject := groupFields[35].Descriptor()
groupDescMcpXMLInject := groupFields[36].Descriptor()
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
groupDescSupportedModelScopes := groupFields[36].Descriptor()
groupDescSupportedModelScopes := groupFields[37].Descriptor()
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
// groupDescSortOrder is the schema descriptor for sort_order field.
groupDescSortOrder := groupFields[37].Descriptor()
groupDescSortOrder := groupFields[38].Descriptor()
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
groupDescAllowMessagesDispatch := groupFields[38].Descriptor()
groupDescAllowMessagesDispatch := groupFields[39].Descriptor()
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[39].Descriptor()
groupDescRequireOauthOnly := groupFields[40].Descriptor()
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
groupDescRequirePrivacySet := groupFields[40].Descriptor()
groupDescRequirePrivacySet := groupFields[41].Descriptor()
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
groupDescDefaultMappedModel := groupFields[41].Descriptor()
groupDescDefaultMappedModel := groupFields[42].Descriptor()
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
groupDescMessagesDispatchModelConfig := groupFields[42].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[43].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
groupDescModelsListConfig := groupFields[43].Descriptor()
groupDescModelsListConfig := groupFields[44].Descriptor()
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
groupDescRpmLimit := groupFields[44].Descriptor()
groupDescRpmLimit := groupFields[45].Descriptor()
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
@@ -1718,40 +1722,46 @@ func init() {
subscriptionplanDescDescription := subscriptionplanFields[2].Descriptor()
// subscriptionplan.DefaultDescription holds the default value on creation for the description field.
subscriptionplan.DefaultDescription = subscriptionplanDescDescription.Default.(string)
// subscriptionplanDescCurrency is the schema descriptor for currency field.
subscriptionplanDescCurrency := subscriptionplanFields[5].Descriptor()
// subscriptionplan.DefaultCurrency holds the default value on creation for the currency field.
subscriptionplan.DefaultCurrency = subscriptionplanDescCurrency.Default.(string)
// subscriptionplan.CurrencyValidator is a validator for the "currency" field. It is called by the builders before save.
subscriptionplan.CurrencyValidator = subscriptionplanDescCurrency.Validators[0].(func(string) error)
// subscriptionplanDescValidityDays is the schema descriptor for validity_days field.
subscriptionplanDescValidityDays := subscriptionplanFields[5].Descriptor()
subscriptionplanDescValidityDays := subscriptionplanFields[6].Descriptor()
// subscriptionplan.DefaultValidityDays holds the default value on creation for the validity_days field.
subscriptionplan.DefaultValidityDays = subscriptionplanDescValidityDays.Default.(int)
// subscriptionplanDescValidityUnit is the schema descriptor for validity_unit field.
subscriptionplanDescValidityUnit := subscriptionplanFields[6].Descriptor()
subscriptionplanDescValidityUnit := subscriptionplanFields[7].Descriptor()
// subscriptionplan.DefaultValidityUnit holds the default value on creation for the validity_unit field.
subscriptionplan.DefaultValidityUnit = subscriptionplanDescValidityUnit.Default.(string)
// subscriptionplan.ValidityUnitValidator is a validator for the "validity_unit" field. It is called by the builders before save.
subscriptionplan.ValidityUnitValidator = subscriptionplanDescValidityUnit.Validators[0].(func(string) error)
// subscriptionplanDescFeatures is the schema descriptor for features field.
subscriptionplanDescFeatures := subscriptionplanFields[7].Descriptor()
subscriptionplanDescFeatures := subscriptionplanFields[8].Descriptor()
// subscriptionplan.DefaultFeatures holds the default value on creation for the features field.
subscriptionplan.DefaultFeatures = subscriptionplanDescFeatures.Default.(string)
// subscriptionplanDescProductName is the schema descriptor for product_name field.
subscriptionplanDescProductName := subscriptionplanFields[8].Descriptor()
subscriptionplanDescProductName := subscriptionplanFields[9].Descriptor()
// subscriptionplan.DefaultProductName holds the default value on creation for the product_name field.
subscriptionplan.DefaultProductName = subscriptionplanDescProductName.Default.(string)
// subscriptionplan.ProductNameValidator is a validator for the "product_name" field. It is called by the builders before save.
subscriptionplan.ProductNameValidator = subscriptionplanDescProductName.Validators[0].(func(string) error)
// subscriptionplanDescForSale is the schema descriptor for for_sale field.
subscriptionplanDescForSale := subscriptionplanFields[9].Descriptor()
subscriptionplanDescForSale := subscriptionplanFields[10].Descriptor()
// subscriptionplan.DefaultForSale holds the default value on creation for the for_sale field.
subscriptionplan.DefaultForSale = subscriptionplanDescForSale.Default.(bool)
// subscriptionplanDescSortOrder is the schema descriptor for sort_order field.
subscriptionplanDescSortOrder := subscriptionplanFields[10].Descriptor()
subscriptionplanDescSortOrder := subscriptionplanFields[11].Descriptor()
// subscriptionplan.DefaultSortOrder holds the default value on creation for the sort_order field.
subscriptionplan.DefaultSortOrder = subscriptionplanDescSortOrder.Default.(int)
// subscriptionplanDescCreatedAt is the schema descriptor for created_at field.
subscriptionplanDescCreatedAt := subscriptionplanFields[11].Descriptor()
subscriptionplanDescCreatedAt := subscriptionplanFields[12].Descriptor()
// subscriptionplan.DefaultCreatedAt holds the default value on creation for the created_at field.
subscriptionplan.DefaultCreatedAt = subscriptionplanDescCreatedAt.Default.(func() time.Time)
// subscriptionplanDescUpdatedAt is the schema descriptor for updated_at field.
subscriptionplanDescUpdatedAt := subscriptionplanFields[12].Descriptor()
subscriptionplanDescUpdatedAt := subscriptionplanFields[13].Descriptor()
// subscriptionplan.DefaultUpdatedAt holds the default value on creation for the updated_at field.
subscriptionplan.DefaultUpdatedAt = subscriptionplanDescUpdatedAt.Default.(func() time.Time)
// subscriptionplan.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
+10
View File
@@ -66,6 +66,12 @@ func (Group) Fields() []ent.Field {
field.String("status").
MaxLen(20).
Default(domain.StatusActive),
field.String("duplicate_operation_id").
MaxLen(64).
Optional().
Nillable().
Immutable().
Comment("内部幂等恢复标识,不对 API 暴露"),
// Subscription-related fields (added by migration 003)
field.String("platform").
@@ -244,5 +250,9 @@ func (Group) Indexes() []ent.Index {
index.Fields("is_exclusive"),
index.Fields("deleted_at"),
index.Fields("sort_order"),
index.Fields("duplicate_operation_id").
Unique().
StorageKey("idx_groups_duplicate_operation_id_active").
Annotations(entsql.IndexWhere("duplicate_operation_id IS NOT NULL AND deleted_at IS NULL")),
}
}
+3
View File
@@ -43,6 +43,9 @@ func (SubscriptionPlan) Fields() []ent.Field {
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}).
Optional().
Nillable(),
field.String("currency").
MaxLen(3).
Default(""),
field.Int("validity_days").
Default(30),
field.String("validity_unit").
+12 -1
View File
@@ -27,6 +27,8 @@ type SubscriptionPlan struct {
Price float64 `json:"price,omitempty"`
// OriginalPrice holds the value of the "original_price" field.
OriginalPrice *float64 `json:"original_price,omitempty"`
// Currency holds the value of the "currency" field.
Currency string `json:"currency,omitempty"`
// ValidityDays holds the value of the "validity_days" field.
ValidityDays int `json:"validity_days,omitempty"`
// ValidityUnit holds the value of the "validity_unit" field.
@@ -57,7 +59,7 @@ func (*SubscriptionPlan) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullFloat64)
case subscriptionplan.FieldID, subscriptionplan.FieldGroupID, subscriptionplan.FieldValidityDays, subscriptionplan.FieldSortOrder:
values[i] = new(sql.NullInt64)
case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName:
case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldCurrency, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName:
values[i] = new(sql.NullString)
case subscriptionplan.FieldCreatedAt, subscriptionplan.FieldUpdatedAt:
values[i] = new(sql.NullTime)
@@ -113,6 +115,12 @@ func (_m *SubscriptionPlan) assignValues(columns []string, values []any) error {
_m.OriginalPrice = new(float64)
*_m.OriginalPrice = value.Float64
}
case subscriptionplan.FieldCurrency:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field currency", values[i])
} else if value.Valid {
_m.Currency = value.String
}
case subscriptionplan.FieldValidityDays:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field validity_days", values[i])
@@ -214,6 +222,9 @@ func (_m *SubscriptionPlan) String() string {
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("currency=")
builder.WriteString(_m.Currency)
builder.WriteString(", ")
builder.WriteString("validity_days=")
builder.WriteString(fmt.Sprintf("%v", _m.ValidityDays))
builder.WriteString(", ")
@@ -23,6 +23,8 @@ const (
FieldPrice = "price"
// FieldOriginalPrice holds the string denoting the original_price field in the database.
FieldOriginalPrice = "original_price"
// FieldCurrency holds the string denoting the currency field in the database.
FieldCurrency = "currency"
// FieldValidityDays holds the string denoting the validity_days field in the database.
FieldValidityDays = "validity_days"
// FieldValidityUnit holds the string denoting the validity_unit field in the database.
@@ -51,6 +53,7 @@ var Columns = []string{
FieldDescription,
FieldPrice,
FieldOriginalPrice,
FieldCurrency,
FieldValidityDays,
FieldValidityUnit,
FieldFeatures,
@@ -76,6 +79,10 @@ var (
NameValidator func(string) error
// DefaultDescription holds the default value on creation for the "description" field.
DefaultDescription string
// DefaultCurrency holds the default value on creation for the "currency" field.
DefaultCurrency string
// CurrencyValidator is a validator for the "currency" field. It is called by the builders before save.
CurrencyValidator func(string) error
// DefaultValidityDays holds the default value on creation for the "validity_days" field.
DefaultValidityDays int
// DefaultValidityUnit holds the default value on creation for the "validity_unit" field.
@@ -133,6 +140,11 @@ func ByOriginalPrice(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldOriginalPrice, opts...).ToFunc()
}
// ByCurrency orders the results by the currency field.
func ByCurrency(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCurrency, opts...).ToFunc()
}
// ByValidityDays orders the results by the validity_days field.
func ByValidityDays(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldValidityDays, opts...).ToFunc()
+70
View File
@@ -79,6 +79,11 @@ func OriginalPrice(v float64) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEQ(FieldOriginalPrice, v))
}
// Currency applies equality check predicate on the "currency" field. It's identical to CurrencyEQ.
func Currency(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEQ(FieldCurrency, v))
}
// ValidityDays applies equality check predicate on the "validity_days" field. It's identical to ValidityDaysEQ.
func ValidityDays(v int) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEQ(FieldValidityDays, v))
@@ -379,6 +384,71 @@ func OriginalPriceNotNil() predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldNotNull(FieldOriginalPrice))
}
// CurrencyEQ applies the EQ predicate on the "currency" field.
func CurrencyEQ(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEQ(FieldCurrency, v))
}
// CurrencyNEQ applies the NEQ predicate on the "currency" field.
func CurrencyNEQ(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldNEQ(FieldCurrency, v))
}
// CurrencyIn applies the In predicate on the "currency" field.
func CurrencyIn(vs ...string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldIn(FieldCurrency, vs...))
}
// CurrencyNotIn applies the NotIn predicate on the "currency" field.
func CurrencyNotIn(vs ...string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldNotIn(FieldCurrency, vs...))
}
// CurrencyGT applies the GT predicate on the "currency" field.
func CurrencyGT(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldGT(FieldCurrency, v))
}
// CurrencyGTE applies the GTE predicate on the "currency" field.
func CurrencyGTE(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldGTE(FieldCurrency, v))
}
// CurrencyLT applies the LT predicate on the "currency" field.
func CurrencyLT(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldLT(FieldCurrency, v))
}
// CurrencyLTE applies the LTE predicate on the "currency" field.
func CurrencyLTE(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldLTE(FieldCurrency, v))
}
// CurrencyContains applies the Contains predicate on the "currency" field.
func CurrencyContains(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldContains(FieldCurrency, v))
}
// CurrencyHasPrefix applies the HasPrefix predicate on the "currency" field.
func CurrencyHasPrefix(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldHasPrefix(FieldCurrency, v))
}
// CurrencyHasSuffix applies the HasSuffix predicate on the "currency" field.
func CurrencyHasSuffix(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldHasSuffix(FieldCurrency, v))
}
// CurrencyEqualFold applies the EqualFold predicate on the "currency" field.
func CurrencyEqualFold(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEqualFold(FieldCurrency, v))
}
// CurrencyContainsFold applies the ContainsFold predicate on the "currency" field.
func CurrencyContainsFold(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldContainsFold(FieldCurrency, v))
}
// ValidityDaysEQ applies the EQ predicate on the "validity_days" field.
func ValidityDaysEQ(v int) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEQ(FieldValidityDays, v))
+70
View File
@@ -68,6 +68,20 @@ func (_c *SubscriptionPlanCreate) SetNillableOriginalPrice(v *float64) *Subscrip
return _c
}
// SetCurrency sets the "currency" field.
func (_c *SubscriptionPlanCreate) SetCurrency(v string) *SubscriptionPlanCreate {
_c.mutation.SetCurrency(v)
return _c
}
// SetNillableCurrency sets the "currency" field if the given value is not nil.
func (_c *SubscriptionPlanCreate) SetNillableCurrency(v *string) *SubscriptionPlanCreate {
if v != nil {
_c.SetCurrency(*v)
}
return _c
}
// SetValidityDays sets the "validity_days" field.
func (_c *SubscriptionPlanCreate) SetValidityDays(v int) *SubscriptionPlanCreate {
_c.mutation.SetValidityDays(v)
@@ -219,6 +233,10 @@ func (_c *SubscriptionPlanCreate) defaults() {
v := subscriptionplan.DefaultDescription
_c.mutation.SetDescription(v)
}
if _, ok := _c.mutation.Currency(); !ok {
v := subscriptionplan.DefaultCurrency
_c.mutation.SetCurrency(v)
}
if _, ok := _c.mutation.ValidityDays(); !ok {
v := subscriptionplan.DefaultValidityDays
_c.mutation.SetValidityDays(v)
@@ -272,6 +290,14 @@ func (_c *SubscriptionPlanCreate) check() error {
if _, ok := _c.mutation.Price(); !ok {
return &ValidationError{Name: "price", err: errors.New(`ent: missing required field "SubscriptionPlan.price"`)}
}
if _, ok := _c.mutation.Currency(); !ok {
return &ValidationError{Name: "currency", err: errors.New(`ent: missing required field "SubscriptionPlan.currency"`)}
}
if v, ok := _c.mutation.Currency(); ok {
if err := subscriptionplan.CurrencyValidator(v); err != nil {
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
}
}
if _, ok := _c.mutation.ValidityDays(); !ok {
return &ValidationError{Name: "validity_days", err: errors.New(`ent: missing required field "SubscriptionPlan.validity_days"`)}
}
@@ -353,6 +379,10 @@ func (_c *SubscriptionPlanCreate) createSpec() (*SubscriptionPlan, *sqlgraph.Cre
_spec.SetField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64, value)
_node.OriginalPrice = &value
}
if value, ok := _c.mutation.Currency(); ok {
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
_node.Currency = value
}
if value, ok := _c.mutation.ValidityDays(); ok {
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
_node.ValidityDays = value
@@ -521,6 +551,18 @@ func (u *SubscriptionPlanUpsert) ClearOriginalPrice() *SubscriptionPlanUpsert {
return u
}
// SetCurrency sets the "currency" field.
func (u *SubscriptionPlanUpsert) SetCurrency(v string) *SubscriptionPlanUpsert {
u.Set(subscriptionplan.FieldCurrency, v)
return u
}
// UpdateCurrency sets the "currency" field to the value that was provided on create.
func (u *SubscriptionPlanUpsert) UpdateCurrency() *SubscriptionPlanUpsert {
u.SetExcluded(subscriptionplan.FieldCurrency)
return u
}
// SetValidityDays sets the "validity_days" field.
func (u *SubscriptionPlanUpsert) SetValidityDays(v int) *SubscriptionPlanUpsert {
u.Set(subscriptionplan.FieldValidityDays, v)
@@ -760,6 +802,20 @@ func (u *SubscriptionPlanUpsertOne) ClearOriginalPrice() *SubscriptionPlanUpsert
})
}
// SetCurrency sets the "currency" field.
func (u *SubscriptionPlanUpsertOne) SetCurrency(v string) *SubscriptionPlanUpsertOne {
return u.Update(func(s *SubscriptionPlanUpsert) {
s.SetCurrency(v)
})
}
// UpdateCurrency sets the "currency" field to the value that was provided on create.
func (u *SubscriptionPlanUpsertOne) UpdateCurrency() *SubscriptionPlanUpsertOne {
return u.Update(func(s *SubscriptionPlanUpsert) {
s.UpdateCurrency()
})
}
// SetValidityDays sets the "validity_days" field.
func (u *SubscriptionPlanUpsertOne) SetValidityDays(v int) *SubscriptionPlanUpsertOne {
return u.Update(func(s *SubscriptionPlanUpsert) {
@@ -1181,6 +1237,20 @@ func (u *SubscriptionPlanUpsertBulk) ClearOriginalPrice() *SubscriptionPlanUpser
})
}
// SetCurrency sets the "currency" field.
func (u *SubscriptionPlanUpsertBulk) SetCurrency(v string) *SubscriptionPlanUpsertBulk {
return u.Update(func(s *SubscriptionPlanUpsert) {
s.SetCurrency(v)
})
}
// UpdateCurrency sets the "currency" field to the value that was provided on create.
func (u *SubscriptionPlanUpsertBulk) UpdateCurrency() *SubscriptionPlanUpsertBulk {
return u.Update(func(s *SubscriptionPlanUpsert) {
s.UpdateCurrency()
})
}
// SetValidityDays sets the "validity_days" field.
func (u *SubscriptionPlanUpsertBulk) SetValidityDays(v int) *SubscriptionPlanUpsertBulk {
return u.Update(func(s *SubscriptionPlanUpsert) {
+44
View File
@@ -125,6 +125,20 @@ func (_u *SubscriptionPlanUpdate) ClearOriginalPrice() *SubscriptionPlanUpdate {
return _u
}
// SetCurrency sets the "currency" field.
func (_u *SubscriptionPlanUpdate) SetCurrency(v string) *SubscriptionPlanUpdate {
_u.mutation.SetCurrency(v)
return _u
}
// SetNillableCurrency sets the "currency" field if the given value is not nil.
func (_u *SubscriptionPlanUpdate) SetNillableCurrency(v *string) *SubscriptionPlanUpdate {
if v != nil {
_u.SetCurrency(*v)
}
return _u
}
// SetValidityDays sets the "validity_days" field.
func (_u *SubscriptionPlanUpdate) SetValidityDays(v int) *SubscriptionPlanUpdate {
_u.mutation.ResetValidityDays()
@@ -277,6 +291,11 @@ func (_u *SubscriptionPlanUpdate) check() error {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)}
}
}
if v, ok := _u.mutation.Currency(); ok {
if err := subscriptionplan.CurrencyValidator(v); err != nil {
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
}
}
if v, ok := _u.mutation.ValidityUnit(); ok {
if err := subscriptionplan.ValidityUnitValidator(v); err != nil {
return &ValidationError{Name: "validity_unit", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.validity_unit": %w`, err)}
@@ -329,6 +348,9 @@ func (_u *SubscriptionPlanUpdate) sqlSave(ctx context.Context) (_node int, err e
if _u.mutation.OriginalPriceCleared() {
_spec.ClearField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64)
}
if value, ok := _u.mutation.Currency(); ok {
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
}
if value, ok := _u.mutation.ValidityDays(); ok {
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
}
@@ -473,6 +495,20 @@ func (_u *SubscriptionPlanUpdateOne) ClearOriginalPrice() *SubscriptionPlanUpdat
return _u
}
// SetCurrency sets the "currency" field.
func (_u *SubscriptionPlanUpdateOne) SetCurrency(v string) *SubscriptionPlanUpdateOne {
_u.mutation.SetCurrency(v)
return _u
}
// SetNillableCurrency sets the "currency" field if the given value is not nil.
func (_u *SubscriptionPlanUpdateOne) SetNillableCurrency(v *string) *SubscriptionPlanUpdateOne {
if v != nil {
_u.SetCurrency(*v)
}
return _u
}
// SetValidityDays sets the "validity_days" field.
func (_u *SubscriptionPlanUpdateOne) SetValidityDays(v int) *SubscriptionPlanUpdateOne {
_u.mutation.ResetValidityDays()
@@ -638,6 +674,11 @@ func (_u *SubscriptionPlanUpdateOne) check() error {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)}
}
}
if v, ok := _u.mutation.Currency(); ok {
if err := subscriptionplan.CurrencyValidator(v); err != nil {
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
}
}
if v, ok := _u.mutation.ValidityUnit(); ok {
if err := subscriptionplan.ValidityUnitValidator(v); err != nil {
return &ValidationError{Name: "validity_unit", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.validity_unit": %w`, err)}
@@ -707,6 +748,9 @@ func (_u *SubscriptionPlanUpdateOne) sqlSave(ctx context.Context) (_node *Subscr
if _u.mutation.OriginalPriceCleared() {
_spec.ClearField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64)
}
if value, ok := _u.mutation.Currency(); ok {
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
}
if value, ok := _u.mutation.ValidityDays(); ok {
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
}
+2
View File
@@ -166,6 +166,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
+384 -53
View File
@@ -6,6 +6,8 @@ import (
"encoding/hex"
"fmt"
"log/slog"
"math"
"net/textproto"
"net/url"
"os"
"strings"
@@ -13,6 +15,7 @@ import (
"time"
"github.com/spf13/viper"
"golang.org/x/net/http/httpguts"
)
const (
@@ -94,6 +97,7 @@ type Config struct {
Update UpdateConfig `mapstructure:"update"`
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
BatchImage BatchImageConfig `mapstructure:"batch_image"`
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
}
type LogConfig struct {
@@ -226,6 +230,49 @@ type BatchImageConfig struct {
VertexGCSBaseURL string `mapstructure:"vertex_gcs_base_url"`
}
// ImageStorageConfig 配置异步图片任务结果上传的 S3 兼容对象存储。
// Enabled 同时作为异步图片任务功能的总开关:未启用或未配置完整凭证时,
// 异步生图接口整体禁用,避免把上游返回的大 base64 结果塞进 Redis。
type ImageStorageConfig struct {
Enabled bool `mapstructure:"enabled"`
Endpoint string `mapstructure:"endpoint"` // e.g. https://<account_id>.r2.cloudflarestorage.com
Region string `mapstructure:"region"` // R2 用 "auto"
Bucket string `mapstructure:"bucket"`
AccessKeyID string `mapstructure:"access_key_id"`
SecretAccessKey string `mapstructure:"secret_access_key"`
Prefix string `mapstructure:"prefix"` // S3 key 前缀,如 "images/"
ForcePathStyle bool `mapstructure:"force_path_style"` // MinIO/路径风格桶
PublicBaseURL string `mapstructure:"public_base_url"` // 配了则返回 public_base_url/key 直链;否则 presigned
PresignExpiry int `mapstructure:"presign_expiry_hours"` // public_base_url 为空时的 presigned 过期时长(小时)
MaxDownloadByte int64 `mapstructure:"max_download_bytes"` // 下载上游 url 图片的字节上限
}
// IsConfigured 检查对象存储必要字段是否已配置
func (c *ImageStorageConfig) IsConfigured() bool {
return c.Bucket != "" && c.AccessKeyID != "" && c.SecretAccessKey != ""
}
// Active 返回异步图片任务是否可用:开关打开且凭证齐全
func (c *ImageStorageConfig) Active() bool {
return c.Enabled && c.IsConfigured()
}
// MissingCredentialKeys 返回 IsConfigured 所缺的配置键名。
// 用于启动日志:只说"凭证不完整"会让运维以为自己漏填了,而实际可能是值填了却没被读到。
func (c *ImageStorageConfig) MissingCredentialKeys() []string {
var missing []string
if c.Bucket == "" {
missing = append(missing, "image_storage.bucket")
}
if c.AccessKeyID == "" {
missing = append(missing, "image_storage.access_key_id")
}
if c.SecretAccessKey == "" {
missing = append(missing, "image_storage.secret_access_key")
}
return missing
}
type LinuxDoConnectConfig struct {
Enabled bool `mapstructure:"enabled"`
ClientID string `mapstructure:"client_id"`
@@ -610,16 +657,18 @@ type PricingConfig struct {
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug/release
EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header
FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP)
MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制
H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug/release
EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header
FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 请求头最大字节数(HTTP/2 映射为 header-list 上限)
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP)
TrustedProxiesConfigured bool `mapstructure:"-" json:"-" yaml:"-"` // 是否显式配置了可信代理列表
MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制
H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置
}
// H2CConfig HTTP/2 Cleartext 配置
@@ -637,36 +686,103 @@ type CORSConfig struct {
AllowCredentials bool `mapstructure:"allow_credentials"`
}
const MaxForwardedClientIPHeaders = 16
type ForwardedClientIPSettings struct {
TrustForwardedIP bool
Headers []string
}
type SecurityConfig struct {
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
CSP CSPConfig `mapstructure:"csp"`
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"`
trustForwardedIPForAPIKeyACLLive *atomic.Bool `mapstructure:"-"`
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
CSP CSPConfig `mapstructure:"csp"`
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
// TrustForwardedIPForAPIKeyACL enables legacy raw forwarded-header takeover.
// When disabled, server.trusted_proxies is authoritative for all client-IP consumers.
TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"`
ForwardedClientIPHeaders []string `mapstructure:"forwarded_client_ip_headers" json:"forwarded_client_ip_headers" yaml:"forwarded_client_ip_headers"`
forwardedClientIPSettingsLive *atomic.Pointer[ForwardedClientIPSettings] `mapstructure:"-" json:"-" yaml:"-"`
}
func NormalizeForwardedClientIPHeaders(headers []string) ([]string, error) {
normalized := make([]string, 0, len(headers))
seen := make(map[string]struct{}, len(headers))
for _, header := range headers {
header = strings.TrimSpace(header)
if !httpguts.ValidHeaderFieldName(header) {
return nil, fmt.Errorf("invalid HTTP header field name %q", header)
}
canonical := textproto.CanonicalMIMEHeaderKey(header)
key := strings.ToLower(canonical)
if _, exists := seen[key]; exists {
continue
}
if len(normalized) == MaxForwardedClientIPHeaders {
return nil, fmt.Errorf("forwarded client IP headers must contain at most %d unique names", MaxForwardedClientIPHeaders)
}
seen[key] = struct{}{}
normalized = append(normalized, canonical)
}
return normalized, nil
}
func cloneForwardedClientIPHeaders(headers []string) []string {
if len(headers) == 0 {
return []string{}
}
return append([]string(nil), headers...)
}
func (c *Config) ForwardedClientIPSettings() ForwardedClientIPSettings {
if c == nil {
return ForwardedClientIPSettings{Headers: []string{}}
}
live := c.Security.forwardedClientIPSettingsLive
if live != nil {
if snapshot := live.Load(); snapshot != nil {
return ForwardedClientIPSettings{
TrustForwardedIP: snapshot.TrustForwardedIP,
Headers: cloneForwardedClientIPHeaders(snapshot.Headers),
}
}
}
return ForwardedClientIPSettings{
TrustForwardedIP: c.Security.TrustForwardedIPForAPIKeyACL,
Headers: cloneForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders),
}
}
func (c *Config) TrustForwardedIPForAPIKeyACL() bool {
return c.ForwardedClientIPSettings().TrustForwardedIP
}
// ForwardedClientIPTrustEnabled reports whether the legacy forwarded-header
// compatibility mode currently overrides server.trusted_proxies.
func (c *Config) ForwardedClientIPTrustEnabled() bool {
return c != nil && c.TrustForwardedIPForAPIKeyACL()
}
func (c *Config) SetForwardedClientIPSettings(enabled bool, headers []string) {
if c == nil {
return false
return
}
live := c.Security.trustForwardedIPForAPIKeyACLLive
if live == nil {
return c.Security.TrustForwardedIPForAPIKeyACL
headers = cloneForwardedClientIPHeaders(headers)
if c.Security.forwardedClientIPSettingsLive == nil {
c.Security.forwardedClientIPSettingsLive = &atomic.Pointer[ForwardedClientIPSettings]{}
}
return live.Load()
c.Security.forwardedClientIPSettingsLive.Store(&ForwardedClientIPSettings{
TrustForwardedIP: enabled,
Headers: headers,
})
}
func (c *Config) SetTrustForwardedIPForAPIKeyACL(enabled bool) {
if c == nil {
return
}
c.Security.TrustForwardedIPForAPIKeyACL = enabled
if c.Security.trustForwardedIPForAPIKeyACLLive == nil {
c.Security.trustForwardedIPForAPIKeyACLLive = &atomic.Bool{}
}
c.Security.trustForwardedIPForAPIKeyACLLive.Store(enabled)
c.SetForwardedClientIPSettings(enabled, c.ForwardedClientIPSettings().Headers)
}
type URLAllowlistConfig struct {
@@ -767,6 +883,8 @@ type GatewayConfig struct {
OpenAIHighEffortFirstOutputTimeoutSeconds int `mapstructure:"openai_high_effort_first_output_timeout_seconds"`
// 请求体最大字节数,用于网关请求体大小限制
MaxBodySize int64 `mapstructure:"max_body_size"`
// TextMaxBodySize limits endpoints that cannot carry inline image/video payloads.
TextMaxBodySize int64 `mapstructure:"text_max_body_size"`
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"`
// 代理探测响应体读取上限(字节)
@@ -1052,11 +1170,35 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
Reset float64 `mapstructure:"reset"`
// QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。
QuotaHeadroom float64 `mapstructure:"quota_headroom"`
// UpstreamCost 倾向上游声明倍率更低的账号;默认 0(关闭,不改变原有行为)。
UpstreamCost float64 `mapstructure:"upstream_cost"`
// PreviousResponse/SessionSticky 仅在开启 OpenAI 高级调度的粘性加权时生效。
PreviousResponse float64 `mapstructure:"previous_response"`
SessionSticky float64 `mapstructure:"session_sticky"`
}
func (w GatewayOpenAIWSSchedulerScoreWeights) BaseWeightSum() float64 {
return w.Priority + w.Load + w.Queue + w.ErrorRate + w.TTFT + w.Reset + w.QuotaHeadroom + w.UpstreamCost
}
func (w GatewayOpenAIWSSchedulerScoreWeights) TotalWeightSum() float64 {
return w.BaseWeightSum() + w.PreviousResponse + w.SessionSticky
}
func (w GatewayOpenAIWSSchedulerScoreWeights) IsValid() bool {
for _, weight := range []float64{
w.Priority, w.Load, w.Queue, w.ErrorRate, w.TTFT, w.Reset,
w.QuotaHeadroom, w.UpstreamCost, w.PreviousResponse, w.SessionSticky,
} {
if weight < 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
return false
}
}
baseSum := w.BaseWeightSum()
return baseSum > 0 && !math.IsNaN(baseSum) && !math.IsInf(baseSum, 0) &&
!math.IsNaN(w.TotalWeightSum()) && !math.IsInf(w.TotalWeightSum(), 0)
}
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
type GatewayOpenAISchedulerConfig struct {
// StickyEscapeEnabled: 是否允许 session_hash sticky 在账号健康度劣化时临时逃逸
@@ -1366,12 +1508,22 @@ type RateLimitConfig struct {
// APIKeyAuthCacheConfig API Key 认证缓存配置
type APIKeyAuthCacheConfig struct {
L1Size int `mapstructure:"l1_size"`
L1TTLSeconds int `mapstructure:"l1_ttl_seconds"`
L2TTLSeconds int `mapstructure:"l2_ttl_seconds"`
NegativeTTLSeconds int `mapstructure:"negative_ttl_seconds"`
JitterPercent int `mapstructure:"jitter_percent"`
Singleflight bool `mapstructure:"singleflight"`
L1Size int `mapstructure:"l1_size"`
L1TTLSeconds int `mapstructure:"l1_ttl_seconds"`
L2TTLSeconds int `mapstructure:"l2_ttl_seconds"`
NegativeTTLSeconds int `mapstructure:"negative_ttl_seconds"`
JitterPercent int `mapstructure:"jitter_percent"`
Singleflight bool `mapstructure:"singleflight"`
LookupConcurrency int `mapstructure:"lookup_concurrency"`
InvalidAbuse InvalidAuthAbuseConfig `mapstructure:"invalid_abuse"`
}
type InvalidAuthAbuseConfig struct {
Enabled bool `mapstructure:"enabled"`
Threshold int `mapstructure:"threshold"`
WindowSeconds int `mapstructure:"window_seconds"`
BlockSeconds int `mapstructure:"block_seconds"`
Capacity int `mapstructure:"capacity"`
}
// SubscriptionCacheConfig 订阅认证 L1 缓存配置
@@ -1498,17 +1650,31 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
}
// 配置文件不存在时使用默认值
}
trustedProxiesEnv, trustedProxiesEnvConfigured := os.LookupEnv("SERVER_TRUSTED_PROXIES")
forwardedClientIPHeadersEnv, forwardedClientIPHeadersEnvConfigured := os.LookupEnv("SECURITY_FORWARDED_CLIENT_IP_HEADERS")
trustedProxiesConfigured := viper.InConfig("server.trusted_proxies") ||
viper.IsSet("server.trusted_proxies") || trustedProxiesEnvConfigured
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config error: %w", err)
}
if trustedProxiesEnvConfigured {
cfg.Server.TrustedProxies = normalizeStringSlice(strings.Split(trustedProxiesEnv, ","))
}
if forwardedClientIPHeadersEnvConfigured {
cfg.Security.ForwardedClientIPHeaders = normalizeStringSlice(strings.Split(forwardedClientIPHeadersEnv, ","))
}
cfg.Server.TrustedProxiesConfigured = trustedProxiesConfigured
if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 {
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
}
if cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate == 0 {
cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5
}
// Kept as a backstop: setEnvReachableDefaults now registers this key with its
// effective default (true), so IsSet always reports true and this branch no
// longer fires. It still guards the default if that registration is dropped.
if !cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled && !viper.IsSet("gateway.openai_scheduler.sticky_escape_enabled") {
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
}
@@ -1558,7 +1724,12 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy)
cfg.SetTrustForwardedIPForAPIKeyACL(cfg.Security.TrustForwardedIPForAPIKeyACL)
forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(cfg.Security.ForwardedClientIPHeaders)
if err != nil {
return nil, fmt.Errorf("security.forwarded_client_ip_headers: %w", err)
}
cfg.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders
cfg.SetForwardedClientIPSettings(cfg.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders)
cfg.Log.Level = strings.ToLower(strings.TrimSpace(cfg.Log.Level))
cfg.Log.Format = strings.ToLower(strings.TrimSpace(cfg.Log.Format))
cfg.Log.ServiceName = strings.TrimSpace(cfg.Log.ServiceName)
@@ -1645,9 +1816,9 @@ func setDefaults() {
viper.SetDefault("server.mode", "release")
viper.SetDefault("server.enable_server_timing", false)
viper.SetDefault("server.frontend_url", "")
viper.SetDefault("server.read_header_timeout", 30) // 30秒读取请求头
viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时
viper.SetDefault("server.trusted_proxies", []string{})
viper.SetDefault("server.read_header_timeout", 10) // 10秒读取请求头
viper.SetDefault("server.max_header_bytes", 64*1024)
viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时
viper.SetDefault("server.max_request_body_size", int64(256*1024*1024))
// H2C 默认配置
viper.SetDefault("server.h2c.enabled", false)
@@ -1704,7 +1875,7 @@ func setDefaults() {
viper.SetDefault("security.csp.enabled", true)
viper.SetDefault("security.csp.policy", DefaultCSPPolicy)
viper.SetDefault("security.proxy_probe.insecure_skip_verify", false)
viper.SetDefault("security.trust_forwarded_ip_for_api_key_acl", false)
viper.SetDefault("security.trust_forwarded_ip_for_api_key_acl", true)
// Security - disable direct fallback on proxy error
viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false)
@@ -1866,6 +2037,23 @@ func setDefaults() {
viper.SetDefault("batch_image.vertex_batch_prediction_base_url", "")
viper.SetDefault("batch_image.vertex_gcs_base_url", "")
// Image storage (async image task result offload to S3-compatible object storage)
viper.SetDefault("image_storage.enabled", false)
viper.SetDefault("image_storage.region", "auto")
viper.SetDefault("image_storage.prefix", "images/")
viper.SetDefault("image_storage.force_path_style", false)
viper.SetDefault("image_storage.presign_expiry_hours", 24)
viper.SetDefault("image_storage.max_download_bytes", 33554432)
// Registered with empty defaults so AutomaticEnv can reach them: viper only
// decodes keys present in AllKeys(), so a credential that is supplied purely
// via IMAGE_STORAGE_* and never appears in config.yaml would be dropped and
// silently disable the whole async image feature.
viper.SetDefault("image_storage.endpoint", "")
viper.SetDefault("image_storage.bucket", "")
viper.SetDefault("image_storage.access_key_id", "")
viper.SetDefault("image_storage.secret_access_key", "")
viper.SetDefault("image_storage.public_base_url", "")
// Ops (vNext)
viper.SetDefault("ops.enabled", true)
viper.SetDefault("ops.use_preaggregated_tables", true)
@@ -1922,6 +2110,12 @@ func setDefaults() {
viper.SetDefault("api_key_auth_cache.negative_ttl_seconds", 30)
viper.SetDefault("api_key_auth_cache.jitter_percent", 10)
viper.SetDefault("api_key_auth_cache.singleflight", true)
viper.SetDefault("api_key_auth_cache.lookup_concurrency", 64)
viper.SetDefault("api_key_auth_cache.invalid_abuse.enabled", true)
viper.SetDefault("api_key_auth_cache.invalid_abuse.threshold", 120)
viper.SetDefault("api_key_auth_cache.invalid_abuse.window_seconds", 60)
viper.SetDefault("api_key_auth_cache.invalid_abuse.block_seconds", 60)
viper.SetDefault("api_key_auth_cache.invalid_abuse.capacity", 16384)
// Subscription auth L1 cache
viper.SetDefault("subscription_cache.l1_size", 16384)
@@ -2033,6 +2227,7 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.reset", 0.0)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.quota_headroom", 0.0)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.upstream_cost", 0.0)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.previous_response", 5.0)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.session_sticky", 3.0)
// OpenAI HTTP upstream protocol strategy
@@ -2049,6 +2244,7 @@ func setDefaults() {
viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1)
viper.SetDefault("gateway.antigravity_extra_retries", 10)
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024))
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
viper.SetDefault("gateway.gemini_debug_response_headers", false)
@@ -2143,9 +2339,140 @@ func setDefaults() {
viper.SetDefault("subscription_maintenance.worker_count", 2)
viper.SetDefault("subscription_maintenance.queue_size", 1024)
setEnvReachableDefaults()
}
// setEnvReachableDefaults registers zero-valued defaults for keys that are
// documented in deploy/config.example.yaml but had no default of their own.
//
// viper.Unmarshal only decodes the keys returned by AllKeys(), which unions
// SetDefault keys, config-file keys and explicitly bound BindEnv keys.
// AutomaticEnv can override a key already in that union, but it never adds one,
// and the viper_bind_struct escape hatch is compiled out (we build with
// -tags embed). So a key that lives only in the example file was unreachable by
// environment variable: the value was read from the process environment and
// then silently dropped. Deployments driven purely by env — which is what
// deploy/docker-compose.yml does — got the zero value with no warning.
//
// The values below are deliberately zero rather than the documented example
// values: an absent key already unmarshalled to the zero value, so registering
// zero keeps behavior identical while making the key addressable from the
// environment. Any subsystem that wants a richer default still applies it after
// unmarshal, exactly as before.
func setEnvReachableDefaults() {
viper.SetDefault("gateway.forced_codex_instructions_template_file", "")
viper.SetDefault("gateway.session_idle_timeout_minutes", 0)
viper.SetDefault("gateway.user_message_queue.mode", "")
viper.SetDefault("update.proxy_url", "")
// sticky_escape_enabled is the one exception to the zero-value rule: its
// effective default is true, applied post-unmarshal via a viper.IsSet guard.
// Registering false would make IsSet always report true and permanently
// disable sticky escape, so register the effective default instead. An
// explicit false in config or env still wins.
viper.SetDefault("gateway.openai_scheduler.sticky_escape_enabled", true)
viper.SetDefault("gateway.openai_scheduler.sticky_escape_error_rate", 0.0)
viper.SetDefault("gateway.openai_scheduler.sticky_escape_ttft_ms", 0)
// server.trusted_proxies and security.forwarded_client_ip_headers are the
// other exception: load() distinguishes explicit configuration from absence
// (issue #4600), and viper.IsSet also reports registered defaults, so a
// SetDefault would make trusted proxies look permanently configured. Both
// environment variables are parsed by hand in load() via os.LookupEnv and
// were never silently dropped; binding them here records that reachability
// where AllKeys() — and the env-reachability guard — can see it, without
// affecting IsSet while the variables are absent. BindEnv only errors when
// called without arguments.
_ = viper.BindEnv("server.trusted_proxies", "SERVER_TRUSTED_PROXIES")
_ = viper.BindEnv("security.forwarded_client_ip_headers", "SECURITY_FORWARDED_CLIENT_IP_HEADERS")
// Third-party login providers. These carry client secrets and are exactly
// the settings an operator expects to inject via the environment, but every
// key here was previously unreachable that way.
for _, provider := range []string{"github_oauth", "google_oauth"} {
viper.SetDefault(provider+".enabled", false)
viper.SetDefault(provider+".client_id", "")
viper.SetDefault(provider+".client_secret", "")
viper.SetDefault(provider+".authorize_url", "")
viper.SetDefault(provider+".token_url", "")
viper.SetDefault(provider+".userinfo_url", "")
viper.SetDefault(provider+".emails_url", "")
viper.SetDefault(provider+".scopes", "")
viper.SetDefault(provider+".redirect_url", "")
viper.SetDefault(provider+".frontend_redirect_url", "")
}
viper.SetDefault("dingtalk_connect.client_id", "")
viper.SetDefault("dingtalk_connect.client_secret", "")
viper.SetDefault("dingtalk_connect.internal_corp_id", "")
viper.SetDefault("dingtalk_connect.redirect_url", "")
viper.SetDefault("dingtalk_connect.bypass_registration", false)
viper.SetDefault("dingtalk_connect.username_attribute_key", "")
viper.SetDefault("dingtalk_connect.enable_attribute_matching", false)
viper.SetDefault("dingtalk_connect.enable_attribute_sync", false)
viper.SetDefault("dingtalk_connect.attribute_sync_fields", []string{})
viper.SetDefault("dingtalk_connect.attribute_sync_overwrite_policy", "")
viper.SetDefault("dingtalk_connect.sync_display_name", false)
viper.SetDefault("dingtalk_connect.sync_display_name_attr_key", "")
viper.SetDefault("dingtalk_connect.sync_display_name_attr_name", "")
viper.SetDefault("dingtalk_connect.sync_dept", false)
viper.SetDefault("dingtalk_connect.sync_dept_attr_key", "")
viper.SetDefault("dingtalk_connect.sync_dept_attr_name", "")
viper.SetDefault("dingtalk_connect.sync_corp_email", false)
viper.SetDefault("dingtalk_connect.sync_corp_email_attr_key", "")
viper.SetDefault("dingtalk_connect.sync_corp_email_attr_name", "")
}
func (c *Config) Validate() error {
forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders)
if err != nil {
return fmt.Errorf("security.forwarded_client_ip_headers: %w", err)
}
c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders
c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders)
if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 {
return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds")
}
if c.Server.MaxHeaderBytes < 8*1024 || c.Server.MaxHeaderBytes > 1024*1024 {
return fmt.Errorf("server.max_header_bytes must be between 8192 and 1048576 bytes")
}
if c.Server.IdleTimeout <= 0 {
return fmt.Errorf("server.idle_timeout must be positive")
}
if c.Server.MaxRequestBodySize < 0 {
return fmt.Errorf("server.max_request_body_size must be non-negative")
}
if c.Server.H2C.Enabled {
if c.Server.H2C.MaxConcurrentStreams == 0 {
return fmt.Errorf("server.h2c.max_concurrent_streams must be positive")
}
if c.Server.H2C.IdleTimeout <= 0 {
return fmt.Errorf("server.h2c.idle_timeout must be positive")
}
if c.Server.H2C.MaxReadFrameSize < 16*1024 || c.Server.H2C.MaxReadFrameSize > 16*1024*1024-1 {
return fmt.Errorf("server.h2c.max_read_frame_size must be between 16384 and 16777215 bytes")
}
if c.Server.H2C.MaxUploadBufferPerConnection < 65535 {
return fmt.Errorf("server.h2c.max_upload_buffer_per_connection must be at least 65535 bytes")
}
if c.Server.H2C.MaxUploadBufferPerStream <= 0 {
return fmt.Errorf("server.h2c.max_upload_buffer_per_stream must be positive")
}
}
if c.APIKeyAuth.InvalidAbuse.Enabled {
if c.APIKeyAuth.InvalidAbuse.Threshold < 10 {
return fmt.Errorf("api_key_auth_cache.invalid_abuse.threshold must be at least 10")
}
if c.APIKeyAuth.InvalidAbuse.WindowSeconds < 1 || c.APIKeyAuth.InvalidAbuse.WindowSeconds > 3600 {
return fmt.Errorf("api_key_auth_cache.invalid_abuse.window_seconds must be between 1 and 3600")
}
if c.APIKeyAuth.InvalidAbuse.BlockSeconds < 1 || c.APIKeyAuth.InvalidAbuse.BlockSeconds > 3600 {
return fmt.Errorf("api_key_auth_cache.invalid_abuse.block_seconds must be between 1 and 3600")
}
if c.APIKeyAuth.InvalidAbuse.Capacity < 256 || c.APIKeyAuth.InvalidAbuse.Capacity > 1_000_000 {
return fmt.Errorf("api_key_auth_cache.invalid_abuse.capacity must be between 256 and 1000000")
}
}
jwtSecret := strings.TrimSpace(c.JWT.Secret)
if jwtSecret == "" {
return fmt.Errorf("jwt.secret is required")
@@ -2671,6 +2998,9 @@ func (c *Config) Validate() error {
if c.Gateway.MaxBodySize <= 0 {
return fmt.Errorf("gateway.max_body_size must be positive")
}
if c.Gateway.TextMaxBodySize <= 0 || c.Gateway.TextMaxBodySize > c.Gateway.MaxBodySize {
return fmt.Errorf("gateway.text_max_body_size must be positive and no greater than gateway.max_body_size")
}
if c.Gateway.UpstreamResponseReadMaxBytes <= 0 {
return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive")
}
@@ -2899,25 +3229,26 @@ func (c *Config) Validate() error {
if c.Gateway.OpenAIHTTP2.FallbackTTLSeconds < 0 {
return fmt.Errorf("gateway.openai_http2.fallback_ttl_seconds must be non-negative")
}
if c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky < 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative")
weights := c.Gateway.OpenAIWS.SchedulerScoreWeights
for _, weight := range []float64{
weights.Priority, weights.Load, weights.Queue, weights.ErrorRate, weights.TTFT,
weights.Reset, weights.QuotaHeadroom, weights.UpstreamCost,
weights.PreviousResponse, weights.SessionSticky,
} {
if weight < 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite")
}
}
weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority +
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load +
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue +
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate +
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT +
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom
weightSum := weights.BaseWeightSum()
if weightSum <= 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
}
if math.IsNaN(weightSum) || math.IsInf(weightSum, 0) {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights base-weight sum must be finite")
}
if totalWeightSum := weights.TotalWeightSum(); math.IsNaN(totalWeightSum) || math.IsInf(totalWeightSum, 0) {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights total-weight sum must be finite")
}
if c.Gateway.OpenAIScheduler.StickyEscapeTTFTMs <= 0 {
return fmt.Errorf("gateway.openai_scheduler.sticky_escape_ttft_ms must be positive")
}
+323
View File
@@ -1,9 +1,12 @@
package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -34,6 +37,213 @@ func TestLoadServerTimingConfig(t *testing.T) {
})
}
func TestLoadHTTPIngressSafetyDefaults(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, 10, cfg.Server.ReadHeaderTimeout)
require.Equal(t, 64*1024, cfg.Server.MaxHeaderBytes)
require.Empty(t, cfg.Server.TrustedProxies)
require.False(t, cfg.Server.TrustedProxiesConfigured)
require.True(t, cfg.TrustForwardedIPForAPIKeyACL())
require.Equal(t, int64(32*1024*1024), cfg.Gateway.TextMaxBodySize)
require.True(t, cfg.APIKeyAuth.InvalidAbuse.Enabled)
require.Equal(t, 120, cfg.APIKeyAuth.InvalidAbuse.Threshold)
require.Equal(t, 16384, cfg.APIKeyAuth.InvalidAbuse.Capacity)
}
func TestNormalizeForwardedClientIPHeaders(t *testing.T) {
headers, err := NormalizeForwardedClientIPHeaders([]string{
" x-cdn-client-ip ",
"X-CDN-CLIENT-IP",
"true-client-ip",
})
require.NoError(t, err)
require.Equal(t, []string{"X-Cdn-Client-Ip", "True-Client-Ip"}, headers)
_, err = NormalizeForwardedClientIPHeaders([]string{"X Invalid"})
require.ErrorContains(t, err, "invalid HTTP header field name")
}
func TestNormalizeForwardedClientIPHeadersLimit(t *testing.T) {
headers := make([]string, 0, MaxForwardedClientIPHeaders+1)
for i := 0; i <= MaxForwardedClientIPHeaders; i++ {
headers = append(headers, fmt.Sprintf("X-CDN-IP-%d", i))
}
_, err := NormalizeForwardedClientIPHeaders(headers)
require.ErrorContains(t, err, "at most 16 unique names")
}
func TestLoadForwardedClientIPHeadersNormalizesAndSnapshots(t *testing.T) {
resetViperWithJWTSecret(t)
viper.Set("security.forwarded_client_ip_headers", []string{" x-cdn-ip ", "X-CDN-IP", "true-client-ip"})
cfg, err := Load()
require.NoError(t, err)
snapshot := cfg.ForwardedClientIPSettings()
require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, snapshot.Headers)
snapshot.Headers[0] = "X-Mutated"
require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers)
}
func TestForwardedClientIPSettingsConcurrentPublication(t *testing.T) {
cfg := &Config{}
cfg.SetForwardedClientIPSettings(true, []string{"X-Public-A"})
const iterations = 2000
start := make(chan struct{})
errCh := make(chan error, 8)
var wg sync.WaitGroup
for _, settings := range []ForwardedClientIPSettings{
{TrustForwardedIP: true, Headers: []string{"X-Public-A"}},
{TrustForwardedIP: false, Headers: []string{"X-Public-B"}},
} {
settings := settings
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < iterations; i++ {
cfg.SetForwardedClientIPSettings(settings.TrustForwardedIP, settings.Headers)
}
}()
}
for i := 0; i < cap(errCh); i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for j := 0; j < iterations; j++ {
snapshot := cfg.ForwardedClientIPSettings()
validA := snapshot.TrustForwardedIP && len(snapshot.Headers) == 1 && snapshot.Headers[0] == "X-Public-A"
validB := !snapshot.TrustForwardedIP && len(snapshot.Headers) == 1 && snapshot.Headers[0] == "X-Public-B"
if !validA && !validB {
errCh <- fmt.Errorf("observed inconsistent forwarded IP settings: %+v", snapshot)
return
}
}
}()
}
close(start)
wg.Wait()
close(errCh)
for err := range errCh {
require.NoError(t, err)
}
}
func TestLoadForwardedClientIPHeadersFromEnvironment(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", " x-cdn-ip , X-CDN-IP, true-client-ip ")
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers)
}
func TestLoadExplicitEmptyForwardedClientIPHeadersFromEnvironment(t *testing.T) {
resetViperWithJWTSecret(t)
viper.Set("security.forwarded_client_ip_headers", []string{"X-Yaml-IP"})
t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", "")
cfg, err := Load()
require.NoError(t, err)
require.Empty(t, cfg.ForwardedClientIPSettings().Headers)
}
func TestLoadRejectsInvalidForwardedClientIPHeaderFromEnvironment(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", "X-Valid-IP, X Invalid")
_, err := Load()
require.ErrorContains(t, err, "security.forwarded_client_ip_headers")
}
func TestLoadRejectsInvalidForwardedClientIPHeader(t *testing.T) {
resetViperWithJWTSecret(t)
viper.Set("security.forwarded_client_ip_headers", []string{"X Invalid"})
_, err := Load()
require.ErrorContains(t, err, "security.forwarded_client_ip_headers")
}
func TestLoadExplicitEmptyTrustedProxiesEnablesConfiguredMode(t *testing.T) {
resetViperWithJWTSecret(t)
viper.Set("server.trusted_proxies", []string{})
cfg, err := Load()
require.NoError(t, err)
require.Empty(t, cfg.Server.TrustedProxies)
require.True(t, cfg.Server.TrustedProxiesConfigured)
}
func TestLoadExplicitTrustedProxiesEnablesConfiguredMode(t *testing.T) {
resetViperWithJWTSecret(t)
viper.Set("server.trusted_proxies", []string{"127.0.0.1/32"})
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, []string{"127.0.0.1/32"}, cfg.Server.TrustedProxies)
require.True(t, cfg.Server.TrustedProxiesConfigured)
}
func TestLoadTrustedProxiesFromEnvironment(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("SERVER_TRUSTED_PROXIES", "127.0.0.1/32, ::1/128")
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, []string{"127.0.0.1/32", "::1/128"}, cfg.Server.TrustedProxies)
require.True(t, cfg.Server.TrustedProxiesConfigured)
}
func TestLoadExplicitEmptyTrustedProxiesFromEnvironment(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("SERVER_TRUSTED_PROXIES", "")
cfg, err := Load()
require.NoError(t, err)
require.Empty(t, cfg.Server.TrustedProxies)
require.True(t, cfg.Server.TrustedProxiesConfigured)
}
func TestLoadTrustedProxiesPresenceFromYAML(t *testing.T) {
tests := []struct {
name string
yaml string
want []string
configured bool
}{
{name: "absent", yaml: "server:\n mode: debug\n", configured: false},
{name: "explicit empty", yaml: "server:\n trusted_proxies: []\n", want: []string{}, configured: true},
{
name: "populated",
yaml: "server:\n trusted_proxies:\n - 127.0.0.1/32\n - ::1/128\n",
want: []string{"127.0.0.1/32", "::1/128"},
configured: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resetViperWithJWTSecret(t)
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(test.yaml), 0o600))
t.Setenv("DATA_DIR", configDir)
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, test.want, cfg.Server.TrustedProxies)
require.Equal(t, test.configured, cfg.Server.TrustedProxiesConfigured)
})
}
}
func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) {
viper.Reset()
t.Setenv("JWT_SECRET", "")
@@ -215,6 +425,9 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom != 0 {
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom)
}
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost != 0 {
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost)
}
if !cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
t.Fatalf("Gateway.OpenAIWS.StoreDisabledForceNewConn = false, want true")
}
@@ -1181,6 +1394,51 @@ func TestValidateConfigErrors(t *testing.T) {
mutate func(*Config)
wantErr string
}{
{
name: "server read header timeout",
mutate: func(c *Config) { c.Server.ReadHeaderTimeout = 0 },
wantErr: "server.read_header_timeout",
},
{
name: "server max header bytes too small",
mutate: func(c *Config) { c.Server.MaxHeaderBytes = 4096 },
wantErr: "server.max_header_bytes",
},
{
name: "server max request body size",
mutate: func(c *Config) { c.Server.MaxRequestBodySize = -1 },
wantErr: "server.max_request_body_size",
},
{
name: "h2c zero concurrent streams",
mutate: func(c *Config) {
c.Server.H2C.Enabled = true
c.Server.H2C.MaxConcurrentStreams = 0
},
wantErr: "server.h2c.max_concurrent_streams",
},
{
name: "h2c oversized read frame",
mutate: func(c *Config) {
c.Server.H2C.Enabled = true
c.Server.H2C.MaxReadFrameSize = 16 * 1024 * 1024
},
wantErr: "server.h2c.max_read_frame_size",
},
{
name: "invalid auth abuse threshold too small",
mutate: func(c *Config) {
c.APIKeyAuth.InvalidAbuse.Threshold = 9
},
wantErr: "api_key_auth_cache.invalid_abuse.threshold",
},
{
name: "invalid auth abuse capacity too small",
mutate: func(c *Config) {
c.APIKeyAuth.InvalidAbuse.Capacity = 255
},
wantErr: "api_key_auth_cache.invalid_abuse.capacity",
},
{
name: "jwt secret required",
mutate: func(c *Config) { c.JWT.Secret = "" },
@@ -1382,6 +1640,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.MaxBodySize = 0 },
wantErr: "gateway.max_body_size",
},
{
name: "gateway text body exceeds media body",
mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 },
wantErr: "gateway.text_max_body_size",
},
{
name: "gateway response header timeout",
mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 },
@@ -1866,6 +2129,42 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = -0.1 },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
},
{
name: "scheduler_score_weights upstream_cost 不能为负数",
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = -0.1 },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
},
{
name: "scheduler_score_weights reset 不能为负数",
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Reset = -0.1 },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
},
{
name: "scheduler_score_weights 不能为 NaN",
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse = math.NaN() },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite",
},
{
name: "scheduler_score_weights 不能为 Inf",
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = math.Inf(1) },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite",
},
{
name: "scheduler_score_weights 总和不能溢出",
mutate: func(c *Config) {
c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = math.MaxFloat64
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load = math.MaxFloat64
},
wantErr: "gateway.openai_ws.scheduler_score_weights base-weight sum must be finite",
},
{
name: "scheduler_score_weights 含 sticky 总和不能溢出",
mutate: func(c *Config) {
c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = math.MaxFloat64
c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse = math.MaxFloat64
},
wantErr: "gateway.openai_ws.scheduler_score_weights total-weight sum must be finite",
},
{
name: "scheduler_score_weights 不能全为 0",
mutate: func(c *Config) {
@@ -1917,6 +2216,30 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
require.NoError(t, cfg.Validate())
})
t.Run("upstream_cost 可作为唯一有效调度权重", func(t *testing.T) {
cfg := buildValid(t)
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = 0.1
require.NoError(t, cfg.Validate())
})
t.Run("reset 可作为唯一有效调度权重", func(t *testing.T) {
cfg := buildValid(t)
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Reset = 0.1
require.NoError(t, cfg.Validate())
})
}
func TestValidateConfig_AutoScaleDisabledIgnoreAutoScaleFields(t *testing.T) {
@@ -0,0 +1,90 @@
//go:build unit
package config
import (
"reflect"
"sort"
"strings"
"testing"
"github.com/spf13/viper"
)
// collectMapstructureKeys walks a config struct and returns every dotted key
// viper would need in order to populate it.
func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]string) {
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue // unexported
}
tag := field.Tag.Get("mapstructure")
name, _, _ := strings.Cut(tag, ",")
if name == "-" {
continue
}
if name == "" {
name = strings.ToLower(field.Name)
}
key := name
if prefix != "" {
key = prefix + "." + name
}
ft := field.Type
for ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
collectMapstructureKeys(ft, key, out)
continue
}
if ft.Kind() == reflect.Map {
// A map cannot be expressed in a single environment variable, so it
// is out of scope here — such settings need a config file either way.
continue
}
out[strings.ToLower(key)] = ft.String()
}
}
// TestConfigKeysAreEnvReachable is the systemic guard behind the image_storage
// bug: viper.Unmarshal only decodes keys returned by AllKeys(), which unions
// SetDefault keys, config-file keys and explicit BindEnv keys. AutomaticEnv can
// override a key already in that union but never introduces one, and the
// viper_bind_struct escape hatch is compiled out (we build with -tags embed).
//
// So a Config field with no registered default is unreachable by environment
// variable whenever the deployment has no config.yaml containing it — the
// operator sets the variable, the loader discards it, and the feature behaves
// as if it were never configured. That is exactly how image_storage credentials
// were lost, silently disabling async image tasks for env-driven deployments.
//
// When this fails, register a zero-valued default in setEnvReachableDefaults
// for each reported key.
func TestConfigKeysAreEnvReachable(t *testing.T) {
bound := map[string]string{}
collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound)
viper.Reset()
t.Cleanup(viper.Reset)
setDefaults()
registered := map[string]struct{}{}
for _, key := range viper.AllKeys() {
registered[key] = struct{}{}
}
var unreachable []string
for key, kind := range bound {
if _, ok := registered[key]; !ok {
unreachable = append(unreachable, key+" ("+kind+")")
}
}
sort.Strings(unreachable)
if len(unreachable) > 0 {
t.Fatalf("%d config keys have no default registered, so their environment variables are silently ignored:\n %s",
len(unreachable), strings.Join(unreachable, "\n "))
}
}
@@ -0,0 +1,41 @@
//go:build unit
package config
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestLoadImageStorageFromEnv guards against a viper trap that silently disabled
// asynchronous image tasks for every environment-variable-only deployment.
//
// viper only decodes keys returned by AllKeys(), which unions SetDefault keys,
// config-file keys and explicit BindEnv keys. AutomaticEnv can override a key
// that is already in that list, but it never introduces a new one. Credentials
// such as image_storage.bucket therefore need an (empty) default registered, or
// IMAGE_STORAGE_BUCKET is dropped on the floor and Active() stays false while
// image_storage.enabled reads true — the endpoints 404 with no useful signal.
func TestLoadImageStorageFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("IMAGE_STORAGE_ENABLED", "true")
t.Setenv("IMAGE_STORAGE_ENDPOINT", "https://acct.r2.cloudflarestorage.com")
t.Setenv("IMAGE_STORAGE_BUCKET", "my-images")
t.Setenv("IMAGE_STORAGE_ACCESS_KEY_ID", "ak")
t.Setenv("IMAGE_STORAGE_SECRET_ACCESS_KEY", "sk")
t.Setenv("IMAGE_STORAGE_PUBLIC_BASE_URL", "https://cdn.example.com")
cfg, err := Load()
require.NoError(t, err)
require.True(t, cfg.ImageStorage.Enabled)
require.Equal(t, "https://acct.r2.cloudflarestorage.com", cfg.ImageStorage.Endpoint)
require.Equal(t, "my-images", cfg.ImageStorage.Bucket)
require.Equal(t, "ak", cfg.ImageStorage.AccessKeyID)
require.Equal(t, "sk", cfg.ImageStorage.SecretAccessKey)
require.Equal(t, "https://cdn.example.com", cfg.ImageStorage.PublicBaseURL)
require.True(t, cfg.ImageStorage.IsConfigured())
require.True(t, cfg.ImageStorage.Active(), "async image tasks must be active when every credential is supplied via env")
}
@@ -1,6 +1,7 @@
package admin
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
@@ -45,3 +46,132 @@ func TestNormalizeCodexImportEntryAcceptsAgentIdentityAuthJSON(t *testing.T) {
require.NotContains(t, item.Credentials, "refresh_token")
require.NotEmpty(t, item.WarningTexts)
}
func TestBuildCodexAgentIdentityKeysUseChatGPTAccountOnly(t *testing.T) {
keys := buildCodexAgentIdentityKeys("team-a")
require.Equal(t, []string{"account:team-a"}, keys)
}
func TestCodexAgentIdentityIndexSeparatesTeamsForSameUser(t *testing.T) {
existing := service.Account{
ID: 1,
Credentials: map[string]any{
"auth_mode": service.OpenAIAuthModeAgentIdentity,
"chatgpt_account_id": "team-a",
"chatgpt_user_id": "same-user",
"agent_runtime_id": "runtime-a",
},
}
index := buildCodexAccountIndex([]service.Account{existing})
teamBKeys := buildCodexAgentIdentityKeys("team-b")
matched, _ := index.Find(teamBKeys, "same-user")
require.Nil(t, matched)
teamAKeys := buildCodexAgentIdentityKeys("team-a")
matched, matchedKey := index.Find(teamAKeys, "same-user")
require.NotNil(t, matched)
require.Equal(t, int64(1), matched.ID)
require.Equal(t, "account:team-a", matchedKey)
}
func TestImportCodexSessionsKeepsAgentIdentityTeamsSeparate(t *testing.T) {
first := buildAgentIdentityImportValue(t, "runtime-a", "team-a", "same-user", "task-a")
second := buildAgentIdentityImportValue(t, "runtime-b", "team-b", "same-user", "task-b")
svc := newCodexImportMemoryAdminService(nil)
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{
SkipDefaultGroupBind: boolPtr(true),
}, []codexImportEntry{{Index: 1, Value: first}, {Index: 2, Value: second}})
require.NoError(t, err)
require.Equal(t, 2, result.Created)
require.Zero(t, result.Updated)
require.Zero(t, result.Skipped)
require.Len(t, svc.createdAccounts, 2)
}
func TestImportCodexSessionsMergesAgentIdentityRuntimesForSameTeam(t *testing.T) {
first := buildAgentIdentityImportValue(t, "runtime-a", "team-a", "same-user", "task-a")
second := buildAgentIdentityImportValue(t, "runtime-b", "team-a", "same-user", "task-b")
firstIdentity, ok := first["agent_identity"].(map[string]any)
require.True(t, ok)
existing := service.Account{
ID: 41,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{
"auth_mode": service.OpenAIAuthModeAgentIdentity,
"agent_runtime_id": firstIdentity["agent_runtime_id"],
"agent_private_key": firstIdentity["agent_private_key"],
"task_id": firstIdentity["task_id"],
"chatgpt_account_id": firstIdentity["account_id"],
"chatgpt_user_id": firstIdentity["chatgpt_user_id"],
},
}
svc := newCodexImportMemoryAdminService([]service.Account{existing})
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{
SkipDefaultGroupBind: boolPtr(true),
}, []codexImportEntry{{Index: 1, Value: second}})
require.NoError(t, err)
require.Zero(t, result.Created)
require.Equal(t, 1, result.Updated)
require.Len(t, svc.updatedAccounts, 1)
require.Equal(t, "runtime-b", svc.updatedAccounts[0].input.Credentials["agent_runtime_id"])
require.Equal(t, "task-b", svc.updatedAccounts[0].input.Credentials["task_id"])
}
func buildAgentIdentityImportValue(t *testing.T, runtimeID, accountID, userID, taskID string) map[string]any {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
return map[string]any{
"auth_mode": "agentIdentity",
"agent_identity": map[string]any{
"agent_runtime_id": runtimeID,
"agent_private_key": base64.StdEncoding.EncodeToString(der),
"task_id": taskID,
"account_id": accountID,
"chatgpt_user_id": userID,
},
}
}
func TestImportCodexSessionsCreatesAgentIdentityWithoutOAuthExpiry(t *testing.T) {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
svc := newCodexImportMemoryAdminService(nil)
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{
SkipDefaultGroupBind: boolPtr(true),
}, []codexImportEntry{{
Index: 1,
Value: map[string]any{
"auth_mode": "agentIdentity",
"agent_identity": map[string]any{
"agent_runtime_id": "runtime-import",
"agent_private_key": base64.StdEncoding.EncodeToString(der),
"task_id": "task-import",
"account_id": "account-import",
"chatgpt_user_id": "user-import",
},
},
}})
require.NoError(t, err)
require.Equal(t, 1, result.Created)
require.Zero(t, result.Failed)
require.Len(t, svc.createdAccounts, 1)
created := svc.createdAccounts[0]
require.Nil(t, created.ExpiresAt)
require.Nil(t, created.AutoPauseOnExpired)
require.Equal(t, service.OpenAIAuthModeAgentIdentity, created.Credentials["auth_mode"])
require.NotContains(t, created.Credentials, "access_token")
require.NotContains(t, created.Credentials, "refresh_token")
}
@@ -532,7 +532,7 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
if item.AgentTaskID == "" {
item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task")
}
item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID, item.UserID, item.Email, item.AgentRuntimeID)
item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID)
item.Name = buildCodexImportAccountName(item, entry.Index)
return item, nil
}
@@ -774,6 +774,12 @@ func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAc
if item == nil {
return nil, nil, nil, nil, errors.New("导入项为空")
}
// Agent Identity has no OAuth access-token lifetime. Its runtime/task
// lifecycle is handled by the upstream task recovery path, so it must not
// be rejected or auto-paused by the OAuth import expiry policy.
if item.IsAgentIdentity {
return nil, nil, nil, nil, nil
}
var requestExpiresAt *time.Time
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
@@ -888,12 +894,17 @@ func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refresh
return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
}
func buildCodexAgentIdentityKeys(accountID, userID, email, runtimeID string) []string {
keys := buildCodexStoredIdentityKeys(accountID, userID, email, "")
if runtimeID = strings.TrimSpace(runtimeID); runtimeID != "" {
keys = append([]string{"agent:" + runtimeID}, keys...)
func buildCodexAgentIdentityKeys(accountID string) []string {
// Agent Identity credentials belonging to the same ChatGPT account are
// intentionally merged, while the same user may own multiple accounts.
// Do not use user/email/runtime as fallback keys here: user_id is shared
// across Team workspaces and runtime_id changes when a new runtime is
// registered for the same account.
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil
}
return keys
return []string{"account:" + accountID}
}
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
@@ -61,7 +61,13 @@ type AccountHandler struct {
sessionLimitCache service.SessionLimitCache
rpmCache service.RPMCache
tokenCacheInvalidator service.TokenCacheInvalidator
grokImportProber grokUsageProber
grokImportProber grokImportProber
upstreamBillingProbe *service.UpstreamBillingProbeService
}
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
func (h *AccountHandler) SetUpstreamBillingProbeService(probe *service.UpstreamBillingProbeService) {
h.upstreamBillingProbe = probe
}
// NewAccountHandler creates a new admin account handler
@@ -115,6 +121,7 @@ type CreateAccountRequest struct {
GroupIDs []int64 `json:"group_ids"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
}
@@ -153,6 +160,7 @@ type BulkUpdateAccountsRequest struct {
GroupIDs *[]int64 `json:"group_ids"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
}
@@ -822,6 +830,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
ProbeEnabled: req.ProbeEnabled,
SkipMixedChannelCheck: skipCheck,
})
if execErr != nil {
@@ -1901,7 +1910,8 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
req.Schedulable != nil ||
req.GroupIDs != nil ||
len(req.Credentials) > 0 ||
len(req.Extra) > 0
len(req.Extra) > 0 ||
req.ProbeEnabled != nil
if !hasUpdates {
response.BadRequest(c, "No updates provided")
@@ -1922,6 +1932,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
GroupIDs: req.GroupIDs,
Credentials: req.Credentials,
Extra: req.Extra,
ProbeEnabled: req.ProbeEnabled,
SkipMixedChannelCheck: skipCheck,
})
if err != nil {
@@ -222,3 +222,22 @@ func TestBulkUpdateAcceptsFilterTargetRequest(t *testing.T) {
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, float64(0), resp["code"])
}
func TestBulkUpdateAcceptsDedicatedUpstreamBillingProbeSetting(t *testing.T) {
adminSvc := newStubAdminService()
router := setupAccountMixedChannelRouter(adminSvc)
body, _ := json.Marshal(map[string]any{
"account_ids": []int64{1, 2},
"upstream_billing_probe_enabled": false,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.NotNil(t, adminSvc.lastBulkUpdateAccountInput)
require.NotNil(t, adminSvc.lastBulkUpdateAccountInput.ProbeEnabled)
require.False(t, *adminSvc.lastBulkUpdateAccountInput.ProbeEnabled)
}
@@ -0,0 +1,122 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type upstreamBillingProbeEnabledRequest struct {
Enabled *bool `json:"enabled" binding:"required"`
}
type upstreamBillingProbeBatchRequest struct {
AccountIDs []int64 `json:"account_ids" binding:"required"`
}
func (h *AccountHandler) GetUpstreamBillingProbeSettings(c *gin.Context) {
if h.upstreamBillingProbe == nil {
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
return
}
settings, err := h.upstreamBillingProbe.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *AccountHandler) UpdateUpstreamBillingProbeSettings(c *gin.Context) {
if h.upstreamBillingProbe == nil {
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
return
}
var req service.UpstreamBillingProbeSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.upstreamBillingProbe.UpdateSettings(c.Request.Context(), &req); err != nil {
response.ErrorFrom(c, err)
return
}
settings, err := h.upstreamBillingProbe.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *AccountHandler) SetUpstreamBillingProbeEnabled(c *gin.Context) {
if h.upstreamBillingProbe == nil {
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
return
}
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || accountID <= 0 {
response.BadRequest(c, "Invalid account ID")
return
}
var req upstreamBillingProbeEnabledRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.upstreamBillingProbe.SetAccountEnabled(c.Request.Context(), accountID, *req.Enabled); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"account_id": accountID, "enabled": *req.Enabled})
}
func (h *AccountHandler) ProbeUpstreamBilling(c *gin.Context) {
if h.upstreamBillingProbe == nil {
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
return
}
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || accountID <= 0 {
response.BadRequest(c, "Invalid account ID")
return
}
snapshot, err := h.upstreamBillingProbe.ProbeAccount(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, service.UpstreamBillingProbeResult{AccountID: accountID, Snapshot: snapshot})
}
func (h *AccountHandler) ProbeUpstreamBillingBatch(c *gin.Context) {
if h.upstreamBillingProbe == nil {
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
return
}
var req upstreamBillingProbeBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.AccountIDs) == 0 || len(req.AccountIDs) > service.UpstreamBillingProbeMaxBatchSize {
response.BadRequest(c, "account_ids must contain between 1 and 20 items")
return
}
seen := make(map[int64]struct{}, len(req.AccountIDs))
accountIDs := make([]int64, 0, len(req.AccountIDs))
for _, accountID := range req.AccountIDs {
if accountID <= 0 {
response.BadRequest(c, "account_ids must contain positive IDs")
return
}
if _, exists := seen[accountID]; exists {
continue
}
seen[accountID] = struct{}{}
accountIDs = append(accountIDs, accountID)
}
response.Success(c, gin.H{"results": h.upstreamBillingProbe.ProbeAccounts(c.Request.Context(), accountIDs)})
}
@@ -0,0 +1,71 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func setupUpstreamBillingProbeRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
handler := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler.SetUpstreamBillingProbeService(service.NewUpstreamBillingProbeService(nil, nil, nil))
router := gin.New()
router.GET("/admin/accounts/upstream-billing-probe/settings", handler.GetUpstreamBillingProbeSettings)
router.POST("/admin/accounts/upstream-billing-probe/batch", handler.ProbeUpstreamBillingBatch)
router.PUT("/admin/accounts/:id/upstream-billing-probe", handler.SetUpstreamBillingProbeEnabled)
return router
}
func TestAccountHandlerGetUpstreamBillingProbeSettingsReturnsDefaults(t *testing.T) {
router := setupUpstreamBillingProbeRouter()
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/admin/accounts/upstream-billing-probe/settings", nil))
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Data service.UpstreamBillingProbeSettings `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Data.Enabled)
require.Equal(t, 30, response.Data.IntervalMinutes)
}
func TestAccountHandlerProbeUpstreamBillingBatchValidatesIDs(t *testing.T) {
router := setupUpstreamBillingProbeRouter()
for _, body := range []string{`{"account_ids":[]}`, `{"account_ids":[0]}`} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/accounts/upstream-billing-probe/batch", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
}
func TestAccountHandlerSetUpstreamBillingProbeEnabledRejectsInvalidID(t *testing.T) {
router := setupUpstreamBillingProbeRouter()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/admin/accounts/not-an-id/upstream-billing-probe", bytes.NewBufferString(`{"enabled":true}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
func TestAccountHandlerSetUpstreamBillingProbeEnabledRequiresValue(t *testing.T) {
router := setupUpstreamBillingProbeRouter()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/admin/accounts/1/upstream-billing-probe", bytes.NewBufferString(`{}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
@@ -16,7 +16,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
router := gin.New()
adminSvc := newStubAdminService()
userHandler := NewUserHandler(adminSvc, nil, nil, nil)
userHandler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil)
groupHandler := NewGroupHandler(adminSvc, nil, nil)
proxyHandler := NewProxyHandler(adminSvc)
redeemHandler := NewRedeemHandler(adminSvc, nil)
@@ -33,6 +33,7 @@ type stubAdminService struct {
createSparkShadowErr error
updateAccountErr error
bulkUpdateAccountErr error
lastBulkUpdateAccountInput *service.BulkUpdateAccountsInput
getAccountResult *service.Account
updateAccountCalls int
updateAccountExtraCalls int
@@ -195,6 +196,10 @@ func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs [
return len(userIDs), nil
}
func (s *stubAdminService) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
return len(userIDs), nil
}
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil
}
@@ -293,6 +298,15 @@ func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.Creat
return &group, nil
}
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
return &group, nil
}
func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
return nil, nil
}
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
return &group, nil
@@ -465,6 +479,7 @@ func (s *stubAdminService) SetAccountSchedulable(ctx context.Context, id int64,
}
func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
s.lastBulkUpdateAccountInput = input
if s.bulkUpdateAccountErr != nil {
return nil, s.bulkUpdateAccountErr
}
@@ -0,0 +1,169 @@
package admin
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// AuditLogHandler 操作审计日志管理接口。
// 审计日志仅管理员可见;不提供单条删除,仅支持带 TOTP 验证的全量清空。
type AuditLogHandler struct {
auditService *service.AuditLogService
totpService *service.TotpService
}
// NewAuditLogHandler 创建审计日志处理器。
func NewAuditLogHandler(auditService *service.AuditLogService, totpService *service.TotpService) *AuditLogHandler {
return &AuditLogHandler{
auditService: auditService,
totpService: totpService,
}
}
// List 分页查询审计日志。
// GET /api/v1/admin/audit-logs
func (h *AuditLogHandler) List(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
if pageSize > 200 {
pageSize = 200
}
filter := &service.AuditLogFilter{
Page: page,
PageSize: pageSize,
ActorEmail: strings.TrimSpace(c.Query("actor_email")),
AuthMethod: strings.TrimSpace(c.Query("auth_method")),
Action: strings.TrimSpace(c.Query("action")),
Method: strings.TrimSpace(c.Query("method")),
ClientIP: strings.TrimSpace(c.Query("client_ip")),
Query: strings.TrimSpace(c.Query("q")),
}
if v := strings.TrimSpace(c.Query("actor_user_id")); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid actor_user_id")
return
}
filter.ActorUserID = &id
}
if v := strings.TrimSpace(c.Query("start_time")); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
response.BadRequest(c, "Invalid start_time, expect RFC3339")
return
}
filter.StartTime = &t
}
if v := strings.TrimSpace(c.Query("end_time")); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
response.BadRequest(c, "Invalid end_time, expect RFC3339")
return
}
filter.EndTime = &t
}
if v := strings.TrimSpace(c.Query("success")); v != "" {
success := v == "true"
filter.Success = &success
}
result, err := h.auditService.List(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, result.Logs, int64(result.Total), result.Page, result.PageSize)
}
// Get 查询单条审计日志详情(含脱敏后的请求体)。
// GET /api/v1/admin/audit-logs/:id
func (h *AuditLogHandler) Get(c *gin.Context) {
id, err := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid audit log id")
return
}
item, err := h.auditService.GetByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, item)
}
type auditLogClearRequest struct {
TotpCode string `json:"totp_code" binding:"required"`
}
// Clear 全量清空审计日志。
// POST /api/v1/admin/audit-logs/clear
//
// 安全要求(与需求对齐):
// 1. 每次清空都必须现场验证 TOTP 码(不复用 step-up sudo 窗口)
// 2. 未启用 TOTP 的管理员不允许清空
// 3. admin API key(机器凭证)不允许清空
// 4. 清空完成后同步写入一条留痕记录(操作者、IP、UA、删除行数)
func (h *AuditLogHandler) Clear(c *gin.Context) {
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
response.ErrorWithDetails(c, http.StatusForbidden,
"Admin API key cannot clear audit logs; a two-factor verified admin session is required",
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
return
}
subject, ok := middleware.GetAuthSubjectFromContext(c)
if !ok || subject.UserID <= 0 {
response.Unauthorized(c, "Unauthorized")
return
}
var req auditLogClearRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithDetails(c, http.StatusBadRequest,
"TOTP code is required to clear audit logs", "TOTP_CODE_REQUIRED", nil)
return
}
// 校验 TOTP:未启用(ErrTotpNotSetup)、码错误、尝试过多均在此拦截。
if err := h.totpService.VerifyCode(c.Request.Context(), subject.UserID, strings.TrimSpace(req.TotpCode)); err != nil {
response.ErrorFrom(c, err)
return
}
uid := subject.UserID
role, _ := middleware.GetUserRoleFromContext(c)
trace := &service.AuditLog{
ActorUserID: &uid,
ActorEmail: c.GetString(middleware.ContextKeyAuthEmail),
ActorRole: role,
AuthMethod: c.GetString("auth_method"),
CredentialMasked: middleware.MaskedRequestCredential(c),
Method: http.MethodPost,
Path: c.FullPath(),
ClientIP: middleware.SecurityClientIP(c),
UserAgent: c.Request.UserAgent(),
StatusCode: http.StatusOK,
}
if requestID := c.Writer.Header().Get("X-Request-ID"); requestID != "" {
trace.RequestID = requestID
}
deleted, err := h.auditService.ClearAll(c.Request.Context(), trace)
if err != nil {
response.ErrorFrom(c, err)
return
}
// 留痕记录已同步落库,跳过异步审计中间件的重复记录。
middleware.SkipAudit(c)
response.Success(c, gin.H{"deleted": deleted})
}
@@ -10,12 +10,14 @@ import (
type BackupHandler struct {
backupService *service.BackupService
userService *service.UserService
imageStorage *service.ImageStorageSettingService
}
func NewBackupHandler(backupService *service.BackupService, userService *service.UserService) *BackupHandler {
func NewBackupHandler(backupService *service.BackupService, userService *service.UserService, imageStorage *service.ImageStorageSettingService) *BackupHandler {
return &BackupHandler{
backupService: backupService,
userService: userService,
imageStorage: imageStorage,
}
}
@@ -203,3 +205,48 @@ func (h *BackupHandler) RestoreBackup(c *gin.Context) {
}
response.Accepted(c, record)
}
// ─── 异步生图对象存储配置 ───
//
// 与备份共用一套 S3 客户端构造,因此放在同一个页面下:勾选"复用备份 S3"即可直接
// 借用备份已配置的端点与密钥,只用不同的前缀区分对象(备份走 backups/,图片走 images/)。
func (h *BackupHandler) GetImageStorageConfig(c *gin.Context) {
ctx := c.Request.Context()
cfg, err := h.imageStorage.Get(ctx)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{
"config": cfg,
"secret_configured": h.imageStorage.SecretConfigured(ctx),
})
}
func (h *BackupHandler) UpdateImageStorageConfig(c *gin.Context) {
var req service.ImageStorageSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
cfg, err := h.imageStorage.Update(c.Request.Context(), req)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, cfg)
}
func (h *BackupHandler) TestImageStorageConnection(c *gin.Context) {
var req service.ImageStorageSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.imageStorage.TestConnection(c.Request.Context(), req); err != nil {
response.Success(c, gin.H{"ok": false, "message": err.Error()})
return
}
response.Success(c, gin.H{"ok": true, "message": "connection successful"})
}
@@ -64,6 +64,7 @@ type channelModelPricingRequest struct {
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
@@ -115,6 +116,7 @@ type channelModelPricingResponse struct {
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
@@ -222,6 +224,7 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheReadPrice: p.CacheReadPrice,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
@@ -273,6 +276,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
OutputPrice: r.OutputPrice,
CacheWritePrice: r.CacheWritePrice,
CacheReadPrice: r.CacheReadPrice,
ImageInputPrice: r.ImageInputPrice,
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
@@ -498,6 +502,7 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
"output_price": pricing.OutputPricePerToken,
"cache_write_price": pricing.CacheCreationPricePerToken,
"cache_read_price": pricing.CacheReadPricePerToken,
"image_input_price": pricing.ImageInputPricePerToken,
"image_output_price": pricing.ImageOutputPricePerToken,
})
}
@@ -0,0 +1,170 @@
//go:build unit
package admin
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type duplicateChannelMonitorHandlerRepoStub struct {
service.ChannelMonitorRepository
source *service.ChannelMonitor
byOperation map[string]*service.ChannelMonitor
createCalls int
}
func (r *duplicateChannelMonitorHandlerRepoStub) GetByID(_ context.Context, id int64) (*service.ChannelMonitor, error) {
if r.source == nil || r.source.ID != id {
return nil, service.ErrChannelMonitorNotFound
}
return r.source, nil
}
func (r *duplicateChannelMonitorHandlerRepoStub) Create(_ context.Context, monitor *service.ChannelMonitor) error {
r.createCalls++
monitor.ID = int64(100 + r.createCalls)
stored := *monitor
if stored.DuplicateOperationID != "" {
if r.byOperation == nil {
r.byOperation = make(map[string]*service.ChannelMonitor)
}
r.byOperation[stored.DuplicateOperationID] = &stored
}
return nil
}
func (r *duplicateChannelMonitorHandlerRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*service.ChannelMonitor, error) {
monitor := r.byOperation[operationID]
if monitor == nil {
return nil, nil
}
cloned := *monitor
return &cloned, nil
}
type duplicateChannelMonitorHandlerEncryptor struct{}
func (duplicateChannelMonitorHandlerEncryptor) Encrypt(plaintext string) (string, error) {
return "ENC:" + plaintext, nil
}
func (duplicateChannelMonitorHandlerEncryptor) Decrypt(ciphertext string) (string, error) {
return strings.TrimPrefix(ciphertext, "ENC:"), nil
}
func setupDuplicateChannelMonitorRouter(t *testing.T) (*gin.Engine, *duplicateChannelMonitorHandlerRepoStub) {
t.Helper()
previousCoordinator := service.DefaultIdempotencyCoordinator()
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(
newMemoryIdempotencyRepoStub(),
service.DefaultIdempotencyConfig(),
))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
repo := &duplicateChannelMonitorHandlerRepoStub{
source: &service.ChannelMonitor{
ID: 42,
Name: "primary",
Provider: service.MonitorProviderOpenAI,
APIMode: service.MonitorAPIModeResponses,
Endpoint: "https://api.example.com",
APIKey: "ENC:top-secret",
PrimaryModel: "gpt-5.4-mini",
ExtraModels: []string{"gpt-5.4"},
Enabled: true,
IntervalSeconds: 60,
BodyOverrideMode: service.MonitorBodyOverrideModeOff,
},
}
monitorService := service.NewChannelMonitorService(repo, duplicateChannelMonitorHandlerEncryptor{})
handler := NewChannelMonitorHandler(monitorService)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
c.Next()
})
router.POST("/api/v1/admin/channel-monitors/:id/duplicate", handler.Duplicate)
return router, repo
}
func TestDuplicateChannelMonitorHandlerRedactsKeyAndReplaysRetry(t *testing.T) {
router, repo := setupDuplicateChannelMonitorRouter(t)
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42")
first := httptest.NewRecorder()
router.ServeHTTP(first, request)
require.Equal(t, http.StatusOK, first.Code)
require.Equal(t, 1, repo.createCalls)
require.Contains(t, first.Body.String(), `"name":"primary (Copy)"`)
require.Contains(t, first.Body.String(), `"api_key_masked":"top-***"`)
require.Contains(t, first.Body.String(), `"created_by":77`)
require.Contains(t, first.Body.String(), `"enabled":false`)
require.NotContains(t, first.Body.String(), "top-secret")
retryRequest := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
retryRequest.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42")
retry := httptest.NewRecorder()
router.ServeHTTP(retry, retryRequest)
require.Equal(t, http.StatusOK, retry.Code)
require.Equal(t, "true", retry.Header().Get("X-Idempotency-Replayed"))
require.Equal(t, 1, repo.createCalls)
require.JSONEq(t, first.Body.String(), retry.Body.String())
}
func TestDuplicateChannelMonitorHandlerRejectsInvalidID(t *testing.T) {
router, repo := setupDuplicateChannelMonitorRouter(t)
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/not-a-number/duplicate", nil)
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-invalid")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Zero(t, repo.createCalls)
}
func TestDuplicateChannelMonitorHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
router, repo := setupDuplicateChannelMonitorRouter(t)
idempotencyRepo := &failOnceMarkSucceededRepo{
memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(),
failNext: true,
}
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(
idempotencyRepo,
service.DefaultIdempotencyConfig(),
))
call := func() *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-recovery")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
return recorder
}
first := call()
second := call()
require.Equal(t, http.StatusOK, first.Code)
require.Equal(t, http.StatusOK, second.Code)
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
require.Equal(t, 1, repo.createCalls, "ambiguous retries must not repeat the create side effect")
require.Contains(t, second.Body.String(), `"id":101`)
require.Contains(t, second.Body.String(), `"api_key_masked":"top-***"`)
require.NotContains(t, second.Body.String(), "top-secret")
}
@@ -1,6 +1,8 @@
package admin
import (
"context"
"log/slog"
"strconv"
"strings"
"time"
@@ -333,6 +335,63 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
response.Created(c, channelMonitorToResponse(m))
}
// Duplicate POST /api/v1/admin/channel-monitors/:id/duplicate
func (h *ChannelMonitorHandler) Duplicate(c *gin.Context) {
id, ok := ParseChannelMonitorID(c)
if !ok {
return
}
subject, _ := middleware2.GetAuthSubjectFromContext(c)
actorScope := adminActorScope(c)
result, err := executeAdminIdempotent(
c,
"admin.channel_monitors.duplicate",
struct {
MonitorID int64 `json:"monitor_id"`
}{MonitorID: id},
service.DefaultWriteIdempotencyTTL(),
func(ctx context.Context) (any, error) {
monitor, err := h.monitorService.Duplicate(
ctx,
id,
subject.UserID,
actorScope,
c.GetHeader("Idempotency-Key"),
)
if err != nil {
return nil, err
}
return channelMonitorToResponse(monitor), nil
},
)
if err != nil {
reason := infraerrors.Reason(err)
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
recovered, recoverErr := h.monitorService.RecoverDuplicate(
c.Request.Context(),
id,
actorScope,
c.GetHeader("Idempotency-Key"),
)
if recoverErr != nil {
slog.Warn("channel_monitor_duplicate_recovery_failed", "monitor_id", id, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
} else if recovered != nil {
c.Header("X-Idempotency-Recovered", "true")
response.Success(c, channelMonitorToResponse(recovered))
return
}
}
response.ErrorFrom(c, err)
return
}
if result != nil && result.Replayed {
c.Header("X-Idempotency-Replayed", "true")
}
response.Success(c, result.Data)
}
// Update PUT /api/v1/admin/channel-monitors/:id
func (h *ChannelMonitorHandler) Update(c *gin.Context) {
id, ok := ParseChannelMonitorID(c)
@@ -15,12 +15,12 @@ const (
grokImportProbeTimeout = 25 * time.Second
)
type grokUsageProber interface {
ProbeUsage(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error)
type grokImportProber interface {
QueryQuota(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error)
}
type grokImportProbeTask struct {
prober grokUsageProber
prober grokImportProber
accountID int64
}
@@ -51,7 +51,7 @@ func newGrokImportProbeScheduler(concurrency int, timeout time.Duration) *grokIm
}
}
func (s *grokImportProbeScheduler) schedule(prober grokUsageProber, account *service.Account) {
func (s *grokImportProbeScheduler) schedule(prober grokImportProber, account *service.Account) {
if s == nil || prober == nil || account == nil || account.ID <= 0 {
return
}
@@ -97,7 +97,7 @@ func (s *grokImportProbeScheduler) nextTask() (grokImportProbeTask, bool) {
return task, true
}
func (s *grokImportProbeScheduler) run(prober grokUsageProber, accountID int64) {
func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64) {
defer func() {
if recovered := recover(); recovered != nil {
slog.Error(
@@ -112,7 +112,7 @@ func (s *grokImportProbeScheduler) run(prober grokUsageProber, accountID int64)
// while this timeout only bounds the actual upstream probe execution.
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
defer cancel()
result, err := prober.ProbeUsage(ctx, accountID)
result, err := prober.QueryQuota(ctx, accountID)
if err != nil {
slog.Warn(
"grok_import_active_probe_failed",
@@ -36,7 +36,7 @@ func newGrokImportProbeStub(buffer int) *grokImportProbeStub {
}
}
func (s *grokImportProbeStub) ProbeUsage(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error) {
func (s *grokImportProbeStub) QueryQuota(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error) {
_, deadlineSeen := ctx.Deadline()
s.mu.Lock()
s.calls[accountID]++
@@ -69,7 +69,7 @@ func (s *grokImportProbeStub) ProbeUsage(ctx context.Context, accountID int64) (
return nil, failure
}
return &service.GrokQuotaProbeResult{
Source: "active_probe",
Source: "hybrid_probe",
Model: "grok-4.5",
StatusCode: 200,
ResetSupported: false,
@@ -23,7 +23,7 @@ type GrokOAuthHandler struct {
grokOAuthService *service.GrokOAuthService
adminService service.AdminService
quotaService *service.GrokQuotaService
importProber grokUsageProber
importProber grokImportProber
reconciler service.GrokOAuthReconciler
}
@@ -373,8 +373,7 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Error: grokSSOImportErrorMessage(err)}}
}
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
credentials = service.MergeCredentials(cloneGrokSSOMap(req.Credentials), credentials)
credentials := grokSSOImportCredentials(h.grokOAuthService.BuildAccountCredentials(tokenInfo), req.Credentials)
name := grokSSOImportAccountName(req.Name, tokenInfo, index, total)
expiresAt, autoPauseOnExpired := grokSSOImportExpiry(req.ExpiresAt, req.AutoPauseOnExpired, tokenInfo)
account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
@@ -408,6 +407,18 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
}
}
// grokSSOImportCredentials 合并 SSO 兑换出的凭据与导入请求携带的运营侧配置。
// token 字段以 BuildAccountCredentials 为准(请求不可覆盖);但 base_url 是运营侧
// 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与
// RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。
func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any {
credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built)
if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" {
credentials["base_url"] = strings.TrimSpace(reqBaseURL)
}
return credentials
}
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
if tokenInfo == nil || strings.TrimSpace(tokenInfo.RefreshToken) != "" || tokenInfo.ExpiresAt <= 0 {
return requestExpiresAt, requestAutoPause
@@ -113,7 +113,7 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
},
}}
upstream := &grokQuotaHandlerUpstream{}
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream)
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream, nil)
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
router := gin.New()
@@ -136,8 +136,12 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
for i, upstreamReq := range requests {
require.Equal(t, "Bearer access-token", upstreamReq.Header.Get("Authorization"))
if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/responses" {
require.Equal(t, "application/json, text/event-stream", upstreamReq.Header.Get("Accept"))
require.Contains(t, string(bodies[i]), `"model":"grok-4.5"`)
require.Contains(t, string(bodies[i]), `"store":false`)
require.Contains(t, string(bodies[i]), `"input":"hi"`)
require.Contains(t, string(bodies[i]), `"stream":true`)
require.NotContains(t, string(bodies[i]), `"max_output_tokens"`)
require.NotContains(t, string(bodies[i]), `"store"`)
}
}
require.NotNil(t, repo.updates[42])
@@ -151,7 +155,7 @@ func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
}}
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil)
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil, nil)
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
router := gin.New()
@@ -223,6 +227,45 @@ func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing.
require.Same(t, &requestedAutoPause, autoPause)
}
func TestGrokSSOImportCredentialsPreservesRequestedBaseURL(t *testing.T) {
built := map[string]any{
"access_token": "at-1",
"base_url": xai.DefaultCLIBaseURL,
}
reqCredentials := map[string]any{
"base_url": "https://relay.example.com/v1",
"header_override_enabled": true,
"header_overrides": map[string]any{"x-relay-key": "k"},
}
credentials := grokSSOImportCredentials(built, reqCredentials)
// token 字段以兑换结果为准;base_url 是运营侧配置,必须保留请求里的自定义地址
require.Equal(t, "at-1", credentials["access_token"])
require.Equal(t, "https://relay.example.com/v1", credentials["base_url"])
require.Equal(t, true, credentials["header_override_enabled"])
require.Equal(t, map[string]any{"x-relay-key": "k"}, credentials["header_overrides"])
// 入参不被污染(req.Credentials 会被多个 worker 并发读取)
require.Equal(t, "https://relay.example.com/v1", reqCredentials["base_url"])
}
func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) {
built := map[string]any{
"access_token": "at-1",
"base_url": xai.DefaultCLIBaseURL,
}
credentials := grokSSOImportCredentials(built, nil)
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
credentials = grokSSOImportCredentials(map[string]any{
"access_token": "at-2",
"base_url": xai.DefaultCLIBaseURL,
}, map[string]any{"base_url": " "})
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
require.Equal(t, "at-2", credentials["access_token"])
}
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
h := &GrokOAuthHandler{}
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
@@ -2,12 +2,15 @@ package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -361,6 +364,53 @@ func (h *GroupHandler) Create(c *gin.Context) {
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// Duplicate handles creating an inactive group copy with the source account bindings.
// POST /api/v1/admin/groups/:id/duplicate
func (h *GroupHandler) Duplicate(c *gin.Context) {
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || groupID <= 0 {
response.BadRequest(c, "Invalid group ID")
return
}
actorScope := adminActorScope(c)
result, err := executeAdminIdempotent(
c,
"admin.groups.duplicate",
struct {
GroupID int64 `json:"group_id"`
}{GroupID: groupID},
service.DefaultWriteIdempotencyTTL(),
func(ctx context.Context) (any, error) {
group, execErr := h.adminService.DuplicateGroup(ctx, groupID, actorScope, c.GetHeader("Idempotency-Key"))
if execErr != nil {
return nil, execErr
}
return dto.GroupFromServiceAdmin(group), nil
},
)
if err != nil {
reason := infraerrors.Reason(err)
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
recovered, recoverErr := h.adminService.RecoverDuplicateGroup(c.Request.Context(), groupID, actorScope, c.GetHeader("Idempotency-Key"))
if recoverErr != nil {
slog.Warn("group_duplicate_recovery_failed", "group_id", groupID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
} else if recovered != nil {
c.Header("X-Idempotency-Recovered", "true")
response.Success(c, dto.GroupFromServiceAdmin(recovered))
return
}
}
response.ErrorFrom(c, err)
return
}
if result != nil && result.Replayed {
c.Header("X-Idempotency-Replayed", "true")
}
response.Success(c, result.Data)
}
// Update handles updating a group
// PUT /api/v1/admin/groups/:id
func (h *GroupHandler) Update(c *gin.Context) {
@@ -0,0 +1,163 @@
//go:build unit
package admin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type duplicateGroupAdminServiceStub struct {
service.AdminService
group *service.Group
calls int
recoverCalls int
groupID int64
actorScope string
operationKey string
recoverScope string
recoverKey string
created bool
}
func (s *duplicateGroupAdminServiceStub) DuplicateGroup(_ context.Context, groupID int64, actorScope, operationKey string) (*service.Group, error) {
s.calls++
s.groupID = groupID
s.actorScope = actorScope
s.operationKey = operationKey
s.created = true
return s.group, nil
}
func (s *duplicateGroupAdminServiceStub) RecoverDuplicateGroup(_ context.Context, _ int64, actorScope, operationKey string) (*service.Group, error) {
s.recoverCalls++
s.recoverScope = actorScope
s.recoverKey = operationKey
if !s.created {
return nil, nil
}
return s.group, nil
}
func setupDuplicateGroupRouter(t *testing.T, svc service.AdminService) *gin.Engine {
t.Helper()
previousCoordinator := service.DefaultIdempotencyCoordinator()
service.SetDefaultIdempotencyCoordinator(nil)
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
c.Next()
})
handler := NewGroupHandler(svc, nil, nil)
router.POST("/api/v1/admin/groups/:id/duplicate", handler.Duplicate)
return router
}
func duplicateGroupHandlerFixture() *service.Group {
return &service.Group{
ID: 43,
Name: "primary (Copy)",
Platform: service.PlatformAnthropic,
Status: "inactive",
RateMultiplier: 1,
AccountCount: 3,
ActiveAccountCount: 2,
DuplicateOperationID: "internal-operation-must-not-leak",
ModelRouting: map[string][]int64{"claude-*": {7}},
}
}
func TestDuplicateGroupHandlerReturnsAdminDTOWithoutOperationMetadata(t *testing.T) {
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
router := setupDuplicateGroupRouter(t, svc)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, 1, svc.calls)
require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
require.Contains(t, recorder.Body.String(), `"status":"inactive"`)
require.Contains(t, recorder.Body.String(), `"account_count":3`)
require.NotContains(t, recorder.Body.String(), "duplicate_operation_id")
require.NotContains(t, recorder.Body.String(), "internal-operation-must-not-leak")
}
func TestDuplicateGroupHandlerRejectsInvalidID(t *testing.T) {
for _, id := range []string{"not-a-number", "0", "-1"} {
t.Run(id, func(t *testing.T) {
svc := &duplicateGroupAdminServiceStub{}
router := setupDuplicateGroupRouter(t, svc)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/"+id+"/duplicate", nil)
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Zero(t, svc.calls)
})
}
}
func TestDuplicateGroupHandlerReplaysSameIdempotencyKey(t *testing.T) {
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
router := setupDuplicateGroupRouter(t, svc)
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
call := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
request.Header.Set("Idempotency-Key", "duplicate-group-42")
router.ServeHTTP(recorder, request)
return recorder
}
first := call()
second := call()
require.Equal(t, http.StatusOK, first.Code)
require.Equal(t, http.StatusOK, second.Code)
require.Equal(t, 1, svc.calls)
require.Equal(t, int64(42), svc.groupID)
require.Equal(t, "admin:77", svc.actorScope)
require.Equal(t, "duplicate-group-42", svc.operationKey)
require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
}
func TestDuplicateGroupHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
router := setupDuplicateGroupRouter(t, svc)
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
call := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
request.Header.Set("Idempotency-Key", "duplicate-group-42-recovery")
router.ServeHTTP(recorder, request)
return recorder
}
first := call()
second := call()
require.Equal(t, http.StatusOK, first.Code)
require.Equal(t, http.StatusOK, second.Code)
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
require.Equal(t, 2, svc.recoverCalls)
require.Equal(t, "admin:77", svc.recoverScope)
require.Equal(t, "duplicate-group-42-recovery", svc.recoverKey)
}
@@ -0,0 +1,21 @@
package admin
import (
"net/http"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// GetAuthCacheInvalidationHealth exposes durable outbox lag and subscriber health.
func (h *OpsHandler) GetAuthCacheInvalidationHealth(c *gin.Context) {
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, h.opsService.GetAuthCacheInvalidationHealth(c.Request.Context()))
}
@@ -0,0 +1,131 @@
package admin
import (
"net/http"
"net/netip"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
var ingressRejectReasons = map[string]struct{}{
"query_api_key_deprecated": {}, "api_key_required": {}, "invalid_api_key": {},
"invalid_auth_rate_limited": {},
"api_key_auth_overloaded": {},
"api_key_disabled": {}, "ip_restricted": {}, "user_inactive": {}, "group_deleted": {},
"group_disabled": {}, "group_not_allowed": {}, "group_unassigned": {}, "other": {},
}
var ingressRejectRouteFamilies = map[string]struct{}{
"antigravity": {}, "gemini": {}, "codex": {}, "messages": {}, "responses": {},
"chat_completions": {}, "images": {}, "videos": {}, "embeddings": {}, "models": {}, "other": {},
}
var ingressRejectProtocols = map[string]struct{}{
"google": {}, "anthropic": {}, "openai": {}, "gateway": {}, "other": {},
}
// ListIngressRejects returns bounded security aggregates, never raw credentials or request bodies.
func (h *OpsHandler) ListIngressRejects(c *gin.Context) {
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
page, pageSize := response.ParsePagination(c)
if pageSize > 200 {
pageSize = 200
}
startTime, endTime, err := parseOpsTimeRange(c, "1h")
if err != nil {
response.BadRequest(c, err.Error())
return
}
filter := &service.OpsIngressRejectFilter{Page: page, PageSize: pageSize}
if !startTime.IsZero() {
filter.StartTime = &startTime
}
if !endTime.IsZero() {
filter.EndTime = &endTime
}
if filter.RejectReason, err = parseIngressRejectEnum(c, "reason", ingressRejectReasons); err != nil {
response.BadRequest(c, err.Error())
return
}
if filter.RouteFamily, err = parseIngressRejectEnum(c, "route_family", ingressRejectRouteFamilies); err != nil {
response.BadRequest(c, err.Error())
return
}
if filter.Protocol, err = parseIngressRejectEnum(c, "protocol", ingressRejectProtocols); err != nil {
response.BadRequest(c, err.Error())
return
}
if raw := strings.TrimSpace(c.Query("client_ip")); raw != "" {
addr, parseErr := netip.ParseAddr(raw)
if parseErr != nil {
response.BadRequest(c, "Invalid client_ip")
return
}
addr = addr.Unmap()
if addr.Is6() {
addr = netip.PrefixFrom(addr, 64).Masked().Addr()
}
filter.ClientIP = addr.String()
}
if filter.UserID, err = parseOptionalPositiveID(c, "user_id"); err != nil {
response.BadRequest(c, err.Error())
return
}
if filter.APIKeyID, err = parseOptionalPositiveID(c, "api_key_id"); err != nil {
response.BadRequest(c, err.Error())
return
}
result, err := h.opsService.ListIngressRejects(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *OpsHandler) GetIngressRejectHealth(c *gin.Context) {
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, h.opsService.GetIngressRejectHealth())
}
func parseIngressRejectEnum(c *gin.Context, name string, allowed map[string]struct{}) (string, error) {
value := strings.TrimSpace(c.Query(name))
if value == "" {
return "", nil
}
if _, ok := allowed[value]; !ok {
return "", &ingressRejectQueryError{message: "Invalid " + name}
}
return value, nil
}
func parseOptionalPositiveID(c *gin.Context, name string) (*int64, error) {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return nil, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value <= 0 {
return nil, &ingressRejectQueryError{message: "Invalid " + name}
}
return &value, nil
}
type ingressRejectQueryError struct{ message string }
func (e *ingressRejectQueryError) Error() string { return e.message }
@@ -0,0 +1,41 @@
package admin
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newIngressRejectHandlerForTest() *OpsHandler {
return NewOpsHandler(service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil))
}
func TestListIngressRejectsValidatesFilters(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, path := range []string{
"/api/v1/admin/ops/ingress-rejections?reason=not-valid",
"/api/v1/admin/ops/ingress-rejections?client_ip=not-an-ip",
"/api/v1/admin/ops/ingress-rejections?user_id=0",
"/api/v1/admin/ops/ingress-rejections?api_key_id=-1",
} {
t.Run(path, func(t *testing.T) {
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = httptest.NewRequest(http.MethodGet, path, nil)
newIngressRejectHandlerForTest().ListIngressRejects(context)
require.Equal(t, http.StatusBadRequest, context.Writer.Status())
})
}
}
func TestGetIngressRejectHealthAvailable(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/ops/ingress-rejections/health", nil)
newIngressRejectHandlerForTest().GetIngressRejectHealth(context)
require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"capacity":8192`)
}
@@ -59,6 +59,8 @@ type SettingHandler struct {
paymentService *service.PaymentService
userAttributeService *service.UserAttributeService
notificationEmailService *service.NotificationEmailService
totpService *service.TotpService
userService *service.UserService
}
// NewSettingHandler 创建系统设置处理器
@@ -80,6 +82,15 @@ func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *s
h.notificationEmailService = notificationEmailService
}
// SetStepUpDeps attaches the services backing the step-up switch preconditions
// (enable requires the acting admin to have TOTP enabled; disable is itself a
// step-up gated operation), without changing the constructor signature used by
// existing unit tests.
func (h *SettingHandler) SetStepUpDeps(totpService *service.TotpService, userService *service.UserService) {
h.totpService = totpService
h.userService = userService
}
// GetSettings 获取所有系统设置
// GET /api/v1/admin/settings
func (h *SettingHandler) GetSettings(c *gin.Context) {
@@ -123,6 +134,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
SessionBindingEnabled: settings.SessionBindingEnabled,
StepUpEnabled: settings.StepUpEnabled,
AuditLogRetentionDays: settings.AuditLogRetentionDays,
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
@@ -138,6 +152,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
TurnstileSiteKey: settings.TurnstileSiteKey,
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP,
ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders,
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
@@ -229,6 +244,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap,
AdminRechargeRebateEnabled: settings.AdminRechargeRebateEnabled,
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: settings.EnableModelFallback,
@@ -268,6 +284,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled,
PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled,
OpenAILowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled,
OpenAIOAuthSchedulingRateMultiplier: settings.OpenAIOAuthSchedulingRateMultiplier,
OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled,
OpenAIAdvancedSchedulerStickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
@@ -279,6 +297,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
OpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT,
OpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset,
OpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
OpenAIAdvancedSchedulerWeightUpstreamCost: settings.OpenAIAdvancedSchedulerWeightUpstreamCost,
OpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse,
OpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky,
OpenAIAdvancedSchedulerEffectiveLBTopK: settings.OpenAIAdvancedSchedulerEffectiveLBTopK,
@@ -289,6 +308,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
OpenAIAdvancedSchedulerEffectiveWeightTTFT: settings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
OpenAIAdvancedSchedulerEffectiveWeightReset: settings.OpenAIAdvancedSchedulerEffectiveWeightReset,
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost: settings.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost,
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: settings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
@@ -56,6 +56,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.TotpEnabled != after.TotpEnabled {
changed = append(changed, "totp_enabled")
}
if before.SessionBindingEnabled != after.SessionBindingEnabled {
changed = append(changed, "session_binding_enabled")
}
if before.StepUpEnabled != after.StepUpEnabled {
changed = append(changed, "step_up_enabled")
}
if before.LoginAgreementEnabled != after.LoginAgreementEnabled {
changed = append(changed, "login_agreement_enabled")
}
@@ -101,6 +107,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP {
changed = append(changed, "api_key_acl_trust_forwarded_ip")
}
if !equalStringSlice(before.ForwardedClientIPHeaders, after.ForwardedClientIPHeaders) {
changed = append(changed, "forwarded_client_ip_headers")
}
if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled {
changed = append(changed, "linuxdo_connect_enabled")
}
@@ -308,6 +317,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap {
changed = append(changed, "affiliate_rebate_per_invitee_cap")
}
if before.AdminRechargeRebateEnabled != after.AdminRechargeRebateEnabled {
changed = append(changed, "affiliate_admin_recharge_enabled")
}
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
changed = append(changed, "default_subscriptions")
}
@@ -437,6 +449,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.PaymentVisibleMethodWxpayEnabled != after.PaymentVisibleMethodWxpayEnabled {
changed = append(changed, "payment_visible_method_wxpay_enabled")
}
if before.OpenAILowUpstreamRatePriorityEnabled != after.OpenAILowUpstreamRatePriorityEnabled {
changed = append(changed, "openai_low_upstream_rate_priority_enabled")
}
if before.OpenAIOAuthSchedulingRateMultiplier != after.OpenAIOAuthSchedulingRateMultiplier {
changed = append(changed, "openai_oauth_scheduling_rate_multiplier")
}
if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled {
changed = append(changed, "openai_advanced_scheduler_enabled")
}
@@ -470,6 +488,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.OpenAIAdvancedSchedulerWeightQuotaHeadroom != after.OpenAIAdvancedSchedulerWeightQuotaHeadroom {
changed = append(changed, "openai_advanced_scheduler_weight_quota_headroom")
}
if before.OpenAIAdvancedSchedulerWeightUpstreamCost != after.OpenAIAdvancedSchedulerWeightUpstreamCost {
changed = append(changed, "openai_advanced_scheduler_weight_upstream_cost")
}
if before.OpenAIAdvancedSchedulerWeightPreviousResponse != after.OpenAIAdvancedSchedulerWeightPreviousResponse {
changed = append(changed, "openai_advanced_scheduler_weight_previous_response")
}
@@ -223,6 +223,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
"payment_visible_method_alipay_enabled": true,
"payment_visible_method_wxpay_enabled": false,
"openai_advanced_scheduler_enabled": true,
"openai_oauth_scheduling_rate_multiplier": 0.05,
"openai_advanced_scheduler_subscription_priority_enabled": true,
}
rawBody, err := json.Marshal(body)
@@ -241,6 +242,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled])
require.Equal(t, "false", repo.values[service.SettingPaymentVisibleMethodWxpayEnabled])
require.Equal(t, "true", repo.values["openai_advanced_scheduler_enabled"])
require.Equal(t, "0.05", repo.values[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier])
require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled])
var resp response.Response
@@ -252,6 +254,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
require.Equal(t, true, data["payment_visible_method_alipay_enabled"])
require.Equal(t, false, data["payment_visible_method_wxpay_enabled"])
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
require.Equal(t, 0.05, data["openai_oauth_scheduling_rate_multiplier"])
require.Equal(t, true, data["openai_advanced_scheduler_subscription_priority_enabled"])
}
@@ -0,0 +1,210 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// step-up 开关转换的门控测试。
// 测试环境不注入认证上下文/userService,因此一旦触发校验会以 401/403/500 中止;
// 借此区分「触发了转换校验」与「直接放行到常规保存(200)」。
func newStepUpSwitchTestHandler(t *testing.T, stored map[string]string) (*SettingHandler, *settingHandlerRepoStub) {
t.Helper()
gin.SetMode(gin.TestMode)
repo := &settingHandlerRepoStub{values: stored}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
return NewSettingHandler(svc, nil, nil, nil, nil, nil, nil), repo
}
func doUpdateSettings(t *testing.T, h *SettingHandler, body map[string]any, prepare func(c *gin.Context)) *httptest.ResponseRecorder {
t.Helper()
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
if prepare != nil {
prepare(c)
}
h.UpdateSettings(c)
return rec
}
// 开启开关(false→true):无认证上下文时拒绝,且带专用错误标记。
func TestUpdateSettingsEnableStepUpRejectsWithoutSession(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil)
require.Equal(t, http.StatusForbidden, rec.Code)
require.Contains(t, rec.Body.String(), "STEP_UP_ENABLE_REQUIRES_TOTP")
require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled])
}
// 开启开关:admin API key(机器凭证)一律拒绝,reason 与门控保持一致便于前端分流。
func TestUpdateSettingsEnableStepUpRejectsAdminAPIKey(t *testing.T) {
h, _ := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) {
c.Set("auth_method", service.AuditAuthMethodAdminAPIKey)
})
require.Equal(t, http.StatusForbidden, rec.Code)
require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN")
}
// 开启开关:有认证会话但 userService 未注入时 fail-closed(500),不得放行。
func TestUpdateSettingsEnableStepUpFailsClosedWithoutUserService(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) {
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1})
})
require.Equal(t, http.StatusInternalServerError, rec.Code)
require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled])
}
// 关闭开关(true→false)本身是敏感操作:无认证上下文时被 step-up 门控以 401 拦截。
func TestUpdateSettingsDisableStepUpRequiresStepUp(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyStepUpEnabled: "true",
})
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil)
require.Equal(t, http.StatusUnauthorized, rec.Code)
require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
}
// 关闭开关:admin API key 被 step-up 门控以 403 拦截。
func TestUpdateSettingsDisableStepUpRejectsAdminAPIKey(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyStepUpEnabled: "true",
})
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, func(c *gin.Context) {
c.Set("auth_method", service.AuditAuthMethodAdminAPIKey)
})
require.Equal(t, http.StatusForbidden, rec.Code)
require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN")
require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
}
// 无状态转换(false→false):不触发任何转换校验,常规保存成功且默认持久化为 false。
func TestUpdateSettingsStepUpNoTransitionSkipsGate(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled])
// 会话 IP/UA 绑定默认关闭:未显式提交时持久化 false。
require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled])
}
// 保持开启(true→true):不触发转换校验,常规保存不被打断。
func TestUpdateSettingsStepUpKeepEnabledSkipsGate(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyStepUpEnabled: "true",
})
rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
}
// 省略字段=保持现值:不含 step_up_enabled/session_binding_enabled 的旧客户端全量保存
// 不得把已开启的安全开关静默重置,也不触发任何转换门控。
func TestUpdateSettingsOmittedSecuritySwitchesKeepStoredValues(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyStepUpEnabled: "true",
service.SettingKeySessionBindingEnabled: "true",
})
rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
require.Equal(t, "true", repo.values[service.SettingKeySessionBindingEnabled])
}
// 省略字段在开关本就关闭时同样保持关闭(默认值路径)。
func TestUpdateSettingsOmittedSecuritySwitchesKeepDisabled(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled])
require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled])
}
func TestUpdateSettingsForwardedClientIPHeadersOmittedPreservesAndEmptyClears(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyForwardedClientIPHeaders: `["X-Cdn-Ip","True-Client-Ip"]`,
})
preserved := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
require.Equal(t, http.StatusOK, preserved.Code)
require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
require.Contains(t, preserved.Body.String(), `"forwarded_client_ip_headers":["X-Cdn-Ip","True-Client-Ip"]`)
cleared := doUpdateSettings(t, h, map[string]any{"forwarded_client_ip_headers": []string{}}, nil)
require.Equal(t, http.StatusOK, cleared.Code)
require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
require.Contains(t, cleared.Body.String(), `"forwarded_client_ip_headers":[]`)
}
func TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted(t *testing.T) {
cfg := &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}
repo := &settingHandlerRepoStub{values: map[string]string{
service.SettingKeyAPIKeyACLTrustForwardedIP: "true",
service.SettingKeyForwardedClientIPHeaders: `{"not":"an array"}`,
}}
svc := service.NewSettingService(repo, cfg)
require.ErrorContains(t, svc.LoadForwardedClientIPSettings(context.Background()), "load forwarded client ip headers")
require.False(t, cfg.ForwardedClientIPSettings().TrustForwardedIP)
h := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "false", repo.values[service.SettingKeyAPIKeyACLTrustForwardedIP])
require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
runtimeSettings := cfg.ForwardedClientIPSettings()
require.False(t, runtimeSettings.TrustForwardedIP)
require.Empty(t, runtimeSettings.Headers)
require.Contains(t, rec.Body.String(), `"api_key_acl_trust_forwarded_ip":false`)
require.Contains(t, rec.Body.String(), `"forwarded_client_ip_headers":[]`)
}
func TestUpdateSettingsRejectsInvalidForwardedClientIPHeader(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyForwardedClientIPHeaders: `["X-Existing-IP"]`,
})
rec := doUpdateSettings(t, h, map[string]any{
"forwarded_client_ip_headers": []string{"X Invalid"},
}, nil)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.JSONEq(t, `["X-Existing-IP"]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
}
@@ -11,6 +11,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -26,7 +27,10 @@ type UpdateSettingsRequest struct {
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值)
StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值)
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
@@ -47,7 +51,8 @@ type UpdateSettingsRequest struct {
TurnstileSecretKey string `json:"turnstile_secret_key"`
// API Key IP 访问控制设置
APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"`
APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"`
ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"`
// LinuxDo Connect OAuth 登录
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
@@ -149,6 +154,7 @@ type UpdateSettingsRequest struct {
AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"`
AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"`
AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"`
AdminRechargeRebateEnabled *bool `json:"affiliate_admin_recharge_enabled"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
@@ -242,19 +248,22 @@ type UpdateSettingsRequest struct {
PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"`
// OpenAI account scheduling
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"`
OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"`
OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"`
OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"`
OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"`
OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"`
OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"`
OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"`
OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"`
OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"`
OpenAILowUpstreamRatePriorityEnabled *bool `json:"openai_low_upstream_rate_priority_enabled"`
OpenAIOAuthSchedulingRateMultiplier *float64 `json:"openai_oauth_scheduling_rate_multiplier"`
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"`
OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"`
OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"`
OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"`
OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"`
OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"`
OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"`
OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"`
OpenAIAdvancedSchedulerWeightUpstreamCost *string `json:"openai_advanced_scheduler_weight_upstream_cost"`
OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"`
OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"`
// 余额不足提醒
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
@@ -329,6 +338,41 @@ type UpdateSettingsRequest struct {
// UpdateSettings 更新系统设置
// PUT /api/v1/admin/settings
// ensureActorTotpForStepUp 校验当前操作者具备开启 step-up 门控的条件:
// 必须是真人管理员会话(admin API key 无法完成 TOTP step-up,拒绝)且本人已启用 TOTP。
// 校验失败时写入错误响应并返回 false。
func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool {
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
response.ErrorWithDetails(c, http.StatusForbidden,
"Admin API key cannot enable step-up verification; use an admin session with TOTP enabled",
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
return false
}
subject, ok := middleware.GetAuthSubjectFromContext(c)
if !ok || subject.UserID <= 0 {
response.ErrorWithDetails(c, http.StatusForbidden,
"Enabling step-up verification requires an authenticated admin session",
"STEP_UP_ENABLE_REQUIRES_TOTP", nil)
return false
}
if h.userService == nil {
response.InternalError(c, "Step-up precondition check unavailable")
return false
}
user, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return false
}
if !user.TotpEnabled {
response.ErrorWithDetails(c, http.StatusBadRequest,
"Enable two-factor authentication (TOTP) for your account before turning on step-up verification",
"STEP_UP_ENABLE_REQUIRES_TOTP", nil)
return false
}
return true
}
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
var req UpdateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -347,6 +391,38 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
return
}
// 两个安全开关的请求字段为指针:省略字段=保持现值,避免旧客户端/脚本
// 用不含新字段的全量 payload 保存设置时把安全开关静默重置。
sessionBindingEnabled := previousSettings.SessionBindingEnabled
if req.SessionBindingEnabled != nil {
sessionBindingEnabled = *req.SessionBindingEnabled
}
stepUpEnabled := previousSettings.StepUpEnabled
if req.StepUpEnabled != nil {
stepUpEnabled = *req.StepUpEnabled
}
forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...)
if req.ForwardedClientIPHeaders != nil {
forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...)
}
// 开启敏感操作 step-up 门控属自锁风险操作:仅允许本人已启用 TOTP 的管理员会话开启,
// 否则开启后操作者立即被挡在所有敏感操作之外。仅在 false→true 的开启瞬间校验,
// 保持开启状态的常规设置保存不受影响。
if stepUpEnabled && !previousSettings.StepUpEnabled {
if !h.ensureActorTotpForStepUp(c) {
return
}
}
// 关闭 step-up 门控本身就是敏感操作:防止拿到管理员会话的攻击者先关闸再执行导出/备份。
// previousSettings 已证实开关处于开启状态,使用无条件门控变体,
// 避免门控内部二次读取开关时因存储故障 fail-open(前端捕获 STEP_UP_REQUIRED 弹码重试)。
if !stepUpEnabled && previousSettings.StepUpEnabled {
if !middleware.EnforceStepUpAlways(c, h.totpService, h.userService) {
return
}
}
// 验证参数
if req.DefaultConcurrency < 1 {
req.DefaultConcurrency = 1
@@ -391,6 +467,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if affiliateRebatePerInviteeCap < 0 {
affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault
}
adminRechargeRebateEnabled := previousSettings.AdminRechargeRebateEnabled
if req.AdminRechargeRebateEnabled != nil {
adminRechargeRebateEnabled = *req.AdminRechargeRebateEnabled
}
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
if req.TableDefaultPageSize <= 0 {
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
@@ -1171,6 +1251,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
SessionBindingEnabled: sessionBindingEnabled,
StepUpEnabled: stepUpEnabled,
AuditLogRetentionDays: req.AuditLogRetentionDays,
LoginAgreementEnabled: req.LoginAgreementEnabled,
LoginAgreementMode: loginAgreementMode,
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
@@ -1191,6 +1274,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.APIKeyACLTrustForwardedIP
}(),
ForwardedClientIPHeaders: forwardedClientIPHeaders,
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
@@ -1279,6 +1363,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AffiliateRebateFreezeHours: affiliateRebateFreezeHours,
AffiliateRebateDurationDays: affiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap,
AdminRechargeRebateEnabled: adminRechargeRebateEnabled,
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: req.EnableModelFallback,
@@ -1423,6 +1508,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.PaymentVisibleMethodWxpayEnabled
}(),
OpenAILowUpstreamRatePriorityEnabled: func() bool {
if req.OpenAILowUpstreamRatePriorityEnabled != nil {
return *req.OpenAILowUpstreamRatePriorityEnabled
}
return previousSettings.OpenAILowUpstreamRatePriorityEnabled
}(),
OpenAIOAuthSchedulingRateMultiplier: func() float64 {
if req.OpenAIOAuthSchedulingRateMultiplier != nil {
return *req.OpenAIOAuthSchedulingRateMultiplier
}
return previousSettings.OpenAIOAuthSchedulingRateMultiplier
}(),
OpenAIAdvancedSchedulerEnabled: func() bool {
if req.OpenAIAdvancedSchedulerEnabled != nil {
return *req.OpenAIAdvancedSchedulerEnabled
@@ -1449,6 +1546,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OpenAIAdvancedSchedulerWeightTTFT: stringSetting(req.OpenAIAdvancedSchedulerWeightTTFT, previousSettings.OpenAIAdvancedSchedulerWeightTTFT),
OpenAIAdvancedSchedulerWeightReset: stringSetting(req.OpenAIAdvancedSchedulerWeightReset, previousSettings.OpenAIAdvancedSchedulerWeightReset),
OpenAIAdvancedSchedulerWeightQuotaHeadroom: stringSetting(req.OpenAIAdvancedSchedulerWeightQuotaHeadroom, previousSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom),
OpenAIAdvancedSchedulerWeightUpstreamCost: stringSetting(req.OpenAIAdvancedSchedulerWeightUpstreamCost, previousSettings.OpenAIAdvancedSchedulerWeightUpstreamCost),
OpenAIAdvancedSchedulerWeightPreviousResponse: stringSetting(req.OpenAIAdvancedSchedulerWeightPreviousResponse, previousSettings.OpenAIAdvancedSchedulerWeightPreviousResponse),
OpenAIAdvancedSchedulerWeightSessionSticky: stringSetting(req.OpenAIAdvancedSchedulerWeightSessionSticky, previousSettings.OpenAIAdvancedSchedulerWeightSessionSticky),
BalanceLowNotifyEnabled: func() bool {
@@ -1596,6 +1694,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
if h.opsService != nil {
h.opsService.SetMonitoringEnabled(settings.OpsMonitoringEnabled)
}
// Update OpenAI fast policy (stored under dedicated key, only when provided).
if req.OpenAIFastPolicySettings != nil {
@@ -1683,6 +1784,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
SessionBindingEnabled: updatedSettings.SessionBindingEnabled,
StepUpEnabled: updatedSettings.StepUpEnabled,
AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays,
LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
LoginAgreementMode: updatedSettings.LoginAgreementMode,
LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
@@ -1698,6 +1802,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP,
ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders,
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
@@ -1786,6 +1891,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap,
AdminRechargeRebateEnabled: updatedSettings.AdminRechargeRebateEnabled,
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
DefaultSubscriptions: updatedDefaultSubscriptions,
EnableModelFallback: updatedSettings.EnableModelFallback,
@@ -1824,6 +1930,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled,
OpenAILowUpstreamRatePriorityEnabled: updatedSettings.OpenAILowUpstreamRatePriorityEnabled,
OpenAIOAuthSchedulingRateMultiplier: updatedSettings.OpenAIOAuthSchedulingRateMultiplier,
OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled,
OpenAIAdvancedSchedulerStickyWeightedEnabled: updatedSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: updatedSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
@@ -1835,6 +1943,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OpenAIAdvancedSchedulerWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerWeightTTFT,
OpenAIAdvancedSchedulerWeightReset: updatedSettings.OpenAIAdvancedSchedulerWeightReset,
OpenAIAdvancedSchedulerWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
OpenAIAdvancedSchedulerWeightUpstreamCost: updatedSettings.OpenAIAdvancedSchedulerWeightUpstreamCost,
OpenAIAdvancedSchedulerWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerWeightPreviousResponse,
OpenAIAdvancedSchedulerWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerWeightSessionSticky,
OpenAIAdvancedSchedulerEffectiveLBTopK: updatedSettings.OpenAIAdvancedSchedulerEffectiveLBTopK,
@@ -1845,6 +1954,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OpenAIAdvancedSchedulerEffectiveWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
OpenAIAdvancedSchedulerEffectiveWeightReset: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightReset,
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost,
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
@@ -22,6 +22,27 @@ type SystemHandler struct {
lockSvc *service.SystemOperationLockService
}
// systemUpdateTimeout bounds a full in-place update or rollback: the release
// manifest fetch plus a large binary download over slow links. It must stay
// above the GitHub download client timeout (10 minutes) so the download owns
// its own deadline.
const systemUpdateTimeout = 15 * time.Minute
// systemUpdateContext detaches a long-running update/rollback from the HTTP
// request lifetime. Browsers and reverse proxies commonly abort idle requests
// after 30-60s (axios default, nginx proxy_read_timeout), which canceled
// c.Request.Context() mid-download and killed the update with
// "download failed: context canceled" (#4504). The swap keeps running after a
// client disconnect; a later retry then hits the system operation lock or
// reports "Already up to date".
func systemUpdateContext(ctx context.Context) (context.Context, context.CancelFunc) {
base := context.Background()
if ctx != nil {
base = context.WithoutCancel(ctx)
}
return context.WithTimeout(base, systemUpdateTimeout)
}
type systemUpdateService interface {
CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
PerformUpdate(ctx context.Context) error
@@ -75,9 +96,12 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
release(releaseReason, succeeded)
}()
if err := h.updateSvc.PerformUpdate(ctx); err != nil {
updateCtx, cancel := systemUpdateContext(ctx)
defer cancel()
if err := h.updateSvc.PerformUpdate(updateCtx); err != nil {
if errors.Is(err, service.ErrNoUpdateAvailable) {
info, checkErr := h.updateSvc.CheckUpdate(ctx, false)
info, checkErr := h.updateSvc.CheckUpdate(updateCtx, false)
if checkErr != nil {
releaseReason = "SYSTEM_UPDATE_FAILED"
return nil, checkErr
@@ -152,7 +176,10 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
}()
if targetVersion != "" {
err = h.updateSvc.RollbackToVersion(ctx, targetVersion)
// 指定版本回退同样要下载完整二进制,与更新一样和请求生命周期解耦。
rollbackCtx, cancel := systemUpdateContext(ctx)
defer cancel()
err = h.updateSvc.RollbackToVersion(rollbackCtx, targetVersion)
} else {
err = h.updateSvc.Rollback()
}
@@ -18,18 +18,22 @@ import (
)
type systemHandlerUpdateServiceStub struct {
performErr error
updateInfo *service.UpdateInfo
checkErr error
checkForces []bool
performCall int
rollbackCall int
rollbackToCall int
rollbackToVersions []string
rollbackToErr error
rollbackVersions []service.RollbackVersion
rollbackVersionsErr error
rollbackVersionsCall int
performErr error
updateInfo *service.UpdateInfo
checkErr error
checkForces []bool
performCall int
performCtxErr error
performHasDeadline bool
rollbackCall int
rollbackToCall int
rollbackToCtxErr error
rollbackToHasDeadline bool
rollbackToVersions []string
rollbackToErr error
rollbackVersions []service.RollbackVersion
rollbackVersionsErr error
rollbackVersionsCall int
}
func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
@@ -37,8 +41,10 @@ func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bo
return s.updateInfo, s.checkErr
}
func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error {
func (s *systemHandlerUpdateServiceStub) PerformUpdate(ctx context.Context) error {
s.performCall++
s.performCtxErr = ctx.Err()
_, s.performHasDeadline = ctx.Deadline()
return s.performErr
}
@@ -52,8 +58,10 @@ func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) (
return s.rollbackVersions, s.rollbackVersionsErr
}
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(_ context.Context, version string) error {
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(ctx context.Context, version string) error {
s.rollbackToCall++
s.rollbackToCtxErr = ctx.Err()
_, s.rollbackToHasDeadline = ctx.Deadline()
s.rollbackToVersions = append(s.rollbackToVersions, version)
return s.rollbackToErr
}
@@ -165,6 +173,55 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T
require.Equal(t, "internal error", body.Message)
}
// TestSystemHandlerPerformUpdateSurvivesClientDisconnect reproduces #4504:
// the browser or a reverse proxy (axios 30s default, nginx proxy_read_timeout
// 60s) aborts the long-running update request and cancels the request
// context. The download must keep running on a detached, bounded context
// instead of dying with "download failed: context canceled".
func TestSystemHandlerPerformUpdateSurvivesClientDisconnect(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
req = req.WithContext(canceledCtx)
req.Header.Set("Idempotency-Key", "disconnected-update")
router.ServeHTTP(rec, req)
require.Equal(t, 1, updateSvc.performCall)
require.NoError(t, updateSvc.performCtxErr,
"update must not observe the canceled request context")
require.True(t, updateSvc.performHasDeadline,
"detached update context must still be bounded by a deadline")
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
}
func TestSystemHandlerRollbackToVersionSurvivesClientDisconnect(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
strings.NewReader(`{"version":"0.1.146"}`))
req.Header.Set("Content-Type", "application/json")
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
req = req.WithContext(canceledCtx)
req.Header.Set("Idempotency-Key", "disconnected-rollback")
router.ServeHTTP(rec, req)
require.Equal(t, 1, updateSvc.rollbackToCall)
require.NoError(t, updateSvc.rollbackToCtxErr,
"versioned rollback must not observe the canceled request context")
require.True(t, updateSvc.rollbackToHasDeadline,
"detached rollback context must still be bounded by a deadline")
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
}
func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
@@ -13,6 +13,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/handler/quotaview"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -30,6 +31,9 @@ type UserHandler struct {
concurrencyService *service.ConcurrencyService
userPlatformQuotaRepo service.UserPlatformQuotaRepository // T13 admin quota view
billingCache service.BillingCache // T17/T18 缓存失效(PUT/POST 路径)
totpService *service.TotpService // 角色提升为管理员的 step-up 门控
userService *service.UserService
settingService *service.SettingService // step-up 功能开关
}
// NewUserHandler creates a new admin user handler
@@ -38,12 +42,18 @@ func NewUserHandler(
concurrencyService *service.ConcurrencyService,
userPlatformQuotaRepo service.UserPlatformQuotaRepository,
billingCache service.BillingCache,
totpService *service.TotpService,
userService *service.UserService,
settingService *service.SettingService,
) *UserHandler {
return &UserHandler{
adminService: adminService,
concurrencyService: concurrencyService,
userPlatformQuotaRepo: userPlatformQuotaRepo,
billingCache: billingCache,
totpService: totpService,
userService: userService,
settingService: settingService,
}
}
@@ -266,6 +276,13 @@ func (h *UserHandler) Create(c *gin.Context) {
return
}
// 创建管理员账号属权限敏感操作:需最近完成 step-up 2FA 验证。
if req.Role == service.RoleAdmin {
if !middleware.EnforceStepUp(c, h.totpService, h.userService, h.settingService) {
return
}
}
user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{
Email: req.Email,
Password: req.Password,
@@ -308,6 +325,21 @@ func (h *UserHandler) Update(c *gin.Context) {
return
}
// 把普通用户提升为管理员属权限敏感操作:需最近完成 step-up 2FA 验证。
// 目标已是管理员时(前端编辑表单总是携带 role)不触发,避免日常编辑被打断。
if req.Role == service.RoleAdmin {
target, err := h.adminService.GetUser(c.Request.Context(), userID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if target.Role != service.RoleAdmin {
if !middleware.EnforceStepUp(c, h.totpService, h.userService, h.settingService) {
return
}
}
}
// 使用指针类型直接传递,nil 表示未提供该字段
user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{
Email: req.Email,
@@ -577,6 +609,73 @@ func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) {
response.Success(c, gin.H{"affected": affected})
}
// BatchUpdateLimits overwrites concurrency and/or RPM limits for multiple users.
// POST /api/v1/admin/users/batch-limits
type BatchUpdateLimitsRequest struct {
UserIDs []int64 `json:"user_ids"`
All bool `json:"all"`
Concurrency *int `json:"concurrency" binding:"omitempty,min=0"`
RPMLimit *int `json:"rpm_limit" binding:"omitempty,min=0"`
}
func (h *UserHandler) BatchUpdateLimits(c *gin.Context) {
var req BatchUpdateLimitsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if req.Concurrency == nil && req.RPMLimit == nil {
response.BadRequest(c, "at least one of concurrency or rpm_limit is required")
return
}
if !req.All && len(req.UserIDs) == 0 {
response.BadRequest(c, "user_ids is required unless all=true")
return
}
if !req.All && len(req.UserIDs) > 500 {
response.BadRequest(c, "user_ids cannot exceed 500")
return
}
userIDs := req.UserIDs
if req.All {
userIDs = nil
page := 1
const pageSize = 500
for {
users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
}
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
if len(users) < pageSize {
break
}
page++
}
}
if len(userIDs) == 0 {
response.Success(c, gin.H{"affected": 0})
return
}
affected, err := h.adminService.BatchUpdateLimits(
c.Request.Context(),
userIDs,
req.Concurrency,
req.RPMLimit,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"affected": affected})
}
// GetUserPlatformQuotas GET /admin/users/:id/platform-quotas
// admin 视角:D14 lazy 归零 + 暴露 *_window_start 调试字段
func (h *UserHandler) GetUserPlatformQuotas(c *gin.Context) {
@@ -35,7 +35,7 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
UpdatedAt: lastLoginAt,
},
}
handler := NewUserHandler(adminSvc, nil, nil, nil)
handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
@@ -89,7 +89,7 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) {
UpdatedAt: lastLoginAt,
},
}
handler := NewUserHandler(adminSvc, nil, nil, nil)
handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
@@ -0,0 +1,146 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type batchLimitsAdminServiceStub struct {
*stubAdminService
calls []batchLimitsAdminServiceCall
}
type batchLimitsAdminServiceCall struct {
userIDs []int64
concurrency *int
rpmLimit *int
}
func cloneIntPointer(value *int) *int {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func (s *batchLimitsAdminServiceStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
s.calls = append(s.calls, batchLimitsAdminServiceCall{
userIDs: append([]int64(nil), userIDs...),
concurrency: cloneIntPointer(concurrency),
rpmLimit: cloneIntPointer(rpmLimit),
})
return len(userIDs), nil
}
func setupBatchLimitsRouter(serviceStub service.AdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewUserHandler(serviceStub, nil, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/users/batch-limits", handler.BatchUpdateLimits)
return router
}
func postBatchLimits(t *testing.T, router *gin.Engine, body []byte) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/admin/users/batch-limits",
bytes.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
return recorder
}
func TestUserHandlerBatchUpdateLimitsAcceptsPartialAndZeroValues(t *testing.T) {
tests := []struct {
name string
body string
expectedConcurrency *int
expectedRPMLimit *int
}{
{name: "concurrency only", body: `{"user_ids":[1,2],"concurrency":10}`, expectedConcurrency: pointerTo(10)},
{name: "both limits", body: `{"user_ids":[1,2],"concurrency":8,"rpm_limit":60}`, expectedConcurrency: pointerTo(8), expectedRPMLimit: pointerTo(60)},
{name: "explicit zero", body: `{"user_ids":[1,2],"concurrency":0,"rpm_limit":0}`, expectedConcurrency: pointerTo(0), expectedRPMLimit: pointerTo(0)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()}
recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), []byte(test.body))
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, serviceStub.calls, 1)
require.Equal(t, []int64{1, 2}, serviceStub.calls[0].userIDs)
require.Equal(t, test.expectedConcurrency, serviceStub.calls[0].concurrency)
require.Equal(t, test.expectedRPMLimit, serviceStub.calls[0].rpmLimit)
var response struct {
Data struct {
Affected int `json:"affected"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
require.Equal(t, 2, response.Data.Affected)
})
}
}
func TestUserHandlerBatchUpdateLimitsRejectsInvalidRequests(t *testing.T) {
tooManyIDs := make([]int64, 501)
for index := range tooManyIDs {
tooManyIDs[index] = int64(index + 1)
}
tooManyBody, err := json.Marshal(map[string]any{"user_ids": tooManyIDs, "rpm_limit": 10})
require.NoError(t, err)
tests := []struct {
name string
body []byte
}{
{name: "no limits", body: []byte(`{"user_ids":[1]}`)},
{name: "invalid json", body: []byte(`{"user_ids":`)},
{name: "missing user ids", body: []byte(`{"rpm_limit":10}`)},
{name: "more than 500 ids", body: tooManyBody},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()}
recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), test.body)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Empty(t, serviceStub.calls)
})
}
}
func TestUserHandlerBatchUpdateLimitsAllUsesEveryListedUser(t *testing.T) {
base := newStubAdminService()
base.users = []service.User{{ID: 11}, {ID: 12}, {ID: 13}}
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: base}
recorder := postBatchLimits(
t,
setupBatchLimitsRouter(serviceStub),
[]byte(`{"all":true,"user_ids":[999],"rpm_limit":0}`),
)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, serviceStub.calls, 1)
require.Equal(t, []int64{11, 12, 13}, serviceStub.calls[0].userIDs)
require.Equal(t, 1, base.lastListUsers.calls)
}
func pointerTo(value int) *int {
return &value
}
@@ -26,7 +26,7 @@ func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (*
func setupGetByIDRouter(svc service.AdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
h := NewUserHandler(svc, nil, nil, nil)
h := NewUserHandler(svc, nil, nil, nil, nil, nil, nil)
r.GET("/admin/users/:id", h.GetByID)
return r
}
@@ -39,7 +39,7 @@ func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
stub := &listUsersFilterStub{AdminService: newStubAdminService()}
r := gin.New()
h := NewUserHandler(stub, nil, nil, nil)
h := NewUserHandler(stub, nil, nil, nil, nil, nil, nil)
r.GET("/admin/users", h.List)
w := httptest.NewRecorder()
@@ -0,0 +1,86 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 角色提升为管理员的 step-up 门控条件测试。
// 测试环境不注入认证上下文,因此门控一旦触发会以 401 中止;
// 借此区分「触发了 step-up 校验」与「直接放行到业务层(200)」。
func setupRoleStepUpRouter(t *testing.T) (*gin.Engine, *stubAdminService) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
adminSvc := newStubAdminService()
// 追加一个已是管理员的目标用户,验证「目标已是 admin 不触发门控」。
adminSvc.users = append(adminSvc.users, service.User{
ID: 2,
Email: "admin@example.com",
Role: service.RoleAdmin,
Status: service.StatusActive,
})
h := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/users", h.Create)
router.PUT("/api/v1/admin/users/:id", h.Update)
return router, adminSvc
}
func doJSON(t *testing.T, router *gin.Engine, method, path string, payload map[string]any) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(payload)
require.NoError(t, err)
rec := httptest.NewRecorder()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
return rec
}
func TestUpdateUserPromoteToAdminRequiresStepUp(t *testing.T) {
router, _ := setupRoleStepUpRouter(t)
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "admin"})
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestUpdateUserKeepAdminRoleSkipsStepUp(t *testing.T) {
router, _ := setupRoleStepUpRouter(t)
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/2", map[string]any{"role": "admin"})
require.Equal(t, http.StatusOK, rec.Code)
}
func TestUpdateUserRegularRoleSkipsStepUp(t *testing.T) {
router, _ := setupRoleStepUpRouter(t)
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "user", "email": "u@example.com"})
require.Equal(t, http.StatusOK, rec.Code)
}
func TestCreateAdminUserRequiresStepUp(t *testing.T) {
router, _ := setupRoleStepUpRouter(t)
rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{
"email": "new-admin@example.com", "password": "pass123", "role": "admin",
})
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestCreateRegularUserSkipsStepUp(t *testing.T) {
router, _ := setupRoleStepUpRouter(t)
rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{
"email": "new-user@example.com", "password": "pass123", "role": "user",
})
require.Equal(t, http.StatusOK, rec.Code)
}
+8 -8
View File
@@ -46,14 +46,14 @@ type CreateAPIKeyRequest struct {
// UpdateAPIKeyRequest represents the update API key request payload
type UpdateAPIKeyRequest struct {
Name string `json:"name"`
GroupID *int64 `json:"group_id"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
IPWhitelist []string `json:"ip_whitelist"` // IP 白名单
IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单
Quota *float64 `json:"quota"` // 配额限制 (USD), 0=无限制
ExpiresAt *string `json:"expires_at"` // 过期时间 (ISO 8601)
ResetQuota *bool `json:"reset_quota"` // 重置已用配额
Name string `json:"name"`
GroupID *int64 `json:"group_id"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
IPWhitelist *[]string `json:"ip_whitelist"` // IP 白名单(nil 不修改,空数组清空)
IPBlacklist *[]string `json:"ip_blacklist"` // IP 黑名单(nil 不修改,空数组清空)
Quota *float64 `json:"quota"` // 配额限制 (USD), 0=无限制
ExpiresAt *string `json:"expires_at"` // 过期时间 (ISO 8601)
ResetQuota *bool `json:"reset_quota"` // 重置已用配额
// Rate limit fields (nil = no change, 0 = unlimited)
RateLimit5h *float64 `json:"rate_limit_5h"`
+1 -1
View File
@@ -107,7 +107,7 @@ func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
if err != nil {
slog.Error("failed to generate token pair", "error", err, "user_id", user.ID)
// 回退到只返回Access Token
token, tokenErr := h.authService.GenerateToken(user)
token, tokenErr := h.authService.GenerateToken(c.Request.Context(), user)
if tokenErr != nil {
response.InternalError(c, "Failed to generate token")
return
@@ -282,7 +282,7 @@ func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
Save(context.Background())
require.NoError(t, err)
token, err := handler.authService.GenerateToken(&service.User{
token, err := handler.authService.GenerateToken(context.Background(), &service.User{
ID: user.ID,
Email: user.Email,
Username: user.Username,
@@ -3168,6 +3168,9 @@ func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64,
func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
panic("unexpected BatchAddConcurrency call")
}
func (r *oauthPendingFlowUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
panic("unexpected BatchUpdateLimits call")
}
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
return map[int64]*time.Time{}, nil
@@ -3360,6 +3363,14 @@ func (s *oauthPendingFlowTotpCacheStub) ClearVerifyAttempts(_ context.Context, u
return nil
}
func (s *oauthPendingFlowTotpCacheStub) SetStepUpGrant(_ context.Context, _ int64, _ string, _ time.Duration) error {
return nil
}
func (s *oauthPendingFlowTotpCacheStub) HasStepUpGrant(_ context.Context, _ int64, _ string) (bool, error) {
return false, nil
}
type oauthPendingFlowTotpEncryptorStub struct{}
func (oauthPendingFlowTotpEncryptorStub) Encrypt(plaintext string) (string, error) {
@@ -72,6 +72,7 @@ type userSupportedModelPricing struct {
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []userPricingIntervalDTO `json:"intervals"`
@@ -284,6 +285,7 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheReadPrice: p.CacheReadPrice,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"errors"
"io"
"net/http"
@@ -20,6 +21,7 @@ type BatchImageHandler struct {
service *service.BatchImagePublicService
download *service.BatchImageDownloadService
cleanup *service.BatchImageCleanupService
openAI *OpenAIGatewayHandler
}
func NewBatchImageHandler(service *service.BatchImagePublicService, download *service.BatchImageDownloadService, cleanup *service.BatchImageCleanupService) *BatchImageHandler {
@@ -37,6 +39,9 @@ func (h *BatchImageHandler) Submit(c *gin.Context) {
batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
return
}
if !h.checkSecurityAuditBeforeSubmit(c, &req) {
return
}
got, err := h.service.Submit(c.Request.Context(), owner, req, c.GetHeader("Idempotency-Key"))
if err != nil {
batchImageError(c, err)
@@ -45,6 +50,44 @@ func (h *BatchImageHandler) Submit(c *gin.Context) {
c.JSON(http.StatusOK, got)
}
func (h *BatchImageHandler) checkSecurityAuditBeforeSubmit(c *gin.Context, req *service.BatchImageSubmitRequest) bool {
if h == nil || h.openAI == nil || req == nil {
return true
}
apiKey, ok := middleware.GetAPIKeyFromContext(c)
if !ok || apiKey == nil {
batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
return false
}
subject, ok := middleware.GetAuthSubjectFromContext(c)
if !ok {
batchImageError(c, infraerrors.New(http.StatusInternalServerError, "USER_CONTEXT_REQUIRED", "User context not found"))
return false
}
items := make([]map[string]string, 0, len(req.Items))
for _, item := range req.Items {
if prompt := strings.TrimSpace(item.Prompt); prompt != "" {
items = append(items, map[string]string{"prompt": prompt})
}
}
if len(items) == 0 {
return true
}
body, err := json.Marshal(map[string]any{"request": map[string]any{"items": items}})
if err != nil {
batchImageError(c, infraerrors.New(http.StatusBadRequest, "INVALID_BATCH_PROMPT", "batch prompts are invalid"))
return false
}
reqLog := requestLogger(c, "handler.batch_image.security_audit",
zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.String("model", req.Model))
decision := h.openAI.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, req.Model, body)
if decision != nil && !decision.AllowNextStage {
h.openAI.openAISecurityAuditError(c, decision)
return false
}
return true
}
func (h *BatchImageHandler) Get(c *gin.Context) {
owner, ok := batchImageOwnerFromContext(c)
if !ok {
@@ -0,0 +1,96 @@
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestChatCompletionsRejectsGPTImageModelsBeforeScheduling(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, model := range []string{"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} {
for _, tc := range []struct {
name string
call func(*gin.Context)
}{
{
name: "gateway",
call: (&GatewayHandler{}).ChatCompletions,
},
{
name: "openai_gateway",
call: newOpenAIImageChatRejectionHandler(t).ChatCompletions,
},
} {
t.Run(tc.name+"/"+model, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"draw"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
setImageChatTestAuth(c)
tc.call(c)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "Chat Completions")
_, selected := c.Get(opsAccountIDKey)
require.False(t, selected, "rejection must happen before account selection")
})
}
}
}
func TestOpenAIChatCompletionsImageModelRejectionDoesNotAcquireConcurrency(t *testing.T) {
var acquireCalls atomic.Int64
cache := &concurrencyCacheMock{
acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) {
acquireCalls.Add(1)
return true, nil
},
}
h := newOpenAIImageChatRejectionHandlerWithCache(t, cache)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(
`{"model":"gpt-image-2","messages":[{"role":"user","content":"draw"}]}`,
))
setImageChatTestAuth(c)
h.ChatCompletions(c)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Zero(t, acquireCalls.Load(), "rejection must happen before user/account concurrency and scheduling")
}
func newOpenAIImageChatRejectionHandler(t *testing.T) *OpenAIGatewayHandler {
t.Helper()
return newOpenAIImageChatRejectionHandlerWithCache(t, &concurrencyCacheMock{})
}
func newOpenAIImageChatRejectionHandlerWithCache(t *testing.T, cache *concurrencyCacheMock) *OpenAIGatewayHandler {
t.Helper()
return &OpenAIGatewayHandler{
gatewayService: &service.OpenAIGatewayService{},
billingCacheService: &service.BillingCacheService{},
apiKeyService: &service.APIKeyService{},
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatNone, time.Second),
}
}
func setImageChatTestAuth(c *gin.Context) {
apiKey := &service.APIKey{ID: 4348, UserID: 4348, User: &service.User{ID: 4348}}
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 1})
}
@@ -12,13 +12,6 @@ import (
"go.uber.org/zap"
)
func (h *GatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
if h == nil || h.contentModerationService == nil {
return nil
}
return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
}
func contentModerationStatus(decision *service.ContentModerationDecision) int {
if decision == nil || decision.StatusCode < 400 || decision.StatusCode > 599 {
return http.StatusForbidden
@@ -30,13 +23,6 @@ func contentModerationErrorCode(decision *service.ContentModerationDecision) str
return "content_policy_violation"
}
func (h *OpenAIGatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
if h == nil || h.contentModerationService == nil {
return nil
}
return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
}
func runContentModeration(c *gin.Context, reqLog *zap.Logger, svc *service.ContentModerationService, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
if svc == nil || c == nil || c.Request == nil {
return nil
+2
View File
@@ -634,6 +634,8 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ImageSize: l.ImageSize,
ImageInputSize: l.ImageInputSize,
ImageOutputSize: l.ImageOutputSize,
ImageInputTokens: l.ImageInputTokens,
ImageInputCost: l.ImageInputCost,
ImageOutputTokens: l.ImageOutputTokens,
ImageOutputCost: l.ImageOutputCost,
ImageSizeSource: l.ImageSizeSource,
+36 -27
View File
@@ -36,6 +36,9 @@ type SystemSettings struct {
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
@@ -49,10 +52,11 @@ type SystemSettings struct {
SMTPFromName string `json:"smtp_from_name"`
SMTPUseTLS bool `json:"smtp_use_tls"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"`
ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"`
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
@@ -148,6 +152,7 @@ type SystemSettings struct {
AffiliateRebateFreezeHours int `json:"affiliate_rebate_freeze_hours"`
AffiliateRebateDurationDays int `json:"affiliate_rebate_duration_days"`
AffiliateRebatePerInviteeCap float64 `json:"affiliate_rebate_per_invitee_cap"`
AdminRechargeRebateEnabled bool `json:"affiliate_admin_recharge_enabled"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"`
@@ -208,29 +213,33 @@ type SystemSettings struct {
PaymentVisibleMethodWxpayEnabled bool `json:"payment_visible_method_wxpay_enabled"`
// OpenAI account scheduling
OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"`
OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"`
OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"`
OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"`
OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"`
OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"`
OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"`
OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"`
OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"`
OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"`
OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"`
OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"`
OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"`
OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"`
OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"`
OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"`
OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"`
OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"`
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"`
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"`
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"`
OpenAILowUpstreamRatePriorityEnabled bool `json:"openai_low_upstream_rate_priority_enabled"`
OpenAIOAuthSchedulingRateMultiplier float64 `json:"openai_oauth_scheduling_rate_multiplier"`
OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"`
OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"`
OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"`
OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"`
OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"`
OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"`
OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"`
OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"`
OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"`
OpenAIAdvancedSchedulerWeightUpstreamCost string `json:"openai_advanced_scheduler_weight_upstream_cost"`
OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"`
OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"`
OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"`
OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"`
OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"`
OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"`
OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"`
OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"`
OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"`
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"`
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost string `json:"openai_advanced_scheduler_effective_weight_upstream_cost"`
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"`
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"`
// Payment configuration
PaymentEnabled bool `json:"payment_enabled"`
+2
View File
@@ -503,6 +503,8 @@ type UsageLog struct {
ImageSize *string `json:"image_size"`
ImageInputSize *string `json:"image_input_size"`
ImageOutputSize *string `json:"image_output_size"`
ImageInputTokens int `json:"image_input_tokens"`
ImageInputCost float64 `json:"image_input_cost"`
ImageOutputTokens int `json:"image_output_tokens"`
ImageOutputCost float64 `json:"image_output_cost"`
ImageSizeSource *string `json:"image_size_source"`
+3
View File
@@ -23,6 +23,7 @@ const (
EndpointResponsesCompact = "/v1/responses/compact"
EndpointImagesGenerations = "/v1/images/generations"
EndpointImagesEdits = "/v1/images/edits"
EndpointImageTasks = "/v1/images/tasks"
EndpointVideosGenerations = "/v1/videos/generations"
EndpointVideosEdits = "/v1/videos/edits"
EndpointVideosExtensions = "/v1/videos/extensions"
@@ -88,6 +89,8 @@ func NormalizeInboundEndpoint(path string) string {
return EndpointImagesGenerations
case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
return EndpointImagesEdits
case strings.Contains(path, EndpointImageTasks) || strings.Contains(path, "/images/tasks/"):
return EndpointImageTasks
case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"):
return EndpointVideosGenerations
case strings.Contains(path, EndpointVideosEdits) || strings.Contains(path, "/videos/edits"):
@@ -31,6 +31,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
{"/v1/responses/compact/detail", EndpointResponsesCompact},
{"/v1/images/generations", EndpointImagesGenerations},
{"/v1/images/edits", EndpointImagesEdits},
{"/v1/images/tasks/imgtask_123", EndpointImageTasks},
{"/v1/videos/generations", EndpointVideosGenerations},
{"/v1/videos/req_123", EndpointVideos},
{"/v1beta/models", EndpointGeminiModels},
@@ -52,6 +53,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
{"/responses/compact", EndpointResponsesCompact},
{"/responses/compact/detail", EndpointResponsesCompact},
{"/alpha/search", EndpointAlphaSearch},
{"/images/tasks/imgtask_123", EndpointImageTasks},
// Bare Codex direct alias route — root vs. compact.
{"/backend-api/codex/responses", EndpointResponses},
+6 -5
View File
@@ -83,8 +83,9 @@ func (s *FailoverState) HandleFailoverError(
return FailoverExhausted
}
// 缓存计费判断
if needForceCacheBilling(s.hasBoundSession, failoverErr) {
// 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。
sameAccountRetry := failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit
if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) {
s.ForceCacheBilling = true
}
@@ -174,9 +175,9 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc
}
// needForceCacheBilling 判断 failover 时是否需要强制缓存计费。
// 粘性会话切换账号、或上游明确标记时,将 input_tokens 转为 cache_read 计费。
func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFailoverError) bool {
return hasBoundSession || (failoverErr != nil && failoverErr.ForceCacheBilling)
// 粘性会话实际切换账号、或上游明确标记时,将 input_tokens 转为 cache_read 计费。
func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFailoverError, sameAccountRetry bool) bool {
return (hasBoundSession && !sameAccountRetry) || (failoverErr != nil && failoverErr.ForceCacheBilling)
}
// failoverClientGone 判断下游客户端是否已断开(请求 context 已取消)。
+40 -2
View File
@@ -271,7 +271,7 @@ func TestHandleFailoverError_BasicSwitch(t *testing.T) {
// ---------------------------------------------------------------------------
func TestHandleFailoverError_CacheBilling(t *testing.T) {
t.Run("hasBoundSession为true时设置ForceCacheBilling", func(t *testing.T) {
t.Run("hasBoundSession为true且实际切换时设置ForceCacheBilling", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, true) // hasBoundSession=true
err := newTestFailoverErr(500, false, false)
@@ -280,6 +280,32 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
require.True(t, fs.ForceCacheBilling)
})
t.Run("同账号重试时仅凭hasBoundSession不设置ForceCacheBilling", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, true)
err := newTestFailoverErr(400, true, false)
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
require.False(t, fs.ForceCacheBilling)
require.Zero(t, fs.SwitchCount)
})
t.Run("同账号重试耗尽并实际切换时设置ForceCacheBilling", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, true)
err := newTestFailoverErr(400, true, false)
for i := 0; i < maxSameAccountRetries; i++ {
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
require.False(t, fs.ForceCacheBilling)
}
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
require.True(t, fs.ForceCacheBilling)
require.Equal(t, 1, fs.SwitchCount)
})
t.Run("failoverErr.ForceCacheBilling为true时设置", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, false)
@@ -289,6 +315,17 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
require.True(t, fs.ForceCacheBilling)
})
t.Run("同账号重试保留显式ForceCacheBilling", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, true)
err := newTestFailoverErr(400, true, true)
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
require.True(t, fs.ForceCacheBilling)
require.Zero(t, fs.SwitchCount)
})
t.Run("两者均为false时不设置", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, false)
@@ -633,13 +670,14 @@ func TestHandleFailoverError_IntegrationScenario(t *testing.T) {
for i := 0; i < maxSameAccountRetries; i++ {
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, retryErr)
require.Equal(t, FailoverContinue, action)
require.False(t, fs.ForceCacheBilling, "同账号重试期间不应仅因绑定会话强制缓存计费")
}
require.True(t, fs.ForceCacheBilling, "hasBoundSession=true 应设置 ForceCacheBilling")
// 2. 账号 100 超过重试上限 → TempUnschedule + 切换
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, retryErr)
require.Equal(t, FailoverContinue, action)
require.Equal(t, 1, fs.SwitchCount)
require.True(t, fs.ForceCacheBilling, "实际切换账号时应设置 ForceCacheBilling")
require.Len(t, mock.calls, 1)
// 3. 账号 200 遇到不可重试错误 → 直接切换
+79 -5
View File
@@ -25,6 +25,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -39,6 +40,7 @@ var gatewayCompatibilityMetricsLogCounter atomic.Uint64
// GatewayHandler handles API gateway requests
type GatewayHandler struct {
gatewayService *service.GatewayService
openAIGatewayService *service.OpenAIGatewayService
geminiCompatService *service.GeminiMessagesCompatService
antigravityGatewayService *service.AntigravityGatewayService
userService *service.UserService
@@ -48,6 +50,7 @@ type GatewayHandler struct {
usageRecordWorkerPool *service.UsageRecordWorkerPool
errorPassthroughService *service.ErrorPassthroughService
contentModerationService *service.ContentModerationService
securityAuditCoordinator *securityaudit.Coordinator
concurrencyHelper *ConcurrencyHelper
userMsgQueueHelper *UserMsgQueueHelper
maxAccountSwitches int
@@ -59,6 +62,7 @@ type GatewayHandler struct {
// NewGatewayHandler creates a new GatewayHandler
func NewGatewayHandler(
gatewayService *service.GatewayService,
openAIGatewayService *service.OpenAIGatewayService,
geminiCompatService *service.GeminiMessagesCompatService,
antigravityGatewayService *service.AntigravityGatewayService,
userService *service.UserService,
@@ -94,6 +98,7 @@ func NewGatewayHandler(
return &GatewayHandler{
gatewayService: gatewayService,
openAIGatewayService: openAIGatewayService,
geminiCompatService: geminiCompatService,
antigravityGatewayService: antigravityGatewayService,
userService: userService,
@@ -161,6 +166,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
return
}
body = parsedReq.Body.Bytes()
reqModel := parsedReq.Model
reqStream := parsedReq.Stream
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
@@ -196,8 +202,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
return
}
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && !decision.AllowNextStage {
h.anthropicSecurityAuditError(c, decision)
return
}
@@ -1017,7 +1023,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
}
if len(availableModels) > 0 {
writeModelsList(c, availableModels)
writeModelsList(c, platform, availableModels)
return
}
@@ -1037,6 +1043,10 @@ func (h *GatewayHandler) Models(c *gin.Context) {
})
return
}
if platform == service.PlatformGrok {
writeGrokModelsList(c, xai.DefaultModelIDs())
return
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
@@ -1044,7 +1054,11 @@ func (h *GatewayHandler) Models(c *gin.Context) {
})
}
func writeModelsList(c *gin.Context, modelIDs []string) {
func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
if platform == service.PlatformGrok {
writeGrokModelsList(c, modelIDs)
return
}
models := make([]claude.Model, 0, len(modelIDs))
for _, modelID := range modelIDs {
models = append(models, claude.Model{
@@ -1065,7 +1079,66 @@ func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) {
writeOpenAIModelsList(c, modelIDs)
return
}
writeModelsList(c, modelIDs)
writeModelsList(c, platform, modelIDs)
}
type grokReasoningEffortOption struct {
Value string `json:"value"`
Label string `json:"label"`
Default bool `json:"default,omitempty"`
}
type grokModelListItem struct {
xai.Model
SupportsReasoningEffort bool `json:"supportsReasoningEffort,omitempty"`
ReasoningEffort string `json:"reasoningEffort,omitempty"`
ReasoningEfforts []grokReasoningEffortOption `json:"reasoningEfforts,omitempty"`
}
func writeGrokModelsList(c *gin.Context, modelIDs []string) {
defaults := xai.DefaultModels()
defaultsByID := make(map[string]xai.Model, len(defaults))
for _, model := range defaults {
defaultsByID[model.ID] = model
}
models := make([]grokModelListItem, 0, len(modelIDs))
for _, modelID := range modelIDs {
model, ok := defaultsByID[modelID]
if !ok {
model = xai.Model{
ID: modelID,
Object: "model",
OwnedBy: "xai",
DisplayName: modelID,
}
}
item := grokModelListItem{Model: model}
if grokModelSupportsConfigurableReasoning(modelID) {
item.SupportsReasoningEffort = true
item.ReasoningEffort = "high"
item.ReasoningEfforts = []grokReasoningEffortOption{
{Value: "low", Label: "Low"},
{Value: "medium", Label: "Medium"},
{Value: "high", Label: "High", Default: true},
}
}
models = append(models, item)
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": models,
})
}
func grokModelSupportsConfigurableReasoning(modelID string) bool {
switch strings.ToLower(strings.TrimSpace(modelID)) {
case "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1":
return true
default:
return false
}
}
func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
@@ -1810,6 +1883,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
return
}
body = parsedReq.Body.Bytes()
// count_tokens 走 messages 严格校验时,复用已解析请求,避免二次反序列化。
SetClaudeCodeClientContext(c, body, parsedReq)
reqLog = reqLog.With(zap.String("model", parsedReq.Model), zap.Bool("stream", parsedReq.Stream))
@@ -80,6 +80,10 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
return
}
if service.IsGPTImageGenerationModel(reqModel) {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint")
return
}
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
setOpsRequestContext(c, reqModel, reqStream)
@@ -95,8 +99,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
return
}
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
h.chatCompletionsErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && !decision.AllowNextStage {
h.openAISecurityAuditError(c, decision)
return
}
@@ -85,7 +85,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
requestCtx := c.Request.Context()
if service.IsImageGenerationIntent("/v1/responses", reqModel, body) {
if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey)) {
requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx)
}
@@ -104,8 +104,8 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
return
}
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
h.responsesErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && !decision.AllowNextStage {
h.responsesSecurityAuditError(c, decision)
return
}
@@ -0,0 +1,108 @@
package handler
import (
"net/http"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const keyBillingInfoSchemaVersion = 1
type keyBillingInfoResponse struct {
Object string `json:"object"`
SchemaVersion int `json:"schema_version"`
BillingScope string `json:"billing_scope"`
GroupRateMultiplier float64 `json:"group_rate_multiplier"`
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
ResolvedRateMultiplier float64 `json:"resolved_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start,omitempty"`
PeakEnd *string `json:"peak_end,omitempty"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier,omitempty"`
AppliedPeakMultiplier *float64 `json:"applied_peak_multiplier,omitempty"`
EffectiveRateMultiplier float64 `json:"effective_rate_multiplier"`
Timezone *string `json:"timezone,omitempty"`
ObservedAt time.Time `json:"observed_at"`
}
// KeyBillingInfo returns the token billing multiplier effective for the authenticated API key.
// GET /v1/sub2api/billing
func (h *GatewayHandler) KeyBillingInfo(c *gin.Context) {
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok {
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
if h.cfg != nil && h.cfg.RunMode == config.RunModeSimple {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Billing information is not supported in simple mode")
return
}
if apiKey.GroupID == nil {
h.errorResponse(c, http.StatusForbidden, "permission_error", "API key is not assigned to a group")
return
}
if apiKey.Group == nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Billing information is unavailable")
return
}
resolvedRate, ok := h.resolveKeyBillingRate(c, apiKey)
if !ok {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Billing information is unavailable")
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, buildKeyBillingInfo(apiKey, resolvedRate, timezone.Now()))
}
func (h *GatewayHandler) resolveKeyBillingRate(c *gin.Context, apiKey *service.APIKey) (float64, bool) {
groupRate := apiKey.Group.RateMultiplier
switch apiKey.Group.Platform {
case service.PlatformOpenAI, service.PlatformGrok:
if h.openAIGatewayService == nil {
return 0, false
}
return h.openAIGatewayService.ResolveUserGroupRateMultiplier(c.Request.Context(), apiKey.UserID, *apiKey.GroupID, groupRate), true
default:
if h.gatewayService == nil {
return 0, false
}
return h.gatewayService.ResolveUserGroupRateMultiplier(c.Request.Context(), apiKey.UserID, *apiKey.GroupID, groupRate), true
}
}
func buildKeyBillingInfo(apiKey *service.APIKey, resolvedRate float64, now time.Time) keyBillingInfoResponse {
groupRate := apiKey.Group.RateMultiplier
var userRate *float64
if resolvedRate != groupRate {
userRate = &resolvedRate
}
appliedPeak := apiKey.Group.PeakMultiplierAt(now)
response := keyBillingInfoResponse{
Object: "sub2api.key_billing",
SchemaVersion: keyBillingInfoSchemaVersion,
BillingScope: "token",
GroupRateMultiplier: groupRate,
UserRateMultiplier: userRate,
ResolvedRateMultiplier: resolvedRate,
PeakRateEnabled: apiKey.Group.PeakRateEnabled,
EffectiveRateMultiplier: resolvedRate * appliedPeak,
ObservedAt: now.UTC(),
}
if apiKey.Group.PeakRateEnabled {
response.PeakStart = &apiKey.Group.PeakStart
response.PeakEnd = &apiKey.Group.PeakEnd
response.PeakRateMultiplier = &apiKey.Group.PeakRateMultiplier
response.AppliedPeakMultiplier = &appliedPeak
tz := timezone.Location().String()
response.Timezone = &tz
}
return response
}
@@ -0,0 +1,311 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type keyBillingUserGroupRateRepo struct {
service.UserGroupRateRepository
rate *float64
err error
gotUserID int64
gotGroupID int64
lookupCalls int
}
func (r *keyBillingUserGroupRateRepo) GetByUserAndGroup(_ context.Context, userID, groupID int64) (*float64, error) {
r.gotUserID = userID
r.gotGroupID = groupID
r.lookupCalls++
return r.rate, r.err
}
func newKeyBillingHandler(repo service.UserGroupRateRepository) *GatewayHandler {
return &GatewayHandler{
gatewayService: newKeyBillingGatewayService(repo),
openAIGatewayService: newKeyBillingOpenAIGatewayService(repo),
}
}
func newKeyBillingGatewayService(repo service.UserGroupRateRepository) *service.GatewayService {
return service.NewGatewayService(
nil, nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
}
func newKeyBillingOpenAIGatewayService(repo service.UserGroupRateRepository) *service.OpenAIGatewayService {
return service.NewOpenAIGatewayService(
nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
}
func newKeyBillingContext(apiKey *service.APIKey) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
if apiKey != nil {
c.Set(string(middleware2.ContextKeyAPIKey), apiKey)
}
return c, w
}
func TestGatewayHandlerKeyBillingInfoUsesGroupRate(t *testing.T) {
groupID := int64(7)
apiKey := &service.APIKey{
UserID: 11,
GroupID: &groupID,
Key: "sk-sensitive-value",
Group: &service.Group{
ID: groupID,
Name: "private-group-name",
RateMultiplier: 0.75,
},
}
c, w := newKeyBillingContext(apiKey)
newKeyBillingHandler(nil).KeyBillingInfo(c)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
var got keyBillingInfoResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
require.Equal(t, "sub2api.key_billing", got.Object)
require.Equal(t, 1, got.SchemaVersion)
require.Equal(t, "token", got.BillingScope)
require.Equal(t, 0.75, got.GroupRateMultiplier)
require.Nil(t, got.UserRateMultiplier)
require.Equal(t, 0.75, got.ResolvedRateMultiplier)
require.False(t, got.PeakRateEnabled)
require.Nil(t, got.PeakStart)
require.Nil(t, got.PeakEnd)
require.Nil(t, got.PeakRateMultiplier)
require.Nil(t, got.AppliedPeakMultiplier)
require.Equal(t, 0.75, got.EffectiveRateMultiplier)
require.Nil(t, got.Timezone)
require.False(t, got.ObservedAt.IsZero())
var fields map[string]json.RawMessage
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &fields))
require.NotContains(t, fields, "user_rate_multiplier")
require.NotContains(t, fields, "peak_start")
require.NotContains(t, fields, "peak_end")
require.NotContains(t, fields, "peak_rate_multiplier")
require.NotContains(t, fields, "applied_peak_multiplier")
require.NotContains(t, fields, "timezone")
require.NotContains(t, w.Body.String(), apiKey.Key)
require.NotContains(t, w.Body.String(), apiKey.Group.Name)
}
func TestGatewayHandlerKeyBillingInfoUsesUserOverride(t *testing.T) {
groupID := int64(7)
userRate := 0.5
apiKey := &service.APIKey{
UserID: 11,
GroupID: &groupID,
Group: &service.Group{ID: groupID, RateMultiplier: 0.75},
}
c, w := newKeyBillingContext(apiKey)
repo := &keyBillingUserGroupRateRepo{rate: &userRate}
newKeyBillingHandler(repo).KeyBillingInfo(c)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, 1, repo.lookupCalls)
require.Equal(t, apiKey.UserID, repo.gotUserID)
require.Equal(t, groupID, repo.gotGroupID)
var got keyBillingInfoResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
require.NotNil(t, got.UserRateMultiplier)
require.Equal(t, 0.5, *got.UserRateMultiplier)
require.Equal(t, 0.5, got.ResolvedRateMultiplier)
require.Equal(t, 0.5, got.EffectiveRateMultiplier)
}
func TestBuildKeyBillingInfoAppliesPeakMultiplier(t *testing.T) {
groupID := int64(7)
apiKey := &service.APIKey{
GroupID: &groupID,
Group: &service.Group{
ID: groupID,
RateMultiplier: 1.2,
SubscriptionType: service.SubscriptionTypeSubscription,
PeakRateEnabled: true,
PeakStart: "09:00",
PeakEnd: "18:00",
PeakRateMultiplier: 1.5,
},
}
now := time.Date(2026, time.July, 12, 10, 0, 0, 0, timezone.Location())
userRate := 0.8
got := buildKeyBillingInfo(apiKey, userRate, now)
require.Equal(t, 1.2, got.GroupRateMultiplier)
require.NotNil(t, got.UserRateMultiplier)
require.Equal(t, 0.8, *got.UserRateMultiplier)
require.Equal(t, 0.8, got.ResolvedRateMultiplier)
require.True(t, got.PeakRateEnabled)
require.NotNil(t, got.PeakStart)
require.Equal(t, "09:00", *got.PeakStart)
require.NotNil(t, got.PeakEnd)
require.Equal(t, "18:00", *got.PeakEnd)
require.NotNil(t, got.PeakRateMultiplier)
require.Equal(t, 1.5, *got.PeakRateMultiplier)
require.NotNil(t, got.AppliedPeakMultiplier)
require.Equal(t, 1.5, *got.AppliedPeakMultiplier)
require.InDelta(t, 1.2, got.EffectiveRateMultiplier, 1e-12)
require.NotNil(t, got.Timezone)
require.Equal(t, timezone.Location().String(), *got.Timezone)
require.Equal(t, now.UTC(), got.ObservedAt)
encoded, err := json.Marshal(got)
require.NoError(t, err)
var fields map[string]json.RawMessage
require.NoError(t, json.Unmarshal(encoded, &fields))
for _, field := range []string{
"user_rate_multiplier",
"peak_start",
"peak_end",
"peak_rate_multiplier",
"applied_peak_multiplier",
"timezone",
} {
require.Contains(t, fields, field)
}
}
func TestKeyBillingInfoJSONKeepsZeroPeakMultiplierWhenEnabled(t *testing.T) {
groupID := int64(7)
apiKey := &service.APIKey{
GroupID: &groupID,
Group: &service.Group{
ID: groupID,
SubscriptionType: service.SubscriptionTypeSubscription,
PeakRateEnabled: true,
PeakStart: "00:00",
PeakEnd: "23:59",
PeakRateMultiplier: 0,
},
}
now := time.Date(2026, time.July, 12, 12, 0, 0, 0, timezone.Location())
encoded, err := json.Marshal(buildKeyBillingInfo(apiKey, apiKey.Group.RateMultiplier, now))
require.NoError(t, err)
var fields map[string]json.RawMessage
require.NoError(t, json.Unmarshal(encoded, &fields))
require.JSONEq(t, "0", string(fields["peak_rate_multiplier"]))
require.JSONEq(t, "0", string(fields["applied_peak_multiplier"]))
}
func TestGatewayHandlerKeyBillingInfoErrorsAreSafe(t *testing.T) {
t.Run("missing API key", func(t *testing.T) {
c, w := newKeyBillingContext(nil)
newKeyBillingHandler(nil).KeyBillingInfo(c)
require.Equal(t, http.StatusUnauthorized, w.Code)
})
t.Run("ungrouped API key", func(t *testing.T) {
c, w := newKeyBillingContext(&service.APIKey{})
newKeyBillingHandler(nil).KeyBillingInfo(c)
require.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("missing billing service", func(t *testing.T) {
groupID := int64(7)
c, w := newKeyBillingContext(&service.APIKey{
UserID: 11,
GroupID: &groupID,
Group: &service.Group{ID: groupID, RateMultiplier: 1},
})
(&GatewayHandler{}).KeyBillingInfo(c)
require.Equal(t, http.StatusInternalServerError, w.Code)
})
t.Run("rate lookup failure matches billing fallback", func(t *testing.T) {
groupID := int64(7)
c, w := newKeyBillingContext(&service.APIKey{
UserID: 11,
GroupID: &groupID,
Group: &service.Group{ID: groupID, RateMultiplier: 1},
})
newKeyBillingHandler(&keyBillingUserGroupRateRepo{err: errors.New("database password leaked")}).KeyBillingInfo(c)
require.Equal(t, http.StatusOK, w.Code)
var got keyBillingInfoResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
require.Equal(t, 1.0, got.ResolvedRateMultiplier)
require.NotContains(t, w.Body.String(), "database password leaked")
})
}
func TestGatewayHandlerKeyBillingInfoSharesBillingResolverCacheByPlatform(t *testing.T) {
for _, tc := range []struct {
name string
platform string
openAI bool
}{
{name: "anthropic", platform: service.PlatformAnthropic},
{name: "openai", platform: service.PlatformOpenAI, openAI: true},
{name: "grok", platform: service.PlatformGrok, openAI: true},
} {
t.Run(tc.name, func(t *testing.T) {
groupID := int64(7)
oldRate, newRate := 0.5, 1.8
repo := &keyBillingUserGroupRateRepo{rate: &oldRate}
gatewayService := newKeyBillingGatewayService(repo)
openAIGatewayService := newKeyBillingOpenAIGatewayService(repo)
h := &GatewayHandler{
gatewayService: gatewayService,
openAIGatewayService: openAIGatewayService,
}
apiKey := &service.APIKey{
UserID: 11,
GroupID: &groupID,
Group: &service.Group{
ID: groupID,
Platform: tc.platform,
RateMultiplier: 0.75,
},
}
if tc.openAI {
require.Equal(t, oldRate, openAIGatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier))
} else {
require.Equal(t, oldRate, gatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier))
}
repo.rate = &newRate
for range 2 {
c, w := newKeyBillingContext(apiKey)
h.KeyBillingInfo(c)
require.Equal(t, http.StatusOK, w.Code)
var got keyBillingInfoResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
require.Equal(t, oldRate, got.ResolvedRateMultiplier)
require.Equal(t, oldRate, got.EffectiveRateMultiplier)
}
var billedRate float64
if tc.openAI {
billedRate = openAIGatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier)
} else {
billedRate = gatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier)
}
require.Equal(t, oldRate, billedRate)
require.Equal(t, 1, repo.lookupCalls)
})
}
}
@@ -25,11 +25,20 @@ type gatewayModelsResponseForTest struct {
}
type gatewayModelItemForTest struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
CreatedAt string `json:"created_at"`
SupportsReasoningEffort bool `json:"supportsReasoningEffort"`
ReasoningEffort string `json:"reasoningEffort"`
ReasoningEfforts []gatewayReasoningEffortOptionForTest `json:"reasoningEfforts"`
}
type gatewayReasoningEffortOptionForTest struct {
Value string `json:"value"`
Label string `json:"label"`
Default bool `json:"default"`
}
func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]service.Account, error) {
@@ -84,6 +93,50 @@ func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
require.NotContains(t, modelIDsForTest(got.Data), "claude-sonnet-4-6")
}
func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(4409)
h := newGatewayModelsHandlerForTest(
&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformGrok,
Credentials: map[string]any{
"model_mapping": map[string]any{"grok-4.5": "grok-4.5"},
},
},
},
},
},
)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Len(t, got.Data, 1)
model := got.Data[0]
require.Equal(t, "grok-4.5", model.ID)
require.True(t, model.SupportsReasoningEffort)
require.Equal(t, "high", model.ReasoningEffort)
require.Equal(t, []gatewayReasoningEffortOptionForTest{
{Value: "low", Label: "Low"},
{Value: "medium", Label: "Medium"},
{Value: "high", Label: "High", Default: true},
}, model.ReasoningEfforts)
}
func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -187,8 +187,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
setOpsRequestContext(c, modelName, stream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
if decision := h.checkContentModeration(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && decision.Blocked {
googleError(c, contentModerationStatus(decision), decision.Message)
if decision := h.checkSecurityAudit(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && !decision.AllowNextStage {
googleSecurityAuditError(c, decision)
return
}
+108 -15
View File
@@ -46,6 +46,11 @@ func (h *OpenAIGatewayHandler) GrokVideoStatus(c *gin.Context) {
h.handleGrokMedia(c, service.GrokMediaEndpointVideoStatus, c.Param("request_id"))
}
// GrokVideoContent proxies downloadable video content through the task's upstream account.
func (h *OpenAIGatewayHandler) GrokVideoContent(c *gin.Context) {
h.handleGrokMedia(c, service.GrokMediaEndpointVideoContent, c.Param("request_id"))
}
func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) {
streamStarted := false
defer h.recoverResponsesPanic(c, &streamStarted)
@@ -95,11 +100,12 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
contentType := c.GetHeader("Content-Type")
requestInfo := service.ParseGrokMediaRequest(contentType, body)
requestModel := requestInfo.Model
routingModel := service.NormalizeGrokMediaModelForEndpoint(endpoint, requestModel, requestInfo.HasInputImage())
if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
if endpoint == service.GrokMediaEndpointVideoStatus && strings.TrimSpace(requestID) == "" {
if endpoint.IsVideoLookupRequest() && strings.TrimSpace(requestID) == "" {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "request_id is required")
return
}
@@ -114,9 +120,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
if moderationBody := requestInfo.ModerationBody(); len(moderationBody) > 0 {
decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, moderationBody)
if decision != nil && decision.Blocked {
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, moderationBody)
if decision != nil && !decision.AllowNextStage {
h.openAISecurityAuditError(c, decision)
return
}
}
@@ -159,20 +165,31 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
sessionSeed = []byte(requestID)
}
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, sessionSeed)
if endpoint == service.GrokMediaEndpointVideoStatus {
sessionHash = service.GrokMediaVideoRequestSessionHash(requestID)
boundLookupAccountID := int64(0)
if endpoint.IsVideoLookupRequest() {
sessionHash = service.GrokMediaVideoRequestSessionHash(requestID, subject.UserID, apiKey.ID)
boundLookupAccountID, err = h.gatewayService.ResolveGrokMediaVideoRequestAccount(
c.Request.Context(), apiKey.GroupID, requestID, subject.UserID, apiKey.ID,
)
if err != nil || boundLookupAccountID <= 0 {
reqLog.Info("grok_media.video_lookup_owner_binding_missing", zap.Error(err))
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Video request not found")
return
}
}
requestCtx := c.Request.Context()
failedAccountIDs := make(map[int64]struct{})
sameAccountRetryCount := make(map[int64]int)
var lastFailoverErr *service.UpstreamFailoverError
var oauth429FailoverState service.OpenAIOAuth429FailoverState
mediaEligibilityRejected := false
switchCount := 0
maxAccountSwitches := h.maxAccountSwitches
if maxAccountSwitches <= 0 {
maxAccountSwitches = 3
}
routingStart := time.Now()
requiredCapability := grokMediaRequiredCapability(endpoint)
for {
if failoverClientGone(c) {
@@ -183,10 +200,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
apiKey.GroupID,
"",
sessionHash,
requestModel,
routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
"",
requiredCapability,
false,
false,
false,
service.PlatformGrok,
@@ -200,8 +218,14 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
zap.Error(err),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if endpoint.IsGenerationRequest() && errors.Is(err, service.ErrNoAvailableAccounts) &&
(len(failedAccountIDs) == 0 || (mediaEligibilityRejected && lastFailoverErr == nil)) {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
return
}
if len(failedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok)
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -216,13 +240,26 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok)
if endpoint.IsGenerationRequest() {
markOpsRoutingCapacityLimited(c)
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
return
}
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
if boundLookupAccountID > 0 && selection.Account.ID != boundLookupAccountID {
reqLog.Warn("grok_media.video_lookup_bound_account_unavailable",
zap.Int64("bound_account_id", boundLookupAccountID),
zap.Int64("selected_account_id", selection.Account.ID),
)
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Video request not found")
return
}
reqLog.Debug("grok_media.account_schedule_decision",
zap.String("layer", scheduleDecision.Layer),
@@ -234,6 +271,25 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
)
account := selection.Account
if endpoint.IsGenerationRequest() {
eligible, eligibilityReason, eligibilityErr := h.ensureGrokMediaAccountEligibility(requestCtx, account)
if !eligible {
mediaEligibilityRejected = true
failedAccountIDs[account.ID] = struct{}{}
reqLog.Warn("grok_media.account_eligibility_rejected",
zap.Int64("account_id", account.ID),
zap.String("reason", eligibilityReason),
zap.Bool("probe_failed", eligibilityErr != nil),
)
if switchCount >= maxAccountSwitches {
markOpsRoutingCapacityLimited(c)
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
return
}
switchCount++
continue
}
}
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
setOpsSelectedAccount(c, account.ID, account.Platform)
@@ -273,7 +329,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
if failoverErr.ShouldReportAccountScheduleFailure() {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, nil), false, nil)
}
if c.Writer.Size() != writerSizeBeforeForward {
h.handleFailoverExhausted(c, failoverErr, true)
@@ -283,6 +339,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
h.handleFailoverExhausted(c, failoverErr, false)
return
}
if endpoint.IsVideoLookupRequest() {
h.handleFailoverExhausted(c, failoverErr, false)
return
}
if failoverErr.RetryableOnSameAccount {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
@@ -321,8 +381,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
)
continue
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
if c.Writer.Size() == writerSizeBeforeForward {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, nil), false, nil)
if !service.IsResponseCommitted(c) && c.Writer.Size() == writerSizeBeforeForward {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
}
reqLog.Warn("grok_media.forward_failed",
@@ -332,9 +392,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, result), true, nil)
if endpoint.IsGenerationRequest() && strings.TrimSpace(result.ResponseID) != "" {
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(requestCtx, apiKey.GroupID, result.ResponseID, account.ID); err != nil {
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, result.ResponseID, subject.UserID, apiKey.ID, account.ID,
); err != nil {
reqLog.Warn("grok_media.bind_video_request_account_failed",
zap.Int64("account_id", account.ID),
zap.String("request_id", result.ResponseID),
@@ -353,6 +415,37 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
}
}
func (h *OpenAIGatewayHandler) ensureGrokMediaAccountEligibility(ctx context.Context, account *service.Account) (bool, string, error) {
if account == nil {
return false, "missing_account", errors.New("grok media account is required")
}
eligible, reason := account.GrokMediaGenerationEligibility()
if eligible || reason != "billing_unobserved" {
return eligible, reason, nil
}
if h == nil || h.grokMediaEligibilityProber == nil {
return false, "billing_probe_unavailable", errors.New("grok media eligibility probe is not configured")
}
return h.grokMediaEligibilityProber.ProbeMediaEligibility(ctx, account.ID)
}
func grokMediaRequiredCapability(endpoint service.GrokMediaEndpoint) service.OpenAIEndpointCapability {
if endpoint.IsGenerationRequest() {
return service.OpenAIEndpointCapabilityGrokMediaGeneration
}
return ""
}
func grokMediaScheduleModel(account *service.Account, routingModel string, result *service.OpenAIForwardResult) string {
if result != nil && strings.TrimSpace(result.UpstreamModel) != "" {
return result.UpstreamModel
}
if account == nil {
return strings.TrimSpace(routingModel)
}
return account.GetMappedModel(routingModel)
}
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool {
return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != ""
}
+113
View File
@@ -1,12 +1,26 @@
package handler
import (
"context"
"errors"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
type grokMediaEligibilityProberStub struct {
eligible bool
reason string
err error
calls int
}
func (s *grokMediaEligibilityProberStub) ProbeMediaEligibility(context.Context, int64) (bool, string, error) {
s.calls++
return s.eligible, s.reason, s.err
}
func TestShouldRecordGrokMediaUsage(t *testing.T) {
tests := []struct {
name string
@@ -38,6 +52,12 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) {
model: "",
want: false,
},
{
name: "video content skips usage",
endpoint: service.GrokMediaEndpointVideoContent,
model: "",
want: false,
},
{
name: "generation skips usage without model",
endpoint: service.GrokMediaEndpointImagesGenerations,
@@ -52,3 +72,96 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) {
})
}
}
func TestGrokMediaRequiredCapability(t *testing.T) {
tests := []struct {
name string
endpoint service.GrokMediaEndpoint
want service.OpenAIEndpointCapability
}{
{name: "image generation", endpoint: service.GrokMediaEndpointImagesGenerations, want: service.OpenAIEndpointCapabilityGrokMediaGeneration},
{name: "image edit", endpoint: service.GrokMediaEndpointImagesEdits, want: service.OpenAIEndpointCapabilityGrokMediaGeneration},
{name: "video generation", endpoint: service.GrokMediaEndpointVideosGenerations, want: service.OpenAIEndpointCapabilityGrokMediaGeneration},
{name: "video edit", endpoint: service.GrokMediaEndpointVideosEdits, want: service.OpenAIEndpointCapabilityGrokMediaGeneration},
{name: "video extension", endpoint: service.GrokMediaEndpointVideosExtensions, want: service.OpenAIEndpointCapabilityGrokMediaGeneration},
{name: "video status preserves lookup", endpoint: service.GrokMediaEndpointVideoStatus, want: ""},
{name: "video content preserves lookup", endpoint: service.GrokMediaEndpointVideoContent, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, grokMediaRequiredCapability(tt.endpoint))
})
}
}
func TestGrokMediaScheduleModelUsesNormalizedMappedUpstream(t *testing.T) {
account := &service.Account{
Platform: service.PlatformGrok,
Credentials: map[string]any{
"model_mapping": map[string]any{
"grok-imagine-video-1.5": "wrong-raw-model",
"grok-imagine-video": "mapped-video-model",
},
},
}
require.Equal(t, "mapped-video-model", grokMediaScheduleModel(account, "grok-imagine-video", nil))
require.Equal(t, "actual-upstream-model", grokMediaScheduleModel(account, "grok-imagine-video", &service.OpenAIForwardResult{
UpstreamModel: "actual-upstream-model",
}))
require.Equal(t, "mapped-video-model", grokMediaScheduleModel(account, "grok-imagine-video", &service.OpenAIForwardResult{}))
require.Equal(t, "grok-imagine-video", grokMediaScheduleModel(nil, " grok-imagine-video ", nil))
}
func TestEnsureGrokMediaAccountEligibility(t *testing.T) {
t.Run("non oauth account does not probe", func(t *testing.T) {
prober := &grokMediaEligibilityProberStub{}
h := &OpenAIGatewayHandler{grokMediaEligibilityProber: prober}
account := &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey}
eligible, reason, err := h.ensureGrokMediaAccountEligibility(context.Background(), account)
require.NoError(t, err)
require.True(t, eligible)
require.Equal(t, "non_oauth", reason)
require.Zero(t, prober.calls)
})
t.Run("unobserved oauth is probed before forwarding", func(t *testing.T) {
prober := &grokMediaEligibilityProberStub{eligible: true, reason: "eligible"}
h := &OpenAIGatewayHandler{grokMediaEligibilityProber: prober}
account := &service.Account{ID: 7, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}
eligible, reason, err := h.ensureGrokMediaAccountEligibility(context.Background(), account)
require.NoError(t, err)
require.True(t, eligible)
require.Equal(t, "eligible", reason)
require.Equal(t, 1, prober.calls)
})
t.Run("missing prober fails closed", func(t *testing.T) {
h := &OpenAIGatewayHandler{}
account := &service.Account{ID: 8, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}
eligible, reason, err := h.ensureGrokMediaAccountEligibility(context.Background(), account)
require.Error(t, err)
require.False(t, eligible)
require.Equal(t, "billing_probe_unavailable", reason)
})
t.Run("probe failure fails closed", func(t *testing.T) {
probeErr := errors.New("probe failed")
prober := &grokMediaEligibilityProberStub{reason: "billing_unobserved", err: probeErr}
h := &OpenAIGatewayHandler{grokMediaEligibilityProber: prober}
account := &service.Account{ID: 9, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}
eligible, reason, err := h.ensureGrokMediaAccountEligibility(context.Background(), account)
require.ErrorIs(t, err, probeErr)
require.False(t, eligible)
require.Equal(t, "billing_unobserved", reason)
})
}
+4
View File
@@ -2,6 +2,7 @@ package handler
import (
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
)
// AdminHandlers contains all admin-related HTTP handlers
@@ -35,9 +36,11 @@ type AdminHandlers struct {
ChannelMonitor *admin.ChannelMonitorHandler
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
ContentModeration *admin.ContentModerationHandler
PromptAudit *securityaudit.PromptAdminHandler
Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
Compliance *admin.ComplianceHandler
AuditLog *admin.AuditLogHandler
}
// Handlers contains all HTTP handlers
@@ -58,6 +61,7 @@ type Handlers struct {
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
AvailableChannel *AvailableChannelHandler
AsyncImage *AsyncImageHandler
BatchImage *BatchImageHandler
}
@@ -0,0 +1,137 @@
//go:build unit
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type toggleSettingRepo struct {
mu sync.Mutex
values map[string]string
}
func (r *toggleSettingRepo) Get(context.Context, string) (*service.Setting, error) { return nil, nil }
func (r *toggleSettingRepo) GetValue(_ context.Context, key string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.values[key], nil
}
func (r *toggleSettingRepo) Set(_ context.Context, key, value string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.values[key] = value
return nil
}
func (r *toggleSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
return map[string]string{}, nil
}
func (r *toggleSettingRepo) SetMultiple(context.Context, map[string]string) error { return nil }
func (r *toggleSettingRepo) GetAll(context.Context) (map[string]string, error) {
return map[string]string{}, nil
}
func (r *toggleSettingRepo) Delete(context.Context, string) error { return nil }
type passthroughEncryptor struct{}
func (passthroughEncryptor) Encrypt(plaintext string) (string, error) { return plaintext, nil }
func (passthroughEncryptor) Decrypt(ciphertext string) (string, error) { return ciphertext, nil }
type noopImageStorage struct{}
func (noopImageStorage) Save(context.Context, string, string, []byte) (string, error) {
return "https://cdn.example.test/object.png", nil
}
// TestAsyncImageEnablesWithoutRestart drives the actual HTTP path for the bug behind
// #4458 and #4542: with object storage unconfigured the async endpoint 404s, and the
// only way to turn it on used to be editing config.yaml and restarting the container.
// Flipping the admin setting must flip the endpoint over in the same process.
func TestAsyncImageEnablesWithoutRestart(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &toggleSettingRepo{values: map[string]string{}}
// A fixed encryption key is required to persist a new S3 secret (#4524).
backup := service.NewBackupService(repo, &config.Config{
Totp: config.TotpConfig{EncryptionKeyConfigured: true},
}, passthroughEncryptor{}, nil, nil)
factory := func(context.Context, *config.ImageStorageConfig) (service.ImageStorage, error) {
return noopImageStorage{}, nil
}
settings := service.NewImageStorageSettingService(repo, passthroughEncryptor{}, backup, factory, config.ImageStorageConfig{})
store := &asyncImageMemoryStore{tasks: make(map[string]*service.ImageTaskRecord)}
tasks := service.NewImageTaskServiceWithResolver(store, settings.Resolver(), time.Hour, time.Minute)
h := &AsyncImageHandler{tasks: tasks}
h.execute = func(_ string, c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"created": 1, "data": []gin.H{{"url": "https://upstream.test/i.png"}}})
}
router := gin.New()
router.Use(func(c *gin.Context) {
groupID := int64(3)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
ID: 9, UserID: 7, GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, AllowImageGeneration: true},
})
c.Next()
})
router.POST("/v1/images/generations/async", h.Submit)
router.GET("/v1/images/tasks/:task_id", h.Get)
submit := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations/async",
strings.NewReader(`{"model":"gpt-image-1","prompt":"a lighthouse"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
rec := submit()
require.Equal(t, http.StatusNotFound, rec.Code, "disabled until an admin configures object storage")
require.Contains(t, rec.Body.String(), "async image tasks are not enabled")
// The admin saves the setting — no restart, same process.
_, err := settings.Update(context.Background(), service.ImageStorageSettings{
Enabled: true, Bucket: "my-images",
Endpoint: "https://acct.r2.cloudflarestorage.com", AccessKeyID: "ak", SecretAccessKey: "sk",
})
require.NoError(t, err)
rec = submit()
require.Equal(t, http.StatusAccepted, rec.Code, "the endpoint must go live as soon as the setting is saved")
var accepted struct {
TaskID string `json:"task_id"`
PollURL string `json:"poll_url"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &accepted))
require.NotEmpty(t, accepted.TaskID)
// Turning the feature back off must not strand a task that was already accepted.
_, err = settings.Update(context.Background(), service.ImageStorageSettings{Enabled: false})
require.NoError(t, err)
require.Equal(t, http.StatusNotFound, submit().Code, "new submissions are refused again")
pollRec := httptest.NewRecorder()
router.ServeHTTP(pollRec, httptest.NewRequest(http.MethodGet, accepted.PollURL, nil))
require.Equal(t, http.StatusOK, pollRec.Code, "an already-accepted task stays pollable after the switch is turned off")
}
@@ -0,0 +1,331 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type AsyncImageHandler struct {
tasks *service.ImageTaskService
openAI *OpenAIGatewayHandler
execute func(platform string, c *gin.Context)
}
func NewAsyncImageHandler(tasks *service.ImageTaskService, openAI *OpenAIGatewayHandler) *AsyncImageHandler {
h := &AsyncImageHandler{tasks: tasks, openAI: openAI}
h.execute = h.executeWithGateway
return h
}
// enabled reports whether the async image task feature is available. Object
// storage is the enablement gate: without it the endpoints are fully disabled
// so that large base64 results never land in Redis.
func (h *AsyncImageHandler) enabled() bool {
return h != nil && h.tasks != nil && h.tasks.Enabled()
}
// pollable reports whether task lookups can be served. It is deliberately weaker
// than enabled(): results already written to Redis stay readable after the
// feature is switched off, so an in-flight task is never stranded.
func (h *AsyncImageHandler) pollable() bool {
return h != nil && h.tasks != nil && h.tasks.Pollable()
}
// Submit accepts the same payload as the synchronous Images endpoint and
// returns before the upstream image generation begins.
func (h *AsyncImageHandler) Submit(c *gin.Context) {
if !h.enabled() {
imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "async image tasks are not enabled")
return
}
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey == nil || apiKey.UserID <= 0 || apiKey.ID <= 0 {
imageTaskError(c, service.ErrImageTaskForbidden)
return
}
platform := ""
if apiKey.Group != nil {
platform = apiKey.Group.Platform
}
if platform != service.PlatformOpenAI && platform != service.PlatformGrok {
imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "Images API is not supported for this platform")
return
}
if !service.GroupAllowsImageGeneration(apiKey.Group) {
imageTaskJSONError(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
return
}
if h == nil || h.tasks == nil || h.execute == nil {
imageTaskError(c, service.ErrImageTaskUnavailable)
return
}
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
imageTaskJSONError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
return
}
imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
if len(body) == 0 {
imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return
}
if asyncImageRequestStreams(c.GetHeader("Content-Type"), body) {
imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "streaming image requests cannot be submitted as asynchronous tasks")
return
}
if err := h.validateRequest(c, platform, body); err != nil {
imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if !h.checkSecurityAuditBeforeSubmit(c, apiKey, platform, body) {
return
}
taskCtx, recorder, cancel := newAsyncImageContext(c, body, h.tasks.ExecutionTimeout())
task, err := h.tasks.Create(c.Request.Context(), service.ImageTaskOwner{UserID: apiKey.UserID, APIKeyID: apiKey.ID})
if err != nil {
cancel()
imageTaskError(c, err)
return
}
pollURL := imageTaskPollURL(c.Request.URL.Path, task.ID)
c.Header("Cache-Control", "no-store")
c.Header("Location", pollURL)
c.Header("Retry-After", "3")
c.JSON(http.StatusAccepted, gin.H{
"id": task.ID,
"task_id": task.TaskID,
"object": task.Object,
"status": task.Status,
"created_at": task.CreatedAt,
"expires_at": task.ExpiresAt,
"poll_url": pollURL,
})
go h.run(task.ID, platform, taskCtx, recorder, cancel)
}
func (h *AsyncImageHandler) checkSecurityAuditBeforeSubmit(c *gin.Context, apiKey *service.APIKey, platform string, body []byte) bool {
if h == nil || h.openAI == nil {
return true
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
imageTaskJSONError(c, http.StatusInternalServerError, "api_error", "User context not found")
return false
}
model := ""
moderationBody := body
if platform == service.PlatformGrok {
parsed := service.ParseGrokMediaRequest(c.GetHeader("Content-Type"), body)
model, moderationBody = parsed.Model, parsed.ModerationBody()
} else if h.openAI.gatewayService != nil {
parsed, err := h.openAI.gatewayService.ParseOpenAIImagesRequest(c, body)
if err != nil {
imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return false
}
model, moderationBody = parsed.Model, parsed.ModerationBody()
}
if len(moderationBody) == 0 {
c.Set(securityAuditCompletedContextKey, true)
return true
}
reqLog := requestLogger(c, "handler.async_image.security_audit",
zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.String("model", model))
decision := h.openAI.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, model, moderationBody)
if decision != nil && !decision.AllowNextStage {
h.openAI.openAISecurityAuditError(c, decision)
return false
}
return true
}
func (h *AsyncImageHandler) Get(c *gin.Context) {
// Polling deliberately does not require the feature to be enabled, only that
// the task store is reachable. Turning the switch off in the admin UI must not
// strand tasks that were already accepted — their results are still in Redis
// and their submitters are still polling.
if !h.pollable() {
imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "async image tasks are not enabled")
return
}
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey == nil || apiKey.UserID <= 0 || apiKey.ID <= 0 {
imageTaskError(c, service.ErrImageTaskForbidden)
return
}
task, err := h.tasks.Get(c.Request.Context(), service.ImageTaskOwner{UserID: apiKey.UserID, APIKeyID: apiKey.ID}, c.Param("task_id"))
if err != nil {
imageTaskError(c, err)
return
}
c.Header("Cache-Control", "no-store")
if task.Status == service.ImageTaskStatusProcessing {
c.Header("Retry-After", "3")
}
c.JSON(http.StatusOK, task)
}
func (h *AsyncImageHandler) validateRequest(c *gin.Context, platform string, body []byte) error {
if h.openAI == nil || h.openAI.gatewayService == nil {
return nil
}
if platform == service.PlatformGrok {
parsed := service.ParseGrokMediaRequest(c.GetHeader("Content-Type"), body)
if strings.TrimSpace(parsed.Model) == "" {
return errors.New("model is required")
}
return nil
}
parsed, err := h.openAI.gatewayService.ParseOpenAIImagesRequest(c, body)
if err != nil {
return err
}
if parsed.Stream {
return errors.New("streaming image requests cannot be submitted as asynchronous tasks")
}
return nil
}
func (h *AsyncImageHandler) executeWithGateway(platform string, c *gin.Context) {
if h.openAI == nil {
imageTaskJSONError(c, http.StatusServiceUnavailable, "api_error", "image gateway is unavailable")
return
}
if platform == service.PlatformGrok {
h.openAI.GrokImages(c)
return
}
h.openAI.Images(c)
}
func (h *AsyncImageHandler) run(taskID, platform string, taskCtx *gin.Context, recorder *httptest.ResponseRecorder, cancel context.CancelFunc) {
defer cancel()
defer func() {
if recovered := recover(); recovered != nil {
logger.L().Error("image_task.execution_panicked", zap.String("task_id", taskID), zap.Any("panic", recovered))
h.failTask(taskID, http.StatusInternalServerError, imageTaskErrorPayload("api_error", "image generation task panicked"))
}
}()
h.execute(platform, taskCtx)
body := bytes.TrimSpace(recorder.Body.Bytes())
if err := taskCtx.Request.Context().Err(); err != nil && len(body) == 0 {
h.failTask(taskID, http.StatusGatewayTimeout, imageTaskErrorPayload("timeout_error", "image generation task timed out"))
return
}
statusCode := recorder.Code
if statusCode == 0 {
statusCode = http.StatusOK
}
if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
if len(body) == 0 || !json.Valid(body) {
h.failTask(taskID, http.StatusBadGateway, imageTaskErrorPayload("api_error", "upstream returned an invalid image response"))
return
}
if err := h.tasks.Complete(context.Background(), taskID, statusCode, json.RawMessage(body)); err != nil {
logger.L().Error("image_task.complete_store_failed", zap.String("task_id", taskID), zap.Error(err))
}
return
}
h.failTask(taskID, statusCode, extractImageTaskError(body))
}
func (h *AsyncImageHandler) failTask(taskID string, statusCode int, taskErr json.RawMessage) {
if err := h.tasks.Fail(context.Background(), taskID, statusCode, taskErr); err != nil {
logger.L().Error("image_task.failure_store_failed", zap.String("task_id", taskID), zap.Error(err))
}
}
func newAsyncImageContext(c *gin.Context, body []byte, timeoutDuration time.Duration) (*gin.Context, *httptest.ResponseRecorder, context.CancelFunc) {
base := context.WithoutCancel(c.Request.Context())
executionCtx, cancel := context.WithTimeout(base, timeoutDuration)
request := c.Request.Clone(executionCtx)
request.Body = io.NopCloser(bytes.NewReader(body))
request.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(body)), nil
}
request.ContentLength = int64(len(body))
request.URL.Path = strings.TrimSuffix(request.URL.Path, "/async")
taskCtx := c.Copy()
recorder := httptest.NewRecorder()
recorderCtx, _ := gin.CreateTestContext(recorder)
taskCtx.Writer = recorderCtx.Writer
taskCtx.Request = request
return taskCtx, recorder, cancel
}
func asyncImageRequestStreams(contentType string, body []byte) bool {
if isMultipartImagesContentType(contentType) {
return false
}
var envelope struct {
Stream bool `json:"stream"`
}
return json.Unmarshal(body, &envelope) == nil && envelope.Stream
}
func imageTaskPollURL(submitPath, taskID string) string {
if strings.HasPrefix(submitPath, "/v1/") {
return "/v1/images/tasks/" + taskID
}
return "/images/tasks/" + taskID
}
func extractImageTaskError(body []byte) json.RawMessage {
if json.Valid(body) {
var envelope struct {
Error json.RawMessage `json:"error"`
}
if json.Unmarshal(body, &envelope) == nil && len(envelope.Error) > 0 && json.Valid(envelope.Error) {
return envelope.Error
}
return json.RawMessage(body)
}
return imageTaskErrorPayload("api_error", "image generation failed")
}
func imageTaskErrorPayload(errorType, message string) json.RawMessage {
data, _ := json.Marshal(gin.H{"type": errorType, "message": message})
return data
}
func imageTaskError(c *gin.Context, err error) {
status := infraerrors.Code(err)
code := infraerrors.Reason(err)
message := infraerrors.Message(err)
if status <= 0 {
status = http.StatusInternalServerError
}
if strings.TrimSpace(code) == "" {
code = "IMAGE_TASK_ERROR"
}
imageTaskJSONError(c, status, code, message)
}
func imageTaskJSONError(c *gin.Context, status int, code, message string) {
c.Header("Cache-Control", "no-store")
c.JSON(status, gin.H{"error": gin.H{"type": code, "code": code, "message": message}})
}
@@ -0,0 +1,145 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type asyncImageMemoryStore struct {
mu sync.RWMutex
tasks map[string]*service.ImageTaskRecord
}
func (s *asyncImageMemoryStore) Save(_ context.Context, task *service.ImageTaskRecord, _ time.Duration) error {
s.mu.Lock()
defer s.mu.Unlock()
copy := *task
copy.Result = append(json.RawMessage(nil), task.Result...)
copy.Error = append(json.RawMessage(nil), task.Error...)
s.tasks[task.ID] = &copy
return nil
}
func (s *asyncImageMemoryStore) Get(_ context.Context, id string) (*service.ImageTaskRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
task := s.tasks[id]
if task == nil {
return nil, service.ErrImageTaskNotFound
}
copy := *task
copy.Result = append(json.RawMessage(nil), task.Result...)
copy.Error = append(json.RawMessage(nil), task.Error...)
return &copy, nil
}
func TestAsyncImageHandlerSubmitAndPoll(t *testing.T) {
gin.SetMode(gin.TestMode)
store := &asyncImageMemoryStore{tasks: make(map[string]*service.ImageTaskRecord)}
tasks := service.NewImageTaskServiceWithUploader(store, nil, time.Hour, time.Minute)
release := make(chan struct{})
h := &AsyncImageHandler{tasks: tasks}
h.execute = func(_ string, c *gin.Context) {
<-release
c.JSON(http.StatusOK, gin.H{"created": 123, "data": []gin.H{{"url": "https://example.test/image.png"}}})
}
router := gin.New()
router.Use(func(c *gin.Context) {
groupID := int64(3)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
ID: 9,
UserID: 7,
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, AllowImageGeneration: true},
})
c.Next()
})
router.POST("/v1/images/generations/async", h.Submit)
router.GET("/v1/images/tasks/:task_id", h.Get)
requestCtx, cancelRequest := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations/async", strings.NewReader(`{"model":"gpt-image-1","prompt":"cat"}`)).WithContext(requestCtx)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusAccepted, w.Code)
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
require.Equal(t, "3", w.Header().Get("Retry-After"))
var accepted struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
PollURL string `json:"poll_url"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &accepted))
require.Equal(t, service.ImageTaskStatusProcessing, accepted.Status)
require.Equal(t, "/v1/images/tasks/"+accepted.TaskID, accepted.PollURL)
require.Equal(t, accepted.PollURL, w.Header().Get("Location"))
// The detached background request must survive completion of/cancellation
// from the short submission request.
cancelRequest()
close(release)
require.Eventually(t, func() bool {
got, err := tasks.Get(context.Background(), service.ImageTaskOwner{UserID: 7, APIKeyID: 9}, accepted.TaskID)
return err == nil && got.Status == service.ImageTaskStatusCompleted
}, time.Second, 10*time.Millisecond)
pollReq := httptest.NewRequest(http.MethodGet, accepted.PollURL, nil)
pollWriter := httptest.NewRecorder()
router.ServeHTTP(pollWriter, pollReq)
require.Equal(t, http.StatusOK, pollWriter.Code)
require.Equal(t, "no-store", pollWriter.Header().Get("Cache-Control"))
require.Empty(t, pollWriter.Header().Get("Retry-After"))
require.Contains(t, pollWriter.Body.String(), "https://example.test/image.png")
}
// When object storage is not configured the feature is fully disabled: the
// endpoints must return 404 without creating a task or writing to Redis.
func TestAsyncImageHandlerDisabledReturns404(t *testing.T) {
gin.SetMode(gin.TestMode)
store := &asyncImageMemoryStore{tasks: make(map[string]*service.ImageTaskRecord)}
tasks := service.NewImageTaskServiceWithOptions(store, time.Hour, time.Minute) // enabled == false
h := &AsyncImageHandler{tasks: tasks}
router := gin.New()
router.Use(func(c *gin.Context) {
groupID := int64(3)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
ID: 9,
UserID: 7,
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, AllowImageGeneration: true},
})
c.Next()
})
router.POST("/v1/images/generations/async", h.Submit)
router.GET("/v1/images/tasks/:task_id", h.Get)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations/async", strings.NewReader(`{"model":"gpt-image-1","prompt":"cat"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Body.String(), "not enabled")
pollReq := httptest.NewRequest(http.MethodGet, "/v1/images/tasks/imgtask_missing", nil)
pollWriter := httptest.NewRecorder()
router.ServeHTTP(pollWriter, pollReq)
require.Equal(t, http.StatusNotFound, pollWriter.Code)
// No task was created / persisted.
require.Empty(t, store.tasks)
}

Some files were not shown because too many files have changed in this diff Show More