mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-24 08:34:45 +08:00
Merge pull request #897 from DaydreamCoding/feat/batch-reset-and-openai-jwt
feat: OpenAI 账号信息增强 & 批量操作支持
This commit is contained in:
@@ -8,6 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -292,6 +295,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enrichCredentialsFromIDToken(&item)
|
||||||
|
|
||||||
accountInput := &service.CreateAccountInput{
|
accountInput := &service.CreateAccountInput{
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
Notes: item.Notes,
|
Notes: item.Notes,
|
||||||
@@ -535,6 +540,57 @@ func defaultProxyName(name string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrichCredentialsFromIDToken performs best-effort extraction of user info fields
|
||||||
|
// (email, plan_type, chatgpt_account_id, etc.) from id_token in credentials.
|
||||||
|
// Only applies to OpenAI/Sora OAuth accounts. Skips expired token errors silently.
|
||||||
|
// Existing credential values are never overwritten — only missing fields are filled.
|
||||||
|
func enrichCredentialsFromIDToken(item *DataAccount) {
|
||||||
|
if item.Credentials == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only enrich OpenAI/Sora OAuth accounts
|
||||||
|
platform := strings.ToLower(strings.TrimSpace(item.Platform))
|
||||||
|
if platform != service.PlatformOpenAI && platform != service.PlatformSora {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(item.Type)) != service.AccountTypeOAuth {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, _ := item.Credentials["id_token"].(string)
|
||||||
|
if strings.TrimSpace(idToken) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeIDToken skips expiry validation — safe for imported data
|
||||||
|
claims, err := openai.DecodeIDToken(idToken)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("import_enrich_id_token_decode_failed", "account", item.Name, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo := claims.GetUserInfo()
|
||||||
|
if userInfo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill missing fields only (never overwrite existing values)
|
||||||
|
setIfMissing := func(key, value string) {
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing, _ := item.Credentials[key].(string); existing == "" {
|
||||||
|
item.Credentials[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIfMissing("email", userInfo.Email)
|
||||||
|
setIfMissing("plan_type", userInfo.PlanType)
|
||||||
|
setIfMissing("chatgpt_account_id", userInfo.ChatGPTAccountID)
|
||||||
|
setIfMissing("chatgpt_user_id", userInfo.ChatGPTUserID)
|
||||||
|
setIfMissing("organization_id", userInfo.OrganizationID)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeProxyStatus(status string) string {
|
func normalizeProxyStatus(status string) string {
|
||||||
normalized := strings.TrimSpace(strings.ToLower(status))
|
normalized := strings.TrimSpace(strings.ToLower(status))
|
||||||
switch normalized {
|
switch normalized {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
"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/geminicli"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
@@ -751,52 +753,31 @@ func (h *AccountHandler) PreviewFromCRS(c *gin.Context) {
|
|||||||
response.Success(c, result)
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh handles refreshing account credentials
|
// refreshSingleAccount refreshes credentials for a single OAuth account.
|
||||||
// POST /api/v1/admin/accounts/:id/refresh
|
// Returns (updatedAccount, warning, error) where warning is used for Antigravity ProjectIDMissing scenario.
|
||||||
func (h *AccountHandler) Refresh(c *gin.Context) {
|
func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *service.Account) (*service.Account, string, error) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only refresh OAuth-based accounts (oauth and setup-token)
|
|
||||||
if !account.IsOAuth() {
|
if !account.IsOAuth() {
|
||||||
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
|
return nil, "", infraerrors.BadRequest("NOT_OAUTH", "cannot refresh non-OAuth account")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var newCredentials map[string]any
|
var newCredentials map[string]any
|
||||||
|
|
||||||
if account.IsOpenAI() {
|
if account.IsOpenAI() {
|
||||||
// Use OpenAI OAuth service to refresh token
|
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account)
|
||||||
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
return nil, "", err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new credentials from token info
|
|
||||||
newCredentials = h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
newCredentials = h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||||
|
|
||||||
// Preserve non-token settings from existing credentials
|
|
||||||
for k, v := range account.Credentials {
|
for k, v := range account.Credentials {
|
||||||
if _, exists := newCredentials[k]; !exists {
|
if _, exists := newCredentials[k]; !exists {
|
||||||
newCredentials[k] = v
|
newCredentials[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if account.Platform == service.PlatformGemini {
|
} else if account.Platform == service.PlatformGemini {
|
||||||
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to refresh credentials: "+err.Error())
|
return nil, "", fmt.Errorf("failed to refresh credentials: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newCredentials = h.geminiOAuthService.BuildAccountCredentials(tokenInfo)
|
newCredentials = h.geminiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||||
@@ -806,10 +787,9 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if account.Platform == service.PlatformAntigravity {
|
} else if account.Platform == service.PlatformAntigravity {
|
||||||
tokenInfo, err := h.antigravityOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
tokenInfo, err := h.antigravityOAuthService.RefreshAccountToken(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
return nil, "", err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newCredentials = h.antigravityOAuthService.BuildAccountCredentials(tokenInfo)
|
newCredentials = h.antigravityOAuthService.BuildAccountCredentials(tokenInfo)
|
||||||
@@ -828,37 +808,27 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果 project_id 获取失败,更新凭证但不标记为 error
|
// 如果 project_id 获取失败,更新凭证但不标记为 error
|
||||||
// LoadCodeAssist 失败可能是临时网络问题,给它机会在下次自动刷新时重试
|
|
||||||
if tokenInfo.ProjectIDMissing {
|
if tokenInfo.ProjectIDMissing {
|
||||||
// 先更新凭证(token 本身刷新成功了)
|
updatedAccount, updateErr := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{
|
||||||
_, updateErr := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
|
||||||
Credentials: newCredentials,
|
Credentials: newCredentials,
|
||||||
})
|
})
|
||||||
if updateErr != nil {
|
if updateErr != nil {
|
||||||
response.InternalError(c, "Failed to update credentials: "+updateErr.Error())
|
return nil, "", fmt.Errorf("failed to update credentials: %w", updateErr)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// 不标记为 error,只返回警告信息
|
return updatedAccount, "missing_project_id_temporary", nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成功获取到 project_id,如果之前是 missing_project_id 错误则清除
|
// 成功获取到 project_id,如果之前是 missing_project_id 错误则清除
|
||||||
if account.Status == service.StatusError && strings.Contains(account.ErrorMessage, "missing_project_id:") {
|
if account.Status == service.StatusError && strings.Contains(account.ErrorMessage, "missing_project_id:") {
|
||||||
if _, clearErr := h.adminService.ClearAccountError(c.Request.Context(), accountID); clearErr != nil {
|
if _, clearErr := h.adminService.ClearAccountError(ctx, account.ID); clearErr != nil {
|
||||||
response.InternalError(c, "Failed to clear account error: "+clearErr.Error())
|
return nil, "", fmt.Errorf("failed to clear account error: %w", clearErr)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use Anthropic/Claude OAuth service to refresh token
|
// Use Anthropic/Claude OAuth service to refresh token
|
||||||
tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account)
|
tokenInfo, err := h.oauthService.RefreshAccountToken(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
return nil, "", err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests)
|
// Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests)
|
||||||
@@ -880,20 +850,51 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
updatedAccount, err := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{
|
||||||
Credentials: newCredentials,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新成功后,清除 token 缓存,确保下次请求使用新 token
|
if warning == "missing_project_id_temporary" {
|
||||||
if h.tokenCacheInvalidator != nil {
|
response.Success(c, gin.H{
|
||||||
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), updatedAccount); invalidateErr != nil {
|
"message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)",
|
||||||
// 缓存失效失败只记录日志,不影响主流程
|
"warning": "missing_project_id_temporary",
|
||||||
_ = c.Error(invalidateErr)
|
})
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount))
|
||||||
@@ -949,14 +950,175 @@ func (h *AccountHandler) ClearError(c *gin.Context) {
|
|||||||
// 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
|
// 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
|
||||||
if h.tokenCacheInvalidator != nil && account.IsOAuth() {
|
if h.tokenCacheInvalidator != nil && account.IsOAuth() {
|
||||||
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), account); invalidateErr != nil {
|
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)
|
||||||
_ = c.Error(invalidateErr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// BatchCreate handles batch creating accounts
|
||||||
// POST /api/v1/admin/accounts/batch
|
// POST /api/v1/admin/accounts/batch
|
||||||
func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ type IDTokenClaims struct {
|
|||||||
type OpenAIAuthClaims struct {
|
type OpenAIAuthClaims struct {
|
||||||
ChatGPTAccountID string `json:"chatgpt_account_id"`
|
ChatGPTAccountID string `json:"chatgpt_account_id"`
|
||||||
ChatGPTUserID string `json:"chatgpt_user_id"`
|
ChatGPTUserID string `json:"chatgpt_user_id"`
|
||||||
|
ChatGPTPlanType string `json:"chatgpt_plan_type"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Organizations []OrganizationClaim `json:"organizations"`
|
Organizations []OrganizationClaim `json:"organizations"`
|
||||||
}
|
}
|
||||||
@@ -325,12 +326,9 @@ func (r *RefreshTokenRequest) ToFormData() string {
|
|||||||
return params.Encode()
|
return params.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseIDToken parses the ID Token JWT and extracts claims.
|
// DecodeIDToken decodes the ID Token JWT payload without validating expiration.
|
||||||
// 注意:当前仅解码 payload 并校验 exp,未验证 JWT 签名。
|
// Use this for best-effort extraction (e.g., during data import) where the token may be expired.
|
||||||
// 生产环境如需用 ID Token 做授权决策,应通过 OpenAI 的 JWKS 端点验证签名:
|
func DecodeIDToken(idToken string) (*IDTokenClaims, error) {
|
||||||
//
|
|
||||||
// https://auth.openai.com/.well-known/jwks.json
|
|
||||||
func ParseIDToken(idToken string) (*IDTokenClaims, error) {
|
|
||||||
parts := strings.Split(idToken, ".")
|
parts := strings.Split(idToken, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
|
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
|
||||||
@@ -360,6 +358,20 @@ func ParseIDToken(idToken string) (*IDTokenClaims, error) {
|
|||||||
return nil, fmt.Errorf("failed to parse JWT claims: %w", err)
|
return nil, fmt.Errorf("failed to parse JWT claims: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseIDToken parses the ID Token JWT and extracts claims.
|
||||||
|
// 注意:当前仅解码 payload 并校验 exp,未验证 JWT 签名。
|
||||||
|
// 生产环境如需用 ID Token 做授权决策,应通过 OpenAI 的 JWKS 端点验证签名:
|
||||||
|
//
|
||||||
|
// https://auth.openai.com/.well-known/jwks.json
|
||||||
|
func ParseIDToken(idToken string) (*IDTokenClaims, error) {
|
||||||
|
claims, err := DecodeIDToken(idToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// 校验 ID Token 是否已过期(允许 2 分钟时钟偏差,防止因服务器时钟略有差异误判刚颁发的令牌)
|
// 校验 ID Token 是否已过期(允许 2 分钟时钟偏差,防止因服务器时钟略有差异误判刚颁发的令牌)
|
||||||
const clockSkewTolerance = 120 // 秒
|
const clockSkewTolerance = 120 // 秒
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
@@ -367,7 +379,7 @@ func ParseIDToken(idToken string) (*IDTokenClaims, error) {
|
|||||||
return nil, fmt.Errorf("id_token has expired (exp: %d, now: %d, skew_tolerance: %ds)", claims.Exp, now, clockSkewTolerance)
|
return nil, fmt.Errorf("id_token has expired (exp: %d, now: %d, skew_tolerance: %ds)", claims.Exp, now, clockSkewTolerance)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfo represents user information extracted from ID Token claims.
|
// UserInfo represents user information extracted from ID Token claims.
|
||||||
@@ -375,6 +387,7 @@ type UserInfo struct {
|
|||||||
Email string
|
Email string
|
||||||
ChatGPTAccountID string
|
ChatGPTAccountID string
|
||||||
ChatGPTUserID string
|
ChatGPTUserID string
|
||||||
|
PlanType string
|
||||||
UserID string
|
UserID string
|
||||||
OrganizationID string
|
OrganizationID string
|
||||||
Organizations []OrganizationClaim
|
Organizations []OrganizationClaim
|
||||||
@@ -389,6 +402,7 @@ func (c *IDTokenClaims) GetUserInfo() *UserInfo {
|
|||||||
if c.OpenAIAuth != nil {
|
if c.OpenAIAuth != nil {
|
||||||
info.ChatGPTAccountID = c.OpenAIAuth.ChatGPTAccountID
|
info.ChatGPTAccountID = c.OpenAIAuth.ChatGPTAccountID
|
||||||
info.ChatGPTUserID = c.OpenAIAuth.ChatGPTUserID
|
info.ChatGPTUserID = c.OpenAIAuth.ChatGPTUserID
|
||||||
|
info.PlanType = c.OpenAIAuth.ChatGPTPlanType
|
||||||
info.UserID = c.OpenAIAuth.UserID
|
info.UserID = c.OpenAIAuth.UserID
|
||||||
info.Organizations = c.OpenAIAuth.Organizations
|
info.Organizations = c.OpenAIAuth.Organizations
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
|
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
|
||||||
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
|
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
|
||||||
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
|
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
|
||||||
|
accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError)
|
||||||
|
accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh)
|
||||||
|
|
||||||
// Antigravity 默认模型映射
|
// Antigravity 默认模型映射
|
||||||
accounts.GET("/antigravity/default-model-mapping", h.Admin.Account.GetAntigravityDefaultModelMapping)
|
accounts.GET("/antigravity/default-model-mapping", h.Admin.Account.GetAntigravityDefaultModelMapping)
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ type OpenAITokenInfo struct {
|
|||||||
ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"`
|
ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"`
|
||||||
ChatGPTUserID string `json:"chatgpt_user_id,omitempty"`
|
ChatGPTUserID string `json:"chatgpt_user_id,omitempty"`
|
||||||
OrganizationID string `json:"organization_id,omitempty"`
|
OrganizationID string `json:"organization_id,omitempty"`
|
||||||
|
PlanType string `json:"plan_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExchangeCode exchanges authorization code for tokens
|
// ExchangeCode exchanges authorization code for tokens
|
||||||
@@ -202,6 +203,7 @@ func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExch
|
|||||||
tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
|
tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
|
||||||
tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
|
tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
|
||||||
tokenInfo.OrganizationID = userInfo.OrganizationID
|
tokenInfo.OrganizationID = userInfo.OrganizationID
|
||||||
|
tokenInfo.PlanType = userInfo.PlanType
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenInfo, nil
|
return tokenInfo, nil
|
||||||
@@ -246,6 +248,7 @@ func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refre
|
|||||||
tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
|
tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
|
||||||
tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
|
tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
|
||||||
tokenInfo.OrganizationID = userInfo.OrganizationID
|
tokenInfo.OrganizationID = userInfo.OrganizationID
|
||||||
|
tokenInfo.PlanType = userInfo.PlanType
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenInfo, nil
|
return tokenInfo, nil
|
||||||
@@ -510,6 +513,9 @@ func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo)
|
|||||||
if tokenInfo.OrganizationID != "" {
|
if tokenInfo.OrganizationID != "" {
|
||||||
creds["organization_id"] = tokenInfo.OrganizationID
|
creds["organization_id"] = tokenInfo.OrganizationID
|
||||||
}
|
}
|
||||||
|
if tokenInfo.PlanType != "" {
|
||||||
|
creds["plan_type"] = tokenInfo.PlanType
|
||||||
|
}
|
||||||
if strings.TrimSpace(tokenInfo.ClientID) != "" {
|
if strings.TrimSpace(tokenInfo.ClientID) != "" {
|
||||||
creds["client_id"] = strings.TrimSpace(tokenInfo.ClientID)
|
creds["client_id"] = strings.TrimSpace(tokenInfo.ClientID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -581,6 +581,43 @@ export async function validateSoraSessionToken(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch operation result type
|
||||||
|
*/
|
||||||
|
export interface BatchOperationResult {
|
||||||
|
total: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
errors?: Array<{ account_id: number; error: string }>
|
||||||
|
warnings?: Array<{ account_id: number; warning: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch clear account errors
|
||||||
|
* @param accountIds - Array of account IDs
|
||||||
|
* @returns Batch operation result
|
||||||
|
*/
|
||||||
|
export async function batchClearError(accountIds: number[]): Promise<BatchOperationResult> {
|
||||||
|
const { data } = await apiClient.post<BatchOperationResult>('/admin/accounts/batch-clear-error', {
|
||||||
|
account_ids: accountIds
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch refresh account credentials
|
||||||
|
* @param accountIds - Array of account IDs
|
||||||
|
* @returns Batch operation result
|
||||||
|
*/
|
||||||
|
export async function batchRefresh(accountIds: number[]): Promise<BatchOperationResult> {
|
||||||
|
const { data } = await apiClient.post<BatchOperationResult>('/admin/accounts/batch-refresh', {
|
||||||
|
account_ids: accountIds,
|
||||||
|
}, {
|
||||||
|
timeout: 120000 // 120s timeout for large batch refreshes
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const accountsAPI = {
|
export const accountsAPI = {
|
||||||
list,
|
list,
|
||||||
listWithEtag,
|
listWithEtag,
|
||||||
@@ -615,7 +652,9 @@ export const accountsAPI = {
|
|||||||
syncFromCrs,
|
syncFromCrs,
|
||||||
exportData,
|
exportData,
|
||||||
importData,
|
importData,
|
||||||
getAntigravityDefaultModelMapping
|
getAntigravityDefaultModelMapping,
|
||||||
|
batchClearError,
|
||||||
|
batchRefresh
|
||||||
}
|
}
|
||||||
|
|
||||||
export default accountsAPI
|
export default accountsAPI
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
||||||
|
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
|
||||||
|
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
|
||||||
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
|
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
|
||||||
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
|
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
|
||||||
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
|
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
|
||||||
@@ -29,5 +31,5 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable']); const { t } = useI18n()
|
defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
|
||||||
</script>
|
</script>
|
||||||
@@ -28,6 +28,10 @@
|
|||||||
<Icon v-else name="key" size="xs" />
|
<Icon v-else name="key" size="xs" />
|
||||||
<span>{{ typeLabel }}</span>
|
<span>{{ typeLabel }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Plan type part (optional) -->
|
||||||
|
<span v-if="planLabel" :class="['inline-flex items-center gap-1 px-1.5 py-1 border-l border-white/20', typeClass]">
|
||||||
|
<span>{{ planLabel }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
interface Props {
|
interface Props {
|
||||||
platform: AccountPlatform
|
platform: AccountPlatform
|
||||||
type: AccountType
|
type: AccountType
|
||||||
|
planType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -65,6 +70,24 @@ const typeLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const planLabel = computed(() => {
|
||||||
|
if (!props.planType) return ''
|
||||||
|
const lower = props.planType.toLowerCase()
|
||||||
|
switch (lower) {
|
||||||
|
case 'plus':
|
||||||
|
return 'Plus'
|
||||||
|
case 'team':
|
||||||
|
return 'Team'
|
||||||
|
case 'chatgptpro':
|
||||||
|
case 'pro':
|
||||||
|
return 'Pro'
|
||||||
|
case 'free':
|
||||||
|
return 'Free'
|
||||||
|
default:
|
||||||
|
return props.planType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const platformClass = computed(() => {
|
const platformClass = computed(() => {
|
||||||
if (props.platform === 'anthropic') {
|
if (props.platform === 'anthropic') {
|
||||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
|||||||
@@ -1836,7 +1836,12 @@ export default {
|
|||||||
edit: 'Bulk Edit',
|
edit: 'Bulk Edit',
|
||||||
delete: 'Bulk Delete',
|
delete: 'Bulk Delete',
|
||||||
enableScheduling: 'Enable Scheduling',
|
enableScheduling: 'Enable Scheduling',
|
||||||
disableScheduling: 'Disable Scheduling'
|
disableScheduling: 'Disable Scheduling',
|
||||||
|
resetStatus: 'Reset Status',
|
||||||
|
refreshToken: 'Refresh Token',
|
||||||
|
resetStatusSuccess: 'Successfully reset {count} account(s) status',
|
||||||
|
refreshTokenSuccess: 'Successfully refreshed {count} account(s) token',
|
||||||
|
partialSuccess: 'Partially completed: {success} succeeded, {failed} failed'
|
||||||
},
|
},
|
||||||
bulkEdit: {
|
bulkEdit: {
|
||||||
title: 'Bulk Edit Accounts',
|
title: 'Bulk Edit Accounts',
|
||||||
|
|||||||
@@ -1983,7 +1983,12 @@ export default {
|
|||||||
edit: '批量编辑账号',
|
edit: '批量编辑账号',
|
||||||
delete: '批量删除',
|
delete: '批量删除',
|
||||||
enableScheduling: '批量启用调度',
|
enableScheduling: '批量启用调度',
|
||||||
disableScheduling: '批量停止调度'
|
disableScheduling: '批量停止调度',
|
||||||
|
resetStatus: '批量重置状态',
|
||||||
|
refreshToken: '批量刷新令牌',
|
||||||
|
resetStatusSuccess: '已成功重置 {count} 个账号状态',
|
||||||
|
refreshTokenSuccess: '已成功刷新 {count} 个账号令牌',
|
||||||
|
partialSuccess: '操作部分完成:{success} 成功,{failed} 失败'
|
||||||
},
|
},
|
||||||
bulkEdit: {
|
bulkEdit: {
|
||||||
title: '批量编辑账号',
|
title: '批量编辑账号',
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #table>
|
<template #table>
|
||||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||||
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="cols"
|
:columns="cols"
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-platform_type="{ row }">
|
<template #cell-platform_type="{ row }">
|
||||||
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
|
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-capacity="{ row }">
|
<template #cell-capacity="{ row }">
|
||||||
<AccountCapacityCell :account="row" />
|
<AccountCapacityCell :account="row" />
|
||||||
@@ -889,6 +889,38 @@ const toggleSelectAllVisible = (event: Event) => {
|
|||||||
toggleVisible(target.checked)
|
toggleVisible(target.checked)
|
||||||
}
|
}
|
||||||
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); clearSelection(); reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
|
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); clearSelection(); reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
|
||||||
|
const handleBulkResetStatus = async () => {
|
||||||
|
if (!confirm(t('common.confirm'))) return
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.accounts.batchClearError(selIds.value)
|
||||||
|
if (result.failed > 0) {
|
||||||
|
appStore.showError(t('admin.accounts.bulkActions.partialSuccess', { success: result.success, failed: result.failed }))
|
||||||
|
} else {
|
||||||
|
appStore.showSuccess(t('admin.accounts.bulkActions.resetStatusSuccess', { count: result.success }))
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to bulk reset status:', error)
|
||||||
|
appStore.showError(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleBulkRefreshToken = async () => {
|
||||||
|
if (!confirm(t('common.confirm'))) return
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.accounts.batchRefresh(selIds.value)
|
||||||
|
if (result.failed > 0) {
|
||||||
|
appStore.showError(t('admin.accounts.bulkActions.partialSuccess', { success: result.success, failed: result.failed }))
|
||||||
|
} else {
|
||||||
|
appStore.showSuccess(t('admin.accounts.bulkActions.refreshTokenSuccess', { count: result.success }))
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to bulk refresh token:', error)
|
||||||
|
appStore.showError(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
|
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
|
||||||
if (accountIds.length === 0) return
|
if (accountIds.length === 0) return
|
||||||
const idSet = new Set(accountIds)
|
const idSet = new Set(accountIds)
|
||||||
|
|||||||
Reference in New Issue
Block a user