From ba7d2aecbb365934903ada2305847da90bcdd106 Mon Sep 17 00:00:00 2001 From: QTom Date: Wed, 18 Mar 2026 23:28:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=96=B0=E5=A2=9E=E5=88=86=E7=BB=84=E5=88=97=E3=80=81?= =?UTF-8?q?=E5=88=86=E7=BB=84=E7=AD=9B=E9=80=89=E4=B8=8E=E4=B8=93=E5=B1=9E?= =?UTF-8?q?=E5=88=86=E7=BB=84=E4=B8=80=E9=94=AE=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增分组列:展示用户的专属/公开分组,支持 hover 查看详情 - 新增分组筛选:下拉选择或模糊搜索分组名过滤用户 - 专属分组替换:点击专属分组弹出操作菜单,选择目标分组后 自动授予新分组权限、迁移绑定的 Key、移除旧分组权限 - 后端新增 POST /admin/users/:id/replace-group 端点,事务内 完成分组替换并失效认证缓存 --- .../handler/admin/admin_service_stub_test.go | 4 + .../internal/handler/admin/user_handler.go | 34 ++++ .../handler/sora_client_handler_test.go | 17 ++ backend/internal/repository/api_key_repo.go | 10 ++ backend/internal/repository/user_repo.go | 16 ++ backend/internal/server/api_contract_test.go | 20 +++ .../server/middleware/admin_auth_test.go | 4 + .../middleware/api_key_auth_google_test.go | 3 + .../server/middleware/api_key_auth_test.go | 4 + backend/internal/server/routes/admin.go | 1 + backend/internal/service/admin_service.go | 73 ++++++++ .../service/admin_service_apikey_test.go | 6 + .../service/admin_service_delete_test.go | 4 + backend/internal/service/api_key_service.go | 2 + .../service/api_key_service_cache_test.go | 3 + .../service/api_key_service_delete_test.go | 3 + .../service/api_key_service_quota_test.go | 3 + .../service/sora_generation_service_test.go | 3 + backend/internal/service/user_service.go | 3 + backend/internal/service/user_service_test.go | 5 +- frontend/src/api/admin/users.ts | 24 ++- .../admin/user/GroupReplaceModal.vue | 131 ++++++++++++++ frontend/src/components/common/DataTable.vue | 6 +- frontend/src/components/common/Select.vue | 22 ++- frontend/src/components/common/types.ts | 1 + frontend/src/components/icons/Icon.vue | 1 + frontend/src/i18n/locales/en.ts | 15 ++ frontend/src/i18n/locales/zh.ts | 15 ++ frontend/src/views/admin/UsersView.vue | 170 +++++++++++++++++- 29 files changed, 594 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/admin/user/GroupReplaceModal.vue diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 37a72cb4..61e2c2bd 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -445,5 +445,9 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser return "" } +func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) { + return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil +} + // Ensure stub implements interface. var _ service.AdminService = (*stubAdminService)(nil) diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 5a55ab14..998308dd 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -75,6 +75,7 @@ type UpdateBalanceRequest struct { // - role: filter by user role // - search: search in email, username // - attr[{id}]: filter by custom attribute value, e.g. attr[1]=company +// - group_name: fuzzy filter by allowed group name func (h *UserHandler) List(c *gin.Context) { page, pageSize := response.ParsePagination(c) @@ -89,6 +90,7 @@ func (h *UserHandler) List(c *gin.Context) { Status: c.Query("status"), Role: c.Query("role"), Search: search, + GroupName: strings.TrimSpace(c.Query("group_name")), Attributes: parseAttributeFilters(c), } if raw, ok := c.GetQuery("include_subscriptions"); ok { @@ -366,3 +368,35 @@ func (h *UserHandler) GetBalanceHistory(c *gin.Context) { "total_recharged": totalRecharged, }) } + +// ReplaceGroupRequest represents the request to replace a user's exclusive group +type ReplaceGroupRequest struct { + OldGroupID int64 `json:"old_group_id" binding:"required,gt=0"` + NewGroupID int64 `json:"new_group_id" binding:"required,gt=0"` +} + +// ReplaceGroup handles replacing a user's exclusive group +// POST /api/v1/admin/users/:id/replace-group +func (h *UserHandler) ReplaceGroup(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid user ID") + return + } + + var req ReplaceGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.adminService.ReplaceUserGroup(c.Request.Context(), userID, req.OldGroupID, req.NewGroupID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "migrated_keys": result.MigratedKeys, + }) +} diff --git a/backend/internal/handler/sora_client_handler_test.go b/backend/internal/handler/sora_client_handler_test.go index dab17673..89dcd394 100644 --- a/backend/internal/handler/sora_client_handler_test.go +++ b/backend/internal/handler/sora_client_handler_test.go @@ -942,6 +942,9 @@ func (r *stubUserRepoForHandler) ExistsByEmail(context.Context, string) (bool, e func (r *stubUserRepoForHandler) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } +func (r *stubUserRepoForHandler) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + return nil +} func (r *stubUserRepoForHandler) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (r *stubUserRepoForHandler) EnableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForHandler) DisableTotp(context.Context, int64) error { return nil } @@ -1017,6 +1020,20 @@ func (r *stubAPIKeyRepoForHandler) SearchAPIKeys(context.Context, int64, string, func (r *stubAPIKeyRepoForHandler) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { return 0, nil } +func (r *stubAPIKeyRepoForHandler) UpdateGroupIDByUserAndGroup(_ context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + var updated int64 + for id, key := range r.keys { + if key.UserID != userID || key.GroupID == nil || *key.GroupID != oldGroupID { + continue + } + clone := *key + gid := newGroupID + clone.GroupID = &gid + r.keys[id] = &clone + updated++ + } + return updated, nil +} func (r *stubAPIKeyRepoForHandler) CountByGroupID(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 4c7f38a8..859eefd5 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -409,6 +409,16 @@ func (r *apiKeyRepository) ClearGroupIDByGroupID(ctx context.Context, groupID in return int64(n), err } +// UpdateGroupIDByUserAndGroup 将用户下绑定 oldGroupID 的所有 Key 迁移到 newGroupID +func (r *apiKeyRepository) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + client := clientFromContext(ctx, r.client) + n, err := client.APIKey.Update(). + Where(apikey.UserIDEQ(userID), apikey.GroupIDEQ(oldGroupID), apikey.DeletedAtIsNil()). + SetGroupID(newGroupID). + Save(ctx) + return int64(n), err +} + // CountByGroupID 获取分组的 API Key 数量 func (r *apiKeyRepository) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { count, err := r.activeQuery().Where(apikey.GroupIDEQ(groupID)).Count(ctx) diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index b56aaaf9..575754e0 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -11,6 +11,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/apikey" + dbgroup "github.com/Wei-Shaw/sub2api/ent/group" dbuser "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/userallowedgroup" "github.com/Wei-Shaw/sub2api/ent/usersubscription" @@ -200,6 +201,12 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination. ) } + if filters.GroupName != "" { + q = q.Where(dbuser.HasAllowedGroupsWith( + dbgroup.NameContainsFold(filters.GroupName), + )) + } + // If attribute filters are specified, we need to filter by user IDs first var allowedUserIDs []int64 if len(filters.Attributes) > 0 { @@ -453,6 +460,15 @@ func (r *userRepository) RemoveGroupFromAllowedGroups(ctx context.Context, group return int64(affected), nil } +// RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限 +func (r *userRepository) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + client := clientFromContext(ctx, r.client) + _, err := client.UserAllowedGroup.Delete(). + Where(userallowedgroup.UserIDEQ(userID), userallowedgroup.GroupIDEQ(groupID)). + Exec(ctx) + return err +} + func (r *userRepository) GetFirstAdmin(ctx context.Context) (*service.User, error) { m, err := r.client.User.Query(). Where( diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 4ae5c272..39637ae6 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -807,6 +807,10 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID return 0, errors.New("not implemented") } +func (r *stubUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + return errors.New("not implemented") +} + func (r *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { return errors.New("not implemented") } @@ -1509,6 +1513,22 @@ func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int6 return 0, errors.New("not implemented") } +func (r *stubApiKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + var updated int64 + for id, key := range r.byID { + if key.UserID != userID || key.GroupID == nil || *key.GroupID != oldGroupID { + continue + } + clone := *key + gid := newGroupID + clone.GroupID = &gid + r.byID[id] = &clone + r.byKey[clone.Key] = &clone + updated++ + } + return updated, nil +} + func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index 138663c4..aafe4a58 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -181,6 +181,10 @@ func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID panic("unexpected RemoveGroupFromAllowedGroups call") } +func (s *stubUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + panic("unexpected RemoveGroupFromUserAllowedGroups call") +} + func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { panic("unexpected AddGroupToAllowedGroups call") } diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 9f9bba13..f8e50fcd 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -104,6 +104,9 @@ func (f fakeAPIKeyRepo) ResetRateLimitWindows(ctx context.Context, id int64) err func (f fakeAPIKeyRepo) GetRateLimitData(ctx context.Context, id int64) (*service.APIKeyRateLimitData, error) { return &service.APIKeyRateLimitData{}, nil } +func (f fakeAPIKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + return 0, errors.New("not implemented") +} func (f fakeGoogleSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error { return errors.New("not implemented") diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index a633ffdd..4a4ab0f9 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -565,6 +565,10 @@ func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int6 return 0, errors.New("not implemented") } +func (r *stubApiKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + return 0, errors.New("not implemented") +} + func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index c80cca54..c4ddeab3 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -215,6 +215,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys) users.GET("/:id/usage", h.Admin.User.GetUserUsage) users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory) + users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup) // User attribute values users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 5eeac183..ccd681a3 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -50,6 +50,9 @@ type AdminService interface { // API Key management (admin) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) + // ReplaceUserGroup 替换用户的专属分组:授予新分组权限、迁移 Key、移除旧分组权限 + ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) + // Account management ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) GetAccount(ctx context.Context, id int64) (*Account, error) @@ -270,6 +273,11 @@ type AdminUpdateAPIKeyGroupIDResult struct { GrantedGroupName string // the group name that was auto-granted } +// ReplaceUserGroupResult 分组替换操作的结果 +type ReplaceUserGroupResult struct { + MigratedKeys int64 // 迁移的 Key 数量 +} + // BulkUpdateAccountsResult is the aggregated response for bulk updates. type BulkUpdateAccountsResult struct { Success int `json:"success"` @@ -1377,6 +1385,71 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i return result, nil } +// ReplaceUserGroup 替换用户的专属分组 +func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) { + if oldGroupID == newGroupID { + return nil, infraerrors.BadRequest("SAME_GROUP", "old and new group must be different") + } + + // 验证新分组存在且为活跃的专属标准分组 + newGroup, err := s.groupRepo.GetByID(ctx, newGroupID) + if err != nil { + return nil, err + } + if newGroup.Status != StatusActive { + return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") + } + if !newGroup.IsExclusive { + return nil, infraerrors.BadRequest("GROUP_NOT_EXCLUSIVE", "target group is not exclusive") + } + if newGroup.IsSubscriptionType() { + return nil, infraerrors.BadRequest("GROUP_IS_SUBSCRIPTION", "subscription groups are not supported for replacement") + } + + // 事务保证原子性 + if s.entClient == nil { + return nil, fmt.Errorf("entClient is nil, cannot perform group replacement") + } + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + opCtx := dbent.NewTxContext(ctx, tx) + + // 1. 授予新分组权限 + if err := s.userRepo.AddGroupToAllowedGroups(opCtx, userID, newGroupID); err != nil { + return nil, fmt.Errorf("add new group to allowed groups: %w", err) + } + + // 2. 迁移绑定旧分组的 Key 到新分组 + migrated, err := s.apiKeyRepo.UpdateGroupIDByUserAndGroup(opCtx, userID, oldGroupID, newGroupID) + if err != nil { + return nil, fmt.Errorf("migrate api keys: %w", err) + } + + // 3. 移除旧分组权限 + if err := s.userRepo.RemoveGroupFromUserAllowedGroups(opCtx, userID, oldGroupID); err != nil { + return nil, fmt.Errorf("remove old group from allowed groups: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit transaction: %w", err) + } + + // 失效该用户所有 Key 的认证缓存 + if s.authCacheInvalidator != nil { + keys, keyErr := s.apiKeyRepo.ListKeysByUserID(ctx, userID) + if keyErr == nil { + for _, k := range keys { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, k) + } + } + } + + return &ReplaceUserGroupResult{MigratedKeys: migrated}, nil +} + // Account management implementations func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index 7588c16d..f9fd6742 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -65,6 +65,9 @@ func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (boo func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { panic("unexpected") } +func (s *userRepoStubForGroupUpdate) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + panic("unexpected") +} func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error { panic("unexpected") } @@ -128,6 +131,9 @@ func (s *apiKeyRepoStubForGroupUpdate) SearchAPIKeys(context.Context, int64, str func (s *apiKeyRepoStubForGroupUpdate) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { panic("unexpected") } +func (s *apiKeyRepoStubForGroupUpdate) UpdateGroupIDByUserAndGroup(context.Context, int64, int64, int64) (int64, error) { + panic("unexpected") +} func (s *apiKeyRepoStubForGroupUpdate) CountByGroupID(context.Context, int64) (int64, error) { panic("unexpected") } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 662b4771..fbc856cf 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -93,6 +93,10 @@ func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID panic("unexpected RemoveGroupFromAllowedGroups call") } +func (s *userRepoStub) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + panic("unexpected RemoveGroupFromUserAllowedGroups call") +} + func (s *userRepoStub) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { panic("unexpected AddGroupToAllowedGroups call") } diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 18e9ff7a..48e0ab2f 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -63,6 +63,8 @@ type APIKeyRepository interface { ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]APIKey, error) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) + // UpdateGroupIDByUserAndGroup 将用户下绑定 oldGroupID 的所有 Key 迁移到 newGroupID + UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) CountByGroupID(ctx context.Context, groupID int64) (int64, error) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 97b8e229..357f8def 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -80,6 +80,9 @@ func (s *authRepoStub) SearchAPIKeys(ctx context.Context, userID int64, keyword func (s *authRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected ClearGroupIDByGroupID call") } +func (s *authRepoStub) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + panic("unexpected UpdateGroupIDByUserAndGroup call") +} func (s *authRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected CountByGroupID call") diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go index dfd481e8..392d52b9 100644 --- a/backend/internal/service/api_key_service_delete_test.go +++ b/backend/internal/service/api_key_service_delete_test.go @@ -108,6 +108,9 @@ func (s *apiKeyRepoStub) SearchAPIKeys(ctx context.Context, userID int64, keywor func (s *apiKeyRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected ClearGroupIDByGroupID call") } +func (s *apiKeyRepoStub) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) { + panic("unexpected UpdateGroupIDByUserAndGroup call") +} func (s *apiKeyRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { panic("unexpected CountByGroupID call") diff --git a/backend/internal/service/api_key_service_quota_test.go b/backend/internal/service/api_key_service_quota_test.go index 2e2f6f78..cf05e16c 100644 --- a/backend/internal/service/api_key_service_quota_test.go +++ b/backend/internal/service/api_key_service_quota_test.go @@ -122,6 +122,9 @@ func (s *quotaBaseAPIKeyRepoStub) SearchAPIKeys(context.Context, int64, string, func (s *quotaBaseAPIKeyRepoStub) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { panic("unexpected ClearGroupIDByGroupID call") } +func (s *quotaBaseAPIKeyRepoStub) UpdateGroupIDByUserAndGroup(context.Context, int64, int64, int64) (int64, error) { + panic("unexpected UpdateGroupIDByUserAndGroup call") +} func (s *quotaBaseAPIKeyRepoStub) CountByGroupID(context.Context, int64) (int64, error) { panic("unexpected CountByGroupID call") } diff --git a/backend/internal/service/sora_generation_service_test.go b/backend/internal/service/sora_generation_service_test.go index 46f322c8..6f33ff39 100644 --- a/backend/internal/service/sora_generation_service_test.go +++ b/backend/internal/service/sora_generation_service_test.go @@ -162,6 +162,9 @@ func (r *stubUserRepoForQuota) ExistsByEmail(context.Context, string) (bool, err func (r *stubUserRepoForQuota) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } +func (r *stubUserRepoForQuota) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + return nil +} func (r *stubUserRepoForQuota) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (r *stubUserRepoForQuota) EnableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForQuota) DisableTotp(context.Context, int64) error { return nil } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 49ba3645..4045c0aa 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -21,6 +21,7 @@ type UserListFilters struct { Status string // User status filter Role string // User role filter Search string // Search in email, username + GroupName string // Filter by allowed group name (fuzzy match) Attributes map[int64]string // Custom attribute filters: attributeID -> value // IncludeSubscriptions controls whether ListWithFilters should load active subscriptions. // For large datasets this can be expensive; admin list pages should enable it on demand. @@ -46,6 +47,8 @@ type UserRepository interface { RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error + // RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限 + RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error // TOTP 双因素认证 UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 05fe5056..e88694f5 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -46,7 +46,10 @@ func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int return 0, nil } func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } -func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil } +func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + return nil +} +func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil } func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil } diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index d631a5b7..bbf0ab51 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -21,6 +21,7 @@ export async function list( status?: 'active' | 'disabled' role?: 'admin' | 'user' search?: string + group_name?: string // fuzzy filter by allowed group name attributes?: Record // attributeId -> value include_subscriptions?: boolean }, @@ -35,6 +36,7 @@ export async function list( status: filters?.status, role: filters?.role, search: filters?.search, + group_name: filters?.group_name, include_subscriptions: filters?.include_subscriptions } @@ -223,6 +225,25 @@ export async function getUserBalanceHistory( return data } +/** + * Replace user's exclusive group + * @param userId - User ID + * @param oldGroupId - Current group ID to replace + * @param newGroupId - New group ID to replace with + * @returns Number of migrated keys + */ +export async function replaceGroup( + userId: number, + oldGroupId: number, + newGroupId: number +): Promise<{ migrated_keys: number }> { + const { data } = await apiClient.post<{ migrated_keys: number }>( + `/admin/users/${userId}/replace-group`, + { old_group_id: oldGroupId, new_group_id: newGroupId } + ) + return data +} + export const usersAPI = { list, getById, @@ -234,7 +255,8 @@ export const usersAPI = { toggleStatus, getUserApiKeys, getUserUsageStats, - getUserBalanceHistory + getUserBalanceHistory, + replaceGroup } export default usersAPI diff --git a/frontend/src/components/admin/user/GroupReplaceModal.vue b/frontend/src/components/admin/user/GroupReplaceModal.vue new file mode 100644 index 00000000..429826ba --- /dev/null +++ b/frontend/src/components/admin/user/GroupReplaceModal.vue @@ -0,0 +1,131 @@ + + + diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 0f05359d..159fbd84 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -79,7 +79,8 @@ 'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400', getAdaptivePaddingClass(), { 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }, - getStickyColumnClass(column, index) + getStickyColumnClass(column, index), + column.class ]" @click="column.sortable && handleSort(column.key)" > @@ -168,7 +169,8 @@ :class="[ 'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100', getAdaptivePaddingClass(), - getStickyColumnClass(column, colIndex) + getStickyColumnClass(column, colIndex), + column.class ]" > - {{ getOptionLabel(option) }} + + {{ getOptionLabel(option) }} (), { disabled: false, error: false, searchable: false, + creatable: false, + creatablePrefix: '', valueKey: 'value', labelKey: 'label' }) @@ -217,6 +227,10 @@ const selectedLabel = computed(() => { if (selectedOption.value) { return getOptionLabel(selectedOption.value) } + // In creatable mode, show the raw value if no matching option + if (props.creatable && props.modelValue) { + return String(props.modelValue) + } return placeholderText.value }) @@ -231,6 +245,12 @@ const filteredOptions = computed(() => { if (opt.description && String(opt.description).toLowerCase().includes(query)) return true return false }) + // In creatable mode, always prepend a fuzzy search option + if (props.creatable && searchQuery.value.trim()) { + const trimmed = searchQuery.value.trim() + const prefix = props.creatablePrefix || t('common.search') + opts = [{ [props.valueKey]: trimmed, [props.labelKey]: `${prefix} "${trimmed}"`, _creatable: true }, ...opts] + } } return opts }) diff --git a/frontend/src/components/common/types.ts b/frontend/src/components/common/types.ts index 4a0ca8d3..fd19573d 100644 --- a/frontend/src/components/common/types.ts +++ b/frontend/src/components/common/types.ts @@ -6,5 +6,6 @@ export interface Column { key: string label: string sortable?: boolean + class?: string formatter?: (value: any, row: any) => string } diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 382a35af..6d0ba8a3 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -86,6 +86,7 @@ const icons = { download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4', upload: 'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5', filter: 'M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z', + globe: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418', sort: 'M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9', // Security diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ba90e11a..1da2757d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1289,6 +1289,9 @@ export default { searchUsers: 'Search by email, username, notes, or API key...', allRoles: 'All Roles', allStatus: 'All Status', + allGroups: 'All Groups', + searchGroups: 'Search groups...', + fuzzySearch: 'Fuzzy search', admin: 'Admin', user: 'User', disabled: 'Disabled', @@ -1313,6 +1316,7 @@ export default { username: 'Username', notes: 'Notes', role: 'Role', + groups: 'Groups', subscriptions: 'Subscriptions', balance: 'Balance', usage: 'Usage', @@ -1324,6 +1328,9 @@ export default { today: 'Today', total: 'Last 30d', noSubscription: 'No subscription', + publicGroupCount: '+{count} public', + exclusiveLabel: 'exclusive', + publicLabel: 'public', daysRemaining: '{days}d', expired: 'Expired', disable: 'Disable', @@ -1379,6 +1386,14 @@ export default { useDefaultRate: 'Use Default', customRatePlaceholder: 'Leave empty for default', groupConfigUpdated: 'Group configuration updated successfully', + replaceGroup: 'Replace Group', + clickToReplace: 'Click to replace', + replaceGroupTitle: 'Replace Exclusive Group', + replaceGroupHint: 'Select a new group to replace "{old}". Keys will be migrated and permissions updated automatically.', + replaceGroupConfirm: 'Confirm Replace', + replaceGroupSuccess: 'Group replaced successfully, {count} key(s) migrated', + selectNewGroup: 'Select target group', + noOtherGroups: 'No other exclusive groups available', deposit: 'Deposit', withdraw: 'Withdraw', depositAmount: 'Deposit Amount', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8180c568..2899b8c1 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1314,6 +1314,9 @@ export default { roleFilter: '角色筛选', allRoles: '全部角色', allStatus: '全部状态', + allGroups: '全部分组', + searchGroups: '搜索分组...', + fuzzySearch: '模糊搜索', statusFilter: '状态筛选', allStatuses: '全部状态', admin: '管理员', @@ -1340,6 +1343,7 @@ export default { username: '用户名', notes: '备注', role: '角色', + groups: '分组', subscriptions: '订阅分组', balance: '余额', usage: '用量', @@ -1351,6 +1355,9 @@ export default { today: '今日', total: '近30天', noSubscription: '暂无订阅', + publicGroupCount: '+{count} 公开', + exclusiveLabel: '专属', + publicLabel: '公开', daysRemaining: '{days}天', expired: '已过期', disable: '禁用', @@ -1442,6 +1449,14 @@ export default { useDefaultRate: '使用默认', customRatePlaceholder: '留空使用默认', groupConfigUpdated: '分组配置更新成功', + replaceGroup: '替换分组', + clickToReplace: '点击替换分组', + replaceGroupTitle: '替换专属分组', + replaceGroupHint: '选择新分组替换「{old}」,将自动迁移绑定的 Key 并更新分组权限', + replaceGroupConfirm: '确认替换', + replaceGroupSuccess: '分组替换成功,已迁移 {count} 个 Key', + selectNewGroup: '请选择目标分组', + noOtherGroups: '没有其他可用的专属分组', deposit: '充值', withdraw: '退款', depositAmount: '充值金额', diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index eea6ed33..696bcdfe 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -48,6 +48,19 @@ /> + +
+