From bdf7ead1572ff0a5ea20f6f169a79189d0d13280 Mon Sep 17 00:00:00 2001 From: DaydreamCoding <22166516+DaydreamCoding@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:32:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(spark-shadow):=20OpenAI=20Spark=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=9E=8B=E5=BD=B1=E5=AD=90=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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 --- backend/cmd/server/wire_gen.go | 4 +- backend/ent/account.go | 69 +- backend/ent/account/account.go | 90 ++ backend/ent/account/where.go | 101 ++ backend/ent/account_create.go | 211 ++++ backend/ent/account_query.go | 155 ++- backend/ent/account_update.go | 354 ++++++ backend/ent/client.go | 32 + .../migrate/auth_identity_fk_ondelete_test.go | 25 + backend/ent/migrate/schema.go | 18 +- backend/ent/mutation.go | 277 ++++- backend/ent/schema/account.go | 14 + .../internal/handler/admin/account_data.go | 32 +- .../admin/account_data_handler_test.go | 49 +- .../internal/handler/admin/account_handler.go | 12 +- .../account_handler_available_models_test.go | 42 + .../account_handler_spark_shadow_test.go | 29 + .../handler/admin/account_shadow_parent.go | 56 + .../admin/account_shadow_parent_test.go | 42 + .../handler/admin/admin_service_stub_test.go | 21 + .../handler/admin/openai_oauth_handler.go | 44 + .../openai_oauth_handler_spark_shadow_test.go | 106 ++ backend/internal/handler/dto/mappers.go | 2 + backend/internal/handler/dto/types.go | 11 + .../handler/openai_gateway_handler.go | 6 +- backend/internal/handler/openai_images.go | 3 +- backend/internal/repository/account_repo.go | 35 + .../account_repo_integration_test.go | 39 + .../account_repo_spark_shadow_test.go | 136 +++ .../repository/fixtures_integration_test.go | 7 + .../internal/repository/scheduler_cache.go | 4 +- .../repository/scheduler_cache_unit_test.go | 29 + backend/internal/server/api_contract_test.go | 4 + backend/internal/server/routes/admin.go | 3 + backend/internal/service/account.go | 43 + .../account_credential_shadow_skip_test.go | 96 ++ .../account_credentials_persistence.go | 47 +- backend/internal/service/account_service.go | 6 + .../service/account_service_delete_test.go | 4 + .../service/account_spark_shadow_test.go | 19 + .../internal/service/account_test_service.go | 21 +- .../account_test_service_openai_test.go | 58 + .../internal/service/account_usage_service.go | 66 +- ...account_usage_service_spark_shadow_test.go | 151 +++ .../service/account_usage_service_test.go | 49 + backend/internal/service/admin_service.go | 289 ++++- .../admin_service_spark_shadow_test.go | 1000 +++++++++++++++++ backend/internal/service/credential_shadow.go | 33 + .../service/credential_shadow_test.go | 46 + backend/internal/service/crs_sync_service.go | 79 ++ .../service/crs_sync_spark_shadow_test.go | 84 ++ backend/internal/service/domain_constants.go | 6 + .../service/gateway_multiplatform_test.go | 4 + .../service/gemini_multiplatform_test.go | 4 + .../service/oauth_refresh_api_test.go | 2 +- .../openai_account_runtime_block_fastpath.go | 5 + ...nai_account_runtime_block_fastpath_test.go | 26 + .../service/openai_account_scheduler.go | 24 + ...enai_account_scheduler_spark_route_test.go | 210 ++++ .../service/openai_chatgpt_headers.go | 17 +- .../service/openai_chatgpt_headers_test.go | 64 ++ .../service/openai_codex_transform.go | 82 +- .../openai_gateway_chat_completions.go | 5 +- .../service/openai_gateway_messages.go | 5 +- .../service/openai_gateway_service.go | 82 +- ...penai_gateway_service_shadow_token_test.go | 47 + .../internal/service/openai_quota_service.go | 116 ++ .../service/openai_quota_spark_window_test.go | 274 +++++ .../openai_spark_shadow_parent_health_test.go | 220 ++++ .../internal/service/openai_ws_forwarder.go | 35 +- .../openai_ws_v2_passthrough_adapter.go | 5 +- backend/internal/service/ratelimit_service.go | 54 +- .../service/ratelimit_service_401_test.go | 45 +- .../service/ratelimit_service_openai_test.go | 38 + .../service/ratelimit_session_window_test.go | 3 + backend/internal/service/shadow_routing.go | 51 + .../internal/service/shadow_routing_test.go | 21 + .../service/spark_shadow_integration_test.go | 192 ++++ .../service/token_refresh_service_test.go | 2 +- backend/internal/service/token_refresher.go | 3 + .../migrations/154_account_spark_shadow.sql | 30 + ...154a_account_spark_shadow_indexes_notx.sql | 6 + ...tity_payment_migrations_regression_test.go | 34 + frontend/src/api/admin/accounts.ts | 15 +- .../components/account/EditAccountModal.vue | 54 +- .../account/OpenAIQuotaResetCell.vue | 7 +- .../__tests__/EditAccountModal.spec.ts | 89 +- .../OpenAIQuotaResetCell.spark_shadow.spec.ts | 67 ++ .../admin/account/AccountActionMenu.vue | 15 +- .../AccountActionMenu.spark_shadow.spec.ts | 127 +++ frontend/src/i18n/locales/en.ts | 6 + frontend/src/i18n/locales/zh.ts | 6 + frontend/src/types/index.ts | 12 + frontend/src/views/admin/AccountsView.vue | 48 +- .../AccountsView.sparkShadow.spec.ts | 256 +++++ 95 files changed, 6388 insertions(+), 179 deletions(-) create mode 100644 backend/internal/handler/admin/account_handler_spark_shadow_test.go create mode 100644 backend/internal/handler/admin/account_shadow_parent.go create mode 100644 backend/internal/handler/admin/account_shadow_parent_test.go create mode 100644 backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go create mode 100644 backend/internal/repository/account_repo_spark_shadow_test.go create mode 100644 backend/internal/service/account_credential_shadow_skip_test.go create mode 100644 backend/internal/service/account_spark_shadow_test.go create mode 100644 backend/internal/service/account_usage_service_spark_shadow_test.go create mode 100644 backend/internal/service/admin_service_spark_shadow_test.go create mode 100644 backend/internal/service/credential_shadow.go create mode 100644 backend/internal/service/credential_shadow_test.go create mode 100644 backend/internal/service/crs_sync_spark_shadow_test.go create mode 100644 backend/internal/service/openai_account_scheduler_spark_route_test.go create mode 100644 backend/internal/service/openai_chatgpt_headers_test.go create mode 100644 backend/internal/service/openai_gateway_service_shadow_token_test.go create mode 100644 backend/internal/service/openai_quota_spark_window_test.go create mode 100644 backend/internal/service/openai_spark_shadow_parent_health_test.go create mode 100644 backend/internal/service/shadow_routing.go create mode 100644 backend/internal/service/shadow_routing_test.go create mode 100644 backend/internal/service/spark_shadow_integration_test.go create mode 100644 backend/migrations/154_account_spark_shadow.sql create mode 100644 backend/migrations/154a_account_spark_shadow_indexes_notx.sql create mode 100644 frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts create mode 100644 frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts create mode 100644 frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 12f78f2718..a6fb5266aa 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -182,8 +182,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { 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, grokQuotaFetcher, usageCache, identityCache, 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) @@ -195,7 +196,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper) backupHandler := admin.NewBackupHandler(backupService, userService) oAuthHandler := admin.NewOAuthHandler(oAuthService) - openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) diff --git a/backend/ent/account.go b/backend/ent/account.go index 86e6030613..a1cd1a73b6 100644 --- a/backend/ent/account.go +++ b/backend/ent/account.go @@ -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() } diff --git a/backend/ent/account/account.go b/backend/ent/account/account.go index 4df894e8d8..61a8a82132 100644 --- a/backend/ent/account/account.go +++ b/backend/ent/account/account.go @@ -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), diff --git a/backend/ent/account/where.go b/backend/ent/account/where.go index 543624f1ec..6b7c375be7 100644 --- a/backend/ent/account/where.go +++ b/backend/ent/account/where.go @@ -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) { diff --git a/backend/ent/account_create.go b/backend/ent/account_create.go index 2c3a7bc307..d74b10eba7 100644 --- a/backend/ent/account_create.go +++ b/backend/ent/account_create.go @@ -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 { diff --git a/backend/ent/account_query.go b/backend/ent/account_query.go index 1761fa6377..f774d5bd95 100644 --- a/backend/ent/account_query.go +++ b/backend/ent/account_query.go @@ -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) { diff --git a/backend/ent/account_update.go b/backend/ent/account_update.go index 3453c8b10f..4584a826a0 100644 --- a/backend/ent/account_update.go +++ b/backend/ent/account_update.go @@ -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, diff --git a/backend/ent/client.go b/backend/ent/client.go index a0dab61036..0b4edcf203 100644 --- a/backend/ent/client.go +++ b/backend/ent/client.go @@ -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() diff --git a/backend/ent/migrate/auth_identity_fk_ondelete_test.go b/backend/ent/migrate/auth_identity_fk_ondelete_test.go index 0e37025a55..5889cfd4fc 100644 --- a/backend/ent/migrate/auth_identity_fk_ondelete_test.go +++ b/backend/ent/migrate/auth_identity_fk_ondelete_test.go @@ -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 +} diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index d5e9130416..9a55ac6415 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -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. @@ -1820,6 +1833,7 @@ func init() { Table: "api_keys", } AccountsTable.ForeignKeys[0].RefTable = ProxiesTable + AccountsTable.ForeignKeys[1].RefTable = AccountsTable AccountsTable.Annotation = &entsql.Annotation{ Table: "accounts", } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index d62620bc7b..4245a6749b 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -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 diff --git a/backend/ent/schema/account.go b/backend/ent/schema/account.go index 5892e606b0..9d771d2903 100644 --- a/backend/ent/schema/account.go +++ b/backend/ent/schema/account.go @@ -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"), } } diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index 94c4f45f7d..bf872c4826 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -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) diff --git a/backend/internal/handler/admin/account_data_handler_test.go b/backend/internal/handler/admin/account_data_handler_test.go index 5793983cba..ecf17a8471 100644 --- a/backend/internal/handler/admin/account_data_handler_test.go +++ b/backend/internal/handler/admin/account_data_handler_test.go @@ -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{ diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index c43348528b..8d4a5dfea6 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -221,6 +221,8 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac } } + h.enrichShadowParents(ctx, []AccountWithConcurrency{item}) + return item } @@ -382,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) @@ -834,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 @@ -1814,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 } diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index eca929fc92..ec6a69280e 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -213,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(), diff --git a/backend/internal/handler/admin/account_handler_spark_shadow_test.go b/backend/internal/handler/admin/account_handler_spark_shadow_test.go new file mode 100644 index 0000000000..cb20a33e7a --- /dev/null +++ b/backend/internal/handler/admin/account_handler_spark_shadow_test.go @@ -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)) +} diff --git a/backend/internal/handler/admin/account_shadow_parent.go b/backend/internal/handler/admin/account_shadow_parent.go new file mode 100644 index 0000000000..f5c50f4609 --- /dev/null +++ b/backend/internal/handler/admin/account_shadow_parent.go @@ -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) +} diff --git a/backend/internal/handler/admin/account_shadow_parent_test.go b/backend/internal/handler/admin/account_shadow_parent_test.go new file mode 100644 index 0000000000..2d75a19c7c --- /dev/null +++ b/backend/internal/handler/admin/account_shadow_parent_test.go @@ -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 } diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 41b9b86289..f7bab13253 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -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) diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index c98eb5abfe..d7a756bd00 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -194,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 { @@ -417,6 +424,43 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { 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) { diff --git a/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go b/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go new file mode 100644 index 0000000000..af3b245c4b --- /dev/null +++ b/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go @@ -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) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 896e5b6beb..10ca454451 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -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 账号有效) diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 8659a1e788..0664619f21 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -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"` diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 2f3a95985d..ac346fea3f 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -509,7 +509,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } } if result != nil { - if account.Type == service.AccountTypeOAuth { + // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 + if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders) } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) @@ -1535,7 +1536,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if result == nil { return } - if account.Type == service.AccountTypeOAuth { + // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 + if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders) } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index 31d245833d..5868f7f35b 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -325,7 +325,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { } } if result != nil { - if account.Type == service.AccountTypeOAuth { + // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 + if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders) } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 3bac6c5cff..314d7229ed 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -132,6 +132,11 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account builder.SetSessionWindowStatus(account.SessionWindowStatus) } + builder.SetQuotaDimension(dbaccount.QuotaDimension(account.QuotaDimensionOrDefault())) + if account.ParentAccountID != nil { + builder.SetParentAccountID(*account.ParentAccountID) + } + created, err := builder.Save(ctx) if err != nil { return translatePersistenceError(err, service.ErrAccountNotFound, nil) @@ -265,7 +270,11 @@ func (r *accountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID } // 使用 sqljson.ValueEQ 生成 JSON 路径过滤,避免手写 SQL 片段导致语法兼容问题。 + // 排除 spark 影子账号(parent_account_id 非空):影子不持凭据,绝不能被 CRS 当作普通账号 + // 更新而覆盖 type/credentials/proxy。即便影子 Extra 被误写入 crs_account_id 也不会命中 + // (外审第7轮 P1)。 m, err := r.client.Account.Query(). + Where(dbaccount.ParentAccountIDIsNil()). Where(func(s *entsql.Selector) { s.Where(sqljson.ValueEQ(dbaccount.FieldExtra, crsAccountID, sqljson.Path("crs_account_id"))) }). @@ -288,10 +297,13 @@ func (r *accountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID } func (r *accountRepository) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + // parent_account_id IS NULL 排除 spark 影子账号:影子不是 CRS 账号,绝不能进 CRS 同步映射 + // (否则会被当普通账号更新而覆盖 type/credentials/proxy)(外审第7轮 P1)。 rows, err := r.sql.QueryContext(ctx, ` SELECT id, extra->>'crs_account_id' FROM accounts WHERE deleted_at IS NULL + AND parent_account_id IS NULL AND extra->>'crs_account_id' IS NOT NULL AND extra->>'crs_account_id' != '' `) @@ -396,6 +408,9 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account builder.ClearNotes() } + builder.SetQuotaDimension(dbaccount.QuotaDimension(account.QuotaDimensionOrDefault())) + builder.SetNillableParentAccountID(account.ParentAccountID) + updated, err := builder.Save(ctx) if err != nil { return translatePersistenceError(err, service.ErrAccountNotFound, nil) @@ -1930,6 +1945,8 @@ func accountEntityToService(m *dbent.Account) *service.Account { SessionWindowStart: m.SessionWindowStart, SessionWindowEnd: m.SessionWindowEnd, SessionWindowStatus: derefString(m.SessionWindowStatus), + ParentAccountID: m.ParentAccountID, + QuotaDimension: string(m.QuotaDimension), } } @@ -2222,3 +2239,21 @@ func (r *accountRepository) RevertProxyFallback(ctx context.Context, accountID i } return nil } + +// ListShadowsByParent 返回指定父账号的影子账号;当前实现仅查 quota_dimension='spark'(唯一预设)。 +// 同时过滤 parent_account_id 和 quota_dimension='spark',防止未来其它 linked 维度被误伤。 +// ⚠️ 新增影子维度时:须更新此函数(或新增维度专用列举),并检查所有调用点(级联删除/一母一影校验/type 守卫),否则会静默漏掉新维度。 +// 软删除行由 SoftDeleteMixin 拦截器自动排除,无需手写 deleted_at IS NULL。 +func (r *accountRepository) ListShadowsByParent(ctx context.Context, parentID int64) ([]*service.Account, error) { + rows, err := r.client.Account.Query(). + Where(dbaccount.ParentAccountIDEQ(parentID), dbaccount.QuotaDimensionEQ(dbaccount.QuotaDimensionSpark)). + All(ctx) + if err != nil { + return nil, err + } + out := make([]*service.Account, 0, len(rows)) + for _, m := range rows { + out = append(out, accountEntityToService(m)) + } + return out, nil +} diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index b216ecd04d..b962ba9190 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -1031,6 +1031,45 @@ func (s *AccountRepoSuite) TestGetByCRSAccountID_EmptyString() { s.Require().Nil(got) } +// TestGetByCRSAccountID_ExcludesSparkShadow 验证外审第7轮 P1:即便 spark 影子的 Extra 被误写入 +// crs_account_id,CRS 查询也绝不能命中影子(否则会被当普通账号更新而覆盖 type/credentials/proxy)。 +func (s *AccountRepoSuite) TestGetByCRSAccountID_ExcludesSparkShadow() { + crsID := "crs-shadow-only-99" + parent := mustCreateAccount(s.T(), s.client, &service.Account{ + Name: "crs-mother", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + }) + mustCreateAccount(s.T(), s.client, &service.Account{ + Name: "crs-shadow", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + ParentAccountID: &parent.ID, + QuotaDimension: service.QuotaDimensionSpark, + Extra: map[string]any{"crs_account_id": crsID}, + }) + + got, err := s.repo.GetByCRSAccountID(s.ctx, crsID) + s.Require().NoError(err) + s.Require().Nil(got, "spark 影子即便带 crs_account_id 也不应被 CRS 命中") +} + +// TestListCRSAccountIDs_ExcludesSparkShadow 验证外审第7轮 P1:影子的 crs_account_id 不应进入 +// CRS 同步映射(否则后续 CRS 同步会把影子当普通账号更新)。 +func (s *AccountRepoSuite) TestListCRSAccountIDs_ExcludesSparkShadow() { + parent := mustCreateAccount(s.T(), s.client, &service.Account{ + Name: "crs-list-mother", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + }) + shadowCRSID := "crs-list-shadow-77" + mustCreateAccount(s.T(), s.client, &service.Account{ + Name: "crs-list-shadow", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + ParentAccountID: &parent.ID, + QuotaDimension: service.QuotaDimensionSpark, + Extra: map[string]any{"crs_account_id": shadowCRSID}, + }) + + ids, err := s.repo.ListCRSAccountIDs(s.ctx) + s.Require().NoError(err) + _, ok := ids[shadowCRSID] + s.Require().False(ok, "影子的 crs_account_id 不应进入 CRS 映射") +} + // --- BulkUpdate --- func (s *AccountRepoSuite) TestBulkUpdate() { diff --git a/backend/internal/repository/account_repo_spark_shadow_test.go b/backend/internal/repository/account_repo_spark_shadow_test.go new file mode 100644 index 0000000000..710f85b809 --- /dev/null +++ b/backend/internal/repository/account_repo_spark_shadow_test.go @@ -0,0 +1,136 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func TestAccountRepoSparkShadowRoundTrip(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + + parent := &service.Account{ + Name: "parent", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + } + if err := repo.Create(ctx, parent); err != nil { + t.Fatalf("create parent: %v", err) + } + pid := parent.ID + shadow := &service.Account{ + Name: "shadow", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + ParentAccountID: &pid, + QuotaDimension: service.QuotaDimensionSpark, + } + if err := repo.Create(ctx, shadow); err != nil { + t.Fatalf("create shadow: %v", err) + } + got, err := repo.GetByID(ctx, shadow.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.ParentAccountID == nil || *got.ParentAccountID != pid { + t.Fatalf("ParentAccountID round-trip: %v", got.ParentAccountID) + } + if got.QuotaDimension != service.QuotaDimensionSpark { + t.Fatalf("QuotaDimension: %q", got.QuotaDimension) + } +} + +func TestListShadowsByParent(t *testing.T) { + // Schema enforces at most one spark shadow per parent (uq_accounts_spark_shadow_per_parent). + // Test strategy: create 2 parents each with 1 spark shadow + 1 unrelated account; + // assert ListShadowsByParent(parent1.ID) returns exactly 1 (filtering by both + // parent_account_id and quota_dimension='spark', excluding parent2's shadow and unrelated). + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + + // Create parent1 and its spark shadow + parent1 := &service.Account{ + Name: "list-parent1", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + } + if err := repo.Create(ctx, parent1); err != nil { + t.Fatalf("create parent1: %v", err) + } + pid1 := parent1.ID + + shadow1 := &service.Account{ + Name: "shadow1", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + ParentAccountID: &pid1, + QuotaDimension: service.QuotaDimensionSpark, + } + if err := repo.Create(ctx, shadow1); err != nil { + t.Fatalf("create shadow1: %v", err) + } + + // Create parent2 and its spark shadow (must NOT appear in parent1's list) + parent2 := &service.Account{ + Name: "list-parent2", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + } + if err := repo.Create(ctx, parent2); err != nil { + t.Fatalf("create parent2: %v", err) + } + pid2 := parent2.ID + + shadow2 := &service.Account{ + Name: "shadow2", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + ParentAccountID: &pid2, + QuotaDimension: service.QuotaDimensionSpark, + } + if err := repo.Create(ctx, shadow2); err != nil { + t.Fatalf("create shadow2: %v", err) + } + + // Create 1 unrelated normal account (no parent, global dimension) + unrelated := &service.Account{ + Name: "unrelated", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + } + if err := repo.Create(ctx, unrelated); err != nil { + t.Fatalf("create unrelated: %v", err) + } + + // Assert ListShadowsByParent returns exactly 1 for parent1 + got, err := repo.ListShadowsByParent(ctx, pid1) + if err != nil { + t.Fatalf("ListShadowsByParent: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 spark shadow for parent1, got %d", len(got)) + } + acc := got[0] + if acc.ParentAccountID == nil || *acc.ParentAccountID != pid1 { + t.Errorf("unexpected ParentAccountID: %v", acc.ParentAccountID) + } + if acc.QuotaDimension != service.QuotaDimensionSpark { + t.Errorf("unexpected QuotaDimension: %q", acc.QuotaDimension) + } + if acc.ID != shadow1.ID { + t.Errorf("expected shadow1.ID=%d, got %d", shadow1.ID, acc.ID) + } +} diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 80b9cab6ab..48c33364c3 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -233,6 +234,12 @@ func mustCreateAccount(t *testing.T, client *dbent.Client, a *service.Account) * 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") diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 921aa081ca..c508f09c71 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -449,6 +449,8 @@ func buildSchedulerMetadataAccount(account service.Account) service.Account { SessionWindowStart: account.SessionWindowStart, SessionWindowEnd: account.SessionWindowEnd, SessionWindowStatus: account.SessionWindowStatus, + ParentAccountID: account.ParentAccountID, + QuotaDimension: account.QuotaDimension, AccountGroups: filterSchedulerAccountGroups(account.AccountGroups), GroupIDs: filterSchedulerGroupIDs(account.GroupIDs, account.AccountGroups), Credentials: filterSchedulerCredentials(account.Credentials), @@ -516,7 +518,7 @@ func filterSchedulerCredentials(credentials map[string]any) map[string]any { if len(credentials) == 0 { return nil } - keys := []string{"model_mapping", "api_key", "project_id", "oauth_type"} + keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type"} filtered := make(map[string]any) for _, key := range keys { if value, ok := credentials[key]; ok && value != nil { diff --git a/backend/internal/repository/scheduler_cache_unit_test.go b/backend/internal/repository/scheduler_cache_unit_test.go index c14721cd7c..19c4cc4f36 100644 --- a/backend/internal/repository/scheduler_cache_unit_test.go +++ b/backend/internal/repository/scheduler_cache_unit_test.go @@ -134,3 +134,32 @@ func TestBuildSchedulerMetadataAccount_KeepsModelRateLimits(t *testing.T) { require.Contains(t, limits, "antigravity:gemini") require.Nil(t, got.Extra["unused_large_field"]) } + +func TestBuildSchedulerMetadataAccount_KeepsSparkShadowRoutingIdentity(t *testing.T) { + parentID := int64(100) + account := service.Account{ + ID: 200, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + ParentAccountID: &parentID, + QuotaDimension: service.QuotaDimensionSpark, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", + }, + "compact_model_mapping": map[string]any{ + "gpt-5.4": "gpt-5.4-openai-compact", + }, + "access_token": "drop-me", + }, + } + + got := buildSchedulerMetadataAccount(account) + + require.NotNil(t, got.ParentAccountID) + require.Equal(t, parentID, *got.ParentAccountID) + require.Equal(t, service.QuotaDimensionSpark, got.QuotaDimension) + require.Equal(t, map[string]any{"gpt-5.3-codex-spark": "gpt-5.3-codex-spark"}, got.Credentials["model_mapping"]) + require.Equal(t, map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"}, got.Credentials["compact_model_mapping"]) + require.Nil(t, got.Credentials["access_token"]) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 4b4e5e0b04..c8463da39d 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1733,6 +1733,10 @@ func (s *stubAccountRepo) BindGroups(ctx context.Context, accountID int64, group return errors.New("not implemented") } +func (s *stubAccountRepo) ListShadowsByParent(ctx context.Context, parentID int64) ([]*service.Account, error) { + return nil, errors.New("not implemented") +} + func (s *stubAccountRepo) ListSchedulable(ctx context.Context) ([]service.Account, error) { return nil, errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 8728ea2c74..d7b100c6dc 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -335,6 +335,9 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // Antigravity 默认模型映射 accounts.GET("/antigravity/default-model-mapping", h.Admin.Account.GetAntigravityDefaultModelMapping) + // Spark 影子账号 + accounts.POST("/:id/shadow", h.Admin.OpenAIOAuth.CreateShadow) + // Claude OAuth routes accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL) accounts.POST("/generate-setup-token-url", h.Admin.OAuth.GenerateSetupTokenURL) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ffd73d2d80..7f68da46c6 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -55,6 +55,9 @@ type Account struct { SessionWindowEnd *time.Time SessionWindowStatus string + ParentAccountID *int64 // non-nil → 影子账号(不持凭据,透传母账号凭据) + QuotaDimension string // 用量维度:"" / "global" / "spark" + Proxy *Proxy AccountGroups []AccountGroup GroupIDs []int64 @@ -154,6 +157,32 @@ func (a *Account) IsSchedulable() bool { return true } +// IsCredentialUsableForShadow 报告本账号(作为某 spark 影子的母账号)的凭据/传输是否可被影子透传使用。 +// +// 检查「凭据/账号/传输可用性」: +// - 账号 active(非禁用/删除); +// - OAuth token 未过期(AutoPauseOnExpired+ExpiresAt); +// - 未处于 TempUnschedulableUntil 冷却期 —— 对 OpenAI 账号该字段由 401 鉴权失败 / +// token 刷新耗尽 / transport·proxy 故障写入(ratelimit/token_refresh/upstream_transport), +// 都代表**共享凭据或传输通道坏死**;影子共享母 token+proxy,故母处于该冷却期时影子也不可用。 +// +// **刻意排除** global 维度的限流/过载窗口(RateLimitResetAt / OverloadUntil)与母账号自身的 +// 手动 Schedulable 开关:spark 影子拥有独立 spark 配额窗口,母账号 global 429(走 RateLimitResetAt) +// 不应连坐 spark(否则重新耦合影子架构本应解耦的两条 429 道)。nil receiver 返回 false。 +func (a *Account) IsCredentialUsableForShadow() bool { + if a == nil || !a.IsActive() { + return false + } + now := time.Now() + if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) { + return false + } + if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) { + return false + } + return true +} + func (a *Account) IsRateLimited() bool { if a.RateLimitResetAt == nil { return false @@ -2541,3 +2570,17 @@ func parseExtraInt(value any) int { } return 0 } + +// IsShadow 报告账号是否为影子账号(parent_account_id 非空;当前唯一预设是 spark 维度)。 +func (a *Account) IsShadow() bool { return a != nil && a.ParentAccountID != nil } + +// IsCredentialShadow 语义别名,供「凭据消费者跳过影子」处使用(管理/后台 OAuth 路径)。 +func (a *Account) IsCredentialShadow() bool { return a.IsShadow() } + +// QuotaDimensionOrDefault 返回账号的用量维度,未设置时回退 "global"。 +func (a *Account) QuotaDimensionOrDefault() string { + if a == nil || strings.TrimSpace(a.QuotaDimension) == "" { + return QuotaDimensionGlobal + } + return a.QuotaDimension +} diff --git a/backend/internal/service/account_credential_shadow_skip_test.go b/backend/internal/service/account_credential_shadow_skip_test.go new file mode 100644 index 0000000000..c89c0c3a10 --- /dev/null +++ b/backend/internal/service/account_credential_shadow_skip_test.go @@ -0,0 +1,96 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/imroc/req/v3" + "github.com/stretchr/testify/require" +) + +// shadowSkipTestRepo 是满足 AccountRepository 接口的最小 stub(只实现 GetByID)。 +// 其他方法通过嵌入 nil 接口值满足编译,若被误调则 panic,便于发现意外调用路径。 +type shadowSkipTestRepo struct { + AccountRepository + account *Account +} + +func (r *shadowSkipTestRepo) GetByID(_ context.Context, id int64) (*Account, error) { + if r.account == nil || r.account.ID != id { + return nil, ErrAccountNotFound + } + return r.account, nil +} + +func newShadowTestGinCtx() *gin.Context { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/200/test", nil) + return c +} + +// --- 1. CanRefresh 守卫 --- + +// TestOpenAITokenRefresherSkipsShadow 验证影子账号不被后台 token 刷新器处理。 +func TestOpenAITokenRefresherSkipsShadow(t *testing.T) { + pid := int64(100) + r := NewOpenAITokenRefresher(nil, nil) + // 影子账号:ParentAccountID 非 nil → CanRefresh 应返回 false + require.False(t, r.CanRefresh(&Account{ID: 200, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &pid})) + // 普通账号:有 refresh_token → CanRefresh 应返回 true + require.True(t, r.CanRefresh(&Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"refresh_token": "RT"}})) +} + +// --- 2. TestAccountConnection 影子凭据解析 --- + +// TestAccountTestServiceSkipsShadow 验证影子账号连接测试不再早拒,而是尝试解析母账号凭据。 +func TestAccountTestServiceSkipsShadow(t *testing.T) { + pid := int64(100) + shadow := &Account{ + ID: 200, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &pid, + } + repo := &shadowSkipTestRepo{account: shadow} + svc := &AccountTestService{accountRepo: repo} + c := newShadowTestGinCtx() + + err := svc.TestAccountConnection(c, 200, "", "", "") + require.Error(t, err) + require.Contains(t, err.Error(), "resolve spark shadow parent") +} + +// --- 3. EnsureOpenAIPrivacy 守卫 --- + +// TestEnsureOpenAIPrivacySkipsShadow 验证影子账号跳过隐私设置(不调用 privacyClientFactory)。 +// 影子账号透传母账号凭据,但 Extra 通常为空,需给它一个 access_token 才能让 +// 现有的 token=="" 提前返回路径失效,从而真实验证 IsCredentialShadow 守卫。 +func TestEnsureOpenAIPrivacySkipsShadow(t *testing.T) { + pid := int64(100) + shadow := &Account{ + ID: 200, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &pid, + // 提供 access_token:没有影子守卫时会进入 factory 调用 + Credentials: map[string]any{"access_token": "shadow-passthrough-token"}, + } + privacyCalled := false + svc := &adminServiceImpl{ + privacyClientFactory: func(proxyURL string) (*req.Client, error) { + privacyCalled = true + return nil, errors.New("should not reach factory for shadow account") + }, + } + got := svc.EnsureOpenAIPrivacy(context.Background(), shadow) + require.Equal(t, "", got) + require.False(t, privacyCalled, "privacyClientFactory 不应被影子账号触发") +} diff --git a/backend/internal/service/account_credentials_persistence.go b/backend/internal/service/account_credentials_persistence.go index 916df5366c..e4ad7688a4 100644 --- a/backend/internal/service/account_credentials_persistence.go +++ b/backend/internal/service/account_credentials_persistence.go @@ -1,6 +1,9 @@ package service -import "context" +import ( + "context" + "log/slog" +) type accountCredentialsUpdater interface { UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error @@ -11,20 +14,50 @@ func persistAccountCredentials(ctx context.Context, repo AccountRepository, acco return nil } - account.Credentials = cloneCredentials(credentials) + // 安全不变量:spark 影子账号恒不持凭据(凭据透传母账号)。这是凭据写入的唯一汇聚点 + // (token 刷新 / 订阅补全 / CRS 创建后刷新等全部经此),在此对影子早返 no-op 是 + // defense-in-depth——即便某条上游路径漏判,也不会把凭据落到影子行(外审第6轮 P1)。 + if account.IsCredentialShadow() { + slog.Warn("skip persisting credentials to spark shadow account", + "account_id", account.ID, "parent_id", *account.ParentAccountID) + return nil + } + + account.Credentials = shallowCopyMap(credentials) if updater, ok := any(repo).(accountCredentialsUpdater); ok { return updater.UpdateCredentials(ctx, account.ID, account.Credentials) } return repo.Update(ctx, account) } -func cloneCredentials(in map[string]any) map[string]any { - if in == nil { +// sparkShadowAllowedCredentialKeys 是 spark 影子账号唯一可写的凭据键集合(仅模型映射)。 +// 校验(isAllowed)与 sanitize 共用此单一来源,避免两处独立硬编码列表漂移。 +var sparkShadowAllowedCredentialKeys = map[string]struct{}{ + "model_mapping": {}, + "compact_model_mapping": {}, +} + +func isAllowedSparkShadowCredentialsUpdate(credentials map[string]any) bool { + if credentials == nil { + return true + } + for key := range credentials { + if _, ok := sparkShadowAllowedCredentialKeys[key]; !ok { + return false + } + } + return true +} + +func sanitizeSparkShadowCredentials(credentials map[string]any) map[string]any { + if len(credentials) == 0 { return map[string]any{} } - out := make(map[string]any, len(in)) - for k, v := range in { - out[k] = v + out := make(map[string]any, len(sparkShadowAllowedCredentialKeys)) + for key := range sparkShadowAllowedCredentialKeys { + if value, ok := credentials[key]; ok && value != nil { + out[key] = value + } } return out } diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index de3e9e7d5a..dcba614c2c 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -82,6 +82,9 @@ type AccountRepository interface { // RevertProxyFallback 将账号的 proxy_id 切回 proxy_fallback_origin_id,并清空 origin 字段。 // 仅当 proxy_fallback_origin_id IS NOT NULL 时更新,否则视为账号不存在(返回 ErrAccountNotFound)。 RevertProxyFallback(ctx context.Context, accountID int64) error + // ListShadowsByParent 返回指定父账号的影子账号;当前实现仅查 quota_dimension='spark'(唯一预设)。 + // ⚠️ 新增影子维度时:须更新此函数(或新增维度专用列举),并检查所有调用点(级联删除/一母一影校验/type 守卫),否则会静默漏掉新维度。 + ListShadowsByParent(ctx context.Context, parentID int64) ([]*Account, error) } // AccountBulkUpdate describes the fields that can be updated in a bulk operation. @@ -334,6 +337,9 @@ func (s *AccountService) Delete(ctx context.Context, id int64) error { return ErrAccountNotFound } + // 注意:此处不级联删除 spark 影子账号。当前唯一的后台删除入口走 AdminService.DeleteAccount + // (已 ListShadowsByParent 先删影子再删母)。本方法目前无删除调用方;若未来有调用方经此 + // 删除母账号,需在此补级联,否则会留下孤儿影子(外审第6轮 P3:当前不可达,记为残留)。 if err := s.accountRepo.Delete(ctx, id); err != nil { return fmt.Errorf("delete account: %w", err) } diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index e1b6d2ffce..a304356c09 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -215,6 +215,10 @@ func (s *accountRepoStub) RevertProxyFallback(ctx context.Context, accountID int panic("unexpected RevertProxyFallback call") } +func (s *accountRepoStub) ListShadowsByParent(ctx context.Context, parentID int64) ([]*Account, error) { + return nil, nil +} + // TestAccountService_Delete_NotFound 测试删除不存在的账号时返回正确的错误。 // 预期行为: // - ExistsByID 返回 false(账号不存在) diff --git a/backend/internal/service/account_spark_shadow_test.go b/backend/internal/service/account_spark_shadow_test.go new file mode 100644 index 0000000000..856df35fdc --- /dev/null +++ b/backend/internal/service/account_spark_shadow_test.go @@ -0,0 +1,19 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAccountSparkShadowHelpers(t *testing.T) { + pid := int64(100) + normal := &Account{ID: 100} + require.False(t, normal.IsShadow()) + require.False(t, normal.IsCredentialShadow()) + require.Equal(t, QuotaDimensionGlobal, normal.QuotaDimensionOrDefault()) + shadow := &Account{ID: 200, ParentAccountID: &pid, QuotaDimension: QuotaDimensionSpark} + require.True(t, shadow.IsShadow()) + require.True(t, shadow.IsCredentialShadow()) + require.Equal(t, QuotaDimensionSpark, shadow.QuotaDimensionOrDefault()) +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index cc25943d27..7eecc1f86f 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -530,29 +530,38 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account return s.testOpenAIImageOAuth(c, ctx, account, testModelID, imagePrompt) } + credentialAccount := account + if account.IsCredentialShadow() { + resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } + credentialAccount = resolved + } + // Determine authentication method and API URL var authToken string var apiURL string var isOAuth bool - if account.IsOAuth() { + if credentialAccount.IsOAuth() { isOAuth = true // OAuth - use Bearer token with ChatGPT internal API - authToken = account.GetOpenAIAccessToken() + authToken = credentialAccount.GetOpenAIAccessToken() if authToken == "" { return s.sendErrorAndEnd(c, "No access token available") } // OAuth uses ChatGPT internal API apiURL = chatgptCodexAPIURL - } else if account.Type == "apikey" { + } else if credentialAccount.Type == "apikey" { // API Key - use Platform API - authToken = account.GetOpenAIApiKey() + authToken = credentialAccount.GetOpenAIApiKey() if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } - baseURL := account.GetOpenAIBaseURL() + baseURL := credentialAccount.GetOpenAIBaseURL() if baseURL == "" { baseURL = "https://api.openai.com" } @@ -596,7 +605,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account if isOAuth { req.Host = "chatgpt.com" req.Header.Set("accept", "text/event-stream") - setOpenAIChatGPTAccountHeaders(req.Header, account) + setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) } // Get proxy URL diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 910567fb25..af28085123 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -137,6 +137,64 @@ func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing. require.Contains(t, recorder.Body.String(), "test_complete") } +func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newTestContext() + + resp := newJSONResponse(http.StatusOK, "") + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + +`)) + + parentID := int64(100) + parent := &Account{ + ID: parentID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "access_token": "parent-token", + "chatgpt_account_id": "org-parent", + }, + } + shadow := &Account{ + ID: 200, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + Concurrency: 2, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", + }, + }, + } + + repo := &openAIAccountTestRepo{ + mockAccountRepoForGemini: mockAccountRepoForGemini{ + accountsByID: map[int64]*Account{ + parentID: parent, + 200: shadow, + }, + }, + } + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} + + err := svc.TestAccountConnection(ctx, shadow.ID, "gpt-5.3-codex-spark", "", "") + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + req := upstream.requests[0] + require.Equal(t, "Bearer parent-token", req.Header.Get("Authorization")) + require.Equal(t, "org-parent", req.Header.Get("chatgpt-account-id")) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, "gpt-5.3-codex-spark", gjson.GetBytes(body, "model").String()) + require.Contains(t, recorder.Body.String(), `"success":true`) +} + func TestAccountTestService_OpenAIStreamEOFBeforeCompletedFails(t *testing.T) { gin.SetMode(gin.TestMode) ctx, recorder := newTestContext() diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 2963d996ac..3c50baaec2 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -276,6 +276,7 @@ type AccountUsageService struct { geminiQuotaService *GeminiQuotaService antigravityQuotaFetcher *AntigravityQuotaFetcher grokQuotaFetcher *GrokQuotaFetcher + openAIQuotaService *OpenAIQuotaService cache *UsageCache identityCache IdentityCache tlsFPProfileService *TLSFingerprintProfileService @@ -289,6 +290,7 @@ func NewAccountUsageService( geminiQuotaService *GeminiQuotaService, antigravityQuotaFetcher *AntigravityQuotaFetcher, grokQuotaFetcher *GrokQuotaFetcher, + openAIQuotaService *OpenAIQuotaService, cache *UsageCache, identityCache IdentityCache, tlsFPProfileService *TLSFingerprintProfileService, @@ -300,6 +302,7 @@ func NewAccountUsageService( geminiQuotaService: geminiQuotaService, antigravityQuotaFetcher: antigravityQuotaFetcher, grokQuotaFetcher: grokQuotaFetcher, + openAIQuotaService: openAIQuotaService, cache: cache, identityCache: identityCache, tlsFPProfileService: tlsFPProfileService, @@ -533,24 +536,33 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou return usage, nil } - if progress := buildCodexUsageProgressFromExtra(account.Extra, "5h", now); progress != nil { - usage.FiveHour = progress - } - if progress := buildCodexUsageProgressFromExtra(account.Extra, "7d", now); progress != nil { - usage.SevenDay = progress - } + applyExtraToUsage(usage, account.Extra, now) if (force || shouldRefreshOpenAICodexSnapshot(account, usage, now)) && s.shouldProbeOpenAICodexSnapshot(account.ID, now, force) { - if updates, err := s.probeOpenAICodexSnapshot(ctx, account); err == nil && len(updates) > 0 { - mergeAccountExtra(account, updates) - if usage.UpdatedAt == nil { - usage.UpdatedAt = &now + if account.IsShadow() { + // Spark shadow accounts fetch usage from /wham/usage (bengalfox channel) + // via the shared OpenAIQuotaService, which resolves credentials from the + // parent account. The result is written to the shadow row's own codex_* + // Extra keys and immediately reflected in the returned UsageInfo. + if s.openAIQuotaService != nil { + if quotaUsage, err := s.openAIQuotaService.QueryUsage(ctx, account.ID); err == nil { + if updates := buildCodexSparkWindowExtraUpdates(quotaUsage, now); len(updates) > 0 { + mergeAccountExtra(account, updates) + s.persistOpenAICodexProbeSnapshot(account.ID, updates) + if usage.UpdatedAt == nil { + usage.UpdatedAt = &now + } + applyExtraToUsage(usage, account.Extra, now) + } + } } - if progress := buildCodexUsageProgressFromExtra(account.Extra, "5h", now); progress != nil { - usage.FiveHour = progress - } - if progress := buildCodexUsageProgressFromExtra(account.Extra, "7d", now); progress != nil { - usage.SevenDay = progress + } else { + if updates, err := s.probeOpenAICodexSnapshot(ctx, account); err == nil && len(updates) > 0 { + mergeAccountExtra(account, updates) + if usage.UpdatedAt == nil { + usage.UpdatedAt = &now + } + applyExtraToUsage(usage, account.Extra, now) } } } @@ -593,7 +605,14 @@ func shouldRefreshOpenAICodexSnapshot(account *Account, usage *UsageInfo, now ti } func isOpenAICodexSnapshotStale(account *Account, now time.Time) bool { - if account == nil || !account.IsOpenAIOAuth() || !account.IsOpenAIResponsesWebSocketV2Enabled() { + if account == nil || !account.IsOpenAIOAuth() { + return false + } + // 普通账号的 codex 刷新走 probe(/responses 头),要求 WSv2;但 spark 影子走 QueryUsage + // (/wham/usage body 的 codex_bengalfox),与 WSv2 无关——不能用 WSv2 门控其 staleness,否则首刷后 + // codex_5h/7d 已存在→staleness 恒 false→spark 窗口永久冻结(外审第9轮 P1)。影子改按 + // codex_usage_updated_at TTL 判定;实际查询频率仍由 shouldProbeOpenAICodexSnapshot 的缓存 TTL 节流。 + if !account.IsShadow() && !account.IsOpenAIResponsesWebSocketV2Enabled() { return false } if account.Extra == nil { @@ -731,6 +750,21 @@ func mergeAccountExtra(account *Account, updates map[string]any) { } } +// applyExtraToUsage rebuilds the codex 5h/7d windows in usage from the +// account's Extra map. Called after mergeAccountExtra to make the in-memory +// UsageInfo consistent with the just-persisted Extra values. +func applyExtraToUsage(usage *UsageInfo, extra map[string]any, now time.Time) { + if usage == nil { + return + } + if progress := buildCodexUsageProgressFromExtra(extra, "5h", now); progress != nil { + usage.FiveHour = progress + } + if progress := buildCodexUsageProgressFromExtra(extra, "7d", now); progress != nil { + usage.SevenDay = progress + } +} + func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Account) (*UsageInfo, error) { now := time.Now() usage := &UsageInfo{ diff --git a/backend/internal/service/account_usage_service_spark_shadow_test.go b/backend/internal/service/account_usage_service_spark_shadow_test.go new file mode 100644 index 0000000000..8aa131f03a --- /dev/null +++ b/backend/internal/service/account_usage_service_spark_shadow_test.go @@ -0,0 +1,151 @@ +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)") +} diff --git a/backend/internal/service/account_usage_service_test.go b/backend/internal/service/account_usage_service_test.go index e0390c4c2a..ca153a3c42 100644 --- a/backend/internal/service/account_usage_service_test.go +++ b/backend/internal/service/account_usage_service_test.go @@ -66,6 +66,55 @@ func TestShouldRefreshOpenAICodexSnapshot(t *testing.T) { } } +// TestShouldRefreshOpenAICodexSnapshot_SparkShadowIgnoresWSv2 外审第9轮 P1:spark 影子用量走 +// QueryUsage(/wham/usage,与 WSv2 无关),staleness 不得被 WSv2 门控,否则首刷后窗口永久冻结。 +func TestShouldRefreshOpenAICodexSnapshot_SparkShadowIgnoresWSv2(t *testing.T) { + t.Parallel() + + now := time.Now() + usage := &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 0}, + SevenDay: &UsageProgress{Utilization: 0}, + } + staleAt := now.Add(-(openAIProbeCacheTTL + time.Minute)).Format(time.RFC3339) + freshAt := now.Add(-time.Minute).Format(time.RFC3339) + parentID := int64(7001) + + // 影子无 WSv2,但首刷后窗口已存在;过期 codex_usage_updated_at 必须触发再刷新。 + shadowStale := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + Extra: map[string]any{"codex_usage_updated_at": staleAt}, + } + if !shouldRefreshOpenAICodexSnapshot(shadowStale, usage, now) { + t.Fatal("expected stale spark shadow (no WSv2) to trigger refresh") + } + + // 影子时间戳仍新鲜→不刷(TTL 生效)。 + shadowFresh := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + Extra: map[string]any{"codex_usage_updated_at": freshAt}, + } + if shouldRefreshOpenAICodexSnapshot(shadowFresh, usage, now) { + t.Fatal("expected fresh spark shadow to skip refresh (TTL not elapsed)") + } + + // 反向对照:普通账号无 WSv2 + 过期时间戳→仍不刷(WSv2 门控普通账号的 probe 刷新)。 + normalNoWS := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_usage_updated_at": staleAt}, + } + if shouldRefreshOpenAICodexSnapshot(normalNoWS, usage, now) { + t.Fatal("expected non-WSv2 normal account to skip codex probe refresh") + } +} + func TestExtractOpenAICodexProbeUpdatesAccepts429WithCodexHeaders(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 7a5637dc75..8e38080c68 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -103,6 +103,9 @@ type AdminService interface { // RevertAccountProxyFallback 将账号的 proxy_id 切回 proxy_fallback_origin_id,并清空 origin 字段。 // 若账号不存在返回 ErrAccountNotFound;若账号存在但不在 fallback 状态,返回 ErrAccountNotInFallback。 RevertAccountProxyFallback(ctx context.Context, id int64) error + // CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。 + // 影子账号不持凭据(Credentials 恒为空),透传母账号凭据;继承母账号的 ProxyID。 + CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error) // Proxy management ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error) @@ -298,6 +301,15 @@ type CreateAccountInput struct { SkipMixedChannelCheck bool } +// ShadowOptions is the input for CreateShadow. +// The shadow holds no credentials — the scheduler transparently delegates to the parent account's tokens. +type ShadowOptions struct { + Name string + Priority int + Concurrency int + GroupIDs []int64 +} + type UpdateAccountInput struct { Name string Notes *string @@ -2694,6 +2706,33 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err != nil { return nil, err } + // 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用, + // 必须在此守住,否则仅在创建时的保证可被这些路径绕过。 + if account.IsCredentialShadow() { + // 影子绝不持有凭据(凭据只在母账号)——外审 F5。 + if !isAllowedSparkShadowCredentialsUpdate(input.Credentials) { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", + "spark shadow accounts do not hold auth credentials; only model mapping can be configured on the shadow account") + } + // 影子 type 不可变——很多上游逻辑按 account.Type 分支(OAuth transform / ChatGPT + // header 注入 / WS OAuth 决策),改成 apikey 会让 spark 影子被选中后按错误协议转发(外审 G7)。 + if input.Type != "" && input.Type != account.Type { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_IMMUTABLE_TYPE", + "spark shadow account type cannot be changed; it must remain an OpenAI OAuth shadow") + } + } else if input.Type != "" && input.Type != account.Type && input.Type != AccountTypeOAuth { + // 母账号守卫(外审 D/P1):有 spark 影子的账号不能把 type 改出 OpenAI OAuth——影子读透母 + // 凭据,母变成 apikey/setup_token 会让影子被调度后按错协议失败(resolveCredentialAccount + // 必报错)。须先删影子再改 type。 + shadows, serr := s.accountRepo.ListShadowsByParent(ctx, id) + if serr != nil { + return nil, serr + } + if len(shadows) > 0 { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IMMUTABLE_TYPE", + "cannot change account type while it has a spark shadow; delete the shadow first") + } + } wasOveragesEnabled := account.IsOveragesEnabled() if input.Name != "" { @@ -2705,7 +2744,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if input.Notes != nil { account.Notes = normalizeAccountNotes(input.Notes) } - if len(input.Credentials) > 0 { + if account.IsCredentialShadow() && input.Credentials != nil { + account.Credentials = sanitizeSparkShadowCredentials(input.Credentials) + } else if len(input.Credentials) > 0 { // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) @@ -2738,7 +2779,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U ComputeQuotaResetAt(account.Extra) NormalizeFixedQuotaWindows(account.Extra) } - if input.ProxyID != nil { + // 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1; + // 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。 + if input.ProxyID != nil && !account.IsCredentialShadow() { // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) if *input.ProxyID == 0 { account.ProxyID = nil @@ -2803,6 +2846,14 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U return nil, err } + // 将 proxy 变更传播到 spark 影子账号(同步;Update 内部已触发调度快照)。 + // 影子自身 proxy 不可独立编辑(见上),故对影子的更新不触发传播。 + if input.ProxyID != nil && !account.IsCredentialShadow() { + if err := s.propagateProxyToShadows(ctx, id, account.ProxyID); err != nil { + return nil, err + } + } + // 绑定分组 if input.GroupIDs != nil { if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil { @@ -2855,14 +2906,43 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck - // 预加载账号平台信息(混合渠道检查需要)。 - platformByID := map[int64]string{} - if needMixedChannelCheck { - accounts, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) + // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。 + var cachedTargets []*Account + if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck { + loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) if err != nil { return nil, err } - for _, account := range accounts { + cachedTargets = loaded + } + + // 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号 + // UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。 + if len(input.Credentials) > 0 { + for _, acc := range cachedTargets { + if acc != nil && acc.IsCredentialShadow() { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", + "spark shadow account %d cannot hold credentials; manage credentials on the parent account", acc.ID) + } + } + } + + // 影子账号 proxy 恒继承母账号(与单账号 UpdateAccount 守卫对齐——外审第4轮 P1):批量携带 proxy + // 时目标不得含影子,否则影子会获得独立 proxy、破坏继承不变量(网关按所选影子自身 proxy 出站, + // 要等母账号下次改 proxy 才覆盖→漂移)。含影子即整体拒绝,提示从选择中剔除影子。 + if input.ProxyID != nil { + for _, acc := range cachedTargets { + if acc != nil && acc.IsCredentialShadow() { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_PROXY_INHERITED", + "spark shadow account %d proxy is inherited from its parent and cannot be set in bulk; manage it on the parent account", acc.ID) + } + } + } + + // 预加载账号平台信息(混合渠道检查需要)。 + platformByID := map[int64]string{} + if needMixedChannelCheck { + for _, account := range cachedTargets { if account != nil { platformByID[account.ID] = account.Platform } @@ -2929,6 +3009,19 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp return nil, err } + // 将 proxy 变更传播到每个目标账号的 spark 影子账号 + if repoUpdates.ProxyID != nil { + var effectiveProxyID *int64 + if *repoUpdates.ProxyID != 0 { + effectiveProxyID = repoUpdates.ProxyID + } + for _, accountID := range input.AccountIDs { + if err := s.propagateProxyToShadows(ctx, accountID, effectiveProxyID); err != nil { + return nil, err + } + } + } + // Handle group bindings per account (requires individual operations). for _, accountID := range input.AccountIDs { entry := BulkUpdateAccountResult{AccountID: accountID} @@ -3003,6 +3096,16 @@ func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filte } func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error { + // 级联删除 spark 影子账号(先删影子,再删母账号) + shadows, err := s.accountRepo.ListShadowsByParent(ctx, id) + if err != nil { + return fmt.Errorf("list spark shadows for cascade delete: %w", err) + } + for _, shadow := range shadows { + if err := s.accountRepo.Delete(ctx, shadow.ID); err != nil { + return fmt.Errorf("cascade delete spark shadow %d: %w", shadow.ID, err) + } + } if err := s.accountRepo.Delete(ctx, id); err != nil { return err } @@ -3056,7 +3159,159 @@ func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64, } func (s *adminServiceImpl) RevertAccountProxyFallback(ctx context.Context, id int64) error { - return s.accountRepo.RevertProxyFallback(ctx, id) + if err := s.accountRepo.RevertProxyFallback(ctx, id); err != nil { + return err + } + // 加载回退后的账号以获取实际 ProxyID,再传播到影子账号 + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("get account after proxy revert: %w", err) + } + return s.propagateProxyToShadows(ctx, id, account.ProxyID) +} + +// CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。 +// 安全不变量:Credentials 恒不含 auth token(仅 model_mapping,守卫 isAllowedSparkShadowCredentialsUpdate 放行)。 +func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error) { + // 1. 加载母账号并校验平台/类型 + parent, err := s.accountRepo.GetByID(ctx, parentID) + if err != nil { + return nil, fmt.Errorf("get parent account: %w", err) + } + if !parent.IsOpenAIOAuth() { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_INVALID_PARENT", + "spark shadow requires an OpenAI OAuth parent account") + } + // G6:母账号本身不能是影子,否则会建出二级影子——resolveCredentialAccount 只解一层, + // 会解析到无凭据的一级影子,进入坏调度/上游失败。 + if parent.IsCredentialShadow() { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IS_SHADOW", + "spark shadow parent must be a real account, not another spark shadow") + } + + // 2. 一母一影校验 + shadows, err := s.accountRepo.ListShadowsByParent(ctx, parentID) + if err != nil { + return nil, fmt.Errorf("check existing spark shadows: %w", err) + } + if len(shadows) > 0 { + return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", + "parent account already has a spark shadow account") + } + + // 3. 解析分组。未指定 GroupIDs 时:优先**继承母账号当前分组**(影子与母同路由域,母在自定义 + // 组时该组的 spark 请求也能选到影子;G1 决策);母无分组再回落 openai-default(F4)。 + // 显式指定 GroupIDs 时,与 UpdateAccount 对齐先校验存在性(创建前),避免建出影子后再因无效组 + // 失败而留下孤儿影子(一母一影唯一索引会挡住重试)——外审 C/P1。 + groupIDs := opts.GroupIDs + if len(groupIDs) > 0 { + if s.groupRepo != nil { + if err := s.validateGroupIDsExist(ctx, groupIDs); err != nil { + return nil, err + } + } + } else if len(parent.GroupIDs) > 0 { + groupIDs = append([]int64(nil), parent.GroupIDs...) + } else if s.groupRepo != nil { + defaultGroupName := PlatformOpenAI + "-default" + if groups, gerr := s.groupRepo.ListActiveByPlatform(ctx, PlatformOpenAI); gerr == nil { + for _, g := range groups { + if g.Name == defaultGroupName { + groupIDs = []int64{g.ID} + break + } + } + } + } + + // 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。 + // name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500 + // (外审 E/P2);并 rune 安全截断到 ent MaxLen(100)。 + name := strings.TrimSpace(opts.Name) + if name == "" { + name = parent.Name + " (Spark)" + } + if runes := []rune(name); len(runes) > 100 { + name = string(runes[:100]) + } + // 并发未指定(<=0)时继承母账号,避免 0 被限流器解读为"无限并发"(外审 F3)。 + concurrency := opts.Concurrency + if concurrency <= 0 { + concurrency = parent.Concurrency + } + // 优先级未指定(<=0)时继承母账号——前端一键创建只传 name,opts.Priority 省略即 0,而调度 + // 比较是「数值越小越优先」(openai_account_scheduler.isOpenAIAccountCandidateBetter),且 repo + // 显式 SetPriority 会绕过 ent 默认 50,直写 0 会让影子意外抢到最高优先级(外审第5轮 P1)。 + // 与上方 Concurrency 一致采用「省略继承母账号」语义(影子的 proxy/分组/并发亦全部继承母账号)。 + priority := opts.Priority + if priority <= 0 { + priority = parent.Priority + } + shadow := &Account{ + Name: name, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{"model_mapping": defaultSparkShadowModelMapping()}, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + ProxyID: parent.ProxyID, + Priority: priority, + Concurrency: concurrency, + Schedulable: true, + } + + // 5. 持久化(Create 填充 shadow.ID)。并发竞态:预查(步骤2)放行后另一请求抢先建成,本次会撞 + // 一母一影唯一索引。复查确认确为"已存在"竞态时返回结构化 409 而非裸 500——外审 A/P1。 + if err := s.accountRepo.Create(ctx, shadow); err != nil { + if existing, qerr := s.accountRepo.ListShadowsByParent(ctx, parentID); qerr == nil && len(existing) > 0 { + return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", + "parent account already has a spark shadow account") + } + return nil, fmt.Errorf("create spark shadow: %w", err) + } + + // 6. 绑定分组。注意:create+bind 非单一 DB 事务(通用 Create 走 r.client、outbox 走 r.sql, + // 无现成共享事务路径),故绑组失败时做 best-effort 补偿删除刚建的影子,避免半成品影子(否则 + // 一母一影唯一索引会挡住重试)——外审 C/P1。补偿删除用 detached ctx,即便请求 ctx 已取消/超时 + // 仍能完成清理(外审第4轮);进程崩溃这种极端仍可能残留,属已知权衡。 + if len(groupIDs) > 0 { + if err := s.accountRepo.BindGroups(ctx, shadow.ID, groupIDs); err != nil { + if delErr := s.accountRepo.Delete(context.WithoutCancel(ctx), shadow.ID); delErr != nil { + slog.Error("spark_shadow_bind_groups_rollback_failed", + "shadow_id", shadow.ID, "parent_id", parentID, "delete_err", delErr) + } + return nil, fmt.Errorf("bind groups for spark shadow: %w", err) + } + shadow.GroupIDs = groupIDs + } + + return shadow, nil +} + +// propagateProxyToShadows syncs proxyID to all spark shadow accounts of parentID. +// It is called synchronously so that proxy changes are immediately consistent; +// accountRepo.Update triggers the scheduler outbox + cache propagation internally. +// Calling this for a non-parent account is a harmless no-op. +func (s *adminServiceImpl) propagateProxyToShadows(ctx context.Context, parentID int64, proxyID *int64) error { + return propagateAccountProxyToShadows(ctx, s.accountRepo, parentID, proxyID) +} + +// propagateAccountProxyToShadows 把母账号的 proxy 同步到其所有 spark 影子(影子 proxy 恒继承母账号)。 +// 供 AdminService 编辑路径与 CRS 同步路径共用——后者改动母账号 proxy 后必须同样传播,否则影子保留 +// 旧 proxy 出现出站漂移(外审第8轮)。 +func propagateAccountProxyToShadows(ctx context.Context, repo AccountRepository, parentID int64, proxyID *int64) error { + shadows, err := repo.ListShadowsByParent(ctx, parentID) + if err != nil { + return fmt.Errorf("list spark shadows for proxy propagation: %w", err) + } + for _, shadow := range shadows { + shadow.ProxyID = proxyID + if err := repo.Update(ctx, shadow); err != nil { + return fmt.Errorf("update spark shadow %d proxy: %w", shadow.ID, err) + } + } + return nil } // Proxy management implementations @@ -3850,12 +4105,26 @@ func (e *MixedChannelError) Error() string { } func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return err + } + // spark 影子账号不持自有配额(凭据透传母账号、spark 用量走独立 codex_* 维度由 QueryUsage 维护), + // 通用 quota 重置对其无意义且语义不一致——明确 400 拒绝(与 OpenAI reset-credit 对影子一致)(外审第7轮 P2)。 + if account.IsCredentialShadow() { + return infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_NO_QUOTA_RESET", + "cannot reset quota for a spark shadow account; manage it on the parent account") + } return s.accountRepo.ResetQuotaUsed(ctx, id) } // EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode, // 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。 func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string { + // 影子账号不持凭据,隐私设置由母账号管理,直接跳过。 + if account.IsCredentialShadow() { + return "" + } if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { return "" } @@ -3889,6 +4158,10 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc // ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string { + // 影子账号不持凭据,隐私由母账号管理,直接跳过(与 EnsureOpenAIPrivacy 一致——外审第4轮)。 + if account.IsCredentialShadow() { + return "" + } if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { return "" } diff --git a/backend/internal/service/admin_service_spark_shadow_test.go b/backend/internal/service/admin_service_spark_shadow_test.go new file mode 100644 index 0000000000..6b4017207a --- /dev/null +++ b/backend/internal/service/admin_service_spark_shadow_test.go @@ -0,0 +1,1000 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +// sparkShadowRepoStub 是 AccountRepository 的内存测试桩, +// 专为 CreateShadow 单元测试设计。 +// 嵌入 mockAccountRepoForGemini(由 gemini_multiplatform_test.go 提供所有 stub 方法), +// 并覆盖测试所需的核心方法。 +type sparkShadowRepoStub struct { + mockAccountRepoForGemini + nextID int64 + accounts map[int64]*Account + groupsOf map[int64][]int64 // accountID → []groupIDs +} + +func newSparkShadowRepoStub() *sparkShadowRepoStub { + return &sparkShadowRepoStub{ + nextID: 0, + accounts: make(map[int64]*Account), + groupsOf: make(map[int64][]int64), + mockAccountRepoForGemini: mockAccountRepoForGemini{ + accountsByID: make(map[int64]*Account), + }, + } +} + +func (s *sparkShadowRepoStub) Create(_ context.Context, account *Account) error { + s.nextID++ + account.ID = s.nextID + cp := *account + s.accounts[account.ID] = &cp + s.mockAccountRepoForGemini.accountsByID[account.ID] = &cp + return nil +} + +func (s *sparkShadowRepoStub) GetByID(_ context.Context, id int64) (*Account, error) { + acc, ok := s.accounts[id] + if !ok { + return nil, ErrAccountNotFound + } + return acc, nil +} + +func (s *sparkShadowRepoStub) ListShadowsByParent(_ context.Context, parentID int64) ([]*Account, error) { + var result []*Account + for _, acc := range s.accounts { + if acc.ParentAccountID != nil && *acc.ParentAccountID == parentID && acc.QuotaDimension == QuotaDimensionSpark { + cp := *acc + result = append(result, &cp) + } + } + return result, nil +} + +func (s *sparkShadowRepoStub) BindGroups(_ context.Context, accountID int64, groupIDs []int64) error { + s.groupsOf[accountID] = append(s.groupsOf[accountID], groupIDs...) + return nil +} + +func (s *sparkShadowRepoStub) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) { + var result []Account + for accID, groups := range s.groupsOf { + for _, gid := range groups { + if gid == groupID { + if acc, ok := s.accounts[accID]; ok { + result = append(result, *acc) + } + break + } + } + } + return result, nil +} + +// ListWithFilters は mockAccountRepoForGemini にないが AccountRepository が要求する。 +// 親の mockAccountRepoForGemini の nil 実装が継承されるため、ここでは省略可。 + +// ── 追加 stub(AccountRepository に必要な残りのメソッド)────────────────── +func (s *sparkShadowRepoStub) ExistsByID(_ context.Context, id int64) (bool, error) { + _, ok := s.accounts[id] + return ok, nil +} +func (s *sparkShadowRepoStub) Update(_ context.Context, account *Account) error { + if _, ok := s.accounts[account.ID]; !ok { + return ErrAccountNotFound + } + cp := *account + s.accounts[account.ID] = &cp + s.mockAccountRepoForGemini.accountsByID[account.ID] = &cp + return nil +} + +func (s *sparkShadowRepoStub) Delete(_ context.Context, id int64) error { + delete(s.accounts, id) + delete(s.mockAccountRepoForGemini.accountsByID, id) + return nil +} +func (s *sparkShadowRepoStub) BatchUpdateLastUsed(_ context.Context, _ map[int64]time.Time) error { + return nil +} +func (s *sparkShadowRepoStub) ListByGroup(_ context.Context, _ int64) ([]Account, error) { + return nil, nil +} +func (s *sparkShadowRepoStub) ListWithFilters(_ context.Context, _ pagination.PaginationParams, _, _, _, _ string, _ int64, _ string) ([]Account, *pagination.PaginationResult, error) { + return nil, nil, nil +} + +// TestCreateShadow はメインのシナリオを検証する。 +// +// Test 1 — 基本生成: ParentAccountID / QuotaDimension / 默认 spark model_mapping / 无 auth token / ProxyID 継承 +// Test 2 — 一母一影: 二度目の生成はエラー +func TestCreateShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + + proxyID := int64(7) + parent := &Account{ + Name: "p", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ProxyID: &proxyID, + Credentials: map[string]any{ + "refresh_token": "RT", + "chatgpt_account_id": "org-x", + }, + } + require.NoError(t, repo.Create(ctx, parent)) + + // Test 1: 基本生成 + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "p-spark", Priority: 50}) + require.NoError(t, err) + require.NotNil(t, shadow) + require.Equal(t, parent.ID, *shadow.ParentAccountID) + require.Equal(t, QuotaDimensionSpark, shadow.QuotaDimension) + require.Equal(t, defaultSparkShadowModelMapping(), shadow.Credentials["model_mapping"], + "影子默认带 spark 恒等变体映射") + require.Nil(t, shadow.Credentials["refresh_token"], "影子不得持有 auth token") + require.Nil(t, shadow.Credentials["access_token"], "影子不得持有 auth token") + require.Equal(t, parent.ProxyID, shadow.ProxyID) + + // Test 2: 一母一影 — 再作成は拒否 + _, err = svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "dup"}) + require.Error(t, err) +} + +// TestCreateShadow_BindGroups は BindGroups の後置呼び出しを検証する。 +// 影子账号が指定グループに属し、ListSchedulableByGroupID で取得可能であること。 +func TestCreateShadow_BindGroups(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + + parent := &Account{ + Name: "parent", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "chatgpt_account_id": "org-y", + }, + } + require.NoError(t, repo.Create(ctx, parent)) + + const testGroupID = int64(42) + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{ + Name: "p-spark", + GroupIDs: []int64{testGroupID}, + }) + require.NoError(t, err) + require.NotNil(t, shadow) + require.Equal(t, []int64{testGroupID}, shadow.GroupIDs, "CreateShadow should backfill GroupIDs into the returned shadow") + + accounts, err := repo.ListSchedulableByGroupID(ctx, testGroupID) + require.NoError(t, err) + require.Len(t, accounts, 1) + require.Equal(t, shadow.ID, accounts[0].ID) +} + +// TestDeleteAccount_CascadeToShadow verifies that deleting a parent account also +// deletes its spark shadow account. +// TestCreateShadow_InheritsParentConcurrency 验证外审 F3:未指定并发时 +// 影子继承母账号并发,避免 Concurrency=0 被限流器当作"无限并发"。 +func TestCreateShadow_InheritsParentConcurrency(t *testing.T) { + ctx := context.Background() + + t.Run("unspecified_inherits_parent", func(t *testing.T) { + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "conc-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Concurrency: 3, + Credentials: map[string]any{"chatgpt_account_id": "org-c"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "conc-shadow"}) + require.NoError(t, err) + require.Equal(t, 3, shadow.Concurrency, "未指定并发应继承母账号(非 0=无限)") + require.Equal(t, 3, repo.accounts[shadow.ID].Concurrency) + }) + + t.Run("explicit_positive_kept", func(t *testing.T) { + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "conc-parent2", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Concurrency: 3, + Credentials: map[string]any{"chatgpt_account_id": "org-c2"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "conc-shadow2", Concurrency: 2}) + require.NoError(t, err) + require.Equal(t, 2, shadow.Concurrency, "显式正并发应保留") + }) +} + +// TestCreateShadow_InheritsParentPriorityWhenOmitted 验证外审第5轮 P1:未指定优先级时 +// 影子继承母账号 priority,而非直写 0 抢到最高调度优先级(repo SetPriority 绕过 ent 默认 50, +// 调度比较数值越小越优先;前端一键创建只传 name 即触发该路径)。 +func TestCreateShadow_InheritsParentPriorityWhenOmitted(t *testing.T) { + ctx := context.Background() + + t.Run("unspecified_inherits_parent", func(t *testing.T) { + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "prio-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Priority: 30, + Credentials: map[string]any{"chatgpt_account_id": "org-p"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + // 模拟前端一键创建:只传 name,priority 省略=0。 + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "prio-shadow"}) + require.NoError(t, err) + require.Equal(t, 30, shadow.Priority, "未指定优先级应继承母账号(而非 0=最高优先级)") + require.Equal(t, 30, repo.accounts[shadow.ID].Priority) + }) + + t.Run("explicit_positive_kept", func(t *testing.T) { + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "prio-parent2", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Priority: 30, + Credentials: map[string]any{"chatgpt_account_id": "org-p2"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "prio-shadow2", Priority: 7}) + require.NoError(t, err) + require.Equal(t, 7, shadow.Priority, "显式正优先级应保留") + }) +} + +// TestPersistAccountCredentials_SkipsShadow 验证外审第6轮 P1:凭据写入唯一汇聚点 +// persistAccountCredentials 对 spark 影子早返 no-op,任何上游路径都无法把凭据落到影子行。 +func TestPersistAccountCredentials_SkipsShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + parentID := int64(1) + shadow := &Account{ + Name: "shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{}, + ParentAccountID: &parentID, QuotaDimension: QuotaDimensionSpark, + } + require.NoError(t, repo.Create(ctx, shadow)) // 回填 shadow.ID,使「漏判则 Update 成功」可被检出 + + err := persistAccountCredentials(ctx, repo, shadow, map[string]any{"access_token": "LEAK", "refresh_token": "LEAK"}) + require.NoError(t, err) + require.Empty(t, shadow.Credentials, "影子凭据不可被写入(传入对象)") + require.Empty(t, repo.accounts[shadow.ID].Credentials, "影子凭据不可被写入(仓储)") +} + +// TestResolveCredentialAccount_RejectsParentShadow 验证外审第6轮 P2 防御:畸形数据/手工 DB +// 写出的「影子→影子」链,凭据解析必须 fail-closed 而非停在无凭据的一级影子。 +func TestResolveCredentialAccount_RejectsParentShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + + grandparent := &Account{ + Name: "gp", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Credentials: map[string]any{"refresh_token": "RT"}, + } + require.NoError(t, repo.Create(ctx, grandparent)) + // parent 本身是影子(非法二级结构) + parentShadow := &Account{ + Name: "parent-shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Credentials: map[string]any{}, ParentAccountID: &grandparent.ID, QuotaDimension: QuotaDimensionSpark, + } + require.NoError(t, repo.Create(ctx, parentShadow)) + child := &Account{ + Name: "child-shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Credentials: map[string]any{}, ParentAccountID: &parentShadow.ID, QuotaDimension: QuotaDimensionSpark, + } + require.NoError(t, repo.Create(ctx, child)) + + _, err := resolveCredentialAccount(ctx, repo, child) + require.Error(t, err, "父账号本身是影子时凭据解析应拒绝(fail-closed)") +} + +// TestPersistOpenAI429PlanType_SkipsShadow 验证外审第7轮 P1:429 plan_type 同步走 BulkUpdate 直写 +// (不经 persistAccountCredentials),必须对影子早返,否则会把 plan_type 写进影子 credentials。 +func TestPersistOpenAI429PlanType_SkipsShadow(t *testing.T) { + ctx := context.Background() + body := []byte(`{"error":{"type":"usage_limit_reached","plan_type":"pro"}}`) + parentID := int64(1) + + t.Run("shadow_skipped", func(t *testing.T) { + repo := newSparkShadowRepoStub() + shadow := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{}, ParentAccountID: &parentID} + persistOpenAI429PlanType(ctx, repo, shadow, body) + require.Empty(t, shadow.Credentials, "影子不可被写入 plan_type 凭据") + }) + + t.Run("normal_account_writes", func(t *testing.T) { + repo := newSparkShadowRepoStub() + normal := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{}} + persistOpenAI429PlanType(ctx, repo, normal, body) + require.Equal(t, "pro", normal.Credentials["plan_type"], "普通账号应写入 plan_type(反向对照,证明 body 有效、写路径通)") + }) +} + +// updateExtraSpyRepo 记录 UpdateExtra 是否被调用,用于验证影子 codex_* 快照守卫。 +type updateExtraSpyRepo struct { + *sparkShadowRepoStub + updateExtraCalled bool +} + +func (r *updateExtraSpyRepo) UpdateExtra(_ context.Context, _ int64, _ map[string]any) error { + r.updateExtraCalled = true + return nil +} + +// TestPersistOpenAICodexSnapshot_SkipsShadow 验证外审第7轮 P1:影子 codex_* 仅由 QueryUsage +// (/wham/usage bengalfox)更新,不能被 429 路径的 x-codex-* 全局头快照污染。 +func TestPersistOpenAICodexSnapshot_SkipsShadow(t *testing.T) { + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "50") + parentID := int64(1) + + t.Run("shadow_skipped", func(t *testing.T) { + spy := &updateExtraSpyRepo{sparkShadowRepoStub: newSparkShadowRepoStub()} + s := &RateLimitService{accountRepo: spy} + shadow := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID} + s.persistOpenAICodexSnapshot(context.Background(), shadow, headers) + require.False(t, spy.updateExtraCalled, "影子不应写 codex_* 头快照") + }) + + t.Run("normal_account_writes", func(t *testing.T) { + spy := &updateExtraSpyRepo{sparkShadowRepoStub: newSparkShadowRepoStub()} + s := &RateLimitService{accountRepo: spy} + normal := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + s.persistOpenAICodexSnapshot(context.Background(), normal, headers) + require.True(t, spy.updateExtraCalled, "普通账号应写 codex_* 头快照(反向对照)") + }) +} + +// TestResetAccountQuota_RejectsShadow 验证外审第7轮 P2:通用 reset-quota 对影子明确 400 拒绝 +// (影子不持自有配额,语义不一致),且母账号仍可正常重置。 +func TestResetAccountQuota_RejectsShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "rq-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Credentials: map[string]any{"chatgpt_account_id": "o"}, + } + require.NoError(t, repo.Create(ctx, parent)) + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "rq-shadow"}) + require.NoError(t, err) + + err = svc.ResetAccountQuota(ctx, shadow.ID) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "影子 reset-quota 应 400") + + require.NoError(t, svc.ResetAccountQuota(ctx, parent.ID), "母账号 reset-quota 应放行") +} + +// sparkShadowGroupRepoStub 嵌入 groupRepoStub(其余方法 panic),仅覆写 +// ListActiveByPlatform 以供 F4 默认绑组测试。 +type sparkShadowGroupRepoStub struct { + groupRepoStub + groups []Group +} + +func (s *sparkShadowGroupRepoStub) ListActiveByPlatform(_ context.Context, _ string) ([]Group, error) { + return s.groups, nil +} + +// TestCreateShadow_DefaultGroupBinding 验证外审 F4:未指定 group_ids 时 +// 影子回落绑定 openai-default 组(否则无组、组内路由选不到)。 +func TestCreateShadow_DefaultGroupBinding(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + groupRepo := &sparkShadowGroupRepoStub{ + groups: []Group{ + {ID: 99, Name: PlatformOpenAI + "-default"}, + {ID: 7, Name: "some-other-group"}, + }, + } + svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo} + + parent := &Account{ + Name: "grp-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-g"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "grp-shadow"}) + require.NoError(t, err) + require.Equal(t, []int64{99}, repo.groupsOf[shadow.ID], "未指定分组应回落绑定 openai-default(id=99)") +} + +// TestCreateShadow_InheritsParentGroups 验证外审 G1:未指定 group_ids 时 +// 影子继承母账号当前分组(而非仅 openai-default),以便母在自定义组时影子也可路由。 +func TestCreateShadow_InheritsParentGroups(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + // groupRepo 故意提供 openai-default,以证明「继承母分组」优先于「回落 openai-default」。 + groupRepo := &sparkShadowGroupRepoStub{groups: []Group{{ID: 99, Name: PlatformOpenAI + "-default"}}} + svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo} + + parent := &Account{ + Name: "grp-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, GroupIDs: []int64{11, 22}, + Credentials: map[string]any{"chatgpt_account_id": "org-grp"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "grp-shadow"}) + require.NoError(t, err) + require.Equal(t, []int64{11, 22}, repo.groupsOf[shadow.ID], "未指定分组应继承母账号分组,而非 openai-default") +} + +// TestCreateShadow_RejectsShadowAsParent 验证外审 G6:不允许把影子当母创建二级影子。 +func TestCreateShadow_RejectsShadowAsParent(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + + parent := &Account{ + Name: "real-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-x"}, + } + require.NoError(t, repo.Create(ctx, parent)) + firstShadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "first-shadow"}) + require.NoError(t, err) + + // 把一级影子当母 → 必须被拒(400)。 + _, err = svc.CreateShadow(ctx, firstShadow.ID, ShadowOptions{Name: "second-shadow"}) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "影子当母应返回 400") +} + +// TestCreateShadow_StructuredErrors 验证外审 G3:可预期业务错误返回结构化 4xx 而非 500。 +func TestCreateShadow_StructuredErrors(t *testing.T) { + ctx := context.Background() + + t.Run("non_oauth_parent_400", func(t *testing.T) { + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{Name: "apikey-parent", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive} + require.NoError(t, repo.Create(ctx, parent)) + _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"}) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "非 OAuth 母账号应 400") + }) + + t.Run("duplicate_409", func(t *testing.T) { + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"}} + require.NoError(t, repo.Create(ctx, parent)) + _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s1"}) + require.NoError(t, err) + _, err = svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s2"}) + require.Error(t, err) + require.Equal(t, http.StatusConflict, infraerrors.Code(err), "重复创建应 409") + }) +} + +// TestUpdateAccount_RejectsTypeChangeOnShadow 验证外审 G7:影子 type 不可被普通更新改坏。 +func TestUpdateAccount_RejectsTypeChangeOnShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "type-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-t"}, + } + require.NoError(t, repo.Create(ctx, parent)) + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "type-shadow"}) + require.NoError(t, err) + + // 试图把影子 type 改成 apikey → 必须被拒(400)。 + _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Type: AccountTypeAPIKey}) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "改影子 type 应 400") + require.Equal(t, AccountTypeOAuth, repo.accounts[shadow.ID].Type, "影子 type 必须保持 oauth") + + // 传入相同 type(oauth)为 no-op,应允许。 + _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Type: AccountTypeOAuth}) + require.NoError(t, err, "传入相同 type 应允许") +} + +// TestBulkUpdateAccounts_RejectsCredentialWriteToShadow 验证外审 G5:批量更新携带凭据时 +// 目标含影子必须被拒(与单账号 UpdateAccount 守卫对齐,堵住 bulk 绕过)。 +func TestBulkUpdateAccounts_RejectsCredentialWriteToShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "bulk-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-b", "access_token": "t"}, + } + require.NoError(t, repo.Create(ctx, parent)) + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "bulk-shadow"}) + require.NoError(t, err) + + _, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{ + AccountIDs: []int64{shadow.ID}, + Credentials: map[string]any{"access_token": "leaked"}, + }) + require.Error(t, err, "批量给影子写凭据必须被拒") + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应 400") + // Credentials 允许持有 model_mapping(CreateShadow 写入的默认值),该不变量只约束 + // 鉴权凭据不可泄露到影子——不能整体断言 Credentials 为空。 + require.Empty(t, repo.accounts[shadow.ID].GetOpenAIAccessToken(), "影子 access_token 必须保持为空 —— 批量写入未生效") +} + +func TestDeleteAccount_CascadeToShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + + parent := &Account{ + Name: "cascade-parent", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{"chatgpt_account_id": "org-cascade"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "cascade-shadow"}) + require.NoError(t, err) + shadowID := shadow.ID + + // Both accounts exist before delete. + _, ok := repo.accounts[parent.ID] + require.True(t, ok) + _, ok = repo.accounts[shadowID] + require.True(t, ok) + + require.NoError(t, svc.DeleteAccount(ctx, parent.ID)) + + // Parent is gone. + _, ok = repo.accounts[parent.ID] + require.False(t, ok, "parent account should be deleted") + // Shadow is also gone (cascade). + _, ok = repo.accounts[shadowID] + require.False(t, ok, "shadow account should be cascade-deleted") +} + +// TestUpdateAccount_PropagatesProxyToShadow verifies that updating a parent +// account's ProxyID propagates the new value to its spark shadow. +func TestUpdateAccount_PropagatesProxyToShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + + oldProxy := int64(7) + parent := &Account{ + Name: "proxy-parent", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ProxyID: &oldProxy, + Credentials: map[string]any{"chatgpt_account_id": "org-proxy"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "proxy-shadow"}) + require.NoError(t, err) + shadowID := shadow.ID + + // Update parent's ProxyID. + newProxy := int64(42) + _, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{ProxyID: &newProxy}) + require.NoError(t, err) + + // Shadow must carry the new ProxyID. + storedShadow, ok := repo.accounts[shadowID] + require.True(t, ok) + require.NotNil(t, storedShadow.ProxyID) + require.Equal(t, newProxy, *storedShadow.ProxyID) +} + +// TestUpdateAccount_RejectsCredentialWriteToShadow 验证安全不变量「影子绝不持有鉴权凭据」 +// 在通用更新路径(UpdateAccount,被 edit/re-auth/refresh/batch 共用)上也被守住: +// 对影子写入 access_token/refresh_token 必须被拒绝,且影子的 access_token/refresh_token +// 保持为空(Credentials 本身允许持有 CreateShadow 写入的 model_mapping,故不能断言整体为空)。 +func TestUpdateAccount_RejectsCredentialWriteToShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + + parent := &Account{ + Name: "cred-parent", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{"access_token": "parent-secret", "refresh_token": "parent-rt"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "cred-shadow"}) + require.NoError(t, err) + require.Empty(t, shadow.GetOpenAIAccessToken(), "前提:影子创建后不持有 access_token") + require.Empty(t, shadow.GetOpenAIRefreshToken(), "前提:影子创建后不持有 refresh_token") + + // 试图给影子写入凭据 → 必须被拒绝。 + _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{ + Credentials: map[string]any{"access_token": "leaked", "refresh_token": "leaked-rt"}, + }) + require.Error(t, err, "对影子写入凭据必须被拒绝") + + // 结构化 4xx(非裸 error→500)。 + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应映射为 400 而非 500") + + // 影子的 access_token/refresh_token 仍为空 —— 凭据未被写入。 + storedShadow, ok := repo.accounts[shadow.ID] + require.True(t, ok) + require.Empty(t, storedShadow.GetOpenAIAccessToken(), "影子 access_token 必须保持为空 —— 凭据未被写入") + require.Empty(t, storedShadow.GetOpenAIRefreshToken(), "影子 refresh_token 必须保持为空 —— 凭据未被写入") + + // 对照:不带凭据的字段更新(如 Priority)仍应成功。 + newPriority := 5 + _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Priority: &newPriority}) + require.NoError(t, err, "影子的非凭据字段更新应正常") +} + +// TestBulkUpdateAccounts_PropagatesProxyToShadow verifies that bulk-updating +// accounts' ProxyID propagates the new value to each account's spark shadow. +func TestBulkUpdateAccounts_PropagatesProxyToShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + + oldProxy := int64(7) + parent := &Account{ + Name: "bulk-parent", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ProxyID: &oldProxy, + Credentials: map[string]any{"chatgpt_account_id": "org-bulk"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "bulk-shadow"}) + require.NoError(t, err) + shadowID := shadow.ID + + // Bulk update parent's ProxyID. + newProxy := int64(99) + _, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{ + AccountIDs: []int64{parent.ID}, + ProxyID: &newProxy, + }) + require.NoError(t, err) + + // Shadow must carry the new ProxyID. + storedShadow, ok := repo.accounts[shadowID] + require.True(t, ok) + require.NotNil(t, storedShadow.ProxyID) + require.Equal(t, newProxy, *storedShadow.ProxyID) +} + +// ── 外审 P1/P2 加固:专用测试桩 ─────────────────────────────────────────── + +// raceCreateRepoStub 模拟并发竞态:对影子的 Create 撞一母一影唯一索引(返回错误), +// 且复查时另一并发请求的影子已存在 → CreateShadow 应映射为结构化 409(外审 A/P1)。 +type raceCreateRepoStub struct { + *sparkShadowRepoStub +} + +func (s *raceCreateRepoStub) Create(ctx context.Context, account *Account) error { + if account.ParentAccountID != nil { + // 模拟另一并发请求已抢先建成影子:注入底层 map,本次 Create 撞唯一索引失败。 + s.sparkShadowRepoStub.nextID++ + phantom := *account + phantom.ID = s.sparkShadowRepoStub.nextID + s.sparkShadowRepoStub.accounts[phantom.ID] = &phantom + return errors.New(`duplicate key value violates unique constraint "uq_accounts_spark_shadow_per_parent"`) + } + return s.sparkShadowRepoStub.Create(ctx, account) +} + +// bindFailRepoStub 让 BindGroups 失败,用于验证绑组失败时补偿删除刚建的影子(外审 C/P1)。 +type bindFailRepoStub struct { + *sparkShadowRepoStub +} + +func (s *bindFailRepoStub) BindGroups(_ context.Context, _ int64, _ []int64) error { + return errors.New("simulated bind failure") +} + +// sparkShadowValidatingGroupRepoStub 实现 groupExistenceBatchReader(ExistsByIDs), +// 使 validateGroupIDsExist 走批量存在性校验路径。 +type sparkShadowValidatingGroupRepoStub struct { + groupRepoStub + existing map[int64]bool +} + +func (s *sparkShadowValidatingGroupRepoStub) ExistsByIDs(_ context.Context, ids []int64) (map[int64]bool, error) { + out := make(map[int64]bool, len(ids)) + for _, id := range ids { + out[id] = s.existing[id] + } + return out, nil +} + +// TestCreateShadow_DefaultsNameFromParent 验证外审 E/P2:空 name 不应 500, +// 而是默认 "<母账号名> (Spark)"。 +func TestCreateShadow_DefaultsNameFromParent(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "mum", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: " "}) + require.NoError(t, err, "空/空白 name 不应 500,应默认命名") + require.Equal(t, "mum (Spark)", shadow.Name) +} + +// TestCreateShadow_ConcurrentCreateReturns409 验证外审 A/P1:并发竞态下预查放行后 +// Create 撞唯一索引,应映射结构化 409 而非裸 500。 +func TestCreateShadow_ConcurrentCreateReturns409(t *testing.T) { + ctx := context.Background() + base := newSparkShadowRepoStub() + repo := &raceCreateRepoStub{sparkShadowRepoStub: base} + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"}, + } + require.NoError(t, base.Create(ctx, parent)) + + _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"}) + require.Error(t, err) + require.Equal(t, http.StatusConflict, infraerrors.Code(err), "并发竞态撞唯一索引应映射 409 而非 500") +} + +// TestCreateShadow_InvalidGroupRejectedNoOrphan 验证外审 C/P1:显式无效分组应在 +// 创建前被拒,不留孤儿影子。 +func TestCreateShadow_InvalidGroupRejectedNoOrphan(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}} + svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo} + parent := &Account{ + Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"}, + } + require.NoError(t, repo.Create(ctx, parent)) + + _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s", GroupIDs: []int64{999}}) + require.Error(t, err, "无效分组应在创建前被拒") + + shadows, qerr := repo.ListShadowsByParent(ctx, parent.ID) + require.NoError(t, qerr) + require.Empty(t, shadows, "无效分组应在创建前被拒,不应建出影子") +} + +// TestCreateShadow_BindFailureRollsBackShadow 验证外审 C/P1:绑组失败时补偿删除 +// 刚建的影子,不留孤儿(否则一母一影唯一索引会挡住重试)。 +func TestCreateShadow_BindFailureRollsBackShadow(t *testing.T) { + ctx := context.Background() + base := newSparkShadowRepoStub() + repo := &bindFailRepoStub{sparkShadowRepoStub: base} + groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}} + svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo} + parent := &Account{ + Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"}, + } + require.NoError(t, base.Create(ctx, parent)) + + _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s", GroupIDs: []int64{7}}) + require.Error(t, err, "绑组失败应返回错误") + + shadows, qerr := base.ListShadowsByParent(ctx, parent.ID) + require.NoError(t, qerr) + require.Empty(t, shadows, "绑组失败后应补偿删除影子,不留孤儿") +} + +// TestUpdateAccount_RejectsParentTypeChangeWithShadow 验证外审 D/P1:母账号有 spark 影子时, +// 不能把 type 改出 OpenAI OAuth(否则影子被调度后透传凭据解析必失败)。 +func TestUpdateAccount_RejectsParentTypeChangeWithShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parent := &Account{ + Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"}, + } + require.NoError(t, repo.Create(ctx, parent)) + _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"}) + require.NoError(t, err) + + _, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{Type: AccountTypeAPIKey}) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "母账号有影子时改 type 出 oauth 应 400") + require.Equal(t, AccountTypeOAuth, repo.accounts[parent.ID].Type, "母账号 type 必须保持 oauth") + + // 对照:把 type 设为相同 oauth(no-op)应允许。 + _, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{Type: AccountTypeOAuth}) + require.NoError(t, err, "传入相同 type(no-op)应允许") +} + +// TestUpdateAccount_IgnoresProxyChangeOnShadow 验证外审 B/P1:影子 proxy 恒继承母账号, +// 普通更新不得独立改动。 +func TestUpdateAccount_IgnoresProxyChangeOnShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parentProxy := int64(7) + parent := &Account{ + Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, ProxyID: &parentProxy, + Credentials: map[string]any{"chatgpt_account_id": "o"}, + } + require.NoError(t, repo.Create(ctx, parent)) + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"}) + require.NoError(t, err) + require.NotNil(t, repo.accounts[shadow.ID].ProxyID) + require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "前提:影子继承母 proxy=7") + + newProxy := int64(42) + _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{ProxyID: &newProxy}) + require.NoError(t, err, "影子的非 proxy 字段更新仍应成功") + require.NotNil(t, repo.accounts[shadow.ID].ProxyID) + require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "影子 proxy 不应被独立改动,恒继承母账号") +} + +func TestUpdateAccount_ShadowAllowsModelMappingAndGroupUpdate(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}} + svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo} + parentID := int64(1) + parent := &Account{ + ID: parentID, + Name: "p", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "access_token": "parent-token", + "chatgpt_account_id": "org-parent", + }, + } + require.NoError(t, repo.Create(ctx, parent)) + shadow := &Account{ + Name: "s", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + Credentials: map[string]any{}, + } + require.NoError(t, repo.Create(ctx, shadow)) + + groupIDs := []int64{7} + updated, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{ + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", + }, + }, + GroupIDs: &groupIDs, + }) + + require.NoError(t, err) + require.Equal(t, []int64{7}, repo.groupsOf[shadow.ID]) + require.Equal(t, map[string]any{"gpt-5.3-codex-spark": "gpt-5.3-codex-spark"}, updated.Credentials["model_mapping"]) + require.Empty(t, updated.GetOpenAIAccessToken(), "影子账号不可持有母账号 access_token") +} + +func TestUpdateAccount_ShadowEmptyCredentialsClearsModelMapping(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parentID := int64(1) + shadow := &Account{ + Name: "s", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", + }, + }, + } + require.NoError(t, repo.Create(ctx, shadow)) + + updated, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{ + Credentials: map[string]any{}, + }) + + require.NoError(t, err) + require.Empty(t, updated.Credentials) + require.Empty(t, repo.accounts[shadow.ID].Credentials) +} + +func TestUpdateAccount_ShadowRejectsAuthCredentials(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parentID := int64(1) + shadow := &Account{ + Name: "s", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + Credentials: map[string]any{}, + } + require.NoError(t, repo.Create(ctx, shadow)) + + _, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{ + Credentials: map[string]any{"access_token": "leak"}, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Empty(t, repo.accounts[shadow.ID].Credentials) +} + +// TestBulkUpdateAccounts_RejectsProxyChangeOnShadow 验证外审第4轮 P1:批量更新携带 proxy 且 +// 目标含影子必须被拒(与单账号 UpdateAccount 守卫对齐,堵住 bulk 绕过"proxy 恒继承母账号")。 +func TestBulkUpdateAccounts_RejectsProxyChangeOnShadow(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + svc := &adminServiceImpl{accountRepo: repo} + parentProxy := int64(7) + parent := &Account{ + Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, ProxyID: &parentProxy, + Credentials: map[string]any{"chatgpt_account_id": "o"}, + } + require.NoError(t, repo.Create(ctx, parent)) + shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"}) + require.NoError(t, err) + + newProxy := int64(42) + _, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{ + AccountIDs: []int64{shadow.ID}, + ProxyID: &newProxy, + }) + require.Error(t, err, "批量给影子改 proxy 必须被拒") + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应 400") + require.NotNil(t, repo.accounts[shadow.ID].ProxyID) + require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "影子 proxy 必须保持继承母账号") +} + +// TestForceOpenAIPrivacy_SkipsShadow 验证外审第4轮:影子隐私设置跳过(由母账号管理), +// 早返不触碰任何依赖(svc 无 deps,若未守卫会 nil panic)。 +func TestForceOpenAIPrivacy_SkipsShadow(t *testing.T) { + svc := &adminServiceImpl{} + pid := int64(1) + shadow := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &pid} + require.Equal(t, "", svc.ForceOpenAIPrivacy(context.Background(), shadow), "影子隐私设置应跳过") +} diff --git a/backend/internal/service/credential_shadow.go b/backend/internal/service/credential_shadow.go new file mode 100644 index 0000000000..2db4a96191 --- /dev/null +++ b/backend/internal/service/credential_shadow.go @@ -0,0 +1,33 @@ +package service + +import ( + "context" + "fmt" +) + +// resolveCredentialAccount 解析影子账号到其母账号,用于凭据/Token 透传。 +// - 普通账号(非影子):直接返回自身。 +// - 影子账号:通过 repo 取母账号,校验母账号存在且为 OpenAI OAuth 类型,否则返回错误。 +// 设计为包级函数(非任何 service 的方法),以便 OpenAIGatewayService / OpenAIQuotaService / +// AccountUsageService 等不同接收者共享同一实现。 +func resolveCredentialAccount(ctx context.Context, repo AccountRepository, account *Account) (*Account, error) { + if account == nil || !account.IsShadow() { + return account, nil + } + parent, err := repo.GetByID(ctx, *account.ParentAccountID) + if err != nil { + return nil, fmt.Errorf("resolve spark shadow parent %d: %w", *account.ParentAccountID, err) + } + if parent == nil { + return nil, fmt.Errorf("spark shadow parent %d not found", *account.ParentAccountID) + } + // 防御:创建路径已禁二级影子(G6),此处再挡一层——畸形数据/手工 DB 写出的影子→影子链 + // 会让凭据解析停在无凭据的一级影子(只解一层),fail-closed 比静默返回坏母更安全(外审第6轮)。 + if parent.IsShadow() { + return nil, fmt.Errorf("spark shadow parent %d is itself a shadow", parent.ID) + } + if !parent.IsOpenAIOAuth() { + return nil, fmt.Errorf("spark shadow parent %d is not OpenAI OAuth", parent.ID) + } + return parent, nil +} diff --git a/backend/internal/service/credential_shadow_test.go b/backend/internal/service/credential_shadow_test.go new file mode 100644 index 0000000000..ad392e51a7 --- /dev/null +++ b/backend/internal/service/credential_shadow_test.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// stubCredRepo 是最小化 AccountRepository stub,仅实现 GetByID,供 credential_shadow_test 使用。 +// 嵌入接口满足完整方法集;未实现的方法若被调用会 panic,从而快速暴露误调用。 +type stubCredRepo struct { + AccountRepository + parent *Account +} + +func (s *stubCredRepo) GetByID(_ context.Context, _ int64) (*Account, error) { + return s.parent, nil +} + +func newStubCredRepo(parent *Account) AccountRepository { + return &stubCredRepo{parent: parent} +} + +func TestResolveCredentialAccount(t *testing.T) { + ctx := context.Background() + pid := int64(100) + + // 普通账号(非影子)→ 返回自身 + parent := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive} + repo := newStubCredRepo(parent) + got, err := resolveCredentialAccount(ctx, repo, parent) + require.NoError(t, err) + require.Equal(t, int64(100), got.ID) + + // 影子账号 + 合法 OpenAI OAuth 母账号 → 返回母账号 + shadow := &Account{ID: 200, ParentAccountID: &pid, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + got, err = resolveCredentialAccount(ctx, repo, shadow) + require.NoError(t, err) + require.Equal(t, int64(100), got.ID) + + // 影子账号 + 母账号非 OpenAI OAuth(API Key 类型)→ 返回 error + badRepo := newStubCredRepo(&Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) + _, err = resolveCredentialAccount(ctx, badRepo, shadow) + require.Error(t, err) +} diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index 02d36b0cbc..edf3cd43d2 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "strconv" "strings" @@ -44,6 +45,31 @@ func NewCRSSyncService( } } +// guardCRSShadowParentInvariant 守住「有 spark 影子的母账号」不变量(与 AdminService.UpdateAccount 一致): +// 影子读透母账号凭据,母账号必须**始终是 OpenAI OAuth**。CRS 同步按全局 crs_account_id 匹配既有账号 +// (GetByCRSAccountID 已排除影子、但能命中母账号),各平台分支会重写 Platform/Type;若 CRS ID 跨 kind/平台 +// 碰撞,非 OpenAI 分支会把母账号改成 Anthropic/Gemini 或 api_key→影子 resolveCredentialAccount 必崩(外审第9轮, +// 收紧第8轮仅查 Type 的版本:Claude OAuth 把 Type 保持 OAuth 但 Platform 改成 Anthropic 能绕过旧守卫)。 +// 故任何会把母账号目标结果改离 OpenAI OAuth 的 CRS 更新,在其有影子时一律拒绝(须先删影子);返回非 nil +// 表示该账号更新应被跳过(调用方标记 failed)。 +func guardCRSShadowParentInvariant(ctx context.Context, repo AccountRepository, existing *Account, newPlatform, newType string) error { + if existing == nil { + return nil + } + // 目标仍是合法影子父(OpenAI OAuth)→ 放行(常见:OpenAI OAuth 分支重新同步母账号),免去一次查询。 + if newPlatform == PlatformOpenAI && newType == AccountTypeOAuth { + return nil + } + shadows, err := repo.ListShadowsByParent(ctx, existing.ID) + if err != nil { + return fmt.Errorf("check spark shadows for crs update: %w", err) + } + if len(shadows) > 0 { + return fmt.Errorf("cannot change a spark-shadow parent account to %s/%s; it must stay OpenAI OAuth (delete the shadow first)", newPlatform, newType) + } + return nil +} + type SyncFromCRSInput struct { BaseURL string Username string @@ -376,6 +402,15 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } + // 母账号守卫(外审第9轮):CRS ID 跨平台碰撞时,本(Anthropic OAuth)分支不得改坏有 spark 影子的 OpenAI 母账号。 + if gerr := guardCRSShadowParentInvariant(ctx, s.accountRepo, existing, PlatformAnthropic, targetType); gerr != nil { + item.Action = "failed" + item.Error = gerr.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + // Update existing existing.Extra = mergeMap(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) @@ -492,6 +527,15 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } + // 母账号守卫(外审第9轮):CRS ID 跨平台碰撞时,本(Anthropic APIKey)分支不得改坏有 spark 影子的 OpenAI 母账号。 + if gerr := guardCRSShadowParentInvariant(ctx, s.accountRepo, existing, PlatformAnthropic, AccountTypeAPIKey); gerr != nil { + item.Action = "failed" + item.Error = gerr.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + existing.Extra = mergeMap(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = PlatformAnthropic @@ -652,6 +696,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput _ = persistAccountCredentials(ctx, s.accountRepo, existing, refreshedCreds) } + // 母账号 proxy 经 CRS 改动后同步到其 spark 影子,避免影子保留旧 proxy 出现出站漂移(外审第8轮)。 + // 影子 proxy 恒继承母账号(创建即继承、AdminService 编辑也传播)。best-effort:母账号本身已成功 + // 更新,影子传播失败仅记录告警,不回退该条目状态。 + if perr := propagateAccountProxyToShadows(ctx, s.accountRepo, existing.ID, existing.ProxyID); perr != nil { + slog.Warn("crs_sync_propagate_proxy_to_shadows_failed", "account_id", existing.ID, "error", perr) + } + item.Action = "updated" result.Updated++ result.Items = append(result.Items, item) @@ -748,6 +799,16 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } + // 母账号守卫(外审第8/9轮):CRS 不得把有 spark 影子的母账号改离 OpenAI OAuth(此处会翻成 api_key), + // 否则影子读透母凭据失败、resolveCredentialAccount 必报错、spark 调度与用量刷新全崩。须先删影子再改。 + if gerr := guardCRSShadowParentInvariant(ctx, s.accountRepo, existing, PlatformOpenAI, AccountTypeAPIKey); gerr != nil { + item.Action = "failed" + item.Error = gerr.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + existing.Extra = mergeMap(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = PlatformOpenAI @@ -866,6 +927,15 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } + // 母账号守卫(外审第9轮):CRS ID 跨平台碰撞时,本(Gemini OAuth)分支不得改坏有 spark 影子的 OpenAI 母账号。 + if gerr := guardCRSShadowParentInvariant(ctx, s.accountRepo, existing, PlatformGemini, AccountTypeOAuth); gerr != nil { + item.Action = "failed" + item.Error = gerr.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + existing.Extra = mergeMap(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = PlatformGemini @@ -979,6 +1049,15 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } + // 母账号守卫(外审第9轮):CRS ID 跨平台碰撞时,本(Gemini APIKey)分支不得改坏有 spark 影子的 OpenAI 母账号。 + if gerr := guardCRSShadowParentInvariant(ctx, s.accountRepo, existing, PlatformGemini, AccountTypeAPIKey); gerr != nil { + item.Action = "failed" + item.Error = gerr.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + existing.Extra = mergeMap(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = PlatformGemini diff --git a/backend/internal/service/crs_sync_spark_shadow_test.go b/backend/internal/service/crs_sync_spark_shadow_test.go new file mode 100644 index 0000000000..ef59865b87 --- /dev/null +++ b/backend/internal/service/crs_sync_spark_shadow_test.go @@ -0,0 +1,84 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPropagateAccountProxyToShadows 外审第8轮:CRS/AdminService 改母账号 proxy 后, +// 影子 proxy 必须跟随(影子 proxy 恒继承母账号,否则出站漂移)。 +func TestPropagateAccountProxyToShadows(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + + oldProxy := int64(11) + mother := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, ProxyID: &oldProxy} + require.NoError(t, repo.Create(ctx, mother)) + parentID := mother.ID + + shadow := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + ProxyID: &oldProxy, + } + require.NoError(t, repo.Create(ctx, shadow)) + + newProxy := int64(22) + require.NoError(t, propagateAccountProxyToShadows(ctx, repo, parentID, &newProxy)) + + got, err := repo.GetByID(ctx, shadow.ID) + require.NoError(t, err) + require.NotNil(t, got.ProxyID) + require.Equal(t, newProxy, *got.ProxyID, "shadow proxy must follow the parent's new proxy") + + // 清空母 proxy 也应传播为 nil。 + require.NoError(t, propagateAccountProxyToShadows(ctx, repo, parentID, nil)) + got, err = repo.GetByID(ctx, shadow.ID) + require.NoError(t, err) + require.Nil(t, got.ProxyID, "clearing parent proxy must clear the shadow proxy too") +} + +// TestGuardCRSShadowParentInvariant 外审第8/9轮:有 spark 影子的母账号经 CRS 任意分支更新后,目标结果 +// 必须仍是 OpenAI OAuth;否则(改 api_key 或跨平台 Anthropic/Gemini)影子读透母凭据失败、spark 全崩。 +func TestGuardCRSShadowParentInvariant(t *testing.T) { + ctx := context.Background() + repo := newSparkShadowRepoStub() + + mother := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + require.NoError(t, repo.Create(ctx, mother)) + parentID := mother.ID + + // 无影子:任何目标都放行(含改离 OpenAI OAuth)。 + require.NoError(t, guardCRSShadowParentInvariant(ctx, repo, mother, PlatformOpenAI, AccountTypeAPIKey)) + require.NoError(t, guardCRSShadowParentInvariant(ctx, repo, mother, PlatformAnthropic, AccountTypeOAuth)) + + // 建一个影子后: + shadow := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + } + require.NoError(t, repo.Create(ctx, shadow)) + + // 翻成 OpenAI api_key 被拒。 + err := guardCRSShadowParentInvariant(ctx, repo, mother, PlatformOpenAI, AccountTypeAPIKey) + require.Error(t, err, "must reject converting a shadow parent to openai api_key") + require.Contains(t, err.Error(), "spark-shadow parent") + + // 跨平台改成 Anthropic OAuth(Type 仍 OAuth、仅 Platform 变)也被拒——第8轮只查 Type 的版本会漏。 + require.Error(t, guardCRSShadowParentInvariant(ctx, repo, mother, PlatformAnthropic, AccountTypeOAuth), + "must reject moving a shadow parent to a non-OpenAI platform even if type stays oauth") + + // 改成 Gemini api_key 被拒。 + require.Error(t, guardCRSShadowParentInvariant(ctx, repo, mother, PlatformGemini, AccountTypeAPIKey)) + + // 保持 OpenAI OAuth(重新同步母账号)放行,即便仍有影子。 + require.NoError(t, guardCRSShadowParentInvariant(ctx, repo, mother, PlatformOpenAI, AccountTypeOAuth)) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 15e9ec73bf..19ed6b5b2e 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -495,6 +495,12 @@ func SettingKeyAuthSourcePlatformQuotas(source string) string { return fmt.Sprintf("auth_source_default_%s_platform_quotas", source) } +// QuotaDimension constants for spark shadow accounts. +const ( + QuotaDimensionGlobal = "global" + QuotaDimensionSpark = "spark" +) + // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). const AdminAPIKeyPrefix = "admin-" diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index a5c6794975..291cea53b9 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -202,6 +202,10 @@ func (m *mockAccountRepoForPlatform) RevertProxyFallback(ctx context.Context, ac return nil } +func (m *mockAccountRepoForPlatform) ListShadowsByParent(ctx context.Context, parentID int64) ([]*Account, error) { + return nil, nil +} + // Verify interface implementation var _ AccountRepository = (*mockAccountRepoForPlatform)(nil) diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index bd263813d8..c021e88edf 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -191,6 +191,10 @@ func (m *mockAccountRepoForGemini) RevertProxyFallback(ctx context.Context, acco return nil } +func (m *mockAccountRepoForGemini) ListShadowsByParent(ctx context.Context, parentID int64) ([]*Account, error) { + return nil, nil +} + // Verify interface implementation var _ AccountRepository = (*mockAccountRepoForGemini)(nil) diff --git a/backend/internal/service/oauth_refresh_api_test.go b/backend/internal/service/oauth_refresh_api_test.go index 4a60723b8b..cacffddc59 100644 --- a/backend/internal/service/oauth_refresh_api_test.go +++ b/backend/internal/service/oauth_refresh_api_test.go @@ -45,7 +45,7 @@ func (r *refreshAPIAccountRepo) UpdateCredentials(_ context.Context, id int64, c if r.account == nil || r.account.ID != id { r.account = &Account{ID: id} } - r.account.Credentials = cloneCredentials(credentials) + r.account.Credentials = shallowCopyMap(credentials) return nil } diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index d22b95b01f..a3f60fee2f 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -70,6 +70,11 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context if s == nil || !isOpenAIOAuthAccount(account) { return } + // Spark 影子:不按 /responses 429 的 global x-codex-* 信号做内存运行时熔断(同 handle429,外审第8轮 P1)。 + // 同时避免把 spark 的 429 计入全局 429 storm 计数(recordOpenAIOAuth429),否则会误伤母账号 failover 决策。 + if account.IsShadow() { + return + } s.recordOpenAIOAuth429() cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index ff5d604fe4..a51c13c531 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -26,6 +26,32 @@ func TestOpenAI429FastPath_MarksOAuthAccountCoolingDown(t *testing.T) { require.False(t, svc.isOpenAIAccountRuntimeBlocked(apiKeyAccount)) } +// TestOpenAI429FastPath_SkipsSparkShadow 外审第8轮 P1:spark 影子被选中后若 /responses 返回 429, +// 不得按 global x-codex-* 信号写内存运行时熔断(否则 spark 被冷却到 global reset、单影子场景无可用账号)。 +func TestOpenAI429FastPath_SkipsSparkShadow(t *testing.T) { + svc := &OpenAIGatewayService{} + parentID := int64(800) + shadow := &Account{ + ID: 801, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + } + normal := &Account{ID: 802, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "100") + headers.Set("x-codex-primary-reset-after-seconds", "18000") + headers.Set("x-codex-primary-window-minutes", "300") + + svc.markOpenAIOAuth429RateLimited(context.Background(), shadow, headers, nil) + svc.markOpenAIOAuth429RateLimited(context.Background(), normal, headers, nil) + + require.False(t, svc.isOpenAIAccountRuntimeBlocked(shadow), "spark shadow must not be runtime-blocked by /responses global 429") + require.True(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account should still be runtime-blocked") +} + func TestOpenAIRuntimeBlock_AppliesToOpenAIAPIKeyWhenRateLimitServiceStopsScheduling(t *testing.T) { svc := &OpenAIGatewayService{} account := &Account{ID: 44, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index eba93534ae..42aa494b1f 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1084,6 +1084,22 @@ func (s *defaultOpenAIAccountScheduler) isAccountTransportCompatible(account *Ac return s.service.isOpenAIAccountTransportCompatible(account, requiredTransport) } +func (s *defaultOpenAIAccountScheduler) lookupShadowParentAccount(ctx context.Context, id int64) *Account { + if s == nil || s.service == nil { + return nil + } + if s.service.schedulerSnapshot != nil { + if account, err := s.service.schedulerSnapshot.GetAccount(ctx, id); err == nil && account != nil { + return account + } + } + if s.service.accountRepo == nil { + return nil + } + account, _ := s.service.accountRepo.GetByID(ctx, id) + return account +} + func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(ctx context.Context, account *Account, req OpenAIAccountScheduleRequest) bool { if account == nil { return false @@ -1098,6 +1114,14 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(ctx context.C if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { return false } + // 母账号健康联动:影子账号的凭据来自母账号,母账号不可调度时影子也不应被选中。 + // Parent-health gate: shadow borrows the parent's credentials; an unschedulable + // parent must block the shadow across all scheduler paths. + if !parentHealthyForShadow(account, func(id int64) *Account { + return s.lookupShadowParentAccount(ctx, id) + }) { + return false + } if req.RequestedModel != "" && !account.IsModelSupported(req.RequestedModel) { return false } diff --git a/backend/internal/service/openai_account_scheduler_spark_route_test.go b/backend/internal/service/openai_account_scheduler_spark_route_test.go new file mode 100644 index 0000000000..c0758b3844 --- /dev/null +++ b/backend/internal/service/openai_account_scheduler_spark_route_test.go @@ -0,0 +1,210 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestSparkRoutingByModel(t *testing.T) { + ctx := context.Background() + sparkModel := "gpt-5.3-codex-spark" + normalModel := "gpt-5.3-codex" + sparkCreds := map[string]any{"model_mapping": defaultSparkShadowModelMapping()} + + newScheduler := func(snapshot map[int64]*Account) *defaultOpenAIAccountScheduler { + return &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{ + schedulerSnapshot: &SchedulerSnapshotService{ + cache: &openAISnapshotCacheStub{accountsByID: snapshot}, + }, + cfg: &config.Config{}, + }} + } + sparkReq := OpenAIAccountScheduleRequest{RequestedModel: sparkModel, Platform: PlatformOpenAI} + normalReq := OpenAIAccountScheduleRequest{RequestedModel: normalModel, Platform: PlatformOpenAI} + + t.Run("normal_account_with_spark_mapping_accepts_spark", func(t *testing.T) { + acc := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Credentials: sparkCreds} + require.True(t, newScheduler(nil).isAccountRequestCompatible(ctx, acc, sparkReq), + "普通账号配了 spark → 可承接 spark(类型门已移除)") + }) + + t.Run("normal_account_without_spark_rejects_spark", func(t *testing.T) { + acc := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, + Credentials: map[string]any{"model_mapping": map[string]any{normalModel: normalModel}}} + require.False(t, newScheduler(nil).isAccountRequestCompatible(ctx, acc, sparkReq), + "普通账号未配 spark → 拒 spark(按配置而非类型)") + }) + + t.Run("shadow_with_spark_mapping_accepts_spark_rejects_non_spark", func(t *testing.T) { + pid := int64(100) + parent := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + shadow := &Account{ID: 200, ParentAccountID: &pid, QuotaDimension: QuotaDimensionSpark, + Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: sparkCreds} + s := newScheduler(map[int64]*Account{100: parent}) + require.True(t, s.isAccountRequestCompatible(ctx, shadow, sparkReq), "影子配 spark + 健康母 → 接 spark") + require.False(t, s.isAccountRequestCompatible(ctx, shadow, normalReq), "影子(仅 spark mapping)→ 拒非 spark") + }) + + t.Run("empty_model_shadow_is_eligible_under_a2", func(t *testing.T) { + // 有意的纯 A2 行为(用户裁决 2026-06-30):空 model 请求不经模型门过滤 + // (isAccountRequestCompatible 的 `req.RequestedModel != ""` 短路),故影子与普通账号 + // 一样成为候选。旧类型门曾在空 model 时排除影子(opt-in),该 opt-in 已随类型门移除—— + // routing 路径不再有任何类型判断。此测试锁定该决策,防被未来改动静默改回。 + pid := int64(100) + parent := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + shadow := &Account{ID: 200, ParentAccountID: &pid, QuotaDimension: QuotaDimensionSpark, + Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: sparkCreds} + emptyReq := OpenAIAccountScheduleRequest{RequestedModel: "", Platform: PlatformOpenAI} + s := newScheduler(map[int64]*Account{100: parent}) + require.True(t, s.isAccountRequestCompatible(ctx, shadow, emptyReq), + "空 model 时影子可被选中(有意的纯 A2 行为:类型门移除后无 opt-in 排除)") + }) +} + +// TestParentHealthSchedulerIntegration 通过 isAccountRequestCompatible 验证「母账号不可调度时影子被 +// 调度器拒绝」这一联动在调度器层面端到端生效。 +// +// 使用的接缝:defaultOpenAIAccountScheduler.isAccountRequestCompatible,它通过 +// s.service.schedulerSnapshot.GetAccount(ctx, parentID) 解析母账号; +// openAISnapshotCacheStub.accountsByID 提供对应的测试桩。 +func TestParentHealthSchedulerIntegration(t *testing.T) { + ctx := context.Background() + pid := int64(78100) + sparkModel := "gpt-5.3-codex-spark" + + shadow := &Account{ + ID: 78200, + ParentAccountID: &pid, + QuotaDimension: QuotaDimensionSpark, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + } + + req := OpenAIAccountScheduleRequest{ + RequestedModel: sparkModel, + Platform: PlatformOpenAI, + } + + makeScheduler := func(parent *Account) *defaultOpenAIAccountScheduler { + snapshotCache := &openAISnapshotCacheStub{ + accountsByID: map[int64]*Account{parent.ID: parent}, + } + snapshotSvc := &SchedulerSnapshotService{cache: snapshotCache} + svc := &OpenAIGatewayService{ + schedulerSnapshot: snapshotSvc, + cfg: &config.Config{}, + } + return &defaultOpenAIAccountScheduler{service: svc} + } + + t.Run("unhealthy_parent_status_error_rejects_shadow", func(t *testing.T) { + unhealthyParent := &Account{ + ID: 78100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusError, // IsActive()==false → IsSchedulable()==false + Schedulable: true, + } + require.False(t, unhealthyParent.IsSchedulable(), "前提:Status=error 的母账号不可调度") + scheduler := makeScheduler(unhealthyParent) + require.False(t, scheduler.isAccountRequestCompatible(ctx, shadow, req), + "母账号不可调度时,影子账号必须被调度器拒绝") + }) + + t.Run("manual_schedulable_false_parent_does_not_reject_shadow", func(t *testing.T) { + // F1 决策 A:母账号手动暂停(Schedulable=false)不传播到影子 —— 凭据仍可用,影子应被接受。 + manualPausedParent := &Account{ + ID: 78100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: false, // 显式手动暂停 + } + require.False(t, manualPausedParent.IsSchedulable(), "前提:手动暂停的母账号自身不可调度") + scheduler := makeScheduler(manualPausedParent) + require.True(t, scheduler.isAccountRequestCompatible(ctx, shadow, req), + "母账号手动暂停不应连坐影子(凭据仍可用)") + }) + + t.Run("global_rate_limited_parent_does_not_reject_shadow", func(t *testing.T) { + // F1 核心修复:母账号 global 429(RateLimitResetAt)不连坐 spark 影子。 + resetAt := time.Now().Add(1 * time.Hour) + rateLimitedParent := &Account{ + ID: 78100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + RateLimitResetAt: &resetAt, + } + require.False(t, rateLimitedParent.IsSchedulable(), "前提:global 限流母账号自身不可调度") + scheduler := makeScheduler(rateLimitedParent) + require.True(t, scheduler.isAccountRequestCompatible(ctx, shadow, req), + "母账号 global 限流不应连坐 spark 影子") + }) + + t.Run("healthy_parent_accepts_shadow_control", func(t *testing.T) { + healthyParent := &Account{ + ID: 78100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + } + require.True(t, healthyParent.IsSchedulable(), "前提:健康母账号必须可调度") + scheduler := makeScheduler(healthyParent) + require.True(t, scheduler.isAccountRequestCompatible(ctx, shadow, req), + "健康母账号时,影子账号必须被调度器接受(对照组)") + }) +} + +func TestParentHealthSchedulerFallsBackToRepoWhenSnapshotMissesParent(t *testing.T) { + ctx := context.Background() + parentID := int64(79100) + parent := Account{ + ID: parentID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + } + shadow := &Account{ + ID: 79200, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + } + + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{parent}} + scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{ + accountRepo: repo, + schedulerSnapshot: &SchedulerSnapshotService{ + cache: &openAISnapshotCacheStub{}, + accountRepo: repo, + cfg: &config.Config{ + Gateway: config.GatewayConfig{ + Scheduling: config.GatewaySchedulingConfig{ + DbFallbackEnabled: false, + }, + }, + }, + }, + cfg: &config.Config{}, + }} + + require.True(t, scheduler.isAccountRequestCompatible(ctx, shadow, OpenAIAccountScheduleRequest{ + RequestedModel: "gpt-5.3-codex-spark", + Platform: PlatformOpenAI, + }), "快照缺失母账号且调度快照 DB fallback 关闭时,应回退 repo 解析健康母账号") +} diff --git a/backend/internal/service/openai_chatgpt_headers.go b/backend/internal/service/openai_chatgpt_headers.go index 5bd1b8f1bc..fe1cd5947e 100644 --- a/backend/internal/service/openai_chatgpt_headers.go +++ b/backend/internal/service/openai_chatgpt_headers.go @@ -1,6 +1,9 @@ package service -import "net/http" +import ( + "context" + "net/http" +) func setOpenAIChatGPTAccountHeaders(headers http.Header, account *Account) { if headers == nil || account == nil || !account.IsOpenAIOAuth() { @@ -15,3 +18,15 @@ func setOpenAIChatGPTAccountHeaders(headers http.Header, account *Account) { headers.Del("x-openai-fedramp") } } + +// resolveAndSetOpenAIChatGPTAccountHeaders 解析 spark 影子账号至其母账号(凭据透传), +// 再调用 setOpenAIChatGPTAccountHeaders 写入 chatgpt-account-id / x-openai-fedramp 头。 +// 普通账号(非影子)为直通,行为与直接调用 setOpenAIChatGPTAccountHeaders 一致。 +func resolveAndSetOpenAIChatGPTAccountHeaders(ctx context.Context, repo AccountRepository, headers http.Header, account *Account) error { + credAccount, err := resolveCredentialAccount(ctx, repo, account) + if err != nil { + return err + } + setOpenAIChatGPTAccountHeaders(headers, credAccount) + return nil +} diff --git a/backend/internal/service/openai_chatgpt_headers_test.go b/backend/internal/service/openai_chatgpt_headers_test.go new file mode 100644 index 0000000000..9956b70251 --- /dev/null +++ b/backend/internal/service/openai_chatgpt_headers_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +// stubChatGPTHeadersRepo 是最小化 AccountRepository stub,仅实现 GetByID, +// 供 TestResolveAndSetOpenAIChatGPTAccountHeaders 使用。 +type stubChatGPTHeadersRepo struct { + AccountRepository + byID map[int64]*Account +} + +func (r *stubChatGPTHeadersRepo) GetByID(_ context.Context, id int64) (*Account, error) { + return r.byID[id], nil +} + +func TestResolveAndSetOpenAIChatGPTAccountHeaders(t *testing.T) { + ctx := context.Background() + pid := int64(100) + + parentCreds := map[string]any{"chatgpt_account_id": "org-parent"} + parent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: parentCreds, + } + repo := &stubChatGPTHeadersRepo{byID: map[int64]*Account{100: parent}} + + t.Run("shadow_resolves_to_parent_org", func(t *testing.T) { + shadow := &Account{ + ID: 200, + ParentAccountID: &pid, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + } + headers := make(http.Header) + err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, repo, headers, shadow) + require.NoError(t, err) + require.Equal(t, "org-parent", headers.Get("chatgpt-account-id"), + "影子账号应透传母账号的 chatgpt-account-id") + }) + + t.Run("normal_account_passthrough", func(t *testing.T) { + ownCreds := map[string]any{"chatgpt_account_id": "org-own"} + normal := &Account{ + ID: 300, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: ownCreds, + } + headers := make(http.Header) + err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, repo, headers, normal) + require.NoError(t, err) + require.Equal(t, "org-own", headers.Get("chatgpt-account-id"), + "普通账号应透传自身的 chatgpt-account-id") + }) +} diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index c551ba54f1..0ece5e44ff 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -9,49 +9,45 @@ import ( ) var codexModelMap = map[string]string{ - "gpt-5.5": "gpt-5.5", - "gpt-5.5-pro": "gpt-5.5-pro", - "codex-auto-review": "codex-auto-review", - "gpt-5.4": "gpt-5.4", - "gpt-5.4-mini": "gpt-5.4-mini", - "gpt-5.4-none": "gpt-5.4", - "gpt-5.4-low": "gpt-5.4", - "gpt-5.4-medium": "gpt-5.4", - "gpt-5.4-high": "gpt-5.4", - "gpt-5.4-xhigh": "gpt-5.4", - "gpt-5.4-chat-latest": "gpt-5.4", - "gpt-5.3": "gpt-5.3-codex", - "gpt-5.3-none": "gpt-5.3-codex", - "gpt-5.3-low": "gpt-5.3-codex", - "gpt-5.3-medium": "gpt-5.3-codex", - "gpt-5.3-high": "gpt-5.3-codex", - "gpt-5.3-xhigh": "gpt-5.3-codex", - "gpt-5.3-codex": "gpt-5.3-codex", - "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", - "gpt-5.3-codex-spark-low": "gpt-5.3-codex-spark", - "gpt-5.3-codex-spark-medium": "gpt-5.3-codex-spark", - "gpt-5.3-codex-spark-high": "gpt-5.3-codex-spark", - "gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex-spark", - "gpt-5.3-codex-low": "gpt-5.3-codex", - "gpt-5.3-codex-medium": "gpt-5.3-codex", - "gpt-5.3-codex-high": "gpt-5.3-codex", - "gpt-5.3-codex-xhigh": "gpt-5.3-codex", - "gpt-5.2": "gpt-5.2", - "gpt-5.2-none": "gpt-5.2", - "gpt-5.2-low": "gpt-5.2", - "gpt-5.2-medium": "gpt-5.2", - "gpt-5.2-high": "gpt-5.2", - "gpt-5.2-xhigh": "gpt-5.2", - "gpt-5": "gpt-5.4", - "gpt-5-mini": "gpt-5.4", - "gpt-5-nano": "gpt-5.4", - "gpt-5.1": "gpt-5.4", - "gpt-5.1-codex": "gpt-5.3-codex", - "gpt-5.1-codex-max": "gpt-5.3-codex", - "gpt-5.1-codex-mini": "gpt-5.3-codex", - "gpt-5.2-codex": "gpt-5.2", - "codex-mini-latest": "gpt-5.3-codex", - "gpt-5-codex": "gpt-5.3-codex", + "gpt-5.5": "gpt-5.5", + "gpt-5.5-pro": "gpt-5.5-pro", + "codex-auto-review": "codex-auto-review", + "gpt-5.4": "gpt-5.4", + "gpt-5.4-mini": "gpt-5.4-mini", + "gpt-5.4-none": "gpt-5.4", + "gpt-5.4-low": "gpt-5.4", + "gpt-5.4-medium": "gpt-5.4", + "gpt-5.4-high": "gpt-5.4", + "gpt-5.4-xhigh": "gpt-5.4", + "gpt-5.4-chat-latest": "gpt-5.4", + "gpt-5.3": "gpt-5.3-codex", + "gpt-5.3-none": "gpt-5.3-codex", + "gpt-5.3-low": "gpt-5.3-codex", + "gpt-5.3-medium": "gpt-5.3-codex", + "gpt-5.3-high": "gpt-5.3-codex", + "gpt-5.3-xhigh": "gpt-5.3-codex", + "gpt-5.3-codex": "gpt-5.3-codex", + "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", + "gpt-5.3-codex-low": "gpt-5.3-codex", + "gpt-5.3-codex-medium": "gpt-5.3-codex", + "gpt-5.3-codex-high": "gpt-5.3-codex", + "gpt-5.3-codex-xhigh": "gpt-5.3-codex", + "gpt-5.2": "gpt-5.2", + "gpt-5.2-none": "gpt-5.2", + "gpt-5.2-low": "gpt-5.2", + "gpt-5.2-medium": "gpt-5.2", + "gpt-5.2-high": "gpt-5.2", + "gpt-5.2-xhigh": "gpt-5.2", + "gpt-5": "gpt-5.4", + "gpt-5-mini": "gpt-5.4", + "gpt-5-nano": "gpt-5.4", + "gpt-5.1": "gpt-5.4", + "gpt-5.1-codex": "gpt-5.3-codex", + "gpt-5.1-codex-max": "gpt-5.3-codex", + "gpt-5.1-codex-mini": "gpt-5.3-codex", + "gpt-5.2-codex": "gpt-5.2", + "codex-mini-latest": "gpt-5.3-codex", + "gpt-5-codex": "gpt-5.3-codex", } var codexVersionModelPrefixes = []struct { diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 13184047c3..20361a6952 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -344,8 +344,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } } - // Extract and save Codex usage snapshot from response headers (for OAuth accounts) - if handleErr == nil && account.Type == AccountTypeOAuth { + // Extract and save Codex usage snapshot from response headers (for OAuth accounts). + // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 + if handleErr == nil && account.Type == AccountTypeOAuth && !account.IsShadow() { if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 60839a0aa6..e135eead9c 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -410,8 +410,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } } - // Extract and save Codex usage snapshot from response headers (for OAuth accounts) - if handleErr == nil && account.Type == AccountTypeOAuth { + // Extract and save Codex usage snapshot from response headers (for OAuth accounts). + // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 + if handleErr == nil && account.Type == AccountTypeOAuth && !account.IsShadow() { if account.Platform == PlatformGrok { s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(resp.Header, resp.StatusCode)) } else if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index af0062db75..6de5164a9c 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1405,6 +1405,11 @@ func openAICompactSupportTier(account *Account) int { return 0 } +// isOpenAICompatibleAccountEligibleForRequest 判断 OpenAI 兼容账号是否满足本次请求的调度条件。 +// 检查内容包括:平台匹配、账号可用性、quota 自动暂停、spark 路由限制、模型支持及端点能力。 +// +// 注意:对 spark 影子账号,调用方还须额外调用 parentHealthyForShadow(account, lookup) +// 检查母账号凭据可用性;该检查未内置于本函数,以避免注入 DB 依赖。 func isOpenAICompatibleAccountEligibleForRequest(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool { platform = normalizeOpenAICompatiblePlatform(platform) if account == nil || account.Platform != platform || !account.IsOpenAICompatible() || !account.IsSchedulableForModelWithContext(ctx, requestedModel) { @@ -1858,6 +1863,10 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID if !isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, false, requiredCapability) { return nil } + if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { + _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) + return nil + } if s.isOpenAIAccountRuntimeBlocked(account) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil @@ -2067,6 +2076,8 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel, requireCompact) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) + } else if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { + _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else { result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result != nil && result.Acquired { @@ -2094,6 +2105,20 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } // ============ Layer 2: Load-aware selection ============ + // Per-pass parent-health cache to avoid repeated DB calls when multiple shadow + // accounts share the same parent. + parentCacheL2 := make(map[int64]*Account) + parentLookupL2 := func(id int64) *Account { + if a, ok := parentCacheL2[id]; ok { + return a + } + if s.accountRepo == nil { + return nil + } + a, _ := s.accountRepo.GetByID(ctx, id) + parentCacheL2[id] = a + return a + } baseCandidateCount := 0 candidates := make([]*Account, 0, len(accounts)) for i := range accounts { @@ -2107,6 +2132,9 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if !isOpenAICompatibleAccountEligibleForRequest(ctx, acc, platform, requestedModel, false, requiredCapability) { continue } + if !parentHealthyForShadow(acc, parentLookupL2) { + continue + } if s.isOpenAIAccountRuntimeBlocked(acc) { continue } @@ -2339,12 +2367,29 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context. if !isOpenAICompatibleAccountEligibleForRequest(ctx, fresh, platform, requestedModel, requireCompact, requiredCapability) { return nil } + if !parentHealthyForShadow(fresh, s.parentAccountLookup(ctx)) { + return nil + } if s.isOpenAIAccountRuntimeBlocked(fresh) { return nil } return fresh } +// parentAccountLookup 返回供 parentHealthyForShadow 使用的母账号解析闭包:经 accountRepo +// 按 ID 取当前 Account(repo 为空时 fail-closed 返回 nil)。统一调度/粘连各路径的母账号解析, +// 取代各调用点重复内联的同一闭包(历史上 recheck 等路径还漏写过 accountRepo==nil 守卫)。 +// L2 候选循环改用带 per-pass 缓存的 parentLookupL2,不走此方法。 +func (s *OpenAIGatewayService) parentAccountLookup(ctx context.Context) func(int64) *Account { + return func(id int64) *Account { + if s.accountRepo == nil { + return nil + } + a, _ := s.accountRepo.GetByID(ctx, id) + return a + } +} + func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { if account == nil { return nil @@ -2354,6 +2399,9 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co if !isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { return nil } + if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { + return nil + } return account } @@ -2364,6 +2412,9 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co if !isOpenAICompatibleAccountEligibleForRequest(ctx, latest, platform, requestedModel, requireCompact, requiredCapability) { return nil } + if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { + return nil + } if s.isOpenAIAccountRuntimeBlocked(latest) { return nil } @@ -2437,6 +2488,13 @@ func (s *OpenAIGatewayService) schedulingConfig() config.GatewaySchedulingConfig // GetAccessToken gets the access token for an OpenAI account func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) { + if account.IsShadow() { + credAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return "", "", err + } + account = credAccount + } switch account.Type { case AccountTypeOAuth: if account.Platform == PlatformGrok { @@ -3285,8 +3343,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } s.bindHTTPResponseAccount(ctx, c, account, responseID) - // Extract and save Codex usage snapshot from response headers (for OAuth accounts) - if account.Type == AccountTypeOAuth { + // Extract and save Codex usage snapshot from response headers (for OAuth accounts). + // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 + if account.Type == AccountTypeOAuth && !account.IsShadow() { if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } @@ -3522,8 +3581,11 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } s.bindHTTPResponseAccount(ctx, c, account, responseID) - if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { - s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) + // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 + if !account.IsShadow() { + if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { + s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) + } } if usage == nil { @@ -3637,7 +3699,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if account.Type == AccountTypeOAuth { promptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) req.Host = "chatgpt.com" - setOpenAIChatGPTAccountHeaders(req.Header, account) + if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil { + return nil, fmt.Errorf("resolve chatgpt account headers: %w", err) + } apiKeyID := getAPIKeyIDFromContext(c) // 先保存客户端原始值,再做 compact 补充,避免后续统一隔离时读到已处理的值。 clientSessionID := strings.TrimSpace(req.Header.Get("session_id")) @@ -4411,7 +4475,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if account.Type == AccountTypeOAuth { // Required: set Host for ChatGPT API (must use req.Host, not Header.Set) req.Host = "chatgpt.com" - setOpenAIChatGPTAccountHeaders(req.Header, account) + if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil { + return nil, fmt.Errorf("resolve chatgpt account headers: %w", err) + } } // Whitelist passthrough headers @@ -6755,6 +6821,10 @@ func buildCodexUsageExtraUpdates(snapshot *OpenAICodexUsageSnapshot, fallbackNow } // updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field +// updateCodexUsageSnapshot 把 /responses 的 x-codex-* 全局头快照写入账号 codex_* Extra。 +// ⚠️ 调用方必须排除 spark 影子账号(account.IsShadow()):影子的 codex_* 仅由 QueryUsage +// (/wham/usage bengalfox 道)更新,不能被全局头口径污染(外审第7轮 P1)。本函数仅持 accountID, +// 无法在此自检影子,故守卫前置到各调用点。 func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) { if snapshot == nil { return diff --git a/backend/internal/service/openai_gateway_service_shadow_token_test.go b/backend/internal/service/openai_gateway_service_shadow_token_test.go new file mode 100644 index 0000000000..7e18ab6235 --- /dev/null +++ b/backend/internal/service/openai_gateway_service_shadow_token_test.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestGetAccessToken_SparkShadowResolvesToParent 验证对影子账号调用 GetAccessToken +// 时能透明地解析到母账号的凭据,防止 refresh_token 脱钩。 +// 影子账号不持凭据;断言必须返回母账号的 access_token。 +func TestGetAccessToken_SparkShadowResolvesToParent(t *testing.T) { + ctx := context.Background() + + parentID := int64(100) + parent := Account{ + ID: parentID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "access_token": "parent-access-token", + }, + } + shadow := Account{ + ID: 200, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + // 影子账号不持凭据,与生产语义一致 + } + + repo := &stubOpenAIAccountRepo{accounts: []Account{parent}} + + svc := &OpenAIGatewayService{ + accountRepo: repo, + // openAITokenProvider=nil → 走降级路径,直接读 account.GetOpenAIAccessToken() + } + + // Before fix (RED): shadow 无凭据 → GetOpenAIAccessToken()="" → error + // After fix (GREEN): shadow 解析到 parent → 返回 parent 的 "parent-access-token" + token, tokenType, err := svc.GetAccessToken(ctx, &shadow) + require.NoError(t, err) + require.Equal(t, "parent-access-token", token) + require.Equal(t, "oauth", tokenType) +} diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index 9707e96f85..11f102dc34 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -13,6 +13,13 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) +// ErrSparkShadowResetNotSupported is returned when ResetCredit is called on a +// spark shadow account. Shadow accounts do not hold credentials of their own; +// the caller must reset the parent account directly. It is a structured +// infraerrors value so the handler maps it to 409 Conflict (not a bare 500); +// errors.Is still matches it by identity since ResetCredit returns this var. +var ErrSparkShadowResetNotSupported = infraerrors.New(http.StatusConflict, "SPARK_SHADOW_RESET_NOT_SUPPORTED", "spark shadow account does not support credit reset; reset the parent account") + // Endpoints used by the OpenAI/ChatGPT/Codex quota query and reset feature. const ( chatGPTUsageURL = "https://chatgpt.com/backend-api/wham/usage" @@ -159,6 +166,24 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* // The redeem_request_id is auto-generated (uuid-like) — upstream uses it for // idempotency. Returns the consumed credit metadata so the UI can refresh. func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (*OpenAIQuotaResetResult, error) { + // Shadow guard: resetting credits via a shadow account would silently + // operate on the parent's quota; that is surprising and unwanted. Callers + // must reset the parent account directly. + // + // Fail-closed: if the account cannot be loaded (transient DB error), we + // must NOT fall through to prepareUpstreamCall. That function resolves a + // shadow to its parent and would perform a parent-level reset — exactly + // what this guard must prevent. Return the load error instead. + if s.accountRepo != nil { + acc, loadErr := s.accountRepo.GetByID(ctx, accountID) + if loadErr != nil { + return nil, infraerrors.Newf(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", loadErr) + } + if acc.IsShadow() { + return nil, ErrSparkShadowResetNotSupported + } + } + accessToken, chatGPTAccountID, proxyURL, fedRAMP, err := s.prepareUpstreamCall(ctx, accountID) if err != nil { return nil, err @@ -227,6 +252,17 @@ func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_TYPE", "account is not an OAuth account") } + // Spark shadow accounts do not hold their own credentials; resolve to the + // parent account so that chatgpt_account_id / access_token / proxy all come + // from the parent. This must happen BEFORE the chatgpt_account_id check. + if account.IsShadow() { + resolved, rerr := resolveCredentialAccount(ctx, s.accountRepo, account) + if rerr != nil { + return "", "", "", false, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_SHADOW_RESOLVE_FAILED", "failed to resolve shadow account: %v", rerr) + } + account = resolved + } + chatGPTAccountID = strings.TrimSpace(account.GetCredential("chatgpt_account_id")) if chatGPTAccountID == "" { // Fall back to organization_id — some legacy accounts only persisted poid. @@ -298,6 +334,86 @@ func generateRedeemRequestID() (string, error) { return fmt.Sprintf("%s-%s-%s-%s-%s", hexStr[0:8], hexStr[8:12], hexStr[12:16], hexStr[16:20], hexStr[20:]), nil } +// buildCodexSparkWindowExtraUpdates extracts Codex Spark usage windows from the +// /wham/usage response body's additional_rate_limits, matching the entry with +// MeteredFeature == "codex_bengalfox". It produces plain codex_* keys (NOT the +// Method-Z "codex_spark_" prefix) so that a spark shadow account's extra map +// is populated with the same key names used by the scheduling / frontend layers. +// Returns nil when no codex_bengalfox entry is present or when the RateLimit +// yields no window data. +func buildCodexSparkWindowExtraUpdates(usage *OpenAIQuotaUsage, now time.Time) map[string]any { + if usage == nil { + return nil + } + var spark *OpenAIRateLimit + for i := range usage.AdditionalRateLimits { + a := usage.AdditionalRateLimits[i] + if a.MeteredFeature == "codex_bengalfox" { + spark = a.RateLimit + break + } + } + if spark == nil { + return nil + } + + // Reuse OpenAICodexUsageSnapshot / Normalize to map primary/secondary windows + // to canonical 5h/7d buckets (same logic as probeOpenAICodexSnapshot). + snap := &OpenAICodexUsageSnapshot{} + if w := spark.PrimaryWindow; w != nil { + p := w.UsedPercent + snap.PrimaryUsedPercent = &p + ra := int(w.ResetAfterSeconds) + snap.PrimaryResetAfterSeconds = &ra + wm := int(w.LimitWindowSeconds / 60) + snap.PrimaryWindowMinutes = &wm + } + if w := spark.SecondaryWindow; w != nil { + p := w.UsedPercent + snap.SecondaryUsedPercent = &p + ra := int(w.ResetAfterSeconds) + snap.SecondaryResetAfterSeconds = &ra + wm := int(w.LimitWindowSeconds / 60) + snap.SecondaryWindowMinutes = &wm + } + + normalized := snap.Normalize() + if normalized == nil { + return nil + } + + updates := make(map[string]any) + if normalized.Used5hPercent != nil { + updates["codex_5h_used_percent"] = *normalized.Used5hPercent + } + if normalized.Reset5hSeconds != nil { + updates["codex_5h_reset_after_seconds"] = *normalized.Reset5hSeconds + } + if normalized.Window5hMinutes != nil { + updates["codex_5h_window_minutes"] = *normalized.Window5hMinutes + } + if normalized.Used7dPercent != nil { + updates["codex_7d_used_percent"] = *normalized.Used7dPercent + } + if normalized.Reset7dSeconds != nil { + updates["codex_7d_reset_after_seconds"] = *normalized.Reset7dSeconds + } + if normalized.Window7dMinutes != nil { + updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes + } + if r := codexResetAtRFC3339(now, normalized.Reset5hSeconds); r != nil { + updates["codex_5h_reset_at"] = *r + } + if r := codexResetAtRFC3339(now, normalized.Reset7dSeconds); r != nil { + updates["codex_7d_reset_at"] = *r + } + if len(updates) == 0 { + return nil + } + updates["codex_usage_updated_at"] = now.Format(time.RFC3339) + return updates +} + // mapUpstreamStatus collapses upstream HTTP statuses into a stable set we // surface from the admin handler. 4xx upstream errors are surfaced as 502 // (BadGateway) so callers can distinguish "your input is bad" (400) from diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go new file mode 100644 index 0000000000..ceb46b708d --- /dev/null +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -0,0 +1,274 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/imroc/req/v3" + "github.com/stretchr/testify/require" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// ── stub helpers ───────────────────────────────────────────────────────────── + +// stubQuotaAccountRepo 是多账号 AccountRepository stub,仅实现 GetByID。 +type stubQuotaAccountRepo struct { + AccountRepository + accounts map[int64]*Account +} + +func (r *stubQuotaAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) { + acc, ok := r.accounts[id] + if !ok { + return nil, fmt.Errorf("account %d not found", id) + } + return acc, nil +} + +// stubQuotaTokenCache 实现 OpenAITokenCache,返回预设静态 token。 +type stubQuotaTokenCache struct { + tokens map[string]string +} + +func (c *stubQuotaTokenCache) GetAccessToken(_ context.Context, key string) (string, error) { + if t, ok := c.tokens[key]; ok { + return t, nil + } + return "", errors.New("token not found") +} + +func (c *stubQuotaTokenCache) SetAccessToken(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} + +func (c *stubQuotaTokenCache) DeleteAccessToken(_ context.Context, _ string) error { return nil } + +func (c *stubQuotaTokenCache) AcquireRefreshLock(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (c *stubQuotaTokenCache) ReleaseRefreshLock(_ context.Context, _ string) error { return nil } + +// newQuotaRedirectingFactory 返回 PrivacyClientFactory,将请求重定向到 httptest.Server。 +func newQuotaRedirectingFactory(srv *httptest.Server) PrivacyClientFactory { + targetURL, _ := url.Parse(srv.URL) + return func(_ string) (*req.Client, error) { + c := req.C().WrapRoundTripFunc(func(rt req.RoundTripper) req.RoundTripFunc { + return func(r *req.Request) (*req.Response, error) { + r.URL.Scheme = targetURL.Scheme + r.URL.Host = targetURL.Host + return rt.RoundTrip(r) + } + }) + return c, nil + } +} + +// ── Part A: buildCodexSparkWindowExtraUpdates ───────────────────────────────── + +// TestBuildCodexSparkWindowExtraUpdates_ContainsCodexKeys 验证: +// - 产出包含 codex_5h_used_percent / codex_7d_used_percent +// - 不含任何 codex_spark_ 前缀的 key(Method Z 前缀已禁止) +// - 数值正确映射(primary 较短→5h,secondary 较长→7d) +func TestBuildCodexSparkWindowExtraUpdates_ContainsCodexKeys(t *testing.T) { + now := time.Now().UTC() + usage := &OpenAIQuotaUsage{ + AdditionalRateLimits: []OpenAIAdditionalRateLimit{ + { + MeteredFeature: "codex_bengalfox", + RateLimit: &OpenAIRateLimit{ + PrimaryWindow: &OpenAIRateLimitWindow{ + UsedPercent: 0.42, + LimitWindowSeconds: 18000, // 300 min = 5 h + ResetAfterSeconds: 3600, + }, + SecondaryWindow: &OpenAIRateLimitWindow{ + UsedPercent: 0.15, + LimitWindowSeconds: 604800, // 7 d + ResetAfterSeconds: 86400, + }, + }, + }, + }, + } + + updates := buildCodexSparkWindowExtraUpdates(usage, now) + require.NotNil(t, updates, "expected non-nil updates for valid codex_bengalfox entry") + + // 必须含有 codex_5h_* 和 codex_7d_* 键 + require.Contains(t, updates, "codex_5h_used_percent") + require.Contains(t, updates, "codex_7d_used_percent") + + // 任何键不得含有 codex_spark_ 前缀(Method Z 已禁止) + for k := range updates { + require.False(t, strings.Contains(k, "codex_spark_"), + "unexpected Method-Z prefix in key: %s", k) + } + + // 数值验证(primary=5h, secondary=7d) + require.InDelta(t, 0.42, updates["codex_5h_used_percent"], 1e-9) + require.InDelta(t, 0.15, updates["codex_7d_used_percent"], 1e-9) +} + +// TestBuildCodexSparkWindowExtraUpdates_NilUsage 验证 nil usage 返回 nil。 +func TestBuildCodexSparkWindowExtraUpdates_NilUsage(t *testing.T) { + require.Nil(t, buildCodexSparkWindowExtraUpdates(nil, time.Now())) +} + +// TestBuildCodexSparkWindowExtraUpdates_NoBengalfox 验证无 codex_bengalfox 条目时返回 nil。 +func TestBuildCodexSparkWindowExtraUpdates_NoBengalfox(t *testing.T) { + usage := &OpenAIQuotaUsage{ + AdditionalRateLimits: []OpenAIAdditionalRateLimit{ + {MeteredFeature: "other_feature", RateLimit: &OpenAIRateLimit{}}, + }, + } + require.Nil(t, buildCodexSparkWindowExtraUpdates(usage, time.Now())) +} + +// ── Part C: ResetCredit 影子拒绝 ─────────────────────────────────────────── + +// TestResetCreditShadowRejected 验证: +// - ResetCredit(ctx, shadowID) 返回 ErrSparkShadowResetNotSupported +// - 不触达上游(privacyClientFactory 为 nil,若调用则 panic) +func TestResetCreditShadowRejected(t *testing.T) { + pid := int64(100) + shadow := &Account{ + ID: 200, + ParentAccountID: &pid, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + QuotaDimension: QuotaDimensionSpark, + } + repo := &stubQuotaAccountRepo{ + accounts: map[int64]*Account{200: shadow}, + } + // privacyClientFactory 故意为 nil —— 若流程误到上游则 prepareUpstreamCall 会先在 + // 配置检查处报错,但我们在此之前就应该拦截并返回 ErrSparkShadowResetNotSupported。 + svc := &OpenAIQuotaService{accountRepo: repo} + + _, err := svc.ResetCredit(context.Background(), 200) + require.ErrorIs(t, err, ErrSparkShadowResetNotSupported, + "shadow ResetCredit should return ErrSparkShadowResetNotSupported, got: %v", err) + // 外审 F6:必须是结构化 409(而非裸 error→500)。 + require.Equal(t, http.StatusConflict, infraerrors.Code(err), + "shadow ResetCredit 应映射为 409 Conflict 而非 500") +} + +// ── Part B: prepareUpstreamCall 影子 resolve ────────────────────────────── + +// TestPrepareUpstreamCallShadowResolve 验证影子账号(200)QueryUsage 时: +// - 不因 chatgpt_account_id 为空而报错 +// - 使用母账号(100)的 chatgpt_account_id("org-parent123") +// +// 测试策略: 直接调用包内可见的 prepareUpstreamCall,注入 stubTokenCache(命中路径) +// 和 stubQuotaAccountRepo(同时持有影子+母账号),绕开 /wham/usage HTTP 往返。 +// 这比 httptest 端到端 mock 更轻量且对实现细节的耦合更低。 +func TestPrepareUpstreamCallShadowResolve(t *testing.T) { + ctx := context.Background() + pid := int64(100) + + // 影子账号:无 chatgpt_account_id credentials + shadow := &Account{ + ID: 200, + ParentAccountID: &pid, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + QuotaDimension: QuotaDimensionSpark, + } + // 母账号:有完整 credentials + parent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "chatgpt_account_id": "org-parent123", + }, + } + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{200: shadow, 100: parent}} + + // stubTokenCache 为母账号 cache key 提供 fake token(走缓存命中路径,无需真实刷新) + tokenCache := &stubQuotaTokenCache{tokens: map[string]string{ + OpenAITokenCacheKey(parent): "fake-access-token", + }} + tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil) + + // privacyClientFactory 可以是任意合法工厂;prepareUpstreamCall 在返回前不调用它 + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, func(_ string) (*req.Client, error) { + return req.C(), nil + }) + + _, chatGPTAccountID, _, _, err := svc.prepareUpstreamCall(ctx, 200) + require.NoError(t, err, "shadow resolve should succeed; got error: %v", err) + require.Equal(t, "org-parent123", chatGPTAccountID, + "prepareUpstreamCall should use parent's chatgpt_account_id after shadow resolve") +} + +// TestResetCreditGetByIDError_FailsClosed 验证守卫「失败关闭」语义: +// 当守卫的 GetByID 发生瞬时错误时,ResetCredit 必须立即返回该错误, +// 不得旁路进入 prepareUpstreamCall(否则影子账号会借 resolve 路径操作母账号)。 +// +// 区分方法:privacyClientFactory/tokenProvider 留 nil; +// - 旁路路径:prepareUpstreamCall 配置检查先命中,报 "not configured" +// - 守卫正确关闭:报 "account not found"(来自守卫的 infraerrors) +func TestResetCreditGetByIDError_FailsClosed(t *testing.T) { + // 空 map:GetByID(200) 返回 "account 200 not found" + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{}} + // tokenProvider / privacyClientFactory 故意为 nil: + // 若代码泄漏到 prepareUpstreamCall,会因配置检查而报 "not configured" 而非 "account not found"。 + svc := &OpenAIQuotaService{accountRepo: repo} + + _, err := svc.ResetCredit(context.Background(), 200) + require.Error(t, err, "GetByID error must propagate; got nil") + require.NotContains(t, err.Error(), "not configured", + "error reached prepareUpstreamCall config-check — guard did not fail-closed; got: %v", err) +} + +// TestQueryUsageShadowResolve_EndToEnd 是端到端补充:通过 httptest 服务真实 /wham/usage +// 路径,验证影子账号的 QueryUsage 能成功拿到服务器响应(header 由母账号注入)。 +func TestQueryUsageShadowResolve_EndToEnd(t *testing.T) { + 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-e2e-parent"}, + } + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{200: shadow, 100: parent}} + + tokenCache := &stubQuotaTokenCache{tokens: map[string]string{ + OpenAITokenCacheKey(parent): "fake-token-e2e", + }} + tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil) + + // httptest server 记录收到的 chatgpt-account-id header,返回空 usage JSON + 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") + _ = json.NewEncoder(w).Encode(OpenAIQuotaUsage{}) + })) + defer srv.Close() + + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + usage, err := svc.QueryUsage(ctx, 200) + require.NoError(t, err) + require.NotNil(t, usage) + require.Equal(t, "org-e2e-parent", capturedAccountID, + "upstream should receive parent's chatgpt-account-id; got: %s", capturedAccountID) +} diff --git a/backend/internal/service/openai_spark_shadow_parent_health_test.go b/backend/internal/service/openai_spark_shadow_parent_health_test.go new file mode 100644 index 0000000000..aece8eb696 --- /dev/null +++ b/backend/internal/service/openai_spark_shadow_parent_health_test.go @@ -0,0 +1,220 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestParentHealthyForShadow covers the pure helper function used across +// scheduler + gateway selection + WS forwarder. +func TestParentHealthyForShadow(t *testing.T) { + pid := int64(100) + + // 所有母账号 fixture 均设 Type=oauth:parentHealthyForShadow 现要求母账号仍是 OpenAI OAuth + // (外审 D fail-closed),不设则各用例会因"非 oauth"而非被测原因失败,使断言失去意义。 + healthyParent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + } + unhealthyParent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusError, + Schedulable: true, // Schedulable flag is set, but Status=error → IsSchedulable()==false + } + shadow := &Account{ + ID: 200, + ParentAccountID: &pid, + QuotaDimension: QuotaDimensionSpark, + Platform: PlatformOpenAI, + Status: StatusActive, + Schedulable: true, + } + normalAccount := &Account{ + ID: 300, + Platform: PlatformOpenAI, + Status: StatusActive, + Schedulable: true, + } + + t.Run("shadow_of_healthy_parent_is_healthy", func(t *testing.T) { + lookup := func(id int64) *Account { + if id == healthyParent.ID { + return healthyParent + } + return nil + } + require.True(t, parentHealthyForShadow(shadow, lookup)) + }) + + t.Run("shadow_of_unhealthy_parent_is_not_healthy", func(t *testing.T) { + // Parent Status=error means IsActive()==false → IsSchedulable()==false. + require.False(t, unhealthyParent.IsSchedulable(), "precondition: unhealthy parent must not be schedulable") + lookup := func(id int64) *Account { + if id == unhealthyParent.ID { + return unhealthyParent + } + return nil + } + require.False(t, parentHealthyForShadow(shadow, lookup)) + }) + + t.Run("shadow_parent_not_found_is_not_healthy", func(t *testing.T) { + lookup := func(_ int64) *Account { return nil } + require.False(t, parentHealthyForShadow(shadow, lookup)) + }) + + t.Run("normal_account_always_healthy", func(t *testing.T) { + // lookup should never be called for non-shadow accounts. + calledLookup := false + lookup := func(_ int64) *Account { + calledLookup = true + return nil + } + require.True(t, parentHealthyForShadow(normalAccount, lookup)) + require.False(t, calledLookup, "lookup must not be called for non-shadow accounts") + }) + + t.Run("nil_account_always_healthy", func(t *testing.T) { + lookup := func(_ int64) *Account { return nil } + require.True(t, parentHealthyForShadow(nil, lookup)) + }) + + t.Run("manual_schedulable_false_parent_does_not_block_shadow", func(t *testing.T) { + // F1 决策 A:母账号被手动暂停(Schedulable=false)是「调度配置」而非「凭据不可用」, + // 不传播到影子——影子有自己的 Schedulable 开关。凭据(active+未过期)仍可用 → 影子健康。 + manualPausedParent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: false, + } + require.False(t, manualPausedParent.IsSchedulable(), "precondition: 母账号被手动暂停不可调度") + lookup := func(id int64) *Account { + if id == manualPausedParent.ID { + return manualPausedParent + } + return nil + } + require.True(t, parentHealthyForShadow(shadow, lookup), + "母账号手动暂停不应连坐影子(凭据仍可用)") + }) + + t.Run("global_rate_limited_parent_does_not_block_shadow", func(t *testing.T) { + // F1 核心修复:母账号 global 429(RateLimitResetAt 未来)是 global 维度限流, + // spark 有独立窗口 → 不得连坐影子,否则违背「global 枯竭后 spark 仍独立」目标。 + resetAt := time.Now().Add(1 * time.Hour) + rateLimitedParent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + RateLimitResetAt: &resetAt, + } + require.False(t, rateLimitedParent.IsSchedulable(), "precondition: global 限流母账号自身不可调度") + lookup := func(id int64) *Account { + if id == rateLimitedParent.ID { + return rateLimitedParent + } + return nil + } + require.True(t, parentHealthyForShadow(shadow, lookup), + "母账号 global 限流不应连坐 spark 影子") + }) + + t.Run("overloaded_parent_does_not_block_shadow", func(t *testing.T) { + // 过载退避(OverloadUntil)同属 global 维度运行态,不连坐影子。 + until := time.Now().Add(30 * time.Minute) + overloadedParent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + OverloadUntil: &until, + } + require.False(t, overloadedParent.IsSchedulable(), "precondition: 过载母账号自身不可调度") + lookup := func(id int64) *Account { + if id == overloadedParent.ID { + return overloadedParent + } + return nil + } + require.True(t, parentHealthyForShadow(shadow, lookup), + "母账号过载退避不应连坐 spark 影子") + }) + + t.Run("temp_unschedulable_parent_blocks_shadow", func(t *testing.T) { + // G2:TempUnschedulableUntil 对 OpenAI 账号由 401/token 刷新耗尽/transport·proxy 写入, + // 代表共享凭据/传输坏死 → 影子共享母 token+proxy,应被挡(与 global 限流 RateLimitResetAt 区分)。 + until := time.Now().Add(15 * time.Minute) + tempUnschedParent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + TempUnschedulableUntil: &until, + } + lookup := func(id int64) *Account { + if id == tempUnschedParent.ID { + return tempUnschedParent + } + return nil + } + require.False(t, parentHealthyForShadow(shadow, lookup), + "母账号 TempUnschedulableUntil(凭据/传输坏死)冷却期内应挡住影子") + }) + + t.Run("expired_parent_credentials_block_shadow", func(t *testing.T) { + // 凭据真正过期(AutoPauseOnExpired + ExpiresAt 已过)→ 透传 token 不可用 → 影子应被挡。 + expiredAt := time.Now().Add(-1 * time.Hour) + expiredParent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + AutoPauseOnExpired: true, + ExpiresAt: &expiredAt, + } + lookup := func(id int64) *Account { + if id == expiredParent.ID { + return expiredParent + } + return nil + } + require.False(t, parentHealthyForShadow(shadow, lookup), + "母账号凭据过期时影子应被挡(透传 token 不可用)") + }) + + t.Run("non_oauth_parent_blocks_shadow", func(t *testing.T) { + // 外审 D:母账号被改成非 OpenAI OAuth(如 apikey)后,透传凭据解析必失败, + // 影子应 fail-closed 不进调度候选(即便账号 active、凭据未过期)。 + apikeyParent := &Account{ + ID: 100, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + } + lookup := func(id int64) *Account { + if id == apikeyParent.ID { + return apikeyParent + } + return nil + } + require.False(t, parentHealthyForShadow(shadow, lookup), + "母账号非 OpenAI OAuth 时影子应被挡(fail-closed)") + }) +} diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index efc805bc06..f57515302d 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -1109,6 +1109,7 @@ func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (stri } func (s *OpenAIGatewayService) buildOpenAIWSHeaders( + ctx context.Context, c *gin.Context, account *Account, token string, @@ -1117,7 +1118,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( turnState string, turnMetadata string, promptCacheKey string, -) (http.Header, openAIWSSessionHeaderResolution) { +) (http.Header, openAIWSSessionHeaderResolution, error) { headers := make(http.Header) headers.Set("authorization", "Bearer "+token) @@ -1152,7 +1153,9 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } if account != nil && account.Type == AccountTypeOAuth { - setOpenAIChatGPTAccountHeaders(headers, account) + if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { + return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err) + } headers.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) } @@ -1180,7 +1183,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( headers.Set("user-agent", codexCLIUserAgent) } - return headers, sessionResolution + return headers, sessionResolution, nil } func (s *OpenAIGatewayService) buildOpenAIWSCreatePayload(reqBody map[string]any, account *Account) map[string]any { @@ -1867,7 +1870,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( storeDisabledConnMode := s.openAIWSStoreDisabledConnMode() forceNewConnByPolicy := shouldForceNewConnOnStoreDisabled(storeDisabledConnMode, lastFailureReason) forceNewConn := forceNewConnByPolicy && storeDisabled && previousResponseID == "" && sessionHash != "" && preferredConnID == "" - wsHeaders, sessionResolution := s.buildOpenAIWSHeaders(c, account, token, decision, isCodexCLI, turnState, turnMetadata, promptCacheKey) + wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, decision, isCodexCLI, turnState, turnMetadata, promptCacheKey) + if buildHdrErr != nil { + return nil, fmt.Errorf("build ws headers: %w", buildHdrErr) + } logOpenAIWSModeDebug( "acquire_start account_id=%d account_type=%s transport=%s preferred_conn_id=%s has_previous_response_id=%v session_hash=%s has_turn_state=%v turn_state_len=%d has_turn_metadata=%v turn_metadata_len=%d store_disabled=%v store_disabled_conn_mode=%s retry_last_reason=%s force_new_conn=%v header_user_agent=%s header_openai_beta=%s header_originator=%s header_accept_language=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_prompt_cache_key=%v has_chatgpt_account_id=%v has_authorization=%v has_session_id=%v has_conversation_id=%v proxy_enabled=%v", account.ID, @@ -2955,7 +2961,10 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } - wsHeaders, _ := s.buildOpenAIWSHeaders(c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), firstPayload.promptCacheKey) + wsHeaders, _, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), firstPayload.promptCacheKey) + if buildHdrErr != nil { + return fmt.Errorf("build ws headers: %w", buildHdrErr) + } baseAcquireReq := openAIWSAcquireRequest{ Account: account, WSURL: wsURL, @@ -3944,8 +3953,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if nextPayload.promptCacheKey != "" { // ingress 会话在整个客户端 WS 生命周期内复用同一上游连接; // prompt_cache_key 对握手头的更新仅在未来需要重新建连时生效。 - updatedHeaders, _ := s.buildOpenAIWSHeaders(c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), nextPayload.promptCacheKey) - baseAcquireReq.Headers = updatedHeaders + updatedHeaders, _, updHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), nextPayload.promptCacheKey) + if updHdrErr != nil { + logOpenAIWSModeInfo("ingress_ws_update_headers_failed account_id=%d err=%v", account.ID, updHdrErr) + } else { + baseAcquireReq.Headers = updatedHeaders + } } if nextPayload.previousResponseID != "" { expectedPrev := strings.TrimSpace(lastTurnResponseID) @@ -4327,6 +4340,10 @@ func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return nil, nil } + if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return nil, nil + } if requestedModel != "" && !account.IsModelSupported(requestedModel) { return nil, nil } @@ -4350,6 +4367,10 @@ func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return nil, nil } + if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return nil, nil + } if requestedModel != "" && !latest.IsModelSupported(requestedModel) { return nil, nil } diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index c93d0981d5..7241f11ed1 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -345,7 +345,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) } - headers, _ := s.buildOpenAIWSHeaders(c, account, token, wsDecision, isCodexCLI, turnState, turnMetadata, promptCacheKey) + headers, _, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, turnMetadata, promptCacheKey) + if buildHdrErr != nil { + return fmt.Errorf("build ws headers: %w", buildHdrErr) + } proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index abdcec5cbb..8bcce231c7 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -225,44 +225,53 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc } // 其他 400 错误(如参数问题)不处理,不禁用账号 case 401: + // 外审第9轮:Spark 影子无独立凭据,401 是母账号 token 问题——失效缓存 / refresh_token 判断 / + // 永久禁用 / 临时不可调度都必须落到凭据 owner(母账号),否则影子(无 refresh_token)必中 + // "refresh_token missing"永久禁用分支、母账号 token cache 也不会被清,把母账号可恢复的 token + // 问题变成影子永久死亡。母账号被标记 temp-unschedulable 后由 parentHealthyForShadow 级联排除影子。 + // 非影子时 resolveCredentialAccount 返回自身;母账号缺失/损坏(orphan 影子,罕见)时回退到原 account。 + authAccount := account + if resolved, rerr := resolveCredentialAccount(ctx, s.accountRepo, account); rerr == nil && resolved != nil { + authAccount = resolved + } // OpenAI: token_invalidated / token_revoked 表示 token 被永久作废(非过期),直接标记 error openai401Code := extractUpstreamErrorCode(responseBody) - if account.Platform == PlatformOpenAI && (openai401Code == "token_invalidated" || openai401Code == "token_revoked") { + if authAccount.Platform == PlatformOpenAI && (openai401Code == "token_invalidated" || openai401Code == "token_revoked") { msg := "Token revoked (401): account authentication permanently revoked" if upstreamMsg != "" { msg = "Token revoked (401): " + upstreamMsg } - s.handleAuthError(ctx, account, msg) + s.handleAuthError(ctx, authAccount, msg) shouldDisable = true break } // OpenAI: {"detail":"Unauthorized"} 表示 token 完全无效(非标准 OpenAI 错误格式),直接标记 error - if account.Platform == PlatformOpenAI && gjson.GetBytes(responseBody, "detail").String() == "Unauthorized" { + if authAccount.Platform == PlatformOpenAI && gjson.GetBytes(responseBody, "detail").String() == "Unauthorized" { msg := "Unauthorized (401): account authentication failed permanently" if upstreamMsg != "" { msg = "Unauthorized (401): " + upstreamMsg } - s.handleAuthError(ctx, account, msg) + s.handleAuthError(ctx, authAccount, msg) shouldDisable = true break } // OAuth 账号在 401 错误时临时不可调度(给 token 刷新窗口);非 OAuth 账号保持原有 SetError 行为。 // Antigravity 除外:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制。 - if account.Type == AccountTypeOAuth && account.Platform != PlatformAntigravity { + if authAccount.Type == AccountTypeOAuth && authAccount.Platform != PlatformAntigravity { // 1. 失效缓存 if s.tokenCacheInvalidator != nil { - if err := s.tokenCacheInvalidator.InvalidateToken(ctx, account); err != nil { - slog.Warn("oauth_401_invalidate_cache_failed", "account_id", account.ID, "error", err) + if err := s.tokenCacheInvalidator.InvalidateToken(ctx, authAccount); err != nil { + slog.Warn("oauth_401_invalidate_cache_failed", "account_id", authAccount.ID, "error", err) } } // 缺少 refresh_token 的 OAuth 账号无法在冷却期内自愈(后台刷新服务也会跳过), // 直接走 SetError 永久禁用,避免冷却结束后再被选中产生一发无意义的 502。 - if strings.TrimSpace(account.GetCredential("refresh_token")) == "" { + if strings.TrimSpace(authAccount.GetCredential("refresh_token")) == "" { msg := "Authentication failed (401): refresh_token missing, cannot recover" if upstreamMsg != "" { msg = "OAuth 401 (no refresh_token): " + upstreamMsg } - s.handleAuthError(ctx, account, msg) + s.handleAuthError(ctx, authAccount, msg) shouldDisable = true break } @@ -284,9 +293,9 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc cooldownMinutes = 10 } until := time.Now().Add(time.Duration(cooldownMinutes) * time.Minute) - s.notifyAccountSchedulingBlocked(account, until, "oauth_401") - if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, msg); err != nil { - slog.Warn("oauth_401_set_temp_unschedulable_failed", "account_id", account.ID, "error", err) + s.notifyAccountSchedulingBlocked(authAccount, until, "oauth_401") + if err := s.accountRepo.SetTempUnschedulable(ctx, authAccount.ID, until, msg); err != nil { + slog.Warn("oauth_401_set_temp_unschedulable_failed", "account_id", authAccount.ID, "error", err) } shouldDisable = true } else { @@ -295,7 +304,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc if upstreamMsg != "" { msg = "Authentication failed (401): " + upstreamMsg } - s.handleAuthError(ctx, account, msg) + s.handleAuthError(ctx, authAccount, msg) shouldDisable = true } case 402: @@ -881,6 +890,14 @@ func (s *RateLimitService) handleCustomErrorCode(ctx context.Context, account *A // handle429 处理429限流错误 // 解析响应头获取重置时间,标记账号为限流状态 func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header, responseBody []byte) { + // Spark 影子:限流/熔断状态 100% 由 QueryUsage(/wham/usage body 的 codex_bengalfox)驱动。 + // /responses 的 429 携带的 x-codex-*/usage_limit_reached 是 global codex 道(plan/spec §8), + // 套到影子会把 spark 误耦合到 global 窗口——即便 spark 仍有配额也会被冷却到 global reset, + // 单影子场景直接变成无可用账号(外审第8轮 P1)。整段跳过;影子的 codex_* 仅由 account_usage 的 + // QueryUsage→persistOpenAICodexProbeSnapshot 维护,枯竭由调度守卫处理。 + if account.IsShadow() { + return + } // 1. OpenAI 平台:优先尝试解析 x-codex-* 响应头(用于 rate_limit_exceeded) if account.Platform == PlatformOpenAI { persistOpenAI429PlanType(ctx, s.accountRepo, account, responseBody) @@ -1306,6 +1323,11 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou if s == nil || s.accountRepo == nil || account == nil || headers == nil { return } + // spark 影子的 codex_* 仅由 QueryUsage(/wham/usage bengalfox 道)更新,不能被 /responses 的 + // x-codex-* 全局头快照污染(外审第7轮 P1,与 updateCodexUsageSnapshot 同口径)。 + if account.IsShadow() { + return + } snapshot := ParseCodexRateLimitHeaders(headers) if snapshot == nil { return @@ -1397,6 +1419,12 @@ func persistOpenAI429PlanType(ctx context.Context, repo AccountRepository, accou if repo == nil || account == nil || account.Platform != PlatformOpenAI { return } + // spark 影子账号恒不持凭据:即便收到带 plan_type 的 429,也不能把 plan_type 写进影子 credentials + // ——该路径走 repo.BulkUpdate 直写、不经 persistAccountCredentials 守卫(外审第7轮 P1)。 + // plan_type 由母账号在自己的请求上维护,影子跳过。 + if account.IsCredentialShadow() { + return + } planType := parseOpenAIRateLimitPlanType(body) if planType == "" { diff --git a/backend/internal/service/ratelimit_service_401_test.go b/backend/internal/service/ratelimit_service_401_test.go index 873aaf3305..d06c2cb7a9 100644 --- a/backend/internal/service/ratelimit_service_401_test.go +++ b/backend/internal/service/ratelimit_service_401_test.go @@ -21,23 +21,27 @@ type rateLimitAccountRepoStub struct { lastCredentials map[string]any lastErrorMsg string lastTempReason string + lastErrorID int64 + lastTempID int64 } func (r *rateLimitAccountRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error { r.setErrorCalls++ + r.lastErrorID = id r.lastErrorMsg = errorMsg return nil } func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error { r.tempCalls++ + r.lastTempID = id r.lastTempReason = reason return nil } func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error { r.updateCredentialsCalls++ - r.lastCredentials = cloneCredentials(credentials) + r.lastCredentials = shallowCopyMap(credentials) return nil } @@ -128,6 +132,45 @@ func TestRateLimitService_HandleUpstreamError_OAuth401SetsTempUnschedulable(t *t }) } +// TestRateLimitService_HandleUpstreamError_SparkShadow401RedirectsToParent 外审第9轮:影子无独立凭据, +// 401(母账号 token 问题)必须重定向到凭据 owner(母账号)——母账号 temp-unschedulable + token cache 失效, +// 影子不得被永久禁用(否则母账号可恢复的 token 问题会把影子永久打死)。 +func TestRateLimitService_HandleUpstreamError_SparkShadow401RedirectsToParent(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + repo.accountsByID = map[int64]*Account{} + invalidator := &tokenCacheInvalidatorRecorder{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + service.SetTokenCacheInvalidator(invalidator) + + const parentID = int64(500) + mother := &Account{ + ID: parentID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "rt-mother"}, + } + repo.accountsByID[parentID] = mother + + shadowParent := parentID + shadow := &Account{ + ID: 501, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &shadowParent, + QuotaDimension: QuotaDimensionSpark, + // 影子不持凭据:GetCredential("refresh_token") == "" + } + + shouldDisable := service.HandleUpstreamError(context.Background(), shadow, 401, http.Header{}, []byte("unauthorized")) + + require.True(t, shouldDisable) + require.Equal(t, 0, repo.setErrorCalls, "spark shadow must not be permanently disabled on a parent-token 401") + require.Equal(t, 1, repo.tempCalls) + require.Equal(t, parentID, repo.lastTempID, "temp-unschedulable must target the credential owner (parent)") + require.Len(t, invalidator.accounts, 1) + require.Equal(t, parentID, invalidator.accounts[0].ID, "token cache invalidation must target the parent") +} + // TestRateLimitService_HandleUpstreamError_OAuth401InvalidatorError // OpenAI OAuth 401 缓存失效出错时仍走 temp_unschedulable。 // 注意:401 handler 不再回写 credentials(避免请求开始时的快照整列覆盖 DB diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go index aa5a070c74..4b619e15b2 100644 --- a/backend/internal/service/ratelimit_service_openai_test.go +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -219,6 +219,44 @@ func TestHandle429_OpenAISyncsObservedPlanType(t *testing.T) { require.Equal(t, account.ID, repo.rateLimitedID) } +// TestHandle429_SkipsSparkShadow 外审第8轮 P1:spark 影子的限流状态只由 QueryUsage(/wham/usage +// codex_bengalfox)维护;/responses 429 携带的 global x-codex-* 不得对影子做任何 DB 限流写入, +// 否则会把 spark 误耦合到 global codex 窗口、冷却到 global reset。 +func TestHandle429_SkipsSparkShadow(t *testing.T) { + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "100") + headers.Set("x-codex-primary-reset-after-seconds", "604800") + headers.Set("x-codex-primary-window-minutes", "10080") + headers.Set("x-codex-secondary-used-percent", "100") + headers.Set("x-codex-secondary-reset-after-seconds", "18000") + headers.Set("x-codex-secondary-window-minutes", "300") + + parentID := int64(900) + shadowRepo := &openAI429SnapshotRepo{} + shadowSvc := NewRateLimitService(shadowRepo, nil, nil, nil, nil) + shadow := &Account{ + ID: 901, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + } + + shadowSvc.handle429(context.Background(), shadow, headers, nil) + + require.Zero(t, shadowRepo.rateLimitedID, "spark shadow must not be SetRateLimited from /responses global 429") + require.Empty(t, shadowRepo.updatedExtra, "spark shadow must not get a codex snapshot from /responses 429") + + // 反向对照:普通 OpenAI OAuth 账号仍按 global 429 限流。 + normalRepo := &openAI429SnapshotRepo{} + normalSvc := NewRateLimitService(normalRepo, nil, nil, nil, nil) + normal := &Account{ID: 902, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + normalSvc.handle429(context.Background(), normal, headers, nil) + + require.Equal(t, normal.ID, normalRepo.rateLimitedID, "normal OpenAI OAuth account should still be rate limited") +} + func TestNormalizedCodexLimits(t *testing.T) { // Test the Normalize() method directly pUsed := 100.0 diff --git a/backend/internal/service/ratelimit_session_window_test.go b/backend/internal/service/ratelimit_session_window_test.go index d2ff0d835c..9337ae5f8f 100644 --- a/backend/internal/service/ratelimit_session_window_test.go +++ b/backend/internal/service/ratelimit_session_window_test.go @@ -162,6 +162,9 @@ func (m *sessionWindowMockRepo) ResetQuotaUsed(context.Context, int64) error { p func (m *sessionWindowMockRepo) RevertProxyFallback(context.Context, int64) error { panic("unexpected") } +func (m *sessionWindowMockRepo) ListShadowsByParent(context.Context, int64) ([]*Account, error) { + panic("unexpected") +} // newRateLimitServiceForTest creates a RateLimitService with the given mock repo. func newRateLimitServiceForTest(repo AccountRepository) *RateLimitService { diff --git a/backend/internal/service/shadow_routing.go b/backend/internal/service/shadow_routing.go new file mode 100644 index 0000000000..7493be82b2 --- /dev/null +++ b/backend/internal/service/shadow_routing.go @@ -0,0 +1,51 @@ +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 +} diff --git a/backend/internal/service/shadow_routing_test.go b/backend/internal/service/shadow_routing_test.go new file mode 100644 index 0000000000..ee60006c42 --- /dev/null +++ b/backend/internal/service/shadow_routing_test.go @@ -0,0 +1,21 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDefaultSparkShadowModelMapping(t *testing.T) { + mapping := defaultSparkShadowModelMapping() + + require.Len(t, mapping, 1, "spark 无 effort 变体,默认只含 base 模型") + require.Equal(t, "gpt-5.3-codex-spark", mapping["gpt-5.3-codex-spark"], "恒等映射:base 映射到自身") +} + +func TestSparkModelVariantsDerivedFromAliases(t *testing.T) { + got := sparkModelVariants() + require.ElementsMatch(t, []string{ + "gpt-5.3-codex-spark", + }, got, "spark 只有 base:effort 变体不存在,已从 codexModelMap 移除") +} diff --git a/backend/internal/service/spark_shadow_integration_test.go b/backend/internal/service/spark_shadow_integration_test.go new file mode 100644 index 0000000000..452b11dc70 --- /dev/null +++ b/backend/internal/service/spark_shadow_integration_test.go @@ -0,0 +1,192 @@ +//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") + }) +} diff --git a/backend/internal/service/token_refresh_service_test.go b/backend/internal/service/token_refresh_service_test.go index df14edaf28..d2315c1db5 100644 --- a/backend/internal/service/token_refresh_service_test.go +++ b/backend/internal/service/token_refresh_service_test.go @@ -39,7 +39,7 @@ func (r *tokenRefreshAccountRepo) UpdateCredentials(ctx context.Context, id int6 if r.updateErr != nil { return r.updateErr } - cloned := cloneCredentials(credentials) + cloned := shallowCopyMap(credentials) if r.accountsByID != nil { if acc, ok := r.accountsByID[id]; ok && acc != nil { acc.Credentials = cloned diff --git a/backend/internal/service/token_refresher.go b/backend/internal/service/token_refresher.go index 9ad65291dd..da5edb782c 100644 --- a/backend/internal/service/token_refresher.go +++ b/backend/internal/service/token_refresher.go @@ -90,6 +90,9 @@ func (r *OpenAITokenRefresher) CacheKey(account *Account) string { // CanRefresh 检查是否能处理此账号 func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool { + if account.IsCredentialShadow() { + return false + } return account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth } diff --git a/backend/migrations/154_account_spark_shadow.sql b/backend/migrations/154_account_spark_shadow.sql new file mode 100644 index 0000000000..b712b0a783 --- /dev/null +++ b/backend/migrations/154_account_spark_shadow.sql @@ -0,0 +1,30 @@ +-- 154_account_spark_shadow.sql +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS parent_account_id BIGINT, + ADD COLUMN IF NOT EXISTS quota_dimension VARCHAR(20) NOT NULL DEFAULT 'global'; + +-- 幂等加约束:维度合法 + 禁自指 + parent⟺非global 维度一致(评审 P1-d) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_accounts_quota_dimension') THEN + ALTER TABLE accounts ADD CONSTRAINT chk_accounts_quota_dimension + CHECK (quota_dimension IN ('global','spark')) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_accounts_parent_dimension') THEN + ALTER TABLE accounts ADD CONSTRAINT chk_accounts_parent_dimension + CHECK ((parent_account_id IS NULL AND quota_dimension = 'global') + OR (parent_account_id IS NOT NULL AND quota_dimension <> 'global')) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_accounts_parent_not_self') THEN + ALTER TABLE accounts ADD CONSTRAINT chk_accounts_parent_not_self + CHECK (parent_account_id IS NULL OR parent_account_id <> id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_accounts_parent_account_id') THEN + ALTER TABLE accounts ADD CONSTRAINT fk_accounts_parent_account_id + FOREIGN KEY (parent_account_id) REFERENCES accounts(id) ON DELETE RESTRICT NOT VALID; + END IF; +END $$; + +ALTER TABLE accounts VALIDATE CONSTRAINT chk_accounts_quota_dimension; +ALTER TABLE accounts VALIDATE CONSTRAINT chk_accounts_parent_dimension; +ALTER TABLE accounts VALIDATE CONSTRAINT chk_accounts_parent_not_self; +ALTER TABLE accounts VALIDATE CONSTRAINT fk_accounts_parent_account_id; diff --git a/backend/migrations/154a_account_spark_shadow_indexes_notx.sql b/backend/migrations/154a_account_spark_shadow_indexes_notx.sql new file mode 100644 index 0000000000..8653f63fc6 --- /dev/null +++ b/backend/migrations/154a_account_spark_shadow_indexes_notx.sql @@ -0,0 +1,6 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_accounts_parent_account_id + ON accounts (parent_account_id) WHERE parent_account_id IS NOT NULL; + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_accounts_spark_shadow_per_parent + ON accounts (parent_account_id) + WHERE parent_account_id IS NOT NULL AND quota_dimension = 'spark' AND deleted_at IS NULL; diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go index 047ea7de8a..7f2263e0f8 100644 --- a/backend/migrations/auth_identity_payment_migrations_regression_test.go +++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go @@ -168,3 +168,37 @@ func TestMigration151AddsAccountAutoPauseExpiryPartialIndex(t *testing.T) { require.Contains(t, sql, "auto_pause_on_expired = TRUE") require.Contains(t, sql, "expires_at IS NOT NULL") } + +func TestMigration154AddsSparkShadowColumnsAndConstraintsWithoutHotIndexes(t *testing.T) { + content, err := FS.ReadFile("154_account_spark_shadow.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS parent_account_id BIGINT") + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS quota_dimension VARCHAR(20) NOT NULL DEFAULT 'global'") + require.Contains(t, sql, "chk_accounts_parent_dimension") + // 约束已放开为「影子 ⇒ 非 global 维度」(spark 不再写死进 parent 约束) + require.Contains(t, sql, "parent_account_id IS NOT NULL AND quota_dimension <> 'global'") + require.NotContains(t, sql, "parent_account_id IS NOT NULL AND quota_dimension = 'spark'") + require.Contains(t, sql, "chk_accounts_parent_not_self") + require.Contains(t, sql, "fk_accounts_parent_account_id") + require.Contains(t, sql, "FOREIGN KEY (parent_account_id) REFERENCES accounts(id)") + require.Contains(t, sql, "ON DELETE RESTRICT") + require.Contains(t, sql, "NOT VALID") + require.NotContains(t, sql, "CREATE INDEX") + require.NotContains(t, sql, "CREATE UNIQUE INDEX") + require.NotContains(t, sql, "CONCURRENTLY") +} + +func TestMigration154aAddsSparkShadowIndexesConcurrently(t *testing.T) { + content, err := FS.ReadFile("154a_account_spark_shadow_indexes_notx.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_accounts_parent_account_id") + require.Contains(t, sql, "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_accounts_spark_shadow_per_parent") + require.Contains(t, sql, "ON accounts (parent_account_id)") + require.Contains(t, sql, "WHERE parent_account_id IS NOT NULL") + require.Contains(t, sql, "quota_dimension = 'spark'") + require.Contains(t, sql, "deleted_at IS NULL") +} diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index ec2156fa2d..ae9394a91e 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -781,6 +781,18 @@ export async function resetOpenAIQuota(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${parentId}/shadow`, payload) + return data +} + export const accountsAPI = { list, listWithEtag, @@ -825,7 +837,8 @@ export const accountsAPI = { setPrivacy, revertProxyFallback, queryOpenAIQuota, - resetOpenAIQuota + resetOpenAIQuota, + createSparkShadow } export default accountsAPI diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 747e171fed..414ec7ae9b 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1296,7 +1296,7 @@ -
+
@@ -2454,6 +2454,10 @@ const { t } = useI18n() const appStore = useAppStore() const authStore = useAuthStore() +// Spark 影子账号(parent_account_id 非空):代理恒继承母账号,不可独立编辑(外审 B/P1), +// 故隐藏代理选择器。 +const isSparkShadow = computed(() => props.account?.parent_account_id != null) + // Platform-specific hint for Base URL const baseUrlHint = computed(() => { if (!props.account) return t('admin.accounts.baseUrlHint') @@ -2930,6 +2934,28 @@ const loadModelRestrictionFromMapping = (rawMapping?: Record) = const buildModelRestrictionMapping = () => buildModelMappingObject('combined', allowedModels.value, modelMappings.value) +const applyOpenAIModelMappingCredentials = (credentials: Record) => { + const shouldApplyModelMapping = !openaiPassthroughEnabled.value + + if (shouldApplyModelMapping) { + const modelMapping = buildModelRestrictionMapping() + if (modelMapping) { + credentials.model_mapping = modelMapping + } else { + delete credentials.model_mapping + } + } else if (!credentials.model_mapping) { + delete credentials.model_mapping + } + + const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value) + if (compactModelMapping) { + credentials.compact_model_mapping = compactModelMapping + } else { + delete credentials.compact_model_mapping + } +} + const syncFormFromAccount = (newAccount: Account | null) => { if (!newAccount) { return @@ -3922,28 +3948,12 @@ const handleSubmit = async () => { // OpenAI OAuth: persist model mapping to credentials if (props.account.platform === 'openai' && props.account.type === 'oauth') { - const currentCredentials = (updatePayload.credentials as Record) || - ((props.account.credentials as Record) || {}) + const currentCredentials = isSparkShadow.value + ? {} + : (updatePayload.credentials as Record) || + ((props.account.credentials as Record) || {}) const newCredentials: Record = { ...currentCredentials } - const shouldApplyModelMapping = !openaiPassthroughEnabled.value - - if (shouldApplyModelMapping) { - const modelMapping = buildModelRestrictionMapping() - if (modelMapping) { - newCredentials.model_mapping = modelMapping - } else { - delete newCredentials.model_mapping - } - } else if (currentCredentials.model_mapping) { - // 透传模式保留现有映射 - newCredentials.model_mapping = currentCredentials.model_mapping - } - const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value) - if (compactModelMapping) { - newCredentials.compact_model_mapping = compactModelMapping - } else { - delete newCredentials.compact_model_mapping - } + applyOpenAIModelMappingCredentials(newCredentials) updatePayload.credentials = newCredentials } diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index d23e297470..5bf8bb68cb 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -119,10 +119,15 @@ const data = ref(null) const resetMessage = ref(null) const showResetConfirm = ref(false) +// 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409); +// 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。 +const isShadow = computed(() => props.account.parent_account_id != null) + const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0) -const canReset = computed(() => availableResetCount.value > 0) +const canReset = computed(() => availableResetCount.value > 0 && !isShadow.value) const resetButtonTitle = computed(() => { + if (isShadow.value) return t('admin.accounts.openaiQuotaReset.resetTooltipShadow') if (!data.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery') if (!canReset.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNoCredits') return t('admin.accounts.openaiQuotaReset.resetTooltipReady') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 77c333fb2d..de59b4d5fe 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1,10 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' -const { updateAccountMock, checkMixedChannelRiskMock } = vi.hoisted(() => ({ +const { updateAccountMock, checkMixedChannelRiskMock, authIsSimpleMode } = vi.hoisted(() => ({ updateAccountMock: vi.fn(), - checkMixedChannelRiskMock: vi.fn() + checkMixedChannelRiskMock: vi.fn(), + authIsSimpleMode: { value: true } })) vi.mock('@/stores/app', () => ({ @@ -17,7 +18,9 @@ vi.mock('@/stores/app', () => ({ vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ - isSimpleMode: true + get isSimpleMode() { + return authIsSimpleMode.value + } }) })) @@ -115,6 +118,28 @@ const SelectStub = defineComponent({ ` }) +const GroupSelectorStub = defineComponent({ + name: 'GroupSelector', + props: { + modelValue: { + type: Array, + default: () => [] + } + }, + emits: ['update:modelValue'], + template: ` +
+ +
+ ` +}) + function buildAccount() { return { id: 1, @@ -141,6 +166,30 @@ function buildAccount() { } as any } +function buildOpenAISparkShadowAccount() { + const account = buildAccount() + return { + ...account, + id: 4, + name: 'OpenAI Spark Shadow', + type: 'oauth', + parent_account_id: 1, + credentials: { + access_token: 'parent-access-token', + refresh_token: 'parent-refresh-token', + api_key: 'sk-parent', + base_url: 'https://api.openai.com', + model_mapping: { + 'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark' + }, + compact_model_mapping: { + 'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark-compact' + } + }, + group_ids: [] + } as any +} + function buildVertexAccount() { return { id: 2, @@ -206,7 +255,7 @@ function mountModal(account = buildAccount()) { Select: SelectStub, Icon: true, ProxySelector: true, - GroupSelector: true, + GroupSelector: GroupSelectorStub, ModelWhitelistSelector: ModelWhitelistSelectorStub } } @@ -214,6 +263,10 @@ function mountModal(account = buildAccount()) { } describe('EditAccountModal', () => { + beforeEach(() => { + authIsSimpleMode.value = true + }) + it('reopening the same account rehydrates the OpenAI whitelist from props', async () => { const account = buildAccount() updateAccountMock.mockReset() @@ -293,6 +346,32 @@ describe('EditAccountModal', () => { }) }) + it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => { + authIsSimpleMode.value = false + const account = buildOpenAISparkShadowAccount() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('[data-testid="set-shadow-group"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.group_ids).toEqual([7]) + expect(payload?.credentials).toEqual({ + model_mapping: { + 'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark' + }, + compact_model_mapping: { + 'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark-compact' + } + }) + }) + it('submits OpenAI APIKey Responses support override mode', async () => { const account = buildAccount() account.extra = { diff --git a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts new file mode 100644 index 0000000000..ffffdbad99 --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue' +import type { Account } from '@/types' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + } +}) + +function makeAccount(overrides: Partial): Account { + return { + id: 1, + name: 'acc', + platform: 'openai', + type: 'oauth', + proxy_id: null, + concurrency: 3, + priority: 50, + status: 'active', + error_message: null, + last_used_at: null, + expires_at: null, + auto_pause_on_expired: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + schedulable: true, + rate_limited_at: null, + rate_limit_reset_at: null, + overload_until: null, + temp_unschedulable_until: null, + temp_unschedulable_reason: null, + session_window_start: null, + session_window_end: null, + session_window_status: null, + ...overrides, + } +} + +// 第二个按钮(橙色)是 reset 按钮::disabled="resetting||loading||!canReset" :title="resetButtonTitle" +const resetButton = (wrapper: ReturnType) => + wrapper.findAll('button')[1] + +describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { + it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => { + const account = makeAccount({ parent_account_id: 100 }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + const btn = resetButton(wrapper) + expect(btn.attributes('disabled')).toBeDefined() + expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipShadow') + wrapper.unmount() + }) + + it('普通账号(无 parent_account_id)未查询时禁用原因是「需先查询」而非影子提示', () => { + const account = makeAccount({ parent_account_id: null }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + const btn = resetButton(wrapper) + // 未加载数据时本就 disabled(无次数),但提示语必须是 needQuery,不得是 shadow 提示。 + expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery') + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 06bd23abf7..9bb5e8891e 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -22,7 +22,8 @@ {{ t('admin.scheduledTests.schedule') }} - @@ -232,7 +232,10 @@