88 "encoding/json"
99 "errors"
1010 "fmt"
11+ "log"
1112 "net/http"
1213 "strconv"
1314 "strings"
@@ -18,6 +19,7 @@ import (
1819 "github.com/Wei-Shaw/sub2api/internal/handler/dto"
1920 "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
2021 "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
22+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
2123 "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
2224 "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
2325 "github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -751,52 +753,31 @@ func (h *AccountHandler) PreviewFromCRS(c *gin.Context) {
751753 response .Success (c , result )
752754}
753755
754- // Refresh handles refreshing account credentials
755- // POST /api/v1/admin/accounts/:id/refresh
756- func (h * AccountHandler ) Refresh (c * gin.Context ) {
757- accountID , err := strconv .ParseInt (c .Param ("id" ), 10 , 64 )
758- if err != nil {
759- response .BadRequest (c , "Invalid account ID" )
760- return
761- }
762-
763- // Get account
764- account , err := h .adminService .GetAccount (c .Request .Context (), accountID )
765- if err != nil {
766- response .NotFound (c , "Account not found" )
767- return
768- }
769-
770- // Only refresh OAuth-based accounts (oauth and setup-token)
756+ // refreshSingleAccount refreshes credentials for a single OAuth account.
757+ // Returns (updatedAccount, warning, error) where warning is used for Antigravity ProjectIDMissing scenario.
758+ func (h * AccountHandler ) refreshSingleAccount (ctx context.Context , account * service.Account ) (* service.Account , string , error ) {
771759 if ! account .IsOAuth () {
772- response .BadRequest (c , "Cannot refresh non-OAuth account credentials" )
773- return
760+ return nil , "" , infraerrors .BadRequest ("NOT_OAUTH" , "cannot refresh non-OAuth account" )
774761 }
775762
776763 var newCredentials map [string ]any
777764
778765 if account .IsOpenAI () {
779- // Use OpenAI OAuth service to refresh token
780- tokenInfo , err := h .openaiOAuthService .RefreshAccountToken (c .Request .Context (), account )
766+ tokenInfo , err := h .openaiOAuthService .RefreshAccountToken (ctx , account )
781767 if err != nil {
782- response .ErrorFrom (c , err )
783- return
768+ return nil , "" , err
784769 }
785770
786- // Build new credentials from token info
787771 newCredentials = h .openaiOAuthService .BuildAccountCredentials (tokenInfo )
788-
789- // Preserve non-token settings from existing credentials
790772 for k , v := range account .Credentials {
791773 if _ , exists := newCredentials [k ]; ! exists {
792774 newCredentials [k ] = v
793775 }
794776 }
795777 } else if account .Platform == service .PlatformGemini {
796- tokenInfo , err := h .geminiOAuthService .RefreshAccountToken (c . Request . Context () , account )
778+ tokenInfo , err := h .geminiOAuthService .RefreshAccountToken (ctx , account )
797779 if err != nil {
798- response .InternalError (c , "Failed to refresh credentials: " + err .Error ())
799- return
780+ return nil , "" , fmt .Errorf ("failed to refresh credentials: %w" , err )
800781 }
801782
802783 newCredentials = h .geminiOAuthService .BuildAccountCredentials (tokenInfo )
@@ -806,10 +787,9 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
806787 }
807788 }
808789 } else if account .Platform == service .PlatformAntigravity {
809- tokenInfo , err := h .antigravityOAuthService .RefreshAccountToken (c . Request . Context () , account )
790+ tokenInfo , err := h .antigravityOAuthService .RefreshAccountToken (ctx , account )
810791 if err != nil {
811- response .ErrorFrom (c , err )
812- return
792+ return nil , "" , err
813793 }
814794
815795 newCredentials = h .antigravityOAuthService .BuildAccountCredentials (tokenInfo )
@@ -828,37 +808,27 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
828808 }
829809
830810 // 如果 project_id 获取失败,更新凭证但不标记为 error
831- // LoadCodeAssist 失败可能是临时网络问题,给它机会在下次自动刷新时重试
832811 if tokenInfo .ProjectIDMissing {
833- // 先更新凭证(token 本身刷新成功了)
834- _ , updateErr := h .adminService .UpdateAccount (c .Request .Context (), accountID , & service.UpdateAccountInput {
812+ updatedAccount , updateErr := h .adminService .UpdateAccount (ctx , account .ID , & service.UpdateAccountInput {
835813 Credentials : newCredentials ,
836814 })
837815 if updateErr != nil {
838- response .InternalError (c , "Failed to update credentials: " + updateErr .Error ())
839- return
816+ return nil , "" , fmt .Errorf ("failed to update credentials: %w" , updateErr )
840817 }
841- // 不标记为 error,只返回警告信息
842- response .Success (c , gin.H {
843- "message" : "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)" ,
844- "warning" : "missing_project_id_temporary" ,
845- })
846- return
818+ return updatedAccount , "missing_project_id_temporary" , nil
847819 }
848820
849821 // 成功获取到 project_id,如果之前是 missing_project_id 错误则清除
850822 if account .Status == service .StatusError && strings .Contains (account .ErrorMessage , "missing_project_id:" ) {
851- if _ , clearErr := h .adminService .ClearAccountError (c .Request .Context (), accountID ); clearErr != nil {
852- response .InternalError (c , "Failed to clear account error: " + clearErr .Error ())
853- return
823+ if _ , clearErr := h .adminService .ClearAccountError (ctx , account .ID ); clearErr != nil {
824+ return nil , "" , fmt .Errorf ("failed to clear account error: %w" , clearErr )
854825 }
855826 }
856827 } else {
857828 // Use Anthropic/Claude OAuth service to refresh token
858- tokenInfo , err := h .oauthService .RefreshAccountToken (c . Request . Context () , account )
829+ tokenInfo , err := h .oauthService .RefreshAccountToken (ctx , account )
859830 if err != nil {
860- response .ErrorFrom (c , err )
861- return
831+ return nil , "" , err
862832 }
863833
864834 // Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests)
@@ -880,22 +850,53 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
880850 }
881851 }
882852
883- updatedAccount , err := h .adminService .UpdateAccount (c . Request . Context (), accountID , & service.UpdateAccountInput {
853+ updatedAccount , err := h .adminService .UpdateAccount (ctx , account . ID , & service.UpdateAccountInput {
884854 Credentials : newCredentials ,
885855 })
886856 if err != nil {
887- response .ErrorFrom (c , err )
888- return
857+ return nil , "" , err
889858 }
890859
891860 // 刷新成功后,清除 token 缓存,确保下次请求使用新 token
892861 if h .tokenCacheInvalidator != nil {
893- if invalidateErr := h .tokenCacheInvalidator .InvalidateToken (c .Request .Context (), updatedAccount ); invalidateErr != nil {
894- // 缓存失效失败只记录日志,不影响主流程
895- _ = c .Error (invalidateErr )
862+ if invalidateErr := h .tokenCacheInvalidator .InvalidateToken (ctx , updatedAccount ); invalidateErr != nil {
863+ log .Printf ("[WARN] Failed to invalidate token cache for account %d: %v" , updatedAccount .ID , invalidateErr )
896864 }
897865 }
898866
867+ return updatedAccount , "" , nil
868+ }
869+
870+ // Refresh handles refreshing account credentials
871+ // POST /api/v1/admin/accounts/:id/refresh
872+ func (h * AccountHandler ) Refresh (c * gin.Context ) {
873+ accountID , err := strconv .ParseInt (c .Param ("id" ), 10 , 64 )
874+ if err != nil {
875+ response .BadRequest (c , "Invalid account ID" )
876+ return
877+ }
878+
879+ // Get account
880+ account , err := h .adminService .GetAccount (c .Request .Context (), accountID )
881+ if err != nil {
882+ response .NotFound (c , "Account not found" )
883+ return
884+ }
885+
886+ updatedAccount , warning , err := h .refreshSingleAccount (c .Request .Context (), account )
887+ if err != nil {
888+ response .ErrorFrom (c , err )
889+ return
890+ }
891+
892+ if warning == "missing_project_id_temporary" {
893+ response .Success (c , gin.H {
894+ "message" : "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)" ,
895+ "warning" : "missing_project_id_temporary" ,
896+ })
897+ return
898+ }
899+
899900 response .Success (c , h .buildAccountResponseWithRuntime (c .Request .Context (), updatedAccount ))
900901}
901902
@@ -949,14 +950,175 @@ func (h *AccountHandler) ClearError(c *gin.Context) {
949950 // 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
950951 if h .tokenCacheInvalidator != nil && account .IsOAuth () {
951952 if invalidateErr := h .tokenCacheInvalidator .InvalidateToken (c .Request .Context (), account ); invalidateErr != nil {
952- // 缓存失效失败只记录日志,不影响主流程
953- _ = c .Error (invalidateErr )
953+ log .Printf ("[WARN] Failed to invalidate token cache for account %d: %v" , accountID , invalidateErr )
954954 }
955955 }
956956
957957 response .Success (c , h .buildAccountResponseWithRuntime (c .Request .Context (), account ))
958958}
959959
960+ // BatchClearError handles batch clearing account errors
961+ // POST /api/v1/admin/accounts/batch-clear-error
962+ func (h * AccountHandler ) BatchClearError (c * gin.Context ) {
963+ var req struct {
964+ AccountIDs []int64 `json:"account_ids"`
965+ }
966+ if err := c .ShouldBindJSON (& req ); err != nil {
967+ response .BadRequest (c , "Invalid request: " + err .Error ())
968+ return
969+ }
970+ if len (req .AccountIDs ) == 0 {
971+ response .BadRequest (c , "account_ids is required" )
972+ return
973+ }
974+
975+ ctx := c .Request .Context ()
976+
977+ const maxConcurrency = 10
978+ g , gctx := errgroup .WithContext (ctx )
979+ g .SetLimit (maxConcurrency )
980+
981+ var mu sync.Mutex
982+ var successCount , failedCount int
983+ var errors []gin.H
984+
985+ // 注意:所有 goroutine 必须 return nil,避免 errgroup cancel 其他并发任务
986+ for _ , id := range req .AccountIDs {
987+ accountID := id // 闭包捕获
988+ g .Go (func () error {
989+ account , err := h .adminService .ClearAccountError (gctx , accountID )
990+ if err != nil {
991+ mu .Lock ()
992+ failedCount ++
993+ errors = append (errors , gin.H {
994+ "account_id" : accountID ,
995+ "error" : err .Error (),
996+ })
997+ mu .Unlock ()
998+ return nil
999+ }
1000+
1001+ // 清除错误后,同时清除 token 缓存
1002+ if h .tokenCacheInvalidator != nil && account .IsOAuth () {
1003+ if invalidateErr := h .tokenCacheInvalidator .InvalidateToken (gctx , account ); invalidateErr != nil {
1004+ log .Printf ("[WARN] Failed to invalidate token cache for account %d: %v" , accountID , invalidateErr )
1005+ }
1006+ }
1007+
1008+ mu .Lock ()
1009+ successCount ++
1010+ mu .Unlock ()
1011+ return nil
1012+ })
1013+ }
1014+
1015+ if err := g .Wait (); err != nil {
1016+ response .ErrorFrom (c , err )
1017+ return
1018+ }
1019+
1020+ response .Success (c , gin.H {
1021+ "total" : len (req .AccountIDs ),
1022+ "success" : successCount ,
1023+ "failed" : failedCount ,
1024+ "errors" : errors ,
1025+ })
1026+ }
1027+
1028+ // BatchRefresh handles batch refreshing account credentials
1029+ // POST /api/v1/admin/accounts/batch-refresh
1030+ func (h * AccountHandler ) BatchRefresh (c * gin.Context ) {
1031+ var req struct {
1032+ AccountIDs []int64 `json:"account_ids"`
1033+ }
1034+ if err := c .ShouldBindJSON (& req ); err != nil {
1035+ response .BadRequest (c , "Invalid request: " + err .Error ())
1036+ return
1037+ }
1038+ if len (req .AccountIDs ) == 0 {
1039+ response .BadRequest (c , "account_ids is required" )
1040+ return
1041+ }
1042+
1043+ ctx := c .Request .Context ()
1044+
1045+ accounts , err := h .adminService .GetAccountsByIDs (ctx , req .AccountIDs )
1046+ if err != nil {
1047+ response .ErrorFrom (c , err )
1048+ return
1049+ }
1050+
1051+ // 建立已获取账号的 ID 集合,检测缺失的 ID
1052+ foundIDs := make (map [int64 ]bool , len (accounts ))
1053+ for _ , acc := range accounts {
1054+ if acc != nil {
1055+ foundIDs [acc .ID ] = true
1056+ }
1057+ }
1058+
1059+ const maxConcurrency = 10
1060+ g , gctx := errgroup .WithContext (ctx )
1061+ g .SetLimit (maxConcurrency )
1062+
1063+ var mu sync.Mutex
1064+ var successCount , failedCount int
1065+ var errors []gin.H
1066+ var warnings []gin.H
1067+
1068+ // 将不存在的账号 ID 标记为失败
1069+ for _ , id := range req .AccountIDs {
1070+ if ! foundIDs [id ] {
1071+ failedCount ++
1072+ errors = append (errors , gin.H {
1073+ "account_id" : id ,
1074+ "error" : "account not found" ,
1075+ })
1076+ }
1077+ }
1078+
1079+ // 注意:所有 goroutine 必须 return nil,避免 errgroup cancel 其他并发任务
1080+ for _ , account := range accounts {
1081+ acc := account // 闭包捕获
1082+ if acc == nil {
1083+ continue
1084+ }
1085+ g .Go (func () error {
1086+ _ , warning , err := h .refreshSingleAccount (gctx , acc )
1087+ mu .Lock ()
1088+ if err != nil {
1089+ failedCount ++
1090+ errors = append (errors , gin.H {
1091+ "account_id" : acc .ID ,
1092+ "error" : err .Error (),
1093+ })
1094+ } else {
1095+ successCount ++
1096+ if warning != "" {
1097+ warnings = append (warnings , gin.H {
1098+ "account_id" : acc .ID ,
1099+ "warning" : warning ,
1100+ })
1101+ }
1102+ }
1103+ mu .Unlock ()
1104+ return nil
1105+ })
1106+ }
1107+
1108+ if err := g .Wait (); err != nil {
1109+ response .ErrorFrom (c , err )
1110+ return
1111+ }
1112+
1113+ response .Success (c , gin.H {
1114+ "total" : len (req .AccountIDs ),
1115+ "success" : successCount ,
1116+ "failed" : failedCount ,
1117+ "errors" : errors ,
1118+ "warnings" : warnings ,
1119+ })
1120+ }
1121+
9601122// BatchCreate handles batch creating accounts
9611123// POST /api/v1/admin/accounts/batch
9621124func (h * AccountHandler ) BatchCreate (c * gin.Context ) {
0 commit comments