@@ -29,15 +29,21 @@ const sessionDuration = 24 * time.Hour
2929// adminMaxBody caps JSON request bodies handled by the admin API.
3030const adminMaxBody = 1 << 16 // 64 KiB
3131
32+ // AccountChecker probes one PAT's real allowance through a throwaway bridge
33+ // (full session bootstrap) and returns the quota snapshot. Implemented by the
34+ // caller (main package) — the admin package stays free of bridge wiring.
35+ type AccountChecker func (ctx context.Context , pat string ) (* store.AccountSnapshot , error )
36+
3237// Admin manages the web UI and admin API endpoints.
3338type Admin struct {
34- store * store.Store
35- modelFetcher ModelFetcher
36- stats * stats.Recorder
37- logRec * logs.Recorder
38- sessions map [string ]time.Time
39- sessionMu sync.Mutex
40- limiter * loginLimiter
39+ store * store.Store
40+ modelFetcher ModelFetcher
41+ stats * stats.Recorder
42+ logRec * logs.Recorder
43+ accountChecker AccountChecker
44+ sessions map [string ]time.Time
45+ sessionMu sync.Mutex
46+ limiter * loginLimiter
4147}
4248
4349// ModelFetcher returns the current model catalog (dynamic or fallback).
@@ -55,6 +61,12 @@ func New(s *store.Store, mf ModelFetcher, rec *stats.Recorder, lr *logs.Recorder
5561 }
5662}
5763
64+ // SetAccountChecker installs the account quota probe used by
65+ // /admin/api/accounts/check. Wiring happens before the server starts serving.
66+ func (a * Admin ) SetAccountChecker (fn AccountChecker ) {
67+ a .accountChecker = fn
68+ }
69+
5870// RegisterRoutes registers all admin API routes on the given mux.
5971//
6072// The WebUI itself is served by the web package (pages under /admin, assets
@@ -69,6 +81,10 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
6981 // Protected API endpoints (require auth)
7082 mux .HandleFunc ("/admin/api/keys" , a .requireAuth (a .handleKeys ))
7183 mux .HandleFunc ("/admin/api/pat" , a .requireAuth (a .handlePAT ))
84+ mux .HandleFunc ("/admin/api/accounts" , a .requireAuth (a .handleAccounts ))
85+ mux .HandleFunc ("/admin/api/accounts/switch" , a .requireAuth (a .handleAccountSwitch ))
86+ mux .HandleFunc ("/admin/api/accounts/note" , a .requireAuth (a .handleAccountNote ))
87+ mux .HandleFunc ("/admin/api/accounts/check" , a .requireAuth (a .handleAccountCheck ))
7288 mux .HandleFunc ("/admin/api/models" , a .requireAuth (a .handleModels ))
7389 mux .HandleFunc ("/admin/api/stats" , a .requireAuth (a .handleStats ))
7490 mux .HandleFunc ("/admin/api/logs" , a .requireAuth (a .handleLogs ))
@@ -600,16 +616,222 @@ func (a *Admin) handlePAT(w http.ResponseWriter, r *http.Request) {
600616 writeJSONError (w , 400 , "请求格式错误" )
601617 return
602618 }
619+ // Store first, then align the account pool with whatever the admin PAT
620+ // form submitted (the mirror write stays low-level): a PAT already in
621+ // the pool is activated, a new one is added+activated, and clearing the
622+ // PAT always deactivates (an empty mirror must not coexist with an
623+ // active pool entry — re-activation is one click on the account page).
624+ oldPAT := a .store .GetPAT ()
603625 if err := a .store .SetPAT (body .PAT ); err != nil {
604626 writeJSONError (w , 500 , err .Error ())
605627 return
606628 }
629+ switch {
630+ case body .PAT == "" :
631+ if a .store .GetActiveAccountID () != "" {
632+ a .store .ClearActiveAccount ()
633+ }
634+ case body .PAT != oldPAT :
635+ a .ensureAccount (body .PAT )
636+ }
607637 writeJSON (w , 200 , map [string ]string {"status" : "ok" })
608638 default :
609639 writeJSONError (w , 405 , "方法不允许" )
610640 }
611641}
612642
643+ // --- Account endpoints ---
644+
645+ // accountView is the JSON shape of one pool entry. The plaintext PAT never
646+ // leaves the server: the response carries the same masked form as the legacy
647+ // PAT endpoint plus the id used by switch/delete.
648+ type accountView struct {
649+ ID string `json:"id"`
650+ PATMasked string `json:"pat_masked"`
651+ Note string `json:"note"`
652+ CreatedAt int64 `json:"created_at"`
653+ Active bool `json:"active"`
654+ Snapshot * store.AccountSnapshot `json:"snapshot,omitempty"`
655+ }
656+
657+ // accountViews maps the pool to its wire form, marking the active entry.
658+ func accountViews (accounts []store.Account , activeID string ) []accountView {
659+ out := make ([]accountView , 0 , len (accounts ))
660+ for i := range accounts {
661+ acc := accounts [i ]
662+ out = append (out , accountView {
663+ ID : acc .ID ,
664+ PATMasked : maskPAT (acc .PAT ),
665+ Note : acc .Note ,
666+ CreatedAt : acc .CreatedAt ,
667+ Active : acc .ID == activeID ,
668+ Snapshot : acc .Snapshot ,
669+ })
670+ }
671+ return out
672+ }
673+
674+ // handleAccounts serves GET/POST/DELETE /admin/api/accounts: the multi-account
675+ // pool CRUD. POST is idempotent on PAT — re-submitting an existing token just
676+ // activates it, so the old single-PAT panel flow keeps working against this
677+ // endpoint too.
678+ func (a * Admin ) handleAccounts (w http.ResponseWriter , r * http.Request ) {
679+ switch r .Method {
680+ case http .MethodGet :
681+ writeJSON (w , 200 , map [string ]interface {}{"accounts" : accountViews (a .store .ListAccounts (), a .store .GetActiveAccountID ())})
682+ case http .MethodPost :
683+ var body struct {
684+ PAT string `json:"pat"`
685+ Note string `json:"note"`
686+ }
687+ if err := readJSON (w , r , & body ); err != nil {
688+ writeJSONError (w , 400 , "请求格式错误" )
689+ return
690+ }
691+ if a .activateExisting (body .PAT ) {
692+ writeJSON (w , 200 , map [string ]string {"status" : "switched" })
693+ return
694+ }
695+ entry , err := a .store .AddAccount (body .PAT , body .Note )
696+ if err != nil {
697+ writeJSONError (w , http .StatusConflict , err .Error ())
698+ return
699+ }
700+ writeJSON (w , 201 , entry )
701+ case http .MethodDelete :
702+ id := r .URL .Query ().Get ("id" )
703+ if id == "" {
704+ writeJSONError (w , 400 , "缺少 id 参数" )
705+ return
706+ }
707+ if err := a .store .DeleteAccount (id ); err != nil {
708+ if errors .Is (err , store .ErrActiveAccount ) {
709+ writeJSONError (w , 400 , err .Error ())
710+ } else {
711+ writeJSONError (w , 404 , err .Error ())
712+ }
713+ return
714+ }
715+ writeJSON (w , 200 , map [string ]string {"status" : "deleted" })
716+ default :
717+ writeJSONError (w , 405 , "方法不允许" )
718+ }
719+ }
720+
721+ // activateExisting switches the active account to an already-pooled PAT.
722+ // Returns false when pat is not in the pool (caller then adds it).
723+ func (a * Admin ) activateExisting (pat string ) bool {
724+ if pat == "" {
725+ return false
726+ }
727+ for _ , acc := range a .store .ListAccounts () {
728+ if acc .PAT == pat {
729+ _ = a .store .ActivateAccount (acc .ID )
730+ return true
731+ }
732+ }
733+ return false
734+ }
735+
736+ // ensureAccount aligns the pool after a legacy PAT write: an already-pooled
737+ // PAT becomes active, a new one is added and activated. Used by the legacy
738+ // /admin/api/pat endpoint so the pool can never silently diverge from the
739+ // mirrored PAT field.
740+ func (a * Admin ) ensureAccount (pat string ) {
741+ if a .activateExisting (pat ) {
742+ return
743+ }
744+ _ , _ = a .store .AddAccount (pat , "" )
745+ }
746+
747+ // handleAccountSwitch serves POST /admin/api/accounts/switch {id}: explicit
748+ // account switch from the panel.
749+ func (a * Admin ) handleAccountSwitch (w http.ResponseWriter , r * http.Request ) {
750+ if r .Method != http .MethodPost {
751+ writeJSONError (w , 405 , "方法不允许" )
752+ return
753+ }
754+ var body struct {
755+ ID string `json:"id"`
756+ }
757+ if err := readJSON (w , r , & body ); err != nil {
758+ writeJSONError (w , 400 , "请求格式错误" )
759+ return
760+ }
761+ if err := a .store .ActivateAccount (body .ID ); err != nil {
762+ writeJSONError (w , 404 , err .Error ())
763+ return
764+ }
765+ writeJSON (w , 200 , map [string ]string {"status" : "ok" })
766+ }
767+
768+ // handleAccountNote serves POST /admin/api/accounts/note {id, note}: renames
769+ // an account's note (separate endpoint so the account CRUD bodies stay small).
770+ func (a * Admin ) handleAccountNote (w http.ResponseWriter , r * http.Request ) {
771+ if r .Method != http .MethodPost {
772+ writeJSONError (w , 405 , "方法不允许" )
773+ return
774+ }
775+ var body struct {
776+ ID string `json:"id"`
777+ Note string `json:"note"`
778+ }
779+ if err := readJSON (w , r , & body ); err != nil {
780+ writeJSONError (w , 400 , "请求格式错误" )
781+ return
782+ }
783+ if err := a .store .UpdateAccountNote (body .ID , body .Note ); err != nil {
784+ writeJSONError (w , 404 , err .Error ())
785+ return
786+ }
787+ writeJSON (w , 200 , map [string ]string {"status" : "ok" })
788+ }
789+
790+ // handleAccountCheck serves POST /admin/api/accounts/check {id?}: probes one
791+ // account (default: the active one) through a throwaway bridge and stores the
792+ // returned quota snapshot. This is the only account-side upstream call —
793+ // deliberately on-demand, since every check costs a full session bootstrap.
794+ func (a * Admin ) handleAccountCheck (w http.ResponseWriter , r * http.Request ) {
795+ if r .Method != http .MethodPost {
796+ writeJSONError (w , 405 , "方法不允许" )
797+ return
798+ }
799+ var body struct {
800+ ID string `json:"id"`
801+ }
802+ _ = readJSON (w , r , & body ) // body optional: default to the active account
803+ id := body .ID
804+ if id == "" {
805+ id = a .store .GetActiveAccountID ()
806+ }
807+ var target * store.Account
808+ for _ , acc := range a .store .ListAccounts () {
809+ if acc .ID == id {
810+ target = & acc
811+ break
812+ }
813+ }
814+ if target == nil {
815+ writeJSONError (w , 404 , "账号不存在" )
816+ return
817+ }
818+ if a .accountChecker == nil {
819+ writeJSONError (w , 503 , "检测功能不可用" )
820+ return
821+ }
822+ snap , err := a .accountChecker (r .Context (), target .PAT )
823+ if err != nil {
824+ _ = a .store .SetAccountSnapshot (target .ID , & store.AccountSnapshot {CheckedAt : time .Now ().Unix (), LastError : err .Error ()})
825+ writeJSONError (w , 502 , "检测失败: " + err .Error ())
826+ return
827+ }
828+ if err := a .store .SetAccountSnapshot (target .ID , snap ); err != nil {
829+ writeJSONError (w , 500 , err .Error ())
830+ return
831+ }
832+ writeJSON (w , 200 , map [string ]interface {}{"snapshot" : snap })
833+ }
834+
613835// --- Models endpoint ---
614836
615837func (a * Admin ) handleModels (w http.ResponseWriter , r * http.Request ) {
0 commit comments