mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
背景: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>
152 lines
5.0 KiB
Go
152 lines
5.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// sparkShadowUsageTestRepo is a minimal AccountRepository stub for spark shadow
|
|
// usage tests. GetByID serves both shadow and parent accounts from a map;
|
|
// UpdateExtra records the persisted updates for assertion.
|
|
type sparkShadowUsageTestRepo struct {
|
|
AccountRepository
|
|
accounts map[int64]*Account
|
|
updateExtraCh chan map[string]any
|
|
}
|
|
|
|
func (r *sparkShadowUsageTestRepo) GetByID(_ context.Context, id int64) (*Account, error) {
|
|
if acc, ok := r.accounts[id]; ok {
|
|
return acc, nil
|
|
}
|
|
return nil, fmt.Errorf("account %d not found", id)
|
|
}
|
|
|
|
func (r *sparkShadowUsageTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
|
|
if r.updateExtraCh != nil {
|
|
copied := make(map[string]any, len(updates))
|
|
for k, v := range updates {
|
|
copied[k] = v
|
|
}
|
|
r.updateExtraCh <- copied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestGetOpenAIUsage_SparkShadow_WritesExtraAndReturnsNonEmptyWindows covers
|
|
// two assertions required by Task 3.2:
|
|
//
|
|
// A) After getOpenAIUsage on a spark shadow account the shadow row's
|
|
// Extra["codex_5h_used_percent"] is persisted, and the upstream call carried
|
|
// the PARENT account's chatgpt-account-id (not the shadow's empty one).
|
|
//
|
|
// B) (P1-b regression guard) The UsageInfo RETURNED by the same call has
|
|
// non-nil FiveHour AND SevenDay windows — proving that the rebuild happened
|
|
// and not just the DB write.
|
|
func TestGetOpenAIUsage_SparkShadow_WritesExtraAndReturnsNonEmptyWindows(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
pid := int64(100)
|
|
shadow := &Account{
|
|
ID: 200,
|
|
ParentAccountID: &pid,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Status: StatusActive,
|
|
QuotaDimension: QuotaDimensionSpark,
|
|
}
|
|
parent := &Account{
|
|
ID: 100,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Status: StatusActive,
|
|
Credentials: map[string]any{
|
|
"chatgpt_account_id": "org-spark-parent",
|
|
},
|
|
}
|
|
|
|
// Repo shared by both the OpenAIQuotaService (needs shadow+parent for resolve)
|
|
// and the AccountUsageService (needs UpdateExtra for persist).
|
|
updateExtraCh := make(chan map[string]any, 1)
|
|
repo := &sparkShadowUsageTestRepo{
|
|
accounts: map[int64]*Account{200: shadow, 100: parent},
|
|
updateExtraCh: updateExtraCh,
|
|
}
|
|
|
|
// Token cache: return a fake token for the parent account key.
|
|
tokenCache := &stubQuotaTokenCache{tokens: map[string]string{
|
|
OpenAITokenCacheKey(parent): "fake-access-token",
|
|
}}
|
|
tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil)
|
|
|
|
// httptest server: records the chatgpt-account-id header and returns a
|
|
// synthetic OpenAIQuotaUsage with codex_bengalfox 5h+7d windows.
|
|
var capturedAccountID string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedAccountID = r.Header.Get("chatgpt-account-id")
|
|
w.Header().Set("content-type", "application/json")
|
|
resp := OpenAIQuotaUsage{
|
|
AdditionalRateLimits: []OpenAIAdditionalRateLimit{
|
|
{
|
|
MeteredFeature: "codex_bengalfox",
|
|
RateLimit: &OpenAIRateLimit{
|
|
// Primary window → 5h (18000 s = 300 min)
|
|
PrimaryWindow: &OpenAIRateLimitWindow{
|
|
UsedPercent: 42.5,
|
|
ResetAfterSeconds: 3600,
|
|
LimitWindowSeconds: 18000,
|
|
},
|
|
// Secondary window → 7d (604800 s = 10080 min)
|
|
SecondaryWindow: &OpenAIRateLimitWindow{
|
|
UsedPercent: 10.0,
|
|
ResetAfterSeconds: 86400,
|
|
LimitWindowSeconds: 604800,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
quotaService := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv))
|
|
svc := &AccountUsageService{
|
|
accountRepo: repo,
|
|
openAIQuotaService: quotaService,
|
|
}
|
|
|
|
usage, err := svc.getOpenAIUsage(ctx, shadow, true /*force*/)
|
|
require.NoError(t, err)
|
|
|
|
// Assertion A-1: upstream received the PARENT's chatgpt-account-id.
|
|
require.Equal(t, "org-spark-parent", capturedAccountID,
|
|
"QueryUsage must use parent's chatgpt-account-id for spark shadow accounts")
|
|
|
|
// Assertion A-2: shadow Extra was persisted with codex_5h_used_percent.
|
|
select {
|
|
case updates := <-updateExtraCh:
|
|
require.Contains(t, updates, "codex_5h_used_percent",
|
|
"persisted extra must contain codex_5h_used_percent")
|
|
require.InDelta(t, 42.5, updates["codex_5h_used_percent"], 0.01,
|
|
"codex_5h_used_percent must match the upstream value")
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("UpdateExtra was not called within timeout — spark shadow persist did not happen")
|
|
}
|
|
|
|
// Assertion B (P1-b regression guard): returned UsageInfo must have
|
|
// non-nil windows. This FAILS if the code only writes Extra without
|
|
// rebuilding the returned UsageInfo.
|
|
require.NotNil(t, usage.FiveHour,
|
|
"returned UsageInfo.FiveHour must be non-nil (rebuild from merged Extra must happen)")
|
|
require.NotNil(t, usage.SevenDay,
|
|
"returned UsageInfo.SevenDay must be non-nil (rebuild from merged Extra must happen)")
|
|
}
|