Merge pull request #3598 from DaydreamCoding/feat/openai-spark-shadow-account

feat(spark-shadow): OpenAI Spark 链接型影子账号
This commit is contained in:
Wesley Liddick
2026-07-01 14:00:52 +08:00
committed by GitHub
95 changed files with 6388 additions and 179 deletions
+2 -2
View File
@@ -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)
+64 -5
View File
@@ -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()
}
+90
View File
@@ -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),
+101
View File
@@ -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) {
+211
View File
@@ -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 {
+154 -1
View File
@@ -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) {
+354
View File
@@ -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,
+32
View File
@@ -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()
@@ -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
}
+16 -2
View File
@@ -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",
}
+273 -4
View File
@@ -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
+14
View File
@@ -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"),
}
}
+29 -3
View File
@@ -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)
@@ -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{
@@ -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
}
@@ -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(),
@@ -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))
}
@@ -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)
}
@@ -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 }
@@ -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)
@@ -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) {
@@ -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)
}
+2
View File
@@ -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 账号有效)
+11
View File
@@ -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"`
@@ -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)
+2 -1
View File
@@ -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)
@@ -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
}
@@ -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() {
@@ -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)
}
}
@@ -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")
@@ -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 {
@@ -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"])
}
@@ -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")
}
+3
View File
@@ -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)
+43
View File
@@ -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
}
@@ -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 不应被影子账号触发")
}
@@ -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
}
@@ -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)
}
@@ -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(账号不存在)
@@ -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())
}
@@ -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
@@ -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()
@@ -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{
@@ -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)")
}
@@ -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()
+281 -8
View File
@@ -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 ""
}
File diff suppressed because it is too large Load Diff
@@ -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
}
@@ -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)
}
@@ -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
@@ -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))
}
@@ -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-"
@@ -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)
@@ -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)
@@ -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
}
@@ -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)
@@ -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}
@@ -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
}
@@ -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 解析健康母账号")
}
@@ -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
}
@@ -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")
})
}
@@ -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 {
@@ -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)
}
@@ -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 {
@@ -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
@@ -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)
}
@@ -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
@@ -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)
}
@@ -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)")
})
}
@@ -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
}
@@ -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()
+41 -13
View File
@@ -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 == "" {
@@ -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
@@ -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
@@ -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 {
@@ -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
}
@@ -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 移除")
}
@@ -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")
})
}
@@ -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
@@ -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
}
@@ -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;
@@ -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;
@@ -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")
}
+14 -1
View File
@@ -781,6 +781,18 @@ export async function resetOpenAIQuota(id: number): Promise<OpenAIQuotaResetResu
return data
}
export interface SparkShadowCreatePayload {
name?: string
priority?: number
concurrency?: number
group_ids?: number[]
}
export async function createSparkShadow(parentId: number, payload: SparkShadowCreatePayload): Promise<Account> {
const { data } = await apiClient.post<Account>(`/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
@@ -1296,7 +1296,7 @@
</div>
</div>
<div>
<div v-if="!isSparkShadow">
<div class="mb-1 flex items-center gap-2">
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
<ProxyAdBanner />
@@ -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<string, unknown>) =
const buildModelRestrictionMapping = () =>
buildModelMappingObject('combined', allowedModels.value, modelMappings.value)
const applyOpenAIModelMappingCredentials = (credentials: Record<string, unknown>) => {
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<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
const currentCredentials = isSparkShadow.value
? {}
: (updatePayload.credentials as Record<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
const newCredentials: Record<string, unknown> = { ...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
}
@@ -119,10 +119,15 @@ const data = ref<OpenAIQuotaUsage | null>(null)
const resetMessage = ref<string | null>(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')
@@ -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: `
<div data-testid="group-selector">
<button
type="button"
data-testid="set-shadow-group"
@click="$emit('update:modelValue', [7])"
>
group
</button>
</div>
`
})
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 = {
@@ -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<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
function makeAccount(overrides: Partial<Account>): 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<typeof mount>) =>
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()
})
})
@@ -22,7 +22,8 @@
<Icon name="clock" size="sm" class="text-orange-500" />
{{ t('admin.scheduledTests.schedule') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<!-- 影子账号不持凭据:重授权/刷新 token 对其无效(后端拒绝),故隐藏(外审 G4)。 -->
<template v-if="(account.type === 'oauth' || account.type === 'setup-token') && !isShadow">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="link" size="sm" />
{{ t('admin.accounts.reAuthorize') }}
@@ -32,6 +33,10 @@
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<button v-if="isOpenAIOAuthParent" @click="$emit('create-spark-shadow', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="sparkles" size="sm" />
{{ t('admin.accounts.createSparkShadow') }}
</button>
<button v-if="supportsPrivacy" @click="$emit('set-privacy', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-emerald-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="shield" size="sm" />
{{ t('admin.accounts.setPrivacy') }}
@@ -59,7 +64,7 @@ import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy'])
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
const { t } = useI18n()
const isRateLimited = computed(() => {
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
@@ -81,7 +86,11 @@ const hasRecoverableState = computed(() => {
})
const isAntigravityOAuth = computed(() => props.account?.platform === 'antigravity' && props.account?.type === 'oauth')
const isOpenAIOAuth = computed(() => props.account?.platform === 'openai' && props.account?.type === 'oauth')
const supportsPrivacy = computed(() => isAntigravityOAuth.value || isOpenAIOAuth.value)
// 影子账号(链接型,持 parent_account_id)不持凭据、type 不可变,凭据/隐私类操作对其无效。
const isShadow = computed(() => props.account?.parent_account_id != null)
// A "parent" OpenAI OAuth account is one that is NOT itself a shadow (parent_account_id == null)
const isOpenAIOAuthParent = computed(() => isOpenAIOAuth.value && !isShadow.value)
const supportsPrivacy = computed(() => (isAntigravityOAuth.value || isOpenAIOAuth.value) && !isShadow.value)
const hasQuotaLimit = computed(() => {
return (props.account?.type === 'apikey' || props.account?.type === 'bedrock') && (
(props.account?.quota_limit ?? 0) > 0 ||
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AccountActionMenu from '../AccountActionMenu.vue'
import type { Account } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
name: 'test-account',
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,
}
}
const position = { top: 100, left: 100 }
// AccountActionMenu uses <Teleport to="body">; content is rendered in document.body, not in wrapper.
const getBodyText = () => document.body.textContent ?? ''
const getBodyButtons = () => Array.from(document.body.querySelectorAll('button'))
describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('OpenAI OAuth 母账号(无 parent_account_id)显示「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).toContain('admin.accounts.createSparkShadow')
wrapper.unmount()
})
it('影子账号(parent_account_id 非 null)隐藏「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.createSparkShadow')
wrapper.unmount()
})
it('非 OpenAI 账号隐藏「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'antigravity', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.createSparkShadow')
wrapper.unmount()
})
it('影子账号隐藏凭据/隐私类操作(重授权/刷新token/隐私)— 外审 G4', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
const body = getBodyText()
expect(body).not.toContain('admin.accounts.reAuthorize')
expect(body).not.toContain('admin.accounts.refreshToken')
expect(body).not.toContain('admin.accounts.setPrivacy')
wrapper.unmount()
})
it('普通 OpenAI OAuth 母账号仍显示凭据/隐私类操作', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
const body = getBodyText()
expect(body).toContain('admin.accounts.reAuthorize')
expect(body).toContain('admin.accounts.setPrivacy')
wrapper.unmount()
})
it('点击按钮触发 create-spark-shadow 事件并携带 account', async () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
// Content is teleported to body — find button by text there
const sparkBtn = getBodyButtons().find(b => b.textContent?.includes('admin.accounts.createSparkShadow'))
expect(sparkBtn).toBeDefined()
sparkBtn!.click()
await wrapper.vm.$nextTick()
const emitted = wrapper.emitted('create-spark-shadow')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toMatchObject({ id: account.id, platform: 'openai' })
wrapper.unmount()
})
})
+6
View File
@@ -3108,6 +3108,7 @@ export default {
dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.',
dataExportConfirm: 'Confirm Export',
dataExported: 'Data exported successfully',
dataExportedSkippedShadows: 'Data exported. Skipped {count} spark shadow account(s): their scheduling config is not included in the backup; recreate and re-tune them after restore.',
dataExportFailed: 'Failed to export data',
dataImportTitle: 'Import Data',
dataImportHint: 'Upload the exported JSON file to import accounts and proxies.',
@@ -3417,6 +3418,10 @@ export default {
revertProxy: 'Revert proxy',
revertProxySuccess: 'Successfully reverted to original proxy',
revertProxyFailed: 'Failed to revert proxy',
createSparkShadow: 'Create Spark Shadow',
createSparkShadowConfirm: 'Create a spark shadow account linked to "{name}"? It shares the parent\'s credentials and serves only spark models.',
createSparkShadowSuccess: 'Spark shadow account created',
createSparkShadowFailed: 'Failed to create spark shadow account',
resetStatus: 'Reset Status',
statusReset: 'Account status reset successfully',
failedToResetStatus: 'Failed to reset account status',
@@ -4244,6 +4249,7 @@ export default {
resetTooltipReady: 'Consume 1 reset credit to immediately restore the window',
resetTooltipNeedQuery: 'Click Credits first to load the available count',
resetTooltipNoCredits: 'No reset credits available',
resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account',
noCreditsAvailable: 'No reset credits available',
resetSuccess: 'Reset {windows} window(s)',
confirmTitle: 'Confirm Weekly Limit Reset',
+6
View File
@@ -3183,6 +3183,7 @@ export default {
dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。',
dataExportConfirm: '确认导出',
dataExported: '数据导出成功',
dataExportedSkippedShadows: '数据已导出。已跳过 {count} 个 spark 影子账号:其调度配置不在备份内,还原后需在重建的影子上重新调优。',
dataExportFailed: '数据导出失败',
dataImportTitle: '导入数据',
dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。',
@@ -3485,6 +3486,7 @@ export default {
resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口',
resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数',
resetTooltipNoCredits: '没有可用的重置次数',
resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置',
noCreditsAvailable: '没有可用的重置次数',
resetSuccess: '已重置 {windows} 个窗口',
confirmTitle: '确认重置周限',
@@ -3588,6 +3590,10 @@ export default {
revertProxy: '切回原代理',
revertProxySuccess: '已成功切回原代理',
revertProxyFailed: '切回原代理失败',
createSparkShadow: '创建 Spark 影子账号',
createSparkShadowConfirm: '为「{name}」创建链接型 Spark 影子账号?影子共享母账号凭据、仅服务 spark 模型。',
createSparkShadowSuccess: 'Spark 影子账号已创建',
createSparkShadowFailed: '创建 Spark 影子账号失败',
resetStatus: '重置状态',
statusReset: '账号状态已重置',
failedToResetStatus: '重置账号状态失败',
+12
View File
@@ -918,6 +918,16 @@ export interface Account {
current_window_cost?: number | null // 当前窗口费用
active_sessions?: number | null // 当前活跃会话数
current_rpm?: number | null // 当前分钟 RPM 计数
// 影子账号关系(spark 维度影子)
parent_account_id?: number | null
quota_dimension?: string
// 影子账号回填的母账号信息(仅影子非空)
parent_email?: string
parent_plan_type?: string
parent_privacy_mode?: string
parent_subscription_expires_at?: string
parent_chatgpt_account_id?: string
}
// Account Usage types
@@ -1127,6 +1137,8 @@ export interface AdminDataPayload {
exported_at: string
proxies: AdminDataProxy[]
accounts: AdminDataAccount[]
// 导出时被排除的 spark 影子账号数量(影子不持凭据、其调度配置不在备份范围)。
skipped_shadows?: number
}
export interface AdminDataProxy {
+42 -6
View File
@@ -217,11 +217,11 @@
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<span
v-if="row.extra?.email_address || row.extra?.email || row.credentials?.email"
v-if="accountDisplayEmail(row)"
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
:title="String(row.extra?.email_address || row.extra?.email || row.credentials?.email)"
:title="accountDisplayEmail(row) + (row.parent_chatgpt_account_id ? ' · ' + row.parent_chatgpt_account_id : '')"
>
{{ row.extra?.email_address || row.extra?.email || row.credentials?.email }}
{{ accountDisplayEmail(row) }}
</span>
</div>
</template>
@@ -232,7 +232,10 @@
<template #cell-platform_type="{ row }">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex flex-wrap items-center gap-1">
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" :subscription-expires-at="row.credentials?.subscription_expires_at" />
<PlatformTypeBadge :platform="row.platform" :type="row.type"
:plan-type="row.credentials?.plan_type || row.parent_plan_type"
:privacy-mode="row.extra?.privacy_mode || row.parent_privacy_mode"
:subscription-expires-at="row.credentials?.subscription_expires_at || row.parent_subscription_expires_at" />
<span
v-if="getAntigravityTierLabel(row)"
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]"
@@ -371,7 +374,7 @@
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
<ScheduledTestsPanel :show="showSchedulePanel" :account-id="scheduleAcc?.id ?? null" :model-options="scheduleModelOptions" @close="closeSchedulePanel" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal
@@ -387,6 +390,7 @@
/>
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
<ConfirmDialog :show="showCreateShadowDialog" :title="t('admin.accounts.createSparkShadow')" :message="t('admin.accounts.createSparkShadowConfirm', { name: creatingShadowAcc?.name })" @confirm="confirmCreateSparkShadow" @cancel="showCreateShadowDialog = false" />
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" v-model="includeProxyOnExport" />
@@ -496,6 +500,7 @@ const showBulkEdit = ref(false)
const bulkEditTarget = ref<AccountBulkEditTarget | null>(null)
const showTempUnsched = ref(false)
const showDeleteDialog = ref(false)
const showCreateShadowDialog = ref(false)
const showReAuth = ref(false)
const showTest = ref(false)
const showStats = ref(false)
@@ -504,6 +509,7 @@ const showTLSFingerprintProfiles = ref(false)
const edAcc = ref<Account | null>(null)
const tempUnschedAcc = ref<Account | null>(null)
const deletingAcc = ref<Account | null>(null)
const creatingShadowAcc = ref<Account | null>(null)
const reAuthAcc = ref<Account | null>(null)
const testingAcc = ref<Account | null>(null)
const statsAcc = ref<Account | null>(null)
@@ -1078,6 +1084,12 @@ function getAntigravityTierLabel(row: any): string | null {
}
}
// 账号显示邮箱:优先账号自身(extra/credentials),影子账号回退母账号 parent_email。
// 供名称单元格 v-if/标题/文本三处共用,避免同一回退链在模板里重复三次。
function accountDisplayEmail(row: any): string {
return row.extra?.email_address || row.extra?.email || row.credentials?.email || row.parent_email || ''
}
type OpenAICompactBadgeState = 'active' | 'blocked' | 'auto'
function getOpenAICompactState(row: any): OpenAICompactBadgeState | null {
@@ -1548,7 +1560,13 @@ const handleExportData = async () => {
link.download = filename
link.click()
URL.revokeObjectURL(url)
appStore.showSuccess(t('admin.accounts.dataExported'))
// spark 影子账号被后端排除出备份(其凭据透传母账号、调度配置不可经凭据型导入重建);
// 跳过非零时明确提示用户,避免「下载成功但少了账号」的静默丢失。
if (dataPayload.skipped_shadows && dataPayload.skipped_shadows > 0) {
appStore.showWarning(t('admin.accounts.dataExportedSkippedShadows', { count: dataPayload.skipped_shadows }))
} else {
appStore.showSuccess(t('admin.accounts.dataExported'))
}
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.dataExportFailed'))
} finally {
@@ -1652,6 +1670,24 @@ const onRevertFallback = async (a: Account) => {
appStore.showError(error?.response?.data?.message || t('admin.accounts.revertProxyFailed'))
}
}
const handleCreateSparkShadow = (a: Account) => {
creatingShadowAcc.value = a
showCreateShadowDialog.value = true
}
const confirmCreateSparkShadow = async () => {
const a = creatingShadowAcc.value
if (!a) return
try {
await adminAPI.accounts.createSparkShadow(a.id, { name: `${a.name} (Spark)` })
showCreateShadowDialog.value = false
creatingShadowAcc.value = null
appStore.showSuccess(t('admin.accounts.createSparkShadowSuccess'))
reload()
} catch (error: any) {
console.error('Failed to create spark shadow:', error)
appStore.showError(error?.response?.data?.message || t('admin.accounts.createSparkShadowFailed'))
}
}
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
const handleToggleSchedulable = async (a: Account) => {
@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AccountsView from '../AccountsView.vue'
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
// 外审 F2:AccountActionMenu emit 'create-spark-shadow',但 AccountsView 此前未监听,
// 导致按钮点击无效。本测试通过真实组件引用 emit 该事件,断言父页面接线调用 API。
const {
listAccounts,
listWithEtag,
getBatchTodayStats,
getAllProxies,
getAllGroups,
createSparkShadow,
showSuccess,
showError
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getBatchTodayStats: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn(),
createSparkShadow: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
list: listAccounts,
listWithEtag,
getBatchTodayStats,
createSparkShadow,
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
toggleSchedulable: vi.fn()
},
proxies: { getAll: getAllProxies },
groups: { getAll: getAllGroups }
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showError, showSuccess, showInfo: vi.fn() })
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ token: 'test-token' })
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
const mountView = () =>
mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
DataTable: true,
Pagination: true,
ConfirmDialog: true,
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
AccountTableFilters: { template: '<div></div>' },
AccountBulkActionsBar: true,
AccountActionMenu: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: true,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, createSparkShadow, showSuccess, showError]) {
fn.mockReset()
}
listAccounts.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
listWithEtag.mockResolvedValue({ notModified: true, etag: null, data: null })
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
createSparkShadow.mockResolvedValue({ id: 999, name: 'parent-acc (Spark)' })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('AccountActionMenu 的 create-spark-shadow 事件触发 createSparkShadow API + 成功提示', async () => {
const wrapper = mountView()
await flushPromises()
const menu = wrapper.findComponent(AccountActionMenu)
expect(menu.exists()).toBe(true)
menu.vm.$emit('create-spark-shadow', { id: 42, name: 'parent-acc' })
await flushPromises()
// 不再用原生 confirm,改用应用内 ConfirmDialog:先弹出,点确认才调 API
const dialog = wrapper.findAllComponents(ConfirmDialog).find(d => d.props('show'))
expect(dialog).toBeTruthy()
dialog?.vm.$emit('confirm')
await flushPromises()
expect(createSparkShadow).toHaveBeenCalledTimes(1)
expect(createSparkShadow).toHaveBeenCalledWith(42, { name: 'parent-acc (Spark)' })
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.createSparkShadowSuccess')
wrapper.unmount()
})
it('用户取消确认时不调用 API', async () => {
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(AccountActionMenu).vm.$emit('create-spark-shadow', { id: 42, name: 'parent-acc' })
await flushPromises()
// 弹出 ConfirmDialog 后点取消,不应调用 API
const dialog = wrapper.findAllComponents(ConfirmDialog).find(d => d.props('show'))
expect(dialog).toBeTruthy()
dialog?.vm.$emit('cancel')
await flushPromises()
expect(createSparkShadow).not.toHaveBeenCalled()
wrapper.unmount()
})
})
// Task 6: 影子行 parent_* OR 兜底展示
const mountViewWithRow = () =>
mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
// 使用能透传 row 数据的自定义 DataTable stub,以便渲染 cell 插槽
DataTable: {
props: ['data', 'columns', 'loading'],
template: `<div>
<div v-for="(row, idx) in (data || [])" :key="idx">
<slot name="cell-name" :row="row" :value="row.name" />
<slot name="cell-platform_type" :row="row" />
</div>
</div>`
},
Pagination: true,
ConfirmDialog: true,
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
AccountTableFilters: { template: '<div></div>' },
AccountBulkActionsBar: true,
AccountActionMenu: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: true,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, createSparkShadow, showSuccess, showError]) {
fn.mockReset()
}
listWithEtag.mockResolvedValue({ notModified: true, etag: null, data: null })
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
vi.stubGlobal('confirm', vi.fn(() => true))
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('影子行 email 单元格显示 parent_email,PlatformTypeBadge 接收 parent_plan_type/parent_privacy_mode', async () => {
const shadowAccount = {
id: 100,
name: '影子账号',
platform: 'openai',
type: 'oauth',
parent_account_id: 1,
parent_email: 'parent@example.com',
parent_plan_type: 'plus',
parent_privacy_mode: 'false',
parent_subscription_expires_at: '2027-01-01T00:00:00Z',
parent_chatgpt_account_id: 'chatgpt-abc123',
}
listAccounts.mockResolvedValue({ items: [shadowAccount], total: 1, page: 1, page_size: 20, pages: 1 })
const wrapper = mountViewWithRow()
await flushPromises()
// 1. email 单元格通过 OR 兜底渲染 parent_email
expect(wrapper.text()).toContain('parent@example.com')
// 2. PlatformTypeBadge 收到 parent_plan_type 和 parent_privacy_mode
const badge = wrapper.findComponent(PlatformTypeBadge)
expect(badge.exists()).toBe(true)
expect(badge.props('planType')).toBe('plus')
expect(badge.props('privacyMode')).toBe('false')
expect(badge.props('subscriptionExpiresAt')).toBe('2027-01-01T00:00:00Z')
wrapper.unmount()
})
})