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

193 lines
8.2 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.
//go:build unit
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestSparkShadowIntegration 是 spark-shadow 功能的端到端集成测试。
//
// 覆盖三个核心属性:
//
// 1. 凭据轮换读透(脱钩命门)——母账号 access_token 轮换后,影子通过
// resolveCredentialAccount / GetAccessToken 立即反映新值,零脱钩。
//
// 2. 路由不变量——路由资格由 IsModelSupported 决定(model_mapping 配置);
// 影子配了 spark mapping 则接受 spark、拒非 spark;普通账号配了 spark 同样可接 spark。
//
// 3. 母账号健康度联动——母不可调度(Status=error 或 Schedulable=false)
// 时,parentHealthyForShadow 对影子返回 false。
//
// 复用的接缝:
// - newStubCredRepo(credential_shadow_test.go,同包无 tag 始终编译)
// - resolveCredentialAccount(credential_shadow.go)
// - OpenAIGatewayService.GetAccessToken(openai_gateway_service.go,openAITokenProvider=nil 降级路径)
// - 路由资格由 IsModelSupported 决定(spark_routing.go 已移除类型门)
// - parentHealthyForShadow(spark_routing.go)
func TestSparkShadowIntegration(t *testing.T) {
ctx := context.Background()
pid := int64(100)
// 共享母账号:Credentials 为 map(引用型),可原地轮换而无需重建 stub。
parent := &Account{
ID: 100,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Credentials: map[string]any{
"access_token": "T1",
},
}
// 影子账号:不持凭据(与生产语义一致),QuotaDimensionSpark 标记 spark 维度。
shadow := &Account{
ID: 200,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
ParentAccountID: &pid,
QuotaDimension: QuotaDimensionSpark,
Status: StatusActive,
Schedulable: true,
}
// repo:stubCredRepo(credential_shadow_test.go)存 *Account 指针,
// Credentials map 变更直接可见,无需重建 stub。
repo := newStubCredRepo(parent)
// ──────────────────────────────────────────────────────────────────────
// 属性 1:凭据轮换读透(脱钩命门)
// ──────────────────────────────────────────────────────────────────────
t.Run("credential_readthrough_initial_T1", func(t *testing.T) {
// 影子无凭据,resolveCredentialAccount 必须透传到母账号。
got, err := resolveCredentialAccount(ctx, repo, shadow)
require.NoError(t, err)
require.Equal(t, int64(100), got.ID, "解析结果应为母账号")
require.Equal(t, "T1", got.GetOpenAIAccessToken(),
"初始应读到 T1")
})
t.Run("credential_readthrough_after_rotation_T2", func(t *testing.T) {
// 模拟 refresh_token 轮换:原地更新母账号凭据。
// 影子不持凭据、无本地缓存,下次解析必须见到新值。
parent.Credentials["access_token"] = "T2"
got, err := resolveCredentialAccount(ctx, repo, shadow)
require.NoError(t, err)
require.Equal(t, "T2", got.GetOpenAIAccessToken(),
"轮换后影子必须立即反映母账号新 token(零脱钩)")
})
t.Run("get_access_token_e2e_reads_through_T3", func(t *testing.T) {
// 端到端:经 OpenAIGatewayService.GetAccessToken 验证全路径读透。
// openAITokenProvider=nil → 降级到直接读 account.GetOpenAIAccessToken()。
parent.Credentials["access_token"] = "T3"
svc := &OpenAIGatewayService{
accountRepo: repo,
}
token, tokenType, err := svc.GetAccessToken(ctx, shadow)
require.NoError(t, err)
require.Equal(t, "T3", token,
"GetAccessToken(影子) 必须返回母账号当前 token")
require.Equal(t, "oauth", tokenType)
})
t.Run("normal_account_returns_its_own_token", func(t *testing.T) {
// 对照组:普通账号(非影子)直接返回自身凭据,不经 resolveCredentialAccount。
ordinary := &Account{
ID: 300,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Credentials: map[string]any{
"access_token": "ordinary-token",
},
}
svc := &OpenAIGatewayService{
accountRepo: newStubCredRepo(ordinary),
}
token, _, err := svc.GetAccessToken(ctx, ordinary)
require.NoError(t, err)
require.Equal(t, "ordinary-token", token)
})
// ──────────────────────────────────────────────────────────────────────
// 属性 2:路由不变量(路由资格由 IsModelSupported 决定)
// ──────────────────────────────────────────────────────────────────────
t.Run("routing_invariant", func(t *testing.T) {
// 路由资格已从「按账号类型」改为「按账号支持模型」(model_mapping / IsModelSupported)。
sparkModel := "gpt-5.3-codex-spark"
normalModel := "gpt-5.3-codex"
sparkCreds := map[string]any{"model_mapping": defaultSparkShadowModelMapping()}
pid := int64(1)
sparkShadow := &Account{ID: 2, ParentAccountID: &pid, Platform: PlatformOpenAI, Credentials: sparkCreds}
require.True(t, sparkShadow.IsModelSupported(sparkModel), "影子配 spark → 接 spark")
require.False(t, sparkShadow.IsModelSupported(normalModel), "影子(仅 spark mapping)→ 拒非 spark")
normalWithSpark := &Account{ID: 3, Platform: PlatformOpenAI, Credentials: sparkCreds}
require.True(t, normalWithSpark.IsModelSupported(sparkModel), "普通账号配 spark → 接 spark(不再按类型排除)")
normalNoSpark := &Account{ID: 4, Platform: PlatformOpenAI,
Credentials: map[string]any{"model_mapping": map[string]any{normalModel: normalModel}}}
require.False(t, normalNoSpark.IsModelSupported(sparkModel), "普通账号未配 spark → 拒 spark(按配置)")
})
// ──────────────────────────────────────────────────────────────────────
// 属性 3:母账号健康度联动(parentHealthyForShadow)
// ──────────────────────────────────────────────────────────────────────
t.Run("parent_health_propagated_to_shadow", func(t *testing.T) {
// 恢复母账号健康状态(属性 1/2 测试可能改过)
parent.Status = StatusActive
parent.Schedulable = true
lookup := func(id int64) *Account {
if id == parent.ID {
return parent
}
return nil
}
// 母健康 → 影子健康
require.True(t, parentHealthyForShadow(shadow, lookup),
"健康母账号时影子应健康")
// 母 Status=error(凭据不可用)→ 影子不健康
parent.Status = StatusError
require.False(t, parentHealthyForShadow(shadow, lookup),
"Status=error 母账号时影子应不健康")
// F1 决策 A:母 Schedulable=false (Status=active) 是手动调度暂停,不连坐影子(凭据仍可用)
parent.Status = StatusActive
parent.Schedulable = false
require.True(t, parentHealthyForShadow(shadow, lookup),
"母账号手动暂停不应连坐影子(凭据仍可用)")
// F1 核心:母 global 限流(RateLimitResetAt 未来)不连坐 spark 影子
parent.Schedulable = true
resetAt := time.Now().Add(1 * time.Hour)
parent.RateLimitResetAt = &resetAt
require.True(t, parentHealthyForShadow(shadow, lookup),
"母账号 global 限流不应连坐 spark 影子")
parent.RateLimitResetAt = nil
// 对照组:非影子账号 parentHealthyForShadow 始终 true,不调用 lookup
parent.Schedulable = true
lookupNotCalled := func(_ int64) *Account {
t.Error("非影子账号不应调用 lookup")
return nil
}
require.True(t, parentHealthyForShadow(parent, lookupNotCalled),
"普通账号应直接返回 true")
})
}