Compare commits

..
281 Commits
Author SHA1 Message Date
shaw 60da9ba178 fix(frontend): refine grok icon, palette and i18n 2026-07-01 18:49:33 +08:00
Wesley Liddick 5eb9da9c93 Merge pull request #3593 from heathermhuang/codex/grok-media-routing
fix: route Grok media endpoints
2026-07-01 17:49:27 +08:00
Heatherm Huang aac3261c69 fix: convert grok image edit uploads 2026-07-01 16:31:33 +08:00
Heatherm Huang f77cf6b477 Revert "feat: add LLM media tester"
This reverts commit a34d4967e6.
2026-07-01 16:08:21 +08:00
shaw 8c2d9b9a12 chore(openai): remove gpt-5.3-codex from OpenAI default model list
移除 OpenAI OAuth 账号默认模型列表中的 gpt-5.3-codex(后端 DefaultModels、前端 openai 白名单及 UseKeyModal 目录),保留 gpt-5.3-codex-spark 与计费/别名/messages-dispatch 相关逻辑不变。
2026-07-01 15:54:34 +08:00
Heatherm Huang c9fb221a31 fix: satisfy grok media lint 2026-07-01 15:42:00 +08:00
Wesley Liddick 0a9146c3d1 Merge pull request #3586 from deqiying/codex/fix-subscription-revoke-soft-delete
修复订阅撤销操作实际上是软删除的bug
2026-07-01 15:38:45 +08:00
Heatherm Huang 42e471f59a fix: harden grok media routing 2026-07-01 15:36:08 +08:00
Heatherm Huang a34d4967e6 feat: add LLM media tester 2026-07-01 15:12:48 +08:00
Wesley Liddick d679ee3c29 Merge pull request #3596 from JRBaggins/fix/tag-version-resolution
fix: source-build version resolution for release tags
2026-07-01 14:45:11 +08:00
shaw fd004bdd88 fix(account-repo): Clone query before Count to prevent state pollution
ListWithFilters reused the Ent query builder across Count() and All(),
so interceptor-appended predicates (SoftDeleteMixin's deleted_at IS NULL)
accumulated on the shared builder — the same pattern already fixed in
group_repo/promo_code_repo/user_repo by commit 2588fa6a (P1-03 audit).

The integration test now asserts pagination.Total matches len(items) on a
single page, guarding the total-vs-items invariant reported in #3601.

Fixes #3601
2026-07-01 14:35:49 +08:00
JRBaggins de64b02612 fix: resolve build version from release tag 2026-07-01 14:25:46 +08:00
Wesley Liddick 3812e627a8 Merge pull request #3546 from nslogx/fix/platform-quota-five-platforms
fix: allow five platform quota updates
2026-07-01 14:07:08 +08:00
Wesley Liddick 9817d5d5bf Merge pull request #3598 from DaydreamCoding/feat/openai-spark-shadow-account
feat(spark-shadow): OpenAI Spark 链接型影子账号
2026-07-01 14:00:52 +08:00
shaw 96c9166b0e fix(usage): 用户使用记录默认显示推理强度列 2026-07-01 14:00:27 +08:00
DaydreamCodingandClaude Sonnet 5 bdf7ead157 feat(spark-shadow): OpenAI Spark 链接型影子账号
背景:gpt-5.3-codex-spark 使用独立于 codex 全局(5h/7d)的配额窗口(数据源是
/wham/usage 响应体的 codex_bengalfox,而非 codex 全局用的 x-codex-* 响应头),且
只能挂在已完成 OAuth 授权的 OpenAI 账号下复用其登录态,不能作为独立账号单独接入。
为此新增“链接型影子账号”(spark shadow account):影子账号本身不持有任何凭据,
通过 parent_account_id 指向母账号,凭据/token/代理透传自母账号并共享母账号的刷新
周期,仅在配额维度(quota_dimension=spark)和用量窗口上与母账号完全独立调度、互不
连坐。

实现:
- 数据模型:migration 154(+154a)给 accounts 表加 parent_account_id /
  quota_dimension 列 + 4 条约束(维度合法 / parent⟺非 global 维度一致 / 禁自指 /
  FK)+ 2 个 CONCURRENTLY 索引(母账号索引 + 每母账号至多一个影子的唯一索引)。
- 创建:POST /api/v1/admin/accounts/:id/shadow(CreateShadow)—— 一母一影(唯一
  索引兜底并发竞态),继承母账号 proxy/分组/并发/优先级(显式传参可覆盖),默认
  model_mapping 恒等映射到 spark(拒绝非 spark 模型),母账号必须是真实的 OpenAI
  OAuth 账号(非影子)。
- 凭据透传:resolveCredentialAccount 把影子解析回母账号,GetAccessToken / 请求头
  / WS 三条路径统一走此函数;影子自身 Credentials 恒为空(仅允许写 model_mapping),
  凭据写入的汇聚点 persistAccountCredentials 对影子早返 no-op,防止误写。
- 调度:parentHealthyForShadow 只看母账号是否仍是 OpenAI OAuth + 凭据/传输是否
  可用(active、token 未过期、未处于 401/刷新失败/传输故障导致的临时不可调度冷却),
  刻意不看母账号的 global 限流窗口——两条 429 道互不连坐。
- 用量:影子的 codex_5h/7d 走 OpenAIQuotaService.QueryUsage(/wham/usage 的
  codex_bengalfox),与母账号走的 WSv2 探测(/responses 头)完全独立的数据源、
  刷新节流与 staleness 判定。
- 备份:ExportData 显式排除影子账号(影子不持凭据,通用凭据型导入强制
  credentials 非空、无法表达父子链接),按 skipped_shadows 计数提示前端。
- 前端:账号操作菜单新增“创建 Spark 影子”入口,影子行展示回填的母账号信息
  (邮箱 / plan / 隐私模式 / 订阅到期 / chatgpt_account_id),批量操作自动跳过
  影子账号。

说明:migrations 目录用完整文件名(而非纯数字前缀)标识迁移,故本次新增的
154_account_spark_shadow.sql / 154a_..._notx.sql 与已有的
154_add_ops_system_logs_api_key_id.sql 按序号共存,与目录里 145/151 已有的
先例一致。

测试:新增约 20 个测试文件,覆盖 handler(CreateShadow 校验 / 母账号信息回填)、
repository(影子 round-trip / 一母一影唯一索引 / 迁移 schema)、service(凭据
透传三路径 / 调度母健康门 / 用量窗口来源与刷新节流 / CRS 母账号不变量 / 各类
早返与 fail-closed 场景)及前端组件(账号列表 / 操作菜单 / 用量重置)。

验证(镜像 CI;golangci-lint 首次全量分析耗时过长被跳过,其余全部实测):
- gofmt -l:干净
- go build ./... / go vet ./...:通过
- go test ./... -count=1:全绿(全部包 ok,含 internal/service、
  internal/repository、migrations)
- go test -tags integration ./internal/repository/... ./internal/service/...
  (真实 Postgres,testcontainers):全绿,含迁移幂等性
  (TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate)与影子相关全部用例
- pnpm lint:check / pnpm typecheck / pnpm build(真实 vite 构建)/
  pnpm vitest run:全绿(124 文件 760 用例)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:21:45 +08:00
Heatherm Huang c3e860607d fix: include official grok media model ids 2026-07-01 11:43:35 +08:00
Heatherm Huang 2fe756e4be fix: recognize grok media models 2026-07-01 11:43:35 +08:00
Heatherm Huang 3b5d812f7a fix: route grok media endpoints 2026-07-01 11:43:35 +08:00
Wesley Liddick 5f62625ae6 Merge pull request #3577 from heathermhuang/codex/fix-english-ui-cjk
fix: remove Chinese strings from English UI
2026-07-01 11:35:04 +08:00
shaw db0414233c feat: 适配 sonnet5 2026-07-01 11:32:22 +08:00
Wesley Liddick e2d21c35af Merge pull request #3587 from alfadb/fix/openai-gpt-55-pro-codex-model
fix(openai): 保留 GPT-5.5 Pro Codex 模型名
2026-07-01 10:54:51 +08:00
shaw 59e9356c51 feat: 抹除 Anthropic OAuth 请求中客户端 dateline 隐写指纹
对 /v1/messages 转发到 Anthropic OAuth/setup-token 账号的请求做 dateline
归一化,将 system prompt 与 <system-reminder> 块中 "Today's date is …"
语句里的 4 种撇号变体与 "/" 日期分隔符还原为 ASCII 撇号 + "-",抹除某些
客户端在检测到非官方 base URL 时注入的 3 bit 隐写指纹。API Key 账号不受
影响。新增系统设置开关 enable_client_dateline_normalization,默认开启。
2026-07-01 10:54:18 +08:00
Wesley Liddick c1335bae12 Merge pull request #3588 from Ogannesson/fix/openai-codex-preserve-encrypted-reasoning
fix(openai): preserve encrypted reasoning across turns on codex OAuth path
2026-07-01 09:11:35 +08:00
alfadb b28a223337 fix(openai): 保留 GPT-5.5 Pro Codex 模型名
避免 Codex OAuth 模型归一化把 gpt-5.5-pro 降级为 gpt-5.5。\n\n同时补充 GPT-5.5 Pro 的计费回退与长上下文计费策略,保证保留上游模型名后仍使用现有 GPT-5.5/GPT-5.4 计费规则。\n\n验证:\n- go test ./internal/service/...\n- go build ./cmd/server/
2026-07-01 01:30:42 +08:00
deqiying 03727ac363 fix(subscription): 修复订阅撤销软删除失效
总: 增加明确的订阅撤销接口,修复撤销后的缓存失效和管理端 revoked 展示。

分: 同步失效订阅 L1 与 billing cache,补跨实例失效通知、soft-delete-aware 列表查询、revoked_at DTO 字段和回归测试。
2026-07-01 00:37:51 +08:00
Heatherm Huang 1158cf31bc fix: require localized interval validation 2026-06-30 22:31:21 +08:00
Heatherm Huang 41bb15d302 fix: remove remaining English fallbacks 2026-06-30 22:27:37 +08:00
Heatherm Huang b4f38b092f fix: preserve zh locale in i18n cleanup 2026-06-30 22:06:27 +08:00
Oganneson 73de2ea7f0 fix(openai): preserve encrypted reasoning across turns on codex OAuth path
PR #2068 dropped every reasoning item from input[] on the OAuth/codex path
(store=false). That silently discards encrypted_content -- the out-of-band
channel that carries reasoning context across turns under store=false --
degrading multi-turn agent reasoning with no visible error. Reported by
@neteroster on PR #2068.

The 404 that #2068 worked around ("Item with id 'rs_...' not found") is
triggered by the rs_* id lookup under store=false, not by the reasoning item
itself -- so the correct fix is to strip the id, not delete the item.

Verified end-to-end against the live chatgpt.com codex backend (gpt-5.5) and
a real OpenClaw container (api: openai-responses):
  - bare rs_ id, no encrypted_content  -> 404
  - id stripped                        -> 200
  - encrypted_content + id stripped    -> 200, reasoning context preserved
  - reasoning items require a summary field (missing -> 400)
  - real OpenClaw multi-turn agent loop -> all /v1/responses 200, zero 404

Fix: keep the reasoning item, strip only the rs_* id (always, independent of
PreserveReferences), preserve encrypted_content/content/summary verbatim, and
backfill an empty summary when absent. Tool-call call_id pairing is untouched.

Also verified compaction_summary items (cmp_*, the other encrypted_content
carrier): they require encrypted_content (missing -> 400) and their id does not
404 when present (kept or stripped), so the existing generic path already
handles them safely -- no special-casing needed.

Adds regression tests for each verified reasoning contract.

Refs #1957, #2068
2026-06-30 21:08:40 +08:00
Heatherm Huang 6e54776509 fix: localize English UI strings 2026-06-30 20:05:23 +08:00
github-actions[bot] dc1bc15457 chore: sync VERSION to 0.1.141 [skip ci] 2026-06-30 09:29:40 +00:00
Wesley Liddick 7cb98e5bdc Merge pull request #2007 from PMExtra/feature/user-usage-admin-parity
feat: align user usage analytics with admin
2026-06-30 17:10:09 +08:00
shaw 930326116e fix: 修复订阅支付金额显示错误 2026-06-30 16:26:22 +08:00
PMExtra cafc95c3e2 feat: align user usage analytics with admin 2026-06-30 15:31:28 +08:00
github-actions[bot] 89b2d63ef2 chore: sync VERSION to 0.1.140 [skip ci] 2026-06-30 06:26:55 +00:00
shaw 345d5c6b56 chore: update sponsors 2026-06-30 14:11:14 +08:00
nslogx 6c46c2cb7d test: update platform quota contract for grok 2026-06-30 14:03:33 +08:00
Wesley Liddick 76e0d90736 Merge pull request #3509 from wucm667/fix/refund-pending-not-success
fix(payment): 退款 pending 不再当成最终成功,避免站内账务与网关状态不一致
2026-06-30 13:38:04 +08:00
Wesley Liddick 56ca027a0f Merge pull request #3568 from DaydreamCoding/fix/auth-signup-grok-platform-quota
fix(auth-signup): 平台配额快照脱离注册事务 + grok 补入 CHECK 约束
2026-06-30 13:37:54 +08:00
shaw bf4f006736 fix: 修复用户列表使用时间排序冲突 2026-06-30 13:36:36 +08:00
DaydreamCodingandClaude Opus 4.8 185f9c9920 fix(auth-signup): 平台配额快照脱离注册事务 + grok 补入 CHECK 约束
自助注册(含钉钉/OAuth)报 500→404 的根因:grok 自 2026-06 进入默认平台配额
(default_platform_quotas / auth_source_*),但 user_platform_quotas 的 CHECK
约束(迁移 142)仅允许 anthropic/openai/gemini/antigravity。注册时
snapshotPlatformQuotaDefaults 写 grok 行违反约束 → 整个注册事务被 Postgres 标记
aborted → consumePendingOAuthBrowserSessionTx 撞 "transaction aborted" → 500 →
clearCookies → 用户重试拿到 404(PENDING_AUTH_SESSION_NOT_FOUND)。
影响面:所有新自助注册(不限钉钉)。

修复(两层):
- 事务隔离(fix①):snapshotPlatformQuotaDefaults 用 ent.WithoutTx 剥离调用方事务,
  在基础连接 autocommit 执行。best-effort 快照失败永不毒化注册主事务,从根上消除
  "事务内 fail-open 形同虚设"陷阱——今后任何平台/约束漂移都不会再连累注册。
- 迁移 157:把 grok 加入 user_platform_quotas.platform 的 CHECK 约束,与代码平台
  列表(domain/constants.go PlatformGrok)对齐(DROP IF EXISTS + ADD,可重入)。

新增 ent.WithoutTx(ctx) helper(手写文件,不动生成代码)。

测试:
- 单测 TestSnapshotPlatformQuotaDefaults_DetachesCallerTransaction(RED→GREEN):
  快照即便在事务 ctx 中也必须用脱离事务的 ctx 调 repo。
- 集成测试 TestUserPlatformQuotaRepository_BulkInsertInitial_GrokAllowed:
  迁移 157 后 grok 可写入(真实 postgres 容器验证)。

验证:go build ./... / go vet -tags unit ./... / 全量单测(-tags unit,45 包) /
平台配额+迁移集成测试 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:22:27 +08:00
Wesley Liddick dcd8689d78 Merge pull request #3548 from dftian478/codex/中文-上下文窗口不切号
修复 OpenAI 上下文窗口错误误触发账号切换
2026-06-30 10:57:26 +08:00
Wesley Liddick efb8b45876 Merge pull request #3560 from StarryKira/feat/issue-3557-oauth-email
feat: fix OAuth email completion flow
2026-06-30 10:55:00 +08:00
Wesley Liddick ac6d4ccf09 Merge pull request #3549 from dftian478/codex/中文-gpt55-codex-instructions
修复 OpenAI GPT-5.5 的 Codex 指令选择
2026-06-30 10:53:35 +08:00
Wesley Liddick ac6f2e9693 Merge pull request #3559 from 315944211/fix/privacy-toast-result
fix: show privacy setting result accurately
2026-06-30 10:50:17 +08:00
Wesley Liddick e70e36e4d2 Merge pull request #3484 from bwliangc/feat/risk-control-matched-keyword
feat(risk-control): record matched keyword in keyword-block logs
2026-06-30 10:40:30 +08:00
wucm667 93a3bf3077 Fix refund pending finalization gaps 2026-06-30 10:19:50 +08:00
wucm667 7316d83027 fix(payment): 区分退款 pending 并收敛匿名查单 2026-06-30 10:00:48 +08:00
Wesley Liddick 55902d8548 Merge pull request #3558 from heathermhuang/codex/grok-cli-compat
fix: support Grok admin test and CLI compatibility
2026-06-30 08:42:59 +08:00
haruka 260fda19b3 feat: fix OAuth email completion flow 2026-06-30 01:11:51 +08:00
315944211 a0a3d0c33e fix: show privacy setting result accurately 2026-06-30 00:37:21 +08:00
Heatherm Huang 438510d298 fix: sanitize grok codex responses payloads 2026-06-29 21:37:30 +08:00
Heatherm Huang 10e623f674 fix: allow grok messages compatibility 2026-06-29 18:33:37 +08:00
Heatherm Huang 4a7148e203 fix: support grok cli compatibility routes 2026-06-29 17:53:19 +08:00
Wesley Liddick d3acd8e96e Merge pull request #3497 from JRBaggins/fix/openai-count-tokens-bridge
Bridge OpenAI count_tokens to responses input_tokens
2026-06-29 15:23:11 +08:00
dftian478 7cbf82ed65 修复 OpenAI 上下文窗口错误误触发账号切换 2026-06-29 10:29:23 +08:00
dftian478 709cf61853 修复 OpenAI GPT-5.5 的 Codex 指令选择 2026-06-29 10:25:43 +08:00
nslogx d86e83259e fix: allow five platform quota updates 2026-06-29 09:40:04 +08:00
Wesley Liddick c99112a9e9 Merge pull request #3515 from bestony/feat/ops-system-log-key-id
feat(ops): add key id filter to system logs
2026-06-29 09:24:40 +08:00
Wesley Liddick a22b12677f Merge pull request #3517 from bestony/feat/keys-column-settings
feat(keys): add API key column settings
2026-06-29 09:24:20 +08:00
Wesley Liddick b0dbc22fe5 Merge pull request #3510 from wucm667/fix/subscription-confirm-show-converted-amount
fix(payment): 订阅订单确认页显示换算后的 CNY 实付金额,而非 USD 套餐价
2026-06-29 09:24:01 +08:00
Wesley Liddick 7c857bd080 Merge pull request #3441 from deqiying/feature/openai-quota-headroom-scheduler
新增 OpenAI 剩余额度调度权重
2026-06-29 09:23:32 +08:00
Wesley Liddick bebafe7697 Merge pull request #3437 from imfangwenjie/fix/api-base-fetch
fix(frontend): use configured API base for direct requests
2026-06-29 09:23:13 +08:00
Wesley Liddick fc1e5a94a9 Merge pull request #3534 from mxyhi/fix/openai-quota-platform-post-billing
fix(openai): preserve quota platform in usage billing
2026-06-29 09:21:44 +08:00
Wesley Liddick 61f735a667 Merge pull request #3498 from liuaho6-commits/fix/codex-image-bridge-tool-choice
fix(openai): set tool_choice auto for Codex image bridge
2026-06-29 09:21:25 +08:00
Wesley Liddick b105cc0fd5 Merge pull request #3337 from ddnio/codex/openai-json-mode-developer-input
fix(openai): preserve JSON instructions in Codex OAuth input
2026-06-29 09:21:05 +08:00
Wesley Liddick 38577c6ca0 Merge pull request #3533 from Pluviobyte/codex/fix-api-key-unlimited-quota
fix(keys): reactivate exhausted keys set to unlimited
2026-06-29 09:19:53 +08:00
Wesley Liddick 47598462f5 Merge pull request #3401 from StarryKira/fix/issue-3394-fallback-pricing-log-spam
fix: stop per-request fallback-pricing log spam for unknown models (#3394)
2026-06-29 09:19:44 +08:00
Wesley Liddick 753c5e255c Merge pull request #3215 from fchange/fix/image-billing-false-positive
fix(openai): prevent false image billing on text-only /v1/responses requests
2026-06-29 09:19:34 +08:00
Wesley Liddick 3b18d1fa3d Merge pull request #3532 from smartcmd/fix/subscription-direct-pay-price
fix(payment): keep subscription price as direct pay amount
2026-06-29 09:19:24 +08:00
Wesley Liddick a822972d80 Merge pull request #3494 from devnomad-byte/docs/source-compile-admin-setup
docs: note admin account wizard requirement in source-compile install
2026-06-29 09:19:14 +08:00
mxyhi 82553c4dca fix(openai): preserve quota platform in usage billing 2026-06-28 11:13:09 +08:00
Pluviobyte da810c3b43 fix(keys): reactivate exhausted keys set to unlimited 2026-06-28 09:05:28 +08:00
daoge_cmd b1403e8b29 fix(payment): keep subscription price as direct pay amount 2026-06-28 06:29:01 +08:00
Bestony b244f850e7 feat(keys): add column settings 2026-06-27 15:14:13 +08:00
Bestony bad87ff533 feat(ops): add api key filter to system logs 2026-06-27 14:35:19 +08:00
wucm667 88ca0c1d13 fix(payment): 显示订阅 CNY 换算实付金额 2026-06-27 12:15:39 +08:00
Hao Liu e5f7836bf3 fix(openai): set tool_choice auto for Codex image bridge 2026-06-26 19:10:37 +08:00
JRBaggins 7a38c66214 Bridge OpenAI count_tokens to responses input_tokens 2026-06-26 17:59:26 +08:00
github-actions[bot] c275422251 chore: sync VERSION to 0.1.139 [skip ci] 2026-06-26 09:55:00 +00:00
shaw 9a0fbcc87d chore: update sponsors 2026-06-26 17:32:50 +08:00
Wesley Liddick df99b9417c Merge pull request #3495 from DaydreamCoding/feat/codex-detect-cli-only-engine-fingerprint
feat(codex-detect): codex_cli_only 检测加固 + 引擎指纹统一信号列表 + 账号级 app-server
2026-06-26 16:41:23 +08:00
DaydreamCodingandClaude Opus 4.8 819fda34d9 feat(codex-detect): codex_cli_only 检测加固 + 引擎指纹统一信号列表 + 账号级 app-server
将 codex_cli_only 客户端识别从「单一 strict 开关 + 固定 OR 头集合」重构为
可逐项管理的引擎指纹信号列表,加固整条判定链,并补齐账号级 app-server 控制、
对齐前端设置文案。

判定链(每步可短路):
- 账号未开 codex_cli_only → 不限制;gateway.force_codex_cli → 旁路放行
- 全局黑名单命中(OR 宽 deny)→ 立即拒
- 身份候选:官方 UA(strict,仅前缀)/ 官方 originator(OR)/ 全局白名单(双因子 AND)
  / 全局 app-server 开关 OR 账号 app-server 开关;均不命中 → 拒
- 版本门(仅官方候选):UA 须可解析引擎版本,再校验 [min,max] 区间
- 引擎指纹 AND 硬门:按信号列表逐条勾选 AND、每条行内变体 OR;无 Required 信号 → 放行

引擎指纹信号列表(唯一真源)
- 新增 openai.EngineFingerprintSignal 类型 + EvaluateEngineFingerprint 求值器
  (勾选 AND / 行内变体 OR / 无勾选 → 放行)
- CodexRestrictionPolicy 增 EngineFingerprintSignals;信号列表单一决定是否启用指纹门,
  不再保留独立「要求引擎指纹」总开关(与「信号全不选」语义重复)
- 新设置键 codex_cli_only_engine_fingerprint_signals(默认只勾 x-codex- 前缀);
  旧 body 指纹开关幂等迁移并入信号列表;wire 接线
- 黑/白名单自由条目、命名预设、版本区间 全局设置管线
- gateway 缺 settingService(仅测试/误配可达)时指纹门回退默认种子信号、失败关闭,
  不再因零值 policy(nil 信号)失败开放

账号级 Codex app-server(替换已失效的 ClaudeCode 放行机制)
- account.IsCodexCLIOnlyAppServerAllowed() 读 extra.codex_cli_only_allow_app_server,
  仅在 codex_cli_only 开启时生效;候选身份门「全局 OR 账号」,与旧系统双层控制对齐
- 移除已无入口的 claude_code 预设机制(allowedClientRegistry / MatchAllowedClients /
  账号 GetCodexCLIOnlyAllowedClients / reason);白名单 AllowedClientEntry / IsAllowedClientMatch 保留

门加固(反伪 + 写入校验)
- 官方 UA 访问门改 strict:IsCodexOfficialClientRequestStrict 仅前缀匹配,收窄「浏览器前缀 +
  中段 codex token」伪造面(strict 仍保留 Codex 家族前缀与 UA 尾部兜底,故对「任意前缀 +
  官方尾部 (name;ver)」仍放行——与 UA 可伪造、真正反伪靠引擎指纹门的设计一致)
- 官方客户端识别扩展:新增 codex-tui/、codex_vscode_copilot/ 前缀 + UA 尾部 (name;ver) 兜底
  (恢复 CODEX_INTERNAL_ORIGINATOR_OVERRIDE 的真实 client,如 cccc→codex-tui),originator 改
  精确集。该识别经 IsCodexOfficialClientByHeaders 被 passthrough 复用,故透传的官方判定一并
  修正(codex-tui 等不再被误改写 UA)——非「行为不变」,属有意修正
- 白名单写入校验 ValidateCodexWhitelistEntriesJSON + AllowedClientEntry.IsWhitelistable:
  双因子 AND 条目须可命中(非空 originator + 非空 ua_contains),拒绝写入会静默失效的死规则;
  黑名单(OR 宽 deny,允许 originator-only)不受约束

管理端 / 前端
- handler / DTO / settings_view / 契约测试;gateway 接入判定链
- 信号列表编辑器(替换 body 开关)、api 类型、SettingsView;无勾选给常驻警告
- Create/Edit/Bulk 三弹窗「Codex Only」下新增 app-server 开关(OR 合并全局)
- 文案:UA/Originator → User-Agent/Originator;黑/白名单重命名为 User-Agent/Originator 黑/白名单;
  「允许 App Server 第三方客户端」→「Codex app-server」+ 简介示例;i18n zh/en 同步
- 移除死代码 HasCodex*Fingerprint helper

测试:引擎指纹求值器 / 账号 app-server(OR 语义)/ detector(含 N1 strict、失败关闭)/
白名单写入校验 / BulkEdit spec 等;后端 build + service/openai/admin 单测全绿,前端 vue-tsc + vitest 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:19:41 +08:00
shaw f93a6c50ce fix: repair CI build & lint regressions on main
- openai_gateway_model_availability.go: pass platform through to
  listSchedulableAccounts so OpenAI/Grok diagnosis scopes to the
  correct candidate pool (build break introduced by Grok subscription
  PR #3310).
- no_account_error.go: drop redundant context.Context type on
  ctx := context.Background() to satisfy staticcheck ST1023.
2026-06-26 16:06:01 +08:00
Wesley Liddick a8cfafa02c Merge pull request #3489 from wucm667/fix/responses-anthropic-custom-tool-schema
fix(apicompat): Responses custom/freeform 工具转 Anthropic 时规范化 schema,修复 apply_patch 报错
2026-06-26 15:42:57 +08:00
Wesley Liddick d57106a56f Merge pull request #3490 from wucm667/feat/admin-cli-jwt-auth-fallback
feat(cli): sub2api-admin 支持 SUB2API_JWT 作为认证回退
2026-06-26 15:42:33 +08:00
Wesley Liddick 2fc4fef847 Merge pull request #3310 from heathermhuang/codex/grok-subscription-support
feat: add grok subscription support
2026-06-26 15:41:52 +08:00
shaw fcd3bc1272 fix: return 404 model_not_found instead of 503 when no account supports the model 2026-06-26 15:38:06 +08:00
devnomad-byteandClaude Opus 4.6 98feeccbe1 docs: note admin account wizard requirement in source-compile install
Method 3 (source compile) instructs users to pre-create config.yaml,
but does not mention that doing so disables the setup wizard — the
only code path that creates the initial admin account. Users following
the README end up with a running server and an empty users table, and
login fails with "invalid email or password".

The default.admin_email / default.admin_password fields in config.yaml
are loaded into the config struct but never read by any code path
(setup.SetupConfig.Admin is tagged yaml:"-"), so they cannot serve as
an alternative way to seed the admin.

Add a "Creating the Admin Account" subsection to Method 3 in all three
language READMEs (EN/CN/JA), explaining the wizard dependency and two
workarounds: let the wizard generate config.yaml, or temporarily move
config.yaml aside to trigger the wizard on first run.

Related: #521 (docker variant of the same symptom), #2617, #2350.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 15:19:58 +08:00
Heatherm Huang 44f502bab8 fix: address grok review feedback 2026-06-26 14:26:43 +08:00
wucm667 ac6e36f96b feat(cli): sub2api-admin 支持 SUB2API_JWT 认证回退 2026-06-26 14:11:25 +08:00
wucm667 40c8252734 fix(apicompat): 规范化 custom 工具 schema 2026-06-26 14:08:03 +08:00
Wesley Liddick 5f022663ac Merge pull request #3487 from lyen1688/fix/openai-response-failed-sanitize
fix: 修复 OpenAI response.failed 事件透传过长内容
2026-06-26 13:56:18 +08:00
lyen1688 8a7269f539 fix: sanitize verbose OpenAI response failed events 2026-06-26 12:29:58 +08:00
Wesley Liddick 683a8d8096 Merge pull request #3421 from syx0310/fork/openai-codex-pat-auth-upstream
feat: add codex personal access token auth
2026-06-26 11:15:38 +08:00
Wesley Liddick 54ef446c1d Merge pull request #3469 from wucm667/fix/responses-passthrough-duplicate-function-args
fix(openai): Responses passthrough 不再重复发出 function_call arguments,修复 Codex CLI 解析失败
2026-06-26 10:58:36 +08:00
bwlcandClaude Opus 4.8 815bc6c9b5 feat(risk-control): record matched keyword in keyword-block logs
The risk control center's moderation log records had no field for the
keyword that triggered a keyword block, so the admin UI couldn't show
which keyword was hit (only the application slog logged it).

- migration 156: add matched_keyword column to content_moderation_logs
- ContentModerationLog gains MatchedKeyword; set it on keyword block
- repo CreateLog/ListLogs persist and read the column
- frontend: show "命中关键词" inline in the log table and detail modal
- i18n: add matchedKeyword (zh/en)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:43:05 +08:00
Heatherm Huang 720db8983f test: harden grok quota readiness 2026-06-26 10:42:21 +08:00
Heatherm Huang 2a80495880 docs: clarify grok public route scope 2026-06-26 10:37:37 +08:00
Heatherm Huang 8a87a658ad test: cover grok readiness paths 2026-06-26 10:37:37 +08:00
Heatherm Huang 939905b8c7 fix: refine grok quota pause behavior 2026-06-26 10:37:37 +08:00
Heatherm Huang 0d28642181 feat: add grok quota probe parity 2026-06-26 10:37:37 +08:00
Heatherm Huang 1b9645ca32 fix: allow grok scheduler capability routing 2026-06-26 10:36:09 +08:00
Heatherm Huang e7a4f3f465 test: fix grok oauth validation suite 2026-06-26 10:36:09 +08:00
Heatherm Huang f29ccc7dfb fix: reduce grok oauth account-risk paths 2026-06-26 10:36:09 +08:00
Heatherm Huang b2e2c7e69c fix: harden grok oauth gateway paths 2026-06-26 10:36:09 +08:00
Heatherm Huang b3a07aeae7 fix: align grok oauth exchange with xai 2026-06-26 10:36:09 +08:00
Heatherm Huang 39be1ec97f feat: add grok subscription support 2026-06-26 10:36:09 +08:00
Wesley Liddick fd9c945fab Merge pull request #3470 from wucm667/fix/order-currency-symbol-from-field
fix(admin): 订单金额币种符号读取 currency 字段,修复 USD 渠道显示为 ¥
2026-06-26 09:46:02 +08:00
Wesley Liddick 94c059c99e Merge pull request #3471 from wucm667/fix/antigravity-standard-tier-empty-project-fallback
fix(antigravity): standard-tier 账号 project_id 为空时兜底,避免全部请求 500
2026-06-26 09:45:54 +08:00
Wesley Liddick 1721b2b2d1 Merge pull request #3404 from wucm667/fix/balance-preflight-prevent-overdraft
fix(billing): 余额扣减增加充足性校验,防止余额透支为负
2026-06-26 09:45:46 +08:00
Wesley Liddick 32ddd5c60b Merge pull request #3405 from wucm667/fix/subscription-order-apply-recharge-multiplier
fix(payment): 订阅套餐订单应用 BALANCE_RECHARGE_MULTIPLIER 汇率换算
2026-06-26 09:45:38 +08:00
Wesley Liddick d53dd112ce Merge pull request #3442 from hsn8086/fix/openai-chat-transport-failover
fix(openai): fail over on chat completions transport errors
2026-06-26 09:43:00 +08:00
Wesley Liddick 36a352f5eb Merge pull request #3429 from 404QAQ/fix/images-content-refusal-passthrough
fix(images): pass content-moderation refusals through as 400 instead of retrying
2026-06-26 09:42:50 +08:00
Wesley Liddick 9983f5b80a Merge pull request #3428 from Milesians/codex/fix-subscription-plan-validity-unit
fix(payment): support plural subscription validity units
2026-06-26 09:42:42 +08:00
Wesley Liddick 0bff6f9ac5 Merge pull request #3482 from SMGoro/fix/payment
fix(payment): 修复后端返回空supported_types时支付提供商卡片消失的问题
2026-06-26 09:42:33 +08:00
Wesley Liddick 6db9e42352 Merge pull request #3476 from zeyugao/main
Detect OpenAI overloaded errors by structured code
2026-06-26 09:42:22 +08:00
Wesley Liddick fd0da2570d Merge pull request #3481 from visa2/fix/gateway-glm-codex-tool-args-doubled
fix(apicompat): avoid doubling tool_call arguments from single-chunk upstreams (Codex + GLM)
2026-06-26 09:42:13 +08:00
Wesley Liddick 50c3e52799 Merge pull request #3460 from StarryKira/codex/fix-responses-cache-input
[codex] fix responses cache input for chat completions bridge
2026-06-26 09:42:04 +08:00
Wesley Liddick db1813f7a3 Merge pull request #3434 from feitianbubu/fix/codex-cli-only-chat-completions
fix(gateway): enforce codex_cli_only restriction on /v1/chat/completions
2026-06-26 09:41:46 +08:00
Wesley Liddick 6d50934de6 Merge pull request #3457 from feitianbubu/fix/admin-usage-cache-breakdown
fix(admin/usage): populate cache creation/read token breakdown in stats
2026-06-26 09:41:36 +08:00
Wesley Liddick 27278f62ed Merge pull request #3467 from jianjianai/fix/invalid-refresh-token-nonretryable
fix(token-refresh): treat refresh_token_invalidated as non-retryable / 添加 refresh_token_invalidated 到不可重试列表
2026-06-26 09:40:04 +08:00
Wesley Liddick d65b4c90ad Merge pull request #3435 from zichuanwangcloud-gif/fix/ops-dashboard-chart-infinite-height
fix(ops): prevent monitoring trend cards from growing unbounded
2026-06-26 09:39:06 +08:00
Wesley Liddick e89f4b43c0 Merge pull request #3433 from feitianbubu/fix/cc-terminal-attribution-header
fix(keys): add CLAUDE_CODE_ATTRIBUTION_HEADER=0 to Claude Code terminal templates
2026-06-26 09:38:57 +08:00
Wesley Liddick 38355ed204 Merge pull request #3436 from feitianbubu/fix/email-identity-error-shadowing
fix(auth): stop swallowing email auth identity create error via shadowed err
2026-06-26 09:38:49 +08:00
Wesley Liddick 03f6a28096 Merge pull request #3395 from 404QAQ/fix/errcheck-writestring
fix(lint): check WriteString return value to fix errcheck failure on main
2026-06-26 09:38:37 +08:00
shaw c9f42e1f77 fix(lint): gofmt token_refresh_service.go after refresh_token_invalidated addition 2026-06-26 09:34:20 +08:00
imfangwenjie 2a58a57a7c fix(frontend): use configured API base for direct requests
Route direct fetch calls, setup requests, gateway usage checks, and ops WebSocket connections through the configured API base instead of assuming same-origin backend paths.

Also derive OAuth callback suggestions from the configured API base so split frontend/API deployments show usable redirect URLs.
2026-06-26 02:42:44 +08:00
visa2andClaude Opus 4.8 29122e3051 fix(apicompat): avoid doubling tool_call arguments from single-chunk upstreams
When converting a Chat Completions stream into Responses events, the first
tool_call delta chunk was copied wholesale into stream state (including
function.arguments), then the same chunk's arguments were accumulated again by
the shared `+=` block. For OpenAI this is harmless because its first tool_call
chunk carries empty arguments, but upstreams that pack id+name+arguments into a
single chunk (e.g. GLM/Zhipu) end up with doubled arguments such as
{"cmd":"ls"}{"cmd":"ls"}. Codex then fails to parse the tool call with
"trailing characters", breaking every tool invocation.

Reset the copied arguments so the shared accumulator counts them exactly once,
keeping the emitted delta and the final done/arguments consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:58:38 +08:00
SMGDev 65ad7df4f4 fix(payment): 修复后端返回空supported_types时支付提供商卡片消失的问题
统一处理后端返回的null类型supported_types,将其标准化为空数组,避免调用includes()时报错导致卡片无法显示,同时修复多处相关的类型判断逻辑
2026-06-26 00:50:22 +08:00
Elsa cc7612bdbd Detect OpenAI overloaded error codes 2026-06-25 21:18:39 +08:00
Wesley Liddick ce6af41357 Merge pull request #3473 from DaydreamCoding/fix/gateway-openai-codex-spark-strip-image-tool
fix(gateway-openai): codex spark 剥离 image_generation 工具,修复 502
2026-06-25 17:43:24 +08:00
DaydreamCodingandClaude Opus 4.8 0112782049 fix(gateway-openai): codex spark 剥离 image_generation 工具,修复 502
gpt-5.3-codex-spark 为 text-only,不支持 image_generation 工具;codex CLI 默认
在 /responses 的 tools[] 携带该工具,导致 ChatGPT 后端返回 400
invalid_request_error(param=tools),被 mapUpstreamError 兜底成 502
"Upstream request failed"(真实 400 见 ops_error_logs.upstream_errors)。

新增 stripCodexSparkImageGenerationTools,对 spark 剥离客户端携带的
image_generation 工具(tools 清空则删键),接入三条路径:
- applyCodexOAuthTransformWithOptions:OAuth 全入口(responses/messages/chat-completions)
- buildUpstreamRequestOpenAIPassthrough 无条件 spark 块:覆盖 OAuth+APIKey
  /responses,不受 image-generation 开关影响
- WS stripCodexSparkImageGenerationToolFromRawPayload:覆盖 WebSocket /responses

bridge 注入(ensureOpenAIResponsesImageGenerationTool)本就跳过 spark;真正的缺口
在 normalizeOpenAIResponsesImageGenerationTools 会规范化并保留客户端工具。
临时绕过=换非 spark 模型;关 bridge/allow_image_generation 无效(工具是客户端带的)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:27:19 +08:00
wucm667 650c50e34b fix(antigravity): add project fallback for standard tier 2026-06-25 16:29:33 +08:00
wucm667 55242ffac1 fix(admin): 订单金额币种符号读取 currency 字段 2026-06-25 16:23:45 +08:00
wucm667 2b49d662cc fix(openai): dedupe passthrough function call args 2026-06-25 16:18:11 +08:00
简简aw 0a97a5f461 fix(token-refresh): treat refresh_token_invalidated as non-retryable 2026-06-25 15:12:21 +08:00
shaw 00d68ff6df feat(openai): add GPT-5.5 codex instructions and use as latest fallback 2026-06-25 11:44:42 +08:00
haruka dbdbfb1122 fix: avoid default codex instructions for chat bridge 2026-06-25 02:32:15 +08:00
feitianbubu 4567f6582b test(admin/usage): update sqlmock rows for cache breakdown columns 2026-06-24 23:59:22 +08:00
feitianbubu 063454ae96 fix(admin/usage): populate cache creation/read token breakdown in stats 2026-06-24 23:43:03 +08:00
shaw a1560ae77c chore: update sponsors 2026-06-24 22:14:47 +08:00
shaw 30adee43bc feat(admin/accounts): confirm before OpenAI weekly limit reset 2026-06-24 21:56:03 +08:00
deqiying a2cf297d90 feat: 新增 OpenAI quota headroom 调度权重 2026-06-24 00:13:22 +08:00
hsn 65fa728921 fix(openai): fail over on chat transport errors 2026-06-23 22:53:20 +08:00
feitianbubu 82576e0a38 fix(auth): stop swallowing email auth identity create error via shadowed err 2026-06-23 19:29:17 +08:00
zichuanwangcloud-gifandClaude Opus 4.8 9707dedca2 fix(ops): prevent monitoring trend cards from growing unbounded
The concurrency / switch-rate / throughput cards on the ops dashboard
sit in grid cells that only set `min-h-[360px]` (no definite height).
Their inner card uses `h-full`, which resolves to `auto` when the parent
height is `auto`. Combined with the Chart.js `responsive` +
`maintainAspectRatio: false` charts, this forms a height feedback loop:
the canvas reads the parent height to size itself, the content then grows,
the next ResizeObserver tick reads an even larger height, and the cards
stretch downward without bound.

On wide screens (`lg:grid-cols-4`) a sibling card usually fixes the row
height via `align-items: stretch`, masking the issue. It surfaces when no
sibling bounds the row height — e.g. the single-column (`grid-cols-1`)
stacked layout on narrow viewports, or when the concurrency card collapses
to little content. `min-h` only sets a floor, not a ceiling.

Fix: give the two Chart.js canvas cells a definite height (`h-[360px]`) so
the responsive resize has a fixed reference and the loop cannot run. The
concurrency card is not a responsive canvas, so it keeps `min-h-[360px]`
to avoid clipping its content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:46:17 +00:00
feitianbubu ae5e980dd1 fix(gateway): enforce codex_cli_only restriction on /v1/chat/completions 2026-06-23 18:38:52 +08:00
feitianbubu 28e7adef09 fix(keys): add CLAUDE_CODE_ATTRIBUTION_HEADER=0 to Claude Code terminal templates 2026-06-23 18:27:49 +08:00
404QAQ 9491de0a39 fix(images): pass content-moderation refusals through as 400 instead of retrying
When gpt-image upstream returns response.completed with no image but a text
refusal (content moderation, e.g. the model replies "this request was judged
unsafe to generate"), the soft-failure path treated it as a probabilistic
upstream failure and returned a retryable UpstreamFailoverError (502). Retrying
or switching accounts is futile for a content-policy block — it just burns
other accounts' quota and still surfaces an opaque 502 to the client.

Distinguish the two no-image cases:
  (A) model text refusal  -> 400 content_policy_violation, no retry, refusal
      reason passed through to the client.
  (B) truly empty response -> unchanged: retryable UpstreamFailoverError (502).

Adds extractOpenAIImagesModelRefusal (extracts the refusal text from
output_text.delta / message output_text, capped at 600 chars) and two unit
tests; the existing empty-response retry test is unaffected.
2026-06-23 16:57:16 +08:00
milesians 147c1879d9 fix(payment): support plural subscription validity units 2026-06-23 15:22:55 +08:00
syx0310 32df33a1c3 feat: add codex personal access token auth 2026-06-22 16:07:41 +00:00
shaw 85a3b12254 chore: update sponsors 2026-06-22 16:37:56 +08:00
wucm667 c6f375d3ab fix(payment): 订阅订单应用充值汇率换算 2026-06-22 10:33:26 +08:00
wucm667 9f5b57fc96 fix(billing): 防止余额计费持续透支 2026-06-22 10:30:27 +08:00
shaw e5f38a6f60 chore: update sponsors 2026-06-22 09:55:31 +08:00
github-actions[bot] d430343f51 chore: sync VERSION to 0.1.138 [skip ci] 2026-06-22 01:24:13 +00:00
harukaandClaude Opus 4.8 6239e395b8 i18n(channel): explain case-insensitive matching in pricing conflict messages (#3394)
Update the modelConflict / mappingConflict strings (en + zh) to state that
model names are matched case-insensitively, so an existing entry (e.g.
"GLM-5.2") already covers all case variants and the lowercase variant does not
need to be added. This addresses the confusion in #3394 where the rejection of
a case-only duplicate looked like inconsistent behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:47:49 -07:00
harukaandClaude Opus 4.8 7c2fee6c99 fix(billing): dedup fallback pricing warn to stop per-request log spam (#3394)
The "[Billing] Using fallback pricing for model: X" warn was emitted on
every request for any model not in LiteLLM but matched by getFallbackPricing
(e.g. "glm-5.2" substring-matches the "glm-5" fallback price). Via the stdlib
log bridge it is inferred as WARN and persisted to ops_system_logs, producing
tens of thousands of rows/day. It fired even when channel pricing already
overrides the price, and from several call sites (resolver, account-stats
pricing, non-channel billing, admin lookup).

Dedup the warn at the source (BillingService.GetModelPricing) via a sync.Map
keyed by the already-lowercased model name, so each model logs at most once
per process while keeping one audit line. Billing amounts are unchanged.

Also clarify the model/mapping pattern conflict error to state that names are
matched case-insensitively, so an existing entry (e.g. "GLM-5.2") already
covers all case variants and the lowercase variant need not be added — the
behavior the issue mistook for a case-sensitivity bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:46:52 -07:00
shaw 6936687870 fix(lint): check WriteString return in summarizeOpenAIImagesNoOutputBody
修复 PR #3381 引入的 errcheck lint 错误,satisfy golangci-lint
对 strings.Builder.WriteString 返回值的检查要求。
2026-06-21 21:43:58 +08:00
404QAQ f4b51b0f2b fix(lint): check WriteString return value in summarizeOpenAIImagesNoOutputBody
golangci-lint (errcheck) flagged the unchecked (*strings.Builder).WriteString
return value at openai_images_responses.go:632. Align with the existing
convention used elsewhere in the package (e.g. gateway_service.go) by
explicitly discarding the return values with `_, _ =`.
2026-06-21 21:39:44 +08:00
Wesley Liddick 8105846f33 Merge pull request #3326 from tairan/fix/selinux-bind-mount-labels
fix(deploy): add :Z SELinux labels to bind mounts in compose files
2026-06-21 21:23:27 +08:00
Wesley Liddick f079742bab Merge pull request #3371 from cugxuan/feat/subscription-affiliate-rebate
feat: apply affiliate rebate to subscription payments
2026-06-21 21:18:50 +08:00
Wesley Liddick a560d2f91e Merge pull request #3363 from kangjwme/feat/prefer-soonest-reset-scheduling
feat(scheduling): opt-in "prefer soonest reset" account selection
2026-06-21 21:17:44 +08:00
Wesley Liddick d2ff9c0c4c Merge pull request #3369 from skyswordw/codex/default-ccswitch-openai-gpt-55
fix(ccswitch): default OpenAI import model to gpt-5.5
2026-06-21 21:16:07 +08:00
Wesley Liddick 92ce5bfe24 Merge pull request #3347 from SavitarC/fix/usage-cache-token-tooltip
fix(usage): 显示缓存 Token 明细
2026-06-21 21:15:45 +08:00
Wesley Liddick febfe538b7 Merge pull request #3344 from Milesians/main
fix(frontend): refresh custom page document title
2026-06-21 21:15:17 +08:00
Wesley Liddick 6e27b82335 Merge pull request #3381 from 404QAQ/fix/images-incomplete-failover
fix(images): 识别 response.incomplete 触发 failover + 记录软失败上游响应
2026-06-21 21:14:48 +08:00
Wesley Liddick f6d734ec23 Merge pull request #3308 from a0yark/fix/gemini-tool-schema-cleanup
fix(gemini): clean unsupported tool schema fields
2026-06-21 21:14:09 +08:00
Wesley Liddick af1a032d35 Merge pull request #3375 from StarryKira/fix/3358-vertex-beta-and-cch
fix(gateway): filter anthropic-beta on the Vertex Anthropic path + drop cch sign (#3358)
2026-06-21 21:13:14 +08:00
Wesley Liddick 93de19665b Merge pull request #3359 from alfadb/fix/glm-effort-mapping
修复 GLM 推理强度映射
2026-06-21 21:12:46 +08:00
Wesley Liddick bed12b7016 Merge pull request #3360 from feitianbubu/fix/auto-mode-cc-entrypoint-ide
fix(gateway): in auto mode recognize Claude Code IDE clients via any cc_entrypoint
2026-06-21 21:12:33 +08:00
Wesley Liddick f597e926da Merge pull request #3335 from FjlI5/fix/openai-upstream-endpoint-logging
fix: 修正 chat-only API-key 账号上游端点被误记为 /v1/responses
2026-06-21 21:12:15 +08:00
Wesley Liddick 7b5fbe5197 Merge pull request #3364 from feitianbubu/fix/promo-clear-expiry
fix(promo): allow clearing promo code expiry on edit
2026-06-21 21:12:02 +08:00
Wesley Liddick 04494fd0d9 Merge pull request #3312 from 315944211/codex/update-pnpm-action-setup-node24
chore: update pnpm action setup
2026-06-21 21:11:43 +08:00
Wesley Liddick 945b9b208b Merge pull request #3374 from wucm667/fix/email-binding-enforce-suffix-whitelist
fix(auth): 邮箱身份绑定流程同样校验注册后缀白名单,堵住绕过
2026-06-21 12:56:53 +08:00
404QAQ b0d5592ae2 fix(images): 识别 response.incomplete + 记录软失败上游响应
修复 gpt-image-2 大图/编辑请求 502 报错(社区 issue #2232/#3135/#2516,
现象:upstream did not return image output)。根因是上游生成超时/截断时返回
response.incomplete,旧逻辑只认 error/response.failed,导致:
1) 软失败报成模糊 502 且不触发 failover 换账号重试
2) 上游真实响应未记录,ops_error_logs 里上游信息全空,无法排查

- openAIImagesUpstreamErrorFromSSEPayload 增加 response.incomplete 识别:
  生成超时/截断(max_output_tokens 等) → 可重试 502 触发 failover;
  content_filter/moderation → 400 不重试
- 软失败兜底(无图无标准错误)记录上游诊断摘要到 ops(last_event/status/
  incomplete_reason/body 片段),非流式+流式两条路径一致
- summarizeOpenAIImagesNoOutputBody 提取诊断信息,body 截断上限 1KB

基于官方 v0.1.137 净分支。测试: 5 个新单测 + 图片回归通过 (-tags unit)
2026-06-21 08:34:12 +08:00
harukaandClaude Opus 4.8 5cb8cdd36c test(claude-code): detection recognizes the new-CLI billing block (no cch)
Locks in that Claude Code detection keys on the billing block prefix +
cc_entrypoint=cli, not on the cch field that the new CLI (and now our own
mimicry) no longer sends:

- BillingBlockRecognizedWithoutCCH: an identity-prose-less sub-request whose
  system block is `x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli;`
  (no cch) is still detected as Claude Code.
- NoCCHBlockStillRequiresClaudeCodeUA: dropping cch did not loosen detection —
  a non-claude-cli UA is still rejected, so ClaudeCodeOnly groups can't be spoofed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:02:44 -07:00
harukaandClaude Opus 4.8 6cfb7898df fix(claude-mimicry): drop the cch sign to match new Claude Code CLI
Recent Claude Code CLI versions no longer emit the cch=... signature field in
their x-anthropic-billing-header system block (issue #3358). sub2api still
injected cch=00000 when mimicking Claude Code for OAuth accounts and optionally
signed it, so mimicked requests now diverge from real CLI traffic — the opposite
of what the mimicry is for.

- buildBillingAttributionText emits the block without the cch=00000 segment;
  cc_version + cc_entrypoint=cli are kept (detection and Anthropic's first-party
  signal rely on the block, not on cch).
- Retire signing: remove the two enableCCH signBillingHeaderCCH call sites in
  buildUpstreamRequest / buildCountTokensRequest and delete the now-dead
  signBillingHeaderCCH, cchPlaceholderRe, cchSeed, xxHash64Seeded helpers.
- enable_cch_signing is now a documented no-op (kept for backward compat).
- Drop the obsolete signing tests (TestSignBillingHeaderCCH, TestXXHash64Seeded,
  TestSanitizeMustBeBeforeCCHSigning_HashConsistency) and update the prompt test
  to assert the injected block no longer carries cch=.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:01:29 -07:00
harukaandClaude Opus 4.8 efffd5d791 test(gateway): Vertex anthropic-beta filtering
Covers the #3358 fix:
- StripsUnsupportedClaudeCodeTokens reproduces the prod 400 — the four Vertex-
  rejected tokens (advisor-tool, prompt-caching-scope, redact-thinking,
  thinking-token-count) plus the identity betas are stripped while whitelisted
  tokens survive. Fails before the builder fix, passes after.
- DropsHeaderWhenAllUnsupported: no anthropic-beta header is sent when every
  client token is filtered out.
- BodySanitizeKeysOnFinalBeta: body.context_management is stripped based on the
  final beta, not the raw client value.
- BlocksViaBetaPolicy: an admin block rule on a Vertex account returns BetaBlockedError.
- TestFilterVertexBetaTokens unit-tests whitelist/drop-set/dedupe/empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:53:32 -07:00
harukaandClaude Opus 4.8 40e1cc14b3 fix(gateway): filter anthropic-beta on the Vertex Anthropic path (#3358)
Vertex AI's Anthropic endpoint rejects unknown anthropic-beta tokens with
HTTP 400. buildUpstreamRequestAnthropicVertex forwarded the client header
verbatim via the allowedHeaders whitelist, so recent Claude Code CLIs that
send advisor-tool-2026-03-01, prompt-caching-scope-2026-01-05,
redact-thinking-2026-02-12 and thinking-token-count-2026-05-13 broke every
Vertex service_account request, even though plain account-test requests passed.

This is the only upstream builder that bypassed beta filtering: the
OAuth/API-key path uses computeFinalAnthropicBeta and the Bedrock path uses
filterBedrockBetaTokens. Close the gap with a Vertex-specific whitelist
(vertexSupportedBetaTokens) mirroring bedrockSupportedBetaTokens, plus the
existing BetaPolicy block check:

- evaluateBetaPolicy block check (symmetric to resolveBedrockBetaTokensForRequest)
- filterVertexBetaTokens strips policy-filtered + non-whitelisted tokens
- body context_management sanitize now keys on the final beta, not the raw client value
- overwrite the anthropic-beta header after the whitelist copy loop with the final value

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:51:24 -07:00
wucm667 ecedc7c8d3 fix(auth): enforce email bind suffix whitelist 2026-06-19 21:17:45 +08:00
cugxuan 0fa604ba71 feat: apply affiliate rebate to subscription payments 2026-06-19 17:47:10 +08:00
skywalker d3dfa28f8e Update CC Switch OpenAI default model 2026-06-19 17:11:25 +08:00
feitianbubu 2dc1387b59 fix(promo): allow clearing promo code expiry on edit 2026-06-18 23:07:25 +08:00
kangjwme 510adf703c feat(scheduling): add opt-in "prefer soonest reset" account selection
Adds a use-it-or-lose-it scheduling strategy: prefer accounts whose
session window resets soonest, so near-reset accounts get drained first
instead of accounts whose reset is still far away.

Both schedulers, opt-in, default behavior unchanged:

- Anthropic (gateway_service.go): new GatewaySchedulingConfig
  .PreferSoonestReset flag. When on, the layered load-aware selection
  inserts a filterBySoonestReset stage (priority -> soonest-reset ->
  load -> LRU). Accounts with no active SessionWindowEnd are treated as
  lowest priority; ties fall through to LRU.

- OpenAI/Codex (openai_account_scheduler.go): new "reset" score weight
  in GatewayOpenAIWSSchedulerScoreWeights. Soonest-reset accounts score
  higher; weight defaults to 0 (no effect).

SessionWindowEnd (upstream 5h/quota ResetsAt) is already carried in the
scheduler snapshot, so no snapshot changes are needed.

Documented in deploy/config.example.yaml. Adds unit tests for the
Anthropic filter and the OpenAI reset factor.
2026-06-18 22:50:46 +08:00
feitianbubu e3e31bd4c5 fix(gateway): auto mode recognize Claude Code IDE clients via any cc_entrypoint 2026-06-18 19:22:10 +08:00
alfadb 89cfe24a0c fix(openai): normalize glm reasoning effort 2026-06-18 19:07:08 +08:00
SavitarC 51d722906c fix(usage): 显示缓存 Token 明细 2026-06-18 14:34:13 +08:00
milesians 952be8711b fix(frontend): refresh custom page document title 2026-06-18 12:30:21 +08:00
欧阳丹 df51edfb06 Preserve OAuth instructions while keeping developer input 2026-06-18 03:15:31 +08:00
FjlI5 bab8a9a93e fix(openai): log /v1/chat/completions upstream endpoint for chat-only API-key accounts
DeriveUpstreamEndpoint maps every OpenAI-platform request to /v1/responses, but
API-key accounts whose upstream only speaks Chat Completions
(!ShouldUseResponsesAPI) are forwarded directly to /v1/chat/completions. The
messages, responses and cyber-policy recording sites derived the endpoint via the
bare GetUpstreamEndpoint, so usage/ops records mislabeled those requests as
/v1/responses. Generalize the existing resolveRawCCUpstreamEndpoint into
resolveOpenAIUpstreamEndpoint and use it at every OpenAI recording site, matching
the already-correct chat-completions client path.
2026-06-18 00:29:04 +08:00
WANG TAIRAN 31640363fc fix(deploy): add :Z SELinux labels to bind mounts
Add the :Z (private unshared) SELinux label to all bind-mounted
directories in docker-compose.local.yml and docker-compose.dev.yml.

On systems with SELinux in Enforcing mode (e.g. Fedora, RHEL, CentOS),
containers using bind mounts are denied access to host directories
because the default 'user_home_t' context is not accessible to
container processes. The :Z label tells the container runtime to
relabel the mount point with 'container_file_t' so the container
can read/write it.

Named volumes in docker-compose.yml are not affected because the
runtime already handles their labels automatically.

Fixes permission-denied errors on:
- ./data:/app/data
- ./postgres_data:/var/lib/postgresql/data
- ./redis_data:/data
2026-06-17 12:38:40 +08:00
a0yarkandCursor 8c4a43cf72 fix(gemini): satisfy schema cleanup test lint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 23:19:45 +08:00
315944211 369f53a7a4 chore: force node24 for cla action 2026-06-16 21:12:09 +08:00
315944211 abc203a37b chore: update pnpm action setup 2026-06-16 21:12:09 +08:00
github-actions[bot] 4a5665da5b chore: sync VERSION to 0.1.137 [skip ci] 2026-06-16 12:59:59 +00:00
Wesley Liddick eba9bea959 Merge pull request #3278 from 315944211/feat/show-account-id-in-admin-list
feat: show account id in account list
2026-06-16 20:35:55 +08:00
Wesley Liddick de38d62360 Merge pull request #3250 from WesleyZiwen/codex/anthropic-429-window-reset
fix: preserve Anthropic window cooldowns
2026-06-16 20:34:36 +08:00
Wesley Liddick 9e9e154f57 Merge pull request #3306 from bestony/fix/ip-acl-denial-message
fix(auth): include client ip in acl denial message
2026-06-16 20:32:50 +08:00
Wesley Liddick 7fb3e1aacd Merge pull request #3247 from alfadb/fix/reasoning-and-thinking-protocol
fix(gateway): 整合推理强度与思考协议处理(替代 #2155 / #2136 / #3246)
2026-06-16 20:29:21 +08:00
Wesley Liddick f204216918 Merge pull request #3243 from alfadb/feature/chinese-llm-fallback-pricing
feat(billing): 国产 LLM 兜底定价 (GLM / Kimi / MiniMax) + 收编 DeepSeek V4
2026-06-16 20:28:49 +08:00
Wesley Liddick 03ec90a25a Merge pull request #3223 from feitianbubu/fix/intercept-streaming-haiku-probe
fix: 修复CC Switch改为流式测试后请求失败的问题
2026-06-16 20:27:33 +08:00
shaw 6c7203d83b fix(gateway): preserve SSE event:error body so ops logs reflect real upstream errors
When an Anthropic upstream returned HTTP 200 but then emitted an SSE
`event: error` frame (overloaded_error / rate_limit_error / api_error /
etc.), Forward's stream branch matched on `err.Error() == "have error in
stream"` and returned `UpstreamFailoverError{StatusCode: 403}` with no
ResponseBody. That dropped three pieces of evidence:

- handleFailoverExhausted → ExtractUpstreamErrorMessage(nil) = "" →
  ops_error_logs.upstream_error_message was empty.
- errorPassthroughService.MatchRule(_, 403, nil) could only match rules
  without keywords, so keyword-based passthrough rules silently never
  fired.
- upstream_errors carried no stream_error record, leaving ops looking at
  a generic 403 with no clue whether the upstream was throttled,
  overloaded, or rejecting the request. ping-during-slot-wait amplified
  this by skipping failover (writerSizeBeforeForward guard), so 403s
  ballooned in the ops view well past the upstream's actual rate.

Fix:
- Introduce *sseStreamErrorEventError that carries the SSE data line.
  Error() still returns "have error in stream" so existing log searches
  keep working.
- Forward extracts via errors.As, appends an OpsUpstreamErrorEvent
  (kind="stream_error", with the sanitized message and a truncated raw
  body honoring LogUpstreamErrorBody*), and returns
  UpstreamFailoverError{StatusCode: 403, ResponseBody: rawJSON}.

StatusCode 403 is preserved verbatim: mapUpstreamError, failover
decisions (shouldFailoverUpstreamError(403)=true), client-visible message,
RetryableOnSameAccount, and rateLimitService side-effects (this path
already didn't invoke them) all match prior behavior. OAuth and API Key
accounts share this path; the API-Key passthrough branch is independent
and already forwards SSE error frames untouched, so it's unaffected.

Adds four unit tests: typed-error contract + RawData, empty data line,
event:error after partial stream output (streamStarted=true), and
non-JSON data line.
2026-06-16 20:25:09 +08:00
alfadb a05d9e87c0 feat(billing): 国产模型 thinking-enabled 自动填充 reasoning_effort 默认值
问题:Kimi/GLM/MiniMax 等国产 LLM 协议层只有 thinking on/off 开关,没有
reasoning_effort 档位概念。客户端启用 thinking 后 usage_log.reasoning_effort
长期为 NULL,无法在用量分析里区分 'thinking 开启' 与 'thinking 关闭'。

方案:仅在 'thinking 启用 + 上游属于 passback-required 国产模型 + 客户端
未明确指定 effort' 三者同时成立时,给 usage_log.reasoning_effort 写默认值
'high'(与 DeepSeek thinking-enabled 默认 effort 一致)。

设计原则:
1. **白名单**:仅 ResolveThinkingProtocol == PassbackRequired 集合内,
   且排除原生支持 effort 的 DeepSeek(避免覆盖客户端意图)。
2. **fail-open**:客户端显式传 effort 时永远不覆盖。
3. **未来兼容**:如 Kimi 后续加入真 effort 档位,客户端开始发 effort,
   guard (3) 自动让出,本逻辑变 no-op。

实现:
- gateway_request.go: 加 DefaultEffortForThinkingEnabled (按模型白名单)
  + OpenAIBodyHasThinkingEnabled (检测 OpenAI 协议 body 里的 thinking.type)
  + ApplyThinkingEnabledFallback (包装现有 extractor 的 nil-then-default 逻辑)
- gateway_handler.go: Anthropic 路径两处(主 + retry)对称补充
- OpenAI 路径全覆盖:openai_gateway_service.go (passthrough + non-passthrough)
  + openai_gateway_chat_completions_raw.go + openai_gateway_responses_chat_fallback.go
  + openai_ws_http_bridge.go + openai_ws_forwarder.go
- 跨协议路径:gateway_forward_as_chat_completions.go (CC client → Anthropic upstream)
  + gateway_forward_as_responses.go (Responses client → Anthropic upstream)
  + gemini_chat_completions_compat_service.go (一致性保持)

未覆盖:openai_ws_v2_passthrough_adapter.go 两处。原因:该 adapter 持有的是
session-level 客户端原始 model,没有 *Account 句柄无法走 GetMappedModel。
WS v2 当前对国产模型场景不重要(pi 调用 Kimi/GLM/MiniMax 走 sync HTTP),
留待后续如果出现 WS v2 + 国产模型用例时单独处理。

测试:
- TestDefaultEffortForThinkingEnabled (14 用例):覆盖 Kimi/GLM/MiniMax 大小写、
  Qwen thinking 变体、DeepSeek 排除、Claude/GPT/Gemini 不命中。
- TestOpenAIBodyHasThinkingEnabled (8 用例):covers enabled/adaptive/disabled、
  大小写、空 body、缺字段、invalid JSON fail-safe。
- TestApplyThinkingEnabledFallback (9 用例):现有 effort 不覆盖、nil + 启用 +
  passback → high、nil + disabled → nil、nil + 启用 + 排除模型 → nil。
2026-06-16 19:37:31 +08:00
alfadb 5c5283979b doc(thinking-protocol): clarify mappedModel vs originalModel semantics per call path
回应 PR #3247 Copilot review:

1. NormalizeChineseLLMThinking 文档写 'MiniMax M3 / M3.x' 但实现覆盖
   minimax-m* (M2.x 也命中)。改成 'MiniMax M-series (M2.x/M3.x)'。

2. Copilot 质疑 gemini_messages_compat 传 originalModel 不是
   mappedModel 是误用。实际是有意为之的跨协议场景:
   - 上游是 Gemini,但被剥离的 body 是 Anthropic 格式
   - 剥离逻辑要按客户端请求的 Anthropic 子协议族判定
   - 传 mappedModel (gemini-3.1-pro) 会被判为 Unknown→不剥离→retry 死循环

   未改代码逻辑,改为更新文档:
   - thinking_protocol.go ResolveThinkingProtocol 添加「调用路径语义」
     说明 Anthropic gateway 与 Gemini compat 两条路径的参数语义差异。
   - gemini_messages_compat_service.go 调用点加 inline 注释解释
     路径语义为什么该传 originalModel。
2026-06-16 19:37:31 +08:00
alfadb 56c6325d15 fix(gateway): rewrite thinking.type=enabled to adaptive for MiniMax M-series
\u95ee\u9898\uff1aAnthropic-SDK \u5ba2\u6237\u7aef\uff08\u5982 pi-ai\uff09\u5728 budget-based thinking \u8def\u5f84\u4e0a\u9ed8\u8ba4\u53d1
`thinking: { type: 'enabled', budget_tokens: ... }` \u7ed9\u4efb\u4f55 Anthropic \u517c\u5bb9\u4e0a\u6e38\uff0c\u5305\u62ec
MiniMax M3 / M2.x \u3002\u4f46 MiniMax \u5b98\u65b9\u6587\u6863\u660e\u786e\u5217\u51fa\uff1athinking.type \u53ea\u63a5\u53d7
"adaptive" \u6216 "disabled"\uff0c\u4e0d\u63a5\u53d7 "enabled"\u3002\u9020\u6210\u4e0a\u6e38\u53ef\u80fd 400 / \u9759\u9ed8\u5ffd\u7565\u3002

\u4fee\u590d\uff1a\u5728 Anthropic forward \u8def\u5f84\u4e0a\u3001StripEmptyTextBlocks \u4e4b\u540e\u3001\u91cd\u8bd5\u5faa\u73af\u4e4b\u524d\uff0c
\u52a0\u4e00\u4e2a NormalizeChineseLLMThinking \u9884\u6539\u5199\u6b65\u9aa4\uff1a
- \u4ec5\u5bf9 ResolveThinkingProtocol == PassbackRequired \u4e0a\u6e38\u751f\u6548\uff08claude-* \u4e0d\u4f1a\u8fdb\u6765\uff09
- \u4ec5\u5339\u914d minimax-m* \u524d\u7f00\u7684\u6620\u5c04\u540e\u6a21\u578b ID
- \u4ec5\u5f53 thinking.type == "enabled" \u65f6\u6539\u5199\u4e3a "adaptive"
- Kimi/GLM/DeepSeek \u7684 enabled \u4e0d\u53d7\u5f71\u54cd\uff08\u8fd9\u4e9b\u4e0a\u6e38\u63a5\u53d7 enabled\uff09

\u8bbe\u8ba1\u539f\u5219\uff1a
1. "\u767d\u540d\u5355\u8bed\u4e49"\uff1a\u53ea\u5bf9\u5df2\u77e5\u9700\u8981\u5904\u7406\u7684\u4e0a\u6e38\u6539\u5199\uff0c\u4e0d\u505a\u9690\u5f0f\u5e7f\u8c31\u8865\u4e01\u3002
2. "\u4f4e\u4fb5\u5165\u6027"\uff1aMiniMax \u4e4b\u5916\u7684\u6a21\u578b\u96f6\u6539\u52a8\uff0c\u73b0\u6709 4 \u4e2a\u4e0a\u6e38\u7684\u884c\u4e3a\u4e0d\u53d8\u3002
3. "\u53ef\u8bca\u65ad"\uff1a\u6539\u5199\u65f6 LegacyPrintf \u8f93\u51fa\u4e00\u884c\u65e5\u5fd7\uff0c\u4fbf\u4e8e\u8c03\u8bd5\u3002

\u6d4b\u8bd5\uff1a9 \u4e2a\u65b0\u7528\u4f8b\u8986\u76d6 MiniMax M3/M2.7\u3001adaptive/disabled \u4e0d\u53d8\u3001\u65e0 thinking
\u5b57\u6bb5\u3001Kimi/GLM/DeepSeek/Claude \u4e0d\u53d7\u5f71\u54cd\u3001invalid JSON fail-safe\u3002

\u4f9d\u8d56\uff1a\u672c\u5206\u652f\u57fa\u4e8e fix/thinking-block-protocol-aware-filter\uff0c\u7528\u5176
ResolveThinkingProtocol \u4f5c\u4e3a passback-required \u5224\u522b\u6e90\u3002\u4e0a\u6e38 PR \u987a\u5e8f\u9700\u5148\u5408
fix/thinking-block-protocol-aware-filter\uff08PR #2136\uff09\u518d\u5408\u672c PR\u3002
2026-06-16 19:37:31 +08:00
alfadb efbf6d2092 fix(test): update FilterThinkingBlocksForRetry call to use mappedModel param
The function signature was changed to require a mappedModel parameter
for protocol-aware thinking-block filtering, but this test call site
was not updated. Without the fix, the unit test build fails on CI.
2026-06-16 19:37:31 +08:00
alfadbandClaude Opus 4.7 6baf00d784 fix(gateway): protocol-aware thinking-block filtering for Anthropic-compatible upstreams
The gateway's thinking-block handling was designed for Anthropic's strict
semantics (drop blocks with missing/invalid signature), but third-party
Anthropic-compatible upstreams have INVERTED semantics:

  * DeepSeek `/anthropic`, Kimi `/coding`, GLM, Moonshot, qwen-*-thinking
    require ALL historical thinking blocks to round-trip verbatim.
  * Stripping any of them produces:
    400 "The content[].thinking in the thinking mode must be passed back
    to the API"

Without this fix, every multi-turn request from a thinking-capable client
(Claude Code, pi, etc.) to such upstreams loses its thinking blocks and
fails. This becomes especially painful when an account's model_mapping
maps `claude-sonnet-4-6 → deepseek-v4-pro` — `reqModel` looks Anthropic
but the upstream contract is the opposite.

Approach
--------

Branch all thinking-block transforms by the *mapped* model id (after
account model_mapping is applied), classifying into three families:

  * `anthropic-strict`     claude-/opus-/sonnet-/haiku-      → existing behaviour
  * `passback-required`    deepseek-/kimi-/moonshot-/glm-/   → preserve verbatim
                           qwen-*-thinking
  * `unknown`              other models                       → conservative
                                                               (preserve, no retry)

Affected entry points (all guarded):

  * Pre-filter on outbound:   `FilterThinkingBlocks`
    Previously dropped blocks with missing/invalid signature; now skips
    entirely for non-strict families. Pre-filter is needed because the
    post-error retry path can run out of budget on long conversations
    (maxRetryElapsed = 10 s).
  * 400 retry rectifier:      `FilterThinkingBlocksForRetry`
    Disables top-level thinking and converts thinking → text. Now skips
    for passback-required (those 400s aren't signature errors and any
    transformation breaks the round-trip contract).
  * 400 retry rectifier (tools): `FilterSignatureSensitiveBlocksForRetry`
    Same family-aware short-circuit.
  * 400 detector:             `shouldRectifySignatureError`
    Returns false for passback-required, so the retry path doesn't even
    fire.

Tests
-----

  * `thinking_protocol_test.go` — classifier across all known vendor
    prefixes plus edge cases (empty, case, qwen non-thinking).
  * `thinking_protocol_filter_integration_test.go` — locks in that the
    three filter entry points return the body byte-for-byte unchanged
    when the model id is passback-required or unknown, and still strip
    invalid blocks for anthropic-strict.

This PR supersedes #1350 (which only added the pre-filter without the
upstream-family awareness, and would have made third-party upstreams
worse). Once merged, please close #1350.

Reference issues:
  - NousResearch/hermes-agent#16748 — DeepSeek /anthropic strip behaviour
  - NousResearch/hermes-agent#15700 — DeepSeek thinking:disabled requirement

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-16 19:37:31 +08:00
alfadb 34b1e56e29 test: add 'max' → 'xhigh' test cases for reasoning effort normalization
Cover the DeepSeek reasoning_effort 'max' alias in:
- TestExtractOpenAIReasoningEffortFromBody
- TestExtractCCReasoningEffortFromBody
- TestExtractResponsesReasoningEffortFromBody
2026-06-16 19:37:31 +08:00
alfadb 142d8c3618 fix(gateway): normalize DeepSeek reasoning_effort 'max' to 'xhigh'
pi maps defaultThinkingLevel 'xhigh' → 'max' for deepseek-v4-pro via
thinkingLevelMap, but normalizeOpenAIReasoningEffort did not recognize
'max', causing all reasoning_effort to be dropped (0% fill rate since
traffic switched from claude to deepseek on May 1).

Add 'max' → 'xhigh' mapping to match Claude's NormalizeClaudeOutputEffort
behavior.
2026-06-16 19:37:31 +08:00
alfadb 262fe1230d feat(billing): 为 doubao-embedding-vision 添加图文差别兜底定价
火山方舟 doubao-embedding-vision 多模态向量化按量付费官方价为文本 ¥0.7/MTok、图片 ¥1.8/MTok,二者不同价。原计费引擎对 embedding 仅记单一 input token、无图片输入档位,无法表达该差别。

变更:
- ModelPricing 新增 ImageInputPricePerToken 字段
- OpenAIUsage / UsageTokens 新增 ImageInputTokens 字段
- extractOpenAIEmbeddingsUsage 解析 usage.prompt_tokens_details.image_tokens
- CalculateCost 拆分文本/图片输入计费;ImageInputTokens 为 0 时走原单价路径,存量 chat/vision 流量行为不变
- getFallbackPricing 新增 doubao-embedding-vision 分支(most-specific-first,覆盖带版本后缀别名),fallback 表填入 $0.098/$0.252 per MTok(汇率 ÷7.14)

测试:新增图文混合/纯文本/图片 token 超额三类用例及定价回退断言;service 包单测与 golangci-lint 全通过。
2026-06-16 19:37:02 +08:00
alfadb 4f5f2788e1 fix(billing): add kimi-for-coding fallback pricing 2026-06-16 19:37:02 +08:00
alfadb c90089c814 fix(billing): address Copilot review feedback
应 PR #3243 review 修正三项问题:

1. 删除 DeepSeek V4 fallback 定价的重复块(cherry-pick 时
   的旧位置 + 新国产 LLM 分段都写了一遍,后写覆盖前写,
   当前值相同所以无 bug,但冗余,未来改一处易漏另一处)。
2. 删除 minimax-m3 匹配中的死代码——modelLower 已经
   strings.ToLower(),'MiniMax-M3' 字面量永远不可能匹中。
3. 测试结构改 *float64 替代 0 sentinel,让 free-tier
   模型(GLM-4.5-Flash / GLM-4.7-Flash)的 0 价能被
   真正断言而非默默跳过;定义内联 floatPtr 辅助函数。
2026-06-16 19:37:02 +08:00
alfadb a4ce73391b feat(billing): add GLM / Kimi / MiniMax fallback pricing for Chinese LLM providers
将 fix/deepseek-fallback-pricing 的 DeepSeek V4 Pro/Flash 定价收编,
并扩展国产 LLM 兜底定价覆盖:

- 智谱 GLM (z.ai 公开 SKU 13 个): glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 /
  glm-4.7-flashx / glm-4.6 / glm-4.5 / glm-4.5-x / glm-4.5-air / glm-4.5-airx /
  glm-4-32b-0414-128k / glm-4.5-flash / glm-4.7-flash
- 月之暗面 Kimi K 系列 4 个: kimi-k2.6 / kimi-k2.5 / kimi-k2-thinking /
  kimi-k2 (K2-0905/K2-0711 官方未保留定价,隐性回退到 kimi-k2)
- MiniMax M 系列 6 个: minimax-m3 (≤512K) / minimax-m2.7 / minimax-m2.7-highspeed /
  minimax-m2.5 / minimax-m2.1 / minimax-m2
- DeepSeek V4 沿用原 fix/deepseek-fallback-pricing 的 entry 移植过来

所有定价数据来自各家官方定价页 (USD/MTok 口径),与现有
Claude/GPT 风格保持一致 (硬编码在 Go 代码里,admin channel 优先)。

匹配策略:长 key 优先 (glm-5.1 优先于 glm-5, k2.6 优先于 k2);
未列出的国产厂商 alias (qwen/doubao/hunyuan) 一律不返回兜底价,
避免误计价——这与 fix/deepseek-fallback-pricing 的白名单语义一致。

数据源注释 (Source URL) 内嵌在每个 initFallbackPricing 分组开头,
便于后续 PR 同步上游价格变化。
2026-06-16 19:37:02 +08:00
alfadb f597d98bdf test(openai): use unpriced model in usage test 2026-06-16 19:37:02 +08:00
alfadb 5a593a511e test(billing): tighten DeepSeek V4 fallback assertions; clarify branch comments
Address copilot-pull-request-reviewer feedback on #2157:

- billing_service_test.go: extend TestGetFallbackPricing_FamilyMatching
  with optional expectedOutput / expectedCacheRead fields and assert
  full Input/Output/CacheRead pricing for all 4 DeepSeek cases
  (v4-pro, v4-flash, deepseek-chat→flash, deepseek-reasoner→flash),
  preventing silent regression to 0 output/cache cost.
- billing_service.go: rewrite the DeepSeek block comment to explicitly
  describe its scope (V4 Pro/Flash + chat/reasoner aliases, no
  unknown-deepseek fallback) and tighten the OpenAI comment to make
  it unambiguous that it only describes the OpenAI/Codex branch
  immediately below it.
2026-06-16 19:37:02 +08:00
alfadb 27e26a3a90 chore: fix gofmt alignment 2026-06-16 19:37:02 +08:00
alfadb c906bf000e feat(billing): add DeepSeek V4 Pro / Flash fallback pricing
Pricing source: https://api-docs.deepseek.com/quick_start/pricing

deepseek-v4-pro:  input $0.435/M, output $0.87/M, cache_read $0.003625/M
deepseek-v4-flash: input $0.14/M,  output $0.28/M, cache_read $0.0028/M

Also map legacy aliases deepseek-chat / deepseek-reasoner → v4-flash.
2026-06-16 19:37:02 +08:00
a0yarkandCursor 6c2db4f461 fix(gemini): clean unsupported tool schema fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 18:05:47 +08:00
shaw b8a482e127 fix(ci): unblock main after recent merges
Three independent CI blockers landed on main from concurrent PR merges:

- openai_quota_service.go (introduced by b8169492): const block spacing
  not gofmt-compliant + trailing blank line. golangci-lint v2.9 flagged it
  on every push after the merge.
- openai_images_failover_test.go (introduced by PR #3155, da30c599):
  NewOpenAIGatewayHandler call missing the opsService argument added by
  PR #3230 (b62b573f). Test was authored before #3230 and merged without
  rebase, causing "not enough arguments" compile error.
- account_quota_reset_test.go: TestIsFixedDailyPeriodExpired_NotExpired
  and TestIsFixedWeeklyPeriodExpired_NotExpired used time.Now()-1min as
  periodStart, which crosses the 09:00 UTC reset boundary when CI runs in
  the 09:00:00-09:00:59 window. Anchoring periodStart to today's 12:00
  UTC removes the race.
2026-06-16 17:59:06 +08:00
Wesley Liddick 44f5791008 Merge pull request #3154 from wucm667/fix/responses-session-hash-input-field
fix(openai): Responses API session hash fallback 纳入 input 内容,避免 sticky 绑死账号
2026-06-16 17:01:16 +08:00
Wesley Liddick 9c2c8ab3e5 Merge pull request #3155 from wucm667/fix/openai-images-server-error-failover
fix(openai): 图像接口上游 server_error 触发 failover 换号,不再 5xx 透传
2026-06-16 17:00:40 +08:00
Bestony@Homelab 56c62c59c8 fix(auth): include client ip in acl denial message 2026-06-16 17:00:35 +08:00
Wesley Liddick e4ccb75d0f Merge pull request #3220 from wucm667/fix/oauth-signup-apply-promo-code
fix(auth): OAuth 注册支持应用 URL 上的 promo_code 优惠码
2026-06-16 16:59:38 +08:00
Wesley Liddick 2ce8788929 Merge pull request #3299 from alfadb/fix/openai-responses-probe-tool-capability
fix(openai-probe): /responses 能力探测增加工具调用校验
2026-06-16 16:58:48 +08:00
Wesley Liddick 2e0ff1cfd5 Merge pull request #3258 from bwliangc/feat/channel-monitor-jitter
feat(渠道监控): 检测间隔支持正负随机抖动配置
2026-06-16 16:58:23 +08:00
Wesley Liddick 16765bde69 Merge pull request #3230 from DaydreamCoding/feat/openai-cyber-policy-passthrough
feat(openai): cyber_policy 硬阻断全链路透传、审计与计费
2026-06-16 16:55:51 +08:00
shaw b816949291 feat(openai-quota): query + reset rate-limit credits for OpenAI accounts
Adds an admin-side action that mirrors the Codex Desktop "rate-limit reset"
flow against chatgpt.com upstream for OpenAI OAuth accounts.

Backend
- OpenAIQuotaService.QueryUsage / ResetCredit hit /wham/usage and
  /wham/rate-limit-reset-credits/consume with the Codex Desktop header set,
  reusing OpenAITokenProvider for refreshed tokens and PrivacyClientFactory
  for the impersonated Chrome TLS fingerprint.
- Honors the account's configured proxy by reading the eager-loaded
  account.Proxy directly (falls back to proxyRepo only when missing).
- GET /api/v1/admin/openai/accounts/:id/quota
  POST /api/v1/admin/openai/accounts/:id/reset-quota
- Wire DI for the new service + handler dependency.

Frontend
- OpenAIQuotaResetCell renders a single action row in AccountUsageCell's
  OpenAI section: the existing local "查询" (active sampling) is injected
  via #pre-actions, alongside a "次数 N" button that doubles as the
  upstream query trigger and the available-credit indicator, and a "重置"
  button that consumes one credit.
- No duplicate 5h/7d window display; the local UsageProgressBar owns those
  bars to avoid confusion.
2026-06-16 16:55:07 +08:00
alfadb b88f8e4c04 fix(openai-probe): /responses 能力探测增加工具调用校验
原探测仅以 HTTP 状态码判定 /v1/responses 端点是否存在(404/405 视为不存在,其余视为存在),无法识别"端点存在、基础补全可用、但工具调用不可用"的上游。火山方舟 coding/v3 的 kimi-k2.6 即属此类:携带 tools 的请求仅返回 reasoning、不产出 function_call,导致账号被误判 openai_responses_supported=true。网关据此走 /responses 转换路径后工具调用结果丢失,带工具的请求确定性失败。

改动:
- 探测请求携带一个工具并以 tool_choice=required 强制调用,仅当响应 output 数组包含 function_call 项时判定为支持;2xx 但无 function_call 判定为不支持,使网关改走 /v1/chat/completions 直转路径(同一模型在该路径下工具调用正常)。
- 探测模型改用账号 model_mapping 中的真实上游模型;占位模型在第三方上游会返回 400 model-not-found,无法判定能力。
- 探测超时 8s 调整为 15s(探测在后台异步执行,为推理型模型先推理再产出工具调用预留时间)。
- 非 2xx(404/405 除外)仍保守判定为支持,不改变既有账号行为;运行时回退函数 isResponsesEndpointSupportedByStatus 保持不变。
- 新增单元测试覆盖判定逻辑与探测模型选择。
2026-06-16 15:59:00 +08:00
shaw f069c9ae00 fix(outbox-dedup): buildSchedulerGroupPayload typed-nil broke dedup_key consistency
#3255 introduced payload-aware dedup_key (sha256 over event_type, account_id,
group_id, payload_json). enqueueSchedulerOutbox checks "if payload != nil"
before json.Marshal — but Go interface containing a typed-nil map (returned
by buildSchedulerGroupPayload(empty)) is NOT == nil at the interface level.

So an ungrouped account's account_changed event went through this path:
  payload := buildSchedulerGroupPayload(account.GroupIDs)  // typed-nil map
  enqueueSchedulerOutbox(..., payload)                     // interface != nil
    → json.Marshal(typedNilMap) = "null"
    → dedup_key hash = sha256(... + "null")

While other call sites pass literal nil:
  enqueueSchedulerOutbox(..., nil)                         // interface == nil
    → payloadJSON stays empty
    → dedup_key hash = sha256(... + "")

The two dedup_keys differ for what should be the same logical event,
silently degrading dedup effectiveness in bursts on ungrouped accounts.

Fix: change buildSchedulerGroupPayload return type from map[string]any to
any so empty input returns true untyped-nil. All call sites pass the
result straight to enqueueSchedulerOutbox(payload any) — no inspection,
no breakage.

Adds regression test TestEnqueueSchedulerOutbox_UngroupedAccountDedupesWithLiteralNilPayload
asserting (1) typed-nil regression doesn't sneak back, (2) dedup_key for
empty-groups payload matches the literal-nil-payload key.
2026-06-16 14:21:09 +08:00
shaw acaffe29ec fix(account-repo): refresh candidates SQL excluded healthy accounts; fix CI build
Post-merge audit of #3272 found two regressions:

1. ListOAuthRefreshCandidates used "AND NOT (a AND b)" which, under PG
   3-valued logic, evaluates to NULL when both temp_unschedulable_until
   and temp_unschedulable_reason are NULL — i.e., the common healthy
   account state. Such rows were silently excluded from the background
   token refresh worker, so their OAuth access tokens would never get
   refreshed and eventually start returning 401.

   Verified empirically against PostgreSQL: only 3 of 5 test rows
   matched before the fix; after switching to "(a AND b) IS NOT TRUE"
   the expected 4 rows match.

2. The new ListOAuthRefreshCandidates method on AccountRepository was
   not implemented on stubAccountRepo in api_contract_test.go (build
   tag "unit"), breaking "make test-unit" which CI runs in
   .github/workflows/backend-ci.yml.

Tests:
- Added IS NOT TRUE and "AND NOT (" assertions to the SQL-shape unit
  test so the predicate can't regress to the broken form again.
- "go test -tags=unit ./internal/..." now passes cleanly.
2026-06-16 14:08:50 +08:00
shaw 2ba52bf4aa Merge pull request #3274 from jianjianai/fix/scheduler-outbox-cleanup
fix: cleanup consumed scheduler outbox rows / 添加 scheduler_outbox 表的清理代码,避免表过大

Additional hardening: WHERE clause adds a 10s grace via
created_at < NOW() - INTERVAL '10 seconds' to defend against the PG
sequence-id vs commit race (id assigned in tx, commit delayed past
watermark advance). Without it, slow committers could lose their
outbox row before the snapshot poller reads it.
2026-06-16 11:57:43 +08:00
shaw 31dc8913ac chore(outbox-cleanup): add 10s grace to defend against id-vs-commit race
PG sequences advance outside transactions, so a slow committer can hold
an id that gets surpassed by the watermark before its commit becomes
visible. Without the grace period, cleanup would delete such rows before
the snapshot poller ever sees them. 10s is a comfortable upper bound
on realistic enqueueSchedulerOutbox commit latency.
2026-06-16 11:57:32 +08:00
jjaw cb14935e9a fix: cleanup consumed scheduler outbox rows 2026-06-16 11:56:40 +08:00
shaw 4254dcb352 Merge pull request #3255 from jianjianai/fix1/outbox-scheduler-snapshot-coalesce
降低高频账号状态变更下的 outbox 写入压力 / safely coalesce scheduler outbox events

Migrations renumbered 151/152 -> 152/153 to avoid collision with #3232.
2026-06-16 11:50:35 +08:00
shaw 1fdbe52f96 chore(migrations): renumber scheduler outbox dedup migrations 151/152 -> 152/153
#3232 (already merged) occupies 151. Renumber #3255's dedup migrations to
avoid collision and keep linear ordering. Updated runner constant + tests.
2026-06-16 11:50:25 +08:00
jjaw b3ec6288ad fix: release scheduler outbox dedup on claim 2026-06-16 11:48:13 +08:00
jjaw 60cf89ae26 fix: recover scheduler outbox invalid dedup index 2026-06-16 11:48:13 +08:00
jjaw 3ef70b045d fix: safely coalesce scheduler outbox events 2026-06-16 11:48:12 +08:00
jjaw 34e66ec0a5 fix: outbox scheduler snapshot coalesce 2026-06-16 11:48:12 +08:00
shaw 45f3b0dd74 Merge pull request #3272 from jianjianai/fix/token-refresh-retry-backoff
fix: token refresh 筛选避免拉取全部账号导致数据库爆炸 / reduce token refresh retry amplification
2026-06-16 11:47:07 +08:00
jjaw 9b270f11d7 refactor: inline token refresh retry reason prefix 2026-06-16 11:44:39 +08:00
jjaw 74199b6a6e fix: reduce token refresh retry amplification 2026-06-16 11:44:39 +08:00
shaw b56c501d30 Merge pull request #3273 from jianjianai/fix/redis-wait-queue-hotpath
fix: move user wait queue accounting off hot path / 用户槽位立即成功的请求不调用等待队列
2026-06-16 11:43:07 +08:00
jjaw b0579c4891 fix: move user wait queue accounting off hot path 2026-06-16 11:41:54 +08:00
shaw 87d0f8da66 Merge pull request #3132 from jianjianai/main
修复 token refresh 拉取大量账号时触发 PostgreSQL 参数上限
2026-06-16 11:40:47 +08:00
jjaw 8b698ff4c1 fix account list parameter limit 2026-06-16 11:39:15 +08:00
Wesley Liddick 62fef6ebed Merge pull request #3178 from jianjianai/fix/fix-failover-error-body-reuse
fix: 修复 OpenAI failover 错误响应体重复读取 / reuse cached OpenAI failover error body
2026-06-16 11:13:10 +08:00
Wesley Liddick 8ddc138094 Merge pull request #3232 from jianjianai/fix1/account-expiry-autopause-index
fix:account expiry autopause index / 账号过期自动禁用索引优化
2026-06-16 11:12:57 +08:00
Wesley Liddick 2f1f1971b4 Merge pull request #3195 from jianjianai/fix/invalid-refresh-token-nonretryable
fix: treat invalid_refresh_token as non-retryable / 将 invalid_refresh_token 判定为不可重试
2026-06-16 11:12:45 +08:00
Wesley Liddick 9acd104151 Merge pull request #3201 from alfadb/fix/dockerfile-copy-docs-legal
fix(docker): 将 docs/legal 纳入构建上下文以支持 admin-compliance gate
2026-06-16 11:12:34 +08:00
Wesley Liddick 15b0753c1d Merge pull request #3283 from wucm667/fix/non-json-200-response-failover
fix(gateway): 上游 200 返回非 JSON 错误报文时纳入 failover,避免坏上游被反复命中误扣费
2026-06-16 11:12:21 +08:00
Wesley Liddick a0fc2c4143 Merge pull request #3218 from wucm667/fix/antigravity-system-role-message
fix(antigravity): 处理 messages 中 role:system 消息,修复 Claude Code 接入 400
2026-06-16 11:12:08 +08:00
Wesley Liddick 28bed8b866 Merge pull request #3252 from wucm667/fix/upstream-zstd-response-decompression
fix(gateway): 解压 zstd 上游响应体,修复非流式 usage 静默记 0 的计费丢失
2026-06-16 11:11:51 +08:00
shaw bbd9702496 fix(frontend): bump form-data to >=4.0.6 via pnpm override
Resolves GHSA-hmw2-7cc7-3qxx (CRLF injection) flagged by
frontend-security CI. axios pulls form-data ^4.0.5 which locked to the
vulnerable 4.0.5; override forces all transitive consumers to 4.0.6+
without needing an audit exception.
2026-06-16 10:48:12 +08:00
Wesley Liddick 715594847b Merge pull request #3267 from codeQuest-fly/codex/fix-sub2api-injection
feat: configure Claude OAuth system prompt blocks
2026-06-16 10:43:38 +08:00
Wesley Liddick 29978ac9a6 Merge pull request #3251 from wucm667/fix/chatcompletions-responses-tool-strict-default
fix(apicompat): chat-completions 转 Responses 显式补全 tool strict=false,避免可选参数被填零值
2026-06-16 10:26:39 +08:00
dailingfei b63b411654 fix: remove unused billing attribution helper 2026-06-15 17:16:26 +08:00
wucm667 ab9987b2e2 fix(gateway): fail over on non-JSON 2xx responses 2026-06-15 11:04:24 +08:00
315944211 25a9762a17 feat: show account id in account list 2026-06-15 00:02:04 +08:00
dailingfei 8ce7b9a8f6 feat: configure Claude OAuth system prompt blocks 2026-06-13 04:12:13 +08:00
bwlcandClaude Fable 5 c70c6a2659 feat(渠道监控): 检测间隔支持正负随机抖动配置
新增 jitter_seconds 配置:每轮调度在 interval 基础上 ± [0, jitter]
均匀随机偏移触发,避免多个监控以固定节奏同步请求上游。

- ent schema 新增 jitter_seconds 字段(默认 0),附迁移 151
- 校验:jitter >= 0 且 interval - jitter >= 15s(创建/更新均校验)
- runner 由固定 ticker 改为每轮重新随机化的 timer,0 抖动时行为不变
- 前端监控表单新增「随机抖动 (± 秒)」输入框,上限随间隔联动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:09:53 +08:00
wucm667 c1c28ac7bb fix(gateway): 解压 zstd 上游响应体 2026-06-12 15:06:01 +08:00
wucm667 edfd5e3736 fix(apicompat): default tool strict to false 2026-06-12 15:01:58 +08:00
wangziwenunique f6e0ebc6bf fix: preserve Anthropic window cooldowns 2026-06-12 13:35:03 +08:00
DaydreamCodingandClaude Opus 4.8 b62b573f7f feat(openai): cyber_policy 硬阻断全链路透传、审计与计费
上游对单次请求下发 error.code=cyber_policy 硬阻断时,网关在所有端点
(/v1/responses、/v1/chat/completions、/v1/messages、WebSocket)及流式/
非流式路径下,将该结果原样透传给客户端,绝不 failover、换号或同步拦截;
命中后异步完成审计与计费:

- 风控中心记录 cyber_policy 留痕并发送通知邮件,落库先于发信,SMTP 阻塞
  不影响留痕
- ops 错误请求记录,状态码对齐客户端实际接收(流式 200 / 非流式 400)
- 用量明细标记 request_type=cyber,按上游真实 token 计费,HTTP 与
  WebSocket 计费口径统一,零 token 命中不误扣
- 会话级自动屏蔽(管理员开关,默认关):命中的会话在可配 TTL 内本地拦截
  不再发往上游,仅屏蔽该会话不影响同 Key 其他会话
- 封号计数排除开关:可选让 cyber 命中不计入自动封号,命中当次不判定且
  历史行在违规计数中一并排除

WebSocket 多轮连接下 cyber 标记按 turn 生命周期管理,逐轮独立检测与记录;
透传的错误响应不被兜底逻辑追加内容污染。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:47:01 +08:00
jjaw e4c255a77a fix:account expiry autopause index 2026-06-12 01:38:09 +08:00
feitianbubu b256f91141 fix(gateway): intercept max_tokens=1 haiku probes for streaming requests too 2026-06-11 20:46:47 +08:00
wucm667 f8c80bf038 fix(auth): apply promo codes to oauth signups 2026-06-11 18:55:27 +08:00
wucm667 65559ac589 fix(antigravity): merge system role messages 2026-06-11 18:37:00 +08:00
方程andClaude Opus 4.7 6bf6722bae chore: gofmt test file
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 17:45:09 +08:00
方程andClaude Opus 4.7 0da1fe28ef fix(openai): prevent false image billing on text-only /v1/responses requests
Two defects in the image output counter could inflate imageCount for
text-only responses, causing per-image charges instead of token billing:

1. addDataArray counted every element in a top-level "data" array
   without checking whether each element contained actual image output
   (url/b64_json).  Non-image objects in a "data" array are now skipped.

2. addImageOutputItem accepted image_generation.completed items with
   no result/b64_json/url, inflating the count.  These items are now
   filtered the same way as every other item type.

Fixes #3196

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 16:56:43 +08:00
jjaw 727ac3f689 fix: add app_session_terminated to non-retryable refresh errors / 将 app_session_terminated 添加到不可重试的刷新错误中 2026-06-10 22:32:04 +08:00
alfadb ad13585456 fix(docker): ship docs/legal in build context for admin-compliance gate
LegalDocumentView.vue (admin-compliance acknowledgement gate) build-time
imports ../../../../docs/legal/*.md?raw. The Docker image build broke
because the frontend-builder stage only COPYs frontend/ (never docs/) and
.dockerignore excludes both docs/ and *.md from the build context.

Upstream CI runs `pnpm build` from the repo root (docs/ resolvable via
../docs/) and never exercises the Docker path, so this stayed hidden until
the buildkit package job surfaced "Could not resolve docs/legal/...md?raw".

Fix:
- COPY docs/legal/ into /app/docs/legal in Dockerfile and deploy/Dockerfile
  so it sits beside /app/frontend (WORKDIR), matching the relative import.
  Only the required subtree is copied to keep the build dependency minimal.
- Re-include docs/legal/*.md in .dockerignore so buildkit ships the subtree.
2026-06-10 16:34:19 +08:00
github-actions[bot] e34ad2b194 chore: sync VERSION to 0.1.136 [skip ci] 2026-06-10 07:02:12 +00:00
jjaw fa8f1749f5 fix: treat invalid_refresh_token as non-retryable / 将 invalid_refresh_token 判定为不可重试 2026-06-10 14:21:44 +08:00
jjaw 46bd7968a3 fix: reuse OpenAI failover error body 2026-06-09 21:56:35 +08:00
wucm667 da30c59923 fix(openai): fail over image server errors 2026-06-09 13:57:45 +08:00
wucm667 a67b10f468 fix(gateway): anchor responses fallback to input 2026-06-09 13:53:19 +08:00
579 changed files with 42886 additions and 4442 deletions
+5
View File
@@ -11,6 +11,11 @@
*.md
!deploy/DOCKER.md
docs/
# admin-compliance gate (LegalDocumentView.vue) build-time imports
# docs/legal/*.md?raw into the frontend bundle; keep that subtree in the
# Docker build context even though docs/ is otherwise excluded.
!docs/legal/
!docs/legal/*.md
# IDE
.idea/
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 9
- name: Setup Node.js
+3
View File
@@ -12,6 +12,9 @@ permissions:
pull-requests: write
statuses: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
cla-check:
if: |
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 9
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 9
- name: Set up Node.js
+2
View File
@@ -119,6 +119,8 @@ tests
CLAUDE.md
.claude
scripts
!backend/scripts/
!backend/scripts/resolve-version.sh
.code-review-state
#openspec/
code-reviews/
+8 -3
View File
@@ -27,8 +27,13 @@ RUN corepack enable && corepack prepare pnpm@9 --activate
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Copy frontend source and build
# Copy frontend source and build.
# LegalDocumentView.vue (admin-compliance gate) build-time imports
# ../../../../docs/legal/*.md?raw, so docs/legal/ must sit beside frontend/
# in the image (WORKDIR /app/frontend -> resolves to /app/docs/legal/*.md).
# Copy only that subtree to keep the build dependency minimal.
COPY frontend/ ./
COPY docs/legal/ /app/docs/legal/
RUN pnpm run build
# -----------------------------------------------------------------------------
@@ -62,9 +67,9 @@ COPY backend/ ./
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 > cmd/server/VERSION
# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
RUN VERSION_VALUE="${VERSION}" && \
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \
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 \
-tags embed \
+124 -29
View File
@@ -12,7 +12,7 @@
**AI API Gateway Platform for Subscription Quota Distribution**
English | [日本語](README_JA.md)
English | [中文](README_CN.md) | [日本語](README_JA.md)
</div>
@@ -24,22 +24,6 @@ Please read the following carefully before using this project:
- **⚖️ Compliant Use**: Use this project only in compliance with the laws and regulations of your country or region. Any unlawful use is strictly prohibited.
- **📖 Disclaimer**: This project is provided for technical learning and research purposes only. The authors assume no liability for account bans, service interruptions, data loss, or any other direct or indirect damages resulting from the use of this project.
## Overview
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
## Features
- **Multi-Account Management** - Support multiple upstream account types (OAuth, API Key)
- **API Key Distribution** - Generate and manage API Keys for users
- **Precise Billing** - Token-level usage tracking and cost calculation
- **Smart Scheduling** - Intelligent account selection with sticky sessions
- **Concurrency Control** - Per-user and per-account concurrency limits
- **Rate Limiting** - Configurable request and token rate limits
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
- **Admin Dashboard** - Web interface for monitoring and management
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
## ❤️ Sponsors
> [Want to appear here?](mailto:support@pincc.ai)
@@ -66,11 +50,6 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
<td>Thanks to SilkAPI for sponsoring this project! <a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay.</td>
</tr>
<tr>
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
<td>Thanks to YLS Code for sponsoring this project! <a href="https://ylscode.com/">YLS Code</a> is dedicated to building secure enterprise-grade Coding Agent productivity services, offering stable and fast Codex / Claude / Gemini subscription services along with pay-as-you-go API options for flexible choices. Register now for a limited-time 3-day Codex trial bonus!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
@@ -97,12 +76,6 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=fu2E"><img src="assets/partners/logos/runapi.png" alt="RunAPI" width="150"></a></td>
<td>Thanks to RunAPI for sponsoring this project! <a href="https://runapi.co/register?aff=fu2E">RunAPI</a> is an efficient and stable API platform and OpenRouter alternative. With one API Key, you can access 150+ popular models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with pricing as low as 10% of the original rate. It is highly stable and seamlessly compatible with tools such as Claude Code and OpenClaw.
</td>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>Thanks to Unity2 for sponsoring this project! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> is a high-performance AI model API relay for individuals, teams, and enterprises, handling 30B+ tokens/day with 5000 RPM concurrency. One API Key works across Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows, with balance billing, bundled subscriptions, enterprise invoicing, and 1-on-1 support. <a href="https://unity2.ai/register?source=sub2api">Register</a> to claim $2 in balance, plus $10 more by joining the official group — up to $12 in free credit.
@@ -121,8 +94,63 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. <a href="https://apikl.com">Register now</a> for a 1:1 top-up bonus — double your balance!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>Thanks to TokenEum for sponsoring this project! <a href="https://tokeneum.ai">TokenEum</a> is a comprehensive AI model aggregation platform and intelligent agent development company. It brings together top-tier international models — including Claude, Gemini, and OpenAI — alongside leading open-source models such as GLM, Qwen, and Kimi, offering a wide range of options across different quality and price tiers to suit every need. TokenEum also provides access to cutting-edge video generation models like Seedance2.0 and Happy Horse. Committed to transparency and honest business practices, TokenEum ensures all model information is accurate and reliable. Visit <a href="https://tokeneum.ai">tokeneum.ai</a> to get started.
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>Thanks to 666api for sponsoring this project! <a href="https://666api.work/sub2api">666api</a> is an all-in-one platform offering:<br>
⚡ API Relay — Pay-as-you-go access to global models sourced 100% from official providers, up to 75% off official pricing<br>
&nbsp;&nbsp;&nbsp;&nbsp;Exclusive: Zhipu GLM 50% off · DeepSeek V4-pro 50% off · Seedance 2.0 8% off (whitelisted) · HappyHorse Overseas 30% off (whitelisted)<br>
🔑 GPT Subscription Accounts (same-origin IP included) · Global Residential IP <br>
💰 Invoices supported
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陆地区的开发者请点击这里</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>Thanks to MiyaIP for sponsoring this project! <a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> is a platform dedicated to global residential proxy network services, committed to providing high-quality, pure overseas residential IP resources for enterprise developers, cross-border business teams, and AI application users. It delivers stable, independent overseas network environments for AI platforms, overseas SaaS, and other online services, supporting multi-region access testing and project environment isolation. Ideal for development and testing scenarios that require access to overseas AI services, such as: AI model platform access, AI development testing, AI SaaS service usage, AI API debugging, and multi-region network environment validation.
</td>
</tr>
</table>
## Overview
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
## Features
- **Multi-Account Management** - Support multiple upstream account types (OAuth, API Key)
- **API Key Distribution** - Generate and manage API Keys for users
- **Precise Billing** - Token-level usage tracking and cost calculation
- **Smart Scheduling** - Intelligent account selection with sticky sessions
- **Concurrency Control** - Per-user and per-account concurrency limits
- **Rate Limiting** - Configurable request and token rate limits
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
- **Admin Dashboard** - Web interface for monitoring and management
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
## Ecosystem
Community projects that extend or integrate with Sub2API:
@@ -419,7 +447,8 @@ pnpm run build
# 4. Build backend with embedded frontend
cd ../backend
go build -tags embed -o sub2api ./cmd/server
VERSION="$(./scripts/resolve-version.sh)"
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
# 5. Create configuration file
cp ../deploy/config.example.yaml ./config.yaml
@@ -520,6 +549,25 @@ If you disable URL validation or response header filtering, harden your network
- Enforce TLS-only outbound traffic
- Strip sensitive upstream response headers at the proxy
#### ⚠️ Important: Creating the Admin Account
The initial admin account is **only created via the setup wizard** (served at `http://<host>:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons.
Because step 5 above pre-creates `config.yaml`, the setup wizard will be **skipped on first run**: the server detects an existing config and boots straight into normal mode with an empty `users` table, so the first login attempt fails with `invalid email or password`.
**Two ways to create the admin account:**
1. **Recommended — let the wizard generate `config.yaml`:** Skip step 5 (do not run the `cp`). Start `./sub2api` directly; the setup wizard at `http://localhost:8080` walks you through database, Redis, and admin account setup, then writes `config.yaml` for you.
2. **If you already created `config.yaml`:** Temporarily move it aside so the wizard can trigger on first run, then restore it afterwards:
```bash
mv config.yaml config.yaml.bak
./sub2api # wizard runs at http://localhost:8080 and writes a fresh config.yaml
# stop the server (Ctrl+C) once the wizard completes, then restore your config:
mv config.yaml.bak config.yaml
./sub2api # restart in normal mode and log in with the admin you just created
```
```bash
# 6. Run the application
./sub2api
@@ -559,6 +607,53 @@ Simple Mode is designed for individual developers or internal teams who want qui
---
## Grok / xAI OAuth Support
Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenAI-compatible Responses traffic to xAI.
### Supported Scope
- Platform name: `grok`
- Account type: OAuth subscription accounts
- Public Responses targets: `/v1/responses`, `/responses`, and `/backend-api/codex/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
- Public Claude-compatible target: `/v1/messages`, converted to xAI Responses and returned as Anthropic Messages output for Claude CLI style clients
- Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/chat/completions`
- Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream
- Initial models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
- Out of scope for this provider: image, video, TTS, transcription, browser automation, cookies, and Grok web scraping
### OAuth Configuration
The Grok OAuth flow uses PKCE and does not require committing private secrets. The default client details follow the public xAI OAuth flow used by compatible clients, and every value can be overridden by environment variable:
| Variable | Default |
|----------|---------|
| `XAI_OAUTH_CLIENT_ID` | Public xAI OAuth client ID |
| `XAI_OAUTH_SCOPE` | `openid profile email offline_access grok-cli:access api:access` |
| `XAI_OAUTH_REDIRECT_URI` | `http://127.0.0.1:56121/callback` |
| `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` |
| `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` |
| `XAI_BASE_URL` | `https://api.x.ai/v1` |
Administrators can create or reauthorize Grok accounts from the dashboard, or use the admin API:
| Endpoint | Purpose |
|----------|---------|
| `POST /api/v1/admin/grok/oauth/auth-url` | Generate an xAI OAuth authorization URL |
| `POST /api/v1/admin/grok/oauth/exchange-code` | Exchange a callback URL, query string, or code for OAuth credentials |
| `POST /api/v1/admin/grok/oauth/refresh-token` | Validate or refresh a Grok refresh token |
| `POST /api/v1/admin/grok/accounts/:id/refresh` | Refresh an existing Grok account |
Credential storage reuses the existing account JSON fields: `access_token`, `refresh_token`, `token_type`, `expires_at`, optional `email`, optional `subscription_tier`, and `entitlement_status`.
### Usage And Quota Display
xAI quota is passive. Sub2API does not invent subscription quota values; it records whitelisted xAI rate-limit headers from successful or rate-limited upstream responses when xAI sends them. Before the first usable upstream response, the dashboard shows quota as unknown and still displays local Sub2API usage stats.
`401` responses mark the account as needing reauthorization. `403` responses are treated as entitlement or subscription-tier failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
---
## Antigravity Support
Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models.
+769
View File
@@ -0,0 +1,769 @@
# Sub2API
<div align="center">
[![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/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)
<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
**AI API 网关平台 - 订阅配额分发管理**
[English](README.md) | 中文 | [日本語](README_JA.md)
</div>
## ⚠️ 重要提醒
使用本项目前,请务必仔细阅读以下内容:
- **🚨 服务条款风险**:使用本项目可能违反 Anthropic 等上游服务商的服务条款。请在使用前仔细阅读相关服务商的用户协议,由此产生的一切风险由用户自行承担。
- **⚖️ 合规使用**:请在符合您所在国家或地区法律法规的前提下使用本项目,严禁将其用于任何违法违规用途。
- **📖 免责声明**:本项目仅供技术学习与研究使用,作者不对因使用本项目导致的账户封禁、服务中断、数据丢失或其他任何直接或间接损失承担责任。
## ❤️ 赞助商
> [想出现在这里?](mailto:support@pincc.ai)
<table>
<tr>
<td width="180"><a href="https://www.openmodel.ai?ref=sub2api"><img src="assets/partners/logos/openmodel.jpg" alt="openmodel" width="150"></a></td>
<td>一个API,顶级模型随便用!<a href="https://www.openmodel.ai?ref=sub2api">OpenModel</a> 专注于生产级、高可用的 AI API 网关,让你的应用真正做到高速稳定:自动故障转移、智能选最优渠道、生产级 SLA 保障。远超单一供应商的 SLA,让稳定性成为您的核心竞争力。</td>
</tr>
<tr>
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
<td>感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://etok.ai">这里</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
</tr>
<tr>
<td width="180"><a href="https://code.silkapi.com/register?aff=SUB2API"><img src="assets/partners/logos/silkapi.png" alt="silkapi" width="150"></a></td>
<td>感谢 丝绸API 赞助了本项目! <a href="https://code.silkapi.com/register?aff=SUB2API">丝绸API</a> 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
</tr>
<tr>
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
<td>感谢 Bestproxy 赞助了本项目!<a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> 是一家提供高纯度住宅IP,支持一号一IP独享,结合真实家庭网络与指纹隔离,可实现链路环境隔离,降低关联风控概率。</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=1tsfr51"><img src="assets/partners/logos/pateway.png" alt="pateway" width="150"></a></td>
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连的高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
同时支持企业级高并发,并为企业客户提供了专业的管理平台,企业客户可签订正式合同并开具发票,更多详情进入官网获取联系方式。
现在通过 <a href="https://pateway.ai/?ch=1tsfr51">此链接</a> 注册即送 $3 试用额度,用户充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150。</td>
</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>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>感谢 Unity2 赞助本项目! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。同时支持余额计费、组合订阅、首充优惠、企业开票、专属 1v1 对接,适合个人高频使用和企业长期接入。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度,适合先体验后长期使用。<a href="https://unity2.ai/register?source=sub2api">注册链接</a>
</td>
</tr>
<tr>
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
<td>感谢 Veilx 赞助本项目! <a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN 专为超大规模 API 请求场景打造,针对 AI 中转站业务与 AI API 调用链路进行了深度优化,轻松应对高并发、高频请求与大流量传输,为开发者与企业提供更快、更稳、更低延迟的加速体验。无论是 OpenAI、Claude、Gemini 等 AI 接口中转,还是聊天、绘图、Embedding、流式输出等复杂场景,Veilx 都能显著提升响应速度与连接稳定性,有效降低网络波动带来的超时与失败问题。同时,Veilx 提供中国三网优化回国极速线路,大幅提升中国大陆地区访问海外 AI 服务的速度与稳定性,特别适合全球 AI 中转平台、海外 AI SaaS、跨境业务与高并发 API 系统部署。专为 AI API 而生,让你的 AI 中转服务更快、更稳、更省心。<a href="https://veilx.io/#/hello/SJRBRVDV">购买地址</a>
</td>
</tr>
<tr>
<td width="180"><a href="https://roxybrowser.com/invite/bgGKG7"><img src="assets/partners/logos/RoxyBrowser.png" alt="RoxyBrowser" width="150"></a></td>
<td>感谢 RoxyBrowser 赞助本项目!<a href="https://roxybrowser.com/invite/bgGKG7">RoxyBrowser</a> 是 Sub2API 的理想搭档:内置原生 Roxy AI Agent 与高质量原生住宅 IP,支持通过简单命令实现批量自动化,显著提升多账号管理的安全性与效率!点击<a href="https://roxybrowser.com/invite/bgGKG7">此链接</a>注册,可领取免费住宅 IP 套餐与终身 9 折优惠。
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。<a href="https://apikl.com">立即注册</a>即享充值 1:1 赠送 — 余额翻倍!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>感谢 TokenEum 赞助本项目!<a href="https://tokeneum.ai">TokenEum</a> 是一家综合性 AI 模型聚合平台与智能体开发公司,汇聚 Claude、Gemini、OpenAI 等国际顶级模型,以及 GLM、Qwen、Kimi 等主流开源模型,提供不同质量与价格梯度的丰富选择,满足多样化需求。平台还接入了 Seedance2.0、Happy Horse 等前沿视频生成模型。秉持透明诚信的经营理念,TokenEum 确保所有模型信息真实可靠。访问 <a href="https://tokeneum.ai">tokeneum.ai</a> 开始使用。
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>感谢 666api 赞助本项目!<a href="https://666api.work/sub2api">666api</a> 是一站式综合服务平台,提供:<br>
⚡ API 中转 — 全球模型按量计费接入,100% 官方源直供,最高 75 折优惠<br>
&nbsp;&nbsp;&nbsp;&nbsp;独家特惠:智谱 GLM 5 折 · DeepSeek V4-pro 5 折 · Seedance 2.0 0.8 折(白名单)· HappyHorse 海外版 3 折(白名单)<br>
🔑 GPT 订阅账号(含同源 IP)· 全球住宅 IP<br>
💰 支持开票
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 Sub2Api 的用户提供了专属福利:通过<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠 <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">>>For developers outside Mainland China, please click here</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>感谢 MiyaIP 赞助本项目!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> 是一家专注于全球住宅代理网络服务的平台,致力于为企业开发者、跨境业务团队及AI 应用用户提供高质量、纯净的海外住宅 IP 资源。为 AI 平台、海外 SaaS 及其他在线服务提供稳定、独立的海外网络环境,支持多地区访问测试和项目环境隔离。适用于需要访问海外 AI 服务的开发和测试场景,例如:AI 模型平台访问、AI 开发测试、AI SaaS 服务使用、AI API 调试、多地区网络环境验证
</td>
</tr>
</table>
## 项目概述
Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 API 配额。用户通过平台生成的 API Key 调用上游 AI 服务,平台负责鉴权、计费、负载均衡和请求转发。
## 核心功能
- **多账号管理** - 支持多种上游账号类型(OAuth、API Key)
- **API Key 分发** - 为用户生成和管理 API Key
- **精确计费** - Token 级别的用量追踪和成本计算
- **智能调度** - 智能账号选择,支持粘性会话
- **并发控制** - 用户级和账号级并发限制
- **速率限制** - 可配置的请求和 Token 速率限制
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([配置指南](docs/PAYMENT_CN.md))
- **管理后台** - Web 界面进行监控和管理
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能
## 生态项目
围绕 Sub2API 的社区扩展与集成项目:
| 项目 | 说明 | 功能 |
|------|------|------|
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付配置指南](docs/PAYMENT_CN.md) |
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 |
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端 | Go 1.25.7, Gin, Ent |
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ |
---
## Nginx 反向代理注意事项
通过 Nginx 反向代理 Sub2API(或 CRS 服务)并搭配 Codex CLI 使用时,需要在 Nginx 配置的 `http` 块中添加:
```nginx
underscores_in_headers on;
```
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
---
## 部署方式
### 方式一:脚本安装(推荐)
一键安装脚本,自动从 GitHub Releases 下载预编译的二进制文件。
#### 前置条件
- Linux 服务器(amd64 或 arm64)
- PostgreSQL 15+(已安装并运行)
- Redis 7+(已安装并运行)
- Root 权限
#### 安装步骤
```bash
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
```
脚本会自动:
1. 检测系统架构
2. 下载最新版本
3. 安装二进制文件到 `/opt/sub2api`
4. 创建 systemd 服务
5. 配置系统用户和权限
#### 安装后配置
```bash
# 1. 启动服务
sudo systemctl start sub2api
# 2. 设置开机自启
sudo systemctl enable sub2api
# 3. 在浏览器中打开设置向导
# http://你的服务器IP:8080
```
设置向导将引导你完成:
- 数据库配置
- Redis 配置
- 管理员账号创建
#### 升级
可以直接在 **管理后台** 左上角点击 **检测更新** 按钮进行在线升级。
网页升级功能支持:
- 自动检测新版本
- 一键下载并应用更新
- 支持回滚
#### 常用命令
```bash
# 查看状态
sudo systemctl status sub2api
# 查看日志
sudo journalctl -u sub2api -f
# 重启服务
sudo systemctl restart sub2api
# 卸载
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
```
---
### 方式二:Docker Compose(推荐)
使用 Docker Compose 部署,包含 PostgreSQL 和 Redis 容器。
#### 前置条件
- Docker 20.10+
- Docker Compose v2+
#### 快速开始(一键部署)
使用自动化部署脚本快速搭建:
```bash
# 创建部署目录
mkdir -p sub2api-deploy && cd sub2api-deploy
# 下载并运行部署准备脚本
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
# 启动服务
docker compose up -d
# 查看日志
docker compose logs -f sub2api
```
**脚本功能:**
- 下载 `docker-compose.local.yml`(本地保存为 `docker-compose.yml`)和 `.env.example`
- 自动生成安全凭证(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD)
- 创建 `.env` 文件并填充自动生成的密钥
- 创建数据目录(使用本地目录,便于备份和迁移)
- 显示生成的凭证供你记录
#### 手动部署
如果你希望手动配置:
```bash
# 1. 克隆仓库
git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api/deploy
# 2. 复制环境配置文件
cp .env.example .env
# 3. 编辑配置(生成安全密码)
nano .env
```
**`.env` 必须配置项:**
```bash
# PostgreSQL 密码(必需)
POSTGRES_PASSWORD=your_secure_password_here
# JWT 密钥(推荐 - 重启后保持用户登录状态)
JWT_SECRET=your_jwt_secret_here
# TOTP 加密密钥(推荐 - 重启后保留双因素认证)
TOTP_ENCRYPTION_KEY=your_totp_key_here
# 可选:管理员账号
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# 可选:自定义端口
SERVER_PORT=8080
```
**生成安全密钥:**
```bash
# 生成 JWT_SECRET
openssl rand -hex 32
# 生成 TOTP_ENCRYPTION_KEY
openssl rand -hex 32
# 生成 POSTGRES_PASSWORD
openssl rand -hex 32
```
```bash
# 4. 创建数据目录(本地版)
mkdir -p data postgres_data redis_data
# 5. 启动所有服务
# 选项 A:本地目录版(推荐 - 易于迁移)
docker compose -f docker-compose.local.yml up -d
# 选项 B:命名卷版(简单设置)
docker compose up -d
# 6. 查看状态
docker compose -f docker-compose.local.yml ps
# 7. 查看日志
docker compose -f docker-compose.local.yml logs -f sub2api
```
#### 部署版本对比
| 版本 | 数据存储 | 迁移便利性 | 适用场景 |
|------|---------|-----------|---------|
| **docker-compose.local.yml** | 本地目录 | ✅ 简单(打包整个目录) | 生产环境、频繁备份 |
| **docker-compose.yml** | 命名卷 | ⚠️ 需要 docker 命令 | 简单设置 |
**推荐:** 使用 `docker-compose.local.yml`(脚本部署)以便更轻松地管理数据。
#### 启用“数据管理”功能(datamanagementd)
如需启用管理后台“数据管理”,需要额外部署宿主机数据管理进程 `datamanagementd`。
关键点:
- 主进程固定探测:`/tmp/sub2api-datamanagement.sock`
- 只有该 Socket 可连通时,数据管理功能才会开启
- Docker 场景需将宿主机 Socket 挂载到容器同路径
详细部署步骤见:`deploy/DATAMANAGEMENTD_CN.md`
#### 访问
在浏览器中打开 `http://你的服务器IP:8080`
如果管理员密码是自动生成的,在日志中查找:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
```
#### 升级
```bash
# 拉取最新镜像并重建容器
docker compose -f docker-compose.local.yml pull
docker compose -f docker-compose.local.yml up -d
```
#### 轻松迁移(本地目录版)
使用 `docker-compose.local.yml` 时,可以轻松迁移到新服务器:
```bash
# 源服务器
docker compose -f docker-compose.local.yml down
cd ..
tar czf sub2api-complete.tar.gz sub2api-deploy/
# 传输到新服务器
scp sub2api-complete.tar.gz user@new-server:/path/
# 新服务器
tar xzf sub2api-complete.tar.gz
cd sub2api-deploy/
docker compose -f docker-compose.local.yml up -d
```
#### 常用命令
```bash
# 停止所有服务
docker compose -f docker-compose.local.yml down
# 重启
docker compose -f docker-compose.local.yml restart
# 查看所有日志
docker compose -f docker-compose.local.yml logs -f
# 删除所有数据(谨慎!)
docker compose -f docker-compose.local.yml down
rm -rf data/ postgres_data/ redis_data/
```
---
### 方式三:源码编译
从源码编译安装,适合开发或定制需求。
#### 前置条件
- Go 1.21+
- Node.js 18+
- PostgreSQL 15+
- Redis 7+
#### 编译步骤
```bash
# 1. 克隆仓库
git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api
# 2. 安装 pnpm(如果还没有安装)
npm install -g pnpm
# 3. 编译前端
cd frontend
pnpm install
pnpm run build
# 构建产物输出到 ../backend/internal/web/dist/
# 4. 编译后端(嵌入前端)
cd ../backend
VERSION="$(./scripts/resolve-version.sh)"
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
# 5. 创建配置文件
cp ../deploy/config.example.yaml ./config.yaml
# 6. 编辑配置
nano config.yaml
```
> **注意:** `-tags embed` 参数会将前端嵌入到二进制文件中。不使用此参数编译的程序将不包含前端界面。
**`config.yaml` 关键配置:**
```yaml
server:
host: "0.0.0.0"
port: 8080
mode: "release"
database:
host: "localhost"
port: 5432
user: "postgres"
password: "your_password"
dbname: "sub2api"
redis:
host: "localhost"
port: 6379
password: ""
jwt:
secret: "change-this-to-a-secure-random-string"
expire_hour: 24
default:
user_concurrency: 5
user_balance: 0
api_key_prefix: "sk-"
rate_multiplier: 1.0
```
### Sora 功能状态(暂不可用)
> ⚠️ 当前 Sora 相关功能因上游接入与媒体链路存在技术问题,暂时不可用。
> 现阶段请勿在生产环境依赖 Sora 能力。
> 文档中的 `gateway.sora_*` 配置仅作预留,待技术问题修复后再恢复可用。
### Sora 媒体签名 URL(功能恢复后可选)
当配置 `gateway.sora_media_signing_key` 且 `gateway.sora_media_signed_url_ttl_seconds > 0` 时,网关会将 Sora 输出的媒体地址改写为临时签名 URL(`/sora/media-signed/...`)。这样无需 API Key 即可在浏览器中直接访问,且具备过期控制与防篡改能力(签名包含 path + query)。
```yaml
gateway:
# /sora/media 是否强制要求 API Key(默认 false)
sora_media_require_api_key: false
# 媒体临时签名密钥(为空则禁用签名)
sora_media_signing_key: "your-signing-key"
# 临时签名 URL 有效期(秒)
sora_media_signed_url_ttl_seconds: 900
```
> 若未配置签名密钥,`/sora/media-signed` 将返回 503。
> 如需更严格的访问控制,可将 `sora_media_require_api_key` 设为 true,仅允许携带 API Key 的 `/sora/media` 访问。
访问策略说明:
- `/sora/media`:内部调用或客户端携带 API Key 才能下载
- `/sora/media-signed`:外部可访问,但有签名 + 过期控制
`config.yaml` 还支持以下安全相关配置:
- `cors.allowed_origins` 配置 CORS 白名单
- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
- `security.url_allowlist.enabled` 可关闭 URL 校验(慎用)
- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 HTTP URL
- `security.url_allowlist.allow_private_hosts` 允许私有/本地 IP 地址
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
- `security.csp` 配置 Content-Security-Policy
- `billing.circuit_breaker` 计费异常时 fail-closed
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
- `turnstile.required` 在 release 模式强制启用 Turnstile
**网关防御纵深建议(重点)**
- `gateway.upstream_response_read_max_bytes`:限制非流式上游响应读取大小(默认 `8MB`),用于防止异常响应导致内存放大。
- `gateway.proxy_probe_response_read_max_bytes`:限制代理探测响应读取大小(默认 `1MB`)。
- `gateway.gemini_debug_response_headers`:默认 `false`,仅在排障时短时开启,避免高频请求日志开销。
- `/auth/register`、`/auth/login`、`/auth/login/2fa`、`/auth/send-verify-code` 已提供服务端兜底限流(Redis 故障时 fail-close)。
- 推荐将 WAF/CDN 作为第一层防护,服务端限流与响应读取上限作为第二层兜底;两层同时保留,避免旁路流量与误配置风险。
**⚠️ 安全警告:HTTP URL 配置**
当 `security.url_allowlist.enabled=false` 时,系统默认执行最小 URL 校验,**拒绝 HTTP URL**,仅允许 HTTPS。要允许 HTTP URL(例如用于开发或内网测试),必须显式设置:
```yaml
security:
url_allowlist:
enabled: false # 禁用白名单检查
allow_insecure_http: true # 允许 HTTP URL(⚠️ 不安全)
```
**或通过环境变量:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
```
**允许 HTTP 的风险:**
- API 密钥和数据以**明文传输**(可被截获)
- 易受**中间人攻击 (MITM)**
- **不适合生产环境**
**适用场景:**
- ✅ 开发/测试环境的本地服务器(http://localhost)
- ✅ 内网可信端点
- ✅ 获取 HTTPS 前测试账号连通性
- ❌ 生产环境(仅使用 HTTPS)
**未设置此项时的错误示例:**
```
Invalid base URL: invalid url scheme: http
```
如关闭 URL 校验或响应头过滤,请加强网络层防护:
- 出站访问白名单限制上游域名/IP
- 阻断私网/回环/链路本地地址
- 强制仅允许 TLS 出站
- 在反向代理层移除敏感响应头
#### ⚠️ 重要:创建管理员账号
初始管理员账号**只能通过 setup 向导创建**(首次启动时访问 `http://<host>:8080`)。`config.yaml` 中的 `default.admin_email` / `default.admin_password` 字段**不会被用来创建管理员**——它们只是出于历史原因保留在模板里。
由于上面第 5 步预先创建了 `config.yaml`,**setup 向导在首次启动时会被跳过**:服务检测到 config 已存在,会直接进入正常模式,此时 `users` 表为空,首次登录会返回 `invalid email or password`。
**创建管理员的两种方式:**
1. **推荐——让向导自动生成 `config.yaml`:** 跳过上面的第 5 步(不要执行 `cp`)。直接运行 `./sub2api`,访问 `http://localhost:8080`,向导会引导你完成数据库、Redis 和管理员账号配置,并自动写出 `config.yaml`。
2. **如果你已经创建了 `config.yaml`:** 首次启动前先把它临时移走以触发向导,完成后再恢复:
```bash
mv config.yaml config.yaml.bak
./sub2api # 向导在 http://localhost:8080 启动,并生成新的 config.yaml
# 向导完成后 Ctrl+C 停服,再恢复你的配置:
mv config.yaml.bak config.yaml
./sub2api # 重启进入正常模式,用刚创建的管理员登录
```
```bash
# 6. 运行应用
./sub2api
```
#### HTTP/2 (h2c) 与 HTTP/1.1 回退
后端明文端口默认支持 h2c,并保留 HTTP/1.1 回退用于 WebSocket 与旧客户端。浏览器通常不支持 h2c,性能收益主要在反向代理或内网链路。
**反向代理示例(Caddy):**
```caddyfile
transport http {
versions h2c h1
}
```
**验证:**
```bash
# h2c prior knowledge
curl --http2-prior-knowledge -I http://localhost:8080/health
# HTTP/1.1 回退
curl --http1.1 -I http://localhost:8080/health
# WebSocket 回退验证(需管理员 token)
websocat -H="Sec-WebSocket-Protocol: sub2api-admin, jwt.<ADMIN_TOKEN>" ws://localhost:8080/api/v1/admin/ops/ws/qps
```
#### 开发模式
```bash
# 后端(支持热重载)
cd backend
go run ./cmd/server
# 前端(支持热重载)
cd frontend
pnpm run dev
```
#### 代码生成
修改 `backend/ent/schema` 后,需要重新生成 Ent + Wire:
```bash
cd backend
go generate ./ent
go generate ./cmd/server
```
---
## 简易模式
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
- 启用方式:设置环境变量 `RUN_MODE=simple`
- 功能差异:隐藏 SaaS 相关功能,跳过计费流程
- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动
---
## Antigravity 使用说明
Sub2API 支持 [Antigravity](https://antigravity.so/) 账户,授权后可通过专用端点访问 Claude 和 Gemini 模型。
### 专用端点
| 端点 | 模型 |
|------|------|
| `/antigravity/v1/messages` | Claude 模型 |
| `/antigravity/v1beta/` | Gemini 模型 |
### Claude Code 配置示例
```bash
export ANTHROPIC_BASE_URL="http://localhost:8080/antigravity"
export ANTHROPIC_AUTH_TOKEN="sk-xxx"
```
### 混合调度模式
Antigravity 账户支持可选的**混合调度**功能。开启后,通用端点 `/v1/messages` 和 `/v1beta/` 也会调度该账户。
> **⚠️ 注意**:Anthropic Claude 和 Antigravity Claude **不能在同一上下文中混合使用**,请通过分组功能做好隔离。
### 已知问题
在 Claude Code 中,无法自动退出Plan Mode。(正常使用原生Claude Api时,Plan 完成后,Claude Code会弹出弹出选项让用户同意或拒绝Plan。)
解决办法:shift + Tab,手动退出Plan mode,然后输入内容 告诉 Claude Code 同意或拒绝 Plan
---
## 项目结构
```
sub2api/
├── backend/ # Go 后端服务
│ ├── cmd/server/ # 应用入口
│ ├── internal/ # 内部模块
│ │ ├── config/ # 配置管理
│ │ ├── model/ # 数据模型
│ │ ├── service/ # 业务逻辑
│ │ ├── handler/ # HTTP 处理器
│ │ └── gateway/ # API 网关核心
│ └── resources/ # 静态资源
│
├── frontend/ # Vue 3 前端
│ └── src/
│ ├── api/ # API 调用
│ ├── stores/ # 状态管理
│ ├── views/ # 页面组件
│ └── components/ # 通用组件
│
└── deploy/ # 部署文件
├── docker-compose.yml # Docker Compose 配置
├── .env.example # Docker Compose 环境变量
├── config.example.yaml # 二进制部署完整配置文件
└── install.sh # 一键安装脚本
```
## 免责声明
> **使用本项目前请仔细阅读:**
>
> :rotating_light: **服务条款风险**: 使用本项目可能违反 Anthropic 的服务条款。请在使用前仔细阅读 Anthropic 的用户协议,使用本项目的一切风险由用户自行承担。
>
> :book: **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。
---
## Star History
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
</picture>
</a>
---
## 许可证
本项目基于 [GNU 宽通用公共许可证 v3.0](LICENSE)(或更高版本)授权。
Copyright (c) 2026 Wesley Liddick
---
<div align="center">
**如果觉得有用,请给个 Star 支持一下!**
</div>
+73 -29
View File
@@ -12,7 +12,7 @@
**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム**
[English](README.md) | 日本語
[English](README.md) | [中文](README_CN.md) | 日本語
</div>
@@ -24,22 +24,6 @@
- **⚖️ 法令遵守**:お住まいの国または地域の法令を遵守した上で本プロジェクトをご利用ください。いかなる違法な目的での使用も固く禁じます。
- **📖 免責事項**:本プロジェクトは技術的な学習および研究の目的でのみ提供されます。本プロジェクトの使用により生じたアカウントの停止、サービスの中断、データの損失、その他一切の直接的または間接的な損害について、作者は一切の責任を負いません。
## 概要
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
## 機能
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
## ❤️ スポンサー
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
@@ -66,11 +50,6 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
<td>SilkAPI のご支援に感謝します!<a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。</td>
</tr>
<tr>
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
<td>YLS Code のご支援に感謝します!<a href="https://ylscode.com/">YLS Code</a> は安全なエンタープライズグレードの Coding Agent 生産性サービスの構築に取り組んでおり、安定かつ高速な Codex / Claude / Gemini サブスクリプションサービスと従量課金 API の柔軟なプランを提供しています。期間限定で新規登録者に 3 日間の Codex 試用特典をプレゼント中!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
@@ -99,12 +78,6 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=fu2E"><img src="assets/partners/logos/runapi.png" alt="RunAPI" width="150"></a></td>
<td>RunAPI のご支援に感謝します!<a href="https://runapi.co/register?aff=fu2E">RunAPI</a> は効率的で安定した API プラットフォームで、OpenRouter の代替として利用できます。1つの API キーで OpenAI、Claude、Gemini、DeepSeek、Grok など 150以上の主要モデルにアクセスでき、価格は最低 10% から。非常に安定しており、Claude Code や OpenClaw などのツールとシームレスに互換します。
</td>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>Unity2 のご支援に感謝します!<a href="https://unity2.ai/register?source=sub2api">Unity2</a> は個人開発者、チーム、企業向けの高性能 AI モデル API 中継プラットフォームです。中国の大手企業に長期にわたりサービスを提供しており、1日あたり 300 億以上のトークン呼び出しを処理し、5000 RPM 級の高並列性をサポートします。1つの API キーで Claude Code、Codex、OpenAI モデル、IDE プラグイン、Agent ワークフローなど様々なシナリオに対応できます。エンタープライズグレードの安定供給能力を備え、高並列・継続的な呼び出し・チームの集中購入シーンでも低レイテンシと高可用性を維持します。残高課金、組み合わせサブスクリプション、初回チャージ特典、企業向け請求書発行、専属 1v1 サポートにも対応しており、個人の頻繁な利用にも企業の長期導入にも適しています。今 Unity2.ai に登録すると $2 の残高、公式グループに参加するとさらに $10 の残高がもらえ、合計最大 $12 の無料クレジットを獲得できます — 試用後に長期利用したい方に最適です。<a href="https://unity2.ai/register?source=sub2api">登録リンク</a>
@@ -123,8 +96,59 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。<a href="https://apikl.com">今すぐ登録</a>でチャージ 1:1 ボーナス — 残高が倍に!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>TokenEum のご支援に感謝します!<a href="https://tokeneum.ai">TokenEum</a> は総合的な AI モデル集約プラットフォームおよびインテリジェントエージェント開発企業です。Claude、Gemini、OpenAI などの世界トップクラスのモデルに加え、GLM、Qwen、Kimi などの主要なオープンソースモデルも集約しており、品質と価格の異なる豊富な選択肢を提供してあらゆるニーズに対応します。また、Seedance2.0 や Happy Horse などの最先端の動画生成モデルも利用可能です。TokenEum は透明性と誠実なビジネスを重視し、すべてのモデル情報の正確性と信頼性を保証します。<a href="https://tokeneum.ai">tokeneum.ai</a> でぜひお試しください。
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>666api のご支援に感謝します!<a href="https://666api.work/sub2api">666api</a> はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は、ByteDance がグローバル市場向けに独自開発した全モーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャに基づき、テキスト・画像・音声・動画の共同理解と生成をサポートします。エージェント協調をネイティブに有効化し、強力な推論・長期タスク実行・ツール統合・コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど多様なシナリオに広く適用可能です。マルチモーダル知覚、エンドツーエンドの複雑タスク配信、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて容易にアクセス・デプロイできます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">こちらのリンク</a>から登録すると、各モデルにつき 50 万トークンの無料推論クォータを獲得できます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陸地域の開発者はこちらをクリック</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>MiyaIP のご支援に感謝します!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> はグローバル住宅プロキシネットワークサービスに特化したプラットフォームで、企業開発者、越境ビジネスチーム、AI アプリケーションユーザーに高品質で純粋な海外住宅 IP リソースを提供することに注力しています。AI プラットフォーム、海外 SaaS、その他のオンラインサービスに安定した独立した海外ネットワーク環境を提供し、複数地域でのアクセステストやプロジェクト環境の分離をサポートします。海外 AI サービスへのアクセスが必要な開発・テストシナリオに最適です。例:AI モデルプラットフォームアクセス、AI 開発テスト、AI SaaS サービス利用、AI API デバッグ、複数地域のネットワーク環境検証など。
</td>
</tr>
</table>
## 概要
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
## 機能
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
## エコシステム
Sub2API を拡張・統合するコミュニティプロジェクト:
@@ -421,7 +445,8 @@ pnpm run build
# 4. フロントエンドを組み込んだバックエンドをビルド
cd ../backend
go build -tags embed -o sub2api ./cmd/server
VERSION="$(./scripts/resolve-version.sh)"
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
# 5. 設定ファイルを作成
cp ../deploy/config.example.yaml ./config.yaml
@@ -522,6 +547,25 @@ URL バリデーションまたはレスポンスヘッダーフィルタリン
- TLS のみのアウトバウンドトラフィックを強制
- プロキシで機密性の高い上流レスポンスヘッダーを除去
#### ⚠️ 重要:管理者アカウントの作成
初期管理者アカウントは**セットアップウィザード経由でのみ作成**されます(初回起動時に `http://<host>:8080` にアクセス)。`config.yaml` の `default.admin_email` / `default.admin_password` フィールドは**管理者作成には使われません**。テンプレートに残っているのは歴史的経緯によるものです。
上記ステップ 5 で事前に `config.yaml` を作成しているため、**初回起動時にセットアップウィザードはスキップされます**。サーバーは既存の config を検出して通常モードで直接起動し、この時点では `users` テーブルが空のため、初回ログインは `invalid email or password` を返します。
**管理者アカウントを作成する 2 つの方法:**
1. **推奨 — ウィザードに `config.yaml` を自動生成させる:** 上記ステップ 5 をスキップします(`cp` を実行しない)。`./sub2api` を直接起動し、`http://localhost:8080` にアクセスすると、セットアップウィザードがデータベース・Redis・管理者アカウントの設定を案内し、`config.yaml` を自動生成します。
2. **すでに `config.yaml` を作成してしまった場合:** 初回起動前に一時的に退避してウィザードを発生させ、完了後に戻します:
```bash
mv config.yaml config.yaml.bak
./sub2api # ウィザードが http://localhost:8080 で起動し、新しい config.yaml を生成します
# ウィザード完了後、Ctrl+C でサーバーを停止し、設定を復元します:
mv config.yaml.bak config.yaml
./sub2api # 通常モードで再起動し、作成した管理者でログインします
```
```bash
# 6. アプリケーションを実行
./sub2api
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

+2 -1
View File
@@ -15,7 +15,8 @@ RUN go mod download
COPY . .
# 构建应用
RUN go build -o main ./cmd/server/
RUN VERSION_VALUE="$(./scripts/resolve-version.sh)" && \
go build -ldflags="-s -w -X main.Version=${VERSION_VALUE}" -o main ./cmd/server/
# 暴露端口
EXPOSE 8080
+1 -1
View File
@@ -1,6 +1,6 @@
.PHONY: build generate test test-unit test-integration test-e2e
VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
VERSION ?= $(shell ./scripts/resolve-version.sh)
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
build:
+1 -1
View File
@@ -1 +1 @@
0.1.135
0.1.141
+7
View File
@@ -94,6 +94,7 @@ func provideCleanup(
openaiOAuth *service.OpenAIOAuthService,
geminiOAuth *service.GeminiOAuthService,
antigravityOAuth *service.AntigravityOAuthService,
grokOAuth *service.GrokOAuthService,
openAIGateway *service.OpenAIGatewayService,
scheduledTestRunner *service.ScheduledTestRunnerService,
backupSvc *service.BackupService,
@@ -222,6 +223,12 @@ func provideCleanup(
antigravityOAuth.Stop()
return nil
}},
{"GrokOAuthService", func() error {
if grokOAuth != nil {
grokOAuth.Stop()
}
return nil
}},
{"OpenAIWSPool", func() error {
if openAIGateway != nil {
openAIGateway.CloseOpenAIWSPool()
+22 -8
View File
@@ -141,7 +141,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
privacyClientFactory := providePrivacyClientFactory()
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
grokOAuthClient := repository.NewGrokOAuthClient()
grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient)
grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
driveClient := repository.NewGeminiDriveClient()
@@ -178,9 +181,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory)
usageCache := service.NewUsageCache()
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
@@ -191,9 +196,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
backupHandler := admin.NewBackupHandler(backupService, userService)
oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream)
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService)
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
@@ -241,12 +248,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
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, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
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)
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, configConfig)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
@@ -265,7 +272,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
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)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
@@ -273,7 +280,7 @@ 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, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
application := &Application{
Server: httpServer,
Cleanup: v,
@@ -324,6 +331,7 @@ func provideCleanup(
openaiOAuth *service.OpenAIOAuthService,
geminiOAuth *service.GeminiOAuthService,
antigravityOAuth *service.AntigravityOAuthService,
grokOAuth *service.GrokOAuthService,
openAIGateway *service.OpenAIGatewayService,
scheduledTestRunner *service.ScheduledTestRunnerService,
backupSvc *service.BackupService,
@@ -451,6 +459,12 @@ func provideCleanup(
antigravityOAuth.Stop()
return nil
}},
{"GrokOAuthService", func() error {
if grokOAuth != nil {
grokOAuth.Stop()
}
return nil
}},
{"OpenAIWSPool", func() error {
if openAIGateway != nil {
openAIGateway.CloseOpenAIWSPool()
+1
View File
@@ -74,6 +74,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
openAIOAuthSvc,
geminiOAuthSvc,
antigravityOAuthSvc,
nil, // grokOAuth
nil, // openAIGateway
nil, // scheduledTestRunner
nil, // backupSvc
+64 -5
View File
@@ -77,6 +77,10 @@ type Account struct {
SessionWindowEnd *time.Time `json:"session_window_end,omitempty"`
// SessionWindowStatus holds the value of the "session_window_status" field.
SessionWindowStatus *string `json:"session_window_status,omitempty"`
// Parent account id for a linked spark shadow (NULL = normal).
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
// 'global' (default) or 'spark' (shadow reads codex_bengalfox).
QuotaDimension account.QuotaDimension `json:"quota_dimension,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the AccountQuery when eager-loading is set.
Edges AccountEdges `json:"edges"`
@@ -89,13 +93,17 @@ type AccountEdges struct {
Groups []*Group `json:"groups,omitempty"`
// Proxy holds the value of the proxy edge.
Proxy *Proxy `json:"proxy,omitempty"`
// Parent holds the value of the parent edge.
Parent *Account `json:"parent,omitempty"`
// Children holds the value of the children edge.
Children []*Account `json:"children,omitempty"`
// UsageLogs holds the value of the usage_logs edge.
UsageLogs []*UsageLog `json:"usage_logs,omitempty"`
// AccountGroups holds the value of the account_groups edge.
AccountGroups []*AccountGroup `json:"account_groups,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [4]bool
loadedTypes [6]bool
}
// GroupsOrErr returns the Groups value or an error if the edge
@@ -118,10 +126,30 @@ func (e AccountEdges) ProxyOrErr() (*Proxy, error) {
return nil, &NotLoadedError{edge: "proxy"}
}
// ParentOrErr returns the Parent value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e AccountEdges) ParentOrErr() (*Account, error) {
if e.Parent != nil {
return e.Parent, nil
} else if e.loadedTypes[2] {
return nil, &NotFoundError{label: account.Label}
}
return nil, &NotLoadedError{edge: "parent"}
}
// ChildrenOrErr returns the Children value or an error if the edge
// was not loaded in eager-loading.
func (e AccountEdges) ChildrenOrErr() ([]*Account, error) {
if e.loadedTypes[3] {
return e.Children, nil
}
return nil, &NotLoadedError{edge: "children"}
}
// UsageLogsOrErr returns the UsageLogs value or an error if the edge
// was not loaded in eager-loading.
func (e AccountEdges) UsageLogsOrErr() ([]*UsageLog, error) {
if e.loadedTypes[2] {
if e.loadedTypes[4] {
return e.UsageLogs, nil
}
return nil, &NotLoadedError{edge: "usage_logs"}
@@ -130,7 +158,7 @@ func (e AccountEdges) UsageLogsOrErr() ([]*UsageLog, error) {
// AccountGroupsOrErr returns the AccountGroups value or an error if the edge
// was not loaded in eager-loading.
func (e AccountEdges) AccountGroupsOrErr() ([]*AccountGroup, error) {
if e.loadedTypes[3] {
if e.loadedTypes[5] {
return e.AccountGroups, nil
}
return nil, &NotLoadedError{edge: "account_groups"}
@@ -147,9 +175,9 @@ func (*Account) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullBool)
case account.FieldRateMultiplier:
values[i] = new(sql.NullFloat64)
case account.FieldID, account.FieldProxyID, account.FieldProxyFallbackOriginID, account.FieldConcurrency, account.FieldLoadFactor, account.FieldPriority:
case account.FieldID, account.FieldProxyID, account.FieldProxyFallbackOriginID, account.FieldConcurrency, account.FieldLoadFactor, account.FieldPriority, account.FieldParentAccountID:
values[i] = new(sql.NullInt64)
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldTempUnschedulableReason, account.FieldSessionWindowStatus:
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldTempUnschedulableReason, account.FieldSessionWindowStatus, account.FieldQuotaDimension:
values[i] = new(sql.NullString)
case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldExpiresAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldTempUnschedulableUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd:
values[i] = new(sql.NullTime)
@@ -368,6 +396,19 @@ func (_m *Account) assignValues(columns []string, values []any) error {
_m.SessionWindowStatus = new(string)
*_m.SessionWindowStatus = value.String
}
case account.FieldParentAccountID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field parent_account_id", values[i])
} else if value.Valid {
_m.ParentAccountID = new(int64)
*_m.ParentAccountID = value.Int64
}
case account.FieldQuotaDimension:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field quota_dimension", values[i])
} else if value.Valid {
_m.QuotaDimension = account.QuotaDimension(value.String)
}
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -391,6 +432,16 @@ func (_m *Account) QueryProxy() *ProxyQuery {
return NewAccountClient(_m.config).QueryProxy(_m)
}
// QueryParent queries the "parent" edge of the Account entity.
func (_m *Account) QueryParent() *AccountQuery {
return NewAccountClient(_m.config).QueryParent(_m)
}
// QueryChildren queries the "children" edge of the Account entity.
func (_m *Account) QueryChildren() *AccountQuery {
return NewAccountClient(_m.config).QueryChildren(_m)
}
// QueryUsageLogs queries the "usage_logs" edge of the Account entity.
func (_m *Account) QueryUsageLogs() *UsageLogQuery {
return NewAccountClient(_m.config).QueryUsageLogs(_m)
@@ -542,6 +593,14 @@ func (_m *Account) String() string {
builder.WriteString("session_window_status=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.ParentAccountID; v != nil {
builder.WriteString("parent_account_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("quota_dimension=")
builder.WriteString(fmt.Sprintf("%v", _m.QuotaDimension))
builder.WriteByte(')')
return builder.String()
}
+90
View File
@@ -3,6 +3,7 @@
package account
import (
"fmt"
"time"
"entgo.io/ent"
@@ -73,10 +74,18 @@ const (
FieldSessionWindowEnd = "session_window_end"
// FieldSessionWindowStatus holds the string denoting the session_window_status field in the database.
FieldSessionWindowStatus = "session_window_status"
// FieldParentAccountID holds the string denoting the parent_account_id field in the database.
FieldParentAccountID = "parent_account_id"
// FieldQuotaDimension holds the string denoting the quota_dimension field in the database.
FieldQuotaDimension = "quota_dimension"
// EdgeGroups holds the string denoting the groups edge name in mutations.
EdgeGroups = "groups"
// EdgeProxy holds the string denoting the proxy edge name in mutations.
EdgeProxy = "proxy"
// EdgeParent holds the string denoting the parent edge name in mutations.
EdgeParent = "parent"
// EdgeChildren holds the string denoting the children edge name in mutations.
EdgeChildren = "children"
// EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations.
EdgeUsageLogs = "usage_logs"
// EdgeAccountGroups holds the string denoting the account_groups edge name in mutations.
@@ -95,6 +104,14 @@ const (
ProxyInverseTable = "proxies"
// ProxyColumn is the table column denoting the proxy relation/edge.
ProxyColumn = "proxy_id"
// ParentTable is the table that holds the parent relation/edge.
ParentTable = "accounts"
// ParentColumn is the table column denoting the parent relation/edge.
ParentColumn = "parent_account_id"
// ChildrenTable is the table that holds the children relation/edge.
ChildrenTable = "accounts"
// ChildrenColumn is the table column denoting the children relation/edge.
ChildrenColumn = "parent_account_id"
// UsageLogsTable is the table that holds the usage_logs relation/edge.
UsageLogsTable = "usage_logs"
// UsageLogsInverseTable is the table name for the UsageLog entity.
@@ -143,6 +160,8 @@ var Columns = []string{
FieldSessionWindowStart,
FieldSessionWindowEnd,
FieldSessionWindowStatus,
FieldParentAccountID,
FieldQuotaDimension,
}
var (
@@ -203,6 +222,32 @@ var (
SessionWindowStatusValidator func(string) error
)
// QuotaDimension defines the type for the "quota_dimension" enum field.
type QuotaDimension string
// QuotaDimensionGlobal is the default value of the QuotaDimension enum.
const DefaultQuotaDimension = QuotaDimensionGlobal
// QuotaDimension values.
const (
QuotaDimensionGlobal QuotaDimension = "global"
QuotaDimensionSpark QuotaDimension = "spark"
)
func (qd QuotaDimension) String() string {
return string(qd)
}
// QuotaDimensionValidator is a validator for the "quota_dimension" field enum values. It is called by the builders before save.
func QuotaDimensionValidator(qd QuotaDimension) error {
switch qd {
case QuotaDimensionGlobal, QuotaDimensionSpark:
return nil
default:
return fmt.Errorf("account: invalid enum value for quota_dimension field: %q", qd)
}
}
// OrderOption defines the ordering options for the Account queries.
type OrderOption func(*sql.Selector)
@@ -346,6 +391,16 @@ func BySessionWindowStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldSessionWindowStatus, opts...).ToFunc()
}
// ByParentAccountID orders the results by the parent_account_id field.
func ByParentAccountID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldParentAccountID, opts...).ToFunc()
}
// ByQuotaDimension orders the results by the quota_dimension field.
func ByQuotaDimension(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldQuotaDimension, opts...).ToFunc()
}
// ByGroupsCount orders the results by groups count.
func ByGroupsCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -367,6 +422,27 @@ func ByProxyField(field string, opts ...sql.OrderTermOption) OrderOption {
}
}
// ByParentField orders the results by parent field.
func ByParentField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newParentStep(), sql.OrderByField(field, opts...))
}
}
// ByChildrenCount orders the results by children count.
func ByChildrenCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newChildrenStep(), opts...)
}
}
// ByChildren orders the results by children terms.
func ByChildren(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newChildrenStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
// ByUsageLogsCount orders the results by usage_logs count.
func ByUsageLogsCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -408,6 +484,20 @@ func newProxyStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.M2O, false, ProxyTable, ProxyColumn),
)
}
func newParentStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, ParentTable, ParentColumn),
)
}
func newChildrenStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, false, ChildrenTable, ChildrenColumn),
)
}
func newUsageLogsStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
+101
View File
@@ -190,6 +190,11 @@ func SessionWindowStatus(v string) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldSessionWindowStatus, v))
}
// ParentAccountID applies equality check predicate on the "parent_account_id" field. It's identical to ParentAccountIDEQ.
func ParentAccountID(v int64) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldParentAccountID, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldCreatedAt, v))
@@ -1550,6 +1555,56 @@ func SessionWindowStatusContainsFold(v string) predicate.Account {
return predicate.Account(sql.FieldContainsFold(FieldSessionWindowStatus, v))
}
// ParentAccountIDEQ applies the EQ predicate on the "parent_account_id" field.
func ParentAccountIDEQ(v int64) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldParentAccountID, v))
}
// ParentAccountIDNEQ applies the NEQ predicate on the "parent_account_id" field.
func ParentAccountIDNEQ(v int64) predicate.Account {
return predicate.Account(sql.FieldNEQ(FieldParentAccountID, v))
}
// ParentAccountIDIn applies the In predicate on the "parent_account_id" field.
func ParentAccountIDIn(vs ...int64) predicate.Account {
return predicate.Account(sql.FieldIn(FieldParentAccountID, vs...))
}
// ParentAccountIDNotIn applies the NotIn predicate on the "parent_account_id" field.
func ParentAccountIDNotIn(vs ...int64) predicate.Account {
return predicate.Account(sql.FieldNotIn(FieldParentAccountID, vs...))
}
// ParentAccountIDIsNil applies the IsNil predicate on the "parent_account_id" field.
func ParentAccountIDIsNil() predicate.Account {
return predicate.Account(sql.FieldIsNull(FieldParentAccountID))
}
// ParentAccountIDNotNil applies the NotNil predicate on the "parent_account_id" field.
func ParentAccountIDNotNil() predicate.Account {
return predicate.Account(sql.FieldNotNull(FieldParentAccountID))
}
// QuotaDimensionEQ applies the EQ predicate on the "quota_dimension" field.
func QuotaDimensionEQ(v QuotaDimension) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldQuotaDimension, v))
}
// QuotaDimensionNEQ applies the NEQ predicate on the "quota_dimension" field.
func QuotaDimensionNEQ(v QuotaDimension) predicate.Account {
return predicate.Account(sql.FieldNEQ(FieldQuotaDimension, v))
}
// QuotaDimensionIn applies the In predicate on the "quota_dimension" field.
func QuotaDimensionIn(vs ...QuotaDimension) predicate.Account {
return predicate.Account(sql.FieldIn(FieldQuotaDimension, vs...))
}
// QuotaDimensionNotIn applies the NotIn predicate on the "quota_dimension" field.
func QuotaDimensionNotIn(vs ...QuotaDimension) predicate.Account {
return predicate.Account(sql.FieldNotIn(FieldQuotaDimension, vs...))
}
// HasGroups applies the HasEdge predicate on the "groups" edge.
func HasGroups() predicate.Account {
return predicate.Account(func(s *sql.Selector) {
@@ -1596,6 +1651,52 @@ func HasProxyWith(preds ...predicate.Proxy) predicate.Account {
})
}
// HasParent applies the HasEdge predicate on the "parent" edge.
func HasParent() predicate.Account {
return predicate.Account(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, ParentTable, ParentColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasParentWith applies the HasEdge predicate on the "parent" edge with a given conditions (other predicates).
func HasParentWith(preds ...predicate.Account) predicate.Account {
return predicate.Account(func(s *sql.Selector) {
step := newParentStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasChildren applies the HasEdge predicate on the "children" edge.
func HasChildren() predicate.Account {
return predicate.Account(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, false, ChildrenTable, ChildrenColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasChildrenWith applies the HasEdge predicate on the "children" edge with a given conditions (other predicates).
func HasChildrenWith(preds ...predicate.Account) predicate.Account {
return predicate.Account(func(s *sql.Selector) {
step := newChildrenStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasUsageLogs applies the HasEdge predicate on the "usage_logs" edge.
func HasUsageLogs() predicate.Account {
return predicate.Account(func(s *sql.Selector) {
+211
View File
@@ -391,6 +391,34 @@ func (_c *AccountCreate) SetNillableSessionWindowStatus(v *string) *AccountCreat
return _c
}
// SetParentAccountID sets the "parent_account_id" field.
func (_c *AccountCreate) SetParentAccountID(v int64) *AccountCreate {
_c.mutation.SetParentAccountID(v)
return _c
}
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
func (_c *AccountCreate) SetNillableParentAccountID(v *int64) *AccountCreate {
if v != nil {
_c.SetParentAccountID(*v)
}
return _c
}
// SetQuotaDimension sets the "quota_dimension" field.
func (_c *AccountCreate) SetQuotaDimension(v account.QuotaDimension) *AccountCreate {
_c.mutation.SetQuotaDimension(v)
return _c
}
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
func (_c *AccountCreate) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountCreate {
if v != nil {
_c.SetQuotaDimension(*v)
}
return _c
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_c *AccountCreate) AddGroupIDs(ids ...int64) *AccountCreate {
_c.mutation.AddGroupIDs(ids...)
@@ -411,6 +439,40 @@ func (_c *AccountCreate) SetProxy(v *Proxy) *AccountCreate {
return _c.SetProxyID(v.ID)
}
// SetParentID sets the "parent" edge to the Account entity by ID.
func (_c *AccountCreate) SetParentID(id int64) *AccountCreate {
_c.mutation.SetParentID(id)
return _c
}
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
func (_c *AccountCreate) SetNillableParentID(id *int64) *AccountCreate {
if id != nil {
_c = _c.SetParentID(*id)
}
return _c
}
// SetParent sets the "parent" edge to the Account entity.
func (_c *AccountCreate) SetParent(v *Account) *AccountCreate {
return _c.SetParentID(v.ID)
}
// AddChildIDs adds the "children" edge to the Account entity by IDs.
func (_c *AccountCreate) AddChildIDs(ids ...int64) *AccountCreate {
_c.mutation.AddChildIDs(ids...)
return _c
}
// AddChildren adds the "children" edges to the Account entity.
func (_c *AccountCreate) AddChildren(v ...*Account) *AccountCreate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _c.AddChildIDs(ids...)
}
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
func (_c *AccountCreate) AddUsageLogIDs(ids ...int64) *AccountCreate {
_c.mutation.AddUsageLogIDs(ids...)
@@ -515,6 +577,10 @@ func (_c *AccountCreate) defaults() error {
v := account.DefaultSchedulable
_c.mutation.SetSchedulable(v)
}
if _, ok := _c.mutation.QuotaDimension(); !ok {
v := account.DefaultQuotaDimension
_c.mutation.SetQuotaDimension(v)
}
return nil
}
@@ -584,6 +650,14 @@ func (_c *AccountCreate) check() error {
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
}
}
if _, ok := _c.mutation.QuotaDimension(); !ok {
return &ValidationError{Name: "quota_dimension", err: errors.New(`ent: missing required field "Account.quota_dimension"`)}
}
if v, ok := _c.mutation.QuotaDimension(); ok {
if err := account.QuotaDimensionValidator(v); err != nil {
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
}
}
return nil
}
@@ -723,6 +797,10 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
_spec.SetField(account.FieldSessionWindowStatus, field.TypeString, value)
_node.SessionWindowStatus = &value
}
if value, ok := _c.mutation.QuotaDimension(); ok {
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
_node.QuotaDimension = value
}
if nodes := _c.mutation.GroupsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
@@ -760,6 +838,39 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
_node.ProxyID = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.ParentIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_node.ParentAccountID = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.ChildrenIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.UsageLogsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1290,6 +1401,36 @@ func (u *AccountUpsert) ClearSessionWindowStatus() *AccountUpsert {
return u
}
// SetParentAccountID sets the "parent_account_id" field.
func (u *AccountUpsert) SetParentAccountID(v int64) *AccountUpsert {
u.Set(account.FieldParentAccountID, v)
return u
}
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
func (u *AccountUpsert) UpdateParentAccountID() *AccountUpsert {
u.SetExcluded(account.FieldParentAccountID)
return u
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (u *AccountUpsert) ClearParentAccountID() *AccountUpsert {
u.SetNull(account.FieldParentAccountID)
return u
}
// SetQuotaDimension sets the "quota_dimension" field.
func (u *AccountUpsert) SetQuotaDimension(v account.QuotaDimension) *AccountUpsert {
u.Set(account.FieldQuotaDimension, v)
return u
}
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
func (u *AccountUpsert) UpdateQuotaDimension() *AccountUpsert {
u.SetExcluded(account.FieldQuotaDimension)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -1874,6 +2015,41 @@ func (u *AccountUpsertOne) ClearSessionWindowStatus() *AccountUpsertOne {
})
}
// SetParentAccountID sets the "parent_account_id" field.
func (u *AccountUpsertOne) SetParentAccountID(v int64) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.SetParentAccountID(v)
})
}
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
func (u *AccountUpsertOne) UpdateParentAccountID() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.UpdateParentAccountID()
})
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (u *AccountUpsertOne) ClearParentAccountID() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.ClearParentAccountID()
})
}
// SetQuotaDimension sets the "quota_dimension" field.
func (u *AccountUpsertOne) SetQuotaDimension(v account.QuotaDimension) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.SetQuotaDimension(v)
})
}
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
func (u *AccountUpsertOne) UpdateQuotaDimension() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.UpdateQuotaDimension()
})
}
// Exec executes the query.
func (u *AccountUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -2624,6 +2800,41 @@ func (u *AccountUpsertBulk) ClearSessionWindowStatus() *AccountUpsertBulk {
})
}
// SetParentAccountID sets the "parent_account_id" field.
func (u *AccountUpsertBulk) SetParentAccountID(v int64) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.SetParentAccountID(v)
})
}
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
func (u *AccountUpsertBulk) UpdateParentAccountID() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.UpdateParentAccountID()
})
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (u *AccountUpsertBulk) ClearParentAccountID() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.ClearParentAccountID()
})
}
// SetQuotaDimension sets the "quota_dimension" field.
func (u *AccountUpsertBulk) SetQuotaDimension(v account.QuotaDimension) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.SetQuotaDimension(v)
})
}
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
func (u *AccountUpsertBulk) UpdateQuotaDimension() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.UpdateQuotaDimension()
})
}
// Exec executes the query.
func (u *AccountUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {
+154 -1
View File
@@ -30,6 +30,8 @@ type AccountQuery struct {
predicates []predicate.Account
withGroups *GroupQuery
withProxy *ProxyQuery
withParent *AccountQuery
withChildren *AccountQuery
withUsageLogs *UsageLogQuery
withAccountGroups *AccountGroupQuery
modifiers []func(*sql.Selector)
@@ -113,6 +115,50 @@ func (_q *AccountQuery) QueryProxy() *ProxyQuery {
return query
}
// QueryParent chains the current query on the "parent" edge.
func (_q *AccountQuery) QueryParent() *AccountQuery {
query := (&AccountClient{config: _q.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := _q.prepareQuery(ctx); err != nil {
return nil, err
}
selector := _q.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(account.Table, account.FieldID, selector),
sqlgraph.To(account.Table, account.FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, account.ParentTable, account.ParentColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryChildren chains the current query on the "children" edge.
func (_q *AccountQuery) QueryChildren() *AccountQuery {
query := (&AccountClient{config: _q.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := _q.prepareQuery(ctx); err != nil {
return nil, err
}
selector := _q.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(account.Table, account.FieldID, selector),
sqlgraph.To(account.Table, account.FieldID),
sqlgraph.Edge(sqlgraph.O2M, false, account.ChildrenTable, account.ChildrenColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryUsageLogs chains the current query on the "usage_logs" edge.
func (_q *AccountQuery) QueryUsageLogs() *UsageLogQuery {
query := (&UsageLogClient{config: _q.config}).Query()
@@ -351,6 +397,8 @@ func (_q *AccountQuery) Clone() *AccountQuery {
predicates: append([]predicate.Account{}, _q.predicates...),
withGroups: _q.withGroups.Clone(),
withProxy: _q.withProxy.Clone(),
withParent: _q.withParent.Clone(),
withChildren: _q.withChildren.Clone(),
withUsageLogs: _q.withUsageLogs.Clone(),
withAccountGroups: _q.withAccountGroups.Clone(),
// clone intermediate query.
@@ -381,6 +429,28 @@ func (_q *AccountQuery) WithProxy(opts ...func(*ProxyQuery)) *AccountQuery {
return _q
}
// WithParent tells the query-builder to eager-load the nodes that are connected to
// the "parent" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *AccountQuery) WithParent(opts ...func(*AccountQuery)) *AccountQuery {
query := (&AccountClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withParent = query
return _q
}
// WithChildren tells the query-builder to eager-load the nodes that are connected to
// the "children" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *AccountQuery) WithChildren(opts ...func(*AccountQuery)) *AccountQuery {
query := (&AccountClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withChildren = query
return _q
}
// WithUsageLogs tells the query-builder to eager-load the nodes that are connected to
// the "usage_logs" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *AccountQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *AccountQuery {
@@ -481,9 +551,11 @@ func (_q *AccountQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Acco
var (
nodes = []*Account{}
_spec = _q.querySpec()
loadedTypes = [4]bool{
loadedTypes = [6]bool{
_q.withGroups != nil,
_q.withProxy != nil,
_q.withParent != nil,
_q.withChildren != nil,
_q.withUsageLogs != nil,
_q.withAccountGroups != nil,
}
@@ -522,6 +594,19 @@ func (_q *AccountQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Acco
return nil, err
}
}
if query := _q.withParent; query != nil {
if err := _q.loadParent(ctx, query, nodes, nil,
func(n *Account, e *Account) { n.Edges.Parent = e }); err != nil {
return nil, err
}
}
if query := _q.withChildren; query != nil {
if err := _q.loadChildren(ctx, query, nodes,
func(n *Account) { n.Edges.Children = []*Account{} },
func(n *Account, e *Account) { n.Edges.Children = append(n.Edges.Children, e) }); err != nil {
return nil, err
}
}
if query := _q.withUsageLogs; query != nil {
if err := _q.loadUsageLogs(ctx, query, nodes,
func(n *Account) { n.Edges.UsageLogs = []*UsageLog{} },
@@ -632,6 +717,71 @@ func (_q *AccountQuery) loadProxy(ctx context.Context, query *ProxyQuery, nodes
}
return nil
}
func (_q *AccountQuery) loadParent(ctx context.Context, query *AccountQuery, nodes []*Account, init func(*Account), assign func(*Account, *Account)) error {
ids := make([]int64, 0, len(nodes))
nodeids := make(map[int64][]*Account)
for i := range nodes {
if nodes[i].ParentAccountID == nil {
continue
}
fk := *nodes[i].ParentAccountID
if _, ok := nodeids[fk]; !ok {
ids = append(ids, fk)
}
nodeids[fk] = append(nodeids[fk], nodes[i])
}
if len(ids) == 0 {
return nil
}
query.Where(account.IDIn(ids...))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
nodes, ok := nodeids[n.ID]
if !ok {
return fmt.Errorf(`unexpected foreign-key "parent_account_id" returned %v`, n.ID)
}
for i := range nodes {
assign(nodes[i], n)
}
}
return nil
}
func (_q *AccountQuery) loadChildren(ctx context.Context, query *AccountQuery, nodes []*Account, init func(*Account), assign func(*Account, *Account)) error {
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*Account)
for i := range nodes {
fks = append(fks, nodes[i].ID)
nodeids[nodes[i].ID] = nodes[i]
if init != nil {
init(nodes[i])
}
}
if len(query.ctx.Fields) > 0 {
query.ctx.AppendFieldOnce(account.FieldParentAccountID)
}
query.Where(predicate.Account(func(s *sql.Selector) {
s.Where(sql.InValues(s.C(account.ChildrenColumn), fks...))
}))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
fk := n.ParentAccountID
if fk == nil {
return fmt.Errorf(`foreign-key "parent_account_id" is nil for node %v`, n.ID)
}
node, ok := nodeids[*fk]
if !ok {
return fmt.Errorf(`unexpected referenced foreign-key "parent_account_id" returned %v for node %v`, *fk, n.ID)
}
assign(node, n)
}
return nil
}
func (_q *AccountQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, nodes []*Account, init func(*Account), assign func(*Account, *UsageLog)) error {
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*Account)
@@ -724,6 +874,9 @@ func (_q *AccountQuery) querySpec() *sqlgraph.QuerySpec {
if _q.withProxy != nil {
_spec.Node.AddColumnOnce(account.FieldProxyID)
}
if _q.withParent != nil {
_spec.Node.AddColumnOnce(account.FieldParentAccountID)
}
}
if ps := _q.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
+354
View File
@@ -530,6 +530,40 @@ func (_u *AccountUpdate) ClearSessionWindowStatus() *AccountUpdate {
return _u
}
// SetParentAccountID sets the "parent_account_id" field.
func (_u *AccountUpdate) SetParentAccountID(v int64) *AccountUpdate {
_u.mutation.SetParentAccountID(v)
return _u
}
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
func (_u *AccountUpdate) SetNillableParentAccountID(v *int64) *AccountUpdate {
if v != nil {
_u.SetParentAccountID(*v)
}
return _u
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (_u *AccountUpdate) ClearParentAccountID() *AccountUpdate {
_u.mutation.ClearParentAccountID()
return _u
}
// SetQuotaDimension sets the "quota_dimension" field.
func (_u *AccountUpdate) SetQuotaDimension(v account.QuotaDimension) *AccountUpdate {
_u.mutation.SetQuotaDimension(v)
return _u
}
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
func (_u *AccountUpdate) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountUpdate {
if v != nil {
_u.SetQuotaDimension(*v)
}
return _u
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_u *AccountUpdate) AddGroupIDs(ids ...int64) *AccountUpdate {
_u.mutation.AddGroupIDs(ids...)
@@ -550,6 +584,40 @@ func (_u *AccountUpdate) SetProxy(v *Proxy) *AccountUpdate {
return _u.SetProxyID(v.ID)
}
// SetParentID sets the "parent" edge to the Account entity by ID.
func (_u *AccountUpdate) SetParentID(id int64) *AccountUpdate {
_u.mutation.SetParentID(id)
return _u
}
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
func (_u *AccountUpdate) SetNillableParentID(id *int64) *AccountUpdate {
if id != nil {
_u = _u.SetParentID(*id)
}
return _u
}
// SetParent sets the "parent" edge to the Account entity.
func (_u *AccountUpdate) SetParent(v *Account) *AccountUpdate {
return _u.SetParentID(v.ID)
}
// AddChildIDs adds the "children" edge to the Account entity by IDs.
func (_u *AccountUpdate) AddChildIDs(ids ...int64) *AccountUpdate {
_u.mutation.AddChildIDs(ids...)
return _u
}
// AddChildren adds the "children" edges to the Account entity.
func (_u *AccountUpdate) AddChildren(v ...*Account) *AccountUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddChildIDs(ids...)
}
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
func (_u *AccountUpdate) AddUsageLogIDs(ids ...int64) *AccountUpdate {
_u.mutation.AddUsageLogIDs(ids...)
@@ -597,6 +665,33 @@ func (_u *AccountUpdate) ClearProxy() *AccountUpdate {
return _u
}
// ClearParent clears the "parent" edge to the Account entity.
func (_u *AccountUpdate) ClearParent() *AccountUpdate {
_u.mutation.ClearParent()
return _u
}
// ClearChildren clears all "children" edges to the Account entity.
func (_u *AccountUpdate) ClearChildren() *AccountUpdate {
_u.mutation.ClearChildren()
return _u
}
// RemoveChildIDs removes the "children" edge to Account entities by IDs.
func (_u *AccountUpdate) RemoveChildIDs(ids ...int64) *AccountUpdate {
_u.mutation.RemoveChildIDs(ids...)
return _u
}
// RemoveChildren removes "children" edges to Account entities.
func (_u *AccountUpdate) RemoveChildren(v ...*Account) *AccountUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemoveChildIDs(ids...)
}
// ClearUsageLogs clears all "usage_logs" edges to the UsageLog entity.
func (_u *AccountUpdate) ClearUsageLogs() *AccountUpdate {
_u.mutation.ClearUsageLogs()
@@ -687,6 +782,11 @@ func (_u *AccountUpdate) check() error {
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
}
}
if v, ok := _u.mutation.QuotaDimension(); ok {
if err := account.QuotaDimensionValidator(v); err != nil {
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
}
}
return nil
}
@@ -843,6 +943,9 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.SessionWindowStatusCleared() {
_spec.ClearField(account.FieldSessionWindowStatus, field.TypeString)
}
if value, ok := _u.mutation.QuotaDimension(); ok {
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
}
if _u.mutation.GroupsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
@@ -929,6 +1032,80 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.ParentCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.ParentIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.ChildrenCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedChildrenIDs(); len(nodes) > 0 && !_u.mutation.ChildrenCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.ChildrenIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.UsageLogsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1493,6 +1670,40 @@ func (_u *AccountUpdateOne) ClearSessionWindowStatus() *AccountUpdateOne {
return _u
}
// SetParentAccountID sets the "parent_account_id" field.
func (_u *AccountUpdateOne) SetParentAccountID(v int64) *AccountUpdateOne {
_u.mutation.SetParentAccountID(v)
return _u
}
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableParentAccountID(v *int64) *AccountUpdateOne {
if v != nil {
_u.SetParentAccountID(*v)
}
return _u
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (_u *AccountUpdateOne) ClearParentAccountID() *AccountUpdateOne {
_u.mutation.ClearParentAccountID()
return _u
}
// SetQuotaDimension sets the "quota_dimension" field.
func (_u *AccountUpdateOne) SetQuotaDimension(v account.QuotaDimension) *AccountUpdateOne {
_u.mutation.SetQuotaDimension(v)
return _u
}
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountUpdateOne {
if v != nil {
_u.SetQuotaDimension(*v)
}
return _u
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_u *AccountUpdateOne) AddGroupIDs(ids ...int64) *AccountUpdateOne {
_u.mutation.AddGroupIDs(ids...)
@@ -1513,6 +1724,40 @@ func (_u *AccountUpdateOne) SetProxy(v *Proxy) *AccountUpdateOne {
return _u.SetProxyID(v.ID)
}
// SetParentID sets the "parent" edge to the Account entity by ID.
func (_u *AccountUpdateOne) SetParentID(id int64) *AccountUpdateOne {
_u.mutation.SetParentID(id)
return _u
}
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableParentID(id *int64) *AccountUpdateOne {
if id != nil {
_u = _u.SetParentID(*id)
}
return _u
}
// SetParent sets the "parent" edge to the Account entity.
func (_u *AccountUpdateOne) SetParent(v *Account) *AccountUpdateOne {
return _u.SetParentID(v.ID)
}
// AddChildIDs adds the "children" edge to the Account entity by IDs.
func (_u *AccountUpdateOne) AddChildIDs(ids ...int64) *AccountUpdateOne {
_u.mutation.AddChildIDs(ids...)
return _u
}
// AddChildren adds the "children" edges to the Account entity.
func (_u *AccountUpdateOne) AddChildren(v ...*Account) *AccountUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddChildIDs(ids...)
}
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
func (_u *AccountUpdateOne) AddUsageLogIDs(ids ...int64) *AccountUpdateOne {
_u.mutation.AddUsageLogIDs(ids...)
@@ -1560,6 +1805,33 @@ func (_u *AccountUpdateOne) ClearProxy() *AccountUpdateOne {
return _u
}
// ClearParent clears the "parent" edge to the Account entity.
func (_u *AccountUpdateOne) ClearParent() *AccountUpdateOne {
_u.mutation.ClearParent()
return _u
}
// ClearChildren clears all "children" edges to the Account entity.
func (_u *AccountUpdateOne) ClearChildren() *AccountUpdateOne {
_u.mutation.ClearChildren()
return _u
}
// RemoveChildIDs removes the "children" edge to Account entities by IDs.
func (_u *AccountUpdateOne) RemoveChildIDs(ids ...int64) *AccountUpdateOne {
_u.mutation.RemoveChildIDs(ids...)
return _u
}
// RemoveChildren removes "children" edges to Account entities.
func (_u *AccountUpdateOne) RemoveChildren(v ...*Account) *AccountUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemoveChildIDs(ids...)
}
// ClearUsageLogs clears all "usage_logs" edges to the UsageLog entity.
func (_u *AccountUpdateOne) ClearUsageLogs() *AccountUpdateOne {
_u.mutation.ClearUsageLogs()
@@ -1663,6 +1935,11 @@ func (_u *AccountUpdateOne) check() error {
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
}
}
if v, ok := _u.mutation.QuotaDimension(); ok {
if err := account.QuotaDimensionValidator(v); err != nil {
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
}
}
return nil
}
@@ -1836,6 +2113,9 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
if _u.mutation.SessionWindowStatusCleared() {
_spec.ClearField(account.FieldSessionWindowStatus, field.TypeString)
}
if value, ok := _u.mutation.QuotaDimension(); ok {
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
}
if _u.mutation.GroupsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
@@ -1922,6 +2202,80 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.ParentCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.ParentIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.ChildrenCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedChildrenIDs(); len(nodes) > 0 && !_u.mutation.ChildrenCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.ChildrenIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.UsageLogsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
+12 -1
View File
@@ -43,6 +43,8 @@ type ChannelMonitor struct {
Enabled bool `json:"enabled,omitempty"`
// IntervalSeconds holds the value of the "interval_seconds" field.
IntervalSeconds int `json:"interval_seconds,omitempty"`
// 每次调度在 interval 基础上 ± [0, jitter] 的均匀随机偏移(秒);0 表示固定间隔。service 层另保证 interval - jitter >= 15
JitterSeconds int `json:"jitter_seconds,omitempty"`
// LastCheckedAt holds the value of the "last_checked_at" field.
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
// CreatedBy holds the value of the "created_by" field.
@@ -112,7 +114,7 @@ func (*ChannelMonitor) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte)
case channelmonitor.FieldEnabled:
values[i] = new(sql.NullBool)
case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
values[i] = new(sql.NullInt64)
case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode:
values[i] = new(sql.NullString)
@@ -213,6 +215,12 @@ func (_m *ChannelMonitor) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.IntervalSeconds = int(value.Int64)
}
case channelmonitor.FieldJitterSeconds:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field jitter_seconds", values[i])
} else if value.Valid {
_m.JitterSeconds = int(value.Int64)
}
case channelmonitor.FieldLastCheckedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field last_checked_at", values[i])
@@ -341,6 +349,9 @@ func (_m *ChannelMonitor) String() string {
builder.WriteString("interval_seconds=")
builder.WriteString(fmt.Sprintf("%v", _m.IntervalSeconds))
builder.WriteString(", ")
builder.WriteString("jitter_seconds=")
builder.WriteString(fmt.Sprintf("%v", _m.JitterSeconds))
builder.WriteString(", ")
if v := _m.LastCheckedAt; v != nil {
builder.WriteString("last_checked_at=")
builder.WriteString(v.Format(time.ANSIC))
@@ -39,6 +39,8 @@ const (
FieldEnabled = "enabled"
// FieldIntervalSeconds holds the string denoting the interval_seconds field in the database.
FieldIntervalSeconds = "interval_seconds"
// FieldJitterSeconds holds the string denoting the jitter_seconds field in the database.
FieldJitterSeconds = "jitter_seconds"
// FieldLastCheckedAt holds the string denoting the last_checked_at field in the database.
FieldLastCheckedAt = "last_checked_at"
// FieldCreatedBy holds the string denoting the created_by field in the database.
@@ -97,6 +99,7 @@ var Columns = []string{
FieldGroupName,
FieldEnabled,
FieldIntervalSeconds,
FieldJitterSeconds,
FieldLastCheckedAt,
FieldCreatedBy,
FieldTemplateID,
@@ -144,6 +147,10 @@ var (
DefaultEnabled bool
// IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save.
IntervalSecondsValidator func(int) error
// DefaultJitterSeconds holds the default value on creation for the "jitter_seconds" field.
DefaultJitterSeconds int
// JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save.
JitterSecondsValidator func(int) error
// DefaultExtraHeaders holds the default value on creation for the "extra_headers" field.
DefaultExtraHeaders map[string]string
// DefaultBodyOverrideMode holds the default value on creation for the "body_override_mode" field.
@@ -239,6 +246,11 @@ func ByIntervalSeconds(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldIntervalSeconds, opts...).ToFunc()
}
// ByJitterSeconds orders the results by the jitter_seconds field.
func ByJitterSeconds(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldJitterSeconds, opts...).ToFunc()
}
// ByLastCheckedAt orders the results by the last_checked_at field.
func ByLastCheckedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLastCheckedAt, opts...).ToFunc()
+45
View File
@@ -105,6 +105,11 @@ func IntervalSeconds(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldIntervalSeconds, v))
}
// JitterSeconds applies equality check predicate on the "jitter_seconds" field. It's identical to JitterSecondsEQ.
func JitterSeconds(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldJitterSeconds, v))
}
// LastCheckedAt applies equality check predicate on the "last_checked_at" field. It's identical to LastCheckedAtEQ.
func LastCheckedAt(v time.Time) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldLastCheckedAt, v))
@@ -675,6 +680,46 @@ func IntervalSecondsLTE(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldIntervalSeconds, v))
}
// JitterSecondsEQ applies the EQ predicate on the "jitter_seconds" field.
func JitterSecondsEQ(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldJitterSeconds, v))
}
// JitterSecondsNEQ applies the NEQ predicate on the "jitter_seconds" field.
func JitterSecondsNEQ(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNEQ(FieldJitterSeconds, v))
}
// JitterSecondsIn applies the In predicate on the "jitter_seconds" field.
func JitterSecondsIn(vs ...int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIn(FieldJitterSeconds, vs...))
}
// JitterSecondsNotIn applies the NotIn predicate on the "jitter_seconds" field.
func JitterSecondsNotIn(vs ...int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldJitterSeconds, vs...))
}
// JitterSecondsGT applies the GT predicate on the "jitter_seconds" field.
func JitterSecondsGT(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGT(FieldJitterSeconds, v))
}
// JitterSecondsGTE applies the GTE predicate on the "jitter_seconds" field.
func JitterSecondsGTE(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGTE(FieldJitterSeconds, v))
}
// JitterSecondsLT applies the LT predicate on the "jitter_seconds" field.
func JitterSecondsLT(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLT(FieldJitterSeconds, v))
}
// JitterSecondsLTE applies the LTE predicate on the "jitter_seconds" field.
func JitterSecondsLTE(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldJitterSeconds, v))
}
// LastCheckedAtEQ applies the EQ predicate on the "last_checked_at" field.
func LastCheckedAtEQ(v time.Time) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldLastCheckedAt, v))
+90
View File
@@ -137,6 +137,20 @@ func (_c *ChannelMonitorCreate) SetIntervalSeconds(v int) *ChannelMonitorCreate
return _c
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (_c *ChannelMonitorCreate) SetJitterSeconds(v int) *ChannelMonitorCreate {
_c.mutation.SetJitterSeconds(v)
return _c
}
// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil.
func (_c *ChannelMonitorCreate) SetNillableJitterSeconds(v *int) *ChannelMonitorCreate {
if v != nil {
_c.SetJitterSeconds(*v)
}
return _c
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (_c *ChannelMonitorCreate) SetLastCheckedAt(v time.Time) *ChannelMonitorCreate {
_c.mutation.SetLastCheckedAt(v)
@@ -305,6 +319,10 @@ func (_c *ChannelMonitorCreate) defaults() {
v := channelmonitor.DefaultEnabled
_c.mutation.SetEnabled(v)
}
if _, ok := _c.mutation.JitterSeconds(); !ok {
v := channelmonitor.DefaultJitterSeconds
_c.mutation.SetJitterSeconds(v)
}
if _, ok := _c.mutation.ExtraHeaders(); !ok {
v := channelmonitor.DefaultExtraHeaders
_c.mutation.SetExtraHeaders(v)
@@ -390,6 +408,14 @@ func (_c *ChannelMonitorCreate) check() error {
return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)}
}
}
if _, ok := _c.mutation.JitterSeconds(); !ok {
return &ValidationError{Name: "jitter_seconds", err: errors.New(`ent: missing required field "ChannelMonitor.jitter_seconds"`)}
}
if v, ok := _c.mutation.JitterSeconds(); ok {
if err := channelmonitor.JitterSecondsValidator(v); err != nil {
return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)}
}
}
if _, ok := _c.mutation.CreatedBy(); !ok {
return &ValidationError{Name: "created_by", err: errors.New(`ent: missing required field "ChannelMonitor.created_by"`)}
}
@@ -479,6 +505,10 @@ func (_c *ChannelMonitorCreate) createSpec() (*ChannelMonitor, *sqlgraph.CreateS
_spec.SetField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value)
_node.IntervalSeconds = value
}
if value, ok := _c.mutation.JitterSeconds(); ok {
_spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
_node.JitterSeconds = value
}
if value, ok := _c.mutation.LastCheckedAt(); ok {
_spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value)
_node.LastCheckedAt = &value
@@ -744,6 +774,24 @@ func (u *ChannelMonitorUpsert) AddIntervalSeconds(v int) *ChannelMonitorUpsert {
return u
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (u *ChannelMonitorUpsert) SetJitterSeconds(v int) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldJitterSeconds, v)
return u
}
// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create.
func (u *ChannelMonitorUpsert) UpdateJitterSeconds() *ChannelMonitorUpsert {
u.SetExcluded(channelmonitor.FieldJitterSeconds)
return u
}
// AddJitterSeconds adds v to the "jitter_seconds" field.
func (u *ChannelMonitorUpsert) AddJitterSeconds(v int) *ChannelMonitorUpsert {
u.Add(channelmonitor.FieldJitterSeconds, v)
return u
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (u *ChannelMonitorUpsert) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldLastCheckedAt, v)
@@ -1053,6 +1101,27 @@ func (u *ChannelMonitorUpsertOne) UpdateIntervalSeconds() *ChannelMonitorUpsertO
})
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (u *ChannelMonitorUpsertOne) SetJitterSeconds(v int) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetJitterSeconds(v)
})
}
// AddJitterSeconds adds v to the "jitter_seconds" field.
func (u *ChannelMonitorUpsertOne) AddJitterSeconds(v int) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddJitterSeconds(v)
})
}
// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create.
func (u *ChannelMonitorUpsertOne) UpdateJitterSeconds() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateJitterSeconds()
})
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (u *ChannelMonitorUpsertOne) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
@@ -1544,6 +1613,27 @@ func (u *ChannelMonitorUpsertBulk) UpdateIntervalSeconds() *ChannelMonitorUpsert
})
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (u *ChannelMonitorUpsertBulk) SetJitterSeconds(v int) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetJitterSeconds(v)
})
}
// AddJitterSeconds adds v to the "jitter_seconds" field.
func (u *ChannelMonitorUpsertBulk) AddJitterSeconds(v int) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddJitterSeconds(v)
})
}
// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create.
func (u *ChannelMonitorUpsertBulk) UpdateJitterSeconds() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateJitterSeconds()
})
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (u *ChannelMonitorUpsertBulk) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
+64
View File
@@ -189,6 +189,27 @@ func (_u *ChannelMonitorUpdate) AddIntervalSeconds(v int) *ChannelMonitorUpdate
return _u
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (_u *ChannelMonitorUpdate) SetJitterSeconds(v int) *ChannelMonitorUpdate {
_u.mutation.ResetJitterSeconds()
_u.mutation.SetJitterSeconds(v)
return _u
}
// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil.
func (_u *ChannelMonitorUpdate) SetNillableJitterSeconds(v *int) *ChannelMonitorUpdate {
if v != nil {
_u.SetJitterSeconds(*v)
}
return _u
}
// AddJitterSeconds adds value to the "jitter_seconds" field.
func (_u *ChannelMonitorUpdate) AddJitterSeconds(v int) *ChannelMonitorUpdate {
_u.mutation.AddJitterSeconds(v)
return _u
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (_u *ChannelMonitorUpdate) SetLastCheckedAt(v time.Time) *ChannelMonitorUpdate {
_u.mutation.SetLastCheckedAt(v)
@@ -462,6 +483,11 @@ func (_u *ChannelMonitorUpdate) check() error {
return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)}
}
}
if v, ok := _u.mutation.JitterSeconds(); ok {
if err := channelmonitor.JitterSecondsValidator(v); err != nil {
return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)}
}
}
if v, ok := _u.mutation.BodyOverrideMode(); ok {
if err := channelmonitor.BodyOverrideModeValidator(v); err != nil {
return &ValidationError{Name: "body_override_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.body_override_mode": %w`, err)}
@@ -526,6 +552,12 @@ func (_u *ChannelMonitorUpdate) sqlSave(ctx context.Context) (_node int, err err
if value, ok := _u.mutation.AddedIntervalSeconds(); ok {
_spec.AddField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.JitterSeconds(); ok {
_spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedJitterSeconds(); ok {
_spec.AddField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.LastCheckedAt(); ok {
_spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value)
}
@@ -846,6 +878,27 @@ func (_u *ChannelMonitorUpdateOne) AddIntervalSeconds(v int) *ChannelMonitorUpda
return _u
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (_u *ChannelMonitorUpdateOne) SetJitterSeconds(v int) *ChannelMonitorUpdateOne {
_u.mutation.ResetJitterSeconds()
_u.mutation.SetJitterSeconds(v)
return _u
}
// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil.
func (_u *ChannelMonitorUpdateOne) SetNillableJitterSeconds(v *int) *ChannelMonitorUpdateOne {
if v != nil {
_u.SetJitterSeconds(*v)
}
return _u
}
// AddJitterSeconds adds value to the "jitter_seconds" field.
func (_u *ChannelMonitorUpdateOne) AddJitterSeconds(v int) *ChannelMonitorUpdateOne {
_u.mutation.AddJitterSeconds(v)
return _u
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (_u *ChannelMonitorUpdateOne) SetLastCheckedAt(v time.Time) *ChannelMonitorUpdateOne {
_u.mutation.SetLastCheckedAt(v)
@@ -1132,6 +1185,11 @@ func (_u *ChannelMonitorUpdateOne) check() error {
return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)}
}
}
if v, ok := _u.mutation.JitterSeconds(); ok {
if err := channelmonitor.JitterSecondsValidator(v); err != nil {
return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)}
}
}
if v, ok := _u.mutation.BodyOverrideMode(); ok {
if err := channelmonitor.BodyOverrideModeValidator(v); err != nil {
return &ValidationError{Name: "body_override_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.body_override_mode": %w`, err)}
@@ -1213,6 +1271,12 @@ func (_u *ChannelMonitorUpdateOne) sqlSave(ctx context.Context) (_node *ChannelM
if value, ok := _u.mutation.AddedIntervalSeconds(); ok {
_spec.AddField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.JitterSeconds(); ok {
_spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedJitterSeconds(); ok {
_spec.AddField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.LastCheckedAt(); ok {
_spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value)
}
+32
View File
@@ -820,6 +820,38 @@ func (c *AccountClient) QueryProxy(_m *Account) *ProxyQuery {
return query
}
// QueryParent queries the parent edge of a Account.
func (c *AccountClient) QueryParent(_m *Account) *AccountQuery {
query := (&AccountClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(account.Table, account.FieldID, id),
sqlgraph.To(account.Table, account.FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, account.ParentTable, account.ParentColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryChildren queries the children edge of a Account.
func (c *AccountClient) QueryChildren(_m *Account) *AccountQuery {
query := (&AccountClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(account.Table, account.FieldID, id),
sqlgraph.To(account.Table, account.FieldID),
sqlgraph.Edge(sqlgraph.O2M, false, account.ChildrenTable, account.ChildrenColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryUsageLogs queries the usage_logs edge of a Account.
func (c *AccountClient) QueryUsageLogs(_m *Account) *UsageLogQuery {
query := (&UsageLogClient{config: c.config}).Query()
@@ -46,6 +46,16 @@ func TestPaymentOrdersOutTradeNoPartialUniqueIndex(t *testing.T) {
require.Equal(t, (&entsql.IndexAnnotation{Where: "out_trade_no <> ''"}).Where, idx.Annotation.Where)
}
func TestAccountsParentAccountForeignKey(t *testing.T) {
fk := findForeignKeyByColumn(t, AccountsTable, "parent_account_id")
require.Len(t, fk.Columns, 1)
require.Equal(t, "parent_account_id", fk.Columns[0].Name)
require.False(t, fk.Columns[0].Unique, "active-shadow uniqueness is enforced by the partial uq_accounts_spark_shadow_per_parent index")
require.Len(t, fk.RefColumns, 1)
require.Equal(t, "id", fk.RefColumns[0].Name)
require.Equal(t, entschema.Restrict, fk.OnDelete)
}
func findForeignKeyBySymbol(t *testing.T, table *entschema.Table, symbol string) *entschema.ForeignKey {
t.Helper()
@@ -71,3 +81,18 @@ func findIndexByName(t *testing.T, table *entschema.Table, name string) *entsche
require.Failf(t, "missing index", "table %s should include index %s", table.Name, name)
return nil
}
func findForeignKeyByColumn(t *testing.T, table *entschema.Table, column string) *entschema.ForeignKey {
t.Helper()
for _, fk := range table.ForeignKeys {
for _, col := range fk.Columns {
if col.Name == column {
return fk
}
}
}
require.Failf(t, "missing foreign key", "table %s should include foreign key for column %s", table.Name, column)
return nil
}
+20 -5
View File
@@ -124,7 +124,9 @@ var (
{Name: "session_window_start", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "session_window_end", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "session_window_status", Type: field.TypeString, Nullable: true, Size: 20},
{Name: "quota_dimension", Type: field.TypeEnum, Enums: []string{"global", "spark"}, Default: "global"},
{Name: "proxy_id", Type: field.TypeInt64, Nullable: true},
{Name: "parent_account_id", Type: field.TypeInt64, Nullable: true},
}
// AccountsTable holds the schema information for the "accounts" table.
AccountsTable = &schema.Table{
@@ -134,10 +136,16 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "accounts_proxies_proxy",
Columns: []*schema.Column{AccountsColumns[29]},
Columns: []*schema.Column{AccountsColumns[30]},
RefColumns: []*schema.Column{ProxiesColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "accounts_accounts_children",
Columns: []*schema.Column{AccountsColumns[31]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.Restrict,
},
},
Indexes: []*schema.Index{
{
@@ -158,7 +166,7 @@ var (
{
Name: "account_proxy_id",
Unique: false,
Columns: []*schema.Column{AccountsColumns[29]},
Columns: []*schema.Column{AccountsColumns[30]},
},
{
Name: "account_priority",
@@ -205,6 +213,11 @@ var (
Unique: false,
Columns: []*schema.Column{AccountsColumns[3]},
},
{
Name: "account_parent_account_id",
Unique: false,
Columns: []*schema.Column{AccountsColumns[31]},
},
},
}
// AccountGroupsColumns holds the columns for the "account_groups" table.
@@ -437,6 +450,7 @@ var (
{Name: "group_name", Type: field.TypeString, Nullable: true, Size: 100, Default: ""},
{Name: "enabled", Type: field.TypeBool, Default: true},
{Name: "interval_seconds", Type: field.TypeInt},
{Name: "jitter_seconds", Type: field.TypeInt, Default: 0},
{Name: "last_checked_at", Type: field.TypeTime, Nullable: true},
{Name: "created_by", Type: field.TypeInt64},
{Name: "extra_headers", Type: field.TypeJSON},
@@ -452,7 +466,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "channel_monitors_channel_monitor_request_templates_request_template",
Columns: []*schema.Column{ChannelMonitorsColumns[18]},
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
RefColumns: []*schema.Column{ChannelMonitorRequestTemplatesColumns[0]},
OnDelete: schema.SetNull,
},
@@ -461,7 +475,7 @@ var (
{
Name: "channelmonitor_enabled_last_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[13]},
Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[14]},
},
{
Name: "channelmonitor_provider",
@@ -481,7 +495,7 @@ var (
{
Name: "channelmonitor_template_id",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[18]},
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
},
},
}
@@ -1819,6 +1833,7 @@ func init() {
Table: "api_keys",
}
AccountsTable.ForeignKeys[0].RefTable = ProxiesTable
AccountsTable.ForeignKeys[1].RefTable = AccountsTable
AccountsTable.Annotation = &entsql.Annotation{
Table: "accounts",
}
+361 -5
View File
@@ -2310,12 +2310,18 @@ type AccountMutation struct {
session_window_start *time.Time
session_window_end *time.Time
session_window_status *string
quota_dimension *account.QuotaDimension
clearedFields map[string]struct{}
groups map[int64]struct{}
removedgroups map[int64]struct{}
clearedgroups bool
proxy *int64
clearedproxy bool
parent *int64
clearedparent bool
children map[int64]struct{}
removedchildren map[int64]struct{}
clearedchildren bool
usage_logs map[int64]struct{}
removedusage_logs map[int64]struct{}
clearedusage_logs bool
@@ -3776,6 +3782,91 @@ func (m *AccountMutation) ResetSessionWindowStatus() {
delete(m.clearedFields, account.FieldSessionWindowStatus)
}
// SetParentAccountID sets the "parent_account_id" field.
func (m *AccountMutation) SetParentAccountID(i int64) {
m.parent = &i
}
// ParentAccountID returns the value of the "parent_account_id" field in the mutation.
func (m *AccountMutation) ParentAccountID() (r int64, exists bool) {
v := m.parent
if v == nil {
return
}
return *v, true
}
// OldParentAccountID returns the old "parent_account_id" field's value of the Account entity.
// If the Account 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 *AccountMutation) OldParentAccountID(ctx context.Context) (v *int64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldParentAccountID is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldParentAccountID requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldParentAccountID: %w", err)
}
return oldValue.ParentAccountID, nil
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (m *AccountMutation) ClearParentAccountID() {
m.parent = nil
m.clearedFields[account.FieldParentAccountID] = struct{}{}
}
// ParentAccountIDCleared returns if the "parent_account_id" field was cleared in this mutation.
func (m *AccountMutation) ParentAccountIDCleared() bool {
_, ok := m.clearedFields[account.FieldParentAccountID]
return ok
}
// ResetParentAccountID resets all changes to the "parent_account_id" field.
func (m *AccountMutation) ResetParentAccountID() {
m.parent = nil
delete(m.clearedFields, account.FieldParentAccountID)
}
// SetQuotaDimension sets the "quota_dimension" field.
func (m *AccountMutation) SetQuotaDimension(ad account.QuotaDimension) {
m.quota_dimension = &ad
}
// QuotaDimension returns the value of the "quota_dimension" field in the mutation.
func (m *AccountMutation) QuotaDimension() (r account.QuotaDimension, exists bool) {
v := m.quota_dimension
if v == nil {
return
}
return *v, true
}
// OldQuotaDimension returns the old "quota_dimension" field's value of the Account entity.
// If the Account 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 *AccountMutation) OldQuotaDimension(ctx context.Context) (v account.QuotaDimension, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldQuotaDimension is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldQuotaDimension requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldQuotaDimension: %w", err)
}
return oldValue.QuotaDimension, nil
}
// ResetQuotaDimension resets all changes to the "quota_dimension" field.
func (m *AccountMutation) ResetQuotaDimension() {
m.quota_dimension = nil
}
// AddGroupIDs adds the "groups" edge to the Group entity by ids.
func (m *AccountMutation) AddGroupIDs(ids ...int64) {
if m.groups == nil {
@@ -3857,6 +3948,100 @@ func (m *AccountMutation) ResetProxy() {
m.clearedproxy = false
}
// SetParentID sets the "parent" edge to the Account entity by id.
func (m *AccountMutation) SetParentID(id int64) {
m.parent = &id
}
// ClearParent clears the "parent" edge to the Account entity.
func (m *AccountMutation) ClearParent() {
m.clearedparent = true
m.clearedFields[account.FieldParentAccountID] = struct{}{}
}
// ParentCleared reports if the "parent" edge to the Account entity was cleared.
func (m *AccountMutation) ParentCleared() bool {
return m.ParentAccountIDCleared() || m.clearedparent
}
// ParentID returns the "parent" edge ID in the mutation.
func (m *AccountMutation) ParentID() (id int64, exists bool) {
if m.parent != nil {
return *m.parent, true
}
return
}
// ParentIDs returns the "parent" edge IDs in the mutation.
// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
// ParentID instead. It exists only for internal usage by the builders.
func (m *AccountMutation) ParentIDs() (ids []int64) {
if id := m.parent; id != nil {
ids = append(ids, *id)
}
return
}
// ResetParent resets all changes to the "parent" edge.
func (m *AccountMutation) ResetParent() {
m.parent = nil
m.clearedparent = false
}
// AddChildIDs adds the "children" edge to the Account entity by ids.
func (m *AccountMutation) AddChildIDs(ids ...int64) {
if m.children == nil {
m.children = make(map[int64]struct{})
}
for i := range ids {
m.children[ids[i]] = struct{}{}
}
}
// ClearChildren clears the "children" edge to the Account entity.
func (m *AccountMutation) ClearChildren() {
m.clearedchildren = true
}
// ChildrenCleared reports if the "children" edge to the Account entity was cleared.
func (m *AccountMutation) ChildrenCleared() bool {
return m.clearedchildren
}
// RemoveChildIDs removes the "children" edge to the Account entity by IDs.
func (m *AccountMutation) RemoveChildIDs(ids ...int64) {
if m.removedchildren == nil {
m.removedchildren = make(map[int64]struct{})
}
for i := range ids {
delete(m.children, ids[i])
m.removedchildren[ids[i]] = struct{}{}
}
}
// RemovedChildren returns the removed IDs of the "children" edge to the Account entity.
func (m *AccountMutation) RemovedChildrenIDs() (ids []int64) {
for id := range m.removedchildren {
ids = append(ids, id)
}
return
}
// ChildrenIDs returns the "children" edge IDs in the mutation.
func (m *AccountMutation) ChildrenIDs() (ids []int64) {
for id := range m.children {
ids = append(ids, id)
}
return
}
// ResetChildren resets all changes to the "children" edge.
func (m *AccountMutation) ResetChildren() {
m.children = nil
m.clearedchildren = false
m.removedchildren = nil
}
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by ids.
func (m *AccountMutation) AddUsageLogIDs(ids ...int64) {
if m.usage_logs == nil {
@@ -3945,7 +4130,7 @@ func (m *AccountMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *AccountMutation) Fields() []string {
fields := make([]string, 0, 29)
fields := make([]string, 0, 31)
if m.created_at != nil {
fields = append(fields, account.FieldCreatedAt)
}
@@ -4033,6 +4218,12 @@ func (m *AccountMutation) Fields() []string {
if m.session_window_status != nil {
fields = append(fields, account.FieldSessionWindowStatus)
}
if m.parent != nil {
fields = append(fields, account.FieldParentAccountID)
}
if m.quota_dimension != nil {
fields = append(fields, account.FieldQuotaDimension)
}
return fields
}
@@ -4099,6 +4290,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) {
return m.SessionWindowEnd()
case account.FieldSessionWindowStatus:
return m.SessionWindowStatus()
case account.FieldParentAccountID:
return m.ParentAccountID()
case account.FieldQuotaDimension:
return m.QuotaDimension()
}
return nil, false
}
@@ -4166,6 +4361,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldSessionWindowEnd(ctx)
case account.FieldSessionWindowStatus:
return m.OldSessionWindowStatus(ctx)
case account.FieldParentAccountID:
return m.OldParentAccountID(ctx)
case account.FieldQuotaDimension:
return m.OldQuotaDimension(ctx)
}
return nil, fmt.Errorf("unknown Account field %s", name)
}
@@ -4378,6 +4577,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error {
}
m.SetSessionWindowStatus(v)
return nil
case account.FieldParentAccountID:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetParentAccountID(v)
return nil
case account.FieldQuotaDimension:
v, ok := value.(account.QuotaDimension)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetQuotaDimension(v)
return nil
}
return fmt.Errorf("unknown Account field %s", name)
}
@@ -4519,6 +4732,9 @@ func (m *AccountMutation) ClearedFields() []string {
if m.FieldCleared(account.FieldSessionWindowStatus) {
fields = append(fields, account.FieldSessionWindowStatus)
}
if m.FieldCleared(account.FieldParentAccountID) {
fields = append(fields, account.FieldParentAccountID)
}
return fields
}
@@ -4581,6 +4797,9 @@ func (m *AccountMutation) ClearField(name string) error {
case account.FieldSessionWindowStatus:
m.ClearSessionWindowStatus()
return nil
case account.FieldParentAccountID:
m.ClearParentAccountID()
return nil
}
return fmt.Errorf("unknown Account nullable field %s", name)
}
@@ -4676,19 +4895,31 @@ func (m *AccountMutation) ResetField(name string) error {
case account.FieldSessionWindowStatus:
m.ResetSessionWindowStatus()
return nil
case account.FieldParentAccountID:
m.ResetParentAccountID()
return nil
case account.FieldQuotaDimension:
m.ResetQuotaDimension()
return nil
}
return fmt.Errorf("unknown Account field %s", name)
}
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *AccountMutation) AddedEdges() []string {
edges := make([]string, 0, 3)
edges := make([]string, 0, 5)
if m.groups != nil {
edges = append(edges, account.EdgeGroups)
}
if m.proxy != nil {
edges = append(edges, account.EdgeProxy)
}
if m.parent != nil {
edges = append(edges, account.EdgeParent)
}
if m.children != nil {
edges = append(edges, account.EdgeChildren)
}
if m.usage_logs != nil {
edges = append(edges, account.EdgeUsageLogs)
}
@@ -4709,6 +4940,16 @@ func (m *AccountMutation) AddedIDs(name string) []ent.Value {
if id := m.proxy; id != nil {
return []ent.Value{*id}
}
case account.EdgeParent:
if id := m.parent; id != nil {
return []ent.Value{*id}
}
case account.EdgeChildren:
ids := make([]ent.Value, 0, len(m.children))
for id := range m.children {
ids = append(ids, id)
}
return ids
case account.EdgeUsageLogs:
ids := make([]ent.Value, 0, len(m.usage_logs))
for id := range m.usage_logs {
@@ -4721,10 +4962,13 @@ func (m *AccountMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *AccountMutation) RemovedEdges() []string {
edges := make([]string, 0, 3)
edges := make([]string, 0, 5)
if m.removedgroups != nil {
edges = append(edges, account.EdgeGroups)
}
if m.removedchildren != nil {
edges = append(edges, account.EdgeChildren)
}
if m.removedusage_logs != nil {
edges = append(edges, account.EdgeUsageLogs)
}
@@ -4741,6 +4985,12 @@ func (m *AccountMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case account.EdgeChildren:
ids := make([]ent.Value, 0, len(m.removedchildren))
for id := range m.removedchildren {
ids = append(ids, id)
}
return ids
case account.EdgeUsageLogs:
ids := make([]ent.Value, 0, len(m.removedusage_logs))
for id := range m.removedusage_logs {
@@ -4753,13 +5003,19 @@ func (m *AccountMutation) RemovedIDs(name string) []ent.Value {
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *AccountMutation) ClearedEdges() []string {
edges := make([]string, 0, 3)
edges := make([]string, 0, 5)
if m.clearedgroups {
edges = append(edges, account.EdgeGroups)
}
if m.clearedproxy {
edges = append(edges, account.EdgeProxy)
}
if m.clearedparent {
edges = append(edges, account.EdgeParent)
}
if m.clearedchildren {
edges = append(edges, account.EdgeChildren)
}
if m.clearedusage_logs {
edges = append(edges, account.EdgeUsageLogs)
}
@@ -4774,6 +5030,10 @@ func (m *AccountMutation) EdgeCleared(name string) bool {
return m.clearedgroups
case account.EdgeProxy:
return m.clearedproxy
case account.EdgeParent:
return m.clearedparent
case account.EdgeChildren:
return m.clearedchildren
case account.EdgeUsageLogs:
return m.clearedusage_logs
}
@@ -4787,6 +5047,9 @@ func (m *AccountMutation) ClearEdge(name string) error {
case account.EdgeProxy:
m.ClearProxy()
return nil
case account.EdgeParent:
m.ClearParent()
return nil
}
return fmt.Errorf("unknown Account unique edge %s", name)
}
@@ -4801,6 +5064,12 @@ func (m *AccountMutation) ResetEdge(name string) error {
case account.EdgeProxy:
m.ResetProxy()
return nil
case account.EdgeParent:
m.ResetParent()
return nil
case account.EdgeChildren:
m.ResetChildren()
return nil
case account.EdgeUsageLogs:
m.ResetUsageLogs()
return nil
@@ -8871,6 +9140,8 @@ type ChannelMonitorMutation struct {
enabled *bool
interval_seconds *int
addinterval_seconds *int
jitter_seconds *int
addjitter_seconds *int
last_checked_at *time.Time
created_by *int64
addcreated_by *int64
@@ -9469,6 +9740,62 @@ func (m *ChannelMonitorMutation) ResetIntervalSeconds() {
m.addinterval_seconds = nil
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (m *ChannelMonitorMutation) SetJitterSeconds(i int) {
m.jitter_seconds = &i
m.addjitter_seconds = nil
}
// JitterSeconds returns the value of the "jitter_seconds" field in the mutation.
func (m *ChannelMonitorMutation) JitterSeconds() (r int, exists bool) {
v := m.jitter_seconds
if v == nil {
return
}
return *v, true
}
// OldJitterSeconds returns the old "jitter_seconds" field's value of the ChannelMonitor entity.
// If the ChannelMonitor 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 *ChannelMonitorMutation) OldJitterSeconds(ctx context.Context) (v int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldJitterSeconds is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldJitterSeconds requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldJitterSeconds: %w", err)
}
return oldValue.JitterSeconds, nil
}
// AddJitterSeconds adds i to the "jitter_seconds" field.
func (m *ChannelMonitorMutation) AddJitterSeconds(i int) {
if m.addjitter_seconds != nil {
*m.addjitter_seconds += i
} else {
m.addjitter_seconds = &i
}
}
// AddedJitterSeconds returns the value that was added to the "jitter_seconds" field in this mutation.
func (m *ChannelMonitorMutation) AddedJitterSeconds() (r int, exists bool) {
v := m.addjitter_seconds
if v == nil {
return
}
return *v, true
}
// ResetJitterSeconds resets all changes to the "jitter_seconds" field.
func (m *ChannelMonitorMutation) ResetJitterSeconds() {
m.jitter_seconds = nil
m.addjitter_seconds = nil
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (m *ChannelMonitorMutation) SetLastCheckedAt(t time.Time) {
m.last_checked_at = &t
@@ -9926,7 +10253,7 @@ func (m *ChannelMonitorMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ChannelMonitorMutation) Fields() []string {
fields := make([]string, 0, 18)
fields := make([]string, 0, 19)
if m.created_at != nil {
fields = append(fields, channelmonitor.FieldCreatedAt)
}
@@ -9963,6 +10290,9 @@ func (m *ChannelMonitorMutation) Fields() []string {
if m.interval_seconds != nil {
fields = append(fields, channelmonitor.FieldIntervalSeconds)
}
if m.jitter_seconds != nil {
fields = append(fields, channelmonitor.FieldJitterSeconds)
}
if m.last_checked_at != nil {
fields = append(fields, channelmonitor.FieldLastCheckedAt)
}
@@ -10013,6 +10343,8 @@ func (m *ChannelMonitorMutation) Field(name string) (ent.Value, bool) {
return m.Enabled()
case channelmonitor.FieldIntervalSeconds:
return m.IntervalSeconds()
case channelmonitor.FieldJitterSeconds:
return m.JitterSeconds()
case channelmonitor.FieldLastCheckedAt:
return m.LastCheckedAt()
case channelmonitor.FieldCreatedBy:
@@ -10058,6 +10390,8 @@ func (m *ChannelMonitorMutation) OldField(ctx context.Context, name string) (ent
return m.OldEnabled(ctx)
case channelmonitor.FieldIntervalSeconds:
return m.OldIntervalSeconds(ctx)
case channelmonitor.FieldJitterSeconds:
return m.OldJitterSeconds(ctx)
case channelmonitor.FieldLastCheckedAt:
return m.OldLastCheckedAt(ctx)
case channelmonitor.FieldCreatedBy:
@@ -10163,6 +10497,13 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error {
}
m.SetIntervalSeconds(v)
return nil
case channelmonitor.FieldJitterSeconds:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetJitterSeconds(v)
return nil
case channelmonitor.FieldLastCheckedAt:
v, ok := value.(time.Time)
if !ok {
@@ -10216,6 +10557,9 @@ func (m *ChannelMonitorMutation) AddedFields() []string {
if m.addinterval_seconds != nil {
fields = append(fields, channelmonitor.FieldIntervalSeconds)
}
if m.addjitter_seconds != nil {
fields = append(fields, channelmonitor.FieldJitterSeconds)
}
if m.addcreated_by != nil {
fields = append(fields, channelmonitor.FieldCreatedBy)
}
@@ -10229,6 +10573,8 @@ func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) {
switch name {
case channelmonitor.FieldIntervalSeconds:
return m.AddedIntervalSeconds()
case channelmonitor.FieldJitterSeconds:
return m.AddedJitterSeconds()
case channelmonitor.FieldCreatedBy:
return m.AddedCreatedBy()
}
@@ -10247,6 +10593,13 @@ func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error {
}
m.AddIntervalSeconds(v)
return nil
case channelmonitor.FieldJitterSeconds:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddJitterSeconds(v)
return nil
case channelmonitor.FieldCreatedBy:
v, ok := value.(int64)
if !ok {
@@ -10344,6 +10697,9 @@ func (m *ChannelMonitorMutation) ResetField(name string) error {
case channelmonitor.FieldIntervalSeconds:
m.ResetIntervalSeconds()
return nil
case channelmonitor.FieldJitterSeconds:
m.ResetJitterSeconds()
return nil
case channelmonitor.FieldLastCheckedAt:
m.ResetLastCheckedAt()
return nil
+8 -2
View File
@@ -529,12 +529,18 @@ func init() {
channelmonitorDescIntervalSeconds := channelmonitorFields[9].Descriptor()
// channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save.
channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error)
// channelmonitorDescJitterSeconds is the schema descriptor for jitter_seconds field.
channelmonitorDescJitterSeconds := channelmonitorFields[10].Descriptor()
// channelmonitor.DefaultJitterSeconds holds the default value on creation for the jitter_seconds field.
channelmonitor.DefaultJitterSeconds = channelmonitorDescJitterSeconds.Default.(int)
// channelmonitor.JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save.
channelmonitor.JitterSecondsValidator = channelmonitorDescJitterSeconds.Validators[0].(func(int) error)
// channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field.
channelmonitorDescExtraHeaders := channelmonitorFields[13].Descriptor()
channelmonitorDescExtraHeaders := channelmonitorFields[14].Descriptor()
// channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field.
channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string)
// channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field.
channelmonitorDescBodyOverrideMode := channelmonitorFields[14].Descriptor()
channelmonitorDescBodyOverrideMode := channelmonitorFields[15].Descriptor()
// channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field.
channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string)
// channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save.
+14
View File
@@ -196,6 +196,11 @@ func (Account) Fields() []ent.Field {
Optional().
Nillable().
MaxLen(20),
field.Int64("parent_account_id").Optional().Nillable().
Comment("Parent account id for a linked spark shadow (NULL = normal)."),
field.Enum("quota_dimension").Values("global", "spark").Default("global").
Comment("'global' (default) or 'spark' (shadow reads codex_bengalfox)."),
}
}
@@ -212,6 +217,14 @@ func (Account) Edges() []ent.Edge {
edge.To("proxy", Proxy.Type).
Field("proxy_id").
Unique(),
// children/parent: linked spark shadow relationship.
// parent_account_id is nullable, and the active one-shadow-per-parent rule
// is enforced by the partial unique index in migration 154a.
edge.To("children", Account.Type).
Annotations(entsql.OnDelete(entsql.Restrict)).
From("parent").
Field("parent_account_id").
Unique(),
// usage_logs: 该账户的使用日志
edge.To("usage_logs", UsageLog.Type),
}
@@ -235,5 +248,6 @@ func (Account) Indexes() []ent.Index {
index.Fields("platform", "priority"),
index.Fields("priority", "status"),
index.Fields("deleted_at"), // 软删除查询优化
index.Fields("parent_account_id"),
}
}
+4
View File
@@ -62,6 +62,10 @@ func (ChannelMonitor) Fields() []ent.Field {
Default(true),
field.Int("interval_seconds").
Range(15, 3600),
field.Int("jitter_seconds").
Default(0).
Range(0, 3600).
Comment("每次调度在 interval 基础上 ± [0, jitter] 的均匀随机偏移(秒);0 表示固定间隔。service 层另保证 interval - jitter >= 15"),
field.Time("last_checked_at").
Optional().
Nillable(),
+1 -1
View File
@@ -41,7 +41,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity":
case "anthropic", "openai", "gemini", "antigravity", "grok":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+17
View File
@@ -0,0 +1,17 @@
package ent
import "context"
// WithoutTx 返回一个剥离了所附 *Tx 的 ctx 副本,使调用方可以在基础 client
// (autocommit)上执行 best-effort、非关键的副作用,而不加入——也就不会毒化——
// 外层事务。
//
// Postgres 语义:事务内任一语句失败即把整个事务标记为 aborted,后续语句全部被拒,
// 直到 ROLLBACK。因此对 fail-open 的副作用(如注册时的默认平台配额快照)必须做事务
// 隔离,否则一条无关紧要的写入失败会连累调用方的关键事务。
func WithoutTx(ctx context.Context) context.Context {
if TxFromContext(ctx) == nil {
return ctx
}
return context.WithValue(ctx, txCtxKey{}, (*Tx)(nil))
}
+2
View File
@@ -164,6 +164,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=
+25 -2
View File
@@ -643,6 +643,10 @@ type ProxyProbeConfig struct {
type BillingConfig struct {
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
// MinimumBalanceReserve is the conservative preflight floor for balance billing.
// Requests in balance mode are rejected when the cached balance is below this
// amount, even if it is still positive. Set to 0 to keep the legacy balance > 0 gate.
MinimumBalanceReserve float64 `mapstructure:"minimum_balance_reserve"`
// UserPlatformQuotaCacheTTLSeconds 用户 × 平台 quota 缓存 TTL(秒),默认 86400=1天,覆盖典型 daily 窗口。
// 消费点:
// - billing_cache_service.cacheWriteWorker 异步累加
@@ -957,6 +961,11 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
Queue float64 `mapstructure:"queue"`
ErrorRate float64 `mapstructure:"error_rate"`
TTFT float64 `mapstructure:"ttft"`
// Reset 倾向「会话窗口最早重置」的账号(use-it-or-lose-it)。
// >0 时,剩余重置时间越短的账号得分越高,从而被优先用尽。默认 0(关闭,不改变原有行为)。
Reset float64 `mapstructure:"reset"`
// QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。
QuotaHeadroom float64 `mapstructure:"quota_headroom"`
}
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
@@ -1055,6 +1064,11 @@ type GatewaySchedulingConfig struct {
// 兜底层账户选择策略: "last_used"(按最后使用时间排序,默认) 或 "random"(随机)
FallbackSelectionMode string `mapstructure:"fallback_selection_mode"`
// PreferSoonestReset 开启后,负载感知选择会优先选用「会话窗口最早重置」的账号
// (use-it-or-lose-it:先用尽即将重置的账号,保留重置时间还很久的账号)。
// 默认 false,保持原有「优先级 → 负载率 → LRU」行为不变。
PreferSoonestReset bool `mapstructure:"prefer_soonest_reset"`
// 负载计算
LoadBatchEnabled bool `mapstructure:"load_batch_enabled"`
LoadBatchCacheTTLMS int `mapstructure:"load_batch_cache_ttl_ms"`
@@ -1607,6 +1621,7 @@ func setDefaults() {
viper.SetDefault("billing.circuit_breaker.failure_threshold", 5)
viper.SetDefault("billing.circuit_breaker.reset_timeout_seconds", 30)
viper.SetDefault("billing.circuit_breaker.half_open_requests", 3)
viper.SetDefault("billing.minimum_balance_reserve", 0.000001)
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)
@@ -1870,6 +1885,8 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.queue", 0.7)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.error_rate", 0.8)
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)
// OpenAI HTTP upstream protocol strategy
viper.SetDefault("gateway.openai_http2.enabled", true)
viper.SetDefault("gateway.openai_http2.allow_proxy_fallback_to_http1", true)
@@ -1906,6 +1923,7 @@ func setDefaults() {
viper.SetDefault("gateway.scheduling.fallback_wait_timeout", 30*time.Second)
viper.SetDefault("gateway.scheduling.fallback_max_waiting", 100)
viper.SetDefault("gateway.scheduling.fallback_selection_mode", "last_used")
viper.SetDefault("gateway.scheduling.prefer_soonest_reset", false)
viper.SetDefault("gateway.scheduling.load_batch_enabled", true)
viper.SetDefault("gateway.scheduling.load_batch_cache_ttl_ms", 200)
viper.SetDefault("gateway.scheduling.snapshot_mget_chunk_size", 128)
@@ -2267,6 +2285,9 @@ func (c *Config) Validate() error {
return fmt.Errorf("billing.circuit_breaker.half_open_requests must be positive")
}
}
if c.Billing.MinimumBalanceReserve < 0 {
return fmt.Errorf("billing.minimum_balance_reserve must be non-negative")
}
if c.Database.MaxOpenConns <= 0 {
return fmt.Errorf("database.max_open_conns must be positive")
}
@@ -2645,14 +2666,16 @@ func (c *Config) Validate() error {
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.TTFT < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative")
}
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.TTFT +
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom
if weightSum <= 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
}
+25
View File
@@ -167,6 +167,9 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if cfg.Gateway.OpenAIWS.PayloadLogSampleRate != 0.2 {
t.Fatalf("Gateway.OpenAIWS.PayloadLogSampleRate = %v, want 0.2", cfg.Gateway.OpenAIWS.PayloadLogSampleRate)
}
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.StoreDisabledForceNewConn {
t.Fatalf("Gateway.OpenAIWS.StoreDisabledForceNewConn = false, want true")
}
@@ -1157,6 +1160,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Billing.CircuitBreaker.HalfOpenRequests = 0 },
wantErr: "billing.circuit_breaker.half_open_requests",
},
{
name: "billing minimum balance reserve",
mutate: func(c *Config) { c.Billing.MinimumBalanceReserve = -0.01 },
wantErr: "billing.minimum_balance_reserve",
},
{
name: "database max open conns",
mutate: func(c *Config) { c.Database.MaxOpenConns = 0 },
@@ -1712,6 +1720,11 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = -0.1 },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
},
{
name: "scheduler_score_weights quota_headroom 不能为负数",
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 不能全为 0",
mutate: func(c *Config) {
@@ -1751,6 +1764,18 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
require.Contains(t, err.Error(), tc.wantErr)
})
}
t.Run("quota_headroom 可作为唯一有效调度权重", 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.QuotaHeadroom = 0.1
require.NoError(t, cfg.Validate())
})
}
func TestValidateConfig_AutoScaleDisabledIgnoreAutoScaleFields(t *testing.T) {
+2
View File
@@ -22,6 +22,7 @@ const (
PlatformOpenAI = "openai"
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
)
// Account type constants
@@ -135,6 +136,7 @@ var DefaultBedrockModelMapping = map[string]string{
"claude-opus-4-1": "us.anthropic.claude-opus-4-1-20250805-v1:0",
"claude-opus-4-20250514": "us.anthropic.claude-opus-4-20250514-v1:0",
// Claude Sonnet
"claude-sonnet-5": "us.anthropic.claude-sonnet-5-v1",
"claude-sonnet-4-6-thinking": "us.anthropic.claude-sonnet-4-6",
"claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
"claude-sonnet-4-5": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
@@ -774,16 +774,20 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
return nil
}
protected := map[string]struct{}{
"access_token": {},
"refresh_token": {},
"id_token": {},
"expires_at": {},
"email": {},
"chatgpt_account_id": {},
"chatgpt_user_id": {},
"organization_id": {},
"plan_type": {},
"client_id": {},
"access_token": {},
"refresh_token": {},
"id_token": {},
"expires_at": {},
"email": {},
"chatgpt_account_id": {},
"chatgpt_user_id": {},
"organization_id": {},
"plan_type": {},
"client_id": {},
"auth_mode": {},
"openai_auth_mode": {},
"token_type": {},
"chatgpt_account_is_fedramp": {},
}
out := make(map[string]any, len(input))
for key, value := range input {
+29 -3
View File
@@ -30,6 +30,9 @@ type DataPayload struct {
ExportedAt string `json:"exported_at"`
Proxies []DataProxy `json:"proxies"`
Accounts []DataAccount `json:"accounts"`
// SkippedShadows 记录导出时被排除的 spark 影子账号数量(见 ExportData)。仅作可见性提示,
// 导入侧忽略该字段;omitempty 保持向后兼容。
SkippedShadows int `json:"skipped_shadows,omitempty"`
}
type DataProxy struct {
@@ -50,6 +53,10 @@ type DataProxy struct {
// DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径,
// Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本,
// 应新增独立结构而非修改这里。
// 注意:本结构不含 parent_account_id/quota_dimension——spark 影子账号在 ExportData 处被显式
// 排除(影子不持凭据、通用凭据型导入强制 credentials 非空无法重建父子链接),不在此表达。
// 影子的独立调度配置(priority/并发/分组/status 管理员可单独调)亦不在本备份范围,属已知局限
// (外审第6轮裁决:保持排除 + 前端警告,而非升级格式做完整往返)。
type DataAccount struct {
Name string `json:"name"`
Notes *string `json:"notes,omitempty"`
@@ -105,6 +112,24 @@ func (h *AccountHandler) ExportData(c *gin.Context) {
return
}
// 排除 spark 影子账号:影子不持凭据,通用凭据型导出无法表达父子链接、导入侧又强制 credentials
// 非空——若混入会产出无法还原的坏备份(导入即失败)。影子的独立调度配置(priority/并发/分组/
// status,管理员可单独调)随之不进备份,还原后需在重建的影子上重新调优;前端按 skipped_shadows
// 提示用户(外审第5轮发现、第6轮裁决:保持排除 + 警告,不做完整往返)。
skippedShadows := 0
exportable := make([]service.Account, 0, len(accounts))
for i := range accounts {
if accounts[i].IsCredentialShadow() {
skippedShadows++
continue
}
exportable = append(exportable, accounts[i])
}
accounts = exportable
if skippedShadows > 0 {
slog.Info("export_skipped_spark_shadows", "count", skippedShadows)
}
includeProxies, err := parseIncludeProxies(c)
if err != nil {
response.BadRequest(c, err.Error())
@@ -191,9 +216,10 @@ func (h *AccountHandler) ExportData(c *gin.Context) {
}
payload := DataPayload{
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Proxies: dataProxies,
Accounts: dataAccounts,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Proxies: dataProxies,
Accounts: dataAccounts,
SkippedShadows: skippedShadows,
}
response.Success(c, payload)
@@ -18,10 +18,11 @@ type dataResponse struct {
}
type dataPayload struct {
Type string `json:"type"`
Version int `json:"version"`
Proxies []dataProxy `json:"proxies"`
Accounts []dataAccount `json:"accounts"`
Type string `json:"type"`
Version int `json:"version"`
Proxies []dataProxy `json:"proxies"`
Accounts []dataAccount `json:"accounts"`
SkippedShadows int `json:"skipped_shadows"`
}
type dataProxy struct {
@@ -172,6 +173,46 @@ func TestExportDataWithoutProxies(t *testing.T) {
require.Nil(t, resp.Data.Accounts[0].ProxyKey)
}
// TestExportDataExcludesSparkShadow 验证外审第5轮 P1/P2:导出时排除 spark 影子账号
// (影子无凭据、导入侧强制 credentials 非空,混入会产出无法还原的坏备份),并透出跳过计数。
func TestExportDataExcludesSparkShadow(t *testing.T) {
router, adminSvc := setupAccountDataRouter()
parentID := int64(21)
adminSvc.accounts = []service.Account{
{
ID: parentID,
Name: "mother",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{"token": "secret"},
Status: service.StatusActive,
},
{
ID: 22,
Name: "mother (Spark)",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{}, // 影子恒空凭据
ParentAccountID: &parentID, // 影子标记
QuotaDimension: service.QuotaDimensionSpark,
Status: service.StatusActive,
},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data?include_proxies=false", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp dataResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Len(t, resp.Data.Accounts, 1, "影子应被排除,仅导出母账号")
require.Equal(t, "mother", resp.Data.Accounts[0].Name)
require.Equal(t, 1, resp.Data.SkippedShadows, "跳过的影子数量应透出")
}
func TestExportDataPassesAccountFiltersAndSort(t *testing.T) {
router, adminSvc := setupAccountDataRouter()
adminSvc.accounts = []service.Account{
@@ -11,6 +11,7 @@ import (
"log"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"sync"
@@ -25,6 +26,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -219,6 +221,8 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac
}
}
h.enrichShadowParents(ctx, []AccountWithConcurrency{item})
return item
}
@@ -380,6 +384,8 @@ func (h *AccountHandler) List(c *gin.Context) {
result[i] = item
}
h.enrichShadowParents(c.Request.Context(), result)
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
if etag != "" {
c.Header("ETag", etag)
@@ -832,6 +838,12 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
if !account.IsOAuth() {
return nil, "", infraerrors.BadRequest("NOT_OAUTH", "cannot refresh non-OAuth account")
}
// spark 影子凭据由母账号管理、自身恒空,刷新无意义且会先打上游;在调用上游前早拒
// (覆盖单账号与批量两入口;批量侧将其计为 failed 并附说明)(外审第6轮)。
if account.IsCredentialShadow() {
return nil, "", infraerrors.BadRequest("SPARK_SHADOW_NO_REFRESH",
"cannot refresh spark shadow account; its credentials are managed by the parent account")
}
var newCredentials map[string]any
@@ -849,6 +861,7 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
newCredentials[k] = v
}
}
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
} else if account.Platform == service.PlatformGemini {
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account)
if err != nil {
@@ -1811,7 +1824,7 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) {
}
if err := h.adminService.ResetAccountQuota(c.Request.Context(), accountID); err != nil {
response.InternalError(c, "Failed to reset account quota: "+err.Error())
response.ErrorFrom(c, err)
return
}
@@ -2063,6 +2076,56 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
return
}
// Handle Grok accounts
if account.Platform == service.PlatformGrok {
defaultModels := xai.DefaultModels()
hasExplicitMapping := false
switch rawMapping := account.Credentials["model_mapping"].(type) {
case map[string]any:
hasExplicitMapping = len(rawMapping) > 0
case map[string]string:
hasExplicitMapping = len(rawMapping) > 0
}
if !hasExplicitMapping {
response.Success(c, defaultModels)
return
}
mapping := account.GetModelMapping()
if len(mapping) == 0 {
response.Success(c, defaultModels)
return
}
defaultByID := make(map[string]xai.Model, len(defaultModels))
for _, model := range defaultModels {
defaultByID[model.ID] = model
}
requestedModels := make([]string, 0, len(mapping))
for requestedModel := range mapping {
requestedModels = append(requestedModels, requestedModel)
}
sort.Strings(requestedModels)
var models []xai.Model
for _, requestedModel := range requestedModels {
if defaultModel, found := defaultByID[requestedModel]; found {
models = append(models, defaultModel)
continue
}
models = append(models, xai.Model{
ID: requestedModel,
Object: "model",
OwnedBy: "xai",
DisplayName: requestedModel,
})
}
response.Success(c, models)
return
}
// Handle Claude/Anthropic accounts
// For OAuth and Setup-Token accounts: return default models
if account.IsOAuth() {
@@ -61,6 +61,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
nil,
nil,
nil,
nil,
upstream,
&config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
nil,
@@ -70,6 +71,77 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
return router
}
func TestAccountHandlerGetAvailableModels_GrokUsesXAIModels(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 44,
Name: "grok-oauth",
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Credentials: map[string]any{
"model_mapping": map[string]any{
"grok-4.3": "grok-4.3",
},
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Data, 1)
require.Equal(t, "grok-4.3", resp.Data[0].ID)
}
func TestAccountHandlerGetAvailableModels_GrokDefaultsToXAIModelsWithoutMapping(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 45,
Name: "grok-oauth-defaults",
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Data)
var ids []string
for _, model := range resp.Data {
id := model.ID
ids = append(ids, id)
require.NotContains(t, strings.ToLower(id), "claude")
}
require.Contains(t, ids, "grok-4.3")
require.Contains(t, ids, "grok-build-0.1")
}
func TestAccountHandlerGetAvailableModels_OpenAIOAuthUsesExplicitModelMapping(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -141,6 +213,48 @@ func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefau
require.NotEqual(t, "gpt-5", resp.Data[0].ID)
}
func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(t *testing.T) {
parentID := int64(100)
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 44,
Name: "openai-spark-shadow",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
ParentAccountID: &parentID,
QuotaDimension: service.QuotaDimensionSpark,
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
},
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
ids := make([]string, 0, len(resp.Data))
for _, m := range resp.Data {
ids = append(ids, m.ID)
}
require.ElementsMatch(t, []string{
"gpt-5.3-codex-spark",
}, ids, "影子可用模型由 model_mapping 派生(非写死)")
}
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -0,0 +1,29 @@
package admin
import (
"context"
"net/http"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// TestRefreshSingleAccount_RejectsShadow 验证外审第6轮:手动刷新对 spark 影子在调用上游前早拒
// (影子凭据由母账号管理、自身恒空,刷新无意义)。该守卫同时覆盖单账号与批量刷新两入口。
func TestRefreshSingleAccount_RejectsShadow(t *testing.T) {
h := &AccountHandler{} // 影子在使用任何依赖前即返回,无需注入
parentID := int64(5)
shadow := &service.Account{
ID: 9,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth, // IsOAuth()=true,确保不是先撞 NOT_OAUTH
ParentAccountID: &parentID,
QuotaDimension: service.QuotaDimensionSpark,
}
_, _, err := h.refreshSingleAccount(context.Background(), shadow)
require.Error(t, err, "影子刷新应被早拒")
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
}
@@ -0,0 +1,56 @@
package admin
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// enrichShadowParentInfo 把母账号的展示信息回填到影子行的 parent_* 字段。
// 纯函数:仅依赖传入的母账号 map,便于单测;非影子或母账号缺失时优雅留空。
func enrichShadowParentInfo(items []AccountWithConcurrency, parents map[int64]*service.Account) {
for i := range items {
a := items[i].Account
if a == nil || a.ParentAccountID == nil {
continue
}
p := parents[*a.ParentAccountID]
if p == nil {
continue
}
a.ParentEmail = p.GetCredential("email")
a.ParentPlanType = p.GetCredential("plan_type")
a.ParentSubscriptionExpiresAt = p.GetCredential("subscription_expires_at")
a.ParentChatGPTAccountID = p.GetCredential("chatgpt_account_id")
a.ParentPrivacyMode = p.GetExtraString("privacy_mode")
}
}
// enrichShadowParents 收集本批影子行的母账号 ID、一次批量解析(避免 N+1),再回填。
// 解析失败时不报错(parent_* 留空,降级)。
func (h *AccountHandler) enrichShadowParents(ctx context.Context, items []AccountWithConcurrency) {
seen := make(map[int64]struct{})
for i := range items {
a := items[i].Account
if a == nil || a.ParentAccountID == nil {
continue
}
seen[*a.ParentAccountID] = struct{}{}
}
if len(seen) == 0 {
return
}
parentIDs := make([]int64, 0, len(seen))
for pid := range seen {
parentIDs = append(parentIDs, pid)
}
parents, err := h.adminService.GetAccountsByIDs(ctx, parentIDs)
if err != nil {
return
}
pmap := make(map[int64]*service.Account, len(parents))
for _, p := range parents {
pmap[p.ID] = p
}
enrichShadowParentInfo(items, pmap)
}
@@ -0,0 +1,42 @@
package admin
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestEnrichShadowParentInfo(t *testing.T) {
pid := int64(100)
parent := &service.Account{
ID: 100,
Credentials: map[string]any{
"email": "owner@example.com",
"plan_type": "pro",
"subscription_expires_at": "2026-12-31T00:00:00Z",
"chatgpt_account_id": "acct_123",
},
Extra: map[string]any{"privacy_mode": "training_off"},
}
parents := map[int64]*service.Account{100: parent}
shadow := AccountWithConcurrency{Account: &dto.Account{ID: 200, ParentAccountID: &pid}}
normal := AccountWithConcurrency{Account: &dto.Account{ID: 1}}
orphan := AccountWithConcurrency{Account: &dto.Account{ID: 201, ParentAccountID: ptrInt64(999)}}
items := []AccountWithConcurrency{shadow, normal, orphan}
enrichShadowParentInfo(items, parents)
require.Equal(t, "owner@example.com", items[0].ParentEmail, "影子回填母账号邮箱")
require.Equal(t, "pro", items[0].ParentPlanType)
require.Equal(t, "training_off", items[0].ParentPrivacyMode)
require.Equal(t, "2026-12-31T00:00:00Z", items[0].ParentSubscriptionExpiresAt)
require.Equal(t, "acct_123", items[0].ParentChatGPTAccountID)
require.Empty(t, items[1].ParentEmail, "非影子不回填")
require.Empty(t, items[2].ParentEmail, "母账号缺失时优雅留空")
}
func ptrInt64(v int64) *int64 { return &v }
@@ -26,6 +26,7 @@ type stubAdminService struct {
testedProxyIDs []int64
getUserErr error
createAccountErr error
createSparkShadowErr error
updateAccountErr error
bulkUpdateAccountErr error
checkMixedErr error
@@ -636,5 +637,25 @@ func (s *stubAdminService) RevertAccountProxyFallback(ctx context.Context, id in
return nil
}
func (s *stubAdminService) CreateShadow(ctx context.Context, parentID int64, opts service.ShadowOptions) (*service.Account, error) {
if s.createSparkShadowErr != nil {
return nil, s.createSparkShadowErr
}
pid := parentID
return &service.Account{
ID: 9001,
Name: opts.Name,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Priority: opts.Priority,
Concurrency: opts.Concurrency,
GroupIDs: opts.GroupIDs,
ParentAccountID: &pid,
QuotaDimension: service.QuotaDimensionSpark,
Credentials: map[string]any{},
Extra: map[string]any{},
}, nil
}
// Ensure stub implements interface.
var _ service.AdminService = (*stubAdminService)(nil)
@@ -509,6 +509,7 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformOpenAI: "openai",
service.PlatformGemini: "google",
service.PlatformAntigravity: "anthropic",
service.PlatformGrok: "xai",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -46,6 +46,7 @@ type channelMonitorCreateRequest struct {
GroupName string `json:"group_name" binding:"max=100"`
Enabled *bool `json:"enabled"`
IntervalSeconds int `json:"interval_seconds" binding:"required,min=15,max=3600"`
JitterSeconds int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"`
TemplateID *int64 `json:"template_id"`
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
@@ -63,6 +64,7 @@ type channelMonitorUpdateRequest struct {
GroupName *string `json:"group_name" binding:"omitempty,max=100"`
Enabled *bool `json:"enabled"`
IntervalSeconds *int `json:"interval_seconds" binding:"omitempty,min=15,max=3600"`
JitterSeconds *int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"`
TemplateID *int64 `json:"template_id"`
ClearTemplate bool `json:"clear_template"` // true 时把 template_id 置空,忽略 TemplateID
ExtraHeaders *map[string]string `json:"extra_headers"`
@@ -83,6 +85,7 @@ type channelMonitorResponse struct {
GroupName string `json:"group_name"`
Enabled bool `json:"enabled"`
IntervalSeconds int `json:"interval_seconds"`
JitterSeconds int `json:"jitter_seconds"`
LastCheckedAt *string `json:"last_checked_at"`
CreatedBy int64 `json:"created_by"`
CreatedAt string `json:"created_at"`
@@ -150,6 +153,7 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
GroupName: m.GroupName,
Enabled: m.Enabled,
IntervalSeconds: m.IntervalSeconds,
JitterSeconds: m.JitterSeconds,
CreatedBy: m.CreatedBy,
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
@@ -315,6 +319,7 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
GroupName: req.GroupName,
Enabled: enabled,
IntervalSeconds: req.IntervalSeconds,
JitterSeconds: req.JitterSeconds,
CreatedBy: subject.UserID,
TemplateID: req.TemplateID,
ExtraHeaders: req.ExtraHeaders,
@@ -351,6 +356,7 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
GroupName: req.GroupName,
Enabled: req.Enabled,
IntervalSeconds: req.IntervalSeconds,
JitterSeconds: req.JitterSeconds,
TemplateID: req.TemplateID,
ClearTemplate: req.ClearTemplate,
ExtraHeaders: req.ExtraHeaders,
@@ -20,36 +20,39 @@ func NewContentModerationHandler(svc *service.ContentModerationService) *Content
}
type contentModerationConfigRequest struct {
Enabled *bool `json:"enabled"`
Mode *string `json:"mode"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
APIKey *string `json:"api_key"`
APIKeys *[]string `json:"api_keys"`
APIKeysMode string `json:"api_keys_mode"`
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
ClearAPIKey bool `json:"clear_api_key"`
TimeoutMS *int `json:"timeout_ms"`
SampleRate *int `json:"sample_rate"`
AllGroups *bool `json:"all_groups"`
GroupIDs *[]int64 `json:"group_ids"`
RecordNonHits *bool `json:"record_non_hits"`
Thresholds *map[string]float64 `json:"thresholds"`
WorkerCount *int `json:"worker_count"`
QueueSize *int `json:"queue_size"`
BlockStatus *int `json:"block_status"`
BlockMessage *string `json:"block_message"`
EmailOnHit *bool `json:"email_on_hit"`
AutoBanEnabled *bool `json:"auto_ban_enabled"`
BanThreshold *int `json:"ban_threshold"`
ViolationWindowHours *int `json:"violation_window_hours"`
RetryCount *int `json:"retry_count"`
HitRetentionDays *int `json:"hit_retention_days"`
NonHitRetentionDays *int `json:"non_hit_retention_days"`
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
BlockedKeywords *[]string `json:"blocked_keywords"`
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
ModelFilter *service.ContentModerationModelFilter `json:"model_filter"`
Enabled *bool `json:"enabled"`
Mode *string `json:"mode"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
APIKey *string `json:"api_key"`
APIKeys *[]string `json:"api_keys"`
APIKeysMode string `json:"api_keys_mode"`
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
ClearAPIKey bool `json:"clear_api_key"`
TimeoutMS *int `json:"timeout_ms"`
SampleRate *int `json:"sample_rate"`
AllGroups *bool `json:"all_groups"`
GroupIDs *[]int64 `json:"group_ids"`
RecordNonHits *bool `json:"record_non_hits"`
Thresholds *map[string]float64 `json:"thresholds"`
WorkerCount *int `json:"worker_count"`
QueueSize *int `json:"queue_size"`
BlockStatus *int `json:"block_status"`
BlockMessage *string `json:"block_message"`
EmailOnHit *bool `json:"email_on_hit"`
AutoBanEnabled *bool `json:"auto_ban_enabled"`
BanThreshold *int `json:"ban_threshold"`
ViolationWindowHours *int `json:"violation_window_hours"`
// cyber_policy 命中是否排除出自动封号计数;前端 RiskControlView 已发送该字段,
// service.UpdateContentModerationConfigInput 已支持,此前 handler 层缺透传导致开关静默失效。
CyberPolicyExcludeFromBanCount *bool `json:"cyber_policy_exclude_from_ban_count"`
RetryCount *int `json:"retry_count"`
HitRetentionDays *int `json:"hit_retention_days"`
NonHitRetentionDays *int `json:"non_hit_retention_days"`
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
BlockedKeywords *[]string `json:"blocked_keywords"`
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
ModelFilter *service.ContentModerationModelFilter `json:"model_filter"`
}
type contentModerationAPIKeyTestRequest struct {
@@ -81,36 +84,37 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
return
}
cfg, err := h.service.UpdateConfig(c.Request.Context(), service.UpdateContentModerationConfigInput{
Enabled: req.Enabled,
Mode: req.Mode,
BaseURL: req.BaseURL,
Model: req.Model,
APIKey: req.APIKey,
APIKeys: req.APIKeys,
APIKeysMode: req.APIKeysMode,
DeleteAPIKeyHashes: req.DeleteAPIKeyHashes,
ClearAPIKey: req.ClearAPIKey,
TimeoutMS: req.TimeoutMS,
SampleRate: req.SampleRate,
AllGroups: req.AllGroups,
GroupIDs: req.GroupIDs,
RecordNonHits: req.RecordNonHits,
Thresholds: req.Thresholds,
WorkerCount: req.WorkerCount,
QueueSize: req.QueueSize,
BlockStatus: req.BlockStatus,
BlockMessage: req.BlockMessage,
EmailOnHit: req.EmailOnHit,
AutoBanEnabled: req.AutoBanEnabled,
BanThreshold: req.BanThreshold,
ViolationWindowHours: req.ViolationWindowHours,
RetryCount: req.RetryCount,
HitRetentionDays: req.HitRetentionDays,
NonHitRetentionDays: req.NonHitRetentionDays,
PreHashCheckEnabled: req.PreHashCheckEnabled,
BlockedKeywords: req.BlockedKeywords,
KeywordBlockingMode: req.KeywordBlockingMode,
ModelFilter: req.ModelFilter,
Enabled: req.Enabled,
Mode: req.Mode,
BaseURL: req.BaseURL,
Model: req.Model,
APIKey: req.APIKey,
APIKeys: req.APIKeys,
APIKeysMode: req.APIKeysMode,
DeleteAPIKeyHashes: req.DeleteAPIKeyHashes,
ClearAPIKey: req.ClearAPIKey,
TimeoutMS: req.TimeoutMS,
SampleRate: req.SampleRate,
AllGroups: req.AllGroups,
GroupIDs: req.GroupIDs,
RecordNonHits: req.RecordNonHits,
Thresholds: req.Thresholds,
WorkerCount: req.WorkerCount,
QueueSize: req.QueueSize,
BlockStatus: req.BlockStatus,
BlockMessage: req.BlockMessage,
EmailOnHit: req.EmailOnHit,
AutoBanEnabled: req.AutoBanEnabled,
BanThreshold: req.BanThreshold,
ViolationWindowHours: req.ViolationWindowHours,
CyberPolicyExcludeFromBanCount: req.CyberPolicyExcludeFromBanCount,
RetryCount: req.RetryCount,
HitRetentionDays: req.HitRetentionDays,
NonHitRetentionDays: req.NonHitRetentionDays,
PreHashCheckEnabled: req.PreHashCheckEnabled,
BlockedKeywords: req.BlockedKeywords,
KeywordBlockingMode: req.KeywordBlockingMode,
ModelFilter: req.ModelFilter,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -0,0 +1,246 @@
package admin
import (
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type GrokOAuthHandler struct {
grokOAuthService *service.GrokOAuthService
adminService service.AdminService
quotaService *service.GrokQuotaService
}
func NewGrokOAuthHandler(
grokOAuthService *service.GrokOAuthService,
adminService service.AdminService,
quotaService *service.GrokQuotaService,
) *GrokOAuthHandler {
return &GrokOAuthHandler{
grokOAuthService: grokOAuthService,
adminService: adminService,
quotaService: quotaService,
}
}
type GrokGenerateAuthURLRequest struct {
ProxyID *int64 `json:"proxy_id"`
RedirectURI string `json:"redirect_uri"`
}
func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) {
var req GrokGenerateAuthURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
req = GrokGenerateAuthURLRequest{}
}
result, err := h.grokOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, req.RedirectURI)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
type GrokExchangeCodeRequest struct {
SessionID string `json:"session_id" binding:"required"`
Code string `json:"code" binding:"required"`
State string `json:"state"`
RedirectURI string `json:"redirect_uri"`
ProxyID *int64 `json:"proxy_id"`
}
func (h *GrokOAuthHandler) ExchangeCode(c *gin.Context) {
var req GrokExchangeCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.grokOAuthService.ExchangeCode(c.Request.Context(), &service.GrokExchangeCodeInput{
SessionID: req.SessionID,
Code: req.Code,
State: req.State,
RedirectURI: req.RedirectURI,
ProxyID: req.ProxyID,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}
type GrokRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token"`
RT string `json:"rt"`
ClientID string `json:"client_id"`
ProxyID *int64 `json:"proxy_id"`
}
func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
var req GrokRefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
refreshToken := strings.TrimSpace(req.RefreshToken)
if refreshToken == "" {
refreshToken = strings.TrimSpace(req.RT)
}
if refreshToken == "" {
response.BadRequest(c, "refresh_token is required")
return
}
var proxyURL string
if req.ProxyID != nil {
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
if err == nil && proxy != nil {
proxyURL = proxy.URL()
}
}
tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}
func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if account.Platform != service.PlatformGrok {
response.BadRequest(c, "Account platform does not match Grok OAuth endpoint")
return
}
if !account.IsOAuth() {
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
return
}
tokenInfo, err := h.grokOAuthService.RefreshAccountToken(c.Request.Context(), account)
if err != nil {
response.ErrorFrom(c, err)
return
}
newCredentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
newCredentials = service.MergeCredentials(account.Credentials, newCredentials)
if baseURL := strings.TrimSpace(account.GetCredential("base_url")); baseURL != "" {
newCredentials["base_url"] = baseURL
}
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
Credentials: newCredentials,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromService(updatedAccount))
}
func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
var req struct {
SessionID string `json:"session_id" binding:"required"`
Code string `json:"code" binding:"required"`
State string `json:"state"`
RedirectURI string `json:"redirect_uri"`
ProxyID *int64 `json:"proxy_id"`
Name string `json:"name"`
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
GroupIDs []int64 `json:"group_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.grokOAuthService.ExchangeCode(c.Request.Context(), &service.GrokExchangeCodeInput{
SessionID: req.SessionID,
Code: req.Code,
State: req.State,
RedirectURI: req.RedirectURI,
ProxyID: req.ProxyID,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
name := strings.TrimSpace(req.Name)
if name == "" && tokenInfo.Email != "" {
name = tokenInfo.Email
}
if name == "" {
name = "Grok OAuth Account"
}
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
Name: name,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Credentials: credentials,
ProxyID: req.ProxyID,
Concurrency: req.Concurrency,
Priority: req.Priority,
GroupIDs: req.GroupIDs,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromService(account))
}
func (h *GrokOAuthHandler) QueryQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "grok quota service is not enabled")
return
}
result, err := h.quotaService.ProbeUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "grok quota service is not enabled")
return
}
result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *GrokOAuthHandler) RuntimeSanity(c *gin.Context) {
response.Success(c, xai.RuntimeSanity())
}
@@ -0,0 +1,147 @@
//go:build unit
package admin
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type grokQuotaHandlerAccountRepo struct {
service.AccountRepository
account *service.Account
updates map[int64]map[string]any
}
func (r *grokQuotaHandlerAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
if r.account != nil && r.account.ID == id {
return r.account, nil
}
return nil, service.ErrAccountNotFound
}
func (r *grokQuotaHandlerAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
if r.updates == nil {
r.updates = make(map[int64]map[string]any)
}
r.updates[id] = updates
return nil
}
type grokQuotaHandlerUpstream struct {
resp *http.Response
lastReq *http.Request
lastBody []byte
}
func (u *grokQuotaHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
u.lastReq = req
if req.Body != nil {
u.lastBody, _ = io.ReadAll(req.Body)
}
return u.resp, nil
}
func (u *grokQuotaHandlerUpstream) DoWithTLS(
req *http.Request,
proxyURL string,
accountID int64,
accountConcurrency int,
_ *tlsfingerprint.Profile,
) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}
func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
ID: 42,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "access-token",
"expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
},
}}
upstream := &grokQuotaHandlerUpstream{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"X-Ratelimit-Limit-Requests": []string{"10"},
"X-Ratelimit-Remaining-Requests": []string{"8"},
},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
}}
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream)
handler := NewGrokOAuthHandler(nil, nil, quotaService)
router := gin.New()
router.GET("/api/v1/admin/grok/accounts/:id/quota", handler.QueryQuota)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/accounts/42/quota", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"source":"active_probe"`)
require.Contains(t, rec.Body.String(), `"headers_observed":true`)
require.NotContains(t, rec.Body.String(), "access-token")
require.Equal(t, xai.DefaultBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Contains(t, string(upstream.lastBody), `"store":false`)
require.NotNil(t, repo.updates[42])
}
func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
ID: 43,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
}}
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil)
handler := NewGrokOAuthHandler(nil, nil, quotaService)
router := gin.New()
router.POST("/api/v1/admin/grok/accounts/:id/reset-quota", handler.ResetQuota)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/accounts/43/reset-quota", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotImplemented, rec.Code)
require.Contains(t, rec.Body.String(), `"reason":"GROK_QUOTA_RESET_UNSUPPORTED"`)
require.NotContains(t, rec.Body.String(), "access-token")
}
func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv(xai.EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
t.Setenv(xai.EnvClientID, "client-secret-like-value")
handler := NewGrokOAuthHandler(nil, nil, nil)
router := gin.New()
router.GET("/api/v1/admin/grok/runtime-sanity", handler.RuntimeSanity)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/runtime-sanity", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"public_gateway_scope":"responses_only"`)
require.Contains(t, rec.Body.String(), `"valid":false`)
require.NotContains(t, rec.Body.String(), "access_token")
require.NotContains(t, rec.Body.String(), "secret")
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
}
@@ -84,7 +84,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -124,7 +124,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -3,6 +3,7 @@ package admin
import (
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
@@ -16,6 +17,7 @@ import (
type OpenAIOAuthHandler struct {
openaiOAuthService *service.OpenAIOAuthService
adminService service.AdminService
quotaService *service.OpenAIQuotaService
}
func oauthPlatformFromPath(c *gin.Context) string {
@@ -23,10 +25,15 @@ func oauthPlatformFromPath(c *gin.Context) string {
}
// NewOpenAIOAuthHandler creates a new OpenAI OAuth handler
func NewOpenAIOAuthHandler(openaiOAuthService *service.OpenAIOAuthService, adminService service.AdminService) *OpenAIOAuthHandler {
func NewOpenAIOAuthHandler(
openaiOAuthService *service.OpenAIOAuthService,
adminService service.AdminService,
quotaService *service.OpenAIQuotaService,
) *OpenAIOAuthHandler {
return &OpenAIOAuthHandler{
openaiOAuthService: openaiOAuthService,
adminService: adminService,
quotaService: quotaService,
}
}
@@ -100,6 +107,24 @@ type OpenAIRefreshTokenRequest struct {
ProxyID *int64 `json:"proxy_id"`
}
type OpenAICodexPATCreateRequest struct {
AccessToken string `json:"access_token" binding:"required"`
Name string `json:"name"`
Notes *string `json:"notes"`
GroupIDs []int64 `json:"group_ids"`
ProxyID *int64 `json:"proxy_id"`
Concurrency *int `json:"concurrency"`
Priority *int `json:"priority"`
RateMultiplier *float64 `json:"rate_multiplier"`
LoadFactor *int `json:"load_factor"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
CredentialExtras map[string]any `json:"credential_extras"`
Extra map[string]any `json:"extra"`
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
}
// RefreshToken refreshes an OpenAI OAuth token
// POST /api/v1/admin/openai/refresh-token
func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
@@ -169,6 +194,13 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
return
}
// spark 影子账号凭据透传母账号、自身恒空,刷新无意义;在调用上游前早拒,避免先打上游
// 再被凭据写守卫拦下的无谓副作用(外审第6轮)。
if account.IsCredentialShadow() {
response.BadRequest(c, "Cannot refresh spark shadow account; its credentials are managed by the parent account")
return
}
// Use OpenAI OAuth service to refresh token
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
if err != nil {
@@ -185,6 +217,7 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
newCredentials[k] = v
}
}
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
Credentials: newCredentials,
@@ -262,3 +295,188 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
response.Success(c, dto.AccountFromService(account))
}
// CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token.
// POST /api/v1/admin/openai/create-from-codex-pat
func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
var req OpenAICodexPATCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if req.Concurrency != nil && *req.Concurrency < 0 {
response.BadRequest(c, "concurrency must be >= 0")
return
}
if req.Priority != nil && *req.Priority < 0 {
response.BadRequest(c, "priority must be >= 0")
return
}
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
response.BadRequest(c, "rate_multiplier must be >= 0")
return
}
if req.LoadFactor != nil && *req.LoadFactor > 10000 {
response.BadRequest(c, "load_factor must be <= 10000")
return
}
var proxyURL string
if req.ProxyID != nil {
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if proxy != nil {
proxyURL = proxy.URL()
}
}
tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL)
if err != nil {
response.ErrorFrom(c, err)
return
}
credentials := mergeCodexImportMap(
h.openaiOAuthService.BuildAccountCredentials(tokenInfo),
sanitizeCodexImportCredentialExtras(req.CredentialExtras),
)
extra := mergeCodexImportMap(req.Extra, map[string]any{
"import_source": "codex_personal_access_token",
"auth_provider": "codex_personal_access_token",
"imported_at": time.Now().UTC().Format(time.RFC3339),
"access_token_sha256": codexTokenFingerprint(req.AccessToken),
})
concurrency := 3
if req.Concurrency != nil {
concurrency = *req.Concurrency
}
priority := 50
if req.Priority != nil {
priority = *req.Priority
}
skipDefaultGroupBind := false
if req.SkipDefaultGroupBind != nil {
skipDefaultGroupBind = *req.SkipDefaultGroupBind
}
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo),
Notes: req.Notes,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: credentials,
Extra: extra,
ProxyID: req.ProxyID,
Concurrency: concurrency,
Priority: priority,
RateMultiplier: req.RateMultiplier,
LoadFactor: req.LoadFactor,
GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
SkipDefaultGroupBind: skipDefaultGroupBind,
SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromService(account))
}
func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string {
name = strings.TrimSpace(name)
if name != "" {
return name
}
if tokenInfo != nil {
for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} {
if candidate = strings.TrimSpace(candidate); candidate != "" {
return candidate
}
}
}
return "Codex PAT Account"
}
// QueryQuota queries the rate-limit / quota usage for an OpenAI account.
// GET /api/v1/admin/openai/accounts/:id/quota
func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "openai quota service is not enabled")
return
}
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, usage)
}
// CreateShadowRequest is the request body for CreateShadow.
type CreateShadowRequest struct {
Name string `json:"name"`
Priority int `json:"priority"`
Concurrency int `json:"concurrency"`
GroupIDs []int64 `json:"group_ids"`
}
// CreateShadow creates a spark-dimension shadow account for a parent OpenAI OAuth account.
// POST /api/v1/admin/accounts/:id/shadow
func (h *OpenAIOAuthHandler) CreateShadow(c *gin.Context) {
parentID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
var req CreateShadowRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
shadow, err := h.adminService.CreateShadow(c.Request.Context(), parentID, service.ShadowOptions{
Name: req.Name,
Priority: req.Priority,
Concurrency: req.Concurrency,
GroupIDs: req.GroupIDs,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromServiceShallow(shadow))
}
// ResetQuota consumes one rate-limit reset credit for an OpenAI account.
// POST /api/v1/admin/openai/accounts/:id/reset-quota
func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "openai quota service is not enabled")
return
}
result, err := h.quotaService.ResetCredit(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
@@ -0,0 +1,106 @@
//go:build unit
package admin
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &stubAdminService{}
h := NewOpenAIOAuthHandler(nil, stub, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
body := `{"name":"p-spark","priority":50,"concurrency":2,"group_ids":[10,20]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp["data"].(map[string]any)
require.True(t, ok, "response should have data field")
// parent_account_id must be present and equal to the path param
pid, ok := data["parent_account_id"].(float64)
require.True(t, ok, "parent_account_id should be present")
require.Equal(t, float64(42), pid)
// quota_dimension must be "spark"
require.Equal(t, service.QuotaDimensionSpark, data["quota_dimension"])
// name round-trips
require.Equal(t, "p-spark", data["name"])
}
func TestCreateShadow_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/shadow",
strings.NewReader(`{"name":"x"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestCreateShadow_ServiceError(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")}
h := NewOpenAIOAuthHandler(nil, stub, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
body := `{"name":"p-spark","priority":50}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
// A generic (non-ApplicationError) service error maps to 500 via response.ErrorFrom.
require.GreaterOrEqual(t, rec.Code, http.StatusBadRequest)
require.Equal(t, http.StatusInternalServerError, rec.Code)
}
func TestCreateShadow_BadBody(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow",
strings.NewReader(`{not valid json`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
@@ -21,6 +21,7 @@ type opsSystemLogCleanupRequest struct {
RequestID string `json:"request_id"`
ClientRequestID string `json:"client_request_id"`
UserID *int64 `json:"user_id"`
APIKeyID *int64 `json:"api_key_id"`
AccountID *int64 `json:"account_id"`
Platform string `json:"platform"`
Model string `json:"model"`
@@ -71,6 +72,14 @@ func (h *OpsHandler) ListSystemLogs(c *gin.Context) {
}
filter.UserID = &id
}
if v := strings.TrimSpace(c.Query("api_key_id")); v != "" {
id, parseErr := strconv.ParseInt(v, 10, 64)
if parseErr != nil || id <= 0 {
response.BadRequest(c, "Invalid api_key_id")
return
}
filter.APIKeyID = &id
}
if v := strings.TrimSpace(c.Query("account_id")); v != "" {
id, parseErr := strconv.ParseInt(v, 10, 64)
if parseErr != nil || id <= 0 {
@@ -136,6 +145,10 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) {
response.BadRequest(c, "Invalid end_time")
return
}
if req.APIKeyID != nil && *req.APIKeyID <= 0 {
response.BadRequest(c, "Invalid api_key_id")
return
}
filter := &service.OpsSystemLogCleanupFilter{
StartTime: start,
@@ -145,6 +158,7 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) {
RequestID: strings.TrimSpace(req.RequestID),
ClientRequestID: strings.TrimSpace(req.ClientRequestID),
UserID: req.UserID,
APIKeyID: req.APIKeyID,
AccountID: req.AccountID,
Platform: strings.TrimSpace(req.Platform),
Model: strings.TrimSpace(req.Model),
@@ -72,6 +72,19 @@ func TestOpsSystemLogHandler_ListInvalidAccountID(t *testing.T) {
}
}
func TestOpsSystemLogHandler_ListInvalidAPIKeyID(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs?api_key_id=abc", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_ListMonitoringDisabled(t *testing.T) {
svc := service.NewOpsService(nil, nil, &config.Config{
Ops: config.OpsConfig{Enabled: false},
@@ -178,6 +191,34 @@ func TestOpsSystemLogHandler_CleanupServiceUnavailable(t *testing.T) {
}
}
func TestOpsSystemLogHandler_CleanupAcceptsAPIKeyID(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"api_key_id":123}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupInvalidAPIKeyID(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"api_key_id":0}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupMonitoringDisabled(t *testing.T) {
svc := service.NewOpsService(nil, nil, &config.Config{
Ops: config.OpsConfig{Enabled: false},
+108 -10
View File
@@ -2,6 +2,7 @@ package admin
import (
"strconv"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -115,24 +116,105 @@ func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
response.Success(c, gin.H{"message": "fulfillment retried"})
}
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*dbent.PaymentOrder {
if len(orders) == 0 {
return orders
}
out := make([]*dbent.PaymentOrder, 0, len(orders))
type AdminPaymentOrderResult struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
UserEmail string `json:"user_email,omitempty"`
UserName string `json:"user_name,omitempty"`
UserNotes *string `json:"user_notes,omitempty"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Currency string `json:"currency"`
RechargeCode string `json:"recharge_code,omitempty"`
OutTradeNo string `json:"out_trade_no"`
PaymentType string `json:"payment_type"`
PaymentTradeNo string `json:"payment_trade_no,omitempty"`
PayURL *string `json:"pay_url,omitempty"`
QRCode *string `json:"qr_code,omitempty"`
QRCodeImg *string `json:"qr_code_img,omitempty"`
OrderType string `json:"order_type"`
PlanID *int64 `json:"plan_id,omitempty"`
SubscriptionGroupID *int64 `json:"subscription_group_id,omitempty"`
SubscriptionDays *int `json:"subscription_days,omitempty"`
ProviderInstanceID *string `json:"provider_instance_id,omitempty"`
ProviderKey *string `json:"provider_key,omitempty"`
Status string `json:"status"`
RefundAmount float64 `json:"refund_amount"`
RefundReason *string `json:"refund_reason,omitempty"`
RefundAt *time.Time `json:"refund_at,omitempty"`
ForceRefund bool `json:"force_refund,omitempty"`
RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
RefundRequestReason *string `json:"refund_request_reason,omitempty"`
RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
FailedAt *time.Time `json:"failed_at,omitempty"`
FailedReason *string `json:"failed_reason,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
SrcHost string `json:"src_host,omitempty"`
SrcURL *string `json:"src_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*AdminPaymentOrderResult {
out := make([]*AdminPaymentOrderResult, 0, len(orders))
for _, order := range orders {
out = append(out, sanitizeAdminPaymentOrderForResponse(order))
if item := sanitizeAdminPaymentOrderForResponse(order); item != nil {
out = append(out, item)
}
}
return out
}
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *dbent.PaymentOrder {
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPaymentOrderResult {
if order == nil {
return nil
}
cloned := *order
cloned.ProviderSnapshot = nil
return &cloned
return &AdminPaymentOrderResult{
ID: order.ID,
UserID: order.UserID,
UserEmail: order.UserEmail,
UserName: order.UserName,
UserNotes: order.UserNotes,
Amount: order.Amount,
PayAmount: order.PayAmount,
FeeRate: order.FeeRate,
Currency: service.PaymentOrderCurrency(order),
RechargeCode: order.RechargeCode,
OutTradeNo: order.OutTradeNo,
PaymentType: order.PaymentType,
PaymentTradeNo: order.PaymentTradeNo,
PayURL: order.PayURL,
QRCode: order.QrCode,
QRCodeImg: order.QrCodeImg,
OrderType: order.OrderType,
PlanID: order.PlanID,
SubscriptionGroupID: order.SubscriptionGroupID,
SubscriptionDays: order.SubscriptionDays,
ProviderInstanceID: order.ProviderInstanceID,
ProviderKey: order.ProviderKey,
Status: order.Status,
RefundAmount: order.RefundAmount,
RefundReason: order.RefundReason,
RefundAt: order.RefundAt,
ForceRefund: order.ForceRefund,
RefundRequestedAt: order.RefundRequestedAt,
RefundRequestReason: order.RefundRequestReason,
RefundRequestedBy: order.RefundRequestedBy,
ExpiresAt: order.ExpiresAt,
PaidAt: order.PaidAt,
CompletedAt: order.CompletedAt,
FailedAt: order.FailedAt,
FailedReason: order.FailedReason,
ClientIP: order.ClientIP,
SrcHost: order.SrcHost,
SrcURL: order.SrcURL,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
}
}
// AdminProcessRefundRequest is the request body for admin refund processing.
@@ -175,6 +257,22 @@ func (h *PaymentHandler) ProcessRefund(c *gin.Context) {
response.Success(c, result)
}
// QueryAndFinalizeRefund queries the provider refund status and finalizes a pending refund.
// POST /api/v1/admin/payment/orders/:id/refund/query
func (h *PaymentHandler) QueryAndFinalizeRefund(c *gin.Context) {
orderID, ok := parseIDParam(c, "id")
if !ok {
return
}
result, err := h.paymentService.QueryAndFinalizeRefund(c.Request.Context(), orderID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// --- Subscription Plans ---
// ListPlans returns all subscription plans.
@@ -0,0 +1,48 @@
package admin
import (
"encoding/json"
"strings"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
)
func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) {
now := time.Now()
order := &dbent.PaymentOrder{
ID: 1,
UserID: 2,
Amount: 100,
PayAmount: 108,
FeeRate: 8,
OutTradeNo: "sub2_202606250001",
PaymentType: "stripe",
OrderType: "subscription",
Status: "COMPLETED",
ExpiresAt: now,
CreatedAt: now,
UpdatedAt: now,
ProviderSnapshot: map[string]any{
"schema_version": 2,
"currency": "USD",
},
}
got := sanitizeAdminPaymentOrderForResponse(order)
if got == nil {
t.Fatal("expected sanitized order")
}
if got.Currency != "USD" {
t.Fatalf("expected currency USD, got %q", got.Currency)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal sanitized order: %v", err)
}
if strings.Contains(string(body), "provider_snapshot") {
t.Fatalf("expected provider_snapshot to be omitted, got %s", string(body))
}
}
@@ -148,7 +148,7 @@ func (h *PromoHandler) Update(c *gin.Context) {
if req.ExpiresAt != nil {
if *req.ExpiresAt == 0 {
// 0 表示清除过期时间
input.ExpiresAt = nil
input.ExpiresAt = &time.Time{}
} else {
t := time.Unix(*req.ExpiresAt, 0)
input.ExpiresAt = &t
+165 -20
View File
@@ -228,6 +228,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
RiskControlEnabled: settings.RiskControlEnabled,
CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled,
CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds,
AffiliateRebateRate: settings.AffiliateRebateRate,
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
@@ -252,11 +254,20 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection,
ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt,
ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
OpenAIAllowClaudeCodeCodexPlugin: settings.OpenAIAllowClaudeCodeCodexPlugin,
MinCodexVersion: settings.MinCodexVersion,
MaxCodexVersion: settings.MaxCodexVersion,
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist,
CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients,
CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals,
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
@@ -580,14 +591,25 @@ type UpdateSettingsRequest struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
EnableCCHSigning *bool `json:"enable_cch_signing"`
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
OpenAIAllowClaudeCodeCodexPlugin *bool `json:"openai_allow_claude_code_codex_plugin"`
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
EnableCCHSigning *bool `json:"enable_cch_signing"`
EnableClaudeOAuthSystemPromptInjection *bool `json:"enable_claude_oauth_system_prompt_injection"`
ClaudeOAuthSystemPrompt *string `json:"claude_oauth_system_prompt"`
ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
// codex_cli_only 加固(global-only)
MinCodexVersion string `json:"min_codex_version"`
MaxCodexVersion string `json:"max_codex_version"`
CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"`
CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"`
CodexCLIOnlyAllowAppServerClients *bool `json:"codex_cli_only_allow_app_server_clients"`
CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"`
// Payment visible method routing
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
@@ -646,6 +668,10 @@ type UpdateSettingsRequest struct {
// 风控中心功能开关
RiskControlEnabled *bool `json:"risk_control_enabled"`
// cyber 会话屏蔽开关 + TTL
CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"`
CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"`
// OpenAI fast/flex policy (optional, only updated when provided)
OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
@@ -1454,6 +1480,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
response.Error(c, http.StatusBadRequest, "min_codex_version must be empty or a valid semver (e.g. 0.141.0)")
return
}
if req.MaxCodexVersion != "" && !semverPattern.MatchString(req.MaxCodexVersion) {
response.Error(c, http.StatusBadRequest, "max_codex_version must be empty or a valid semver (e.g. 0.200.0)")
return
}
if req.MinCodexVersion != "" && req.MaxCodexVersion != "" && service.CompareVersions(req.MaxCodexVersion, req.MinCodexVersion) < 0 {
response.Error(c, http.StatusBadRequest, "max_codex_version must be greater than or equal to min_codex_version")
return
}
// codex_cli_only 黑/白名单:非空须为合法 []AllowedClientEntry JSON。
// 黑名单 OR 宽 deny(允许 originator-only);白名单双因子 AND,额外要求每条可命中(非空 originator + ua_contains)。
if err := service.ValidateCodexClientEntriesJSON(req.CodexCLIOnlyBlacklist); err != nil {
response.Error(c, http.StatusBadRequest, "codex_cli_only_blacklist "+err.Error())
return
}
if err := service.ValidateCodexWhitelistEntriesJSON(req.CodexCLIOnlyWhitelist); err != nil {
response.Error(c, http.StatusBadRequest, "codex_cli_only_whitelist "+err.Error())
return
}
if err := service.ValidateEngineFingerprintSignalsJSON(req.CodexCLIOnlyEngineFingerprintSignals); err != nil {
response.Error(c, http.StatusBadRequest, "codex_cli_only_engine_fingerprint_signals "+err.Error())
return
}
// 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号
if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" {
if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 {
@@ -1462,6 +1516,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// cyber 会话屏蔽 TTL 校验:提供时必须 > 0
if req.CyberSessionBlockTTLSeconds != nil && *req.CyberSessionBlockTTLSeconds <= 0 {
response.BadRequest(c, "cyber_session_block_ttl_seconds must be > 0")
return
}
settings := &service.SystemSettings{
// 系统全局 platform quota 默认值(整体替换语义)
DefaultPlatformQuotas: req.DefaultPlatformQuotas,
@@ -1643,6 +1703,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.EnableCCHSigning
}(),
EnableClaudeOAuthSystemPromptInjection: func() bool {
if req.EnableClaudeOAuthSystemPromptInjection != nil {
return *req.EnableClaudeOAuthSystemPromptInjection
}
return previousSettings.EnableClaudeOAuthSystemPromptInjection
}(),
ClaudeOAuthSystemPrompt: func() string {
if req.ClaudeOAuthSystemPrompt != nil {
return *req.ClaudeOAuthSystemPrompt
}
return previousSettings.ClaudeOAuthSystemPrompt
}(),
ClaudeOAuthSystemPromptBlocks: func() string {
if req.ClaudeOAuthSystemPromptBlocks != nil {
return *req.ClaudeOAuthSystemPromptBlocks
}
return previousSettings.ClaudeOAuthSystemPromptBlocks
}(),
EnableAnthropicCacheTTL1hInjection: func() bool {
if req.EnableAnthropicCacheTTL1hInjection != nil {
return *req.EnableAnthropicCacheTTL1hInjection
@@ -1655,6 +1733,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.RewriteMessageCacheControl
}(),
EnableClientDatelineNormalization: func() bool {
if req.EnableClientDatelineNormalization != nil {
return *req.EnableClientDatelineNormalization
}
return previousSettings.EnableClientDatelineNormalization
}(),
AntigravityUserAgentVersion: func() string {
if req.AntigravityUserAgentVersion != nil {
return *req.AntigravityUserAgentVersion
@@ -1667,12 +1751,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpenAICodexUserAgent
}(),
OpenAIAllowClaudeCodeCodexPlugin: func() bool {
if req.OpenAIAllowClaudeCodeCodexPlugin != nil {
return *req.OpenAIAllowClaudeCodeCodexPlugin
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
CodexCLIOnlyWhitelist: strings.TrimSpace(req.CodexCLIOnlyWhitelist),
CodexCLIOnlyAllowAppServerClients: func() bool {
if req.CodexCLIOnlyAllowAppServerClients != nil {
return *req.CodexCLIOnlyAllowAppServerClients
}
return previousSettings.OpenAIAllowClaudeCodeCodexPlugin
return previousSettings.CodexCLIOnlyAllowAppServerClients
}(),
CodexCLIOnlyEngineFingerprintSignals: strings.TrimSpace(req.CodexCLIOnlyEngineFingerprintSignals),
PaymentVisibleMethodAlipaySource: func() string {
if req.PaymentVisibleMethodAlipaySource != nil {
return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource)
@@ -1769,6 +1858,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.RiskControlEnabled
}(),
CyberSessionBlockEnabled: func() bool {
if req.CyberSessionBlockEnabled != nil {
return *req.CyberSessionBlockEnabled
}
return previousSettings.CyberSessionBlockEnabled
}(),
CyberSessionBlockTTLSeconds: func() int {
if req.CyberSessionBlockTTLSeconds != nil {
return *req.CyberSessionBlockTTLSeconds
}
return previousSettings.CyberSessionBlockTTLSeconds
}(),
}
// req.AuthSourceXxxPlatformQuotas 为 nil 表示本次请求未包含该 source 的 quota 配置(保留 previousAuthSourceDefaults 中的值);
@@ -2045,11 +2146,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
EnableCCHSigning: updatedSettings.EnableCCHSigning,
EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection,
ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt,
ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
OpenAIAllowClaudeCodeCodexPlugin: updatedSettings.OpenAIAllowClaudeCodeCodexPlugin,
MinCodexVersion: updatedSettings.MinCodexVersion,
MaxCodexVersion: updatedSettings.MaxCodexVersion,
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist,
CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients,
CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals,
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
@@ -2090,8 +2200,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AffiliateEnabled: updatedSettings.AffiliateEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled,
CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds,
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
}
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
slog.Error("openai_fast_policy_settings_get_failed", "error", err)
@@ -2475,6 +2587,24 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion {
changed = append(changed, "max_claude_code_version")
}
if before.MinCodexVersion != after.MinCodexVersion {
changed = append(changed, "min_codex_version")
}
if before.MaxCodexVersion != after.MaxCodexVersion {
changed = append(changed, "max_codex_version")
}
if before.CodexCLIOnlyAllowAppServerClients != after.CodexCLIOnlyAllowAppServerClients {
changed = append(changed, "codex_cli_only_allow_app_server_clients")
}
if before.CodexCLIOnlyEngineFingerprintSignals != after.CodexCLIOnlyEngineFingerprintSignals {
changed = append(changed, "codex_cli_only_engine_fingerprint_signals")
}
if before.CodexCLIOnlyBlacklist != after.CodexCLIOnlyBlacklist {
changed = append(changed, "codex_cli_only_blacklist")
}
if before.CodexCLIOnlyWhitelist != after.CodexCLIOnlyWhitelist {
changed = append(changed, "codex_cli_only_whitelist")
}
if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling {
changed = append(changed, "allow_ungrouped_key_scheduling")
}
@@ -2508,21 +2638,30 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.EnableCCHSigning != after.EnableCCHSigning {
changed = append(changed, "enable_cch_signing")
}
if before.EnableClaudeOAuthSystemPromptInjection != after.EnableClaudeOAuthSystemPromptInjection {
changed = append(changed, "enable_claude_oauth_system_prompt_injection")
}
if before.ClaudeOAuthSystemPrompt != after.ClaudeOAuthSystemPrompt {
changed = append(changed, "claude_oauth_system_prompt")
}
if before.ClaudeOAuthSystemPromptBlocks != after.ClaudeOAuthSystemPromptBlocks {
changed = append(changed, "claude_oauth_system_prompt_blocks")
}
if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection {
changed = append(changed, "enable_anthropic_cache_ttl_1h_injection")
}
if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl {
changed = append(changed, "rewrite_message_cache_control")
}
if before.EnableClientDatelineNormalization != after.EnableClientDatelineNormalization {
changed = append(changed, "enable_client_dateline_normalization")
}
if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion {
changed = append(changed, "antigravity_user_agent_version")
}
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
changed = append(changed, "openai_codex_user_agent")
}
if before.OpenAIAllowClaudeCodeCodexPlugin != after.OpenAIAllowClaudeCodeCodexPlugin {
changed = append(changed, "openai_allow_claude_code_codex_plugin")
}
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
changed = append(changed, "payment_visible_method_alipay_source")
}
@@ -2572,6 +2711,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.RiskControlEnabled != after.RiskControlEnabled {
changed = append(changed, "risk_control_enabled")
}
if before.CyberSessionBlockEnabled != after.CyberSessionBlockEnabled {
changed = append(changed, "cyber_session_block_enabled")
}
if before.CyberSessionBlockTTLSeconds != after.CyberSessionBlockTTLSeconds {
changed = append(changed, "cyber_session_block_ttl_seconds")
}
// Default platform quotas(JSON map,整体比较)
if !equalPlatformQuotaSettings(before.DefaultPlatformQuotas, after.DefaultPlatformQuotas) {
changed = append(changed, service.SettingKeyDefaultPlatformQuotas)
@@ -3667,7 +3812,7 @@ func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 {
return nil
}
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all 12 slots.
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots.
func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool {
for _, platform := range service.AllowedQuotaPlatforms {
b := before[platform]
@@ -0,0 +1,58 @@
//go:build unit
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// updateSettingsCodexStatus PUT /settings 仅带给定字段,返回 HTTP 状态码(轻量 stub repo,无 DB)。
func updateSettingsCodexStatus(t *testing.T, body map[string]any) int {
t.Helper()
gin.SetMode(gin.TestMode)
repo := &settingHandlerRepoStub{values: map[string]string{service.SettingKeyPromoCodeEnabled: "true"}}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
raw, 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(raw))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
return rec.Code
}
// 白名单是双因子 AND:originator-only 条目在运行时永不命中(静默失效)。
// handler 应路由到 ValidateCodexWhitelistEntriesJSON,在写入时即拒(400)。
func TestUpdateSettings_CodexWhitelistRejectsUnmatchable(t *testing.T) {
code := updateSettingsCodexStatus(t, map[string]any{
"codex_cli_only_whitelist": `[{"originator":"opencode"}]`,
})
require.Equal(t, http.StatusBadRequest, code, "白名单 originator-only 应被拒(静默失效防护)")
}
func TestUpdateSettings_CodexWhitelistAcceptsMatchable(t *testing.T) {
code := updateSettingsCodexStatus(t, map[string]any{
"codex_cli_only_whitelist": `[{"originator":"opencode","ua_contains":["opencode/"]}]`,
})
require.Equal(t, http.StatusOK, code, "可命中白名单条目应通过")
}
// 黑名单是 OR 宽 deny:允许 originator-only。非对称——不受白名单收紧影响。
func TestUpdateSettings_CodexBlacklistAllowsOriginatorOnly(t *testing.T) {
code := updateSettingsCodexStatus(t, map[string]any{
"codex_cli_only_blacklist": `[{"originator":"evil"}]`,
})
require.Equal(t, http.StatusOK, code, "黑名单 originator-only 应允许(非对称)")
}
@@ -249,8 +249,9 @@ func (h *SubscriptionHandler) ResetQuota(c *gin.Context) {
response.Success(c, dto.UserSubscriptionFromServiceAdmin(sub))
}
// Revoke handles revoking a subscription
// DELETE /api/v1/admin/subscriptions/:id
// Revoke handles revoking a subscription.
// POST /api/v1/admin/subscriptions/:id/revoke
// DELETE /api/v1/admin/subscriptions/:id is kept for backward compatibility.
func (h *SubscriptionHandler) Revoke(c *gin.Context) {
subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -3,6 +3,7 @@ package admin
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"strconv"
@@ -629,8 +630,8 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
return
}
if len(req.Quotas) > 4 {
response.BadRequest(c, "quotas length must be <= 4")
if len(req.Quotas) > len(service.AllowedQuotaPlatforms) {
response.BadRequest(c, fmt.Sprintf("quotas length must be <= %d", len(service.AllowedQuotaPlatforms)))
return
}
seen := make(map[string]struct{}, len(req.Quotas))
@@ -749,7 +750,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
// 失效 cache:对全部允许的 platform 统一 invalidate。
// Trade-off:精确失效(仅 req 涉及平台 + 被软删平台)需 upsert 前额外 ListByUser,
// 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 只有 4 个元素,
// 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 数量很少,
// 全量 invalidate 的额外开销可接受,且能可靠覆盖软删除场景。
if h.billingCache != nil {
for _, p := range service.AllowedQuotaPlatforms {
@@ -98,7 +98,10 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
body := `{"quotas":[
{"platform":"anthropic","daily_limit_usd":10.0,"weekly_limit_usd":null,"monthly_limit_usd":100.0},
{"platform":"openai","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
{"platform":"openai","daily_limit_usd":80.0,"weekly_limit_usd":300.0,"monthly_limit_usd":null},
{"platform":"gemini","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
{"platform":"antigravity","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
{"platform":"grok","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
]}`
c, w := putReq(t, body)
h.UpdateUserPlatformQuotas(c)
@@ -109,12 +112,12 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
if len(repo.upsertCalls) != 1 {
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
}
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) {
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
}
// 缓存失效:请求中 2 个 platform + 软删除的 2 个 platform(gemini, antigravity)= 4 次
if len(cache.deleteCalls) != 4 {
t.Errorf("expected 4 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
// 缓存失效:按全部允许平台统一失效。
if len(cache.deleteCalls) != 5 {
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
}
}
@@ -154,7 +157,7 @@ func TestUpdateUserPlatformQuotas_RejectsNegativeLimit(t *testing.T) {
func TestUpdateUserPlatformQuotas_RejectsTooManyEntries(t *testing.T) {
h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
body := `{"quotas":[
{"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"anthropic"}
{"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"grok"},{"platform":"anthropic"}
]}`
c, w := putReq(t, body)
h.UpdateUserPlatformQuotas(c)
@@ -143,6 +143,7 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
intent := normalizeOAuthIntent(c.Query("intent"))
setDingTalkCookie(c, dingTalkOAuthIntentCookieName, encodeCookieValue(intent), dingTalkOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
@@ -317,6 +318,7 @@ func (h *AuthHandler) DingTalkOAuthCallback(c *gin.Context) {
clearDingTalkCookie(c, dingTalkOAuthStateCookieName, secureCookie)
clearDingTalkCookie(c, dingTalkOAuthRedirectCookie, secureCookie)
clearDingTalkCookie(c, dingTalkOAuthIntentCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, dingTalkOAuthStateCookieName)
@@ -779,7 +781,15 @@ func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "dingtalk")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"dingtalk",
)
if err != nil {
response.ErrorFrom(c, err)
return
+10 -1
View File
@@ -78,6 +78,7 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie)
emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie)
emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie)
captureOAuthPromoCode(c, secureCookie)
if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" {
emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie)
} else {
@@ -119,6 +120,7 @@ func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName)
if err != nil || expectedState == "" || expectedState != state {
@@ -181,7 +183,13 @@ func (h *AuthHandler) emailOAuthCallbackWithProfile(
return
}
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(c.Request.Context(), input, "", affiliateCode)
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
c.Request.Context(),
input,
"",
affiliateCode,
readOAuthPromoCode(c),
)
if err != nil {
if errors.Is(err, service.ErrOAuthInvitationRequired) {
if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil {
@@ -427,6 +435,7 @@ func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider st
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
return
}
h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session))
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
clearCookies()
writeOAuthTokenPairResponse(c, tokenPair)
@@ -184,6 +184,49 @@ func TestEmailOAuthCallbackCreatesPasswordRegistrationSessionForNewEmail(t *test
require.Equal(t, "aff-user@example.com", completion["resolved_email"])
}
func TestEmailOAuthStartPreservesPromoCodeInPendingSession(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
settingValues: map[string]string{
service.SettingKeyGitHubOAuthEnabled: "true",
service.SettingKeyGitHubOAuthClientID: "github-client",
service.SettingKeyGitHubOAuthClientSecret: "github-secret",
service.SettingKeyGitHubOAuthRedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
},
})
ctx := context.Background()
startRecorder := httptest.NewRecorder()
startCtx, _ := gin.CreateTestContext(startRecorder)
startCtx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start?promo_code=WELCOME2024", nil)
handler.GitHubOAuthStart(startCtx)
require.Equal(t, http.StatusFound, startRecorder.Code)
promoCookie := findCookie(startRecorder.Result().Cookies(), oauthPromoCodeCookieName)
require.NotNil(t, promoCookie)
require.Equal(t, "WELCOME2024", decodeCookieValueForTest(t, promoCookie.Value))
callbackRecorder := httptest.NewRecorder()
callbackCtx, _ := gin.CreateTestContext(callbackRecorder)
callbackReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil)
callbackReq.AddCookie(promoCookie)
callbackCtx.Request = callbackReq
handler.emailOAuthCallbackWithProfile(callbackCtx, "github", config.EmailOAuthProviderConfig{
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
Subject: "github-promo-user",
Email: "promo-user@example.com",
EmailVerified: true,
Username: "promo-user",
})
require.Equal(t, http.StatusFound, callbackRecorder.Code)
session, err := client.PendingAuthSession.Query().Only(ctx)
require.NoError(t, err)
require.Equal(t, "WELCOME2024", pendingOAuthPromoCode(session))
}
func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) {
affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002})
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
+33 -2
View File
@@ -110,6 +110,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
intent := normalizeOAuthIntent(c.Query("intent"))
setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -182,6 +183,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie)
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName)
@@ -329,7 +331,15 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "", "", "linuxdo")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
"",
"",
readOAuthPromoCode(c),
"linuxdo",
)
if err == nil {
if err := applyPendingOAuthBinding(
c.Request.Context(),
@@ -373,6 +383,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
upstreamClaims,
compatEmail,
compatEmailUser,
emailVerificationRequired,
forceEmailOnSignup,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
@@ -423,6 +434,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
upstreamClaims map[string]any,
compatEmail string,
compatEmailUser *dbent.User,
emailVerificationRequired bool,
forceEmailOnSignup bool,
) error {
suggestionEmail := strings.TrimSpace(suggestedEmail)
@@ -457,6 +469,17 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
if forceEmailOnSignup && compatEmailUser == nil {
completionResponse["choice_reason"] = "force_email_on_signup"
}
if (emailVerificationRequired || forceEmailOnSignup) && compatEmailUser == nil {
completionResponse["step"] = "create_account_required"
completionResponse["email_binding_required"] = true
completionResponse["force_email_on_signup"] = true
if emailVerificationRequired {
completionResponse["choice_reason"] = "email_verification_required"
}
delete(completionResponse, "email")
delete(completionResponse, "resolved_email")
resolvedChoiceEmail = ""
}
var targetUserID *int64
if compatEmailUser != nil && compatEmailUser.ID > 0 {
@@ -561,7 +584,15 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "linuxdo")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"linuxdo",
)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -631,6 +631,108 @@ func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite
require.Equal(t, "third_party_signup", completion["choice_reason"])
}
func TestLinuxDoOAuthCallbackEmailVerificationCompletesWithBoundEmail(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
case "/userinfo":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"email-verify-123","username":"linuxdo_email","name":"Email Verify","avatar_url":"https://cdn.example/email.png"}`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
handler, client := newLinuxDoOAuthHandlerAndClientWithEmailVerification(t, false, "fresh@example.com", "246810", config.LinuxDoConnectConfig{
Enabled: true,
ClientID: "linuxdo-client",
ClientSecret: "linuxdo-secret",
AuthorizeURL: upstream.URL + "/authorize",
TokenURL: upstream.URL + "/token",
UserInfoURL: upstream.URL + "/userinfo",
Scopes: "read",
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
FrontendRedirectURL: "/auth/linuxdo/callback",
TokenAuthMethod: "client_secret_post",
UsePKCE: true,
})
t.Cleanup(func() { _ = client.Close() })
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-email&state=state-email", nil)
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-email"))
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-email"))
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-email"))
c.Request = req
handler.LinuxDoOAuthCallback(c)
require.Equal(t, http.StatusFound, recorder.Code)
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
require.NotNil(t, sessionCookie)
ctx := context.Background()
session, err := client.PendingAuthSession.Query().
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
Only(ctx)
require.NoError(t, err)
require.Equal(t, oauthIntentLogin, session.Intent)
require.Nil(t, session.TargetUserID)
require.Empty(t, session.ResolvedEmail)
require.Equal(t, "linuxdo-email-verify-123@linuxdo-connect.invalid", session.UpstreamIdentityClaims["email"])
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
require.True(t, ok)
require.Equal(t, "create_account_required", completion["step"])
require.Equal(t, true, completion["email_binding_required"])
require.Equal(t, true, completion["force_email_on_signup"])
require.Equal(t, "email_verification_required", completion["choice_reason"])
require.NotContains(t, completion, "email")
require.NotContains(t, completion, "resolved_email")
createRecorder := httptest.NewRecorder()
createCtx, _ := gin.CreateTestContext(createRecorder)
body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/create-account", body)
createReq.Header.Set("Content-Type", "application/json")
createReq.AddCookie(sessionCookie)
createReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-email")})
createCtx.Request = createReq
handler.CreatePendingOAuthAccount(createCtx)
require.Equal(t, http.StatusOK, createRecorder.Code)
responseData := decodeJSONBody(t, createRecorder)
require.NotEmpty(t, responseData["access_token"])
userEntity, err := client.User.Query().
Where(dbuser.EmailEQ("fresh@example.com")).
Only(ctx)
require.NoError(t, err)
require.Equal(t, "linuxdo", userEntity.SignupSource)
identity, err := client.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ("linuxdo"),
authidentity.ProviderKeyEQ("linuxdo"),
authidentity.ProviderSubjectEQ("email-verify-123"),
).
Only(ctx)
require.NoError(t, err)
require.Equal(t, userEntity.ID, identity.UserID)
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
require.NoError(t, err)
require.NotNil(t, storedSession.ConsumedAt)
}
func TestLinuxDoOAuthCallbackDirectlyLogsInNewUserWhenEmailVerificationDisabled(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -1103,6 +1205,24 @@ func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg c
func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) (*AuthHandler, *dbent.Client) {
t.Helper()
handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
configureLinuxDoOAuthTestHandler(handler, oauthCfg)
return handler, client
}
func newLinuxDoOAuthHandlerAndClientWithEmailVerification(
t *testing.T,
invitationEnabled bool,
email string,
code string,
oauthCfg config.LinuxDoConnectConfig,
) (*AuthHandler, *dbent.Client) {
t.Helper()
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, invitationEnabled, email, code)
configureLinuxDoOAuthTestHandler(handler, oauthCfg)
return handler, client
}
func configureLinuxDoOAuthTestHandler(handler *AuthHandler, oauthCfg config.LinuxDoConnectConfig) {
handler.settingSvc = nil
handler.cfg = &config.Config{
JWT: config.JWTConfig{
@@ -1113,5 +1233,4 @@ func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauth
},
LinuxDo: oauthCfg,
}
return handler, client
}
@@ -32,10 +32,12 @@ const (
oauthPendingBrowserCookieName = "oauth_pending_browser_session"
oauthPendingSessionCookiePath = "/api/v1/auth/oauth"
oauthPendingSessionCookieName = "oauth_pending_session"
oauthPromoCodeCookieName = "oauth_promo_code"
oauthPendingCookieMaxAgeSec = 10 * 60
oauthPendingChoiceStep = "choose_account_action_required"
oauthCompletionResponseKey = "completion_response"
oauthPromoCodeStateKey = "promo_code"
)
var pendingOAuthCreateAccountPreCommitHook func(context.Context, *dbent.PendingAuthSession) error
@@ -161,6 +163,53 @@ func readOAuthPendingSessionCookie(c *gin.Context) (string, error) {
return readCookieDecoded(c, oauthPendingSessionCookieName)
}
func captureOAuthPromoCode(c *gin.Context, secure bool) {
promoCode := strings.TrimSpace(c.Query("promo_code"))
if promoCode == "" {
clearOAuthPromoCodeCookie(c, secure)
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: oauthPromoCodeCookieName,
Value: encodeCookieValue(promoCode),
Path: oauthPendingBrowserCookiePath,
MaxAge: oauthPendingCookieMaxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func clearOAuthPromoCodeCookie(c *gin.Context, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: oauthPromoCodeCookieName,
Value: "",
Path: oauthPendingBrowserCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func readOAuthPromoCode(c *gin.Context) string {
if c == nil {
return ""
}
promoCode, err := readCookieDecoded(c, oauthPromoCodeCookieName)
if err != nil {
return ""
}
return strings.TrimSpace(promoCode)
}
func pendingOAuthPromoCode(session *dbent.PendingAuthSession) string {
if session == nil {
return ""
}
return pendingSessionStringValue(session.LocalFlowState, oauthPromoCodeStateKey)
}
func redirectToFrontendCallback(c *gin.Context, frontendCallback string) {
u, err := url.Parse(frontendCallback)
if err != nil {
@@ -183,6 +232,13 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
return err
}
localFlowState := map[string]any{
oauthCompletionResponseKey: payload.CompletionResponse,
}
if promoCode := readOAuthPromoCode(c); promoCode != "" {
localFlowState[oauthPromoCodeStateKey] = promoCode
}
session, err := svc.CreatePendingSession(c.Request.Context(), service.CreatePendingAuthSessionInput{
Intent: strings.TrimSpace(payload.Intent),
Identity: payload.Identity,
@@ -191,9 +247,7 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
RedirectTo: strings.TrimSpace(payload.RedirectTo),
BrowserSessionKey: strings.TrimSpace(payload.BrowserSessionKey),
UpstreamIdentityClaims: payload.UpstreamIdentityClaims,
LocalFlowState: map[string]any{
oauthCompletionResponseKey: payload.CompletionResponse,
},
LocalFlowState: localFlowState,
})
if err != nil {
slog.Error("pending auth session create failed",
@@ -1820,6 +1874,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
return
}
h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session))
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
// createPendingOAuthAccount = 注册新账户,需要把钉钉昵称同步到 users.username 作为初始值
h.maybeSyncDingTalkAfterRegistration(c.Request.Context(), session, user.ID)
@@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -1049,6 +1050,201 @@ func TestCreateOIDCOAuthAccountCreatesUserBindsIdentityAndConsumesSession(t *tes
require.NotNil(t, storedSession.ConsumedAt)
}
func TestCreateOIDCOAuthAccountAppliesPromoCodeFromPendingSession(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"promo@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("promo-create-account-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-promo-123").
SetBrowserSessionKey("promo-create-account-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "promo_user"}).
SetLocalFlowState(map[string]any{oauthPromoCodeStateKey: "WELCOME2024"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"promo@example.com","verify_code":"246810","password":"secret-123"}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []string{"WELCOME2024"}, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("promo@example.com")).Only(ctx)
require.NoError(t, err)
require.Equal(t, 25.0, createdUser.Balance)
require.Len(t, promoRepo.usages, 1)
require.Equal(t, createdUser.ID, promoRepo.usages[0].UserID)
}
func TestCreateOIDCOAuthAccountWithoutPromoCodeDoesNotApplyPromo(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"no-promo@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("no-promo-create-account-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-no-promo-123").
SetBrowserSessionKey("no-promo-create-account-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "no_promo_user"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"no-promo@example.com","verify_code":"246810","password":"secret-123"}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Empty(t, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("no-promo@example.com")).Only(ctx)
require.NoError(t, err)
require.Zero(t, createdUser.Balance)
}
func TestCreateOIDCOAuthAccountDoesNotApplyPromoWhenDisabled(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"promo-disabled@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "false",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("promo-disabled-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-promo-disabled-123").
SetBrowserSessionKey("promo-disabled-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "promo_disabled_user"}).
SetLocalFlowState(map[string]any{oauthPromoCodeStateKey: "WELCOME2024"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"promo-disabled@example.com","verify_code":"246810","password":"secret-123"}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Empty(t, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("promo-disabled@example.com")).Only(ctx)
require.NoError(t, err)
require.Zero(t, createdUser.Balance)
}
func TestOAuthExistingUserLoginDoesNotApplyPromoCode(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
existingUser, err := client.User.Create().
SetEmail("existing-promo@example.com").
SetUsername("existing").
SetPasswordHash("hash").
SetRole(service.RoleUser).
SetStatus(service.StatusActive).
SetBalance(7).
Save(ctx)
require.NoError(t, err)
_, loggedInUser, err := handler.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
ctx,
existingUser.Email,
existingUser.Username,
"",
"",
"WELCOME2024",
"oidc",
)
require.NoError(t, err)
require.Equal(t, existingUser.ID, loggedInUser.ID)
require.Empty(t, promoRepo.applyCalls)
reloadedUser, err := client.User.Get(ctx, existingUser.ID)
require.NoError(t, err)
require.Equal(t, 7.0, reloadedUser.Balance)
}
func TestCreateOIDCOAuthAccountExistingEmailReturnsChoicePendingSessionState(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
ctx := context.Background()
@@ -1178,6 +1374,58 @@ func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *te
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
}
func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"foo@gmail.com": {
Code: "135790",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
},
settingValues: map[string]string{
service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("suffix-whitelist-session-token").
SetIntent("login").
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-suffix-whitelist-123").
SetBrowserSessionKey("suffix-whitelist-browser-session-key").
SetUpstreamIdentityClaims(map[string]any{
"username": "oidc_user",
}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"foo@gmail.com","verify_code":"135790","password":"secret-123"}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("suffix-whitelist-browser-session-key")})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
payload := decodeJSONBody(t, recorder)
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", payload["reason"])
count, err := client.User.Query().Where(dbuser.EmailEQ("foo@gmail.com")).Count(ctx)
require.NoError(t, err)
require.Zero(t, count)
}
func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
ctx := context.Background()
@@ -2120,6 +2368,7 @@ type oauthPendingFlowTestHandlerOptions struct {
emailVerifyEnabled bool
emailCache service.EmailCache
settingValues map[string]string
promoRepo service.PromoCodeRepository
defaultSubAssigner service.DefaultSubscriptionAssigner
affiliateService *service.AffiliateService
affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService
@@ -2212,6 +2461,10 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
options: options.userRepoOptions,
}
redeemRepo := &oauthPendingFlowRedeemCodeRepo{client: client}
var promoService *service.PromoService
if options.promoRepo != nil {
promoService = service.NewPromoService(options.promoRepo, userRepo, nil, client, nil)
}
var emailService *service.EmailService
if options.emailCache != nil {
emailService = service.NewEmailService(&oauthPendingFlowSettingRepoStub{
@@ -2230,7 +2483,7 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
emailService,
nil,
nil,
nil,
promoService,
options.defaultSubAssigner,
affiliateService,
nil,
@@ -2250,10 +2503,11 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
}
return &AuthHandler{
authService: authSvc,
userService: userSvc,
settingSvc: settingSvc,
totpService: totpSvc,
authService: authSvc,
userService: userSvc,
settingSvc: settingSvc,
promoService: promoService,
totpService: totpSvc,
}, client
}
@@ -2272,6 +2526,84 @@ type oauthPendingFlowSettingRepoStub struct {
values map[string]string
}
type oauthPendingFlowPromoRepoStub struct {
promo *service.PromoCode
applyCalls []string
usages []service.PromoCodeUsage
}
func newOAuthPendingFlowPromoRepoStub(code string, bonusAmount float64) *oauthPendingFlowPromoRepoStub {
return &oauthPendingFlowPromoRepoStub{
promo: &service.PromoCode{
ID: 1,
Code: code,
BonusAmount: bonusAmount,
Status: service.PromoCodeStatusActive,
},
}
}
func (r *oauthPendingFlowPromoRepoStub) Create(context.Context, *service.PromoCode) error {
panic("unexpected Create call")
}
func (r *oauthPendingFlowPromoRepoStub) GetByID(context.Context, int64) (*service.PromoCode, error) {
panic("unexpected GetByID call")
}
func (r *oauthPendingFlowPromoRepoStub) GetByCode(_ context.Context, code string) (*service.PromoCode, error) {
if r.promo == nil || !strings.EqualFold(strings.TrimSpace(code), r.promo.Code) {
return nil, service.ErrPromoCodeNotFound
}
clone := *r.promo
return &clone, nil
}
func (r *oauthPendingFlowPromoRepoStub) GetByCodeForUpdate(ctx context.Context, code string) (*service.PromoCode, error) {
promoCode, err := r.GetByCode(ctx, code)
if err != nil {
return nil, err
}
r.applyCalls = append(r.applyCalls, strings.TrimSpace(code))
return promoCode, nil
}
func (r *oauthPendingFlowPromoRepoStub) Update(context.Context, *service.PromoCode) error {
panic("unexpected Update call")
}
func (r *oauthPendingFlowPromoRepoStub) Delete(context.Context, int64) error {
panic("unexpected Delete call")
}
func (r *oauthPendingFlowPromoRepoStub) List(context.Context, pagination.PaginationParams) ([]service.PromoCode, *pagination.PaginationResult, error) {
panic("unexpected List call")
}
func (r *oauthPendingFlowPromoRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string) ([]service.PromoCode, *pagination.PaginationResult, error) {
panic("unexpected ListWithFilters call")
}
func (r *oauthPendingFlowPromoRepoStub) CreateUsage(_ context.Context, usage *service.PromoCodeUsage) error {
r.usages = append(r.usages, *usage)
return nil
}
func (r *oauthPendingFlowPromoRepoStub) GetUsageByPromoCodeAndUser(context.Context, int64, int64) (*service.PromoCodeUsage, error) {
return nil, nil
}
func (r *oauthPendingFlowPromoRepoStub) ListUsagesByPromoCode(context.Context, int64, pagination.PaginationParams) ([]service.PromoCodeUsage, *pagination.PaginationResult, error) {
panic("unexpected ListUsagesByPromoCode call")
}
func (r *oauthPendingFlowPromoRepoStub) IncrementUsedCount(context.Context, int64) error {
if r.promo != nil {
r.promo.UsedCount++
}
return nil
}
func (s *oauthPendingFlowSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
return nil, service.ErrSettingNotFound
}
@@ -2813,8 +3145,12 @@ func (r *oauthPendingFlowUserRepo) ListWithFilters(context.Context, pagination.P
panic("unexpected ListWithFilters call")
}
func (r *oauthPendingFlowUserRepo) UpdateBalance(context.Context, int64, float64) error {
panic("unexpected UpdateBalance call")
func (r *oauthPendingFlowUserRepo) UpdateBalance(ctx context.Context, userID int64, amount float64) error {
client := r.client
if tx := dbent.TxFromContext(ctx); tx != nil {
client = tx.Client()
}
return client.User.UpdateOneID(userID).AddBalance(amount).Exec(ctx)
}
func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64) error {
+18 -2
View File
@@ -143,6 +143,7 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie)
intent := normalizeOAuthIntent(c.Query("intent"))
oidcSetCookie(c, oidcOAuthIntentCookieName, encodeCookieValue(intent), oidcOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -226,6 +227,7 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie)
oidcClearCookie(c, oidcOAuthIntentCookieName, secureCookie)
oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName)
@@ -685,7 +687,15 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "oidc")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"oidc",
)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -1258,7 +1268,13 @@ func (h *AuthHandler) tryOIDCVerifiedEmailFastPath(
AvatarURL: pendingSessionStringValue(upstreamClaims, "suggested_avatar_url"),
UpstreamMetadata: upstreamMetadata,
}
tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(ctx, input, "", "")
tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
ctx,
input,
"",
"",
readOAuthPromoCode(c),
)
if err != nil {
log.Printf("[OIDC OAuth] verified-email fast path skipped: reason=%s", infraerrors.Reason(err))
return false
+11 -1
View File
@@ -125,6 +125,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
wechatSetCookie(c, wechatOAuthRedirectCookieName, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthIntentCookieName, encodeCookieValue(intent), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -171,6 +172,7 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName)
@@ -548,7 +550,15 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "wechat")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"wechat",
)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -10,6 +10,12 @@ import (
const statusClientClosedRequest = 499
func concurrencyErrorResponse(err error, slotType string) (int, string, string) {
var waitQueueFullErr *WaitQueueFullError
if errors.As(err, &waitQueueFullErr) {
return http.StatusTooManyRequests, "rate_limit_error",
"Too many pending requests, please retry later"
}
var concurrencyErr *ConcurrencyError
if errors.As(err, &concurrencyErr) {
if concurrencyErr.SlotType != "" {
+9 -4
View File
@@ -234,6 +234,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
SessionWindowEnd: a.SessionWindowEnd,
SessionWindowStatus: a.SessionWindowStatus,
GroupIDs: a.GroupIDs,
ParentAccountID: a.ParentAccountID,
QuotaDimension: a.QuotaDimension,
}
// 提取 5h 窗口费用控制和会话数量控制配置(仅 Anthropic OAuth/SetupToken 账号有效)
@@ -573,7 +575,7 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
}
func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、account、upstream_model)。
requestType := l.EffectiveRequestType()
stream, openAIWSMode := service.ApplyLegacyRequestFields(requestType, l.Stream, l.OpenAIWSMode)
requestedModel := l.RequestedModel
@@ -590,7 +592,6 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ServiceTier: l.ServiceTier,
ReasoningEffort: l.ReasoningEffort,
InboundEndpoint: l.InboundEndpoint,
UpstreamEndpoint: l.UpstreamEndpoint,
GroupID: l.GroupID,
SubscriptionID: l.SubscriptionID,
InputTokens: l.InputTokens,
@@ -622,6 +623,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ImageSizeBreakdown: l.ImageSizeBreakdown,
MediaType: l.MediaType,
UserAgent: l.UserAgent,
IPAddress: l.IPAddress,
CacheTTLOverridden: l.CacheTTLOverridden,
BillingMode: l.BillingMode,
CreatedAt: l.CreatedAt,
@@ -633,7 +635,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
}
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// It excludes Account details and IP address - users should not see these.
// It excludes admin-only account/upstream internals while keeping user billing and request metadata.
func UsageLogFromService(l *service.UsageLog) *UsageLog {
if l == nil {
return nil
@@ -648,8 +650,10 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
if l == nil {
return nil
}
usageLog := usageLogFromServiceUser(l)
usageLog.UpstreamEndpoint = l.UpstreamEndpoint
return &AdminUsageLog{
UsageLog: usageLogFromServiceUser(l),
UsageLog: usageLog,
UpstreamModel: l.UpstreamModel,
ChannelID: l.ChannelID,
ModelMappingChain: l.ModelMappingChain,
@@ -751,6 +755,7 @@ func userSubscriptionFromServiceBase(sub *service.UserSubscription) UserSubscrip
MonthlyUsageUSD: sub.MonthlyUsageUSD,
CreatedAt: sub.CreatedAt,
UpdatedAt: sub.UpdatedAt,
RevokedAt: sub.DeletedAt,
User: UserFromServiceShallow(sub.User),
Group: GroupFromServiceShallow(sub.Group),
}
@@ -95,8 +95,7 @@ func TestUsageLogFromService_IncludesServiceTierForUserAndAdmin(t *testing.T) {
require.Equal(t, serviceTier, *userDTO.ServiceTier)
require.NotNil(t, userDTO.InboundEndpoint)
require.Equal(t, inboundEndpoint, *userDTO.InboundEndpoint)
require.NotNil(t, userDTO.UpstreamEndpoint)
require.Equal(t, upstreamEndpoint, *userDTO.UpstreamEndpoint)
require.Nil(t, userDTO.UpstreamEndpoint)
require.NotNil(t, adminDTO.ServiceTier)
require.Equal(t, serviceTier, *adminDTO.ServiceTier)
require.NotNil(t, adminDTO.InboundEndpoint)
@@ -133,6 +132,45 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
}
func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
t.Parallel()
ipAddress := "203.0.113.10"
accountRateMultiplier := 1.5
accountStatsCost := 0.21
log := &service.UsageLog{
RequestID: "req_user_visible_billing",
Model: "gpt-5.4",
InputCost: 0.01,
OutputCost: 0.02,
CacheCreationCost: 0.03,
CacheReadCost: 0.04,
TotalCost: 0.10,
ActualCost: 0.08,
RateMultiplier: 0.8,
IPAddress: &ipAddress,
AccountRateMultiplier: &accountRateMultiplier,
AccountStatsCost: &accountStatsCost,
}
userDTO := UsageLogFromService(log)
require.Equal(t, 0.01, userDTO.InputCost)
require.Equal(t, 0.02, userDTO.OutputCost)
require.Equal(t, 0.03, userDTO.CacheCreationCost)
require.Equal(t, 0.04, userDTO.CacheReadCost)
require.Equal(t, 0.10, userDTO.TotalCost)
require.Equal(t, 0.08, userDTO.ActualCost)
require.Equal(t, 0.8, userDTO.RateMultiplier)
require.NotNil(t, userDTO.IPAddress)
require.Equal(t, ipAddress, *userDTO.IPAddress)
userJSON, err := json.Marshal(userDTO)
require.NoError(t, err)
require.NotContains(t, string(userJSON), "account_rate_multiplier")
require.NotContains(t, string(userJSON), "account_stats_cost")
require.NotContains(t, string(userJSON), "account_cost")
}
func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) {
t.Parallel()
+23 -8
View File
@@ -178,14 +178,25 @@ type SystemSettings struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
EnableCCHSigning bool `json:"enable_cch_signing"`
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"`
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
EnableCCHSigning bool `json:"enable_cch_signing"`
EnableClaudeOAuthSystemPromptInjection bool `json:"enable_claude_oauth_system_prompt_injection"`
ClaudeOAuthSystemPrompt string `json:"claude_oauth_system_prompt"`
ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
// codex_cli_only 加固
MinCodexVersion string `json:"min_codex_version"`
MaxCodexVersion string `json:"max_codex_version"`
CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"`
CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"`
CodexCLIOnlyAllowAppServerClients bool `json:"codex_cli_only_allow_app_server_clients"`
CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"`
// Web Search Emulation
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
@@ -244,6 +255,10 @@ type SystemSettings struct {
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
// cyber 会话屏蔽开关 + TTL
CyberSessionBlockEnabled bool `json:"cyber_session_block_enabled"`
CyberSessionBlockTTLSeconds int `json:"cyber_session_block_ttl_seconds"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled bool `json:"affiliate_enabled"`
+17 -3
View File
@@ -253,6 +253,17 @@ type Account struct {
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
// 影子账号关系(spark 维度影子)
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
QuotaDimension string `json:"quota_dimension,omitempty"`
// 影子账号回填的母账号信息(仅影子非空,源自母账号 Credentials/Extra)
ParentEmail string `json:"parent_email,omitempty"`
ParentPlanType string `json:"parent_plan_type,omitempty"`
ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"`
ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"`
ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"`
Proxy *Proxy `json:"proxy,omitempty"`
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
@@ -480,6 +491,8 @@ type UsageLog struct {
// User-Agent
UserAgent *string `json:"user_agent"`
// IPAddress is visible to the owner of the usage record.
IPAddress *string `json:"ip_address,omitempty"`
// Cache TTL Override 标记
CacheTTLOverridden bool `json:"cache_ttl_overridden"`
@@ -515,7 +528,7 @@ type AdminUsageLog struct {
// AccountStatsCost 自定义定价规则计算的账号统计费用(nil 表示使用默认公式)
AccountStatsCost *float64 `json:"account_stats_cost,omitempty"`
// IPAddress 用户请求 IP(仅管理员可见)
// IPAddress 用户请求 IP
IPAddress *string `json:"ip_address,omitempty"`
// Account 最小账号信息(避免泄露敏感字段)
@@ -581,8 +594,9 @@ type UserSubscription struct {
WeeklyUsageUSD float64 `json:"weekly_usage_usd"`
MonthlyUsageUSD float64 `json:"monthly_usage_usd"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
User *User `json:"user,omitempty"`
Group *Group `json:"group,omitempty"`
+8 -2
View File
@@ -21,6 +21,8 @@ const (
EndpointResponses = "/v1/responses"
EndpointImagesGenerations = "/v1/images/generations"
EndpointImagesEdits = "/v1/images/edits"
EndpointVideosGenerations = "/v1/videos/generations"
EndpointVideos = "/v1/videos"
EndpointGeminiModels = "/v1beta/models"
)
@@ -53,6 +55,10 @@ func NormalizeInboundEndpoint(path string) string {
return EndpointImagesGenerations
case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
return EndpointImagesEdits
case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"):
return EndpointVideosGenerations
case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"):
return EndpointVideos
case strings.Contains(path, EndpointResponses):
return EndpointResponses
case strings.Contains(path, EndpointGeminiModels):
@@ -77,8 +83,8 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
inbound = strings.TrimSpace(inbound)
switch platform {
case service.PlatformOpenAI:
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits {
case service.PlatformOpenAI, service.PlatformGrok:
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideos {
return inbound
}
// OpenAI forwards everything to the Responses API.
@@ -28,6 +28,8 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
{"/v1/responses", EndpointResponses},
{"/v1/images/generations", EndpointImagesGenerations},
{"/v1/images/edits", EndpointImagesEdits},
{"/v1/videos/generations", EndpointVideosGenerations},
{"/v1/videos/req_123", EndpointVideos},
{"/v1beta/models", EndpointGeminiModels},
// Prefixed paths (antigravity, openai).
@@ -81,6 +83,8 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
{"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings},
{"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations},
{"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits},
{"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations},
{"grok video status", EndpointVideos, "/videos/req_123", service.PlatformGrok, EndpointVideos},
// Antigravity — uses inbound to pick Claude vs Gemini upstream.
{"antigravity claude", EndpointMessages, "/antigravity/v1/messages", service.PlatformAntigravity, EndpointMessages},
+56 -44
View File
@@ -25,6 +25,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -169,7 +170,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 设置 max_tokens=1 + haiku 探测请求标识到 context 中
// 必须在 SetClaudeCodeClientContext 之前设置,因为 ClaudeCodeValidator 需要读取此标识进行绕过判断
if isMaxTokensOneHaikuRequest(reqModel, parsedReq.MaxTokens, reqStream) {
if isMaxTokensOneHaikuRequest(reqModel, parsedReq.MaxTokens) {
ctx := service.WithIsMaxTokensOneHaikuRequest(c.Request.Context(), true, h.metadataBridgeEnabled())
c.Request = c.Request.WithContext(ctx)
}
@@ -211,28 +212,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 获取订阅信息(可能为nil)- 提前获取用于后续检查
subscription, _ := middleware2.GetSubscriptionFromContext(c)
// 0. 检查wait队列是否已满
maxWait := service.CalculateMaxWait(subject.Concurrency)
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
waitCounted := false
if err != nil {
reqLog.Warn("gateway.user_wait_counter_increment_failed", zap.Error(err))
// On error, allow request to proceed
} else if !canWait {
reqLog.Info("gateway.user_wait_queue_full", zap.Int("max_wait", maxWait))
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
return
}
if err == nil && canWait {
waitCounted = true
}
// Ensure we decrement if we exit before acquiring the user slot.
defer func() {
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
}
}()
// 1. 首先获取用户并发槽位
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted)
if err != nil {
@@ -240,11 +219,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.handleConcurrencyError(c, err, "user", streamStarted)
return
}
// User slot acquired: no longer waiting in the queue.
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
waitCounted = false
}
// 在请求结束或 Context 取消时确保释放槽位,避免客户端断开造成泄漏
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
@@ -328,14 +302,22 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
reqLog.Warn("gateway.select_account_no_available",
zap.String("model", reqModel),
zap.Int64p("group_id", apiKey.GroupID),
zap.String("platform", platform),
zap.Bool("model_not_found", cls.ModelNotFound),
zap.Error(err),
)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
message := cls.Message
if !cls.ModelNotFound {
message = "No available accounts: " + err.Error()
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -360,7 +342,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if account.IsInterceptWarmupEnabled() {
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, reqStream, isClaudeCodeClient)
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, isClaudeCodeClient)
if interceptType != InterceptTypeNone {
if selection.Acquired && selection.ReleaseFunc != nil {
selection.ReleaseFunc()
@@ -523,6 +505,16 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort)
}
// 国产模型 thinking-enabled 默认 effort 填充:Kimi/GLM/MiniMax 这些不支持 effort 档位的
// passback-required 上游,仅要 thinking 启用且 OutputEffort 未明确传递时,在 usage_log 写 "high"
// 避免该字段长期为 NULL(详见 DefaultEffortForThinkingEnabled 文档)。
if result.ReasoningEffort == nil && parsedReq.ThinkingEnabled {
protocolModel := result.UpstreamModel
if protocolModel == "" {
protocolModel = result.Model
}
result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel)
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
@@ -595,15 +587,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
reqLog.Warn("gateway.select_account_no_available",
zap.String("model", reqModel),
zap.Int64p("group_id", currentAPIKey.GroupID),
zap.String("platform", platform),
zap.Bool("fallback_used", fallbackUsed),
zap.Bool("model_not_found", cls.ModelNotFound),
zap.Error(err),
)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
message := cls.Message
if !cls.ModelNotFound {
message = "No available accounts: " + err.Error()
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -638,7 +638,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if account.IsInterceptWarmupEnabled() {
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, reqStream, isClaudeCodeClient)
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, isClaudeCodeClient)
if interceptType != InterceptTypeNone {
if selection.Acquired && selection.ReleaseFunc != nil {
selection.ReleaseFunc()
@@ -937,6 +937,14 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)
}
// 同上(重试路径中的对称填充)。详见非重试路径同名注释。
if result.ReasoningEffort == nil && attemptParsedReq.ThinkingEnabled {
protocolModel := result.UpstreamModel
if protocolModel == "" {
protocolModel = result.Model
}
result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel)
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
@@ -1150,6 +1158,8 @@ func defaultModelIDsForPlatform(platform string) []string {
ids = append(ids, model.ID)
}
return ids
case service.PlatformGrok:
return xai.DefaultModelIDs()
default:
ids := make([]string, 0, len(claude.DefaultModels))
for _, model := range claude.DefaultModels {
@@ -1794,8 +1804,11 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, parsedReq.Model)
if err != nil {
reqLog.Warn("gateway.count_tokens_select_account_failed", zap.Error(err))
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable")
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, parsedReq.Model, parsedReq.Model, service.PlatformAnthropic)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
setOpsSelectedAccount(c, account.ID, account.Platform)
@@ -1824,10 +1837,10 @@ func isHaikuModel(model string) bool {
}
// isMaxTokensOneHaikuRequest 检查是否为 max_tokens=1 + haiku 模型的探测请求
// 这类请求用于 Claude Code 验证 API 连通性
// 条件:max_tokens == 1 且 model 包含 "haiku" 且非流式请求
func isMaxTokensOneHaikuRequest(model string, maxTokens int, isStream bool) bool {
return maxTokens == 1 && isHaikuModel(model) && !isStream
// 这类请求用于 Claude Code 验证 API 连通性(流式/非流式均会出现,如 cc-switch v3.9.0 起的健康检查探测为流式)
// 条件:max_tokens == 1 且 model 包含 "haiku"
func isMaxTokensOneHaikuRequest(model string, maxTokens int) bool {
return maxTokens == 1 && isHaikuModel(model)
}
// detectInterceptType 检测请求是否需要拦截,返回拦截类型
@@ -1835,11 +1848,10 @@ func isMaxTokensOneHaikuRequest(model string, maxTokens int, isStream bool) bool
// - body: 请求体字节
// - model: 请求的模型名称
// - maxTokens: max_tokens 值
// - isStream: 是否为流式请求
// - isClaudeCodeClient: 是否已通过 Claude Code 客户端校验
func detectInterceptType(body []byte, model string, maxTokens int, isStream bool, isClaudeCodeClient bool) InterceptType {
// 优先检查 max_tokens=1 + haiku 探测请求(仅非流式)
if isClaudeCodeClient && isMaxTokensOneHaikuRequest(model, maxTokens, isStream) {
func detectInterceptType(body []byte, model string, maxTokens int, isClaudeCodeClient bool) InterceptType {
// 优先检查 max_tokens=1 + haiku 探测请求(流式/非流式均适用)
if isClaudeCodeClient && isMaxTokensOneHaikuRequest(model, maxTokens) {
return InterceptTypeMaxTokensOneHaiku
}
@@ -109,35 +109,12 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
// 1. Acquire user concurrency slot
maxWait := service.CalculateMaxWait(subject.Concurrency)
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
waitCounted := false
if err != nil {
reqLog.Warn("gateway.cc.user_wait_counter_increment_failed", zap.Error(err))
} else if !canWait {
h.chatCompletionsErrorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
return
}
if err == nil && canWait {
waitCounted = true
}
defer func() {
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
}
}()
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted)
if err != nil {
reqLog.Warn("gateway.cc.user_slot_acquire_failed", zap.Error(err))
h.handleConcurrencyError(c, err, "user", streamStarted)
return
}
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
waitCounted = false
}
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
defer userReleaseFunc()
@@ -185,8 +162,15 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
message := cls.Message
if !cls.ModelNotFound {
message = "No available accounts: " + err.Error()
}
h.chatCompletionsErrorResponse(c, cls.Status, cls.ErrType, message)
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -14,10 +14,10 @@ import (
func TestDetectInterceptType_MaxTokensOneHaikuRequiresClaudeCodeClient(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
notClaudeCode := detectInterceptType(body, "claude-haiku-4-5", 1, false, false)
notClaudeCode := detectInterceptType(body, "claude-haiku-4-5", 1, false)
require.Equal(t, InterceptTypeNone, notClaudeCode)
isClaudeCode := detectInterceptType(body, "claude-haiku-4-5", 1, false, true)
isClaudeCode := detectInterceptType(body, "claude-haiku-4-5", 1, true)
require.Equal(t, InterceptTypeMaxTokensOneHaiku, isClaudeCode)
}
@@ -30,7 +30,7 @@ func TestDetectInterceptType_SuggestionModeUnaffected(t *testing.T) {
"system":[]
}`)
got := detectInterceptType(body, "claude-sonnet-4-5", 256, false, false)
got := detectInterceptType(body, "claude-sonnet-4-5", 256, false)
require.Equal(t, InterceptTypeSuggestionMode, got)
}
@@ -118,35 +118,12 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
// 1. Acquire user concurrency slot
maxWait := service.CalculateMaxWait(subject.Concurrency)
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
waitCounted := false
if err != nil {
reqLog.Warn("gateway.responses.user_wait_counter_increment_failed", zap.Error(err))
} else if !canWait {
h.responsesErrorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
return
}
if err == nil && canWait {
waitCounted = true
}
defer func() {
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
}
}()
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted)
if err != nil {
reqLog.Warn("gateway.responses.user_slot_acquire_failed", zap.Error(err))
h.handleConcurrencyError(c, err, "user", streamStarted)
return
}
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
waitCounted = false
}
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
defer userReleaseFunc()
@@ -183,8 +160,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformAnthropic)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
message := cls.Message
if !cls.ModelNotFound {
message = "No available accounts: " + err.Error()
}
h.responsesErrorResponse(c, cls.Status, cls.ErrType, message)
return
}
action := fs.HandleSelectionExhausted(requestCtx)
+26 -1
View File
@@ -127,6 +127,14 @@ func (e *ConcurrencyError) Error() string {
return fmt.Sprintf("%s concurrency limit reached", e.SlotType)
}
type WaitQueueFullError struct {
SlotType string
}
func (e *WaitQueueFullError) Error() string {
return "Too many pending requests, please retry later"
}
// ConcurrencyHelper provides common concurrency slot management for gateway handlers
type ConcurrencyHelper struct {
concurrencyService *service.ConcurrencyService
@@ -220,6 +228,10 @@ func (h *ConcurrencyHelper) TryAcquireAccountSlot(ctx context.Context, accountID
// For streaming requests, sends ping events during the wait.
// streamStarted is updated if streaming response has begun.
func (h *ConcurrencyHelper) AcquireUserSlotWithWait(c *gin.Context, userID int64, maxConcurrency int, isStream bool, streamStarted *bool) (func(), error) {
return h.acquireUserSlotWithWaitTimeout(c, userID, maxConcurrency, maxConcurrencyWait, isStream, streamStarted)
}
func (h *ConcurrencyHelper) acquireUserSlotWithWaitTimeout(c *gin.Context, userID int64, maxConcurrency int, timeout time.Duration, isStream bool, streamStarted *bool) (func(), error) {
ctx := c.Request.Context()
// Try to acquire immediately
@@ -232,8 +244,21 @@ func (h *ConcurrencyHelper) AcquireUserSlotWithWait(c *gin.Context, userID int64
return releaseFunc, nil
}
queueLimit := service.CalculateMaxWait(maxConcurrency) - maxConcurrency
if queueLimit < 1 {
queueLimit = 1
}
canWait, err := h.IncrementWaitCount(ctx, userID, queueLimit)
if err != nil {
return nil, err
}
if !canWait {
return nil, &WaitQueueFullError{SlotType: "user"}
}
defer h.DecrementWaitCount(ctx, userID)
// Need to wait - handle streaming ping if needed
return h.waitForSlotWithPing(c, "user", userID, maxConcurrency, isStream, streamStarted)
return h.waitForSlotWithPingTimeout(c, "user", userID, maxConcurrency, timeout, isStream, streamStarted, false)
}
// AcquireAccountSlotWithWait acquires an account concurrency slot, waiting if necessary.
@@ -24,6 +24,11 @@ type helperConcurrencyCacheStub struct {
userAcquireCalls int
accountReleaseCalls int
userReleaseCalls int
waitAllowed bool
waitIncrementCalls int
waitDecrementCalls int
waitMaxWait int
waitIncrementHook func()
}
func (s *helperConcurrencyCacheStub) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
@@ -93,10 +98,25 @@ func (s *helperConcurrencyCacheStub) GetUserConcurrency(ctx context.Context, use
}
func (s *helperConcurrencyCacheStub) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
s.mu.Lock()
s.waitIncrementCalls++
s.waitMaxWait = maxWait
waitAllowed := s.waitAllowed
hook := s.waitIncrementHook
s.mu.Unlock()
if hook != nil {
hook()
}
if !waitAllowed {
return false, nil
}
return true, nil
}
func (s *helperConcurrencyCacheStub) DecrementWaitCount(ctx context.Context, userID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.waitDecrementCalls++
return nil
}
@@ -226,6 +246,98 @@ func TestWaitForSlotWithPingTimeout_AccountAndUserAcquire(t *testing.T) {
})
}
func TestAcquireUserSlotWithWait_ImmediateAcquireSkipsWaitQueue(t *testing.T) {
cache := &helperConcurrencyCacheStub{
userSeq: []bool{true},
}
concurrency := service.NewConcurrencyService(cache)
helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
streamStarted := false
release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
require.NoError(t, err)
require.NotNil(t, release)
release()
require.Equal(t, 1, cache.userAcquireCalls)
require.Equal(t, 0, cache.waitIncrementCalls)
require.Equal(t, 0, cache.waitDecrementCalls)
require.Equal(t, 1, cache.userReleaseCalls)
}
func TestAcquireUserSlotWithWait_WaitSuccessDecrementsBeforeReturn(t *testing.T) {
cache := &helperConcurrencyCacheStub{
userSeq: []bool{false, true},
waitAllowed: true,
}
concurrency := service.NewConcurrencyService(cache)
helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
streamStarted := false
release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
require.NoError(t, err)
require.NotNil(t, release)
require.Equal(t, 2, cache.userAcquireCalls)
require.Equal(t, 1, cache.waitIncrementCalls)
require.Equal(t, 20, cache.waitMaxWait)
require.Equal(t, 1, cache.waitDecrementCalls)
release()
require.Equal(t, 1, cache.userReleaseCalls)
}
func TestAcquireUserSlotWithWait_TimeoutDecrementsWaitQueue(t *testing.T) {
cache := &helperConcurrencyCacheStub{
userSeq: []bool{false, false, false},
waitAllowed: true,
}
concurrency := service.NewConcurrencyService(cache)
helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
streamStarted := false
release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, 30*time.Millisecond, false, &streamStarted)
require.Nil(t, release)
var cErr *ConcurrencyError
require.ErrorAs(t, err, &cErr)
require.True(t, cErr.IsTimeout)
require.Equal(t, 1, cache.waitIncrementCalls)
require.Equal(t, 1, cache.waitDecrementCalls)
require.Equal(t, 0, cache.userReleaseCalls)
}
func TestAcquireUserSlotWithWait_RequestCancelDecrementsWaitQueue(t *testing.T) {
cancelled := make(chan struct{})
var cancel context.CancelFunc
cache := &helperConcurrencyCacheStub{
userSeq: []bool{false, false},
waitAllowed: true,
waitIncrementHook: func() {
cancel()
close(cancelled)
},
}
concurrency := service.NewConcurrencyService(cache)
helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
reqCtx, cancelFunc := context.WithCancel(c.Request.Context())
cancel = cancelFunc
defer cancel()
c.Request = c.Request.WithContext(reqCtx)
streamStarted := false
release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
<-cancelled
require.Nil(t, release)
require.ErrorIs(t, err, context.Canceled)
require.Equal(t, 1, cache.waitIncrementCalls)
require.Equal(t, 1, cache.waitDecrementCalls)
require.Equal(t, 0, cache.userReleaseCalls)
}
func TestWaitForSlotWithPingTimeout_TimeoutAndStreamPing(t *testing.T) {
cache := &helperConcurrencyCacheStub{
accountSeq: []bool{false, false, false},
@@ -205,26 +205,6 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// For Gemini native API, do not send Claude-style ping frames.
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0)
// 0) wait queue check
maxWait := service.CalculateMaxWait(authSubject.Concurrency)
canWait, err := geminiConcurrency.IncrementWaitCount(c.Request.Context(), authSubject.UserID, maxWait)
waitCounted := false
if err != nil {
reqLog.Warn("gemini.user_wait_counter_increment_failed", zap.Error(err))
} else if !canWait {
reqLog.Info("gemini.user_wait_queue_full", zap.Int("max_wait", maxWait))
googleError(c, http.StatusTooManyRequests, "Too many pending requests, please retry later")
return
}
if err == nil && canWait {
waitCounted = true
}
defer func() {
if waitCounted {
geminiConcurrency.DecrementWaitCount(c.Request.Context(), authSubject.UserID)
}
}()
// 1) user concurrency slot
streamStarted := false
if h.errorPassthroughService != nil {
@@ -236,10 +216,6 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
googleError(c, http.StatusTooManyRequests, err.Error())
return
}
if waitCounted {
geminiConcurrency.DecrementWaitCount(c.Request.Context(), authSubject.UserID)
waitCounted = false
}
// 确保请求取消时也会释放槽位,避免长连接被动中断造成泄漏
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
@@ -374,8 +350,15 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
message := cls.Message
if !cls.ModelNotFound {
message = "No available Gemini accounts: " + err.Error()
}
googleError(c, cls.Status, message)
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
+377
View File
@@ -0,0 +1,377 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"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"
)
// GrokImages handles xAI image generation/editing through Grok groups.
func (h *OpenAIGatewayHandler) GrokImages(c *gin.Context) {
endpoint := service.GrokMediaEndpointImagesGenerations
if strings.Contains(c.Request.URL.Path, "/images/edits") {
endpoint = service.GrokMediaEndpointImagesEdits
}
h.handleGrokMedia(c, endpoint, "")
}
// GrokVideoGeneration handles xAI video generation through Grok groups.
func (h *OpenAIGatewayHandler) GrokVideoGeneration(c *gin.Context) {
h.handleGrokMedia(c, service.GrokMediaEndpointVideosGenerations, "")
}
// GrokVideoStatus handles xAI video status retrieval through Grok groups.
func (h *OpenAIGatewayHandler) GrokVideoStatus(c *gin.Context) {
h.handleGrokMedia(c, service.GrokMediaEndpointVideoStatus, c.Param("request_id"))
}
func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) {
streamStarted := false
defer h.recoverResponsesPanic(c, &streamStarted)
requestStart := time.Now()
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok {
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
return
}
reqLog := requestLogger(
c,
"handler.openai_gateway.grok_media",
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("endpoint", string(endpoint)),
)
if !h.ensureResponsesDependencies(c, reqLog) {
return
}
var body []byte
var err error
if endpoint.RequiresRequestBody() {
body, err = pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
return
}
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
if len(body) == 0 {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return
}
}
contentType := c.GetHeader("Content-Type")
requestInfo := service.ParseGrokMediaRequest(contentType, body)
requestModel := requestInfo.Model
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) == "" {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "request_id is required")
return
}
reqLog = reqLog.With(zap.String("model", requestModel))
setOpsRequestContext(c, requestModel, false)
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
if endpoint.IsGenerationRequest() {
if !service.GroupAllowsImageGeneration(apiKey.Group) {
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
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)
return
}
}
imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted)
if !acquired {
return
}
if imageReleaseFunc != nil {
defer imageReleaseFunc()
}
}
if h.errorPassthroughService != nil {
service.BindErrorPassthroughService(c, h.errorPassthroughService)
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
userReleaseFunc, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, false, &streamStarted, reqLog)
if !acquired {
return
}
if userReleaseFunc != nil {
defer userReleaseFunc()
}
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("grok_media.billing_eligibility_check_failed", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
sessionSeed := body
if len(sessionSeed) == 0 && strings.TrimSpace(requestID) != "" {
sessionSeed = []byte(requestID)
}
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, sessionSeed)
if endpoint == service.GrokMediaEndpointVideoStatus {
sessionHash = service.GrokMediaVideoRequestSessionHash(requestID)
}
requestCtx := c.Request.Context()
failedAccountIDs := make(map[int64]struct{})
sameAccountRetryCount := make(map[int64]int)
var lastFailoverErr *service.UpstreamFailoverError
switchCount := 0
maxAccountSwitches := h.maxAccountSwitches
if maxAccountSwitches <= 0 {
maxAccountSwitches = 3
}
routingStart := time.Now()
for {
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
requestCtx,
apiKey.GroupID,
"",
sessionHash,
requestModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
"",
false,
service.PlatformGrok,
)
if err != nil {
reqLog.Warn("grok_media.account_select_failed",
zap.Error(err),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
if lastFailoverErr != nil {
h.handleFailoverExhausted(c, lastFailoverErr, false)
} else {
h.errorResponse(c, http.StatusBadGateway, "api_error", "Upstream request failed")
}
return
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
reqLog.Debug("grok_media.account_schedule_decision",
zap.String("layer", scheduleDecision.Layer),
zap.Bool("sticky_session_hit", scheduleDecision.StickySessionHit),
zap.Int("candidate_count", scheduleDecision.CandidateCount),
zap.Int("top_k", scheduleDecision.TopK),
zap.Int64("latency_ms", scheduleDecision.LatencyMs),
zap.Float64("load_skew", scheduleDecision.LoadSkew),
)
account := selection.Account
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
setOpsSelectedAccount(c, account.ID, account.Platform)
accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
if !accountAcquired {
return
}
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
forwardStart := time.Now()
writerSizeBeforeForward := c.Writer.Size()
result, err := func() (*service.OpenAIForwardResult, error) {
defer func() {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
}()
return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType)
}()
forwardDurationMs := time.Since(forwardStart).Milliseconds()
upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey)
responseLatencyMs := forwardDurationMs
if upstreamLatencyMs > 0 && forwardDurationMs > upstreamLatencyMs {
responseLatencyMs = forwardDurationMs - upstreamLatencyMs
}
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs)
if err != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
if c.Writer.Size() != writerSizeBeforeForward {
h.handleFailoverExhausted(c, failoverErr, true)
return
}
if failoverErr.RetryableOnSameAccount {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
sameAccountRetryCount[account.ID]++
reqLog.Warn("grok_media.pool_mode_same_account_retry",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("retry_limit", retryLimit),
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
)
select {
case <-requestCtx.Done():
return
case <-time.After(sameAccountRetryDelay):
}
continue
}
}
h.gatewayService.RecordOpenAIAccountSwitch()
failedAccountIDs[account.ID] = struct{}{}
lastFailoverErr = failoverErr
if switchCount >= maxAccountSwitches {
h.handleFailoverExhausted(c, failoverErr, false)
return
}
switchCount++
reqLog.Warn("grok_media.upstream_failover_switching",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("switch_count", switchCount),
zap.Int("max_switches", maxAccountSwitches),
)
continue
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
if c.Writer.Size() == writerSizeBeforeForward {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
}
reqLog.Warn("grok_media.forward_failed",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
return
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
if endpoint == service.GrokMediaEndpointVideosGenerations && strings.TrimSpace(result.ResponseID) != "" {
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(requestCtx, apiKey.GroupID, result.ResponseID, 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),
zap.Error(err),
)
}
}
if shouldRecordGrokMediaUsage(endpoint, requestModel) {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID)
}
reqLog.Debug("grok_media.request_completed",
zap.Int64("account_id", account.ID),
zap.Int("switch_count", switchCount),
)
return
}
}
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool {
return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != ""
}
func recordGrokMediaUsage(
c *gin.Context,
h *OpenAIGatewayHandler,
reqLog *zap.Logger,
apiKey *service.APIKey,
subject middleware2.AuthSubject,
subscription *service.UserSubscription,
account *service.Account,
result *service.OpenAIForwardResult,
requestModel string,
body []byte,
requestID string,
) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
payloadForHash := body
if len(payloadForHash) == 0 && strings.TrimSpace(requestID) != "" {
payloadForHash = []byte(requestID)
}
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
channelUsageFields := service.ChannelUsageFields{
OriginalModel: requestModel,
ChannelMappedModel: requestModel,
}
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: service.HashUsageRequestPayload(payloadForHash),
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: channelUsageFields,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.grok_media"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", requestModel),
zap.Int64("account_id", account.ID),
).Error("grok_media.record_usage_failed", zap.Error(err))
reqLog.Debug("grok_media.record_usage_failed", zap.Error(err))
}
})
}
@@ -0,0 +1,54 @@
package handler
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestShouldRecordGrokMediaUsage(t *testing.T) {
tests := []struct {
name string
endpoint service.GrokMediaEndpoint
model string
want bool
}{
{
name: "image generation records usage",
endpoint: service.GrokMediaEndpointImagesGenerations,
model: "grok-imagine",
want: true,
},
{
name: "image edit records usage",
endpoint: service.GrokMediaEndpointImagesEdits,
model: "grok-imagine-edit",
want: true,
},
{
name: "video generation records usage",
endpoint: service.GrokMediaEndpointVideosGenerations,
model: "grok-imagine-video-1.5",
want: true,
},
{
name: "video status skips empty model usage",
endpoint: service.GrokMediaEndpointVideoStatus,
model: "",
want: false,
},
{
name: "generation skips usage without model",
endpoint: service.GrokMediaEndpointImagesGenerations,
model: " ",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model))
})
}
}

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