feat(account): 添加批量编辑账户凭据功能并优化 CRS 同步
- 新增批量更新账户凭据接口(account_uuid/org_uuid/intercept_warmup_requests) - 新增前端批量编辑模态框组件 - 优化 CRS 同步逻辑,改进 extra 字段处理 - 优化 CRS 同步 UI,添加更详细的结果展示 - 完善国际化文案(中英文)
This commit is contained in:
@@ -434,6 +434,94 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
success := 0
|
||||
failed := 0
|
||||
results := []gin.H{}
|
||||
|
||||
for _, accountID := range req.AccountIDs {
|
||||
// Get account
|
||||
account, err := h.adminService.GetAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
failed++
|
||||
results = append(results, gin.H{
|
||||
"account_id": accountID,
|
||||
"success": false,
|
||||
"error": "Account not found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Update credentials field
|
||||
if account.Credentials == nil {
|
||||
account.Credentials = make(map[string]any)
|
||||
}
|
||||
|
||||
account.Credentials[req.Field] = req.Value
|
||||
|
||||
// Update account
|
||||
updateInput := &service.UpdateAccountInput{
|
||||
Credentials: account.Credentials,
|
||||
}
|
||||
|
||||
_, err = h.adminService.UpdateAccount(ctx, accountID, updateInput)
|
||||
if err != nil {
|
||||
failed++
|
||||
results = append(results, gin.H{
|
||||
"account_id": accountID,
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
success++
|
||||
results = append(results, gin.H{
|
||||
"account_id": accountID,
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== OAuth Handlers ==========
|
||||
|
||||
// GenerateAuthURLRequest represents the request for generating auth URL
|
||||
|
||||
@@ -193,6 +193,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
|
||||
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
||||
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
||||
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
||||
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
|
||||
|
||||
// Claude OAuth routes
|
||||
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
|
||||
|
||||
@@ -93,6 +93,7 @@ type crsClaudeAccount struct {
|
||||
Status string `json:"status"`
|
||||
Proxy *crsProxy `json:"proxy"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
|
||||
type crsConsoleAccount struct {
|
||||
@@ -137,6 +138,7 @@ type crsOpenAIOAuthAccount struct {
|
||||
Status string `json:"status"`
|
||||
Proxy *crsProxy `json:"proxy"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
|
||||
func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) {
|
||||
@@ -214,15 +216,28 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
}
|
||||
|
||||
credentials := sanitizeCredentialsMap(src.Credentials)
|
||||
// 🔧 Remove /v1 suffix from base_url for Claude accounts
|
||||
cleanBaseURL(credentials, "/v1")
|
||||
// 🔧 Convert expires_at from ISO string to Unix timestamp
|
||||
if expiresAtStr, ok := credentials["expires_at"].(string); ok && expiresAtStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil {
|
||||
credentials["expires_at"] = t.Unix()
|
||||
}
|
||||
}
|
||||
// 🔧 Add intercept_warmup_requests if not present (defaults to false)
|
||||
if _, exists := credentials["intercept_warmup_requests"]; !exists {
|
||||
credentials["intercept_warmup_requests"] = false
|
||||
}
|
||||
priority := clampPriority(src.Priority)
|
||||
concurrency := 3
|
||||
status := mapCRSStatus(src.IsActive, src.Status)
|
||||
|
||||
extra := map[string]any{
|
||||
"crs_account_id": src.ID,
|
||||
"crs_kind": src.Kind,
|
||||
"crs_synced_at": now,
|
||||
// 🔧 Use CRS extra data directly, add sync metadata
|
||||
extra := src.Extra
|
||||
if extra == nil {
|
||||
extra = make(map[string]any)
|
||||
}
|
||||
extra["crs_synced_at"] = now
|
||||
|
||||
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||
if err != nil {
|
||||
@@ -260,17 +275,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
}
|
||||
|
||||
// Update existing
|
||||
if existing.Extra == nil {
|
||||
existing.Extra = make(model.JSONB)
|
||||
}
|
||||
for k, v := range extra {
|
||||
existing.Extra[k] = v
|
||||
}
|
||||
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||
existing.Name = defaultName(src.Name, src.ID)
|
||||
existing.Platform = model.PlatformAnthropic
|
||||
existing.Type = targetType
|
||||
existing.Credentials = model.JSONB(credentials)
|
||||
existing.ProxyID = proxyID
|
||||
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||
if proxyID != nil {
|
||||
existing.ProxyID = proxyID
|
||||
}
|
||||
existing.Concurrency = concurrency
|
||||
existing.Priority = priority
|
||||
existing.Status = status
|
||||
@@ -364,17 +376,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.Extra == nil {
|
||||
existing.Extra = make(model.JSONB)
|
||||
}
|
||||
for k, v := range extra {
|
||||
existing.Extra[k] = v
|
||||
}
|
||||
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||
existing.Name = defaultName(src.Name, src.ID)
|
||||
existing.Platform = model.PlatformAnthropic
|
||||
existing.Type = model.AccountTypeApiKey
|
||||
existing.Credentials = model.JSONB(credentials)
|
||||
existing.ProxyID = proxyID
|
||||
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||
if proxyID != nil {
|
||||
existing.ProxyID = proxyID
|
||||
}
|
||||
existing.Concurrency = concurrency
|
||||
existing.Priority = priority
|
||||
existing.Status = status
|
||||
@@ -430,15 +439,22 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
if v, ok := credentials["token_type"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||
credentials["token_type"] = "Bearer"
|
||||
}
|
||||
// 🔧 Convert expires_at from ISO string to Unix timestamp
|
||||
if expiresAtStr, ok := credentials["expires_at"].(string); ok && expiresAtStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil {
|
||||
credentials["expires_at"] = t.Unix()
|
||||
}
|
||||
}
|
||||
priority := clampPriority(src.Priority)
|
||||
concurrency := 3
|
||||
status := mapCRSStatus(src.IsActive, src.Status)
|
||||
|
||||
extra := map[string]any{
|
||||
"crs_account_id": src.ID,
|
||||
"crs_kind": src.Kind,
|
||||
"crs_synced_at": now,
|
||||
// 🔧 Use CRS extra data directly, add sync metadata
|
||||
extra := src.Extra
|
||||
if extra == nil {
|
||||
extra = make(map[string]any)
|
||||
}
|
||||
extra["crs_synced_at"] = now
|
||||
|
||||
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||
if err != nil {
|
||||
@@ -475,17 +491,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.Extra == nil {
|
||||
existing.Extra = make(model.JSONB)
|
||||
}
|
||||
for k, v := range extra {
|
||||
existing.Extra[k] = v
|
||||
}
|
||||
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||
existing.Name = defaultName(src.Name, src.ID)
|
||||
existing.Platform = model.PlatformOpenAI
|
||||
existing.Type = model.AccountTypeOAuth
|
||||
existing.Credentials = model.JSONB(credentials)
|
||||
existing.ProxyID = proxyID
|
||||
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||
if proxyID != nil {
|
||||
existing.ProxyID = proxyID
|
||||
}
|
||||
existing.Concurrency = concurrency
|
||||
existing.Priority = priority
|
||||
existing.Status = status
|
||||
@@ -524,6 +537,8 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
if baseURL, ok := src.Credentials["base_url"].(string); !ok || strings.TrimSpace(baseURL) == "" {
|
||||
src.Credentials["base_url"] = "https://api.openai.com"
|
||||
}
|
||||
// 🔧 Remove /v1 suffix from base_url for OpenAI accounts
|
||||
cleanBaseURL(src.Credentials, "/v1")
|
||||
|
||||
proxyID, err := s.mapOrCreateProxy(
|
||||
ctx,
|
||||
@@ -586,17 +601,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.Extra == nil {
|
||||
existing.Extra = make(model.JSONB)
|
||||
}
|
||||
for k, v := range extra {
|
||||
existing.Extra[k] = v
|
||||
}
|
||||
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||
existing.Name = defaultName(src.Name, src.ID)
|
||||
existing.Platform = model.PlatformOpenAI
|
||||
existing.Type = model.AccountTypeApiKey
|
||||
existing.Credentials = model.JSONB(credentials)
|
||||
existing.ProxyID = proxyID
|
||||
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||
if proxyID != nil {
|
||||
existing.ProxyID = proxyID
|
||||
}
|
||||
existing.Concurrency = concurrency
|
||||
existing.Priority = priority
|
||||
existing.Status = status
|
||||
@@ -618,6 +630,18 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mergeJSONB merges two JSONB maps without removing keys that are absent in updates.
|
||||
func mergeJSONB(existing model.JSONB, updates map[string]any) model.JSONB {
|
||||
out := make(model.JSONB)
|
||||
for k, v := range existing {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range updates {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *CRSSyncService) mapOrCreateProxy(ctx context.Context, enabled bool, cached *[]model.Proxy, src *crsProxy, defaultName string) (*int64, error) {
|
||||
if !enabled || src == nil {
|
||||
return nil, nil
|
||||
@@ -731,6 +755,17 @@ func normalizeBaseURL(raw string) (string, error) {
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
// cleanBaseURL removes trailing suffix from base_url in credentials
|
||||
// Used for both Claude and OpenAI accounts to remove /v1
|
||||
func cleanBaseURL(credentials map[string]any, suffixToRemove string) {
|
||||
if baseURL, ok := credentials["base_url"].(string); ok && baseURL != "" {
|
||||
trimmed := strings.TrimSpace(baseURL)
|
||||
if strings.HasSuffix(trimmed, suffixToRemove) {
|
||||
credentials["base_url"] = strings.TrimSuffix(trimmed, suffixToRemove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func crsLogin(ctx context.Context, client *http.Client, baseURL, username, password string) (string, error) {
|
||||
payload := map[string]any{
|
||||
"username": username,
|
||||
|
||||
Reference in New Issue
Block a user