背景: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>
435 lines
9.4 KiB
Go
435 lines
9.4 KiB
Go
//go:build integration
|
|
|
|
package repository
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
dbaccount "github.com/Wei-Shaw/sub2api/ent/account"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func mustCreateUser(t *testing.T, client *dbent.Client, u *service.User) *service.User {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
if u.Email == "" {
|
|
u.Email = "user-" + time.Now().Format(time.RFC3339Nano) + "@example.com"
|
|
}
|
|
if u.PasswordHash == "" {
|
|
u.PasswordHash = "test-password-hash"
|
|
}
|
|
if u.Role == "" {
|
|
u.Role = service.RoleUser
|
|
}
|
|
if u.Status == "" {
|
|
u.Status = service.StatusActive
|
|
}
|
|
if u.Concurrency == 0 {
|
|
u.Concurrency = 5
|
|
}
|
|
|
|
create := client.User.Create().
|
|
SetEmail(u.Email).
|
|
SetPasswordHash(u.PasswordHash).
|
|
SetRole(u.Role).
|
|
SetStatus(u.Status).
|
|
SetBalance(u.Balance).
|
|
SetConcurrency(u.Concurrency).
|
|
SetUsername(u.Username).
|
|
SetNotes(u.Notes)
|
|
if !u.CreatedAt.IsZero() {
|
|
create.SetCreatedAt(u.CreatedAt)
|
|
}
|
|
if !u.UpdatedAt.IsZero() {
|
|
create.SetUpdatedAt(u.UpdatedAt)
|
|
}
|
|
|
|
created, err := create.Save(ctx)
|
|
require.NoError(t, err, "create user")
|
|
|
|
u.ID = created.ID
|
|
u.CreatedAt = created.CreatedAt
|
|
u.UpdatedAt = created.UpdatedAt
|
|
|
|
if len(u.AllowedGroups) > 0 {
|
|
for _, groupID := range u.AllowedGroups {
|
|
_, err := client.UserAllowedGroup.Create().
|
|
SetUserID(u.ID).
|
|
SetGroupID(groupID).
|
|
Save(ctx)
|
|
require.NoError(t, err, "create user_allowed_groups row")
|
|
}
|
|
}
|
|
|
|
return u
|
|
}
|
|
|
|
func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *service.Group {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
if g.Platform == "" {
|
|
g.Platform = service.PlatformAnthropic
|
|
}
|
|
if g.Status == "" {
|
|
g.Status = service.StatusActive
|
|
}
|
|
if g.SubscriptionType == "" {
|
|
g.SubscriptionType = service.SubscriptionTypeStandard
|
|
}
|
|
|
|
create := client.Group.Create().
|
|
SetName(g.Name).
|
|
SetPlatform(g.Platform).
|
|
SetStatus(g.Status).
|
|
SetSubscriptionType(g.SubscriptionType).
|
|
SetRateMultiplier(g.RateMultiplier).
|
|
SetIsExclusive(g.IsExclusive)
|
|
if g.Description != "" {
|
|
create.SetDescription(g.Description)
|
|
}
|
|
if g.DailyLimitUSD != nil {
|
|
create.SetDailyLimitUsd(*g.DailyLimitUSD)
|
|
}
|
|
if g.WeeklyLimitUSD != nil {
|
|
create.SetWeeklyLimitUsd(*g.WeeklyLimitUSD)
|
|
}
|
|
if g.MonthlyLimitUSD != nil {
|
|
create.SetMonthlyLimitUsd(*g.MonthlyLimitUSD)
|
|
}
|
|
if !g.CreatedAt.IsZero() {
|
|
create.SetCreatedAt(g.CreatedAt)
|
|
}
|
|
if !g.UpdatedAt.IsZero() {
|
|
create.SetUpdatedAt(g.UpdatedAt)
|
|
}
|
|
|
|
created, err := create.Save(ctx)
|
|
require.NoError(t, err, "create group")
|
|
|
|
g.ID = created.ID
|
|
g.CreatedAt = created.CreatedAt
|
|
g.UpdatedAt = created.UpdatedAt
|
|
return g
|
|
}
|
|
|
|
func mustCreateProxy(t *testing.T, client *dbent.Client, p *service.Proxy) *service.Proxy {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
if p.Protocol == "" {
|
|
p.Protocol = "http"
|
|
}
|
|
if p.Host == "" {
|
|
p.Host = "127.0.0.1"
|
|
}
|
|
if p.Port == 0 {
|
|
p.Port = 8080
|
|
}
|
|
if p.Status == "" {
|
|
p.Status = service.StatusActive
|
|
}
|
|
|
|
create := client.Proxy.Create().
|
|
SetName(p.Name).
|
|
SetProtocol(p.Protocol).
|
|
SetHost(p.Host).
|
|
SetPort(p.Port).
|
|
SetStatus(p.Status)
|
|
if p.Username != "" {
|
|
create.SetUsername(p.Username)
|
|
}
|
|
if p.Password != "" {
|
|
create.SetPassword(p.Password)
|
|
}
|
|
if !p.CreatedAt.IsZero() {
|
|
create.SetCreatedAt(p.CreatedAt)
|
|
}
|
|
if !p.UpdatedAt.IsZero() {
|
|
create.SetUpdatedAt(p.UpdatedAt)
|
|
}
|
|
|
|
created, err := create.Save(ctx)
|
|
require.NoError(t, err, "create proxy")
|
|
|
|
p.ID = created.ID
|
|
p.CreatedAt = created.CreatedAt
|
|
p.UpdatedAt = created.UpdatedAt
|
|
return p
|
|
}
|
|
|
|
func mustCreateAccount(t *testing.T, client *dbent.Client, a *service.Account) *service.Account {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
if a.Platform == "" {
|
|
a.Platform = service.PlatformAnthropic
|
|
}
|
|
if a.Type == "" {
|
|
a.Type = service.AccountTypeOAuth
|
|
}
|
|
if a.Status == "" {
|
|
a.Status = service.StatusActive
|
|
}
|
|
if a.Concurrency == 0 {
|
|
a.Concurrency = 3
|
|
}
|
|
if a.Priority == 0 {
|
|
a.Priority = 50
|
|
}
|
|
if !a.Schedulable {
|
|
a.Schedulable = true
|
|
}
|
|
if a.Credentials == nil {
|
|
a.Credentials = map[string]any{}
|
|
}
|
|
if a.Extra == nil {
|
|
a.Extra = map[string]any{}
|
|
}
|
|
|
|
create := client.Account.Create().
|
|
SetName(a.Name).
|
|
SetPlatform(a.Platform).
|
|
SetType(a.Type).
|
|
SetCredentials(a.Credentials).
|
|
SetExtra(a.Extra).
|
|
SetConcurrency(a.Concurrency).
|
|
SetPriority(a.Priority).
|
|
SetStatus(a.Status).
|
|
SetSchedulable(a.Schedulable).
|
|
SetErrorMessage(a.ErrorMessage)
|
|
|
|
if a.ProxyID != nil {
|
|
create.SetProxyID(*a.ProxyID)
|
|
}
|
|
if a.LastUsedAt != nil {
|
|
create.SetLastUsedAt(*a.LastUsedAt)
|
|
}
|
|
if a.RateLimitedAt != nil {
|
|
create.SetRateLimitedAt(*a.RateLimitedAt)
|
|
}
|
|
if a.RateLimitResetAt != nil {
|
|
create.SetRateLimitResetAt(*a.RateLimitResetAt)
|
|
}
|
|
if a.OverloadUntil != nil {
|
|
create.SetOverloadUntil(*a.OverloadUntil)
|
|
}
|
|
if a.SessionWindowStart != nil {
|
|
create.SetSessionWindowStart(*a.SessionWindowStart)
|
|
}
|
|
if a.SessionWindowEnd != nil {
|
|
create.SetSessionWindowEnd(*a.SessionWindowEnd)
|
|
}
|
|
if a.SessionWindowStatus != "" {
|
|
create.SetSessionWindowStatus(a.SessionWindowStatus)
|
|
}
|
|
if !a.CreatedAt.IsZero() {
|
|
create.SetCreatedAt(a.CreatedAt)
|
|
}
|
|
if !a.UpdatedAt.IsZero() {
|
|
create.SetUpdatedAt(a.UpdatedAt)
|
|
}
|
|
if a.ParentAccountID != nil {
|
|
create.SetParentAccountID(*a.ParentAccountID)
|
|
}
|
|
if a.QuotaDimension != "" {
|
|
create.SetQuotaDimension(dbaccount.QuotaDimension(a.QuotaDimension))
|
|
}
|
|
|
|
created, err := create.Save(ctx)
|
|
require.NoError(t, err, "create account")
|
|
|
|
a.ID = created.ID
|
|
a.CreatedAt = created.CreatedAt
|
|
a.UpdatedAt = created.UpdatedAt
|
|
return a
|
|
}
|
|
|
|
func mustCreateApiKey(t *testing.T, client *dbent.Client, k *service.APIKey) *service.APIKey {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
if k.Status == "" {
|
|
k.Status = service.StatusActive
|
|
}
|
|
if k.Key == "" {
|
|
k.Key = "sk-" + time.Now().Format("150405.000000")
|
|
}
|
|
if k.Name == "" {
|
|
k.Name = "default"
|
|
}
|
|
|
|
create := client.APIKey.Create().
|
|
SetUserID(k.UserID).
|
|
SetKey(k.Key).
|
|
SetName(k.Name).
|
|
SetStatus(k.Status)
|
|
if k.Quota != 0 {
|
|
create.SetQuota(k.Quota)
|
|
}
|
|
if k.QuotaUsed != 0 {
|
|
create.SetQuotaUsed(k.QuotaUsed)
|
|
}
|
|
if k.RateLimit5h != 0 {
|
|
create.SetRateLimit5h(k.RateLimit5h)
|
|
}
|
|
if k.RateLimit1d != 0 {
|
|
create.SetRateLimit1d(k.RateLimit1d)
|
|
}
|
|
if k.RateLimit7d != 0 {
|
|
create.SetRateLimit7d(k.RateLimit7d)
|
|
}
|
|
if k.Usage5h != 0 {
|
|
create.SetUsage5h(k.Usage5h)
|
|
}
|
|
if k.Usage1d != 0 {
|
|
create.SetUsage1d(k.Usage1d)
|
|
}
|
|
if k.Usage7d != 0 {
|
|
create.SetUsage7d(k.Usage7d)
|
|
}
|
|
if k.Window5hStart != nil {
|
|
create.SetWindow5hStart(*k.Window5hStart)
|
|
}
|
|
if k.Window1dStart != nil {
|
|
create.SetWindow1dStart(*k.Window1dStart)
|
|
}
|
|
if k.Window7dStart != nil {
|
|
create.SetWindow7dStart(*k.Window7dStart)
|
|
}
|
|
if k.ExpiresAt != nil {
|
|
create.SetExpiresAt(*k.ExpiresAt)
|
|
}
|
|
if k.GroupID != nil {
|
|
create.SetGroupID(*k.GroupID)
|
|
}
|
|
if !k.CreatedAt.IsZero() {
|
|
create.SetCreatedAt(k.CreatedAt)
|
|
}
|
|
if !k.UpdatedAt.IsZero() {
|
|
create.SetUpdatedAt(k.UpdatedAt)
|
|
}
|
|
|
|
created, err := create.Save(ctx)
|
|
require.NoError(t, err, "create api key")
|
|
|
|
k.ID = created.ID
|
|
k.CreatedAt = created.CreatedAt
|
|
k.UpdatedAt = created.UpdatedAt
|
|
return k
|
|
}
|
|
|
|
func mustCreateRedeemCode(t *testing.T, client *dbent.Client, c *service.RedeemCode) *service.RedeemCode {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
if c.Status == "" {
|
|
c.Status = service.StatusUnused
|
|
}
|
|
if c.Type == "" {
|
|
c.Type = service.RedeemTypeBalance
|
|
}
|
|
if c.Code == "" {
|
|
c.Code = "rc-" + time.Now().Format("150405.000000")
|
|
}
|
|
|
|
create := client.RedeemCode.Create().
|
|
SetCode(c.Code).
|
|
SetType(c.Type).
|
|
SetValue(c.Value).
|
|
SetStatus(c.Status).
|
|
SetNotes(c.Notes).
|
|
SetValidityDays(c.ValidityDays)
|
|
if c.UsedBy != nil {
|
|
create.SetUsedBy(*c.UsedBy)
|
|
}
|
|
if c.UsedAt != nil {
|
|
create.SetUsedAt(*c.UsedAt)
|
|
}
|
|
if c.GroupID != nil {
|
|
create.SetGroupID(*c.GroupID)
|
|
}
|
|
if !c.CreatedAt.IsZero() {
|
|
create.SetCreatedAt(c.CreatedAt)
|
|
}
|
|
|
|
created, err := create.Save(ctx)
|
|
require.NoError(t, err, "create redeem code")
|
|
|
|
c.ID = created.ID
|
|
c.CreatedAt = created.CreatedAt
|
|
return c
|
|
}
|
|
|
|
func mustCreateSubscription(t *testing.T, client *dbent.Client, s *service.UserSubscription) *service.UserSubscription {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
if s.Status == "" {
|
|
s.Status = service.SubscriptionStatusActive
|
|
}
|
|
now := time.Now()
|
|
if s.StartsAt.IsZero() {
|
|
s.StartsAt = now.Add(-1 * time.Hour)
|
|
}
|
|
if s.ExpiresAt.IsZero() {
|
|
s.ExpiresAt = now.Add(24 * time.Hour)
|
|
}
|
|
if s.AssignedAt.IsZero() {
|
|
s.AssignedAt = now
|
|
}
|
|
if s.CreatedAt.IsZero() {
|
|
s.CreatedAt = now
|
|
}
|
|
if s.UpdatedAt.IsZero() {
|
|
s.UpdatedAt = now
|
|
}
|
|
|
|
create := client.UserSubscription.Create().
|
|
SetUserID(s.UserID).
|
|
SetGroupID(s.GroupID).
|
|
SetStartsAt(s.StartsAt).
|
|
SetExpiresAt(s.ExpiresAt).
|
|
SetStatus(s.Status).
|
|
SetAssignedAt(s.AssignedAt).
|
|
SetNotes(s.Notes).
|
|
SetDailyUsageUsd(s.DailyUsageUSD).
|
|
SetWeeklyUsageUsd(s.WeeklyUsageUSD).
|
|
SetMonthlyUsageUsd(s.MonthlyUsageUSD)
|
|
|
|
if s.AssignedBy != nil {
|
|
create.SetAssignedBy(*s.AssignedBy)
|
|
}
|
|
if !s.CreatedAt.IsZero() {
|
|
create.SetCreatedAt(s.CreatedAt)
|
|
}
|
|
if !s.UpdatedAt.IsZero() {
|
|
create.SetUpdatedAt(s.UpdatedAt)
|
|
}
|
|
|
|
created, err := create.Save(ctx)
|
|
require.NoError(t, err, "create user subscription")
|
|
|
|
s.ID = created.ID
|
|
s.CreatedAt = created.CreatedAt
|
|
s.UpdatedAt = created.UpdatedAt
|
|
return s
|
|
}
|
|
|
|
func mustBindAccountToGroup(t *testing.T, client *dbent.Client, accountID, groupID int64, priority int) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
_, err := client.AccountGroup.Create().
|
|
SetAccountID(accountID).
|
|
SetGroupID(groupID).
|
|
SetPriority(priority).
|
|
Save(ctx)
|
|
require.NoError(t, err, "create account_group")
|
|
}
|