| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311 |
- // Package admin provides HTTP handlers for administrative operations.
- package admin
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "log"
- "log/slog"
- "net/http"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/domain"
- "github.com/Wei-Shaw/sub2api/internal/handler/dto"
- "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
- "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "golang.org/x/sync/errgroup"
- )
- // OAuthHandler handles OAuth-related operations for accounts
- type OAuthHandler struct {
- oauthService *service.OAuthService
- }
- // NewOAuthHandler creates a new OAuth handler
- func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
- return &OAuthHandler{
- oauthService: oauthService,
- }
- }
- // AccountHandler handles admin account management
- type AccountHandler struct {
- adminService service.AdminService
- oauthService *service.OAuthService
- openaiOAuthService *service.OpenAIOAuthService
- geminiOAuthService *service.GeminiOAuthService
- antigravityOAuthService *service.AntigravityOAuthService
- grokOAuthService service.GrokOAuthTokenService
- rateLimitService *service.RateLimitService
- accountUsageService *service.AccountUsageService
- accountTestService *service.AccountTestService
- concurrencyService *service.ConcurrencyService
- crsSyncService *service.CRSSyncService
- sessionLimitCache service.SessionLimitCache
- rpmCache service.RPMCache
- tokenCacheInvalidator service.TokenCacheInvalidator
- grokImportProber grokImportProber
- upstreamBillingProbe *service.UpstreamBillingProbeService
- ollamaCloudUsage *service.OllamaCloudUsageService
- accountTraffic *AccountTrafficHandler
- cfg *config.Config
- }
- // SetUpstreamBillingProbeService attaches the optional remote billing probe service.
- func (h *AccountHandler) SetUpstreamBillingProbeService(probe *service.UpstreamBillingProbeService) {
- h.upstreamBillingProbe = probe
- }
- func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUsageService) {
- h.ollamaCloudUsage = usage
- }
- func (h *AccountHandler) SetAccountTrafficHandler(traffic *AccountTrafficHandler) {
- h.accountTraffic = traffic
- }
- // NewAccountHandler creates a new admin account handler
- func NewAccountHandler(
- adminService service.AdminService,
- oauthService *service.OAuthService,
- openaiOAuthService *service.OpenAIOAuthService,
- geminiOAuthService *service.GeminiOAuthService,
- antigravityOAuthService *service.AntigravityOAuthService,
- grokOAuthService service.GrokOAuthTokenService,
- rateLimitService *service.RateLimitService,
- accountUsageService *service.AccountUsageService,
- accountTestService *service.AccountTestService,
- concurrencyService *service.ConcurrencyService,
- crsSyncService *service.CRSSyncService,
- sessionLimitCache service.SessionLimitCache,
- rpmCache service.RPMCache,
- tokenCacheInvalidator service.TokenCacheInvalidator,
- ) *AccountHandler {
- return &AccountHandler{
- adminService: adminService,
- oauthService: oauthService,
- openaiOAuthService: openaiOAuthService,
- geminiOAuthService: geminiOAuthService,
- antigravityOAuthService: antigravityOAuthService,
- grokOAuthService: grokOAuthService,
- rateLimitService: rateLimitService,
- accountUsageService: accountUsageService,
- accountTestService: accountTestService,
- concurrencyService: concurrencyService,
- crsSyncService: crsSyncService,
- sessionLimitCache: sessionLimitCache,
- rpmCache: rpmCache,
- tokenCacheInvalidator: tokenCacheInvalidator,
- }
- }
- // CreateAccountRequest represents create account request
- type CreateAccountRequest struct {
- Name string `json:"name" binding:"required"`
- Notes *string `json:"notes"`
- Platform string `json:"platform" binding:"required"`
- Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock service_account"`
- Credentials map[string]any `json:"credentials" binding:"required"`
- Extra map[string]any `json:"extra"`
- ProxyID *int64 `json:"proxy_id"`
- Concurrency int `json:"concurrency"`
- Priority int `json:"priority"`
- RateMultiplier *float64 `json:"rate_multiplier"`
- LoadFactor *int `json:"load_factor"`
- GroupIDs []int64 `json:"group_ids"`
- ExpiresAt *int64 `json:"expires_at"`
- AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
- ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
- ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
- }
- // UpdateAccountRequest represents update account request
- // 使用指针类型来区分"未提供"和"设置为0"
- type UpdateAccountRequest struct {
- Name string `json:"name"`
- Notes *string `json:"notes"`
- Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock service_account"`
- Credentials map[string]any `json:"credentials"`
- Extra map[string]any `json:"extra"`
- ProxyID *int64 `json:"proxy_id"`
- Concurrency *int `json:"concurrency"`
- Priority *int `json:"priority"`
- RateMultiplier *float64 `json:"rate_multiplier"`
- LoadFactor *int `json:"load_factor"`
- Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
- GroupIDs *[]int64 `json:"group_ids"`
- ExpiresAt *int64 `json:"expires_at"`
- AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
- ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
- RateSyncEnabled *bool `json:"upstream_billing_rate_sync_enabled"`
- ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
- }
- // BulkUpdateAccountsRequest represents the payload for bulk editing accounts
- type BulkUpdateAccountsRequest struct {
- AccountIDs []int64 `json:"account_ids"`
- Filters *BulkUpdateAccountFilters `json:"filters"`
- Name string `json:"name"`
- ProxyID *int64 `json:"proxy_id"`
- Concurrency *int `json:"concurrency"`
- Priority *int `json:"priority"`
- RateMultiplier *float64 `json:"rate_multiplier"`
- LoadFactor *int `json:"load_factor"`
- Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
- Schedulable *bool `json:"schedulable"`
- GroupIDs *[]int64 `json:"group_ids"`
- Credentials map[string]any `json:"credentials"`
- Extra map[string]any `json:"extra"`
- ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
- ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
- }
- type BulkUpdateAccountFilters struct {
- Platform string `json:"platform"`
- Type string `json:"type"`
- Status string `json:"status"`
- Group string `json:"group"`
- Search string `json:"search"`
- PrivacyMode string `json:"privacy_mode"`
- }
- // CheckMixedChannelRequest represents check mixed channel risk request
- type CheckMixedChannelRequest struct {
- Platform string `json:"platform" binding:"required"`
- GroupIDs []int64 `json:"group_ids"`
- AccountID *int64 `json:"account_id"`
- }
- // AccountWithConcurrency extends Account with real-time concurrency info
- type AccountWithConcurrency struct {
- *dto.Account
- simpleMode bool `json:"-"`
- CurrentConcurrency int `json:"current_concurrency"`
- SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
- SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
- // 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回
- CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` // 当前窗口费用
- ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
- CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
- }
- // AccountListItemWithConcurrency is the compact account-list envelope used
- // for lite=1. It embeds dto.AccountListItem instead of the full dto.Account,
- // so groups/account_groups never appear in the list payload.
- type AccountListItemWithConcurrency struct {
- *dto.AccountListItem
- CurrentConcurrency int `json:"current_concurrency"`
- SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
- SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
- CurrentWindowCost *float64 `json:"current_window_cost,omitempty"`
- ActiveSessions *int `json:"active_sessions,omitempty"`
- CurrentRPM *int `json:"current_rpm,omitempty"`
- }
- type simpleModeGroupReference struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- Platform string `json:"platform"`
- Status string `json:"status"`
- }
- type simpleModeAccountGroupReference struct {
- AccountID int64 `json:"account_id"`
- GroupID int64 `json:"group_id"`
- Priority int `json:"priority"`
- CreatedAt time.Time `json:"created_at"`
- Group *simpleModeGroupReference `json:"group,omitempty"`
- }
- func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference {
- if group == nil {
- return nil
- }
- return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}
- }
- func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} {
- hidden := make(map[int64]struct{})
- if account == nil {
- return hidden
- }
- for _, group := range account.Groups {
- if group != nil && group.Platform == service.PlatformComposite {
- hidden[group.ID] = struct{}{}
- }
- }
- for _, accountGroup := range account.AccountGroups {
- if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
- hidden[accountGroup.GroupID] = struct{}{}
- }
- }
- return hidden
- }
- func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 {
- visible := make([]int64, 0, len(groupIDs))
- for _, groupID := range groupIDs {
- if _, ok := hidden[groupID]; !ok {
- visible = append(visible, groupID)
- }
- }
- return visible
- }
- func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} {
- hidden := make(map[int64]struct{})
- if account == nil {
- return hidden
- }
- for _, group := range account.Groups {
- if group != nil && group.Platform == service.PlatformComposite {
- hidden[group.ID] = struct{}{}
- }
- }
- for _, accountGroup := range account.AccountGroups {
- if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
- hidden[accountGroup.GroupID] = struct{}{}
- }
- }
- return hidden
- }
- func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) {
- type alias AccountWithConcurrency
- if !a.simpleMode || a.Account == nil {
- return json.Marshal(alias(a))
- }
- groups := make([]simpleModeGroupReference, 0, len(a.Groups))
- compositeIDs := simpleModeCompositeGroupIDs(a.Account)
- for _, group := range a.Groups {
- if group != nil && group.Platform == service.PlatformComposite {
- continue
- }
- if ref := simpleModeGroupReferenceFromDTO(group); ref != nil {
- groups = append(groups, *ref)
- }
- }
- accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups))
- for _, accountGroup := range a.AccountGroups {
- if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
- continue
- }
- if _, hidden := compositeIDs[accountGroup.GroupID]; hidden {
- continue
- }
- accountGroups = append(accountGroups, simpleModeAccountGroupReference{
- AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority,
- CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group),
- })
- }
- return json.Marshal(struct {
- alias
- GroupIDs []int64 `json:"group_ids,omitempty"`
- Groups []simpleModeGroupReference `json:"groups"`
- AccountGroups []simpleModeAccountGroupReference `json:"account_groups"`
- }{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups})
- }
- type AccountSchedulerScore struct {
- BaseScore float64 `json:"base_score"`
- StickyScore float64 `json:"sticky_score"`
- StickyScoreInfinity bool `json:"sticky_score_infinity"`
- StickyWeightedEnabled bool `json:"sticky_weighted_enabled"`
- }
- type AccountSchedulerGroupScore struct {
- GroupID *int64 `json:"group_id"`
- GroupName string `json:"group_name,omitempty"`
- GroupPriority *int `json:"group_priority,omitempty"`
- AccountSchedulerScore
- }
- const accountListGroupUngroupedQueryValue = "ungrouped"
- func (h *AccountHandler) accountResponseFromService(account *service.Account) *dto.Account {
- out := dto.AccountFromService(account)
- if h != nil && h.ollamaCloudUsage != nil && out != nil {
- h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
- }
- return out
- }
- func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account {
- out := dto.AccountFromServiceShallow(account)
- if out != nil && account != nil {
- out.Proxy = dto.ProxyFromService(account.Proxy)
- }
- if h != nil && h.ollamaCloudUsage != nil && out != nil {
- h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
- }
- return out
- }
- func (h *AccountHandler) isSimpleMode() bool {
- return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
- }
- func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
- item := AccountWithConcurrency{
- Account: h.accountResponseFromService(account),
- simpleMode: h.isSimpleMode(),
- CurrentConcurrency: 0,
- }
- if account == nil {
- return item
- }
- if h.concurrencyService != nil {
- if counts, err := h.concurrencyService.GetAccountConcurrencyBatch(ctx, []int64{account.ID}); err == nil {
- item.CurrentConcurrency = counts[account.ID]
- }
- }
- if account.IsAnthropicOAuthOrSetupToken() {
- if h.accountUsageService != nil && account.GetWindowCostLimit() > 0 {
- startTime := account.GetCurrentWindowStartTime()
- if stats, err := h.accountUsageService.GetAccountWindowStats(ctx, account.ID, startTime); err == nil && stats != nil {
- cost := stats.StandardCost
- item.CurrentWindowCost = &cost
- }
- }
- if h.sessionLimitCache != nil && account.GetMaxSessions() > 0 {
- idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute
- idleTimeouts := map[int64]time.Duration{account.ID: idleTimeout}
- if sessions, err := h.sessionLimitCache.GetActiveSessionCountBatch(ctx, []int64{account.ID}, idleTimeouts); err == nil {
- if count, ok := sessions[account.ID]; ok {
- item.ActiveSessions = &count
- }
- }
- }
- if h.rpmCache != nil && account.GetBaseRPM() > 0 {
- if rpm, err := h.rpmCache.GetRPM(ctx, account.ID); err == nil {
- item.CurrentRPM = &rpm
- }
- }
- }
- h.enrichShadowParents(ctx, []AccountWithConcurrency{item})
- return item
- }
- // scoreOpenAIAccountSchedulerPool 对池内 OpenAI 账号计算调度分数快照。
- // loadMap 为共享的账号负载数据(含池内全部账号即可,多余条目无害);传 nil 时自行批查。
- func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, accounts []service.Account, loadMap map[int64]*service.AccountLoadInfo) map[int64]AccountSchedulerScore {
- if len(accounts) == 0 {
- return nil
- }
- openAIAccounts := make([]*service.Account, 0, len(accounts))
- for i := range accounts {
- account := &accounts[i]
- if account.Platform != service.PlatformOpenAI {
- continue
- }
- openAIAccounts = append(openAIAccounts, account)
- }
- if len(openAIAccounts) == 0 {
- return nil
- }
- if loadMap == nil {
- loadMap = h.fetchOpenAIAccountLoadMap(ctx, openAIAccounts)
- }
- var scores map[int64]service.OpenAIAccountSchedulerScoreSnapshot
- if h.rateLimitService != nil {
- scores = h.rateLimitService.BuildOpenAIAccountSchedulerScoreSnapshot(ctx, openAIAccounts, loadMap)
- } else {
- scores = service.BuildOpenAIAccountSchedulerScoreSnapshot(openAIAccounts, loadMap)
- }
- result := make(map[int64]AccountSchedulerScore, len(scores))
- for accountID, score := range scores {
- result[accountID] = AccountSchedulerScore{
- BaseScore: score.BaseScore,
- StickyScore: score.StickyScore,
- StickyScoreInfinity: score.StickyScoreInfinity,
- StickyWeightedEnabled: score.StickyWeightedEnabled,
- }
- }
- return result
- }
- // fetchOpenAIAccountLoadMap 一次性批查给定 OpenAI 账号的负载数据;
- // 失败时记录日志并返回空表(分数按零负载计算,属可接受降级)。
- func (h *AccountHandler) fetchOpenAIAccountLoadMap(ctx context.Context, openAIAccounts []*service.Account) map[int64]*service.AccountLoadInfo {
- loadMap := map[int64]*service.AccountLoadInfo{}
- if h.concurrencyService == nil || len(openAIAccounts) == 0 {
- return loadMap
- }
- seen := make(map[int64]struct{}, len(openAIAccounts))
- loadReq := make([]service.AccountWithConcurrency, 0, len(openAIAccounts))
- for _, account := range openAIAccounts {
- if account == nil {
- continue
- }
- if _, ok := seen[account.ID]; ok {
- continue
- }
- seen[account.ID] = struct{}{}
- loadReq = append(loadReq, service.AccountWithConcurrency{
- ID: account.ID,
- MaxConcurrency: account.EffectiveLoadFactor(),
- })
- }
- if batchLoad, err := h.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); err != nil {
- slog.Warn("openai_scheduler_score_load_batch_failed", "error", err)
- } else if batchLoad != nil {
- loadMap = batchLoad
- }
- return loadMap
- }
- func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
- ctx context.Context,
- accounts []service.Account,
- filterPool []service.Account,
- ) (map[int64]*AccountSchedulerScore, map[int64][]AccountSchedulerGroupScore) {
- if len(accounts) == 0 {
- return nil, nil
- }
- if len(filterPool) == 0 {
- filterPool = accounts
- }
- pageOpenAIAccountIDs := make(map[int64]struct{})
- groupIDs := make(map[int64]struct{})
- for i := range accounts {
- account := &accounts[i]
- if account.Platform != service.PlatformOpenAI {
- continue
- }
- pageOpenAIAccountIDs[account.ID] = struct{}{}
- if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 {
- continue
- }
- for _, accountGroup := range account.AccountGroups {
- if accountGroup.GroupID > 0 {
- groupIDs[accountGroup.GroupID] = struct{}{}
- }
- }
- for _, groupID := range account.GroupIDs {
- if groupID > 0 {
- groupIDs[groupID] = struct{}{}
- }
- }
- }
- if len(pageOpenAIAccountIDs) == 0 {
- return nil, nil
- }
- // 先取各分组池,再对"过滤池 ∪ 分组池"的账号并集做一次负载批查,
- // 避免每个池各查一次 Redis 的 N+1。
- groupIDList := make([]int64, 0, len(groupIDs))
- for groupID := range groupIDs {
- groupIDList = append(groupIDList, groupID)
- }
- sort.Slice(groupIDList, func(i, j int) bool { return groupIDList[i] < groupIDList[j] })
- groupPools := make(map[int64][]service.Account, len(groupIDList))
- if h.adminService != nil {
- for _, groupID := range groupIDList {
- gid := groupID
- pool, err := h.adminService.ListOpenAISchedulableAccountsForSchedulerScore(ctx, &gid)
- if err != nil {
- slog.Warn("openai_scheduler_group_score_pool_failed", "group_id", gid, "error", err)
- continue
- }
- groupPools[gid] = pool
- }
- }
- loadUnion := make([]*service.Account, 0, len(filterPool))
- collectOpenAIAccounts := func(pool []service.Account) {
- for i := range pool {
- if pool[i].Platform == service.PlatformOpenAI {
- loadUnion = append(loadUnion, &pool[i])
- }
- }
- }
- collectOpenAIAccounts(filterPool)
- for _, pool := range groupPools {
- collectOpenAIAccounts(pool)
- }
- loadMap := h.fetchOpenAIAccountLoadMap(ctx, loadUnion)
- baseScores := make(map[int64]*AccountSchedulerScore)
- for accountID, score := range h.scoreOpenAIAccountSchedulerPool(ctx, filterPool, loadMap) {
- copiedScore := score
- baseScores[accountID] = &copiedScore
- }
- groupScoresByAccount := make(map[int64][]AccountSchedulerGroupScore)
- scoreGroupPool := func(groupID *int64, groupNameByID map[int64]string, groupPriorityByAccount map[int64]int, pool []service.Account) {
- if len(pool) == 0 {
- return
- }
- scores := h.scoreOpenAIAccountSchedulerPool(ctx, pool, loadMap)
- for accountID, schedulerScore := range scores {
- if _, ok := pageOpenAIAccountIDs[accountID]; !ok {
- continue
- }
- groupScore := AccountSchedulerGroupScore{
- GroupID: groupID,
- AccountSchedulerScore: schedulerScore,
- }
- if groupID != nil {
- groupScore.GroupName = groupNameByID[*groupID]
- if priority, ok := groupPriorityByAccount[accountID]; ok {
- groupScore.GroupPriority = &priority
- }
- }
- groupScoresByAccount[accountID] = append(groupScoresByAccount[accountID], groupScore)
- }
- }
- for _, groupID := range groupIDList {
- gid := groupID
- pool, ok := groupPools[gid]
- if !ok {
- continue
- }
- groupNameByID := make(map[int64]string)
- groupPriorityByAccount := make(map[int64]int)
- for i := range pool {
- account := &pool[i]
- for _, accountGroup := range account.AccountGroups {
- if accountGroup.GroupID != gid {
- continue
- }
- groupPriorityByAccount[account.ID] = accountGroup.Priority
- if accountGroup.Group != nil {
- groupNameByID[gid] = accountGroup.Group.Name
- }
- }
- }
- scoreGroupPool(&gid, groupNameByID, groupPriorityByAccount, pool)
- }
- for accountID := range groupScoresByAccount {
- sort.SliceStable(groupScoresByAccount[accountID], func(i, j int) bool {
- left := groupScoresByAccount[accountID][i]
- right := groupScoresByAccount[accountID][j]
- return *left.GroupID < *right.GroupID
- })
- }
- return baseScores, groupScoresByAccount
- }
- func (h *AccountHandler) listAccountSchedulerScoreFilterPool(
- ctx context.Context,
- platform, accountType, status, search string,
- groupID int64,
- privacyMode string,
- ) []service.Account {
- if h.adminService == nil || (platform != "" && platform != service.PlatformOpenAI) {
- return nil
- }
- // 池只用于 OpenAI 分数计算(非 OpenAI 账号会在打分时被丢弃),
- // 无论列表页平台过滤为何,查询一律限定 openai,避免无过滤时全表扫描。
- accounts, err := h.adminService.ListAccountsForSchedulerScoreFilter(ctx, service.PlatformOpenAI, accountType, status, search, groupID, privacyMode)
- if err != nil {
- slog.Warn("openai_scheduler_filter_score_pool_failed", "error", err)
- return nil
- }
- return accounts
- }
- // List handles listing all accounts with pagination
- // GET /api/v1/admin/accounts
- func (h *AccountHandler) List(c *gin.Context) {
- page, pageSize := response.ParsePagination(c)
- platform := c.Query("platform")
- accountType := c.Query("type")
- status := c.Query("status")
- search := c.Query("search")
- privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
- sortBy := c.DefaultQuery("sort_by", "name")
- sortOrder := c.DefaultQuery("sort_order", "asc")
- // 标准化和验证 search 参数
- search = strings.TrimSpace(search)
- if len(search) > 100 {
- search = search[:100]
- }
- lite := parseBoolQueryWithDefault(c.Query("lite"), false)
- // 调度分需要跨候选池批量打分并读取负载,默认列表不计算;只有前端列可见时才显式开启。
- includeSchedulerScore := parseBoolQueryWithDefault(c.Query("include_scheduler_score"), false)
- var groupID int64
- if groupIDStr := c.Query("group"); groupIDStr != "" {
- if groupIDStr == accountListGroupUngroupedQueryValue {
- groupID = service.AccountListGroupUngrouped
- } else {
- parsedGroupID, parseErr := strconv.ParseInt(groupIDStr, 10, 64)
- if parseErr != nil {
- response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter"))
- return
- }
- if parsedGroupID < 0 {
- response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter"))
- return
- }
- groupID = parsedGroupID
- }
- }
- accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID, privacyMode, sortBy, sortOrder)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if h.ollamaCloudUsage != nil && len(accounts) > 0 {
- accountPointers := make([]*service.Account, len(accounts))
- for index := range accounts {
- accountPointers[index] = &accounts[index]
- }
- if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- }
- // Get current concurrency counts for all accounts
- accountIDs := make([]int64, len(accounts))
- for i, acc := range accounts {
- accountIDs[i] = acc.ID
- }
- concurrencyCounts := make(map[int64]int)
- var windowCosts map[int64]float64
- var activeSessions map[int64]int
- var rpmCounts map[int64]int
- // 双重门控:用户要看该列,且当前页确实有 OpenAI 账号,才进入昂贵的候选池打分路径。
- var schedulerScores map[int64]*AccountSchedulerScore
- var schedulerGroupScores map[int64][]AccountSchedulerGroupScore
- pageHasOpenAIAccounts := false
- for i := range accounts {
- if accounts[i].Platform == service.PlatformOpenAI {
- pageHasOpenAIAccounts = true
- break
- }
- }
- if includeSchedulerScore && pageHasOpenAIAccounts {
- schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode)
- schedulerScores, schedulerGroupScores = h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool)
- }
- // 始终获取并发数(Redis ZCARD,极低开销)
- if h.concurrencyService != nil {
- if cc, ccErr := h.concurrencyService.GetAccountConcurrencyBatch(c.Request.Context(), accountIDs); ccErr == nil && cc != nil {
- concurrencyCounts = cc
- }
- }
- // 识别需要查询窗口费用、会话数和 RPM 的账号(Anthropic OAuth/SetupToken 且启用了相应功能)
- windowCostAccountIDs := make([]int64, 0)
- sessionLimitAccountIDs := make([]int64, 0)
- rpmAccountIDs := make([]int64, 0)
- sessionIdleTimeouts := make(map[int64]time.Duration) // 各账号的会话空闲超时配置
- for i := range accounts {
- acc := &accounts[i]
- if acc.IsAnthropicOAuthOrSetupToken() {
- if acc.GetWindowCostLimit() > 0 {
- windowCostAccountIDs = append(windowCostAccountIDs, acc.ID)
- }
- if acc.GetMaxSessions() > 0 {
- sessionLimitAccountIDs = append(sessionLimitAccountIDs, acc.ID)
- sessionIdleTimeouts[acc.ID] = time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute
- }
- if acc.GetBaseRPM() > 0 {
- rpmAccountIDs = append(rpmAccountIDs, acc.ID)
- }
- }
- }
- // 始终获取 RPM 计数(Redis GET,极低开销)
- if len(rpmAccountIDs) > 0 && h.rpmCache != nil {
- rpmCounts, _ = h.rpmCache.GetRPMBatch(c.Request.Context(), rpmAccountIDs)
- if rpmCounts == nil {
- rpmCounts = make(map[int64]int)
- }
- }
- // 始终获取活跃会话数(Redis ZCARD,低开销)
- if len(sessionLimitAccountIDs) > 0 && h.sessionLimitCache != nil {
- activeSessions, _ = h.sessionLimitCache.GetActiveSessionCountBatch(c.Request.Context(), sessionLimitAccountIDs, sessionIdleTimeouts)
- if activeSessions == nil {
- activeSessions = make(map[int64]int)
- }
- }
- // 始终获取窗口费用(PostgreSQL 聚合查询)
- if len(windowCostAccountIDs) > 0 {
- windowCosts = make(map[int64]float64)
- var mu sync.Mutex
- g, gctx := errgroup.WithContext(c.Request.Context())
- g.SetLimit(10) // 限制并发数
- for i := range accounts {
- acc := &accounts[i]
- if !acc.IsAnthropicOAuthOrSetupToken() || acc.GetWindowCostLimit() <= 0 {
- continue
- }
- accCopy := acc // 闭包捕获
- g.Go(func() error {
- // 使用统一的窗口开始时间计算逻辑(考虑窗口过期情况)
- startTime := accCopy.GetCurrentWindowStartTime()
- stats, err := h.accountUsageService.GetAccountWindowStats(gctx, accCopy.ID, startTime)
- if err == nil && stats != nil {
- mu.Lock()
- windowCosts[accCopy.ID] = stats.StandardCost // 使用标准费用
- mu.Unlock()
- }
- return nil // 不返回错误,允许部分失败
- })
- }
- _ = g.Wait()
- }
- // Build response with concurrency info
- result := make([]AccountWithConcurrency, len(accounts))
- for i := range accounts {
- acc := &accounts[i]
- accountResponse := h.accountResponseFromService(acc)
- if lite {
- accountResponse = h.accountListResponseFromService(acc)
- if h.isSimpleMode() {
- accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc))
- }
- }
- item := AccountWithConcurrency{
- Account: accountResponse,
- simpleMode: h.isSimpleMode(),
- CurrentConcurrency: concurrencyCounts[acc.ID],
- SchedulerScore: schedulerScores[acc.ID],
- SchedulerScores: schedulerGroupScores[acc.ID],
- }
- // 添加窗口费用(仅当启用时)
- if windowCosts != nil {
- if cost, ok := windowCosts[acc.ID]; ok {
- item.CurrentWindowCost = &cost
- }
- }
- // 添加活跃会话数(仅当启用时)
- if activeSessions != nil {
- if count, ok := activeSessions[acc.ID]; ok {
- item.ActiveSessions = &count
- }
- }
- // 添加 RPM 计数(仅当启用时)
- if rpmCounts != nil {
- if rpm, ok := rpmCounts[acc.ID]; ok {
- item.CurrentRPM = &rpm
- }
- }
- result[i] = item
- }
- h.enrichShadowParents(c.Request.Context(), result)
- if lite {
- compact := make([]AccountListItemWithConcurrency, len(result))
- for i := range result {
- item := result[i]
- compact[i] = AccountListItemWithConcurrency{
- AccountListItem: dto.AccountListItemFromAccount(item.Account),
- CurrentConcurrency: item.CurrentConcurrency,
- SchedulerScore: item.SchedulerScore,
- SchedulerScores: item.SchedulerScores,
- CurrentWindowCost: item.CurrentWindowCost,
- ActiveSessions: item.ActiveSessions,
- CurrentRPM: item.CurrentRPM,
- }
- }
- etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true)
- if etag != "" {
- c.Header("ETag", etag)
- c.Header("Vary", "If-None-Match")
- if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
- c.Status(http.StatusNotModified)
- return
- }
- }
- response.Paginated(c, compact, total, page, pageSize)
- return
- }
- etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false)
- if etag != "" {
- c.Header("ETag", etag)
- c.Header("Vary", "If-None-Match")
- if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
- c.Status(http.StatusNotModified)
- return
- }
- }
- response.Paginated(c, result, total, page, pageSize)
- }
- func buildAccountsListETag[T any](
- items []T,
- total int64,
- page, pageSize int,
- platform, accountType, status, search string,
- lite bool,
- ) string {
- payload := struct {
- Total int64 `json:"total"`
- Page int `json:"page"`
- PageSize int `json:"page_size"`
- Platform string `json:"platform"`
- AccountType string `json:"type"`
- Status string `json:"status"`
- Search string `json:"search"`
- Lite bool `json:"lite"`
- Items []T `json:"items"`
- }{
- Total: total,
- Page: page,
- PageSize: pageSize,
- Platform: platform,
- AccountType: accountType,
- Status: status,
- Search: search,
- Lite: lite,
- Items: items,
- }
- raw, err := json.Marshal(payload)
- if err != nil {
- return ""
- }
- sum := sha256.Sum256(raw)
- return "\"" + hex.EncodeToString(sum[:]) + "\""
- }
- func ifNoneMatchMatched(ifNoneMatch, etag string) bool {
- if etag == "" || ifNoneMatch == "" {
- return false
- }
- for _, token := range strings.Split(ifNoneMatch, ",") {
- candidate := strings.TrimSpace(token)
- if candidate == "*" {
- return true
- }
- if candidate == etag {
- return true
- }
- if strings.HasPrefix(candidate, "W/") && strings.TrimPrefix(candidate, "W/") == etag {
- return true
- }
- }
- return false
- }
- // GetByID handles getting an account by ID
- // GET /api/v1/admin/accounts/:id
- func (h *AccountHandler) GetByID(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if h.ollamaCloudUsage != nil {
- if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), []*service.Account{account}); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
- }
- // CheckMixedChannel handles checking mixed channel risk for account-group binding.
- // POST /api/v1/admin/accounts/check-mixed-channel
- func (h *AccountHandler) CheckMixedChannel(c *gin.Context) {
- var req CheckMixedChannelRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if len(req.GroupIDs) == 0 {
- response.Success(c, gin.H{"has_risk": false})
- return
- }
- accountID := int64(0)
- if req.AccountID != nil {
- accountID = *req.AccountID
- }
- err := h.adminService.CheckMixedChannelRisk(c.Request.Context(), accountID, req.Platform, req.GroupIDs)
- if err != nil {
- var mixedErr *service.MixedChannelError
- if errors.As(err, &mixedErr) {
- response.Success(c, gin.H{
- "has_risk": true,
- "error": "mixed_channel_warning",
- "message": mixedErr.Error(),
- "details": gin.H{
- "group_id": mixedErr.GroupID,
- "group_name": mixedErr.GroupName,
- "current_platform": mixedErr.CurrentPlatform,
- "other_platform": mixedErr.OtherPlatform,
- },
- })
- return
- }
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"has_risk": false})
- }
- // Create handles creating a new account
- // POST /api/v1/admin/accounts
- func (h *AccountHandler) Create(c *gin.Context) {
- var req CreateAccountRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if err := service.ValidateOpenAILongContextBillingExtra(req.Platform, req.Extra); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
- response.BadRequest(c, "rate_multiplier must be >= 0")
- return
- }
- // base_rpm 输入校验:负值归零,超过 10000 截断
- sanitizeExtraBaseRPM(req.Extra)
- if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // 确定是否跳过混合渠道检查
- skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
- // 捕获闭包内创建的账号引用,用于创建成功后触发异步探测。
- // 幂等重放时闭包不会执行 → createdAccount 为 nil → 不重复调度。
- var createdAccount *service.Account
- result, err := executeAdminIdempotent(c, "admin.accounts.create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
- account, execErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
- Name: req.Name,
- Notes: req.Notes,
- Platform: req.Platform,
- Type: req.Type,
- Credentials: req.Credentials,
- Extra: req.Extra,
- ProxyID: req.ProxyID,
- Concurrency: req.Concurrency,
- Priority: req.Priority,
- RateMultiplier: req.RateMultiplier,
- LoadFactor: req.LoadFactor,
- GroupIDs: req.GroupIDs,
- ExpiresAt: req.ExpiresAt,
- AutoPauseOnExpired: req.AutoPauseOnExpired,
- ProbeEnabled: req.ProbeEnabled,
- SkipMixedChannelCheck: skipCheck,
- })
- if execErr != nil {
- return nil, execErr
- }
- createdAccount = account
- // Antigravity OAuth: 新账号直接设置隐私
- h.adminService.ForceAntigravityPrivacy(ctx, account)
- // OpenAI OAuth: 新账号直接设置隐私
- h.adminService.ForceOpenAIPrivacy(ctx, account)
- return h.buildAccountResponseWithRuntime(ctx, account), nil
- })
- if err != nil {
- // 检查是否为混合渠道错误
- var mixedErr *service.MixedChannelError
- if errors.As(err, &mixedErr) {
- // 创建接口仅返回最小必要字段,详细信息由专门检查接口提供
- c.JSON(409, gin.H{
- "error": "mixed_channel_warning",
- "message": mixedErr.Error(),
- })
- return
- }
- if retryAfter := service.RetryAfterSecondsFromError(err); retryAfter > 0 {
- c.Header("Retry-After", strconv.Itoa(retryAfter))
- }
- response.ErrorFrom(c, err)
- return
- }
- if result != nil && result.Replayed {
- c.Header("X-Idempotency-Replayed", "true")
- }
- // OpenAI APIKey 账号创建后异步探测上游 /v1/responses 能力。
- // 探测失败不影响账号创建响应。
- h.scheduleOpenAIResponsesProbe(createdAccount)
- h.scheduleGrokImportProbe(createdAccount)
- response.Success(c, result.Data)
- }
- // Duplicate handles creating an independent account from an existing account's configuration.
- // POST /api/v1/admin/accounts/:id/duplicate
- func (h *AccountHandler) Duplicate(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- actorScope := adminActorScope(c)
- result, err := executeAdminIdempotent(
- c,
- "admin.accounts.duplicate",
- struct {
- AccountID int64 `json:"account_id"`
- }{AccountID: accountID},
- service.DefaultWriteIdempotencyTTL(),
- func(ctx context.Context) (any, error) {
- account, execErr := h.adminService.DuplicateAccount(ctx, accountID, actorScope, c.GetHeader("Idempotency-Key"))
- if execErr != nil {
- return nil, execErr
- }
- return h.buildAccountResponseWithRuntime(ctx, account), nil
- },
- )
- if err != nil {
- reason := infraerrors.Reason(err)
- if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
- recovered, recoverErr := h.adminService.RecoverDuplicateAccount(c.Request.Context(), accountID, actorScope, c.GetHeader("Idempotency-Key"))
- if recoverErr != nil {
- slog.Warn("account_duplicate_recovery_failed", "account_id", accountID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
- } else if recovered != nil {
- c.Header("X-Idempotency-Recovered", "true")
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), recovered))
- return
- }
- }
- response.ErrorFrom(c, err)
- return
- }
- if result != nil && result.Replayed {
- c.Header("X-Idempotency-Replayed", "true")
- }
- response.Success(c, result.Data)
- }
- // Update handles updating an account
- // PUT /api/v1/admin/accounts/:id
- func (h *AccountHandler) Update(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- var req UpdateAccountRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
- response.BadRequest(c, "rate_multiplier must be >= 0")
- return
- }
- // base_rpm 输入校验:负值归零,超过 10000 截断
- sanitizeExtraBaseRPM(req.Extra)
- if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // 确定是否跳过混合渠道检查
- skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
- account, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
- Name: req.Name,
- Notes: req.Notes,
- Type: req.Type,
- Credentials: req.Credentials,
- Extra: req.Extra,
- ProxyID: req.ProxyID,
- Concurrency: req.Concurrency, // 指针类型,nil 表示未提供
- Priority: req.Priority, // 指针类型,nil 表示未提供
- RateMultiplier: req.RateMultiplier,
- LoadFactor: req.LoadFactor,
- Status: req.Status,
- GroupIDs: req.GroupIDs,
- ExpiresAt: req.ExpiresAt,
- AutoPauseOnExpired: req.AutoPauseOnExpired,
- ProbeEnabled: req.ProbeEnabled,
- RateSyncEnabled: req.RateSyncEnabled,
- SkipMixedChannelCheck: skipCheck,
- })
- if err != nil {
- // 检查是否为混合渠道错误
- var mixedErr *service.MixedChannelError
- if errors.As(err, &mixedErr) {
- // 更新接口仅返回最小必要字段,详细信息由专门检查接口提供
- c.JSON(409, gin.H{
- "error": "mixed_channel_warning",
- "message": mixedErr.Error(),
- })
- return
- }
- response.ErrorFrom(c, err)
- return
- }
- // OpenAI APIKey: credentials 修改后重新探测上游能力(base_url/api_key 可能变更)。
- if h.accountTraffic != nil && account.Extra[service.AccountTrafficPolicyKey] != nil {
- if err := h.accountTraffic.traffic.Sync(c.Request.Context(), account); err != nil {
- slog.Warn("account_traffic_sync_after_edit_failed", "account_id", account.ID, "error", err)
- c.Header("X-Account-Traffic-State", "unavailable")
- }
- }
- // 异步执行,探测失败不影响账号更新响应。
- if len(req.Credentials) > 0 {
- h.scheduleOpenAIResponsesProbe(account)
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
- }
- // scheduleOpenAIResponsesProbe 异步触发 OpenAI APIKey 账号的 Responses API 能力探测。
- //
- // 仅对 platform=openai && type=apikey 账号生效;其他账号无操作。
- // 探测本身在 goroutine 中执行(会发一次 HTTP 请求到上游),不会阻塞
- // 当前请求。探测错误仅记录日志,不向上下文传播:探测失败时标记保持缺失,
- // 网关会按"现状即证据"默认走 Responses。
- func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) {
- if account == nil || account.Type != service.AccountTypeAPIKey ||
- (account.Platform != service.PlatformOpenAI && !service.IsCNProvider(account.Platform)) {
- return
- }
- if h.accountTestService == nil {
- return
- }
- accountID := account.ID
- go func() {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("openai_responses_probe_panic", "account_id", accountID, "recover", r)
- }
- }()
- h.accountTestService.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), accountID)
- }()
- }
- // Delete handles deleting an account
- // DELETE /api/v1/admin/accounts/:id
- func (h *AccountHandler) Delete(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- err = h.adminService.DeleteAccount(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Account deleted successfully"})
- }
- // TestAccountRequest represents the request body for testing an account
- type TestAccountRequest struct {
- ModelID string `json:"model_id"`
- Prompt string `json:"prompt"`
- Mode string `json:"mode"`
- // Optional media for Grok (and future) real generation tests.
- // ImageDataURL / AudioDataURL are data:<mime>;base64,... payloads.
- ImageDataURL string `json:"image_data_url"`
- AudioDataURL string `json:"audio_data_url"`
- }
- type SyncFromCRSRequest struct {
- BaseURL string `json:"base_url" binding:"required"`
- Username string `json:"username" binding:"required"`
- Password string `json:"password" binding:"required"`
- SyncProxies *bool `json:"sync_proxies"`
- SelectedAccountIDs []string `json:"selected_account_ids"`
- }
- type PreviewFromCRSRequest struct {
- BaseURL string `json:"base_url" binding:"required"`
- Username string `json:"username" binding:"required"`
- Password string `json:"password" binding:"required"`
- }
- // Test handles testing account connectivity with SSE streaming
- // POST /api/v1/admin/accounts/:id/test
- func (h *AccountHandler) Test(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- var req TestAccountRequest
- // Allow empty body, model_id is optional
- _ = c.ShouldBindJSON(&req)
- opts := service.AccountTestOptions{
- ImageDataURL: req.ImageDataURL,
- AudioDataURL: req.AudioDataURL,
- }
- // Use AccountTestService to test the account with SSE streaming
- if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode, opts); err != nil {
- // Error already sent via SSE, just log
- return
- }
- if h.rateLimitService != nil {
- if _, err := h.rateLimitService.RecoverAccountAfterSuccessfulTest(c.Request.Context(), accountID); err != nil {
- _ = c.Error(err)
- }
- }
- }
- // RecoverState handles unified recovery of recoverable account runtime state.
- // POST /api/v1/admin/accounts/:id/recover-state
- func (h *AccountHandler) RecoverState(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- if h.rateLimitService == nil {
- response.Error(c, http.StatusServiceUnavailable, "Rate limit service unavailable")
- return
- }
- if _, err := h.rateLimitService.RecoverAccountState(c.Request.Context(), accountID, service.AccountRecoveryOptions{
- InvalidateToken: true,
- }); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
- }
- // SyncFromCRS handles syncing accounts from claude-relay-service (CRS)
- // POST /api/v1/admin/accounts/sync/crs
- func (h *AccountHandler) SyncFromCRS(c *gin.Context) {
- var req SyncFromCRSRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- // Default to syncing proxies (can be disabled by explicitly setting false)
- syncProxies := true
- if req.SyncProxies != nil {
- syncProxies = *req.SyncProxies
- }
- result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{
- BaseURL: req.BaseURL,
- Username: req.Username,
- Password: req.Password,
- SyncProxies: syncProxies,
- SelectedAccountIDs: req.SelectedAccountIDs,
- })
- if err != nil {
- // Provide detailed error message for CRS sync failures
- response.InternalError(c, "CRS sync failed: "+err.Error())
- return
- }
- response.Success(c, result)
- }
- // PreviewFromCRS handles previewing accounts from CRS before sync
- // POST /api/v1/admin/accounts/sync/crs/preview
- func (h *AccountHandler) PreviewFromCRS(c *gin.Context) {
- var req PreviewFromCRSRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- result, err := h.crsSyncService.PreviewFromCRS(c.Request.Context(), service.SyncFromCRSInput{
- BaseURL: req.BaseURL,
- Username: req.Username,
- Password: req.Password,
- })
- if err != nil {
- response.InternalError(c, "CRS preview failed: "+err.Error())
- return
- }
- response.Success(c, result)
- }
- // refreshSingleAccount refreshes credentials for a single OAuth account.
- // Returns (updatedAccount, warning, error) where warning is used for Antigravity ProjectIDMissing scenario.
- func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *service.Account) (*service.Account, string, error) {
- if !account.IsOAuth() {
- return nil, "", infraerrors.BadRequest("NOT_OAUTH", "cannot refresh non-OAuth account")
- }
- // spark 影子凭据由母账号管理、自身恒空,刷新无意义且会先打上游;在调用上游前早拒
- // (覆盖单账号与批量两入口;批量侧将其计为 failed 并附说明)(外审第6轮)。
- if account.IsCredentialShadow() {
- return nil, "", infraerrors.BadRequest("SPARK_SHADOW_NO_REFRESH",
- "cannot refresh spark shadow account; its credentials are managed by the parent account")
- }
- var newCredentials map[string]any
- if account.IsOpenAI() {
- tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account)
- if err != nil {
- // 刷新失败但 access_token 可能仍有效,尝试设置隐私
- h.adminService.EnsureOpenAIPrivacy(ctx, account)
- return nil, "", err
- }
- newCredentials = h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
- for k, v := range account.Credentials {
- if _, exists := newCredentials[k]; !exists {
- newCredentials[k] = v
- }
- }
- newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
- } else if account.Platform == service.PlatformGemini {
- tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account)
- if err != nil {
- return nil, "", fmt.Errorf("failed to refresh credentials: %w", err)
- }
- newCredentials = h.geminiOAuthService.BuildAccountCredentials(tokenInfo)
- for k, v := range account.Credentials {
- if _, exists := newCredentials[k]; !exists {
- newCredentials[k] = v
- }
- }
- } else if account.Platform == service.PlatformAntigravity {
- tokenInfo, err := h.antigravityOAuthService.RefreshAccountToken(ctx, account)
- if err != nil {
- return nil, "", err
- }
- newCredentials = h.antigravityOAuthService.BuildAccountCredentials(tokenInfo)
- for k, v := range account.Credentials {
- if _, exists := newCredentials[k]; !exists {
- newCredentials[k] = v
- }
- }
- // 特殊处理 project_id:如果新值为空但旧值非空,保留旧值
- // 这确保了即使 LoadCodeAssist 失败,project_id 也不会丢失
- if newProjectID, _ := newCredentials["project_id"].(string); newProjectID == "" {
- if oldProjectID := strings.TrimSpace(account.GetCredential("project_id")); oldProjectID != "" {
- newCredentials["project_id"] = oldProjectID
- }
- }
- // 如果 project_id 获取失败,更新凭证但不标记为 error
- if tokenInfo.ProjectIDMissing {
- updatedAccount, updateErr := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{
- Credentials: newCredentials,
- })
- if updateErr != nil {
- return nil, "", fmt.Errorf("failed to update credentials: %w", updateErr)
- }
- h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
- return updatedAccount, "missing_project_id_temporary", nil
- }
- // 成功获取到 project_id,如果之前是 missing_project_id 错误则清除
- if account.Status == service.StatusError && strings.Contains(account.ErrorMessage, "missing_project_id:") {
- if _, clearErr := h.adminService.ClearAccountError(ctx, account.ID); clearErr != nil {
- return nil, "", fmt.Errorf("failed to clear account error: %w", clearErr)
- }
- }
- } else if account.Platform == service.PlatformGrok {
- if h.grokOAuthService == nil {
- return nil, "", fmt.Errorf("grok oauth service is not configured")
- }
- tokenInfo, err := h.grokOAuthService.RefreshAccountToken(ctx, account)
- if err != nil {
- return nil, "", fmt.Errorf("failed to refresh Grok credentials: %w", err)
- }
- newCredentials = service.MergeCredentials(account.Credentials, h.grokOAuthService.BuildAccountCredentials(tokenInfo))
- if baseURL := strings.TrimSpace(account.GetCredential("base_url")); baseURL != "" {
- newCredentials["base_url"] = baseURL
- }
- } else {
- // Use Anthropic/Claude OAuth service to refresh token
- tokenInfo, err := h.oauthService.RefreshAccountToken(ctx, account)
- if err != nil {
- return nil, "", err
- }
- // Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests)
- newCredentials = make(map[string]any)
- for k, v := range account.Credentials {
- newCredentials[k] = v
- }
- // Update token-related fields
- newCredentials["access_token"] = tokenInfo.AccessToken
- newCredentials["token_type"] = tokenInfo.TokenType
- newCredentials["expires_in"] = strconv.FormatInt(tokenInfo.ExpiresIn, 10)
- newCredentials["expires_at"] = strconv.FormatInt(tokenInfo.ExpiresAt, 10)
- if strings.TrimSpace(tokenInfo.RefreshToken) != "" {
- newCredentials["refresh_token"] = tokenInfo.RefreshToken
- }
- if strings.TrimSpace(tokenInfo.Scope) != "" {
- newCredentials["scope"] = tokenInfo.Scope
- }
- }
- updatedAccount, err := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{
- Credentials: newCredentials,
- })
- if err != nil {
- return nil, "", err
- }
- // 刷新成功后,清除 token 缓存,确保下次请求使用新 token
- if h.tokenCacheInvalidator != nil {
- if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updatedAccount); invalidateErr != nil {
- log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", updatedAccount.ID, invalidateErr)
- }
- }
- // OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
- h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
- // Antigravity OAuth: 刷新成功后检查并设置 privacy_mode
- h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
- return updatedAccount, "", nil
- }
- // Refresh handles refreshing account credentials
- // POST /api/v1/admin/accounts/:id/refresh
- func (h *AccountHandler) Refresh(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- // Get account
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.NotFound(c, "Account not found")
- return
- }
- updatedAccount, warning, err := h.refreshSingleAccount(c.Request.Context(), account)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if warning == "missing_project_id_temporary" {
- response.Success(c, gin.H{
- "message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)",
- "warning": "missing_project_id_temporary",
- })
- return
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount))
- }
- // ApplyOAuthCredentialsRequest is the payload for persisting re-authorized OAuth credentials.
- type ApplyOAuthCredentialsRequest struct {
- Type string `json:"type" binding:"required,oneof=oauth setup-token"`
- Credentials map[string]any `json:"credentials" binding:"required"`
- Extra map[string]any `json:"extra"`
- }
- // ApplyOAuthCredentials 将"重新授权"得到的新凭据原子落库。
- // POST /api/v1/admin/accounts/:id/apply-oauth-credentials
- //
- // 与通用 PUT /:id (Update) 接口的关键区别:
- // - 仅接收 type / credentials / extra 三个字段(不接受 concurrency / rpm / quota_* 等可能误传的字段)
- // - Extra 走 UpdateAccountExtra(JSONB key 级合并),**绝不**全量覆盖;
- // 避免 base_rpm / window_cost_limit / max_sessions / quota_* / privacy_mode
- // 等持久化配置在重新授权后丢失
- // - 内置 ClearError + InvalidateToken,避免前端额外两次调用,
- // 并修复旧路径未失效 token 缓存导致重新授权后立即 401 的隐性 bug
- //
- // 与 /refresh 的区别:/refresh 用现有 refresh_token 换 access_token(无用户交互),
- // 本接口承接前端完成完整 OAuth 流程后的落库步骤。
- func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- var req ApplyOAuthCredentialsRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- ctx := c.Request.Context()
- // 预检查账号存在 + OAuth 类型(与 Refresh handler 语义一致,提供更友好的错误信息)。
- existing, err := h.adminService.GetAccount(ctx, accountID)
- if err != nil {
- response.NotFound(c, "Account not found")
- return
- }
- if !existing.IsOAuth() {
- response.ErrorFrom(c, infraerrors.BadRequest("NOT_OAUTH", "cannot apply oauth credentials to non-OAuth account"))
- return
- }
- if err := service.ValidateOpenAILongContextBillingExtra(existing.Platform, req.Extra); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
- req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
- updatedAccount, err := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{
- Type: req.Type,
- Credentials: req.Credentials,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // 增量合并 Extra(JSONB key 级 merge,绝不覆盖 base_rpm / window_cost_limit /
- // max_sessions / quota_* / privacy_mode 等持久化键)。
- // best-effort:失败仅记日志;下方 ClearAccountError 会从 DB 重新读取最新 account,
- // 因此响应里的 extra 始终以 DB 为准——这里不需要手动维护内存快照。
- if len(req.Extra) > 0 {
- if extraErr := h.adminService.UpdateAccountExtra(ctx, accountID, req.Extra); extraErr != nil {
- extraKeys := make([]string, 0, len(req.Extra))
- for k := range req.Extra {
- extraKeys = append(extraKeys, k)
- }
- slog.Error("apply_oauth_credentials.update_extra_failed",
- "account_id", accountID,
- "extra_keys", extraKeys,
- "err", extraErr,
- )
- }
- }
- // Successful re-auth clears the soft spending-limit reauth flag for Grok.
- if existing.Platform == service.PlatformGrok {
- if clearErr := h.adminService.UpdateAccountExtra(ctx, accountID, map[string]any{
- "grok_needs_reauth": false,
- "grok_needs_reauth_reason": "",
- "grok_needs_reauth_at": "",
- }); clearErr != nil {
- slog.Warn("apply_oauth_credentials.clear_grok_reauth_failed",
- "account_id", accountID,
- "err", clearErr,
- )
- }
- }
- if cleared, clearErr := h.adminService.ClearAccountError(ctx, accountID); clearErr != nil {
- slog.Warn("apply_oauth_credentials.clear_error_failed",
- "account_id", accountID,
- "err", clearErr,
- )
- } else if cleared != nil {
- updatedAccount = cleared
- }
- if h.tokenCacheInvalidator != nil && updatedAccount.IsOAuth() {
- if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updatedAccount); invalidateErr != nil {
- slog.Warn("apply_oauth_credentials.invalidate_token_failed",
- "account_id", accountID,
- "err", invalidateErr,
- )
- }
- }
- response.Success(c, h.buildAccountResponseWithRuntime(ctx, updatedAccount))
- }
- // GetStats handles getting account statistics
- // GET /api/v1/admin/accounts/:id/stats
- func (h *AccountHandler) GetStats(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- // Parse days parameter (default 30)
- days := 30
- if daysStr := c.Query("days"); daysStr != "" {
- if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 90 {
- days = d
- }
- }
- // Calculate time range
- now := timezone.Now()
- endTime := timezone.StartOfDay(now.AddDate(0, 0, 1))
- startTime := timezone.StartOfDay(now.AddDate(0, 0, -days+1))
- stats, err := h.accountUsageService.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, stats)
- }
- // ClearError handles clearing account error
- // POST /api/v1/admin/accounts/:id/clear-error
- func (h *AccountHandler) ClearError(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- account, err := h.adminService.ClearAccountError(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // 清除错误后,同时清除 token 缓存,确保下次请求会获取最新的 token(触发刷新或从 DB 读取)
- // 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
- if h.tokenCacheInvalidator != nil && account.IsOAuth() {
- if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), account); invalidateErr != nil {
- log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", accountID, invalidateErr)
- }
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
- }
- // RevertProxyFallback handles reverting account proxy to original before fallback.
- // POST /api/v1/admin/accounts/:id/revert-proxy-fallback
- func (h *AccountHandler) RevertProxyFallback(c *gin.Context) {
- id, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- if err := h.adminService.RevertAccountProxyFallback(c.Request.Context(), id); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "reverted"})
- }
- // BatchDelete handles deleting multiple accounts with bounded concurrency.
- // POST /api/v1/admin/accounts/batch-delete
- func (h *AccountHandler) BatchDelete(c *gin.Context) {
- var req struct {
- AccountIDs []int64 `json:"account_ids"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- accountIDs := normalizeInt64IDList(req.AccountIDs)
- if len(accountIDs) == 0 {
- response.BadRequest(c, "account_ids is required")
- return
- }
- accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), accountIDs)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- type deleteError struct {
- AccountID int64 `json:"account_id"`
- Error string `json:"error"`
- }
- requestedIDs := make(map[int64]struct{}, len(accountIDs))
- for _, accountID := range accountIDs {
- requestedIDs[accountID] = struct{}{}
- }
- accountsByID := make(map[int64]*service.Account, len(accounts))
- for _, account := range accounts {
- if account != nil {
- accountsByID[account.ID] = account
- }
- }
- rootIDs := make([]int64, 0, len(accountIDs))
- dependentIDs := make(map[int64][]int64)
- failedIDs := make([]int64, 0)
- errorsByAccount := make([]deleteError, 0)
- for _, accountID := range accountIDs {
- account := accountsByID[accountID]
- if account == nil {
- failedIDs = append(failedIDs, accountID)
- errorsByAccount = append(errorsByAccount, deleteError{
- AccountID: accountID,
- Error: "account not found",
- })
- continue
- }
- rootID := accountID
- visited := map[int64]struct{}{accountID: {}}
- for {
- current := accountsByID[rootID]
- if current == nil || current.ParentAccountID == nil {
- break
- }
- parentID := *current.ParentAccountID
- if _, selected := requestedIDs[parentID]; !selected {
- break
- }
- if _, exists := accountsByID[parentID]; !exists {
- break
- }
- if _, cyclic := visited[parentID]; cyclic {
- rootID = accountID
- break
- }
- visited[parentID] = struct{}{}
- rootID = parentID
- }
- if rootID != accountID {
- dependentIDs[rootID] = append(dependentIDs[rootID], accountID)
- continue
- }
- rootIDs = append(rootIDs, accountID)
- }
- const maxConcurrency = 5
- g, gctx := errgroup.WithContext(c.Request.Context())
- g.SetLimit(maxConcurrency)
- var mu sync.Mutex
- successIDs := make([]int64, 0, len(accountIDs))
- // Every worker returns nil so one account failure does not cancel the remaining deletions.
- for _, id := range rootIDs {
- accountID := id
- g.Go(func() error {
- err := h.adminService.DeleteAccount(gctx, accountID)
- mu.Lock()
- defer mu.Unlock()
- affectedIDs := append([]int64{accountID}, dependentIDs[accountID]...)
- if err != nil {
- for _, affectedID := range affectedIDs {
- failedIDs = append(failedIDs, affectedID)
- errorsByAccount = append(errorsByAccount, deleteError{
- AccountID: affectedID,
- Error: err.Error(),
- })
- }
- return nil
- }
- successIDs = append(successIDs, affectedIDs...)
- return nil
- })
- }
- if err := g.Wait(); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- sort.Slice(successIDs, func(i, j int) bool { return successIDs[i] < successIDs[j] })
- sort.Slice(failedIDs, func(i, j int) bool { return failedIDs[i] < failedIDs[j] })
- sort.Slice(errorsByAccount, func(i, j int) bool {
- return errorsByAccount[i].AccountID < errorsByAccount[j].AccountID
- })
- response.Success(c, gin.H{
- "total": len(accountIDs),
- "success": len(successIDs),
- "failed": len(failedIDs),
- "success_ids": successIDs,
- "failed_ids": failedIDs,
- "errors": errorsByAccount,
- })
- }
- // BatchClearError handles batch clearing account errors
- // POST /api/v1/admin/accounts/batch-clear-error
- func (h *AccountHandler) BatchClearError(c *gin.Context) {
- var req struct {
- AccountIDs []int64 `json:"account_ids"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if len(req.AccountIDs) == 0 {
- response.BadRequest(c, "account_ids is required")
- return
- }
- ctx := c.Request.Context()
- const maxConcurrency = 10
- g, gctx := errgroup.WithContext(ctx)
- g.SetLimit(maxConcurrency)
- var mu sync.Mutex
- var successCount, failedCount int
- var errors []gin.H
- // 注意:所有 goroutine 必须 return nil,避免 errgroup cancel 其他并发任务
- for _, id := range req.AccountIDs {
- accountID := id // 闭包捕获
- g.Go(func() error {
- account, err := h.adminService.ClearAccountError(gctx, accountID)
- if err != nil {
- mu.Lock()
- failedCount++
- errors = append(errors, gin.H{
- "account_id": accountID,
- "error": err.Error(),
- })
- mu.Unlock()
- return nil
- }
- // 清除错误后,同时清除 token 缓存
- if h.tokenCacheInvalidator != nil && account.IsOAuth() {
- if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(gctx, account); invalidateErr != nil {
- log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", accountID, invalidateErr)
- }
- }
- mu.Lock()
- successCount++
- mu.Unlock()
- return nil
- })
- }
- if err := g.Wait(); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{
- "total": len(req.AccountIDs),
- "success": successCount,
- "failed": failedCount,
- "errors": errors,
- })
- }
- // BatchRefresh handles batch refreshing account credentials
- // POST /api/v1/admin/accounts/batch-refresh
- func (h *AccountHandler) BatchRefresh(c *gin.Context) {
- var req struct {
- AccountIDs []int64 `json:"account_ids"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if len(req.AccountIDs) == 0 {
- response.BadRequest(c, "account_ids is required")
- return
- }
- ctx := c.Request.Context()
- accounts, err := h.adminService.GetAccountsByIDs(ctx, req.AccountIDs)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // 建立已获取账号的 ID 集合,检测缺失的 ID
- foundIDs := make(map[int64]bool, len(accounts))
- for _, acc := range accounts {
- if acc != nil {
- foundIDs[acc.ID] = true
- }
- }
- const maxConcurrency = 10
- g, gctx := errgroup.WithContext(ctx)
- g.SetLimit(maxConcurrency)
- var mu sync.Mutex
- var successCount, failedCount int
- var errors []gin.H
- var warnings []gin.H
- // 将不存在的账号 ID 标记为失败
- for _, id := range req.AccountIDs {
- if !foundIDs[id] {
- failedCount++
- errors = append(errors, gin.H{
- "account_id": id,
- "error": "account not found",
- })
- }
- }
- // 注意:所有 goroutine 必须 return nil,避免 errgroup cancel 其他并发任务
- for _, account := range accounts {
- acc := account // 闭包捕获
- if acc == nil {
- continue
- }
- g.Go(func() error {
- _, warning, err := h.refreshSingleAccount(gctx, acc)
- mu.Lock()
- if err != nil {
- failedCount++
- errors = append(errors, gin.H{
- "account_id": acc.ID,
- "error": err.Error(),
- })
- } else {
- successCount++
- if warning != "" {
- warnings = append(warnings, gin.H{
- "account_id": acc.ID,
- "warning": warning,
- })
- }
- }
- mu.Unlock()
- return nil
- })
- }
- if err := g.Wait(); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{
- "total": len(req.AccountIDs),
- "success": successCount,
- "failed": failedCount,
- "errors": errors,
- "warnings": warnings,
- })
- }
- // BatchCreate handles batch creating accounts
- // POST /api/v1/admin/accounts/batch
- func (h *AccountHandler) BatchCreate(c *gin.Context) {
- var req struct {
- Accounts []CreateAccountRequest `json:"accounts" binding:"required,min=1"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- for _, item := range req.Accounts {
- if err := service.ValidateOpenAILongContextBillingExtra(item.Platform, item.Extra); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- }
- groupIDs := make([]int64, 0)
- for _, item := range req.Accounts {
- groupIDs = append(groupIDs, item.GroupIDs...)
- }
- if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
- success := 0
- failed := 0
- results := make([]gin.H, 0, len(req.Accounts))
- // 收集需要异步设置隐私的 OAuth 账号
- var antigravityPrivacyAccounts []*service.Account
- var openaiPrivacyAccounts []*service.Account
- for _, item := range req.Accounts {
- if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
- failed++
- results = append(results, gin.H{
- "name": item.Name,
- "success": false,
- "error": "rate_multiplier must be >= 0",
- })
- continue
- }
- // base_rpm 输入校验:负值归零,超过 10000 截断
- sanitizeExtraBaseRPM(item.Extra)
- if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil {
- failed++
- results = append(results, gin.H{
- "name": item.Name,
- "success": false,
- "error": err.Error(),
- })
- continue
- }
- skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk
- account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
- Name: item.Name,
- Notes: item.Notes,
- Platform: item.Platform,
- Type: item.Type,
- Credentials: item.Credentials,
- Extra: item.Extra,
- ProxyID: item.ProxyID,
- Concurrency: item.Concurrency,
- Priority: item.Priority,
- RateMultiplier: item.RateMultiplier,
- GroupIDs: item.GroupIDs,
- ExpiresAt: item.ExpiresAt,
- AutoPauseOnExpired: item.AutoPauseOnExpired,
- SkipMixedChannelCheck: skipCheck,
- })
- if err != nil {
- failed++
- results = append(results, gin.H{
- "name": item.Name,
- "success": false,
- "error": err.Error(),
- })
- continue
- }
- // 收集需要异步设置隐私的 OAuth 账号
- if account.Type == service.AccountTypeOAuth {
- switch account.Platform {
- case service.PlatformAntigravity:
- antigravityPrivacyAccounts = append(antigravityPrivacyAccounts, account)
- case service.PlatformOpenAI:
- openaiPrivacyAccounts = append(openaiPrivacyAccounts, account)
- }
- }
- // OpenAI APIKey 账号异步探测 /v1/responses 能力。
- h.scheduleOpenAIResponsesProbe(account)
- h.scheduleGrokImportProbe(account)
- success++
- results = append(results, gin.H{
- "name": item.Name,
- "id": account.ID,
- "success": true,
- })
- }
- // 异步设置隐私,避免批量创建时阻塞请求
- adminSvc := h.adminService
- if len(antigravityPrivacyAccounts) > 0 {
- accounts := antigravityPrivacyAccounts
- go func() {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("batch_create_antigravity_privacy_panic", "recover", r)
- }
- }()
- bgCtx := context.Background()
- for _, acc := range accounts {
- adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
- }
- }()
- }
- if len(openaiPrivacyAccounts) > 0 {
- accounts := openaiPrivacyAccounts
- go func() {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("batch_create_openai_privacy_panic", "recover", r)
- }
- }()
- bgCtx := context.Background()
- for _, acc := range accounts {
- adminSvc.ForceOpenAIPrivacy(bgCtx, acc)
- }
- }()
- }
- return gin.H{
- "success": success,
- "failed": failed,
- "results": results,
- }, nil
- })
- }
- // BatchUpdateCredentialsRequest represents batch credentials update request
- type BatchUpdateCredentialsRequest struct {
- AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
- Field string `json:"field" binding:"required,oneof=account_uuid org_uuid intercept_warmup_requests"`
- Value any `json:"value"`
- }
- // BatchUpdateCredentials handles batch updating credentials fields
- // POST /api/v1/admin/accounts/batch-update-credentials
- func (h *AccountHandler) BatchUpdateCredentials(c *gin.Context) {
- var req BatchUpdateCredentialsRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- // Validate value type based on field
- if req.Field == "intercept_warmup_requests" {
- // Must be boolean
- if _, ok := req.Value.(bool); !ok {
- response.BadRequest(c, "intercept_warmup_requests must be boolean")
- return
- }
- } else {
- // account_uuid and org_uuid can be string or null
- if req.Value != nil {
- if _, ok := req.Value.(string); !ok {
- response.BadRequest(c, req.Field+" must be string or null")
- return
- }
- }
- }
- ctx := c.Request.Context()
- // 阶段一:预验证所有账号存在,收集 credentials
- type accountUpdate struct {
- ID int64
- Credentials map[string]any
- }
- updates := make([]accountUpdate, 0, len(req.AccountIDs))
- for _, accountID := range req.AccountIDs {
- account, err := h.adminService.GetAccount(ctx, accountID)
- if err != nil {
- response.Error(c, 404, fmt.Sprintf("Account %d not found", accountID))
- return
- }
- if account.Credentials == nil {
- account.Credentials = make(map[string]any)
- }
- account.Credentials[req.Field] = req.Value
- updates = append(updates, accountUpdate{ID: accountID, Credentials: account.Credentials})
- }
- // 阶段二:依次更新,返回每个账号的成功/失败明细,便于调用方重试
- success := 0
- failed := 0
- successIDs := make([]int64, 0, len(updates))
- failedIDs := make([]int64, 0, len(updates))
- results := make([]gin.H, 0, len(updates))
- for _, u := range updates {
- updateInput := &service.UpdateAccountInput{Credentials: u.Credentials}
- if _, err := h.adminService.UpdateAccount(ctx, u.ID, updateInput); err != nil {
- failed++
- failedIDs = append(failedIDs, u.ID)
- results = append(results, gin.H{
- "account_id": u.ID,
- "success": false,
- "error": err.Error(),
- })
- continue
- }
- success++
- successIDs = append(successIDs, u.ID)
- results = append(results, gin.H{
- "account_id": u.ID,
- "success": true,
- })
- }
- response.Success(c, gin.H{
- "success": success,
- "failed": failed,
- "success_ids": successIDs,
- "failed_ids": failedIDs,
- "results": results,
- })
- }
- // BulkUpdate handles bulk updating accounts with selected fields/credentials.
- // POST /api/v1/admin/accounts/bulk-update
- func (h *AccountHandler) BulkUpdate(c *gin.Context) {
- var req BulkUpdateAccountsRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
- response.BadRequest(c, "rate_multiplier must be >= 0")
- return
- }
- if len(req.AccountIDs) == 0 && req.Filters == nil {
- response.BadRequest(c, "account_ids or filters is required")
- return
- }
- // base_rpm 输入校验:负值归零,超过 10000 截断
- sanitizeExtraBaseRPM(req.Extra)
- if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // 确定是否跳过混合渠道检查
- skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
- hasUpdates := req.Name != "" ||
- req.ProxyID != nil ||
- req.Concurrency != nil ||
- req.Priority != nil ||
- req.RateMultiplier != nil ||
- req.LoadFactor != nil ||
- req.Status != "" ||
- req.Schedulable != nil ||
- req.GroupIDs != nil ||
- len(req.Credentials) > 0 ||
- len(req.Extra) > 0 ||
- req.ProbeEnabled != nil
- if !hasUpdates {
- response.BadRequest(c, "No updates provided")
- return
- }
- result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
- AccountIDs: req.AccountIDs,
- Filters: toServiceBulkUpdateAccountFilters(req.Filters),
- Name: req.Name,
- ProxyID: req.ProxyID,
- Concurrency: req.Concurrency,
- Priority: req.Priority,
- RateMultiplier: req.RateMultiplier,
- LoadFactor: req.LoadFactor,
- Status: req.Status,
- Schedulable: req.Schedulable,
- GroupIDs: req.GroupIDs,
- Credentials: req.Credentials,
- Extra: req.Extra,
- ProbeEnabled: req.ProbeEnabled,
- SkipMixedChannelCheck: skipCheck,
- })
- if err != nil {
- var mixedErr *service.MixedChannelError
- if errors.As(err, &mixedErr) {
- c.JSON(409, gin.H{
- "error": "mixed_channel_warning",
- "message": mixedErr.Error(),
- "details": gin.H{
- "group_id": mixedErr.GroupID,
- "group_name": mixedErr.GroupName,
- "current_platform": mixedErr.CurrentPlatform,
- "other_platform": mixedErr.OtherPlatform,
- },
- })
- return
- }
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, result)
- }
- func toServiceBulkUpdateAccountFilters(filters *BulkUpdateAccountFilters) *service.BulkUpdateAccountFilters {
- if filters == nil {
- return nil
- }
- return &service.BulkUpdateAccountFilters{
- Platform: filters.Platform,
- Type: filters.Type,
- Status: filters.Status,
- Group: filters.Group,
- Search: filters.Search,
- PrivacyMode: filters.PrivacyMode,
- }
- }
- // ========== OAuth Handlers ==========
- // GenerateAuthURLRequest represents the request for generating auth URL
- type GenerateAuthURLRequest struct {
- ProxyID *int64 `json:"proxy_id"`
- }
- // GenerateAuthURL generates OAuth authorization URL with full scope
- // POST /api/v1/admin/accounts/generate-auth-url
- func (h *OAuthHandler) GenerateAuthURL(c *gin.Context) {
- var req GenerateAuthURLRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- // Allow empty body
- req = GenerateAuthURLRequest{}
- }
- result, err := h.oauthService.GenerateAuthURL(c.Request.Context(), req.ProxyID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, result)
- }
- // GenerateSetupTokenURL generates OAuth authorization URL for setup token (inference only)
- // POST /api/v1/admin/accounts/generate-setup-token-url
- func (h *OAuthHandler) GenerateSetupTokenURL(c *gin.Context) {
- var req GenerateAuthURLRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- // Allow empty body
- req = GenerateAuthURLRequest{}
- }
- result, err := h.oauthService.GenerateSetupTokenURL(c.Request.Context(), req.ProxyID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, result)
- }
- // ExchangeCodeRequest represents the request for exchanging auth code
- type ExchangeCodeRequest struct {
- SessionID string `json:"session_id" binding:"required"`
- Code string `json:"code" binding:"required"`
- ProxyID *int64 `json:"proxy_id"`
- }
- // ExchangeCode exchanges authorization code for tokens
- // POST /api/v1/admin/accounts/exchange-code
- func (h *OAuthHandler) ExchangeCode(c *gin.Context) {
- var req ExchangeCodeRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- tokenInfo, err := h.oauthService.ExchangeCode(c.Request.Context(), &service.ExchangeCodeInput{
- SessionID: req.SessionID,
- Code: req.Code,
- ProxyID: req.ProxyID,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, tokenInfo)
- }
- // ExchangeSetupTokenCode exchanges authorization code for setup token
- // POST /api/v1/admin/accounts/exchange-setup-token-code
- func (h *OAuthHandler) ExchangeSetupTokenCode(c *gin.Context) {
- var req ExchangeCodeRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- tokenInfo, err := h.oauthService.ExchangeCode(c.Request.Context(), &service.ExchangeCodeInput{
- SessionID: req.SessionID,
- Code: req.Code,
- ProxyID: req.ProxyID,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, tokenInfo)
- }
- // CookieAuthRequest represents the request for cookie-based authentication
- type CookieAuthRequest struct {
- SessionKey string `json:"code" binding:"required"` // Using 'code' field as sessionKey (frontend sends it this way)
- ProxyID *int64 `json:"proxy_id"`
- }
- // CookieAuth performs OAuth using sessionKey (cookie-based auto-auth)
- // POST /api/v1/admin/accounts/cookie-auth
- func (h *OAuthHandler) CookieAuth(c *gin.Context) {
- var req CookieAuthRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- tokenInfo, err := h.oauthService.CookieAuth(c.Request.Context(), &service.CookieAuthInput{
- SessionKey: req.SessionKey,
- ProxyID: req.ProxyID,
- Scope: "full",
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, tokenInfo)
- }
- // SetupTokenCookieAuth performs OAuth using sessionKey for setup token (inference only)
- // POST /api/v1/admin/accounts/setup-token-cookie-auth
- func (h *OAuthHandler) SetupTokenCookieAuth(c *gin.Context) {
- var req CookieAuthRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- tokenInfo, err := h.oauthService.CookieAuth(c.Request.Context(), &service.CookieAuthInput{
- SessionKey: req.SessionKey,
- ProxyID: req.ProxyID,
- Scope: "inference",
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, tokenInfo)
- }
- // GetUsage handles getting account usage information
- // GET /api/v1/admin/accounts/:id/usage?source=passive|active&force=true
- func (h *AccountHandler) GetUsage(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- source := c.DefaultQuery("source", "active")
- force := c.Query("force") == "true"
- var usage *service.UsageInfo
- if source == "passive" {
- usage, err = h.accountUsageService.GetPassiveUsage(c.Request.Context(), accountID)
- } else {
- usage, err = h.accountUsageService.GetUsage(c.Request.Context(), accountID, force)
- }
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, usage)
- }
- // ClearRateLimit handles clearing account rate limit status
- // POST /api/v1/admin/accounts/:id/clear-rate-limit
- func (h *AccountHandler) ClearRateLimit(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- err = h.rateLimitService.ClearRateLimit(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
- }
- // ResetQuota handles resetting account quota usage
- // POST /api/v1/admin/accounts/:id/reset-quota
- func (h *AccountHandler) ResetQuota(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- if err := h.adminService.ResetAccountQuota(c.Request.Context(), accountID); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
- }
- // GetTempUnschedulable handles getting temporary unschedulable status
- // GET /api/v1/admin/accounts/:id/temp-unschedulable
- func (h *AccountHandler) GetTempUnschedulable(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- state, err := h.rateLimitService.GetTempUnschedStatus(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if state == nil || state.UntilUnix <= time.Now().Unix() {
- response.Success(c, gin.H{"active": false})
- return
- }
- response.Success(c, gin.H{
- "active": true,
- "state": state,
- })
- }
- // ClearTempUnschedulable handles clearing temporary unschedulable status
- // DELETE /api/v1/admin/accounts/:id/temp-unschedulable
- func (h *AccountHandler) ClearTempUnschedulable(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- if err := h.rateLimitService.ClearTempUnschedulable(c.Request.Context(), accountID); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Temp unschedulable cleared successfully"})
- }
- // GetTodayStats handles getting account today statistics
- // GET /api/v1/admin/accounts/:id/today-stats
- func (h *AccountHandler) GetTodayStats(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- stats, err := h.accountUsageService.GetTodayStats(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, stats)
- }
- // BatchTodayStatsRequest 批量今日统计请求体。
- type BatchTodayStatsRequest struct {
- AccountIDs []int64 `json:"account_ids" binding:"required"`
- }
- type BatchUsageRequest struct {
- AccountIDs []int64 `json:"account_ids" binding:"required"`
- Force bool `json:"force"`
- }
- // GetBatchTodayStats 批量获取多个账号的今日统计。
- // POST /api/v1/admin/accounts/today-stats/batch
- func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) {
- var req BatchTodayStatsRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- accountIDs := normalizeInt64IDList(req.AccountIDs)
- if len(accountIDs) == 0 {
- response.Success(c, gin.H{"stats": map[string]any{}})
- return
- }
- cacheKey := buildAccountTodayStatsBatchCacheKey(accountIDs)
- if cached, ok := accountTodayStatsBatchCache.Get(cacheKey); ok {
- if cached.ETag != "" {
- c.Header("ETag", cached.ETag)
- c.Header("Vary", "If-None-Match")
- if ifNoneMatchMatched(c.GetHeader("If-None-Match"), cached.ETag) {
- c.Status(http.StatusNotModified)
- return
- }
- }
- c.Header("X-Snapshot-Cache", "hit")
- response.Success(c, cached.Payload)
- return
- }
- stats, err := h.accountUsageService.GetTodayStatsBatch(c.Request.Context(), accountIDs)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- payload := gin.H{"stats": stats}
- cached := accountTodayStatsBatchCache.Set(cacheKey, payload)
- if cached.ETag != "" {
- c.Header("ETag", cached.ETag)
- c.Header("Vary", "If-None-Match")
- }
- c.Header("X-Snapshot-Cache", "miss")
- response.Success(c, payload)
- }
- // GetBatchUsage 批量获取多个账号的 current usage。
- // POST /api/v1/admin/accounts/usage/batch
- func (h *AccountHandler) GetBatchUsage(c *gin.Context) {
- var req BatchUsageRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- accountIDs := normalizeInt64IDList(req.AccountIDs)
- if len(accountIDs) == 0 {
- response.Success(c, gin.H{
- "usage": map[string]any{},
- "errors": map[string]string{},
- })
- return
- }
- usageByAccount, errorsByAccount, err := h.accountUsageService.GetUsageBatch(c.Request.Context(), accountIDs, req.Force)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{
- "usage": usageByAccount,
- "errors": errorsByAccount,
- })
- }
- // SetSchedulableRequest represents the request body for setting schedulable status
- type SetSchedulableRequest struct {
- Schedulable bool `json:"schedulable"`
- }
- // SetSchedulable handles toggling account schedulable status
- // POST /api/v1/admin/accounts/:id/schedulable
- func (h *AccountHandler) SetSchedulable(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- var req SetSchedulableRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- account, err := h.adminService.SetAccountSchedulable(c.Request.Context(), accountID, req.Schedulable)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
- }
- // GetAvailableModels handles getting available models for an account
- // GET /api/v1/admin/accounts/:id/models
- func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.NotFound(c, "Account not found")
- return
- }
- // Handle OpenAI accounts
- if account.IsOpenAI() {
- // Prefer the shared, account-keyed upstream catalog. If discovery fails,
- // retain the legacy local catalog below so the test dialog remains usable.
- if h.accountTestService != nil {
- if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil {
- response.Success(c, models)
- return
- }
- }
- // OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
- if account.IsOpenAIPassthroughEnabled() {
- response.Success(c, openai.DefaultModels)
- return
- }
- mapping := account.GetModelMapping()
- if len(mapping) == 0 {
- response.Success(c, openai.DefaultModels)
- return
- }
- // Return mapped models
- var models []openai.Model
- for requestedModel := range mapping {
- var found bool
- for _, dm := range openai.DefaultModels {
- if dm.ID == requestedModel {
- models = append(models, dm)
- found = true
- break
- }
- }
- if !found {
- models = append(models, openai.Model{
- ID: requestedModel,
- Object: "model",
- Type: "model",
- DisplayName: requestedModel,
- })
- }
- }
- response.Success(c, models)
- return
- }
- // Handle Gemini accounts
- if account.IsGemini() {
- // Consumer Google One OAuth still uses the legacy Gemini CLI / Code
- // Assist channel. Do not advertise newer 3.x or image models that the
- // channel cannot serve.
- if account.IsOAuth() {
- if account.IsGeminiGoogleOne() {
- response.Success(c, geminicli.GoogleOneModels)
- return
- }
- response.Success(c, geminicli.DefaultModels)
- return
- }
- // For API Key accounts: return models based on model_mapping
- mapping := account.GetModelMapping()
- if len(mapping) == 0 {
- response.Success(c, geminicli.DefaultModels)
- return
- }
- var models []geminicli.Model
- for requestedModel := range mapping {
- var found bool
- for _, dm := range geminicli.DefaultModels {
- if dm.ID == requestedModel {
- models = append(models, dm)
- found = true
- break
- }
- }
- if !found {
- models = append(models, geminicli.Model{
- ID: requestedModel,
- Type: "model",
- DisplayName: requestedModel,
- CreatedAt: "",
- })
- }
- }
- response.Success(c, models)
- return
- }
- // Handle Antigravity accounts: return Claude + Gemini models
- if account.Platform == service.PlatformAntigravity {
- // 直接复用 antigravity.DefaultModels(),与 /v1/models 端点保持同步
- response.Success(c, antigravity.DefaultModels())
- return
- }
- // Handle Grok accounts
- if account.Platform == service.PlatformGrok {
- defaultModels := xai.DefaultModels()
- hasExplicitMapping := false
- switch rawMapping := account.Credentials["model_mapping"].(type) {
- case map[string]any:
- hasExplicitMapping = len(rawMapping) > 0
- case map[string]string:
- hasExplicitMapping = len(rawMapping) > 0
- }
- if !hasExplicitMapping {
- response.Success(c, defaultModels)
- return
- }
- mapping := account.GetModelMapping()
- if len(mapping) == 0 {
- response.Success(c, defaultModels)
- return
- }
- defaultByID := make(map[string]xai.Model, len(defaultModels))
- for _, model := range defaultModels {
- defaultByID[model.ID] = model
- }
- requestedModels := make([]string, 0, len(mapping))
- for requestedModel := range mapping {
- requestedModels = append(requestedModels, requestedModel)
- }
- sort.Strings(requestedModels)
- var models []xai.Model
- for _, requestedModel := range requestedModels {
- if defaultModel, found := defaultByID[requestedModel]; found {
- models = append(models, defaultModel)
- continue
- }
- models = append(models, xai.Model{
- ID: requestedModel,
- Object: "model",
- OwnedBy: "xai",
- DisplayName: requestedModel,
- })
- }
- response.Success(c, models)
- return
- }
- // Handle Claude/Anthropic accounts
- // For OAuth and Setup-Token accounts: return default models
- if account.IsOAuth() {
- response.Success(c, claude.DefaultModels)
- return
- }
- // For API Key accounts: return models based on model_mapping
- mapping := account.GetModelMapping()
- if len(mapping) == 0 {
- // No mapping configured, return default models
- response.Success(c, claude.DefaultModels)
- return
- }
- // Return mapped models (keys of the mapping are the available model IDs)
- var models []claude.Model
- for requestedModel := range mapping {
- // Try to find display info from default models
- var found bool
- for _, dm := range claude.DefaultModels {
- if dm.ID == requestedModel {
- models = append(models, dm)
- found = true
- break
- }
- }
- // If not found in defaults, create a basic entry
- if !found {
- models = append(models, claude.Model{
- ID: requestedModel,
- Type: "model",
- DisplayName: requestedModel,
- CreatedAt: "",
- })
- }
- }
- response.Success(c, models)
- }
- // SyncUpstreamModels handles syncing live supported models from an account's upstream.
- // POST /api/v1/admin/accounts/:id/models/sync-upstream
- func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.NotFound(c, "Account not found")
- return
- }
- if h.accountTestService == nil {
- response.InternalError(c, "Account test service is not configured")
- return
- }
- catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account)
- if err != nil {
- var syncErr *service.UpstreamModelSyncError
- if errors.As(err, &syncErr) {
- switch syncErr.Kind {
- case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
- response.BadRequest(c, syncErr.SafeMessage())
- case service.UpstreamModelSyncErrorInternal:
- response.InternalError(c, syncErr.SafeMessage())
- default:
- slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
- response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
- }
- return
- }
- slog.Warn("sync_upstream_models_failed", "account_id", accountID)
- response.Error(c, http.StatusBadGateway, "Failed to sync upstream models from upstream")
- return
- }
- response.Success(c, catalog)
- }
- // SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
- // POST /api/v1/admin/accounts/models/sync-upstream-preview
- func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
- var req struct {
- Platform string `json:"platform" binding:"required"`
- Type string `json:"type" binding:"required"`
- BaseURL string `json:"base_url"`
- APIKey string `json:"api_key" binding:"required"`
- ModelMapping map[string]string `json:"model_mapping"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- modelMapping := make(map[string]any, len(req.ModelMapping))
- for sourceModel, upstreamModel := range req.ModelMapping {
- modelMapping[sourceModel] = upstreamModel
- }
- tempAccount := &service.Account{
- Platform: req.Platform,
- Type: req.Type,
- Credentials: map[string]any{
- "api_key": req.APIKey,
- "base_url": req.BaseURL,
- "model_mapping": modelMapping,
- },
- }
- if h.accountTestService == nil {
- response.InternalError(c, "Account test service is not configured")
- return
- }
- catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount)
- if err != nil {
- var syncErr *service.UpstreamModelSyncError
- if errors.As(err, &syncErr) {
- switch syncErr.Kind {
- case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
- response.BadRequest(c, syncErr.SafeMessage())
- case service.UpstreamModelSyncErrorInternal:
- response.InternalError(c, syncErr.SafeMessage())
- default:
- slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
- response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
- }
- return
- }
- slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform)
- response.Error(c, http.StatusBadGateway, "Failed to sync upstream models from upstream")
- return
- }
- response.Success(c, catalog)
- }
- // SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
- // POST /api/v1/admin/accounts/:id/set-privacy
- func (h *AccountHandler) SetPrivacy(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.NotFound(c, "Account not found")
- return
- }
- if account.Type != service.AccountTypeOAuth {
- response.BadRequest(c, "Only OAuth accounts support privacy setting")
- return
- }
- var mode string
- switch account.Platform {
- case service.PlatformOpenAI:
- mode = h.adminService.ForceOpenAIPrivacy(c.Request.Context(), account)
- case service.PlatformAntigravity:
- mode = h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account)
- default:
- response.BadRequest(c, "Only OpenAI and Antigravity OAuth accounts support privacy setting")
- return
- }
- if mode == "" {
- response.BadRequest(c, "Cannot set privacy: missing access_token")
- return
- }
- // 从 DB 重新读取以确保返回最新状态
- updated, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- // 隐私已设置成功但读取失败,回退到内存更新
- if account.Extra == nil {
- account.Extra = make(map[string]any)
- }
- account.Extra["privacy_mode"] = mode
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
- return
- }
- response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updated))
- }
- // RefreshTier handles refreshing Google One tier for a single account
- // POST /api/v1/admin/accounts/:id/refresh-tier
- func (h *AccountHandler) RefreshTier(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
- ctx := c.Request.Context()
- account, err := h.adminService.GetAccount(ctx, accountID)
- if err != nil {
- response.NotFound(c, "Account not found")
- return
- }
- if account.Platform != service.PlatformGemini || account.Type != service.AccountTypeOAuth {
- response.BadRequest(c, "Only Gemini OAuth accounts support tier refresh")
- return
- }
- oauthType, _ := account.Credentials["oauth_type"].(string)
- if oauthType != "google_one" {
- response.BadRequest(c, "Only google_one OAuth accounts support tier refresh")
- return
- }
- tierID, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(ctx, account)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- _, updateErr := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{
- Credentials: creds,
- Extra: extra,
- })
- if updateErr != nil {
- response.ErrorFrom(c, updateErr)
- return
- }
- response.Success(c, gin.H{
- "tier_id": tierID,
- "storage_info": extra,
- "drive_storage_limit": extra["drive_storage_limit"],
- "drive_storage_usage": extra["drive_storage_usage"],
- "updated_at": extra["drive_tier_updated_at"],
- })
- }
- // BatchRefreshTierRequest represents batch tier refresh request
- type BatchRefreshTierRequest struct {
- AccountIDs []int64 `json:"account_ids"`
- }
- // BatchRefreshTier handles batch refreshing Google One tier
- // POST /api/v1/admin/accounts/batch-refresh-tier
- func (h *AccountHandler) BatchRefreshTier(c *gin.Context) {
- var req BatchRefreshTierRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- req = BatchRefreshTierRequest{}
- }
- ctx := c.Request.Context()
- accounts := make([]*service.Account, 0)
- if len(req.AccountIDs) == 0 {
- allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "", 0, "", "name", "asc")
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- for i := range allAccounts {
- acc := &allAccounts[i]
- oauthType, _ := acc.Credentials["oauth_type"].(string)
- if oauthType == "google_one" {
- accounts = append(accounts, acc)
- }
- }
- } else {
- fetched, err := h.adminService.GetAccountsByIDs(ctx, req.AccountIDs)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- for _, acc := range fetched {
- if acc == nil {
- continue
- }
- if acc.Platform != service.PlatformGemini || acc.Type != service.AccountTypeOAuth {
- continue
- }
- oauthType, _ := acc.Credentials["oauth_type"].(string)
- if oauthType != "google_one" {
- continue
- }
- accounts = append(accounts, acc)
- }
- }
- const maxConcurrency = 10
- g, gctx := errgroup.WithContext(ctx)
- g.SetLimit(maxConcurrency)
- var mu sync.Mutex
- var successCount, failedCount int
- var errors []gin.H
- for _, account := range accounts {
- acc := account // 闭包捕获
- g.Go(func() error {
- _, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(gctx, acc)
- if err != nil {
- mu.Lock()
- failedCount++
- errors = append(errors, gin.H{
- "account_id": acc.ID,
- "error": err.Error(),
- })
- mu.Unlock()
- return nil
- }
- _, updateErr := h.adminService.UpdateAccount(gctx, acc.ID, &service.UpdateAccountInput{
- Credentials: creds,
- Extra: extra,
- })
- mu.Lock()
- if updateErr != nil {
- failedCount++
- errors = append(errors, gin.H{
- "account_id": acc.ID,
- "error": updateErr.Error(),
- })
- } else {
- successCount++
- }
- mu.Unlock()
- return nil
- })
- }
- if err := g.Wait(); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- results := gin.H{
- "total": len(accounts),
- "success": successCount,
- "failed": failedCount,
- "errors": errors,
- }
- response.Success(c, results)
- }
- // GetAntigravityDefaultModelMapping 获取 Antigravity 平台的默认模型映射
- // GET /api/v1/admin/accounts/antigravity/default-model-mapping
- func (h *AccountHandler) GetAntigravityDefaultModelMapping(c *gin.Context) {
- response.Success(c, domain.DefaultAntigravityModelMapping)
- }
- // sanitizeExtraBaseRPM 对 extra map 中的 base_rpm 值进行范围校验和归一化。
- // 负值归零,超过 10000 截断为 10000。extra 为 nil 或不含 base_rpm 时无操作。
- func sanitizeExtraBaseRPM(extra map[string]any) {
- if extra == nil {
- return
- }
- raw, ok := extra["base_rpm"]
- if !ok {
- return
- }
- v := service.ParseExtraInt(raw)
- if v < 0 {
- v = 0
- } else if v > 10000 {
- v = 10000
- }
- extra["base_rpm"] = v
- }
|