Skip to content

Commit 1ee9844

Browse files
authored
Merge pull request Wei-Shaw#957 from touwaeriol/feat/group-rate-multipliers-modal
feat(groups): add rate multipliers management modal
2 parents fd693dc + d648811 commit 1ee9844

13 files changed

Lines changed: 989 additions & 3 deletions

File tree

backend/internal/handler/admin/admin_service_stub_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, p
175175
return s.apiKeys, int64(len(s.apiKeys)), nil
176176
}
177177

178+
func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) ([]service.UserGroupRateEntry, error) {
179+
return nil, nil
180+
}
181+
182+
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
183+
return nil
184+
}
185+
186+
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
187+
return nil
188+
}
189+
178190
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]service.Account, int64, error) {
179191
return s.accounts, int64(len(s.accounts)), nil
180192
}

backend/internal/handler/admin/group_handler.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,72 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
335335
response.Paginated(c, outKeys, total, page, pageSize)
336336
}
337337

338+
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
339+
// GET /api/v1/admin/groups/:id/rate-multipliers
340+
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
341+
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
342+
if err != nil {
343+
response.BadRequest(c, "Invalid group ID")
344+
return
345+
}
346+
347+
entries, err := h.adminService.GetGroupRateMultipliers(c.Request.Context(), groupID)
348+
if err != nil {
349+
response.ErrorFrom(c, err)
350+
return
351+
}
352+
353+
if entries == nil {
354+
entries = []service.UserGroupRateEntry{}
355+
}
356+
response.Success(c, entries)
357+
}
358+
359+
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
360+
// DELETE /api/v1/admin/groups/:id/rate-multipliers
361+
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
362+
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
363+
if err != nil {
364+
response.BadRequest(c, "Invalid group ID")
365+
return
366+
}
367+
368+
if err := h.adminService.ClearGroupRateMultipliers(c.Request.Context(), groupID); err != nil {
369+
response.ErrorFrom(c, err)
370+
return
371+
}
372+
373+
response.Success(c, gin.H{"message": "Rate multipliers cleared successfully"})
374+
}
375+
376+
// BatchSetGroupRateMultipliersRequest represents batch set rate multipliers request
377+
type BatchSetGroupRateMultipliersRequest struct {
378+
Entries []service.GroupRateMultiplierInput `json:"entries" binding:"required"`
379+
}
380+
381+
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
382+
// PUT /api/v1/admin/groups/:id/rate-multipliers
383+
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
384+
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
385+
if err != nil {
386+
response.BadRequest(c, "Invalid group ID")
387+
return
388+
}
389+
390+
var req BatchSetGroupRateMultipliersRequest
391+
if err := c.ShouldBindJSON(&req); err != nil {
392+
response.BadRequest(c, "Invalid request: "+err.Error())
393+
return
394+
}
395+
396+
if err := h.adminService.BatchSetGroupRateMultipliers(c.Request.Context(), groupID, req.Entries); err != nil {
397+
response.ErrorFrom(c, err)
398+
return
399+
}
400+
401+
response.Success(c, gin.H{"message": "Rate multipliers updated successfully"})
402+
}
403+
338404
// UpdateSortOrderRequest represents the request to update group sort orders
339405
type UpdateSortOrderRequest struct {
340406
Updates []struct {

backend/internal/repository/user_group_rate_repo.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,35 @@ func (r *userGroupRateRepository) GetByUserIDs(ctx context.Context, userIDs []in
9595
return result, nil
9696
}
9797

98+
// GetByGroupID 获取指定分组下所有用户的专属倍率
99+
func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int64) ([]service.UserGroupRateEntry, error) {
100+
query := `
101+
SELECT ugr.user_id, u.username, u.email, COALESCE(u.notes, ''), u.status, ugr.rate_multiplier
102+
FROM user_group_rate_multipliers ugr
103+
JOIN users u ON u.id = ugr.user_id
104+
WHERE ugr.group_id = $1
105+
ORDER BY ugr.user_id
106+
`
107+
rows, err := r.sql.QueryContext(ctx, query, groupID)
108+
if err != nil {
109+
return nil, err
110+
}
111+
defer func() { _ = rows.Close() }()
112+
113+
var result []service.UserGroupRateEntry
114+
for rows.Next() {
115+
var entry service.UserGroupRateEntry
116+
if err := rows.Scan(&entry.UserID, &entry.UserName, &entry.UserEmail, &entry.UserNotes, &entry.UserStatus, &entry.RateMultiplier); err != nil {
117+
return nil, err
118+
}
119+
result = append(result, entry)
120+
}
121+
if err := rows.Err(); err != nil {
122+
return nil, err
123+
}
124+
return result, nil
125+
}
126+
98127
// GetByUserAndGroup 获取用户在特定分组的专属倍率
99128
func (r *userGroupRateRepository) GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) {
100129
query := `SELECT rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2`
@@ -164,6 +193,31 @@ func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID
164193
return nil
165194
}
166195

196+
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(先删后插)
197+
func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []service.GroupRateMultiplierInput) error {
198+
if _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID); err != nil {
199+
return err
200+
}
201+
if len(entries) == 0 {
202+
return nil
203+
}
204+
userIDs := make([]int64, len(entries))
205+
rates := make([]float64, len(entries))
206+
for i, e := range entries {
207+
userIDs[i] = e.UserID
208+
rates[i] = e.RateMultiplier
209+
}
210+
now := time.Now()
211+
_, err := r.sql.ExecContext(ctx, `
212+
INSERT INTO user_group_rate_multipliers (user_id, group_id, rate_multiplier, created_at, updated_at)
213+
SELECT data.user_id, $1::bigint, data.rate_multiplier, $2::timestamptz, $2::timestamptz
214+
FROM unnest($3::bigint[], $4::double precision[]) AS data(user_id, rate_multiplier)
215+
ON CONFLICT (user_id, group_id)
216+
DO UPDATE SET rate_multiplier = EXCLUDED.rate_multiplier, updated_at = EXCLUDED.updated_at
217+
`, groupID, now, pq.Array(userIDs), pq.Array(rates))
218+
return err
219+
}
220+
167221
// DeleteByGroupID 删除指定分组的所有用户专属倍率
168222
func (r *userGroupRateRepository) DeleteByGroupID(ctx context.Context, groupID int64) error {
169223
_, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID)

backend/internal/server/routes/admin.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,9 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
228228
groups.PUT("/:id", h.Admin.Group.Update)
229229
groups.DELETE("/:id", h.Admin.Group.Delete)
230230
groups.GET("/:id/stats", h.Admin.Group.GetStats)
231+
groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers)
232+
groups.PUT("/:id/rate-multipliers", h.Admin.Group.BatchSetGroupRateMultipliers)
233+
groups.DELETE("/:id/rate-multipliers", h.Admin.Group.ClearGroupRateMultipliers)
231234
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
232235
}
233236
}

backend/internal/service/admin_service.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ type AdminService interface {
4242
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
4343
DeleteGroup(ctx context.Context, id int64) error
4444
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error)
45+
GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
46+
ClearGroupRateMultipliers(ctx context.Context, groupID int64) error
47+
BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
4548
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
4649

4750
// API Key management (admin)
@@ -1249,6 +1252,27 @@ func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, p
12491252
return keys, result.Total, nil
12501253
}
12511254

1255+
func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) {
1256+
if s.userGroupRateRepo == nil {
1257+
return nil, nil
1258+
}
1259+
return s.userGroupRateRepo.GetByGroupID(ctx, groupID)
1260+
}
1261+
1262+
func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error {
1263+
if s.userGroupRateRepo == nil {
1264+
return nil
1265+
}
1266+
return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID)
1267+
}
1268+
1269+
func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
1270+
if s.userGroupRateRepo == nil {
1271+
return nil
1272+
}
1273+
return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries)
1274+
}
1275+
12521276
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
12531277
return s.groupRepo.UpdateSortOrders(ctx, updates)
12541278
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
//go:build unit
2+
3+
package service
4+
5+
import (
6+
"context"
7+
"errors"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// userGroupRateRepoStubForGroupRate implements UserGroupRateRepository for group rate tests.
14+
type userGroupRateRepoStubForGroupRate struct {
15+
getByGroupIDData map[int64][]UserGroupRateEntry
16+
getByGroupIDErr error
17+
18+
deletedGroupIDs []int64
19+
deleteByGroupErr error
20+
21+
syncedGroupID int64
22+
syncedEntries []GroupRateMultiplierInput
23+
syncGroupErr error
24+
}
25+
26+
func (s *userGroupRateRepoStubForGroupRate) GetByUserID(_ context.Context, _ int64) (map[int64]float64, error) {
27+
panic("unexpected GetByUserID call")
28+
}
29+
30+
func (s *userGroupRateRepoStubForGroupRate) GetByUserAndGroup(_ context.Context, _, _ int64) (*float64, error) {
31+
panic("unexpected GetByUserAndGroup call")
32+
}
33+
34+
func (s *userGroupRateRepoStubForGroupRate) GetByGroupID(_ context.Context, groupID int64) ([]UserGroupRateEntry, error) {
35+
if s.getByGroupIDErr != nil {
36+
return nil, s.getByGroupIDErr
37+
}
38+
return s.getByGroupIDData[groupID], nil
39+
}
40+
41+
func (s *userGroupRateRepoStubForGroupRate) SyncUserGroupRates(_ context.Context, _ int64, _ map[int64]*float64) error {
42+
panic("unexpected SyncUserGroupRates call")
43+
}
44+
45+
func (s *userGroupRateRepoStubForGroupRate) SyncGroupRateMultipliers(_ context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
46+
s.syncedGroupID = groupID
47+
s.syncedEntries = entries
48+
return s.syncGroupErr
49+
}
50+
51+
func (s *userGroupRateRepoStubForGroupRate) DeleteByGroupID(_ context.Context, groupID int64) error {
52+
s.deletedGroupIDs = append(s.deletedGroupIDs, groupID)
53+
return s.deleteByGroupErr
54+
}
55+
56+
func (s *userGroupRateRepoStubForGroupRate) DeleteByUserID(_ context.Context, _ int64) error {
57+
panic("unexpected DeleteByUserID call")
58+
}
59+
60+
func TestAdminService_GetGroupRateMultipliers(t *testing.T) {
61+
t.Run("returns entries for group", func(t *testing.T) {
62+
repo := &userGroupRateRepoStubForGroupRate{
63+
getByGroupIDData: map[int64][]UserGroupRateEntry{
64+
10: {
65+
{UserID: 1, UserName: "alice", UserEmail: "alice@test.com", RateMultiplier: 1.5},
66+
{UserID: 2, UserName: "bob", UserEmail: "bob@test.com", RateMultiplier: 0.8},
67+
},
68+
},
69+
}
70+
svc := &adminServiceImpl{userGroupRateRepo: repo}
71+
72+
entries, err := svc.GetGroupRateMultipliers(context.Background(), 10)
73+
require.NoError(t, err)
74+
require.Len(t, entries, 2)
75+
require.Equal(t, int64(1), entries[0].UserID)
76+
require.Equal(t, "alice", entries[0].UserName)
77+
require.Equal(t, 1.5, entries[0].RateMultiplier)
78+
require.Equal(t, int64(2), entries[1].UserID)
79+
require.Equal(t, 0.8, entries[1].RateMultiplier)
80+
})
81+
82+
t.Run("returns nil when repo is nil", func(t *testing.T) {
83+
svc := &adminServiceImpl{userGroupRateRepo: nil}
84+
85+
entries, err := svc.GetGroupRateMultipliers(context.Background(), 10)
86+
require.NoError(t, err)
87+
require.Nil(t, entries)
88+
})
89+
90+
t.Run("returns empty slice for group with no entries", func(t *testing.T) {
91+
repo := &userGroupRateRepoStubForGroupRate{
92+
getByGroupIDData: map[int64][]UserGroupRateEntry{},
93+
}
94+
svc := &adminServiceImpl{userGroupRateRepo: repo}
95+
96+
entries, err := svc.GetGroupRateMultipliers(context.Background(), 99)
97+
require.NoError(t, err)
98+
require.Nil(t, entries)
99+
})
100+
101+
t.Run("propagates repo error", func(t *testing.T) {
102+
repo := &userGroupRateRepoStubForGroupRate{
103+
getByGroupIDErr: errors.New("db error"),
104+
}
105+
svc := &adminServiceImpl{userGroupRateRepo: repo}
106+
107+
_, err := svc.GetGroupRateMultipliers(context.Background(), 10)
108+
require.Error(t, err)
109+
require.Contains(t, err.Error(), "db error")
110+
})
111+
}
112+
113+
func TestAdminService_ClearGroupRateMultipliers(t *testing.T) {
114+
t.Run("deletes by group ID", func(t *testing.T) {
115+
repo := &userGroupRateRepoStubForGroupRate{}
116+
svc := &adminServiceImpl{userGroupRateRepo: repo}
117+
118+
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
119+
require.NoError(t, err)
120+
require.Equal(t, []int64{42}, repo.deletedGroupIDs)
121+
})
122+
123+
t.Run("returns nil when repo is nil", func(t *testing.T) {
124+
svc := &adminServiceImpl{userGroupRateRepo: nil}
125+
126+
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
127+
require.NoError(t, err)
128+
})
129+
130+
t.Run("propagates repo error", func(t *testing.T) {
131+
repo := &userGroupRateRepoStubForGroupRate{
132+
deleteByGroupErr: errors.New("delete failed"),
133+
}
134+
svc := &adminServiceImpl{userGroupRateRepo: repo}
135+
136+
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
137+
require.Error(t, err)
138+
require.Contains(t, err.Error(), "delete failed")
139+
})
140+
}
141+
142+
func TestAdminService_BatchSetGroupRateMultipliers(t *testing.T) {
143+
t.Run("syncs entries to repo", func(t *testing.T) {
144+
repo := &userGroupRateRepoStubForGroupRate{}
145+
svc := &adminServiceImpl{userGroupRateRepo: repo}
146+
147+
entries := []GroupRateMultiplierInput{
148+
{UserID: 1, RateMultiplier: 1.5},
149+
{UserID: 2, RateMultiplier: 0.8},
150+
}
151+
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, entries)
152+
require.NoError(t, err)
153+
require.Equal(t, int64(10), repo.syncedGroupID)
154+
require.Equal(t, entries, repo.syncedEntries)
155+
})
156+
157+
t.Run("returns nil when repo is nil", func(t *testing.T) {
158+
svc := &adminServiceImpl{userGroupRateRepo: nil}
159+
160+
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, nil)
161+
require.NoError(t, err)
162+
})
163+
164+
t.Run("propagates repo error", func(t *testing.T) {
165+
repo := &userGroupRateRepoStubForGroupRate{
166+
syncGroupErr: errors.New("sync failed"),
167+
}
168+
svc := &adminServiceImpl{userGroupRateRepo: repo}
169+
170+
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, []GroupRateMultiplierInput{
171+
{UserID: 1, RateMultiplier: 1.0},
172+
})
173+
require.Error(t, err)
174+
require.Contains(t, err.Error(), "sync failed")
175+
})
176+
}

0 commit comments

Comments
 (0)