mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3598 from DaydreamCoding/feat/openai-spark-shadow-account
feat(spark-shadow): OpenAI Spark 链接型影子账号
This commit is contained in:
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 账号有效)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
+127
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '重置账号状态失败',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user