Skip to content

Commit 252d6c5

Browse files
author
QTom
committed
feat: 支持批量重置状态和批量刷新令牌
- 提取 refreshSingleAccount 私有方法复用单账号刷新逻辑 - 新增 BatchClearError handler (POST /admin/accounts/batch-clear-error) - 新增 BatchRefresh handler (POST /admin/accounts/batch-refresh) - 前端 AccountBulkActionsBar 添加批量重置状态/刷新令牌按钮 - AccountsView 添加 handler 支持 partial success 反馈 - i18n 中英文补充批量操作相关翻译
1 parent 7a4e65a commit 252d6c5

7 files changed

Lines changed: 310 additions & 63 deletions

File tree

backend/internal/handler/admin/account_handler.go

Lines changed: 219 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
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
9621124
func (h *AccountHandler) BatchCreate(c *gin.Context) {

backend/internal/server/routes/admin.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
264264
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
265265
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
266266
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
267+
accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError)
268+
accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh)
267269

268270
// Antigravity 默认模型映射
269271
accounts.GET("/antigravity/default-model-mapping", h.Admin.Account.GetAntigravityDefaultModelMapping)

0 commit comments

Comments
 (0)