From 979e1c1bf5e7aebc6ac2639437a1e1ffb247acfa Mon Sep 17 00:00:00 2001 From: erio Date: Thu, 12 Mar 2026 19:57:12 +0800 Subject: [PATCH] feat(groups): batch adjust rate multipliers with global factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PUT /admin/groups/:id/rate-multipliers batch endpoint - Add batch adjust UI: multiply all rates by a factor (e.g., ×0.5 to halve, ×2 to restore) - Remove inline rate editing (blur-save), rates now display as read-only - Backend: SyncGroupRateMultipliers repo method (delete + batch upsert) --- backend/cmd/server/VERSION | 2 +- .../handler/admin/admin_service_stub_test.go | 4 + .../internal/handler/admin/group_handler.go | 28 ++++++ .../repository/user_group_rate_repo.go | 25 ++++++ backend/internal/server/routes/admin.go | 1 + backend/internal/service/admin_service.go | 8 ++ .../service/admin_service_group_rate_test.go | 46 ++++++++++ .../service/admin_service_list_users_test.go | 8 +- backend/internal/service/user_group_rate.go | 9 ++ frontend/src/api/admin/groups.ts | 18 ++++ .../admin/group/GroupRateMultipliersModal.vue | 87 ++++++++++++------- frontend/src/i18n/locales/en.ts | 4 + frontend/src/i18n/locales/zh.ts | 4 + 13 files changed, 210 insertions(+), 34 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 4ef96867a4..844056b340 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.96.3 +0.1.96.4 diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 94627b06e3..c1ef9e53fd 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -183,6 +183,10 @@ func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) return nil } +func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error { + return nil +} + func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]service.Account, int64, error) { return s.accounts, int64(len(s.accounts)), nil } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index ca70aed751..badc1f87aa 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -377,6 +377,34 @@ func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) { response.Success(c, gin.H{"message": "Rate multipliers cleared successfully"}) } +// BatchSetGroupRateMultipliersRequest represents batch set rate multipliers request +type BatchSetGroupRateMultipliersRequest struct { + Entries []service.GroupRateMultiplierInput `json:"entries" binding:"required"` +} + +// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group +// PUT /api/v1/admin/groups/:id/rate-multipliers +func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) { + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid group ID") + return + } + + var req BatchSetGroupRateMultipliersRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + if err := h.adminService.BatchSetGroupRateMultipliers(c.Request.Context(), groupID, req.Entries); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "Rate multipliers updated successfully"}) +} + // UpdateSortOrderRequest represents the request to update group sort orders type UpdateSortOrderRequest struct { Updates []struct { diff --git a/backend/internal/repository/user_group_rate_repo.go b/backend/internal/repository/user_group_rate_repo.go index b8b6ca2132..e2471ae5b5 100644 --- a/backend/internal/repository/user_group_rate_repo.go +++ b/backend/internal/repository/user_group_rate_repo.go @@ -193,6 +193,31 @@ func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID return nil } +// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(先删后插) +func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []service.GroupRateMultiplierInput) error { + if _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID); err != nil { + return err + } + if len(entries) == 0 { + return nil + } + userIDs := make([]int64, len(entries)) + rates := make([]float64, len(entries)) + for i, e := range entries { + userIDs[i] = e.UserID + rates[i] = e.RateMultiplier + } + now := time.Now() + _, err := r.sql.ExecContext(ctx, ` + INSERT INTO user_group_rate_multipliers (user_id, group_id, rate_multiplier, created_at, updated_at) + SELECT data.user_id, $1::bigint, data.rate_multiplier, $2::timestamptz, $2::timestamptz + FROM unnest($3::bigint[], $4::double precision[]) AS data(user_id, rate_multiplier) + ON CONFLICT (user_id, group_id) + DO UPDATE SET rate_multiplier = EXCLUDED.rate_multiplier, updated_at = EXCLUDED.updated_at + `, groupID, now, pq.Array(userIDs), pq.Array(rates)) + return err +} + // DeleteByGroupID 删除指定分组的所有用户专属倍率 func (r *userGroupRateRepository) DeleteByGroupID(ctx context.Context, groupID int64) error { _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 7e6ea4ec2b..46c2ccded7 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -229,6 +229,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { groups.DELETE("/:id", h.Admin.Group.Delete) groups.GET("/:id/stats", h.Admin.Group.GetStats) groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers) + groups.PUT("/:id/rate-multipliers", h.Admin.Group.BatchSetGroupRateMultipliers) groups.DELETE("/:id/rate-multipliers", h.Admin.Group.ClearGroupRateMultipliers) groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys) } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index fc9f4836db..c4d2d34a13 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -44,6 +44,7 @@ type AdminService interface { GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error + BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error // API Key management (admin) @@ -1279,6 +1280,13 @@ func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupI return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID) } +func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error { + if s.userGroupRateRepo == nil { + return nil + } + return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries) +} + func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { return s.groupRepo.UpdateSortOrders(ctx, updates) } diff --git a/backend/internal/service/admin_service_group_rate_test.go b/backend/internal/service/admin_service_group_rate_test.go index f20016a904..33278a211b 100644 --- a/backend/internal/service/admin_service_group_rate_test.go +++ b/backend/internal/service/admin_service_group_rate_test.go @@ -17,6 +17,10 @@ type userGroupRateRepoStubForGroupRate struct { deletedGroupIDs []int64 deleteByGroupErr error + + syncedGroupID int64 + syncedEntries []GroupRateMultiplierInput + syncGroupErr error } func (s *userGroupRateRepoStubForGroupRate) GetByUserID(_ context.Context, _ int64) (map[int64]float64, error) { @@ -38,6 +42,12 @@ func (s *userGroupRateRepoStubForGroupRate) SyncUserGroupRates(_ context.Context panic("unexpected SyncUserGroupRates call") } +func (s *userGroupRateRepoStubForGroupRate) SyncGroupRateMultipliers(_ context.Context, groupID int64, entries []GroupRateMultiplierInput) error { + s.syncedGroupID = groupID + s.syncedEntries = entries + return s.syncGroupErr +} + func (s *userGroupRateRepoStubForGroupRate) DeleteByGroupID(_ context.Context, groupID int64) error { s.deletedGroupIDs = append(s.deletedGroupIDs, groupID) return s.deleteByGroupErr @@ -128,3 +138,39 @@ func TestAdminService_ClearGroupRateMultipliers(t *testing.T) { require.Contains(t, err.Error(), "delete failed") }) } + +func TestAdminService_BatchSetGroupRateMultipliers(t *testing.T) { + t.Run("syncs entries to repo", func(t *testing.T) { + repo := &userGroupRateRepoStubForGroupRate{} + svc := &adminServiceImpl{userGroupRateRepo: repo} + + entries := []GroupRateMultiplierInput{ + {UserID: 1, RateMultiplier: 1.5}, + {UserID: 2, RateMultiplier: 0.8}, + } + err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, entries) + require.NoError(t, err) + require.Equal(t, int64(10), repo.syncedGroupID) + require.Equal(t, entries, repo.syncedEntries) + }) + + t.Run("returns nil when repo is nil", func(t *testing.T) { + svc := &adminServiceImpl{userGroupRateRepo: nil} + + err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, nil) + require.NoError(t, err) + }) + + t.Run("propagates repo error", func(t *testing.T) { + repo := &userGroupRateRepoStubForGroupRate{ + syncGroupErr: errors.New("sync failed"), + } + svc := &adminServiceImpl{userGroupRateRepo: repo} + + err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, []GroupRateMultiplierInput{ + {UserID: 1, RateMultiplier: 1.0}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "sync failed") + }) +} diff --git a/backend/internal/service/admin_service_list_users_test.go b/backend/internal/service/admin_service_list_users_test.go index 579fa98108..37f348dfbd 100644 --- a/backend/internal/service/admin_service_list_users_test.go +++ b/backend/internal/service/admin_service_list_users_test.go @@ -68,11 +68,15 @@ func (s *userGroupRateRepoStubForListUsers) SyncUserGroupRates(_ context.Context panic("unexpected SyncUserGroupRates call") } -func (s *userGroupRateRepoStubForListUsers) GetByGroupID(_ context.Context, groupID int64) ([]UserGroupRateEntry, error) { +func (s *userGroupRateRepoStubForListUsers) GetByGroupID(_ context.Context, _ int64) ([]UserGroupRateEntry, error) { panic("unexpected GetByGroupID call") } -func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, groupID int64) error { +func (s *userGroupRateRepoStubForListUsers) SyncGroupRateMultipliers(_ context.Context, _ int64, _ []GroupRateMultiplierInput) error { + panic("unexpected SyncGroupRateMultipliers call") +} + +func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, _ int64) error { panic("unexpected DeleteByGroupID call") } diff --git a/backend/internal/service/user_group_rate.go b/backend/internal/service/user_group_rate.go index c6c55957c6..3d221a25e5 100644 --- a/backend/internal/service/user_group_rate.go +++ b/backend/internal/service/user_group_rate.go @@ -12,6 +12,12 @@ type UserGroupRateEntry struct { RateMultiplier float64 `json:"rate_multiplier"` } +// GroupRateMultiplierInput 批量设置分组倍率的输入条目 +type GroupRateMultiplierInput struct { + UserID int64 `json:"user_id"` + RateMultiplier float64 `json:"rate_multiplier"` +} + // UserGroupRateRepository 用户专属分组倍率仓储接口 // 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率 type UserGroupRateRepository interface { @@ -30,6 +36,9 @@ type UserGroupRateRepository interface { // rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率 SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error + // SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组数据) + SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error + // DeleteByGroupID 删除指定分组的所有用户专属倍率(分组删除时调用) DeleteByGroupID(ctx context.Context, groupID int64) error diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index b2d089d941..7c2658fa45 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -201,6 +201,23 @@ export async function clearGroupRateMultipliers(id: number): Promise<{ message: return data } +/** + * Batch set rate multipliers for users in a group + * @param id - Group ID + * @param entries - Array of { user_id, rate_multiplier } + * @returns Success confirmation + */ +export async function batchSetGroupRateMultipliers( + id: number, + entries: Array<{ user_id: number; rate_multiplier: number }> +): Promise<{ message: string }> { + const { data } = await apiClient.put<{ message: string }>( + `/admin/groups/${id}/rate-multipliers`, + { entries } + ) + return data +} + export const groupsAPI = { list, getAll, @@ -214,6 +231,7 @@ export const groupsAPI = { getGroupApiKeys, getGroupRateMultipliers, clearGroupRateMultipliers, + batchSetGroupRateMultipliers, updateSortOrder } diff --git a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue index cbb202e9ab..404167b6db 100644 --- a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue +++ b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue @@ -12,7 +12,7 @@ - +

{{ t('admin.groups.addUserRate') }} @@ -65,16 +65,43 @@ {{ t('common.add') }} - +

+ + +
+ {{ t('admin.groups.batchAdjust') }} +
+ × + + +
+
+ +
@@ -134,17 +161,8 @@ {{ entry.user_status }} - - + + {{ entry.rate_multiplier }}