admin_account.go 66 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "log/slog"
  9. "maps"
  10. "net/http"
  11. "reflect"
  12. "strconv"
  13. "strings"
  14. "time"
  15. "github.com/Wei-Shaw/sub2api/internal/config"
  16. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  18. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  19. )
  20. // Account management implementations
  21. 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) {
  22. if groupID > 0 {
  23. if err := s.ValidateAccountGroupBindings(ctx, []int64{groupID}); err != nil {
  24. return nil, 0, err
  25. }
  26. }
  27. params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
  28. accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode)
  29. if err != nil {
  30. return nil, 0, err
  31. }
  32. return accounts, result.Total, nil
  33. }
  34. func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) {
  35. if s == nil || s.accountRepo == nil {
  36. return nil, nil
  37. }
  38. return s.accountRepo.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode)
  39. }
  40. func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) {
  41. if s == nil || s.accountRepo == nil {
  42. return nil, nil
  43. }
  44. if groupID != nil {
  45. return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI)
  46. }
  47. return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI)
  48. }
  49. func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account, error) {
  50. return s.accountRepo.GetByID(ctx, id)
  51. }
  52. func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) {
  53. if len(ids) == 0 {
  54. return []*Account{}, nil
  55. }
  56. accounts, err := s.accountRepo.GetByIDs(ctx, ids)
  57. if err != nil {
  58. return nil, fmt.Errorf("failed to get accounts by IDs: %w", err)
  59. }
  60. return accounts, nil
  61. }
  62. const maxAccountNameRunes = 100
  63. const duplicateAccountOperationIDExtraKey = "duplicate_operation_id"
  64. func duplicateAccountName(sourceName string) string {
  65. const suffix = " (Copy)"
  66. nameRunes := []rune(strings.TrimSpace(sourceName))
  67. maxBaseRunes := maxAccountNameRunes - len([]rune(suffix))
  68. if len(nameRunes) > maxBaseRunes {
  69. nameRunes = nameRunes[:maxBaseRunes]
  70. }
  71. return string(nameRunes) + suffix
  72. }
  73. func cloneAccountJSONMap(value map[string]any) (map[string]any, error) {
  74. if value == nil {
  75. return nil, nil
  76. }
  77. payload, err := json.Marshal(value)
  78. if err != nil {
  79. return nil, err
  80. }
  81. cloned := make(map[string]any, len(value))
  82. if err := json.Unmarshal(payload, &cloned); err != nil {
  83. return nil, err
  84. }
  85. return cloned, nil
  86. }
  87. var duplicateAccountDiscardedExtraKeys = map[string]struct{}{
  88. // A retry identity belongs to the operation that created one copy, not to later copies.
  89. duplicateAccountOperationIDExtraKey: {},
  90. // External sync identity belongs to one local account only.
  91. "crs_account_id": {},
  92. "crs_kind": {},
  93. "crs_synced_at": {},
  94. // Local quota usage and derived window timestamps must start fresh.
  95. "quota_used": {},
  96. "quota_daily_used": {},
  97. "quota_weekly_used": {},
  98. "quota_daily_start": {},
  99. "quota_weekly_start": {},
  100. "quota_daily_reset_at": {},
  101. "quota_weekly_reset_at": {},
  102. // Provider observations, capability probes, and transient scheduling state.
  103. "model_rate_limits": {},
  104. "session_window_utilization": {},
  105. "passive_usage_7d_utilization": {},
  106. "passive_usage_7d_reset": {},
  107. "passive_usage_7d_oi_utilization": {},
  108. "passive_usage_7d_oi_reset": {},
  109. "passive_usage_sampled_at": {},
  110. "grok_usage_snapshot": {},
  111. "grok_billing_snapshot": {},
  112. "openai_responses_supported": {},
  113. "openai_compact_supported": {},
  114. "openai_compact_checked_at": {},
  115. "openai_compact_last_status": {},
  116. "openai_compact_last_error": {},
  117. "antigravity_credits_overages": {},
  118. "antigravity_force_token_refresh": {},
  119. "antigravity_force_token_refresh_at": {},
  120. "antigravity_force_token_refresh_reason": {},
  121. "drive_storage_limit": {},
  122. "drive_storage_usage": {},
  123. "drive_tier_updated_at": {},
  124. // Codex fingerprint convergence uses a per-account random seed, never copied from another account.
  125. codexFingerprintSeedExtraKey: {},
  126. "codex_primary_used_percent": {},
  127. "codex_primary_reset_after_seconds": {},
  128. "codex_primary_window_minutes": {},
  129. "codex_secondary_used_percent": {},
  130. "codex_secondary_reset_after_seconds": {},
  131. "codex_secondary_window_minutes": {},
  132. "codex_primary_over_secondary_percent": {},
  133. "codex_usage_updated_at": {},
  134. "codex_5h_used_percent": {},
  135. "codex_5h_reset_after_seconds": {},
  136. "codex_5h_window_minutes": {},
  137. "codex_5h_reset_at": {},
  138. "codex_7d_used_percent": {},
  139. "codex_7d_reset_after_seconds": {},
  140. "codex_7d_window_minutes": {},
  141. "codex_7d_reset_at": {},
  142. }
  143. func duplicateAccountExtra(value map[string]any) (map[string]any, error) {
  144. cloned, err := cloneAccountJSONMap(value)
  145. if err != nil {
  146. return nil, err
  147. }
  148. for key := range duplicateAccountDiscardedExtraKeys {
  149. delete(cloned, key)
  150. }
  151. return cloned, nil
  152. }
  153. func canDuplicateAccountType(accountType string) bool {
  154. switch accountType {
  155. case AccountTypeAPIKey, AccountTypeUpstream, AccountTypeBedrock, AccountTypeServiceAccount:
  156. return true
  157. default:
  158. return false
  159. }
  160. }
  161. func duplicateAccountGroups(source *Account) ([]AccountGroup, []int64) {
  162. if len(source.AccountGroups) > 0 {
  163. groups := make([]AccountGroup, 0, len(source.AccountGroups))
  164. groupIDs := make([]int64, 0, len(source.AccountGroups))
  165. for _, sourceGroup := range source.AccountGroups {
  166. groups = append(groups, AccountGroup{GroupID: sourceGroup.GroupID, Priority: sourceGroup.Priority})
  167. groupIDs = append(groupIDs, sourceGroup.GroupID)
  168. }
  169. return groups, groupIDs
  170. }
  171. groups := make([]AccountGroup, 0, len(source.GroupIDs))
  172. groupIDs := append([]int64(nil), source.GroupIDs...)
  173. for i, groupID := range groupIDs {
  174. groups = append(groups, AccountGroup{GroupID: groupID, Priority: i + 1})
  175. }
  176. return groups, groupIDs
  177. }
  178. func duplicateAccountOperationID(sourceID int64, actorScope, operationKey string) string {
  179. operationKey = strings.TrimSpace(operationKey)
  180. if operationKey == "" {
  181. return ""
  182. }
  183. actorScope = strings.TrimSpace(actorScope)
  184. if actorScope == "" {
  185. actorScope = "admin:0"
  186. }
  187. payload := "admin.accounts.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey
  188. digest := sha256.Sum256([]byte(payload))
  189. return fmt.Sprintf("%x", digest)
  190. }
  191. func (s *adminServiceImpl) findDuplicateByOperationID(ctx context.Context, operationID string) (*Account, error) {
  192. if operationID == "" {
  193. return nil, nil
  194. }
  195. accounts, err := s.accountRepo.FindByExtraField(ctx, duplicateAccountOperationIDExtraKey, operationID)
  196. if err != nil {
  197. return nil, fmt.Errorf("find duplicate account operation: %w", err)
  198. }
  199. if len(accounts) == 0 {
  200. return nil, nil
  201. }
  202. account := accounts[0]
  203. return &account, nil
  204. }
  205. // RecoverDuplicateAccount performs a read-only lookup for an already committed duplicate.
  206. // It is used when the idempotency coordinator cannot confirm whether response persistence
  207. // succeeded, and deliberately never repeats the create side effect.
  208. func (s *adminServiceImpl) RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) {
  209. return s.findDuplicateByOperationID(ctx, duplicateAccountOperationID(id, actorScope, operationKey))
  210. }
  211. func cloneAccountValuePointer[T any](value *T) *T {
  212. if value == nil {
  213. return nil
  214. }
  215. cloned := *value
  216. return &cloned
  217. }
  218. // DuplicateAccount creates a paused account from source configuration without carrying first-class
  219. // runtime state. Credentials and extra configuration are deep-copied so normalization of the new
  220. // account cannot mutate the in-memory source. Linked credential shadows are excluded because they
  221. // intentionally do not own credentials and must be created through CreateShadow.
  222. func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) {
  223. operationID := duplicateAccountOperationID(id, actorScope, operationKey)
  224. existing, err := s.RecoverDuplicateAccount(ctx, id, actorScope, operationKey)
  225. if err != nil {
  226. return nil, err
  227. }
  228. if existing != nil {
  229. return existing, nil
  230. }
  231. source, err := s.accountRepo.GetByID(ctx, id)
  232. if err != nil {
  233. return nil, err
  234. }
  235. if source.IsCredentialShadow() {
  236. return nil, infraerrors.BadRequest(
  237. "ACCOUNT_DUPLICATE_SHADOW_UNSUPPORTED",
  238. "linked credential shadow accounts cannot be duplicated; duplicate the parent account instead",
  239. )
  240. }
  241. if !canDuplicateAccountType(source.Type) {
  242. return nil, infraerrors.BadRequest(
  243. "ACCOUNT_DUPLICATE_CREDENTIAL_TYPE_UNSUPPORTED",
  244. "accounts with rotating or unsupported credential types cannot be duplicated",
  245. )
  246. }
  247. credentials, err := cloneAccountJSONMap(source.Credentials)
  248. if err != nil {
  249. return nil, fmt.Errorf("clone account credentials: %w", err)
  250. }
  251. extra, err := duplicateAccountExtra(source.Extra)
  252. if err != nil {
  253. return nil, fmt.Errorf("clone account extra configuration: %w", err)
  254. }
  255. if operationID != "" {
  256. if extra == nil {
  257. extra = make(map[string]any, 1)
  258. }
  259. extra[duplicateAccountOperationIDExtraKey] = operationID
  260. }
  261. var expiresAt *int64
  262. if source.ExpiresAt != nil {
  263. unix := source.ExpiresAt.Unix()
  264. expiresAt = &unix
  265. }
  266. autoPauseOnExpired := source.AutoPauseOnExpired
  267. groups, groupIDs := duplicateAccountGroups(source)
  268. if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
  269. return nil, err
  270. }
  271. proxyID := source.ProxyID
  272. if source.ProxyFallbackOriginID != nil {
  273. // Proxy fallback is transient runtime state; duplicate the configured origin.
  274. proxyID = source.ProxyFallbackOriginID
  275. }
  276. input := &CreateAccountInput{
  277. Name: duplicateAccountName(source.Name),
  278. Notes: cloneAccountValuePointer(source.Notes),
  279. Platform: source.Platform,
  280. Type: source.Type,
  281. Credentials: credentials,
  282. Extra: extra,
  283. ProxyID: cloneAccountValuePointer(proxyID),
  284. Concurrency: source.Concurrency,
  285. Priority: source.Priority,
  286. RateMultiplier: cloneAccountValuePointer(source.RateMultiplier),
  287. LoadFactor: cloneAccountValuePointer(source.LoadFactor),
  288. GroupIDs: groupIDs,
  289. ExpiresAt: expiresAt,
  290. AutoPauseOnExpired: &autoPauseOnExpired,
  291. SkipDefaultGroupBind: true,
  292. SkipMixedChannelCheck: true,
  293. }
  294. accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra)
  295. if err != nil {
  296. return nil, fmt.Errorf("normalize duplicate account extra: %w", err)
  297. }
  298. accountExtra = NormalizeProxyModeExtra(accountExtra)
  299. if err := ValidateUpstreamRequestIDHeaderExtra(accountExtra); err != nil {
  300. return nil, fmt.Errorf("validate duplicate account extra: %w", err)
  301. }
  302. if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
  303. return nil, err
  304. }
  305. duplicate, err := buildAccountForCreate(input, accountExtra)
  306. if err != nil {
  307. return nil, err
  308. }
  309. // A copied credential must be reviewed before it can share live traffic with its source.
  310. duplicate.Schedulable = false
  311. if s.accountDuplicateRepo == nil {
  312. return nil, errors.New("account duplicate repository is not configured")
  313. }
  314. if err := s.accountDuplicateRepo.CreateWithAccountGroups(ctx, duplicate, groups); err != nil {
  315. return nil, fmt.Errorf("create duplicate account: %w", err)
  316. }
  317. for i := range groups {
  318. groups[i].AccountID = duplicate.ID
  319. }
  320. duplicate.AccountGroups = groups
  321. duplicate.GroupIDs = groupIDs
  322. return duplicate, nil
  323. }
  324. func normalizeAccountConcurrency(platform, accountType string, concurrency int) int {
  325. if platform == PlatformGrok && accountType == AccountTypeOAuth {
  326. if concurrency <= 0 {
  327. return 1
  328. }
  329. }
  330. return concurrency
  331. }
  332. // ValidateOpenAILongContextBillingExtra validates the OpenAI account billing flag when present.
  333. func ValidateOpenAILongContextBillingExtra(platform string, extra map[string]any) error {
  334. if platform != PlatformOpenAI {
  335. return nil
  336. }
  337. raw, exists := extra[openAILongContextBillingEnabledKey]
  338. if !exists {
  339. return nil
  340. }
  341. if _, ok := raw.(bool); !ok {
  342. return infraerrors.BadRequest(
  343. "OPENAI_LONG_CONTEXT_BILLING_INVALID",
  344. "openai_long_context_billing_enabled must be a boolean",
  345. )
  346. }
  347. return nil
  348. }
  349. func normalizeOpenAILongContextBillingExtra(platform string, extra map[string]any) (map[string]any, error) {
  350. if platform != PlatformOpenAI {
  351. return extra, nil
  352. }
  353. if err := ValidateOpenAILongContextBillingExtra(platform, extra); err != nil {
  354. return nil, err
  355. }
  356. normalized := maps.Clone(extra)
  357. if normalized == nil {
  358. normalized = make(map[string]any, 1)
  359. }
  360. _, exists := normalized[openAILongContextBillingEnabledKey]
  361. if !exists {
  362. normalized[openAILongContextBillingEnabledKey] = false
  363. }
  364. return normalized, nil
  365. }
  366. func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *UpdateAccountInput) (map[string]any, error) {
  367. normalized, err := normalizeOpenAILongContextBillingExtra(account.Platform, input.Extra)
  368. if err != nil || account.Platform != PlatformOpenAI {
  369. return normalized, err
  370. }
  371. _, provided := input.Extra[openAILongContextBillingEnabledKey]
  372. current, hasCurrent := account.Extra[openAILongContextBillingEnabledKey].(bool)
  373. if !provided {
  374. if hasCurrent {
  375. normalized[openAILongContextBillingEnabledKey] = current
  376. }
  377. }
  378. return normalized, nil
  379. }
  380. // Grok media eligibility helpers live in account_grok_media_eligibility.go.
  381. func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) {
  382. // Probe/session state is system-managed. New accounts always start with automatic refresh disabled.
  383. delete(accountExtra, UpstreamBillingProbeEnabledExtraKey)
  384. delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey)
  385. delete(accountExtra, UpstreamBillingProbeExtraKey)
  386. delete(accountExtra, OllamaCloudUsageSessionExtraKey)
  387. delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey)
  388. delete(accountExtra, OllamaCloudUsageSnapshotExtraKey)
  389. accountExtra = prepareCodexFingerprintExtraForCreate(input.Platform, input.Type, accountExtra)
  390. account := &Account{
  391. Name: input.Name,
  392. Notes: normalizeAccountNotes(input.Notes),
  393. Platform: input.Platform,
  394. Type: input.Type,
  395. Credentials: input.Credentials,
  396. Extra: accountExtra,
  397. ProxyID: input.ProxyID,
  398. Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency),
  399. Priority: input.Priority,
  400. Status: StatusActive,
  401. Schedulable: true,
  402. }
  403. if input.ProbeEnabled != nil && *input.ProbeEnabled {
  404. if !isUpstreamBillingProbeAccount(account) {
  405. return nil, ErrUpstreamBillingProbeAccountInvalid
  406. }
  407. if account.Extra == nil {
  408. account.Extra = make(map[string]any)
  409. }
  410. account.Extra[UpstreamBillingProbeEnabledExtraKey] = true
  411. }
  412. // 预计算固定时间重置的下次重置时间
  413. if account.Extra != nil {
  414. if err := ValidateQuotaResetConfig(account.Extra); err != nil {
  415. return nil, err
  416. }
  417. ComputeQuotaResetAt(account.Extra)
  418. NormalizeFixedQuotaWindows(account.Extra)
  419. }
  420. if input.ExpiresAt != nil && *input.ExpiresAt > 0 {
  421. expiresAt := time.Unix(*input.ExpiresAt, 0)
  422. account.ExpiresAt = &expiresAt
  423. }
  424. if input.AutoPauseOnExpired != nil {
  425. account.AutoPauseOnExpired = *input.AutoPauseOnExpired
  426. } else {
  427. account.AutoPauseOnExpired = true
  428. }
  429. if input.RateMultiplier != nil {
  430. if *input.RateMultiplier < 0 {
  431. return nil, errors.New("rate_multiplier must be >= 0")
  432. }
  433. account.RateMultiplier = input.RateMultiplier
  434. }
  435. if input.LoadFactor != nil && *input.LoadFactor > 0 {
  436. if *input.LoadFactor > 10000 {
  437. return nil, errors.New("load_factor must be <= 10000")
  438. }
  439. account.LoadFactor = input.LoadFactor
  440. }
  441. return account, nil
  442. }
  443. func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
  444. accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra)
  445. if err != nil {
  446. return nil, err
  447. }
  448. accountExtra, err = normalizeGrokMediaEligibilityExtra(input.Platform, accountExtra)
  449. if err != nil {
  450. return nil, err
  451. }
  452. accountExtra, err = normalizeOpenAIAutoResetCreditExtra(input.Platform, input.Type, false, accountExtra)
  453. if err != nil {
  454. return nil, err
  455. }
  456. accountExtra = NormalizeProxyModeExtra(accountExtra)
  457. if err := ValidateUpstreamRequestIDHeaderExtra(accountExtra); err != nil {
  458. return nil, err
  459. }
  460. // 绑定分组
  461. groupIDs := input.GroupIDs
  462. // 如果没有指定分组,自动绑定对应平台的默认分组
  463. if len(groupIDs) == 0 && !input.SkipDefaultGroupBind {
  464. defaultGroupName := input.Platform + "-default"
  465. groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
  466. if err == nil {
  467. for _, g := range groups {
  468. if g.Name == defaultGroupName {
  469. groupIDs = []int64{g.ID}
  470. break
  471. }
  472. }
  473. }
  474. }
  475. // 检查混合渠道风险(除非用户已确认)
  476. if len(groupIDs) > 0 && !input.SkipMixedChannelCheck {
  477. if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil {
  478. return nil, err
  479. }
  480. }
  481. // 校验并规范化请求头覆写配置(header 名小写化、格式检查)
  482. if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
  483. return nil, err
  484. }
  485. // Never persist ephemeral SSO/password secrets after OAuth conversion.
  486. input.Credentials = SanitizeStoredCredentials(input.Platform, input.Credentials)
  487. account, err := buildAccountForCreate(input, accountExtra)
  488. if err != nil {
  489. return nil, err
  490. }
  491. // Random proxy mode owns proxy selection at request time; never persist a
  492. // simultaneously supplied fixed proxy association.
  493. if account.IsRandomProxy() {
  494. account.ProxyID = nil
  495. }
  496. if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
  497. return nil, err
  498. }
  499. if err := s.accountRepo.Create(ctx, account); err != nil {
  500. return nil, err
  501. }
  502. // 绑定分组
  503. if len(groupIDs) > 0 {
  504. if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil {
  505. return nil, err
  506. }
  507. }
  508. // OAuth 账号:创建后异步设置隐私。
  509. // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。
  510. if account.Type == AccountTypeOAuth {
  511. switch account.Platform {
  512. case PlatformOpenAI:
  513. go func() {
  514. defer func() {
  515. if r := recover(); r != nil {
  516. slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r)
  517. }
  518. }()
  519. s.EnsureOpenAIPrivacy(context.Background(), account)
  520. }()
  521. case PlatformAntigravity:
  522. go func() {
  523. defer func() {
  524. if r := recover(); r != nil {
  525. slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r)
  526. }
  527. }()
  528. s.EnsureAntigravityPrivacy(context.Background(), account)
  529. }()
  530. }
  531. }
  532. return account, nil
  533. }
  534. func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) {
  535. account, err := s.accountRepo.GetByID(ctx, id)
  536. if err != nil {
  537. return nil, err
  538. }
  539. if !ProtectionManagedWrite(ctx) && ProtectedProxyModeConflict(account, input.Extra) {
  540. return nil, ErrProtectedProxyModeChange
  541. }
  542. if err := validateRequestIntegrityExtra(input.Extra); err != nil {
  543. return nil, err
  544. }
  545. if _, err := ParseAccountTrafficPolicy(input.Extra); err != nil {
  546. return nil, err
  547. }
  548. var normalizedExtra map[string]any
  549. if input.Extra != nil {
  550. normalizedExtra, err = normalizeOpenAILongContextBillingUpdateExtra(account, input)
  551. if err != nil {
  552. return nil, err
  553. }
  554. normalizedExtra, err = normalizeGrokMediaEligibilityUpdateExtra(account, input, normalizedExtra)
  555. if err != nil {
  556. return nil, err
  557. }
  558. effectiveType := account.Type
  559. if input.Type != "" {
  560. effectiveType = input.Type
  561. }
  562. normalizedExtra, err = normalizeOpenAIAutoResetCreditExtra(account.Platform, effectiveType, account.IsShadow(), normalizedExtra)
  563. if err != nil {
  564. return nil, err
  565. }
  566. if err := ValidateUpstreamRequestIDHeaderExtra(normalizedExtra); err != nil {
  567. return nil, err
  568. }
  569. }
  570. previousProbeIdentity := upstreamBillingProbeIdentity(account)
  571. previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account)
  572. // 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用,
  573. // 必须在此守住,否则仅在创建时的保证可被这些路径绕过。
  574. if account.IsCredentialShadow() {
  575. // 影子绝不持有凭据(凭据只在母账号)——外审 F5。
  576. if !isAllowedSparkShadowCredentialsUpdate(input.Credentials) {
  577. return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS",
  578. "spark shadow accounts do not hold auth credentials; only model mapping can be configured on the shadow account")
  579. }
  580. // 影子 type 不可变——很多上游逻辑按 account.Type 分支(OAuth transform / ChatGPT
  581. // header 注入 / WS OAuth 决策),改成 apikey 会让 spark 影子被选中后按错误协议转发(外审 G7)。
  582. if input.Type != "" && input.Type != account.Type {
  583. return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_IMMUTABLE_TYPE",
  584. "spark shadow account type cannot be changed; it must remain an OpenAI OAuth shadow")
  585. }
  586. } else if input.Type != "" && input.Type != account.Type && input.Type != AccountTypeOAuth {
  587. // 母账号守卫(外审 D/P1):有 spark 影子的账号不能把 type 改出 OpenAI OAuth——影子读透母
  588. // 凭据,母变成 apikey/setup_token 会让影子被调度后按错协议失败(resolveCredentialAccount
  589. // 必报错)。须先删影子再改 type。
  590. shadows, serr := s.accountRepo.ListShadowsByParent(ctx, id)
  591. if serr != nil {
  592. return nil, serr
  593. }
  594. if len(shadows) > 0 {
  595. return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IMMUTABLE_TYPE",
  596. "cannot change account type while it has a spark shadow; delete the shadow first")
  597. }
  598. }
  599. wasOveragesEnabled := account.IsOveragesEnabled()
  600. if input.Name != "" {
  601. account.Name = input.Name
  602. }
  603. if input.Type != "" {
  604. account.Type = input.Type
  605. }
  606. if input.Notes != nil {
  607. account.Notes = normalizeAccountNotes(input.Notes)
  608. }
  609. if account.IsCredentialShadow() && input.Credentials != nil {
  610. account.Credentials = sanitizeSparkShadowCredentials(input.Credentials)
  611. } else if len(input.Credentials) > 0 {
  612. // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏,
  613. // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。
  614. account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials)
  615. // 校验并规范化请求头覆写配置(header 名小写化、格式检查)
  616. if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil {
  617. return nil, err
  618. }
  619. // Strip SSO/password residue that must never sit next to OAuth tokens.
  620. account.Credentials = SanitizeStoredCredentials(account.Platform, account.Credentials)
  621. }
  622. // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
  623. // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
  624. requestedProbeEnabledUpdate := input.ProbeEnabled
  625. requestedRateSyncEnabledUpdate := input.RateSyncEnabled
  626. if input.Extra != nil {
  627. requestedProbeEnabled, hasRequestedProbeEnabled := normalizedExtra[UpstreamBillingProbeEnabledExtraKey]
  628. if hasRequestedProbeEnabled {
  629. enabled, ok := requestedProbeEnabled.(bool)
  630. if !ok {
  631. return nil, infraerrors.BadRequest("INVALID_UPSTREAM_BILLING_PROBE_ENABLED", "upstream_billing_probe_enabled must be a boolean")
  632. }
  633. if requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate != enabled {
  634. return nil, infraerrors.BadRequest("CONFLICTING_UPSTREAM_BILLING_PROBE_ENABLED", "conflicting upstream_billing_probe_enabled values")
  635. }
  636. requestedProbeEnabledUpdate = &enabled
  637. }
  638. delete(normalizedExtra, UpstreamBillingProbeEnabledExtraKey)
  639. delete(normalizedExtra, UpstreamBillingRateSyncEnabledExtraKey)
  640. delete(normalizedExtra, UpstreamBillingProbeExtraKey)
  641. delete(normalizedExtra, OllamaCloudUsageSessionExtraKey)
  642. delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey)
  643. delete(normalizedExtra, OllamaCloudUsageSnapshotExtraKey)
  644. // 保留配额用量和专用服务受管字段,防止普通账号编辑意外覆盖。
  645. for _, key := range []string{
  646. "quota_used",
  647. "quota_daily_used",
  648. "quota_daily_start",
  649. "quota_weekly_used",
  650. "quota_weekly_start",
  651. grokBillingExtraKey,
  652. UpstreamBillingProbeEnabledExtraKey,
  653. UpstreamBillingRateSyncEnabledExtraKey,
  654. UpstreamBillingProbeExtraKey,
  655. OllamaCloudUsageSessionExtraKey,
  656. OllamaCloudUsageAutoRefreshExtraKey,
  657. OllamaCloudUsageSnapshotExtraKey,
  658. OpenAIAutoResetCreditStateExtraKey,
  659. } {
  660. if v, ok := account.Extra[key]; ok {
  661. normalizedExtra[key] = v
  662. }
  663. }
  664. normalizedExtra = preserveMode1ManagedExtra(ctx, account, normalizedExtra)
  665. normalizedExtra = prepareCodexFingerprintExtraForUpdate(account, normalizedExtra)
  666. normalizedExtra = NormalizeProxyModeExtra(normalizedExtra)
  667. account.Extra = normalizedExtra
  668. if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() {
  669. delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
  670. // 清除 AICredits 限流 key
  671. if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok {
  672. delete(rawLimits, creditsExhaustedKey)
  673. }
  674. }
  675. if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() {
  676. delete(account.Extra, modelRateLimitsKey)
  677. delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
  678. }
  679. // 校验并预计算固定时间重置的下次重置时间
  680. if err := ValidateQuotaResetConfig(account.Extra); err != nil {
  681. return nil, err
  682. }
  683. ComputeQuotaResetAt(account.Extra)
  684. NormalizeFixedQuotaWindows(account.Extra)
  685. }
  686. if input.Extra == nil {
  687. account.Extra = prepareCodexFingerprintExtraForUpdate(account, account.Extra)
  688. }
  689. if requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate {
  690. if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
  691. return nil, infraerrors.BadRequest(
  692. "UPSTREAM_BILLING_RATE_SYNC_REQUIRES_PROBE",
  693. "upstream billing rate sync requires upstream billing probe",
  694. )
  695. }
  696. enabled := true
  697. requestedProbeEnabledUpdate = &enabled
  698. }
  699. if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
  700. disabled := false
  701. requestedRateSyncEnabledUpdate = &disabled
  702. }
  703. if (requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate) ||
  704. (requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate) {
  705. if !isUpstreamBillingProbeAccount(account) {
  706. return nil, ErrUpstreamBillingProbeAccountInvalid
  707. }
  708. }
  709. if account.Extra == nil && (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) {
  710. account.Extra = make(map[string]any)
  711. }
  712. if requestedProbeEnabledUpdate != nil {
  713. account.Extra[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate
  714. }
  715. if requestedRateSyncEnabledUpdate != nil {
  716. account.Extra[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate
  717. }
  718. // 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1;
  719. // 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。
  720. if input.ProxyID != nil && !account.IsCredentialShadow() {
  721. randomProxyRequested := input.Extra != nil && account.IsRandomProxy()
  722. // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图)
  723. if *input.ProxyID == 0 {
  724. account.ProxyID = nil
  725. if !randomProxyRequested && account.Extra != nil {
  726. delete(account.Extra, ProxyModeExtraKey)
  727. }
  728. } else {
  729. account.ProxyID = input.ProxyID
  730. if account.Extra != nil {
  731. delete(account.Extra, ProxyModeExtraKey)
  732. }
  733. }
  734. account.Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID
  735. }
  736. if !reflect.DeepEqual(previousProbeIdentity, upstreamBillingProbeIdentity(account)) && account.Extra != nil {
  737. delete(account.Extra, UpstreamBillingProbeExtraKey)
  738. if !isUpstreamBillingProbeAccount(account) {
  739. delete(account.Extra, UpstreamBillingProbeEnabledExtraKey)
  740. delete(account.Extra, UpstreamBillingRateSyncEnabledExtraKey)
  741. }
  742. }
  743. if account.Extra != nil {
  744. if !IsOllamaCloudUsageAccount(account) {
  745. delete(account.Extra, OllamaCloudUsageSessionExtraKey)
  746. delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
  747. delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
  748. } else if !reflect.DeepEqual(previousOllamaUsageIdentity, ollamaCloudUsageIdentity(account)) {
  749. delete(account.Extra, OllamaCloudUsageSessionExtraKey)
  750. delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
  751. delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
  752. }
  753. }
  754. // 只在指针非 nil 时更新 Concurrency(支持设置为 0)
  755. if input.Concurrency != nil {
  756. account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency)
  757. }
  758. // 只在指针非 nil 时更新 Priority(支持设置为 0)
  759. if input.Priority != nil {
  760. account.Priority = *input.Priority
  761. }
  762. if input.RateMultiplier != nil {
  763. if *input.RateMultiplier < 0 {
  764. return nil, errors.New("rate_multiplier must be >= 0")
  765. }
  766. // 同步开启时倍率归上游所有,手工值活不过下一次成功探测(表现为"改了又自己
  767. // 变回去"),与批量路径一样直接拒绝。判断的是本次请求生效后的状态:上面
  768. // 已把请求携带的两个开关落进 account.Extra,所以"同一请求关闭同步 + 改倍率"
  769. // (用户显式收回所有权)会走到这里时读到 false,正常放行。
  770. if upstreamBillingRateSyncEnabled(account) {
  771. return nil, ErrUpstreamBillingRateSyncConflict
  772. }
  773. account.RateMultiplier = input.RateMultiplier
  774. }
  775. if input.LoadFactor != nil {
  776. if *input.LoadFactor <= 0 {
  777. account.LoadFactor = nil // 0 或负数表示清除
  778. } else if *input.LoadFactor > 10000 {
  779. return nil, errors.New("load_factor must be <= 10000")
  780. } else {
  781. account.LoadFactor = input.LoadFactor
  782. }
  783. }
  784. if input.Status != "" {
  785. account.Status = input.Status
  786. }
  787. if input.ExpiresAt != nil {
  788. if *input.ExpiresAt <= 0 {
  789. account.ExpiresAt = nil
  790. } else {
  791. expiresAt := time.Unix(*input.ExpiresAt, 0)
  792. account.ExpiresAt = &expiresAt
  793. }
  794. }
  795. if input.AutoPauseOnExpired != nil {
  796. account.AutoPauseOnExpired = *input.AutoPauseOnExpired
  797. }
  798. // 先验证分组是否存在(在任何写操作之前)
  799. if input.GroupIDs != nil {
  800. if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
  801. return nil, err
  802. }
  803. if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
  804. return nil, err
  805. }
  806. // 检查混合渠道风险(除非用户已确认)
  807. if !input.SkipMixedChannelCheck {
  808. if err := s.checkMixedChannelRisk(ctx, account.ID, account.Platform, *input.GroupIDs); err != nil {
  809. return nil, err
  810. }
  811. }
  812. }
  813. BoundAccountProtectionConcurrency(account)
  814. if err := ValidateAccountProtectionConfiguration(account); err != nil {
  815. return nil, err
  816. }
  817. billingSettingsAppliedAtomically := false
  818. updater := s.accountBillingRepo
  819. if updater == nil {
  820. // Unit tests and narrow internal callers may construct adminServiceImpl
  821. // directly; production wiring requires this capability through
  822. // AdminAccountRepository.
  823. updater, _ = s.accountRepo.(AccountBillingSettingsRepository)
  824. }
  825. if updater != nil {
  826. if err := updater.UpdateWithAccountBillingSettings(
  827. ctx,
  828. account,
  829. requestedProbeEnabledUpdate,
  830. requestedRateSyncEnabledUpdate,
  831. input.RateMultiplier,
  832. ); err != nil {
  833. return nil, err
  834. }
  835. billingSettingsAppliedAtomically = true
  836. }
  837. if !billingSettingsAppliedAtomically {
  838. if err := s.accountRepo.Update(ctx, account); err != nil {
  839. return nil, err
  840. }
  841. if (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) &&
  842. isUpstreamBillingProbeAccount(account) {
  843. settings := make(map[string]any, 2)
  844. if requestedProbeEnabledUpdate != nil {
  845. settings[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate
  846. }
  847. if requestedRateSyncEnabledUpdate != nil {
  848. settings[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate
  849. }
  850. if err := s.accountRepo.UpdateExtra(ctx, account.ID, settings); err != nil {
  851. return nil, err
  852. }
  853. }
  854. }
  855. // 将 proxy 变更传播到 spark 影子账号(同步;Update 内部已触发调度快照)。
  856. // 影子自身 proxy 不可独立编辑(见上),故对影子的更新不触发传播。
  857. if input.ProxyID != nil && !account.IsCredentialShadow() {
  858. if err := s.propagateProxyToShadows(ctx, id, account.ProxyID); err != nil {
  859. return nil, err
  860. }
  861. }
  862. // 绑定分组
  863. if input.GroupIDs != nil {
  864. if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil {
  865. return nil, err
  866. }
  867. }
  868. // 重新查询以确保返回完整数据(包括正确的 Proxy 关联对象)
  869. updated, err := s.accountRepo.GetByID(ctx, id)
  870. if err != nil {
  871. return nil, err
  872. }
  873. return updated, nil
  874. }
  875. // UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键
  876. // (如 model_rate_limits / passive_usage_* 等)。
  877. func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error {
  878. if _, err := ParseAccountTrafficPolicy(updates); err != nil {
  879. return err
  880. }
  881. if err := validateRequestIntegrityExtra(updates); err != nil {
  882. return err
  883. }
  884. if hasMode1ManagedUpdates(updates) {
  885. account, err := s.accountRepo.GetByID(ctx, id)
  886. if err != nil {
  887. return err
  888. }
  889. if isMode1ProtectionRequested(account) {
  890. return infraerrors.BadRequest("MODE1_MANAGED_FIELDS", "模式一策略字段请通过专用应用/还原接口修改")
  891. }
  892. }
  893. updates = sanitizedCodexFingerprintExtraUpdates(updates)
  894. updates = stripOpenAIAutoResetCreditManagedExtra(updates, true)
  895. delete(updates, UpstreamBillingProbeEnabledExtraKey)
  896. delete(updates, UpstreamBillingRateSyncEnabledExtraKey)
  897. delete(updates, UpstreamBillingProbeExtraKey)
  898. delete(updates, OllamaCloudUsageSessionExtraKey)
  899. delete(updates, OllamaCloudUsageAutoRefreshExtraKey)
  900. delete(updates, OllamaCloudUsageSnapshotExtraKey)
  901. updates = NormalizeProxyModeExtra(updates)
  902. if err := ValidateUpstreamRequestIDHeaderExtra(updates); err != nil {
  903. return err
  904. }
  905. if _, exists := updates[openAILongContextBillingEnabledKey]; exists {
  906. account, err := s.accountRepo.GetByID(ctx, id)
  907. if err != nil {
  908. return err
  909. }
  910. if err := ValidateOpenAILongContextBillingExtra(account.Platform, updates); err != nil {
  911. return err
  912. }
  913. }
  914. if len(updates) == 0 {
  915. return nil
  916. }
  917. return s.accountRepo.UpdateExtra(ctx, id, updates)
  918. }
  919. // BulkUpdateAccounts updates multiple accounts in one request.
  920. // It merges credentials/extra keys instead of overwriting the whole object.
  921. func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
  922. // Managed probe/session state may only enter through dedicated typed endpoints.
  923. input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra)
  924. input.Extra = stripOpenAIAutoResetCreditManagedExtra(input.Extra, true)
  925. delete(input.Extra, UpstreamBillingProbeEnabledExtraKey)
  926. delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey)
  927. delete(input.Extra, UpstreamBillingProbeExtraKey)
  928. delete(input.Extra, OllamaCloudUsageSessionExtraKey)
  929. delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey)
  930. delete(input.Extra, OllamaCloudUsageSnapshotExtraKey)
  931. input.Extra = NormalizeProxyModeExtra(input.Extra)
  932. if err := ValidateUpstreamRequestIDHeaderExtra(input.Extra); err != nil {
  933. return nil, err
  934. }
  935. // Random mode and a fixed proxy are mutually exclusive. Normalize the bulk
  936. // request early so repository writes and shadow propagation clear proxy_id.
  937. if mode, ok := input.Extra[ProxyModeExtraKey].(string); ok && strings.EqualFold(strings.TrimSpace(mode), ProxyModeRandom) {
  938. proxyID := int64(0)
  939. input.ProxyID = &proxyID
  940. }
  941. if len(input.AccountIDs) == 0 && input.Filters != nil {
  942. accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
  943. if err != nil {
  944. return nil, err
  945. }
  946. input.AccountIDs = accountIDs
  947. }
  948. result := &BulkUpdateAccountsResult{
  949. SuccessIDs: make([]int64, 0, len(input.AccountIDs)),
  950. FailedIDs: make([]int64, 0, len(input.AccountIDs)),
  951. Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)),
  952. }
  953. if len(input.AccountIDs) == 0 {
  954. return result, nil
  955. }
  956. if input.GroupIDs != nil {
  957. if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
  958. return nil, err
  959. }
  960. if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
  961. return nil, err
  962. }
  963. }
  964. openAISettings, err := normalizeBulkOpenAISettings(input)
  965. if err != nil {
  966. return nil, err
  967. }
  968. needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
  969. if _, err := ParseAccountTrafficPolicy(input.Extra); err != nil {
  970. return nil, err
  971. }
  972. if err := validateRequestIntegrityExtra(input.Extra); err != nil {
  973. return nil, err
  974. }
  975. // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。
  976. var cachedTargets []*Account
  977. if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || openAISettings.any() || input.ProbeEnabled != nil || input.RateMultiplier != nil || hasMode1ManagedUpdates(input.Extra) {
  978. loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
  979. if err != nil {
  980. return nil, err
  981. }
  982. cachedTargets = loaded
  983. }
  984. if hasMode1ManagedUpdates(input.Extra) {
  985. for _, a := range cachedTargets {
  986. if ProtectedProxyModeConflict(a, input.Extra) {
  987. return nil, ErrProtectedProxyModeChange
  988. }
  989. if isMode1ProtectionRequested(a) {
  990. return nil, infraerrors.BadRequest("MODE1_MANAGED_FIELDS", "请先还原模式一,再批量修改其策略字段")
  991. }
  992. }
  993. }
  994. targetsByID := make(map[int64]*Account, len(cachedTargets))
  995. for _, account := range cachedTargets {
  996. if account != nil {
  997. targetsByID[account.ID] = account
  998. }
  999. }
  1000. if openAISettings.any() {
  1001. inheritedCount, err := validateBulkOpenAISettingsTargets(input, openAISettings, targetsByID)
  1002. if err != nil {
  1003. return nil, err
  1004. }
  1005. result.LongContextInheritedCount = inheritedCount
  1006. }
  1007. if input.ProbeEnabled != nil {
  1008. for _, accountID := range input.AccountIDs {
  1009. account, ok := targetsByID[accountID]
  1010. if !ok {
  1011. return nil, ErrAccountNotFound
  1012. }
  1013. if !isUpstreamBillingProbeAccount(account) {
  1014. return nil, ErrUpstreamBillingProbeAccountInvalid
  1015. }
  1016. }
  1017. }
  1018. // 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号
  1019. // UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。
  1020. if len(input.Credentials) > 0 {
  1021. for _, acc := range cachedTargets {
  1022. if acc != nil && acc.IsCredentialShadow() {
  1023. return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS",
  1024. "spark shadow account %d cannot hold credentials; manage credentials on the parent account", acc.ID)
  1025. }
  1026. }
  1027. }
  1028. // 影子账号 proxy 恒继承母账号(与单账号 UpdateAccount 守卫对齐——外审第4轮 P1):批量携带 proxy
  1029. // 时目标不得含影子,否则影子会获得独立 proxy、破坏继承不变量(网关按所选影子自身 proxy 出站,
  1030. // 要等母账号下次改 proxy 才覆盖→漂移)。含影子即整体拒绝,提示从选择中剔除影子。
  1031. if input.ProxyID != nil {
  1032. for _, acc := range cachedTargets {
  1033. if acc != nil && acc.IsCredentialShadow() {
  1034. return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_PROXY_INHERITED",
  1035. "spark shadow account %d proxy is inherited from its parent and cannot be set in bulk; manage it on the parent account", acc.ID)
  1036. }
  1037. }
  1038. }
  1039. // 预加载账号平台信息(混合渠道检查需要)。
  1040. platformByID := map[int64]string{}
  1041. if needMixedChannelCheck {
  1042. for _, account := range cachedTargets {
  1043. if account != nil {
  1044. platformByID[account.ID] = account.Platform
  1045. }
  1046. }
  1047. }
  1048. // 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。
  1049. if needMixedChannelCheck {
  1050. for _, accountID := range input.AccountIDs {
  1051. platform := platformByID[accountID]
  1052. if platform == "" {
  1053. continue
  1054. }
  1055. if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil {
  1056. return nil, err
  1057. }
  1058. }
  1059. }
  1060. if input.RateMultiplier != nil {
  1061. if *input.RateMultiplier < 0 {
  1062. return nil, errors.New("rate_multiplier must be >= 0")
  1063. }
  1064. syncEnabledCount := 0
  1065. for _, account := range cachedTargets {
  1066. if account == nil || account.Extra == nil {
  1067. continue
  1068. }
  1069. enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool)
  1070. if enabled {
  1071. syncEnabledCount++
  1072. }
  1073. }
  1074. if syncEnabledCount > 0 {
  1075. return nil, ErrUpstreamBillingRateSyncBulkConflict.WithMetadata(map[string]string{
  1076. "count": strconv.Itoa(syncEnabledCount),
  1077. })
  1078. }
  1079. }
  1080. // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可)
  1081. if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
  1082. return nil, err
  1083. }
  1084. // Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie
  1085. // only when platform is known Grok — empty platform still strips password/*).
  1086. if input.Credentials != nil {
  1087. input.Credentials = SanitizeStoredCredentials("", input.Credentials)
  1088. }
  1089. // Prepare bulk updates for columns and JSONB fields.
  1090. repoUpdates := AccountBulkUpdate{
  1091. Credentials: input.Credentials,
  1092. Extra: input.Extra,
  1093. ProbeEnabled: input.ProbeEnabled,
  1094. EnsureCodexFingerprintSeed: ShouldEnsureCodexFingerprintSeedForExtraUpdates(input.Extra),
  1095. }
  1096. if input.ProbeEnabled != nil {
  1097. if repoUpdates.Extra == nil {
  1098. repoUpdates.Extra = make(map[string]any)
  1099. }
  1100. repoUpdates.Extra[UpstreamBillingProbeEnabledExtraKey] = *input.ProbeEnabled
  1101. if !*input.ProbeEnabled {
  1102. repoUpdates.Extra[UpstreamBillingRateSyncEnabledExtraKey] = false
  1103. }
  1104. }
  1105. if updatesUpstreamBillingProbeIdentity(input.Credentials) || input.ProxyID != nil {
  1106. if repoUpdates.Extra == nil {
  1107. repoUpdates.Extra = make(map[string]any)
  1108. }
  1109. // JSON null makes every reader treat the old snapshot as absent and lets the
  1110. // next enabled runner cycle probe the new upstream identity immediately.
  1111. repoUpdates.Extra[UpstreamBillingProbeExtraKey] = nil
  1112. }
  1113. if input.Name != "" {
  1114. repoUpdates.Name = &input.Name
  1115. }
  1116. if input.ProxyID != nil {
  1117. repoUpdates.ProxyID = input.ProxyID
  1118. }
  1119. if input.Concurrency != nil {
  1120. repoUpdates.Concurrency = input.Concurrency
  1121. }
  1122. if input.Priority != nil {
  1123. repoUpdates.Priority = input.Priority
  1124. }
  1125. if input.RateMultiplier != nil {
  1126. repoUpdates.RateMultiplier = input.RateMultiplier
  1127. }
  1128. if input.LoadFactor != nil {
  1129. if *input.LoadFactor <= 0 {
  1130. repoUpdates.LoadFactor = nil // 0 或负数表示清除
  1131. } else if *input.LoadFactor > 10000 {
  1132. return nil, errors.New("load_factor must be <= 10000")
  1133. } else {
  1134. repoUpdates.LoadFactor = input.LoadFactor
  1135. }
  1136. }
  1137. if input.Status != "" {
  1138. repoUpdates.Status = &input.Status
  1139. }
  1140. if input.Schedulable != nil {
  1141. repoUpdates.Schedulable = input.Schedulable
  1142. }
  1143. // Run bulk update for column/jsonb fields first.
  1144. if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil {
  1145. return nil, err
  1146. }
  1147. // 将 proxy 变更传播到每个目标账号的 spark 影子账号
  1148. if repoUpdates.ProxyID != nil {
  1149. var effectiveProxyID *int64
  1150. if *repoUpdates.ProxyID != 0 {
  1151. effectiveProxyID = repoUpdates.ProxyID
  1152. }
  1153. for _, accountID := range input.AccountIDs {
  1154. if err := s.propagateProxyToShadows(ctx, accountID, effectiveProxyID); err != nil {
  1155. return nil, err
  1156. }
  1157. }
  1158. }
  1159. // Handle group bindings per account (requires individual operations).
  1160. for _, accountID := range input.AccountIDs {
  1161. entry := BulkUpdateAccountResult{AccountID: accountID}
  1162. if input.GroupIDs != nil {
  1163. if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil {
  1164. entry.Success = false
  1165. entry.Error = err.Error()
  1166. result.Failed++
  1167. result.FailedIDs = append(result.FailedIDs, accountID)
  1168. result.Results = append(result.Results, entry)
  1169. continue
  1170. }
  1171. }
  1172. entry.Success = true
  1173. result.Success++
  1174. result.SuccessIDs = append(result.SuccessIDs, accountID)
  1175. result.Results = append(result.Results, entry)
  1176. }
  1177. return result, nil
  1178. }
  1179. func updatesUpstreamBillingProbeIdentity(credentials map[string]any) bool {
  1180. for _, key := range []string{"api_key", "base_url", credKeyHeaderOverrideEnabled, credKeyHeaderOverrides} {
  1181. if _, ok := credentials[key]; ok {
  1182. return true
  1183. }
  1184. }
  1185. return false
  1186. }
  1187. func upstreamBillingProbeIdentity(account *Account) map[string]any {
  1188. if account == nil {
  1189. return nil
  1190. }
  1191. identity := map[string]any{"platform": account.Platform, "type": account.Type, "proxy_id": nil}
  1192. if account.ProxyID != nil {
  1193. identity["proxy_id"] = *account.ProxyID
  1194. }
  1195. for _, key := range []string{"api_key", "base_url", credKeyHeaderOverrideEnabled, credKeyHeaderOverrides} {
  1196. if value, ok := account.Credentials[key]; ok {
  1197. identity[key] = value
  1198. }
  1199. }
  1200. return identity
  1201. }
  1202. func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) {
  1203. if filters == nil {
  1204. return nil, nil
  1205. }
  1206. groupID := int64(0)
  1207. switch strings.TrimSpace(filters.Group) {
  1208. case "":
  1209. case "ungrouped":
  1210. groupID = AccountListGroupUngrouped
  1211. default:
  1212. parsedGroupID, err := strconv.ParseInt(strings.TrimSpace(filters.Group), 10, 64)
  1213. if err != nil {
  1214. return nil, fmt.Errorf("invalid group filter: %w", err)
  1215. }
  1216. groupID = parsedGroupID
  1217. }
  1218. const pageSize = 500
  1219. page := 1
  1220. accountIDs := make([]int64, 0, pageSize)
  1221. for {
  1222. accounts, total, err := s.ListAccounts(
  1223. ctx,
  1224. page,
  1225. pageSize,
  1226. filters.Platform,
  1227. filters.Type,
  1228. filters.Status,
  1229. filters.Search,
  1230. groupID,
  1231. filters.PrivacyMode,
  1232. "",
  1233. "",
  1234. )
  1235. if err != nil {
  1236. return nil, err
  1237. }
  1238. for _, account := range accounts {
  1239. accountIDs = append(accountIDs, account.ID)
  1240. }
  1241. if int64(len(accountIDs)) >= total || len(accounts) == 0 {
  1242. return accountIDs, nil
  1243. }
  1244. page++
  1245. }
  1246. }
  1247. func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error {
  1248. // 级联删除 spark 影子账号(先删影子,再删母账号)
  1249. shadows, err := s.accountRepo.ListShadowsByParent(ctx, id)
  1250. if err != nil {
  1251. return fmt.Errorf("list spark shadows for cascade delete: %w", err)
  1252. }
  1253. for _, shadow := range shadows {
  1254. if err := s.accountRepo.Delete(ctx, shadow.ID); err != nil {
  1255. return fmt.Errorf("cascade delete spark shadow %d: %w", shadow.ID, err)
  1256. }
  1257. }
  1258. if err := s.accountRepo.Delete(ctx, id); err != nil {
  1259. return err
  1260. }
  1261. return nil
  1262. }
  1263. func (s *adminServiceImpl) RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error) {
  1264. account, err := s.accountRepo.GetByID(ctx, id)
  1265. if err != nil {
  1266. return nil, err
  1267. }
  1268. // TODO: Implement refresh logic
  1269. return account, nil
  1270. }
  1271. func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Account, error) {
  1272. if err := s.accountRepo.ClearError(ctx, id); err != nil {
  1273. return nil, err
  1274. }
  1275. if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil {
  1276. return nil, err
  1277. }
  1278. if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil {
  1279. return nil, err
  1280. }
  1281. if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil {
  1282. return nil, err
  1283. }
  1284. if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil {
  1285. return nil, err
  1286. }
  1287. if s.runtimeBlocker != nil {
  1288. s.runtimeBlocker.ClearAccountSchedulingBlock(id)
  1289. }
  1290. return s.accountRepo.GetByID(ctx, id)
  1291. }
  1292. func (s *adminServiceImpl) SetAccountError(ctx context.Context, id int64, errorMsg string) error {
  1293. return s.accountRepo.SetError(ctx, id, errorMsg)
  1294. }
  1295. // BackfillAccountCredentialsEncryption 加密一批历史明文账号凭据(幂等)。
  1296. // 仓储层无该能力(如测试替身)时返回 0,不报错。
  1297. func (s *adminServiceImpl) BackfillAccountCredentialsEncryption(ctx context.Context, limit int) (int64, error) {
  1298. repo, ok := s.accountRepo.(interface {
  1299. BackfillCredentialsEncryption(context.Context, int) (int64, error)
  1300. })
  1301. if !ok || repo == nil {
  1302. return 0, nil
  1303. }
  1304. return repo.BackfillCredentialsEncryption(ctx, limit)
  1305. }
  1306. func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error) {
  1307. if err := s.accountRepo.SetSchedulable(ctx, id, schedulable); err != nil {
  1308. return nil, err
  1309. }
  1310. updated, err := s.accountRepo.GetByID(ctx, id)
  1311. if err != nil {
  1312. return nil, err
  1313. }
  1314. return updated, nil
  1315. }
  1316. func (s *adminServiceImpl) RevertAccountProxyFallback(ctx context.Context, id int64) error {
  1317. if err := s.accountRepo.RevertProxyFallback(ctx, id); err != nil {
  1318. return err
  1319. }
  1320. // 加载回退后的账号以获取实际 ProxyID,再传播到影子账号
  1321. account, err := s.accountRepo.GetByID(ctx, id)
  1322. if err != nil {
  1323. return fmt.Errorf("get account after proxy revert: %w", err)
  1324. }
  1325. return s.propagateProxyToShadows(ctx, id, account.ProxyID)
  1326. }
  1327. // CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。
  1328. // 安全不变量:Credentials 恒不含 auth token(仅 model_mapping,守卫 isAllowedSparkShadowCredentialsUpdate 放行)。
  1329. func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error) {
  1330. // 1. 加载母账号并校验平台/类型
  1331. parent, err := s.accountRepo.GetByID(ctx, parentID)
  1332. if err != nil {
  1333. return nil, fmt.Errorf("get parent account: %w", err)
  1334. }
  1335. if !parent.IsOpenAIOAuth() {
  1336. return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_INVALID_PARENT",
  1337. "spark shadow requires an OpenAI OAuth parent account")
  1338. }
  1339. // G6:母账号本身不能是影子,否则会建出二级影子——resolveCredentialAccount 只解一层,
  1340. // 会解析到无凭据的一级影子,进入坏调度/上游失败。
  1341. if parent.IsCredentialShadow() {
  1342. return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IS_SHADOW",
  1343. "spark shadow parent must be a real account, not another spark shadow")
  1344. }
  1345. // 2. 一母一影校验
  1346. shadows, err := s.accountRepo.ListShadowsByParent(ctx, parentID)
  1347. if err != nil {
  1348. return nil, fmt.Errorf("check existing spark shadows: %w", err)
  1349. }
  1350. if len(shadows) > 0 {
  1351. return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS",
  1352. "parent account already has a spark shadow account")
  1353. }
  1354. // 3. 解析分组。未指定 GroupIDs 时:优先**继承母账号当前分组**(影子与母同路由域,母在自定义
  1355. // 组时该组的 spark 请求也能选到影子;G1 决策);母无分组再回落 openai-default(F4)。
  1356. // 显式指定 GroupIDs 时,与 UpdateAccount 对齐先校验存在性(创建前),避免建出影子后再因无效组
  1357. // 失败而留下孤儿影子(一母一影唯一索引会挡住重试)——外审 C/P1。
  1358. groupIDs := opts.GroupIDs
  1359. if len(groupIDs) > 0 {
  1360. if s.groupRepo != nil {
  1361. if err := s.validateGroupIDsExist(ctx, groupIDs); err != nil {
  1362. return nil, err
  1363. }
  1364. }
  1365. } else if len(parent.GroupIDs) > 0 {
  1366. groupIDs = append([]int64(nil), parent.GroupIDs...)
  1367. } else if s.groupRepo != nil {
  1368. defaultGroupName := PlatformOpenAI + "-default"
  1369. if groups, gerr := s.groupRepo.ListActiveByPlatform(ctx, PlatformOpenAI); gerr == nil {
  1370. for _, g := range groups {
  1371. if g.Name == defaultGroupName {
  1372. groupIDs = []int64{g.ID}
  1373. break
  1374. }
  1375. }
  1376. }
  1377. }
  1378. if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil {
  1379. return nil, err
  1380. }
  1381. // 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。
  1382. // name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500
  1383. // (外审 E/P2);并 rune 安全截断到 ent MaxLen(100)。
  1384. name := strings.TrimSpace(opts.Name)
  1385. if name == "" {
  1386. name = parent.Name + " (Spark)"
  1387. }
  1388. if runes := []rune(name); len(runes) > 100 {
  1389. name = string(runes[:100])
  1390. }
  1391. // 并发未指定(<=0)时继承母账号,避免 0 被限流器解读为"无限并发"(外审 F3)。
  1392. concurrency := opts.Concurrency
  1393. if concurrency <= 0 {
  1394. concurrency = parent.Concurrency
  1395. }
  1396. // 优先级未指定(<=0)时继承母账号——前端一键创建只传 name,opts.Priority 省略即 0,而调度
  1397. // 比较是「数值越小越优先」(openai_account_scheduler.isOpenAIAccountCandidateBetter),且 repo
  1398. // 显式 SetPriority 会绕过 ent 默认 50,直写 0 会让影子意外抢到最高优先级(外审第5轮 P1)。
  1399. // 与上方 Concurrency 一致采用「省略继承母账号」语义(影子的 proxy/分组/并发亦全部继承母账号)。
  1400. priority := opts.Priority
  1401. if priority <= 0 {
  1402. priority = parent.Priority
  1403. }
  1404. shadow := &Account{
  1405. Name: name,
  1406. Platform: PlatformOpenAI,
  1407. Type: AccountTypeOAuth,
  1408. Status: StatusActive,
  1409. Credentials: map[string]any{"model_mapping": defaultSparkShadowModelMapping()},
  1410. ParentAccountID: &parentID,
  1411. QuotaDimension: QuotaDimensionSpark,
  1412. ProxyID: parent.ProxyID,
  1413. Priority: priority,
  1414. Concurrency: concurrency,
  1415. Schedulable: true,
  1416. Extra: map[string]any{
  1417. openAILongContextBillingEnabledKey: parent.IsOpenAILongContextBillingEnabled(),
  1418. },
  1419. }
  1420. // 5. 持久化(Create 填充 shadow.ID)。并发竞态:预查(步骤2)放行后另一请求抢先建成,本次会撞
  1421. // 一母一影唯一索引。复查确认确为"已存在"竞态时返回结构化 409 而非裸 500——外审 A/P1。
  1422. if err := s.accountRepo.Create(ctx, shadow); err != nil {
  1423. if existing, qerr := s.accountRepo.ListShadowsByParent(ctx, parentID); qerr == nil && len(existing) > 0 {
  1424. return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS",
  1425. "parent account already has a spark shadow account")
  1426. }
  1427. return nil, fmt.Errorf("create spark shadow: %w", err)
  1428. }
  1429. // 6. 绑定分组。注意:create+bind 非单一 DB 事务(通用 Create 走 r.client、outbox 走 r.sql,
  1430. // 无现成共享事务路径),故绑组失败时做 best-effort 补偿删除刚建的影子,避免半成品影子(否则
  1431. // 一母一影唯一索引会挡住重试)——外审 C/P1。补偿删除用 detached ctx,即便请求 ctx 已取消/超时
  1432. // 仍能完成清理(外审第4轮);进程崩溃这种极端仍可能残留,属已知权衡。
  1433. if len(groupIDs) > 0 {
  1434. if err := s.accountRepo.BindGroups(ctx, shadow.ID, groupIDs); err != nil {
  1435. if delErr := s.accountRepo.Delete(context.WithoutCancel(ctx), shadow.ID); delErr != nil {
  1436. slog.Error("spark_shadow_bind_groups_rollback_failed",
  1437. "shadow_id", shadow.ID, "parent_id", parentID, "delete_err", delErr)
  1438. }
  1439. return nil, fmt.Errorf("bind groups for spark shadow: %w", err)
  1440. }
  1441. shadow.GroupIDs = groupIDs
  1442. }
  1443. return shadow, nil
  1444. }
  1445. // propagateProxyToShadows syncs proxyID to all spark shadow accounts of parentID.
  1446. // It is called synchronously so that proxy changes are immediately consistent;
  1447. // accountRepo.Update triggers the scheduler outbox + cache propagation internally.
  1448. // Calling this for a non-parent account is a harmless no-op.
  1449. func (s *adminServiceImpl) propagateProxyToShadows(ctx context.Context, parentID int64, proxyID *int64) error {
  1450. return propagateAccountProxyToShadows(ctx, s.accountRepo, parentID, proxyID)
  1451. }
  1452. // propagateAccountProxyToShadows 把母账号的 proxy 同步到其所有 spark 影子(影子 proxy 恒继承母账号)。
  1453. // 供 AdminService 编辑路径与 CRS 同步路径共用——后者改动母账号 proxy 后必须同样传播,否则影子保留
  1454. // 旧 proxy 出现出站漂移(外审第8轮)。
  1455. func propagateAccountProxyToShadows(ctx context.Context, repo AccountRepository, parentID int64, proxyID *int64) error {
  1456. shadows, err := repo.ListShadowsByParent(ctx, parentID)
  1457. if err != nil {
  1458. return fmt.Errorf("list spark shadows for proxy propagation: %w", err)
  1459. }
  1460. for _, shadow := range shadows {
  1461. shadow.ProxyID = proxyID
  1462. if err := repo.Update(ctx, shadow); err != nil {
  1463. return fmt.Errorf("update spark shadow %d proxy: %w", shadow.ID, err)
  1464. }
  1465. }
  1466. return nil
  1467. }
  1468. // checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic)
  1469. // 如果存在混合,返回错误提示用户确认
  1470. func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
  1471. // 判断当前账号的渠道类型(基于 platform 字段,而不是 type 字段)
  1472. currentPlatform := getAccountPlatform(currentAccountPlatform)
  1473. if currentPlatform == "" {
  1474. // 不是 Antigravity 或 Anthropic,无需检查
  1475. return nil
  1476. }
  1477. // 检查每个分组中的其他账号
  1478. for _, groupID := range groupIDs {
  1479. accounts, err := s.accountRepo.ListByGroup(ctx, groupID)
  1480. if err != nil {
  1481. return fmt.Errorf("get accounts in group %d: %w", groupID, err)
  1482. }
  1483. // 检查是否存在不同渠道的账号
  1484. for _, account := range accounts {
  1485. if currentAccountID > 0 && account.ID == currentAccountID {
  1486. continue // 跳过当前账号
  1487. }
  1488. otherPlatform := getAccountPlatform(account.Platform)
  1489. if otherPlatform == "" {
  1490. continue // 不是 Antigravity 或 Anthropic,跳过
  1491. }
  1492. // 检测混合渠道
  1493. if currentPlatform != otherPlatform {
  1494. group, _ := s.groupRepo.GetByID(ctx, groupID)
  1495. groupName := fmt.Sprintf("Group %d", groupID)
  1496. if group != nil {
  1497. groupName = group.Name
  1498. }
  1499. return &MixedChannelError{
  1500. GroupID: groupID,
  1501. GroupName: groupName,
  1502. CurrentPlatform: currentPlatform,
  1503. OtherPlatform: otherPlatform,
  1504. }
  1505. }
  1506. }
  1507. }
  1508. return nil
  1509. }
  1510. func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error {
  1511. if len(groupIDs) == 0 {
  1512. return nil
  1513. }
  1514. if s.groupRepo == nil {
  1515. return errors.New("group repository not configured")
  1516. }
  1517. if batchReader, ok := s.groupRepo.(groupExistenceBatchReader); ok {
  1518. existsByID, err := batchReader.ExistsByIDs(ctx, groupIDs)
  1519. if err != nil {
  1520. return fmt.Errorf("check groups exists: %w", err)
  1521. }
  1522. for _, groupID := range groupIDs {
  1523. if groupID <= 0 || !existsByID[groupID] {
  1524. return fmt.Errorf("get group: %w", ErrGroupNotFound)
  1525. }
  1526. }
  1527. return nil
  1528. }
  1529. for _, groupID := range groupIDs {
  1530. if _, err := s.groupRepo.GetByID(ctx, groupID); err != nil {
  1531. return fmt.Errorf("get group: %w", err)
  1532. }
  1533. }
  1534. return nil
  1535. }
  1536. // ValidateAccountGroupBindings is the shared fail-closed policy boundary for
  1537. // every account path that accepts explicit group bindings.
  1538. func (s *adminServiceImpl) ValidateAccountGroupBindings(ctx context.Context, groupIDs []int64) error {
  1539. if len(groupIDs) == 0 || s.cfg == nil || s.cfg.RunMode != config.RunModeSimple {
  1540. return nil
  1541. }
  1542. if s.groupRepo == nil {
  1543. return errors.New("group repository not configured")
  1544. }
  1545. seen := make(map[int64]struct{}, len(groupIDs))
  1546. for _, groupID := range groupIDs {
  1547. if groupID <= 0 {
  1548. return fmt.Errorf("get group: %w", ErrGroupNotFound)
  1549. }
  1550. if _, ok := seen[groupID]; ok {
  1551. continue
  1552. }
  1553. seen[groupID] = struct{}{}
  1554. group, err := s.groupRepo.GetByIDLite(ctx, groupID)
  1555. if err != nil {
  1556. return fmt.Errorf("get group: %w", err)
  1557. }
  1558. if !IsGroupBindableInSimpleMode(group) {
  1559. return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
  1560. }
  1561. }
  1562. return nil
  1563. }
  1564. // CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform.
  1565. func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
  1566. return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs)
  1567. }
  1568. // getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识
  1569. func getAccountPlatform(accountPlatform string) string {
  1570. switch strings.ToLower(strings.TrimSpace(accountPlatform)) {
  1571. case PlatformAntigravity:
  1572. return "Antigravity"
  1573. case PlatformAnthropic, "claude":
  1574. return "Anthropic"
  1575. default:
  1576. return ""
  1577. }
  1578. }
  1579. // MixedChannelError 混合渠道错误
  1580. type MixedChannelError struct {
  1581. GroupID int64
  1582. GroupName string
  1583. CurrentPlatform string
  1584. OtherPlatform string
  1585. }
  1586. func (e *MixedChannelError) Error() string {
  1587. 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.",
  1588. e.GroupName, e.CurrentPlatform, e.OtherPlatform)
  1589. }
  1590. func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error {
  1591. account, err := s.accountRepo.GetByID(ctx, id)
  1592. if err != nil {
  1593. return err
  1594. }
  1595. // spark 影子账号不持自有配额(凭据透传母账号、spark 用量走独立 codex_* 维度由 QueryUsage 维护),
  1596. // 通用 quota 重置对其无意义且语义不一致——明确 400 拒绝(与 OpenAI reset-credit 对影子一致)(外审第7轮 P2)。
  1597. if account.IsCredentialShadow() {
  1598. return infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_NO_QUOTA_RESET",
  1599. "cannot reset quota for a spark shadow account; manage it on the parent account")
  1600. }
  1601. return s.accountRepo.ResetQuotaUsedAndClearRateLimitCooldown(ctx, id)
  1602. }
  1603. // EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode,
  1604. // 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。
  1605. func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string {
  1606. // 影子账号不持凭据,隐私设置由母账号管理,直接跳过。
  1607. if account.IsCredentialShadow() {
  1608. return ""
  1609. }
  1610. if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
  1611. return ""
  1612. }
  1613. if s.privacyClientFactory == nil {
  1614. return ""
  1615. }
  1616. if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
  1617. return ""
  1618. }
  1619. token, _ := account.Credentials["access_token"].(string)
  1620. if token == "" {
  1621. return ""
  1622. }
  1623. var proxyURL string
  1624. if account.ProxyID != nil {
  1625. if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
  1626. proxyURL = p.URL()
  1627. }
  1628. }
  1629. mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
  1630. if mode == "" {
  1631. return ""
  1632. }
  1633. _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
  1634. return mode
  1635. }
  1636. // ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
  1637. func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string {
  1638. // 影子账号不持凭据,隐私由母账号管理,直接跳过(与 EnsureOpenAIPrivacy 一致——外审第4轮)。
  1639. if account.IsCredentialShadow() {
  1640. return ""
  1641. }
  1642. if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
  1643. return ""
  1644. }
  1645. if s.privacyClientFactory == nil {
  1646. return ""
  1647. }
  1648. token, _ := account.Credentials["access_token"].(string)
  1649. if token == "" {
  1650. return ""
  1651. }
  1652. var proxyURL string
  1653. if account.ProxyID != nil {
  1654. if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
  1655. proxyURL = p.URL()
  1656. }
  1657. }
  1658. mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
  1659. if mode == "" {
  1660. return ""
  1661. }
  1662. if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
  1663. logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
  1664. return mode
  1665. }
  1666. if account.Extra == nil {
  1667. account.Extra = make(map[string]any)
  1668. }
  1669. account.Extra["privacy_mode"] = mode
  1670. return mode
  1671. }
  1672. // EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
  1673. // 仅当 privacy_mode 已成功设置("privacy_set")时跳过;
  1674. // 未设置或之前失败("privacy_set_failed")均会重试。
  1675. func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
  1676. if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
  1677. return ""
  1678. }
  1679. if account.Extra != nil {
  1680. if existing, ok := account.Extra["privacy_mode"].(string); ok && existing == AntigravityPrivacySet {
  1681. return existing
  1682. }
  1683. }
  1684. token, _ := account.Credentials["access_token"].(string)
  1685. if token == "" {
  1686. return ""
  1687. }
  1688. projectID, _ := account.Credentials["project_id"].(string)
  1689. var proxyURL string
  1690. if account.ProxyID != nil {
  1691. if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
  1692. proxyURL = p.URL()
  1693. }
  1694. }
  1695. mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
  1696. if mode == "" {
  1697. return ""
  1698. }
  1699. if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
  1700. logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
  1701. return mode
  1702. }
  1703. applyAntigravityPrivacyMode(account, mode)
  1704. return mode
  1705. }
  1706. // ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
  1707. func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string {
  1708. if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
  1709. return ""
  1710. }
  1711. token, _ := account.Credentials["access_token"].(string)
  1712. if token == "" {
  1713. return ""
  1714. }
  1715. projectID, _ := account.Credentials["project_id"].(string)
  1716. var proxyURL string
  1717. if account.ProxyID != nil {
  1718. if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
  1719. proxyURL = p.URL()
  1720. }
  1721. }
  1722. mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
  1723. if mode == "" {
  1724. return ""
  1725. }
  1726. if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
  1727. logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
  1728. return mode
  1729. }
  1730. applyAntigravityPrivacyMode(account, mode)
  1731. return mode
  1732. }