Skip to content

Commit ba7d2ae

Browse files
author
QTom
committed
feat(admin): 用户管理新增分组列、分组筛选与专属分组一键替换
- 新增分组列:展示用户的专属/公开分组,支持 hover 查看详情 - 新增分组筛选:下拉选择或模糊搜索分组名过滤用户 - 专属分组替换:点击专属分组弹出操作菜单,选择目标分组后 自动授予新分组权限、迁移绑定的 Key、移除旧分组权限 - 后端新增 POST /admin/users/:id/replace-group 端点,事务内 完成分组替换并失效认证缓存
1 parent 0236b97 commit ba7d2ae

29 files changed

Lines changed: 594 additions & 9 deletions

backend/internal/handler/admin/admin_service_stub_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,5 +445,9 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser
445445
return ""
446446
}
447447

448+
func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) {
449+
return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil
450+
}
451+
448452
// Ensure stub implements interface.
449453
var _ service.AdminService = (*stubAdminService)(nil)

backend/internal/handler/admin/user_handler.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ type UpdateBalanceRequest struct {
7575
// - role: filter by user role
7676
// - search: search in email, username
7777
// - attr[{id}]: filter by custom attribute value, e.g. attr[1]=company
78+
// - group_name: fuzzy filter by allowed group name
7879
func (h *UserHandler) List(c *gin.Context) {
7980
page, pageSize := response.ParsePagination(c)
8081

@@ -89,6 +90,7 @@ func (h *UserHandler) List(c *gin.Context) {
8990
Status: c.Query("status"),
9091
Role: c.Query("role"),
9192
Search: search,
93+
GroupName: strings.TrimSpace(c.Query("group_name")),
9294
Attributes: parseAttributeFilters(c),
9395
}
9496
if raw, ok := c.GetQuery("include_subscriptions"); ok {
@@ -366,3 +368,35 @@ func (h *UserHandler) GetBalanceHistory(c *gin.Context) {
366368
"total_recharged": totalRecharged,
367369
})
368370
}
371+
372+
// ReplaceGroupRequest represents the request to replace a user's exclusive group
373+
type ReplaceGroupRequest struct {
374+
OldGroupID int64 `json:"old_group_id" binding:"required,gt=0"`
375+
NewGroupID int64 `json:"new_group_id" binding:"required,gt=0"`
376+
}
377+
378+
// ReplaceGroup handles replacing a user's exclusive group
379+
// POST /api/v1/admin/users/:id/replace-group
380+
func (h *UserHandler) ReplaceGroup(c *gin.Context) {
381+
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
382+
if err != nil {
383+
response.BadRequest(c, "Invalid user ID")
384+
return
385+
}
386+
387+
var req ReplaceGroupRequest
388+
if err := c.ShouldBindJSON(&req); err != nil {
389+
response.BadRequest(c, "Invalid request: "+err.Error())
390+
return
391+
}
392+
393+
result, err := h.adminService.ReplaceUserGroup(c.Request.Context(), userID, req.OldGroupID, req.NewGroupID)
394+
if err != nil {
395+
response.ErrorFrom(c, err)
396+
return
397+
}
398+
399+
response.Success(c, gin.H{
400+
"migrated_keys": result.MigratedKeys,
401+
})
402+
}

backend/internal/handler/sora_client_handler_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,9 @@ func (r *stubUserRepoForHandler) ExistsByEmail(context.Context, string) (bool, e
942942
func (r *stubUserRepoForHandler) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
943943
return 0, nil
944944
}
945+
func (r *stubUserRepoForHandler) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
946+
return nil
947+
}
945948
func (r *stubUserRepoForHandler) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
946949
func (r *stubUserRepoForHandler) EnableTotp(context.Context, int64) error { return nil }
947950
func (r *stubUserRepoForHandler) DisableTotp(context.Context, int64) error { return nil }
@@ -1017,6 +1020,20 @@ func (r *stubAPIKeyRepoForHandler) SearchAPIKeys(context.Context, int64, string,
10171020
func (r *stubAPIKeyRepoForHandler) ClearGroupIDByGroupID(context.Context, int64) (int64, error) {
10181021
return 0, nil
10191022
}
1023+
func (r *stubAPIKeyRepoForHandler) UpdateGroupIDByUserAndGroup(_ context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
1024+
var updated int64
1025+
for id, key := range r.keys {
1026+
if key.UserID != userID || key.GroupID == nil || *key.GroupID != oldGroupID {
1027+
continue
1028+
}
1029+
clone := *key
1030+
gid := newGroupID
1031+
clone.GroupID = &gid
1032+
r.keys[id] = &clone
1033+
updated++
1034+
}
1035+
return updated, nil
1036+
}
10201037
func (r *stubAPIKeyRepoForHandler) CountByGroupID(context.Context, int64) (int64, error) {
10211038
return 0, nil
10221039
}

backend/internal/repository/api_key_repo.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,16 @@ func (r *apiKeyRepository) ClearGroupIDByGroupID(ctx context.Context, groupID in
409409
return int64(n), err
410410
}
411411

412+
// UpdateGroupIDByUserAndGroup 将用户下绑定 oldGroupID 的所有 Key 迁移到 newGroupID
413+
func (r *apiKeyRepository) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
414+
client := clientFromContext(ctx, r.client)
415+
n, err := client.APIKey.Update().
416+
Where(apikey.UserIDEQ(userID), apikey.GroupIDEQ(oldGroupID), apikey.DeletedAtIsNil()).
417+
SetGroupID(newGroupID).
418+
Save(ctx)
419+
return int64(n), err
420+
}
421+
412422
// CountByGroupID 获取分组的 API Key 数量
413423
func (r *apiKeyRepository) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
414424
count, err := r.activeQuery().Where(apikey.GroupIDEQ(groupID)).Count(ctx)

backend/internal/repository/user_repo.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
dbent "github.com/Wei-Shaw/sub2api/ent"
1313
"github.com/Wei-Shaw/sub2api/ent/apikey"
14+
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
1415
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
1516
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
1617
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -200,6 +201,12 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
200201
)
201202
}
202203

204+
if filters.GroupName != "" {
205+
q = q.Where(dbuser.HasAllowedGroupsWith(
206+
dbgroup.NameContainsFold(filters.GroupName),
207+
))
208+
}
209+
203210
// If attribute filters are specified, we need to filter by user IDs first
204211
var allowedUserIDs []int64
205212
if len(filters.Attributes) > 0 {
@@ -453,6 +460,15 @@ func (r *userRepository) RemoveGroupFromAllowedGroups(ctx context.Context, group
453460
return int64(affected), nil
454461
}
455462

463+
// RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
464+
func (r *userRepository) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
465+
client := clientFromContext(ctx, r.client)
466+
_, err := client.UserAllowedGroup.Delete().
467+
Where(userallowedgroup.UserIDEQ(userID), userallowedgroup.GroupIDEQ(groupID)).
468+
Exec(ctx)
469+
return err
470+
}
471+
456472
func (r *userRepository) GetFirstAdmin(ctx context.Context) (*service.User, error) {
457473
m, err := r.client.User.Query().
458474
Where(

backend/internal/server/api_contract_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,10 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
807807
return 0, errors.New("not implemented")
808808
}
809809

810+
func (r *stubUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
811+
return errors.New("not implemented")
812+
}
813+
810814
func (r *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
811815
return errors.New("not implemented")
812816
}
@@ -1509,6 +1513,22 @@ func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int6
15091513
return 0, errors.New("not implemented")
15101514
}
15111515

1516+
func (r *stubApiKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
1517+
var updated int64
1518+
for id, key := range r.byID {
1519+
if key.UserID != userID || key.GroupID == nil || *key.GroupID != oldGroupID {
1520+
continue
1521+
}
1522+
clone := *key
1523+
gid := newGroupID
1524+
clone.GroupID = &gid
1525+
r.byID[id] = &clone
1526+
r.byKey[clone.Key] = &clone
1527+
updated++
1528+
}
1529+
return updated, nil
1530+
}
1531+
15121532
func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
15131533
return 0, errors.New("not implemented")
15141534
}

backend/internal/server/middleware/admin_auth_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
181181
panic("unexpected RemoveGroupFromAllowedGroups call")
182182
}
183183

184+
func (s *stubUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
185+
panic("unexpected RemoveGroupFromUserAllowedGroups call")
186+
}
187+
184188
func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
185189
panic("unexpected AddGroupToAllowedGroups call")
186190
}

backend/internal/server/middleware/api_key_auth_google_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ func (f fakeAPIKeyRepo) ResetRateLimitWindows(ctx context.Context, id int64) err
104104
func (f fakeAPIKeyRepo) GetRateLimitData(ctx context.Context, id int64) (*service.APIKeyRateLimitData, error) {
105105
return &service.APIKeyRateLimitData{}, nil
106106
}
107+
func (f fakeAPIKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
108+
return 0, errors.New("not implemented")
109+
}
107110

108111
func (f fakeGoogleSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error {
109112
return errors.New("not implemented")

backend/internal/server/middleware/api_key_auth_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,10 @@ func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int6
565565
return 0, errors.New("not implemented")
566566
}
567567

568+
func (r *stubApiKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
569+
return 0, errors.New("not implemented")
570+
}
571+
568572
func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
569573
return 0, errors.New("not implemented")
570574
}

backend/internal/server/routes/admin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
215215
users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys)
216216
users.GET("/:id/usage", h.Admin.User.GetUserUsage)
217217
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
218+
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
218219

219220
// User attribute values
220221
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)

0 commit comments

Comments
 (0)