Files
sub2api/backend/internal/service/shadow_routing.go
T
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

52 lines
2.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
// parentHealthyForShadow 报告 spark 影子账号的母账号凭据是否可用(影子据此可被调度)。
//
// 非影子账号直接返回 true(不受此检查约束)。
// lookup 将母账号 ID 解析为当前 Account(来自调度快照 map 或 repo)。
//
// 关键语义(F1 决策 A + 外审 D):母账号须仍是 OpenAI OAuth(fail-closed——否则透传凭据解析必失败,
// 影子不应进调度候选),且凭据「可用」。IsCredentialUsableForShadow 检查:账号 active、OAuth token
// 未过期、且**未处于 TempUnschedulableUntil 冷却期**——对 OpenAI 账号该字段由 401/token 刷新耗尽/
// transport·proxy 故障写入,代表共享凭据或传输坏死,故**连坐**影子。
//
// **刻意排除** global 维度的 RateLimitResetAt/OverloadUntil 与母账号手动 Schedulable 开关:
// 母账号 global 429 不得连坐 spark 影子,否则会重新耦合影子架构本应解耦的两条 429 道。
// 母账号未找到(nil)、非 OpenAI OAuth、或凭据不可用时影子被挡。
func parentHealthyForShadow(account *Account, lookup func(int64) *Account) bool {
if account == nil || !account.IsShadow() {
return true
}
parent := lookup(*account.ParentAccountID)
if parent == nil {
return false
}
return parent.IsOpenAIOAuth() && parent.IsCredentialUsableForShadow()
}
// sparkModelVariants 返回所有归一到 spark 的模型 ID(当前仅 base:spark 无 effort 变体)。
// 从 codexModelMap 派生,使集合与别名表单一来源、不漂移;若上游将来新增 spark 变体,
// 在 codexModelMap 注册后此处自动跟随。
func sparkModelVariants() []string {
out := make([]string, 0, 1)
for alias, target := range codexModelMap {
if target == "gpt-5.3-codex-spark" {
out = append(out, alias)
}
}
return out
}
// defaultSparkShadowModelMapping 返回 spark 影子账号的默认 model_mapping。
//
// 恒等映射(key 映射到自身)把「只接 spark」限制落在 key 白名单上,模型零改写、
// 与空 mapping 透传行为一致。当前 spark 仅 base 一个模型(无 effort 变体)。
func defaultSparkShadowModelMapping() map[string]any {
variants := sparkModelVariants()
mapping := make(map[string]any, len(variants))
for _, m := range variants {
mapping[m] = m
}
return mapping
}