Skip to content

Commit 3bac382

Browse files
committed
feat(accounts): 支持用户账号批量编辑
新增用户账号批量更新接口与路由,校验账号归属、状态、分组绑定及用户可更新字段。前端用户账号页加入批量选择和批量编辑入口,复用批量编辑弹窗并按用户作用域屏蔽代理、费率、Base URL 等管理员字段。同时补充用户批量更新 API、disabled 状态文案与相关测试,并修正用户仪表盘本地日期格式化。
1 parent 0cce395 commit 3bac382

12 files changed

Lines changed: 679 additions & 30 deletions

File tree

backend/internal/handler/user_account_handler.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ type updateUserAccountRequest struct {
9595
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
9696
}
9797

98+
type bulkUpdateUserAccountsRequest struct {
99+
AccountIDs []int64 `json:"account_ids"`
100+
ProxyID *int64 `json:"proxy_id"`
101+
Concurrency *int `json:"concurrency"`
102+
LoadFactor *int `json:"load_factor"`
103+
Priority *int `json:"priority"`
104+
RateMultiplier *float64 `json:"rate_multiplier"`
105+
Status string `json:"status" binding:"omitempty,oneof=active disabled inactive"`
106+
Schedulable *bool `json:"schedulable"`
107+
GroupIDs *[]int64 `json:"group_ids"`
108+
Credentials map[string]any `json:"credentials"`
109+
Extra map[string]any `json:"extra"`
110+
}
111+
98112
type userOAuthProxyRequest struct {
99113
ProxyID *int64 `json:"proxy_id"`
100114
}
@@ -717,6 +731,79 @@ func (h *UserAccountHandler) Update(c *gin.Context) {
717731
response.Success(c, dto.AccountFromService(account))
718732
}
719733

734+
func (h *UserAccountHandler) BulkUpdate(c *gin.Context) {
735+
subject, ok := middleware2.GetAuthSubjectFromContext(c)
736+
if !ok {
737+
response.Unauthorized(c, "User not authenticated")
738+
return
739+
}
740+
741+
var req bulkUpdateUserAccountsRequest
742+
if err := c.ShouldBindJSON(&req); err != nil {
743+
response.BadRequest(c, "Invalid request: "+err.Error())
744+
return
745+
}
746+
accountIDs := normalizeUserAccountIDList(req.AccountIDs)
747+
if len(accountIDs) == 0 {
748+
response.BadRequest(c, "account_ids is required")
749+
return
750+
}
751+
if !rejectUserProxyID(c, req.ProxyID) {
752+
return
753+
}
754+
if req.RateMultiplier != nil {
755+
response.BadRequest(c, "rate_multiplier is not allowed for user accounts")
756+
return
757+
}
758+
759+
status := strings.ToLower(strings.TrimSpace(req.Status))
760+
if status == "inactive" {
761+
status = service.StatusDisabled
762+
}
763+
if req.Concurrency != nil && *req.Concurrency <= 0 {
764+
response.BadRequest(c, "concurrency must be > 0")
765+
return
766+
}
767+
if req.Priority != nil && *req.Priority <= 0 {
768+
response.BadRequest(c, "priority must be > 0")
769+
return
770+
}
771+
if req.LoadFactor != nil && *req.LoadFactor > 10000 {
772+
response.BadRequest(c, "load_factor must be <= 10000")
773+
return
774+
}
775+
776+
hasUpdates := req.Concurrency != nil ||
777+
req.LoadFactor != nil ||
778+
req.Priority != nil ||
779+
status != "" ||
780+
req.Schedulable != nil ||
781+
req.GroupIDs != nil ||
782+
len(req.Credentials) > 0 ||
783+
len(req.Extra) > 0
784+
if !hasUpdates {
785+
response.BadRequest(c, "No updates provided")
786+
return
787+
}
788+
789+
result, err := h.accountService.BulkUpdateOwned(c.Request.Context(), subject.UserID, &service.BulkUpdateOwnedAccountsInput{
790+
AccountIDs: accountIDs,
791+
Concurrency: req.Concurrency,
792+
LoadFactor: req.LoadFactor,
793+
Priority: req.Priority,
794+
Status: status,
795+
Schedulable: req.Schedulable,
796+
GroupIDs: req.GroupIDs,
797+
Credentials: req.Credentials,
798+
Extra: req.Extra,
799+
})
800+
if err != nil {
801+
response.ErrorFrom(c, err)
802+
return
803+
}
804+
response.Success(c, result)
805+
}
806+
720807
func (h *UserAccountHandler) Delete(c *gin.Context) {
721808
subject, ok := middleware2.GetAuthSubjectFromContext(c)
722809
if !ok {

backend/internal/server/routes/user.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func RegisterUserRoutes(
7373
accounts.POST("", h.UserAccount.Create)
7474
accounts.POST("/import", h.UserAccount.Import)
7575
accounts.POST("/import-credentials", h.UserAccount.ImportCredentials)
76+
accounts.POST("/bulk-update", h.UserAccount.BulkUpdate)
7677
accounts.PUT("/:id", h.UserAccount.Update)
7778
accounts.DELETE("/:id", h.UserAccount.Delete)
7879
}

backend/internal/service/account_service.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,18 @@ type AccountListFilters struct {
177177
PrivacyMode string
178178
}
179179

180+
type BulkUpdateOwnedAccountsInput struct {
181+
AccountIDs []int64
182+
Concurrency *int
183+
Priority *int
184+
LoadFactor *int
185+
Status string
186+
Schedulable *bool
187+
GroupIDs *[]int64
188+
Credentials map[string]any
189+
Extra map[string]any
190+
}
191+
180192
// NewAccountService 创建账号服务实例
181193
func NewAccountService(
182194
accountRepo AccountRepository,
@@ -640,6 +652,157 @@ func (s *AccountService) DeleteOwned(ctx context.Context, ownerUserID, accountID
640652
// Delete 删除账号
641653
// 优化:使用 ExistsByID 替代 GetByID 进行存在性检查,
642654
// 避免加载完整账号对象及其关联数据,提升删除操作的性能
655+
func normalizeOwnedBulkAccountIDs(ids []int64) []int64 {
656+
if len(ids) == 0 {
657+
return nil
658+
}
659+
out := make([]int64, 0, len(ids))
660+
seen := make(map[int64]struct{}, len(ids))
661+
for _, id := range ids {
662+
if id <= 0 {
663+
continue
664+
}
665+
if _, ok := seen[id]; ok {
666+
continue
667+
}
668+
seen[id] = struct{}{}
669+
out = append(out, id)
670+
}
671+
return out
672+
}
673+
674+
func normalizeOwnedBulkStatus(status string) (string, error) {
675+
normalized := strings.ToLower(strings.TrimSpace(status))
676+
if normalized == "" {
677+
return "", nil
678+
}
679+
if normalized == "inactive" {
680+
normalized = StatusDisabled
681+
}
682+
switch normalized {
683+
case StatusActive, StatusDisabled:
684+
return normalized, nil
685+
default:
686+
return "", fmt.Errorf("invalid account status: %s", status)
687+
}
688+
}
689+
690+
func mergeAccountMap(current map[string]any, updates map[string]any) map[string]any {
691+
if len(current) == 0 && len(updates) == 0 {
692+
return nil
693+
}
694+
next := make(map[string]any, len(current)+len(updates))
695+
for key, value := range current {
696+
next[key] = value
697+
}
698+
for key, value := range updates {
699+
next[key] = value
700+
}
701+
return next
702+
}
703+
704+
func (s *AccountService) BulkUpdateOwned(ctx context.Context, ownerUserID int64, input *BulkUpdateOwnedAccountsInput) (*BulkUpdateAccountsResult, error) {
705+
if ownerUserID <= 0 {
706+
return nil, ErrUserNotFound
707+
}
708+
if input == nil {
709+
return nil, ErrAccountNilInput
710+
}
711+
712+
accountIDs := normalizeOwnedBulkAccountIDs(input.AccountIDs)
713+
result := &BulkUpdateAccountsResult{
714+
SuccessIDs: make([]int64, 0, len(accountIDs)),
715+
FailedIDs: make([]int64, 0, len(accountIDs)),
716+
Results: make([]BulkUpdateAccountResult, 0, len(accountIDs)),
717+
}
718+
if len(accountIDs) == 0 {
719+
return result, nil
720+
}
721+
722+
if input.Concurrency != nil && *input.Concurrency <= 0 {
723+
return nil, fmt.Errorf("concurrency must be > 0")
724+
}
725+
if input.Priority != nil && *input.Priority <= 0 {
726+
return nil, fmt.Errorf("priority must be > 0")
727+
}
728+
if input.LoadFactor != nil && *input.LoadFactor > 10000 {
729+
return nil, fmt.Errorf("load_factor must be <= 10000")
730+
}
731+
status, err := normalizeOwnedBulkStatus(input.Status)
732+
if err != nil {
733+
return nil, err
734+
}
735+
736+
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDs)
737+
if err != nil {
738+
return nil, fmt.Errorf("get accounts: %w", err)
739+
}
740+
accountsByID := make(map[int64]*Account, len(accounts))
741+
for _, account := range accounts {
742+
if account != nil {
743+
accountsByID[account.ID] = account
744+
}
745+
}
746+
747+
validatedGroupIDsByAccount := make(map[int64][]int64, len(accountIDs))
748+
for _, accountID := range accountIDs {
749+
account := accountsByID[accountID]
750+
if account == nil || account.OwnerUserID == nil || *account.OwnerUserID != ownerUserID {
751+
return nil, ErrAccountNotFound
752+
}
753+
754+
nextCredentials := mergeAccountMap(account.Credentials, input.Credentials)
755+
nextExtra := mergeAccountMap(account.Extra, input.Extra)
756+
if err := validateOwnedAccountSource(account.Type, nextCredentials, nextExtra); err != nil {
757+
return nil, err
758+
}
759+
760+
if input.GroupIDs != nil {
761+
groupIDs, err := s.validateOwnedAccountGroupBinding(ctx, ownerUserID, account.Platform, account.Type, *input.GroupIDs)
762+
if err != nil {
763+
return nil, err
764+
}
765+
validatedGroupIDsByAccount[accountID] = groupIDs
766+
}
767+
}
768+
769+
repoUpdates := AccountBulkUpdate{
770+
Concurrency: input.Concurrency,
771+
Priority: input.Priority,
772+
LoadFactor: input.LoadFactor,
773+
Schedulable: input.Schedulable,
774+
Credentials: input.Credentials,
775+
Extra: input.Extra,
776+
}
777+
if status != "" {
778+
repoUpdates.Status = &status
779+
}
780+
781+
if _, err := s.accountRepo.BulkUpdate(ctx, accountIDs, repoUpdates); err != nil {
782+
return nil, fmt.Errorf("bulk update accounts: %w", err)
783+
}
784+
785+
for _, accountID := range accountIDs {
786+
entry := BulkUpdateAccountResult{AccountID: accountID}
787+
if input.GroupIDs != nil {
788+
if err := s.accountRepo.BindGroups(ctx, accountID, validatedGroupIDsByAccount[accountID]); err != nil {
789+
entry.Success = false
790+
entry.Error = err.Error()
791+
result.Failed++
792+
result.FailedIDs = append(result.FailedIDs, accountID)
793+
result.Results = append(result.Results, entry)
794+
continue
795+
}
796+
}
797+
entry.Success = true
798+
result.Success++
799+
result.SuccessIDs = append(result.SuccessIDs, accountID)
800+
result.Results = append(result.Results, entry)
801+
}
802+
803+
return result, nil
804+
}
805+
643806
func (s *AccountService) Delete(ctx context.Context, id int64) error {
644807
// 使用轻量级的存在性检查,而非加载完整账号对象
645808
exists, err := s.accountRepo.ExistsByID(ctx, id)

frontend/src/api/accounts.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ export async function toggleStatus(id: number, status: 'active' | 'disabled'): P
9999
return update(id, { status })
100100
}
101101

102+
export async function bulkUpdate(
103+
accountIds: number[],
104+
updates: Partial<UpdateAccountRequest>
105+
): Promise<{
106+
success: number
107+
failed: number
108+
success_ids?: number[]
109+
failed_ids?: number[]
110+
results: Array<{ account_id: number; success: boolean; error?: string }>
111+
}> {
112+
const { data } = await apiClient.post<{
113+
success: number
114+
failed: number
115+
success_ids?: number[]
116+
failed_ids?: number[]
117+
results: Array<{ account_id: number; success: boolean; error?: string }>
118+
}>('/accounts/bulk-update', {
119+
account_ids: accountIds,
120+
...updates
121+
})
122+
return data
123+
}
124+
102125
export async function getUsage(id: number, source?: 'passive' | 'active'): Promise<AccountUsageInfo> {
103126
const { data } = await apiClient.get<AccountUsageInfo>(`/accounts/${id}/usage`, {
104127
params: source ? { source } : undefined
@@ -326,6 +349,7 @@ export const accountsAPI = {
326349
update,
327350
delete: deleteAccount,
328351
toggleStatus,
352+
bulkUpdate,
329353
getUsage,
330354
getTodayStats,
331355
getBatchTodayStats,

0 commit comments

Comments
 (0)