diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index bc4da86c52..41b9b86289 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -264,6 +264,10 @@ func (s *stubAdminService) GetAllGroupsByPlatform(ctx context.Context, platform return s.groups, nil } +func (s *stubAdminService) GetAllGroupsIncludingInactive(ctx context.Context) ([]service.Group, error) { + return s.groups, nil +} + func (s *stubAdminService) GetGroup(ctx context.Context, id int64) (*service.Group, error) { group := service.Group{ID: id, Name: "group", Status: service.StatusActive} return &group, nil diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 102ee02fdc..0468fc34e9 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -196,15 +196,21 @@ func (h *GroupHandler) List(c *gin.Context) { response.Paginated(c, outGroups, total, page, pageSize) } -// GetAll handles getting all active groups without pagination +// GetAll handles getting all active groups without pagination. +// Pass ?include_inactive=true to also include disabled groups (used by the +// API Key group filter, which needs to surface groups that still have API keys +// bound to them even after the group is disabled). // GET /api/v1/admin/groups/all func (h *GroupHandler) GetAll(c *gin.Context) { platform := c.Query("platform") + includeInactive := c.Query("include_inactive") == "true" var groups []service.Group var err error - if platform != "" { + if includeInactive { + groups, err = h.adminService.GetAllGroupsIncludingInactive(c.Request.Context()) + } else if platform != "" { groups, err = h.adminService.GetAllGroupsByPlatform(c.Request.Context(), platform) } else { groups, err = h.adminService.GetAllGroups(c.Request.Context()) diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index a21fe55a93..fb953057aa 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -106,6 +106,7 @@ type BindUserAuthIdentityChannelRequest struct { // - 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 +// - api_key_group_id: filter by the exact group bound to the user's API keys func (h *UserHandler) List(c *gin.Context) { page, pageSize := response.ParsePagination(c) @@ -123,6 +124,11 @@ func (h *UserHandler) List(c *gin.Context) { GroupName: strings.TrimSpace(c.Query("group_name")), Attributes: parseAttributeFilters(c), } + if raw := strings.TrimSpace(c.Query("api_key_group_id")); raw != "" { + if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { + filters.APIKeyGroupID = id + } + } sortBy := c.DefaultQuery("sort_by", "created_at") sortOrder := c.DefaultQuery("sort_order", "desc") if raw, ok := c.GetQuery("include_subscriptions"); ok { diff --git a/backend/internal/handler/admin/user_handler_list_apikey_group_test.go b/backend/internal/handler/admin/user_handler_list_apikey_group_test.go new file mode 100644 index 0000000000..b5cdd9a18f --- /dev/null +++ b/backend/internal/handler/admin/user_handler_list_apikey_group_test.go @@ -0,0 +1,53 @@ +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// listUsersFilterStub 捕获传入 ListUsers 的 filters,其余 AdminService 方法走 baseline stub。 +type listUsersFilterStub struct { + service.AdminService + captured service.UserListFilters +} + +func (s *listUsersFilterStub) ListUsers(_ context.Context, _, _ int, filters service.UserListFilters, _, _ string) ([]service.User, int64, error) { + s.captured = filters + return []service.User{}, 0, nil +} + +func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) { + gin.SetMode(gin.TestMode) + cases := []struct { + name string + query string + want int64 + }{ + {"valid id", "?api_key_group_id=42", 42}, + {"missing", "", 0}, + {"zero ignored", "?api_key_group_id=0", 0}, + {"negative ignored", "?api_key_group_id=-3", 0}, + {"non-numeric ignored", "?api_key_group_id=abc", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stub := &listUsersFilterStub{AdminService: newStubAdminService()} + r := gin.New() + h := NewUserHandler(stub, nil, nil, nil) + r.GET("/admin/users", h.List) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/admin/users"+tc.query, nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, tc.want, stub.captured.APIKeyGroupID) + }) + } +} diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 599b9febf4..98122c0bea 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -462,6 +462,17 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination. )) } + if filters.APIKeyGroupID > 0 { + // 按"API Key 实际绑定的分组"过滤:用户只要有任意一个未软删除的 API Key + // 绑定到该分组即命中(EXISTS 语义)。 + // 注意:SoftDeleteMixin 的拦截器不会自动下沉到 HasAPIKeysWith 子查询, + // 必须显式加 apikey.DeletedAtIsNil(),否则已软删除的 key 会污染过滤结果。 + q = q.Where(dbuser.HasAPIKeysWith( + apikey.GroupIDEQ(filters.APIKeyGroupID), + apikey.DeletedAtIsNil(), + )) + } + // If attribute filters are specified, we need to filter by user IDs first var allowedUserIDs []int64 if len(filters.Attributes) > 0 { diff --git a/backend/internal/repository/user_repo_apikey_group_filter_integration_test.go b/backend/internal/repository/user_repo_apikey_group_filter_integration_test.go new file mode 100644 index 0000000000..a23706e33c --- /dev/null +++ b/backend/internal/repository/user_repo_apikey_group_filter_integration_test.go @@ -0,0 +1,174 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/suite" +) + +type UserRepoAPIKeyGroupFilterSuite struct { + suite.Suite + ctx context.Context + client *dbent.Client + repo *userRepository +} + +func (s *UserRepoAPIKeyGroupFilterSuite) SetupTest() { + s.ctx = context.Background() + s.client = testEntClient(s.T()) + s.repo = newUserRepositoryWithSQL(s.client, integrationDB) + // api_keys 必须先于 users 清理(外键);groups 也清理避免跨用例串扰。 + _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM api_keys") + _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_allowed_groups") + _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_subscriptions") + _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM users") + _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM groups") +} + +func TestUserRepoAPIKeyGroupFilterSuite(t *testing.T) { + suite.Run(t, new(UserRepoAPIKeyGroupFilterSuite)) +} + +func (s *UserRepoAPIKeyGroupFilterSuite) mustCreateUser(email string) *service.User { + s.T().Helper() + u := &service.User{ + Email: email, + PasswordHash: "test-password-hash", + Role: service.RoleUser, + Status: service.StatusActive, + Concurrency: 5, + } + s.Require().NoError(s.repo.Create(s.ctx, u), "create user") + return u +} + +func (s *UserRepoAPIKeyGroupFilterSuite) mustCreateGroup(name string) *dbent.Group { + s.T().Helper() + g, err := s.client.Group.Create(). + SetName(name). + SetStatus(service.StatusActive). + Save(s.ctx) + s.Require().NoError(err, "create group") + return g +} + +func (s *UserRepoAPIKeyGroupFilterSuite) mustCreateAPIKey(userID int64, key, name string, groupID *int64) *dbent.APIKey { + s.T().Helper() + create := s.client.APIKey.Create(). + SetUserID(userID). + SetKey(key). + SetName(name) + if groupID != nil { + create = create.SetGroupID(*groupID) + } + ak, err := create.Save(s.ctx) + s.Require().NoError(err, "create api key") + return ak +} + +func (s *UserRepoAPIKeyGroupFilterSuite) ids(users []service.User) []int64 { + out := make([]int64, len(users)) + for i := range users { + out[i] = users[i].ID + } + return out +} + +func (s *UserRepoAPIKeyGroupFilterSuite) listByAPIKeyGroup(groupID int64) []service.User { + s.T().Helper() + users, _, err := s.repo.ListWithFilters( + s.ctx, + pagination.PaginationParams{Page: 1, PageSize: 50}, + service.UserListFilters{APIKeyGroupID: groupID}, + ) + s.Require().NoError(err, "ListWithFilters") + return users +} + +// 命中:拥有绑定到该分组 API Key 的用户出现,绑定到其它分组的不出现。 +func (s *UserRepoAPIKeyGroupFilterSuite) TestFiltersUsersByAPIKeyGroup() { + g := s.mustCreateGroup("grp-target") + other := s.mustCreateGroup("grp-other") + hit := s.mustCreateUser("hit@test.com") + miss := s.mustCreateUser("miss@test.com") + s.mustCreateAPIKey(hit.ID, "sk-hit", "K", &g.ID) + s.mustCreateAPIKey(miss.ID, "sk-miss", "K", &other.ID) + + s.Require().Equal([]int64{hit.ID}, s.ids(s.listByAPIKeyGroup(g.ID))) +} + +// 软删除的 API Key 不应命中(核心:软删除不会自动下沉到子查询,靠 DeletedAtIsNil 排除)。 +func (s *UserRepoAPIKeyGroupFilterSuite) TestSoftDeletedAPIKeyExcluded() { + g := s.mustCreateGroup("grp-soft") + u := s.mustCreateUser("soft@test.com") + ak := s.mustCreateAPIKey(u.ID, "sk-soft", "K", &g.ID) + // 软删除该 key:SoftDeleteMixin 的 Hook 把 Delete 转为 UPDATE deleted_at。 + s.Require().NoError(s.client.APIKey.DeleteOne(ak).Exec(s.ctx), "soft delete api key") + + s.Require().Empty(s.listByAPIKeyGroup(g.ID), "user with only a soft-deleted key must not match") +} + +// 多 Key:用户有多个 key,仅一个绑该分组 → 命中且只返回一条(EXISTS/去重)。 +func (s *UserRepoAPIKeyGroupFilterSuite) TestMultipleKeysAnyMatchDedup() { + g := s.mustCreateGroup("grp-multi") + other := s.mustCreateGroup("grp-multi-other") + u := s.mustCreateUser("multi@test.com") + s.mustCreateAPIKey(u.ID, "sk-m1", "K1", &other.ID) + s.mustCreateAPIKey(u.ID, "sk-m2", "K2", &g.ID) + s.mustCreateAPIKey(u.ID, "sk-m3", "K3", nil) // 无分组 + + s.Require().Equal([]int64{u.ID}, s.ids(s.listByAPIKeyGroup(g.ID))) +} + +// 叠加过滤:api_key_group_id 与 status 同时指定时取交集——只返回同时满足两者的用户。 +func (s *UserRepoAPIKeyGroupFilterSuite) TestAPIKeyGroupAndStatusFilter() { + g := s.mustCreateGroup("grp-combined") + + // active 用户,key 绑 target 分组 → 应命中 + active := s.mustCreateUser("active-hit@test.com") + s.mustCreateAPIKey(active.ID, "sk-active", "K", &g.ID) + + // disabled 用户,key 也绑 target 分组 → 只用 group 过滤会命中,但 status=active 后排除 + disabled := s.mustCreateUser("disabled-hit@test.com") + s.mustCreateAPIKey(disabled.ID, "sk-disabled", "K2", &g.ID) + _, err := s.client.User.UpdateOneID(disabled.ID).SetStatus(service.StatusDisabled).Save(s.ctx) + s.Require().NoError(err, "disable user") + + // active 用户,key 绑其它分组 → group 过滤排除 + other := s.mustCreateGroup("grp-combined-other") + miss := s.mustCreateUser("active-miss@test.com") + s.mustCreateAPIKey(miss.ID, "sk-miss", "K3", &other.ID) + + users, _, err := s.repo.ListWithFilters( + s.ctx, + pagination.PaginationParams{Page: 1, PageSize: 50}, + service.UserListFilters{ + APIKeyGroupID: g.ID, + Status: service.StatusActive, + }, + ) + s.Require().NoError(err) + s.Require().Equal([]int64{active.ID}, s.ids(users), "only active user with matching key group should match") +} + +// 缺省(APIKeyGroupID=0)不过滤:所有用户都返回。 +func (s *UserRepoAPIKeyGroupFilterSuite) TestZeroGroupIDNoFilter() { + g := s.mustCreateGroup("grp-zero") + u1 := s.mustCreateUser("z1@test.com") + u2 := s.mustCreateUser("z2@test.com") + s.mustCreateAPIKey(u1.ID, "sk-z1", "K", &g.ID) + + users, _, err := s.repo.ListWithFilters( + s.ctx, + pagination.PaginationParams{Page: 1, PageSize: 50}, + service.UserListFilters{APIKeyGroupID: 0}, + ) + s.Require().NoError(err) + s.Require().ElementsMatch([]int64{u1.ID, u2.ID}, s.ids(users)) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index c945c843ad..5a7c14f00a 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -52,6 +52,9 @@ type AdminService interface { ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) GetAllGroups(ctx context.Context) ([]Group, error) GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error) + // GetAllGroupsIncludingInactive returns all groups regardless of status (active + disabled), + // ordered by sort_order then id. Used by the API Key group filter dropdown. + GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error) GetGroup(ctx context.Context, id int64) (*Group, error) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) @@ -1701,6 +1704,13 @@ func (s *adminServiceImpl) GetAllGroupsByPlatform(ctx context.Context, platform return s.groupRepo.ListActiveByPlatform(ctx, platform) } +func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error) { + // ListWithFilters with empty status = no status filter, so active + disabled groups are returned. + // PageSize 10000 is intentionally large; group count is O(dozens) in practice. + groups, _, err := s.groupRepo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10000}, "", "", "", nil) + return groups, err +} + func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) { return s.groupRepo.GetByID(ctx, id) } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index f801e2c4cb..2c87221401 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -65,11 +65,15 @@ var ( // UserListFilters contains all filter options for listing users 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 + 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) + // APIKeyGroupID filters users who own at least one non-soft-deleted API key + // bound to this group (api_keys.group_id). 0 = no filter. Covers all three + // group types since it matches the key's group directly, not allowed_groups. + APIKeyGroupID int64 + 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. // nil means not specified (default: load subscriptions for backward compatibility). diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index b7846efda0..31a1b57650 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -57,6 +57,17 @@ export async function getAll(platform?: GroupPlatform): Promise { return data } +/** + * Get ALL groups including disabled ones — used by the API Key group filter so + * that admins can filter users whose keys are still bound to a now-disabled group. + */ +export async function getAllIncludingInactive(): Promise { + const { data } = await apiClient.get('/admin/groups/all', { + params: { include_inactive: true } + }) + return data +} + /** * Get active groups by platform * @param platform - Platform to filter by @@ -322,6 +333,7 @@ export const groupsAPI = { list, getAll, getByPlatform, + getAllIncludingInactive, getById, getModelsListCandidates, create, diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 61879d2f55..7667dfc4c9 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -60,6 +60,7 @@ export async function list( role?: 'admin' | 'user' search?: string group_name?: string // fuzzy filter by allowed group name + api_key_group_id?: number // filter users by the group their API keys are bound to attributes?: Record // attributeId -> value include_subscriptions?: boolean sort_by?: string @@ -77,6 +78,7 @@ export async function list( role: filters?.role, search: filters?.search, group_name: filters?.group_name, + api_key_group_id: filters?.api_key_group_id, include_subscriptions: filters?.include_subscriptions, sort_by: filters?.sort_by, sort_order: filters?.sort_order diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index fed7a60b1c..ed4823d230 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1796,6 +1796,16 @@ export default { allGroups: 'All Groups', searchGroups: 'Search groups...', fuzzySearch: 'Fuzzy search', + apiKeyGroupFilter: 'API Key Group', + apiKeyGroupExclusive: 'Exclusive Groups', + apiKeyGroupPublic: 'Public Groups', + apiKeyGroupSubscription: 'Subscription Groups', + apiKeyGroupDisabled: 'Disabled Groups', + authorizedGroupFilter: 'Authorized Group', + allAuthorizedGroups: 'All Authorized Groups', + searchAuthorizedGroups: 'Search authorized groups...', + allApiKeyGroups: 'All API Key Groups', + searchApiKeyGroups: 'Search API Key groups...', admin: 'Admin', user: 'User', disabled: 'Disabled', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index de110d5760..9a23b612bf 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1820,6 +1820,16 @@ export default { allGroups: '全部分组', searchGroups: '搜索分组...', fuzzySearch: '模糊搜索', + apiKeyGroupFilter: 'API Key 分组', + apiKeyGroupExclusive: '专用分组', + apiKeyGroupPublic: '公开分组', + apiKeyGroupSubscription: '订阅分组', + apiKeyGroupDisabled: '已禁用分组', + authorizedGroupFilter: '授权分组', + allAuthorizedGroups: '全部授权分组', + searchAuthorizedGroups: '搜索授权分组...', + allApiKeyGroups: '全部 API Key 分组', + searchApiKeyGroups: '搜索 API Key 分组...', statusFilter: '状态筛选', allStatuses: '全部状态', admin: '管理员', diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 93c70c6b56..6076188205 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -56,7 +56,18 @@ searchable creatable :creatable-prefix="t('admin.users.fuzzySearch')" - :search-placeholder="t('admin.users.searchGroups')" + :search-placeholder="t('admin.users.searchAuthorizedGroups')" + @change="applyFilter" + /> + + + +
+