| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860 |
- package service
- import (
- "context"
- "crypto/sha256"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "maps"
- "net/http"
- "reflect"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- )
- // Account management implementations
- func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) {
- if groupID > 0 {
- if err := s.ValidateAccountGroupBindings(ctx, []int64{groupID}); err != nil {
- return nil, 0, err
- }
- }
- params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
- accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode)
- if err != nil {
- return nil, 0, err
- }
- return accounts, result.Total, nil
- }
- func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) {
- if s == nil || s.accountRepo == nil {
- return nil, nil
- }
- return s.accountRepo.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode)
- }
- func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) {
- if s == nil || s.accountRepo == nil {
- return nil, nil
- }
- if groupID != nil {
- return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI)
- }
- return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI)
- }
- func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account, error) {
- return s.accountRepo.GetByID(ctx, id)
- }
- func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) {
- if len(ids) == 0 {
- return []*Account{}, nil
- }
- accounts, err := s.accountRepo.GetByIDs(ctx, ids)
- if err != nil {
- return nil, fmt.Errorf("failed to get accounts by IDs: %w", err)
- }
- return accounts, nil
- }
- const maxAccountNameRunes = 100
- const duplicateAccountOperationIDExtraKey = "duplicate_operation_id"
- func duplicateAccountName(sourceName string) string {
- const suffix = " (Copy)"
- nameRunes := []rune(strings.TrimSpace(sourceName))
- maxBaseRunes := maxAccountNameRunes - len([]rune(suffix))
- if len(nameRunes) > maxBaseRunes {
- nameRunes = nameRunes[:maxBaseRunes]
- }
- return string(nameRunes) + suffix
- }
- func cloneAccountJSONMap(value map[string]any) (map[string]any, error) {
- if value == nil {
- return nil, nil
- }
- payload, err := json.Marshal(value)
- if err != nil {
- return nil, err
- }
- cloned := make(map[string]any, len(value))
- if err := json.Unmarshal(payload, &cloned); err != nil {
- return nil, err
- }
- return cloned, nil
- }
- var duplicateAccountDiscardedExtraKeys = map[string]struct{}{
- // A retry identity belongs to the operation that created one copy, not to later copies.
- duplicateAccountOperationIDExtraKey: {},
- // External sync identity belongs to one local account only.
- "crs_account_id": {},
- "crs_kind": {},
- "crs_synced_at": {},
- // Local quota usage and derived window timestamps must start fresh.
- "quota_used": {},
- "quota_daily_used": {},
- "quota_weekly_used": {},
- "quota_daily_start": {},
- "quota_weekly_start": {},
- "quota_daily_reset_at": {},
- "quota_weekly_reset_at": {},
- // Provider observations, capability probes, and transient scheduling state.
- "model_rate_limits": {},
- "session_window_utilization": {},
- "passive_usage_7d_utilization": {},
- "passive_usage_7d_reset": {},
- "passive_usage_7d_oi_utilization": {},
- "passive_usage_7d_oi_reset": {},
- "passive_usage_sampled_at": {},
- "grok_usage_snapshot": {},
- "grok_billing_snapshot": {},
- "openai_responses_supported": {},
- "openai_compact_supported": {},
- "openai_compact_checked_at": {},
- "openai_compact_last_status": {},
- "openai_compact_last_error": {},
- "antigravity_credits_overages": {},
- "antigravity_force_token_refresh": {},
- "antigravity_force_token_refresh_at": {},
- "antigravity_force_token_refresh_reason": {},
- "drive_storage_limit": {},
- "drive_storage_usage": {},
- "drive_tier_updated_at": {},
- // Codex fingerprint convergence uses a per-account random seed, never copied from another account.
- codexFingerprintSeedExtraKey: {},
- "codex_primary_used_percent": {},
- "codex_primary_reset_after_seconds": {},
- "codex_primary_window_minutes": {},
- "codex_secondary_used_percent": {},
- "codex_secondary_reset_after_seconds": {},
- "codex_secondary_window_minutes": {},
- "codex_primary_over_secondary_percent": {},
- "codex_usage_updated_at": {},
- "codex_5h_used_percent": {},
- "codex_5h_reset_after_seconds": {},
- "codex_5h_window_minutes": {},
- "codex_5h_reset_at": {},
- "codex_7d_used_percent": {},
- "codex_7d_reset_after_seconds": {},
- "codex_7d_window_minutes": {},
- "codex_7d_reset_at": {},
- }
- func duplicateAccountExtra(value map[string]any) (map[string]any, error) {
- cloned, err := cloneAccountJSONMap(value)
- if err != nil {
- return nil, err
- }
- for key := range duplicateAccountDiscardedExtraKeys {
- delete(cloned, key)
- }
- return cloned, nil
- }
- func canDuplicateAccountType(accountType string) bool {
- switch accountType {
- case AccountTypeAPIKey, AccountTypeUpstream, AccountTypeBedrock, AccountTypeServiceAccount:
- return true
- default:
- return false
- }
- }
- func duplicateAccountGroups(source *Account) ([]AccountGroup, []int64) {
- if len(source.AccountGroups) > 0 {
- groups := make([]AccountGroup, 0, len(source.AccountGroups))
- groupIDs := make([]int64, 0, len(source.AccountGroups))
- for _, sourceGroup := range source.AccountGroups {
- groups = append(groups, AccountGroup{GroupID: sourceGroup.GroupID, Priority: sourceGroup.Priority})
- groupIDs = append(groupIDs, sourceGroup.GroupID)
- }
- return groups, groupIDs
- }
- groups := make([]AccountGroup, 0, len(source.GroupIDs))
- groupIDs := append([]int64(nil), source.GroupIDs...)
- for i, groupID := range groupIDs {
- groups = append(groups, AccountGroup{GroupID: groupID, Priority: i + 1})
- }
- return groups, groupIDs
- }
- func duplicateAccountOperationID(sourceID int64, actorScope, operationKey string) string {
- operationKey = strings.TrimSpace(operationKey)
- if operationKey == "" {
- return ""
- }
- actorScope = strings.TrimSpace(actorScope)
- if actorScope == "" {
- actorScope = "admin:0"
- }
- payload := "admin.accounts.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey
- digest := sha256.Sum256([]byte(payload))
- return fmt.Sprintf("%x", digest)
- }
- func (s *adminServiceImpl) findDuplicateByOperationID(ctx context.Context, operationID string) (*Account, error) {
- if operationID == "" {
- return nil, nil
- }
- accounts, err := s.accountRepo.FindByExtraField(ctx, duplicateAccountOperationIDExtraKey, operationID)
- if err != nil {
- return nil, fmt.Errorf("find duplicate account operation: %w", err)
- }
- if len(accounts) == 0 {
- return nil, nil
- }
- account := accounts[0]
- return &account, nil
- }
- // RecoverDuplicateAccount performs a read-only lookup for an already committed duplicate.
- // It is used when the idempotency coordinator cannot confirm whether response persistence
- // succeeded, and deliberately never repeats the create side effect.
- func (s *adminServiceImpl) RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) {
- return s.findDuplicateByOperationID(ctx, duplicateAccountOperationID(id, actorScope, operationKey))
- }
- func cloneAccountValuePointer[T any](value *T) *T {
- if value == nil {
- return nil
- }
- cloned := *value
- return &cloned
- }
- // DuplicateAccount creates a paused account from source configuration without carrying first-class
- // runtime state. Credentials and extra configuration are deep-copied so normalization of the new
- // account cannot mutate the in-memory source. Linked credential shadows are excluded because they
- // intentionally do not own credentials and must be created through CreateShadow.
- func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) {
- operationID := duplicateAccountOperationID(id, actorScope, operationKey)
- existing, err := s.RecoverDuplicateAccount(ctx, id, actorScope, operationKey)
- if err != nil {
- return nil, err
- }
- if existing != nil {
- return existing, nil
- }
- source, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return nil, err
- }
- if source.IsCredentialShadow() {
- return nil, infraerrors.BadRequest(
- "ACCOUNT_DUPLICATE_SHADOW_UNSUPPORTED",
- "linked credential shadow accounts cannot be duplicated; duplicate the parent account instead",
- )
- }
- if !canDuplicateAccountType(source.Type) {
- return nil, infraerrors.BadRequest(
- "ACCOUNT_DUPLICATE_CREDENTIAL_TYPE_UNSUPPORTED",
- "accounts with rotating or unsupported credential types cannot be duplicated",
- )
- }
- credentials, err := cloneAccountJSONMap(source.Credentials)
- if err != nil {
- return nil, fmt.Errorf("clone account credentials: %w", err)
- }
- extra, err := duplicateAccountExtra(source.Extra)
- if err != nil {
- return nil, fmt.Errorf("clone account extra configuration: %w", err)
- }
- if operationID != "" {
- if extra == nil {
- extra = make(map[string]any, 1)
- }
- extra[duplicateAccountOperationIDExtraKey] = operationID
- }
- var expiresAt *int64
- if source.ExpiresAt != nil {
- unix := source.ExpiresAt.Unix()
- expiresAt = &unix
- }
- autoPauseOnExpired := source.AutoPauseOnExpired
- groups, groupIDs := duplicateAccountGroups(source)
- if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
- return nil, err
- }
- proxyID := source.ProxyID
- if source.ProxyFallbackOriginID != nil {
- // Proxy fallback is transient runtime state; duplicate the configured origin.
- proxyID = source.ProxyFallbackOriginID
- }
- input := &CreateAccountInput{
- Name: duplicateAccountName(source.Name),
- Notes: cloneAccountValuePointer(source.Notes),
- Platform: source.Platform,
- Type: source.Type,
- Credentials: credentials,
- Extra: extra,
- ProxyID: cloneAccountValuePointer(proxyID),
- Concurrency: source.Concurrency,
- Priority: source.Priority,
- RateMultiplier: cloneAccountValuePointer(source.RateMultiplier),
- LoadFactor: cloneAccountValuePointer(source.LoadFactor),
- GroupIDs: groupIDs,
- ExpiresAt: expiresAt,
- AutoPauseOnExpired: &autoPauseOnExpired,
- SkipDefaultGroupBind: true,
- SkipMixedChannelCheck: true,
- }
- accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra)
- if err != nil {
- return nil, fmt.Errorf("normalize duplicate account extra: %w", err)
- }
- accountExtra = NormalizeProxyModeExtra(accountExtra)
- if err := ValidateUpstreamRequestIDHeaderExtra(accountExtra); err != nil {
- return nil, fmt.Errorf("validate duplicate account extra: %w", err)
- }
- if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
- return nil, err
- }
- duplicate, err := buildAccountForCreate(input, accountExtra)
- if err != nil {
- return nil, err
- }
- // A copied credential must be reviewed before it can share live traffic with its source.
- duplicate.Schedulable = false
- if s.accountDuplicateRepo == nil {
- return nil, errors.New("account duplicate repository is not configured")
- }
- if err := s.accountDuplicateRepo.CreateWithAccountGroups(ctx, duplicate, groups); err != nil {
- return nil, fmt.Errorf("create duplicate account: %w", err)
- }
- for i := range groups {
- groups[i].AccountID = duplicate.ID
- }
- duplicate.AccountGroups = groups
- duplicate.GroupIDs = groupIDs
- return duplicate, nil
- }
- func normalizeAccountConcurrency(platform, accountType string, concurrency int) int {
- if platform == PlatformGrok && accountType == AccountTypeOAuth {
- if concurrency <= 0 {
- return 1
- }
- }
- return concurrency
- }
- // ValidateOpenAILongContextBillingExtra validates the OpenAI account billing flag when present.
- func ValidateOpenAILongContextBillingExtra(platform string, extra map[string]any) error {
- if platform != PlatformOpenAI {
- return nil
- }
- raw, exists := extra[openAILongContextBillingEnabledKey]
- if !exists {
- return nil
- }
- if _, ok := raw.(bool); !ok {
- return infraerrors.BadRequest(
- "OPENAI_LONG_CONTEXT_BILLING_INVALID",
- "openai_long_context_billing_enabled must be a boolean",
- )
- }
- return nil
- }
- func normalizeOpenAILongContextBillingExtra(platform string, extra map[string]any) (map[string]any, error) {
- if platform != PlatformOpenAI {
- return extra, nil
- }
- if err := ValidateOpenAILongContextBillingExtra(platform, extra); err != nil {
- return nil, err
- }
- normalized := maps.Clone(extra)
- if normalized == nil {
- normalized = make(map[string]any, 1)
- }
- _, exists := normalized[openAILongContextBillingEnabledKey]
- if !exists {
- normalized[openAILongContextBillingEnabledKey] = false
- }
- return normalized, nil
- }
- func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *UpdateAccountInput) (map[string]any, error) {
- normalized, err := normalizeOpenAILongContextBillingExtra(account.Platform, input.Extra)
- if err != nil || account.Platform != PlatformOpenAI {
- return normalized, err
- }
- _, provided := input.Extra[openAILongContextBillingEnabledKey]
- current, hasCurrent := account.Extra[openAILongContextBillingEnabledKey].(bool)
- if !provided {
- if hasCurrent {
- normalized[openAILongContextBillingEnabledKey] = current
- }
- }
- return normalized, nil
- }
- // Grok media eligibility helpers live in account_grok_media_eligibility.go.
- func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) {
- // Probe/session state is system-managed. New accounts always start with automatic refresh disabled.
- delete(accountExtra, UpstreamBillingProbeEnabledExtraKey)
- delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey)
- delete(accountExtra, UpstreamBillingProbeExtraKey)
- delete(accountExtra, OllamaCloudUsageSessionExtraKey)
- delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey)
- delete(accountExtra, OllamaCloudUsageSnapshotExtraKey)
- accountExtra = prepareCodexFingerprintExtraForCreate(input.Platform, input.Type, accountExtra)
- account := &Account{
- Name: input.Name,
- Notes: normalizeAccountNotes(input.Notes),
- Platform: input.Platform,
- Type: input.Type,
- Credentials: input.Credentials,
- Extra: accountExtra,
- ProxyID: input.ProxyID,
- Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency),
- Priority: input.Priority,
- Status: StatusActive,
- Schedulable: true,
- }
- if input.ProbeEnabled != nil && *input.ProbeEnabled {
- if !isUpstreamBillingProbeAccount(account) {
- return nil, ErrUpstreamBillingProbeAccountInvalid
- }
- if account.Extra == nil {
- account.Extra = make(map[string]any)
- }
- account.Extra[UpstreamBillingProbeEnabledExtraKey] = true
- }
- // 预计算固定时间重置的下次重置时间
- if account.Extra != nil {
- if err := ValidateQuotaResetConfig(account.Extra); err != nil {
- return nil, err
- }
- ComputeQuotaResetAt(account.Extra)
- NormalizeFixedQuotaWindows(account.Extra)
- }
- if input.ExpiresAt != nil && *input.ExpiresAt > 0 {
- expiresAt := time.Unix(*input.ExpiresAt, 0)
- account.ExpiresAt = &expiresAt
- }
- if input.AutoPauseOnExpired != nil {
- account.AutoPauseOnExpired = *input.AutoPauseOnExpired
- } else {
- account.AutoPauseOnExpired = true
- }
- if input.RateMultiplier != nil {
- if *input.RateMultiplier < 0 {
- return nil, errors.New("rate_multiplier must be >= 0")
- }
- account.RateMultiplier = input.RateMultiplier
- }
- if input.LoadFactor != nil && *input.LoadFactor > 0 {
- if *input.LoadFactor > 10000 {
- return nil, errors.New("load_factor must be <= 10000")
- }
- account.LoadFactor = input.LoadFactor
- }
- return account, nil
- }
- func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
- accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra)
- if err != nil {
- return nil, err
- }
- accountExtra, err = normalizeGrokMediaEligibilityExtra(input.Platform, accountExtra)
- if err != nil {
- return nil, err
- }
- accountExtra, err = normalizeOpenAIAutoResetCreditExtra(input.Platform, input.Type, false, accountExtra)
- if err != nil {
- return nil, err
- }
- accountExtra = NormalizeProxyModeExtra(accountExtra)
- if err := ValidateUpstreamRequestIDHeaderExtra(accountExtra); err != nil {
- return nil, err
- }
- // 绑定分组
- groupIDs := input.GroupIDs
- // 如果没有指定分组,自动绑定对应平台的默认分组
- if len(groupIDs) == 0 && !input.SkipDefaultGroupBind {
- defaultGroupName := input.Platform + "-default"
- groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
- if err == nil {
- for _, g := range groups {
- if g.Name == defaultGroupName {
- groupIDs = []int64{g.ID}
- break
- }
- }
- }
- }
- // 检查混合渠道风险(除非用户已确认)
- if len(groupIDs) > 0 && !input.SkipMixedChannelCheck {
- if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil {
- return nil, err
- }
- }
- // 校验并规范化请求头覆写配置(header 名小写化、格式检查)
- if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
- return nil, err
- }
- // Never persist ephemeral SSO/password secrets after OAuth conversion.
- input.Credentials = SanitizeStoredCredentials(input.Platform, input.Credentials)
- account, err := buildAccountForCreate(input, accountExtra)
- if err != nil {
- return nil, err
- }
- // Random proxy mode owns proxy selection at request time; never persist a
- // simultaneously supplied fixed proxy association.
- if account.IsRandomProxy() {
- account.ProxyID = nil
- }
- if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
- return nil, err
- }
- if err := s.accountRepo.Create(ctx, account); err != nil {
- return nil, err
- }
- // 绑定分组
- if len(groupIDs) > 0 {
- if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil {
- return nil, err
- }
- }
- // OAuth 账号:创建后异步设置隐私。
- // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。
- if account.Type == AccountTypeOAuth {
- switch account.Platform {
- case PlatformOpenAI:
- go func() {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r)
- }
- }()
- s.EnsureOpenAIPrivacy(context.Background(), account)
- }()
- case PlatformAntigravity:
- go func() {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r)
- }
- }()
- s.EnsureAntigravityPrivacy(context.Background(), account)
- }()
- }
- }
- return account, nil
- }
- func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return nil, err
- }
- if !ProtectionManagedWrite(ctx) && ProtectedProxyModeConflict(account, input.Extra) {
- return nil, ErrProtectedProxyModeChange
- }
- if err := validateRequestIntegrityExtra(input.Extra); err != nil {
- return nil, err
- }
- if _, err := ParseAccountTrafficPolicy(input.Extra); err != nil {
- return nil, err
- }
- var normalizedExtra map[string]any
- if input.Extra != nil {
- normalizedExtra, err = normalizeOpenAILongContextBillingUpdateExtra(account, input)
- if err != nil {
- return nil, err
- }
- normalizedExtra, err = normalizeGrokMediaEligibilityUpdateExtra(account, input, normalizedExtra)
- if err != nil {
- return nil, err
- }
- effectiveType := account.Type
- if input.Type != "" {
- effectiveType = input.Type
- }
- normalizedExtra, err = normalizeOpenAIAutoResetCreditExtra(account.Platform, effectiveType, account.IsShadow(), normalizedExtra)
- if err != nil {
- return nil, err
- }
- if err := ValidateUpstreamRequestIDHeaderExtra(normalizedExtra); err != nil {
- return nil, err
- }
- }
- previousProbeIdentity := upstreamBillingProbeIdentity(account)
- previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account)
- // 安全/身份不变量(影子账号):通用更新路径被 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 != "" {
- account.Name = input.Name
- }
- if input.Type != "" {
- account.Type = input.Type
- }
- if input.Notes != nil {
- account.Notes = normalizeAccountNotes(input.Notes)
- }
- 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)
- // 校验并规范化请求头覆写配置(header 名小写化、格式检查)
- if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil {
- return nil, err
- }
- // Strip SSO/password residue that must never sit next to OAuth tokens.
- account.Credentials = SanitizeStoredCredentials(account.Platform, account.Credentials)
- }
- // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
- // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
- requestedProbeEnabledUpdate := input.ProbeEnabled
- requestedRateSyncEnabledUpdate := input.RateSyncEnabled
- if input.Extra != nil {
- requestedProbeEnabled, hasRequestedProbeEnabled := normalizedExtra[UpstreamBillingProbeEnabledExtraKey]
- if hasRequestedProbeEnabled {
- enabled, ok := requestedProbeEnabled.(bool)
- if !ok {
- return nil, infraerrors.BadRequest("INVALID_UPSTREAM_BILLING_PROBE_ENABLED", "upstream_billing_probe_enabled must be a boolean")
- }
- if requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate != enabled {
- return nil, infraerrors.BadRequest("CONFLICTING_UPSTREAM_BILLING_PROBE_ENABLED", "conflicting upstream_billing_probe_enabled values")
- }
- requestedProbeEnabledUpdate = &enabled
- }
- delete(normalizedExtra, UpstreamBillingProbeEnabledExtraKey)
- delete(normalizedExtra, UpstreamBillingRateSyncEnabledExtraKey)
- delete(normalizedExtra, UpstreamBillingProbeExtraKey)
- delete(normalizedExtra, OllamaCloudUsageSessionExtraKey)
- delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey)
- delete(normalizedExtra, OllamaCloudUsageSnapshotExtraKey)
- // 保留配额用量和专用服务受管字段,防止普通账号编辑意外覆盖。
- for _, key := range []string{
- "quota_used",
- "quota_daily_used",
- "quota_daily_start",
- "quota_weekly_used",
- "quota_weekly_start",
- grokBillingExtraKey,
- UpstreamBillingProbeEnabledExtraKey,
- UpstreamBillingRateSyncEnabledExtraKey,
- UpstreamBillingProbeExtraKey,
- OllamaCloudUsageSessionExtraKey,
- OllamaCloudUsageAutoRefreshExtraKey,
- OllamaCloudUsageSnapshotExtraKey,
- OpenAIAutoResetCreditStateExtraKey,
- } {
- if v, ok := account.Extra[key]; ok {
- normalizedExtra[key] = v
- }
- }
- normalizedExtra = preserveMode1ManagedExtra(ctx, account, normalizedExtra)
- normalizedExtra = prepareCodexFingerprintExtraForUpdate(account, normalizedExtra)
- normalizedExtra = NormalizeProxyModeExtra(normalizedExtra)
- account.Extra = normalizedExtra
- if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() {
- delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
- // 清除 AICredits 限流 key
- if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok {
- delete(rawLimits, creditsExhaustedKey)
- }
- }
- if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() {
- delete(account.Extra, modelRateLimitsKey)
- delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
- }
- // 校验并预计算固定时间重置的下次重置时间
- if err := ValidateQuotaResetConfig(account.Extra); err != nil {
- return nil, err
- }
- ComputeQuotaResetAt(account.Extra)
- NormalizeFixedQuotaWindows(account.Extra)
- }
- if input.Extra == nil {
- account.Extra = prepareCodexFingerprintExtraForUpdate(account, account.Extra)
- }
- if requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate {
- if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
- return nil, infraerrors.BadRequest(
- "UPSTREAM_BILLING_RATE_SYNC_REQUIRES_PROBE",
- "upstream billing rate sync requires upstream billing probe",
- )
- }
- enabled := true
- requestedProbeEnabledUpdate = &enabled
- }
- if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
- disabled := false
- requestedRateSyncEnabledUpdate = &disabled
- }
- if (requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate) ||
- (requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate) {
- if !isUpstreamBillingProbeAccount(account) {
- return nil, ErrUpstreamBillingProbeAccountInvalid
- }
- }
- if account.Extra == nil && (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) {
- account.Extra = make(map[string]any)
- }
- if requestedProbeEnabledUpdate != nil {
- account.Extra[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate
- }
- if requestedRateSyncEnabledUpdate != nil {
- account.Extra[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate
- }
- // 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1;
- // 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。
- if input.ProxyID != nil && !account.IsCredentialShadow() {
- randomProxyRequested := input.Extra != nil && account.IsRandomProxy()
- // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图)
- if *input.ProxyID == 0 {
- account.ProxyID = nil
- if !randomProxyRequested && account.Extra != nil {
- delete(account.Extra, ProxyModeExtraKey)
- }
- } else {
- account.ProxyID = input.ProxyID
- if account.Extra != nil {
- delete(account.Extra, ProxyModeExtraKey)
- }
- }
- account.Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID
- }
- if !reflect.DeepEqual(previousProbeIdentity, upstreamBillingProbeIdentity(account)) && account.Extra != nil {
- delete(account.Extra, UpstreamBillingProbeExtraKey)
- if !isUpstreamBillingProbeAccount(account) {
- delete(account.Extra, UpstreamBillingProbeEnabledExtraKey)
- delete(account.Extra, UpstreamBillingRateSyncEnabledExtraKey)
- }
- }
- if account.Extra != nil {
- if !IsOllamaCloudUsageAccount(account) {
- delete(account.Extra, OllamaCloudUsageSessionExtraKey)
- delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
- delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
- } else if !reflect.DeepEqual(previousOllamaUsageIdentity, ollamaCloudUsageIdentity(account)) {
- delete(account.Extra, OllamaCloudUsageSessionExtraKey)
- delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
- delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
- }
- }
- // 只在指针非 nil 时更新 Concurrency(支持设置为 0)
- if input.Concurrency != nil {
- account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency)
- }
- // 只在指针非 nil 时更新 Priority(支持设置为 0)
- if input.Priority != nil {
- account.Priority = *input.Priority
- }
- if input.RateMultiplier != nil {
- if *input.RateMultiplier < 0 {
- return nil, errors.New("rate_multiplier must be >= 0")
- }
- // 同步开启时倍率归上游所有,手工值活不过下一次成功探测(表现为"改了又自己
- // 变回去"),与批量路径一样直接拒绝。判断的是本次请求生效后的状态:上面
- // 已把请求携带的两个开关落进 account.Extra,所以"同一请求关闭同步 + 改倍率"
- // (用户显式收回所有权)会走到这里时读到 false,正常放行。
- if upstreamBillingRateSyncEnabled(account) {
- return nil, ErrUpstreamBillingRateSyncConflict
- }
- account.RateMultiplier = input.RateMultiplier
- }
- if input.LoadFactor != nil {
- if *input.LoadFactor <= 0 {
- account.LoadFactor = nil // 0 或负数表示清除
- } else if *input.LoadFactor > 10000 {
- return nil, errors.New("load_factor must be <= 10000")
- } else {
- account.LoadFactor = input.LoadFactor
- }
- }
- if input.Status != "" {
- account.Status = input.Status
- }
- if input.ExpiresAt != nil {
- if *input.ExpiresAt <= 0 {
- account.ExpiresAt = nil
- } else {
- expiresAt := time.Unix(*input.ExpiresAt, 0)
- account.ExpiresAt = &expiresAt
- }
- }
- if input.AutoPauseOnExpired != nil {
- account.AutoPauseOnExpired = *input.AutoPauseOnExpired
- }
- // 先验证分组是否存在(在任何写操作之前)
- if input.GroupIDs != nil {
- if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
- return nil, err
- }
- if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
- return nil, err
- }
- // 检查混合渠道风险(除非用户已确认)
- if !input.SkipMixedChannelCheck {
- if err := s.checkMixedChannelRisk(ctx, account.ID, account.Platform, *input.GroupIDs); err != nil {
- return nil, err
- }
- }
- }
- BoundAccountProtectionConcurrency(account)
- if err := ValidateAccountProtectionConfiguration(account); err != nil {
- return nil, err
- }
- billingSettingsAppliedAtomically := false
- updater := s.accountBillingRepo
- if updater == nil {
- // Unit tests and narrow internal callers may construct adminServiceImpl
- // directly; production wiring requires this capability through
- // AdminAccountRepository.
- updater, _ = s.accountRepo.(AccountBillingSettingsRepository)
- }
- if updater != nil {
- if err := updater.UpdateWithAccountBillingSettings(
- ctx,
- account,
- requestedProbeEnabledUpdate,
- requestedRateSyncEnabledUpdate,
- input.RateMultiplier,
- ); err != nil {
- return nil, err
- }
- billingSettingsAppliedAtomically = true
- }
- if !billingSettingsAppliedAtomically {
- if err := s.accountRepo.Update(ctx, account); err != nil {
- return nil, err
- }
- if (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) &&
- isUpstreamBillingProbeAccount(account) {
- settings := make(map[string]any, 2)
- if requestedProbeEnabledUpdate != nil {
- settings[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate
- }
- if requestedRateSyncEnabledUpdate != nil {
- settings[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate
- }
- if err := s.accountRepo.UpdateExtra(ctx, account.ID, settings); err != nil {
- 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 {
- return nil, err
- }
- }
- // 重新查询以确保返回完整数据(包括正确的 Proxy 关联对象)
- updated, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return nil, err
- }
- return updated, nil
- }
- // UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键
- // (如 model_rate_limits / passive_usage_* 等)。
- func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error {
- if _, err := ParseAccountTrafficPolicy(updates); err != nil {
- return err
- }
- if err := validateRequestIntegrityExtra(updates); err != nil {
- return err
- }
- if hasMode1ManagedUpdates(updates) {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return err
- }
- if isMode1ProtectionRequested(account) {
- return infraerrors.BadRequest("MODE1_MANAGED_FIELDS", "模式一策略字段请通过专用应用/还原接口修改")
- }
- }
- updates = sanitizedCodexFingerprintExtraUpdates(updates)
- updates = stripOpenAIAutoResetCreditManagedExtra(updates, true)
- delete(updates, UpstreamBillingProbeEnabledExtraKey)
- delete(updates, UpstreamBillingRateSyncEnabledExtraKey)
- delete(updates, UpstreamBillingProbeExtraKey)
- delete(updates, OllamaCloudUsageSessionExtraKey)
- delete(updates, OllamaCloudUsageAutoRefreshExtraKey)
- delete(updates, OllamaCloudUsageSnapshotExtraKey)
- updates = NormalizeProxyModeExtra(updates)
- if err := ValidateUpstreamRequestIDHeaderExtra(updates); err != nil {
- return err
- }
- if _, exists := updates[openAILongContextBillingEnabledKey]; exists {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return err
- }
- if err := ValidateOpenAILongContextBillingExtra(account.Platform, updates); err != nil {
- return err
- }
- }
- if len(updates) == 0 {
- return nil
- }
- return s.accountRepo.UpdateExtra(ctx, id, updates)
- }
- // BulkUpdateAccounts updates multiple accounts in one request.
- // It merges credentials/extra keys instead of overwriting the whole object.
- func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
- // Managed probe/session state may only enter through dedicated typed endpoints.
- input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra)
- input.Extra = stripOpenAIAutoResetCreditManagedExtra(input.Extra, true)
- delete(input.Extra, UpstreamBillingProbeEnabledExtraKey)
- delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey)
- delete(input.Extra, UpstreamBillingProbeExtraKey)
- delete(input.Extra, OllamaCloudUsageSessionExtraKey)
- delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey)
- delete(input.Extra, OllamaCloudUsageSnapshotExtraKey)
- input.Extra = NormalizeProxyModeExtra(input.Extra)
- if err := ValidateUpstreamRequestIDHeaderExtra(input.Extra); err != nil {
- return nil, err
- }
- // Random mode and a fixed proxy are mutually exclusive. Normalize the bulk
- // request early so repository writes and shadow propagation clear proxy_id.
- if mode, ok := input.Extra[ProxyModeExtraKey].(string); ok && strings.EqualFold(strings.TrimSpace(mode), ProxyModeRandom) {
- proxyID := int64(0)
- input.ProxyID = &proxyID
- }
- if len(input.AccountIDs) == 0 && input.Filters != nil {
- accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
- if err != nil {
- return nil, err
- }
- input.AccountIDs = accountIDs
- }
- result := &BulkUpdateAccountsResult{
- SuccessIDs: make([]int64, 0, len(input.AccountIDs)),
- FailedIDs: make([]int64, 0, len(input.AccountIDs)),
- Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)),
- }
- if len(input.AccountIDs) == 0 {
- return result, nil
- }
- if input.GroupIDs != nil {
- if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
- return nil, err
- }
- if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
- return nil, err
- }
- }
- openAISettings, err := normalizeBulkOpenAISettings(input)
- if err != nil {
- return nil, err
- }
- needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
- if _, err := ParseAccountTrafficPolicy(input.Extra); err != nil {
- return nil, err
- }
- if err := validateRequestIntegrityExtra(input.Extra); err != nil {
- return nil, err
- }
- // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。
- var cachedTargets []*Account
- if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || openAISettings.any() || input.ProbeEnabled != nil || input.RateMultiplier != nil || hasMode1ManagedUpdates(input.Extra) {
- loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
- if err != nil {
- return nil, err
- }
- cachedTargets = loaded
- }
- if hasMode1ManagedUpdates(input.Extra) {
- for _, a := range cachedTargets {
- if ProtectedProxyModeConflict(a, input.Extra) {
- return nil, ErrProtectedProxyModeChange
- }
- if isMode1ProtectionRequested(a) {
- return nil, infraerrors.BadRequest("MODE1_MANAGED_FIELDS", "请先还原模式一,再批量修改其策略字段")
- }
- }
- }
- targetsByID := make(map[int64]*Account, len(cachedTargets))
- for _, account := range cachedTargets {
- if account != nil {
- targetsByID[account.ID] = account
- }
- }
- if openAISettings.any() {
- inheritedCount, err := validateBulkOpenAISettingsTargets(input, openAISettings, targetsByID)
- if err != nil {
- return nil, err
- }
- result.LongContextInheritedCount = inheritedCount
- }
- if input.ProbeEnabled != nil {
- for _, accountID := range input.AccountIDs {
- account, ok := targetsByID[accountID]
- if !ok {
- return nil, ErrAccountNotFound
- }
- if !isUpstreamBillingProbeAccount(account) {
- return nil, ErrUpstreamBillingProbeAccountInvalid
- }
- }
- }
- // 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 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
- }
- }
- }
- // 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。
- if needMixedChannelCheck {
- for _, accountID := range input.AccountIDs {
- platform := platformByID[accountID]
- if platform == "" {
- continue
- }
- if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil {
- return nil, err
- }
- }
- }
- if input.RateMultiplier != nil {
- if *input.RateMultiplier < 0 {
- return nil, errors.New("rate_multiplier must be >= 0")
- }
- syncEnabledCount := 0
- for _, account := range cachedTargets {
- if account == nil || account.Extra == nil {
- continue
- }
- enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool)
- if enabled {
- syncEnabledCount++
- }
- }
- if syncEnabledCount > 0 {
- return nil, ErrUpstreamBillingRateSyncBulkConflict.WithMetadata(map[string]string{
- "count": strconv.Itoa(syncEnabledCount),
- })
- }
- }
- // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可)
- if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
- return nil, err
- }
- // Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie
- // only when platform is known Grok — empty platform still strips password/*).
- if input.Credentials != nil {
- input.Credentials = SanitizeStoredCredentials("", input.Credentials)
- }
- // Prepare bulk updates for columns and JSONB fields.
- repoUpdates := AccountBulkUpdate{
- Credentials: input.Credentials,
- Extra: input.Extra,
- ProbeEnabled: input.ProbeEnabled,
- EnsureCodexFingerprintSeed: ShouldEnsureCodexFingerprintSeedForExtraUpdates(input.Extra),
- }
- if input.ProbeEnabled != nil {
- if repoUpdates.Extra == nil {
- repoUpdates.Extra = make(map[string]any)
- }
- repoUpdates.Extra[UpstreamBillingProbeEnabledExtraKey] = *input.ProbeEnabled
- if !*input.ProbeEnabled {
- repoUpdates.Extra[UpstreamBillingRateSyncEnabledExtraKey] = false
- }
- }
- if updatesUpstreamBillingProbeIdentity(input.Credentials) || input.ProxyID != nil {
- if repoUpdates.Extra == nil {
- repoUpdates.Extra = make(map[string]any)
- }
- // JSON null makes every reader treat the old snapshot as absent and lets the
- // next enabled runner cycle probe the new upstream identity immediately.
- repoUpdates.Extra[UpstreamBillingProbeExtraKey] = nil
- }
- if input.Name != "" {
- repoUpdates.Name = &input.Name
- }
- if input.ProxyID != nil {
- repoUpdates.ProxyID = input.ProxyID
- }
- if input.Concurrency != nil {
- repoUpdates.Concurrency = input.Concurrency
- }
- if input.Priority != nil {
- repoUpdates.Priority = input.Priority
- }
- if input.RateMultiplier != nil {
- repoUpdates.RateMultiplier = input.RateMultiplier
- }
- if input.LoadFactor != nil {
- if *input.LoadFactor <= 0 {
- repoUpdates.LoadFactor = nil // 0 或负数表示清除
- } else if *input.LoadFactor > 10000 {
- return nil, errors.New("load_factor must be <= 10000")
- } else {
- repoUpdates.LoadFactor = input.LoadFactor
- }
- }
- if input.Status != "" {
- repoUpdates.Status = &input.Status
- }
- if input.Schedulable != nil {
- repoUpdates.Schedulable = input.Schedulable
- }
- // Run bulk update for column/jsonb fields first.
- if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil {
- 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}
- if input.GroupIDs != nil {
- if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil {
- entry.Success = false
- entry.Error = err.Error()
- result.Failed++
- result.FailedIDs = append(result.FailedIDs, accountID)
- result.Results = append(result.Results, entry)
- continue
- }
- }
- entry.Success = true
- result.Success++
- result.SuccessIDs = append(result.SuccessIDs, accountID)
- result.Results = append(result.Results, entry)
- }
- return result, nil
- }
- func updatesUpstreamBillingProbeIdentity(credentials map[string]any) bool {
- for _, key := range []string{"api_key", "base_url", credKeyHeaderOverrideEnabled, credKeyHeaderOverrides} {
- if _, ok := credentials[key]; ok {
- return true
- }
- }
- return false
- }
- func upstreamBillingProbeIdentity(account *Account) map[string]any {
- if account == nil {
- return nil
- }
- identity := map[string]any{"platform": account.Platform, "type": account.Type, "proxy_id": nil}
- if account.ProxyID != nil {
- identity["proxy_id"] = *account.ProxyID
- }
- for _, key := range []string{"api_key", "base_url", credKeyHeaderOverrideEnabled, credKeyHeaderOverrides} {
- if value, ok := account.Credentials[key]; ok {
- identity[key] = value
- }
- }
- return identity
- }
- func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) {
- if filters == nil {
- return nil, nil
- }
- groupID := int64(0)
- switch strings.TrimSpace(filters.Group) {
- case "":
- case "ungrouped":
- groupID = AccountListGroupUngrouped
- default:
- parsedGroupID, err := strconv.ParseInt(strings.TrimSpace(filters.Group), 10, 64)
- if err != nil {
- return nil, fmt.Errorf("invalid group filter: %w", err)
- }
- groupID = parsedGroupID
- }
- const pageSize = 500
- page := 1
- accountIDs := make([]int64, 0, pageSize)
- for {
- accounts, total, err := s.ListAccounts(
- ctx,
- page,
- pageSize,
- filters.Platform,
- filters.Type,
- filters.Status,
- filters.Search,
- groupID,
- filters.PrivacyMode,
- "",
- "",
- )
- if err != nil {
- return nil, err
- }
- for _, account := range accounts {
- accountIDs = append(accountIDs, account.ID)
- }
- if int64(len(accountIDs)) >= total || len(accounts) == 0 {
- return accountIDs, nil
- }
- page++
- }
- }
- 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
- }
- return nil
- }
- func (s *adminServiceImpl) RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error) {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return nil, err
- }
- // TODO: Implement refresh logic
- return account, nil
- }
- func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Account, error) {
- if err := s.accountRepo.ClearError(ctx, id); err != nil {
- return nil, err
- }
- if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil {
- return nil, err
- }
- if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil {
- return nil, err
- }
- if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil {
- return nil, err
- }
- if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil {
- return nil, err
- }
- if s.runtimeBlocker != nil {
- s.runtimeBlocker.ClearAccountSchedulingBlock(id)
- }
- return s.accountRepo.GetByID(ctx, id)
- }
- func (s *adminServiceImpl) SetAccountError(ctx context.Context, id int64, errorMsg string) error {
- return s.accountRepo.SetError(ctx, id, errorMsg)
- }
- // BackfillAccountCredentialsEncryption 加密一批历史明文账号凭据(幂等)。
- // 仓储层无该能力(如测试替身)时返回 0,不报错。
- func (s *adminServiceImpl) BackfillAccountCredentialsEncryption(ctx context.Context, limit int) (int64, error) {
- repo, ok := s.accountRepo.(interface {
- BackfillCredentialsEncryption(context.Context, int) (int64, error)
- })
- if !ok || repo == nil {
- return 0, nil
- }
- return repo.BackfillCredentialsEncryption(ctx, limit)
- }
- func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error) {
- if err := s.accountRepo.SetSchedulable(ctx, id, schedulable); err != nil {
- return nil, err
- }
- updated, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return nil, err
- }
- return updated, nil
- }
- func (s *adminServiceImpl) RevertAccountProxyFallback(ctx context.Context, id int64) error {
- 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
- }
- }
- }
- }
- if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
- return nil, err
- }
- // 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,
- Extra: map[string]any{
- openAILongContextBillingEnabledKey: parent.IsOpenAILongContextBillingEnabled(),
- },
- }
- // 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
- }
- // checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic)
- // 如果存在混合,返回错误提示用户确认
- func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
- // 判断当前账号的渠道类型(基于 platform 字段,而不是 type 字段)
- currentPlatform := getAccountPlatform(currentAccountPlatform)
- if currentPlatform == "" {
- // 不是 Antigravity 或 Anthropic,无需检查
- return nil
- }
- // 检查每个分组中的其他账号
- for _, groupID := range groupIDs {
- accounts, err := s.accountRepo.ListByGroup(ctx, groupID)
- if err != nil {
- return fmt.Errorf("get accounts in group %d: %w", groupID, err)
- }
- // 检查是否存在不同渠道的账号
- for _, account := range accounts {
- if currentAccountID > 0 && account.ID == currentAccountID {
- continue // 跳过当前账号
- }
- otherPlatform := getAccountPlatform(account.Platform)
- if otherPlatform == "" {
- continue // 不是 Antigravity 或 Anthropic,跳过
- }
- // 检测混合渠道
- if currentPlatform != otherPlatform {
- group, _ := s.groupRepo.GetByID(ctx, groupID)
- groupName := fmt.Sprintf("Group %d", groupID)
- if group != nil {
- groupName = group.Name
- }
- return &MixedChannelError{
- GroupID: groupID,
- GroupName: groupName,
- CurrentPlatform: currentPlatform,
- OtherPlatform: otherPlatform,
- }
- }
- }
- }
- return nil
- }
- func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error {
- if len(groupIDs) == 0 {
- return nil
- }
- if s.groupRepo == nil {
- return errors.New("group repository not configured")
- }
- if batchReader, ok := s.groupRepo.(groupExistenceBatchReader); ok {
- existsByID, err := batchReader.ExistsByIDs(ctx, groupIDs)
- if err != nil {
- return fmt.Errorf("check groups exists: %w", err)
- }
- for _, groupID := range groupIDs {
- if groupID <= 0 || !existsByID[groupID] {
- return fmt.Errorf("get group: %w", ErrGroupNotFound)
- }
- }
- return nil
- }
- for _, groupID := range groupIDs {
- if _, err := s.groupRepo.GetByID(ctx, groupID); err != nil {
- return fmt.Errorf("get group: %w", err)
- }
- }
- return nil
- }
- // ValidateAccountGroupBindings is the shared fail-closed policy boundary for
- // every account path that accepts explicit group bindings.
- func (s *adminServiceImpl) ValidateAccountGroupBindings(ctx context.Context, groupIDs []int64) error {
- if len(groupIDs) == 0 || s.cfg == nil || s.cfg.RunMode != config.RunModeSimple {
- return nil
- }
- if s.groupRepo == nil {
- return errors.New("group repository not configured")
- }
- seen := make(map[int64]struct{}, len(groupIDs))
- for _, groupID := range groupIDs {
- if groupID <= 0 {
- return fmt.Errorf("get group: %w", ErrGroupNotFound)
- }
- if _, ok := seen[groupID]; ok {
- continue
- }
- seen[groupID] = struct{}{}
- group, err := s.groupRepo.GetByIDLite(ctx, groupID)
- if err != nil {
- return fmt.Errorf("get group: %w", err)
- }
- if !IsGroupBindableInSimpleMode(group) {
- return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
- }
- }
- return nil
- }
- // CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform.
- func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
- return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs)
- }
- // getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识
- func getAccountPlatform(accountPlatform string) string {
- switch strings.ToLower(strings.TrimSpace(accountPlatform)) {
- case PlatformAntigravity:
- return "Antigravity"
- case PlatformAnthropic, "claude":
- return "Anthropic"
- default:
- return ""
- }
- }
- // MixedChannelError 混合渠道错误
- type MixedChannelError struct {
- GroupID int64
- GroupName string
- CurrentPlatform string
- OtherPlatform string
- }
- func (e *MixedChannelError) Error() string {
- return fmt.Sprintf("mixed_channel_warning: Group '%s' contains both %s and %s accounts. Using mixed channels in the same context may cause thinking block signature validation issues, which will fallback to non-thinking mode for historical messages.",
- e.GroupName, e.CurrentPlatform, e.OtherPlatform)
- }
- 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.ResetQuotaUsedAndClearRateLimitCooldown(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 ""
- }
- if s.privacyClientFactory == nil {
- return ""
- }
- if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
- return ""
- }
- token, _ := account.Credentials["access_token"].(string)
- if token == "" {
- return ""
- }
- var proxyURL string
- if account.ProxyID != nil {
- if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
- proxyURL = p.URL()
- }
- }
- mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
- if mode == "" {
- return ""
- }
- _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
- return mode
- }
- // 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 ""
- }
- if s.privacyClientFactory == nil {
- return ""
- }
- token, _ := account.Credentials["access_token"].(string)
- if token == "" {
- return ""
- }
- var proxyURL string
- if account.ProxyID != nil {
- if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
- proxyURL = p.URL()
- }
- }
- mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
- if mode == "" {
- return ""
- }
- if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
- logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
- return mode
- }
- if account.Extra == nil {
- account.Extra = make(map[string]any)
- }
- account.Extra["privacy_mode"] = mode
- return mode
- }
- // EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
- // 仅当 privacy_mode 已成功设置("privacy_set")时跳过;
- // 未设置或之前失败("privacy_set_failed")均会重试。
- func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
- if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
- return ""
- }
- if account.Extra != nil {
- if existing, ok := account.Extra["privacy_mode"].(string); ok && existing == AntigravityPrivacySet {
- return existing
- }
- }
- token, _ := account.Credentials["access_token"].(string)
- if token == "" {
- return ""
- }
- projectID, _ := account.Credentials["project_id"].(string)
- var proxyURL string
- if account.ProxyID != nil {
- if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
- proxyURL = p.URL()
- }
- }
- mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
- if mode == "" {
- return ""
- }
- if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
- logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
- return mode
- }
- applyAntigravityPrivacyMode(account, mode)
- return mode
- }
- // ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
- func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string {
- if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
- return ""
- }
- token, _ := account.Credentials["access_token"].(string)
- if token == "" {
- return ""
- }
- projectID, _ := account.Credentials["project_id"].(string)
- var proxyURL string
- if account.ProxyID != nil {
- if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
- proxyURL = p.URL()
- }
- }
- mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
- if mode == "" {
- return ""
- }
- if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
- logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
- return mode
- }
- applyAntigravityPrivacyMode(account, mode)
- return mode
- }
|