From 5debe1db33e689a3f32c86bc3e4d896e11321f2f Mon Sep 17 00:00:00 2001 From: "Bestony@Homelab" Date: Wed, 8 Jul 2026 15:51:22 +0800 Subject: [PATCH] feat(keys): sort by current concurrency --- backend/internal/repository/api_key_repo.go | 39 +++- .../api_key_repo_sort_integration_test.go | 17 ++ backend/internal/service/api_key_service.go | 83 ++++++++- .../service/api_key_service_delete_test.go | 171 ++++++++++++++++-- frontend/src/views/user/KeysView.vue | 2 +- .../src/views/user/__tests__/KeysView.spec.ts | 68 ++++++- 6 files changed, 358 insertions(+), 22 deletions(-) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 877fc90353..6e69d07d03 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -388,10 +388,9 @@ func (r *apiKeyRepository) deleteWithAudit(ctx context.Context, exec *dbent.Clie return nil } -func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) { +func (r *apiKeyRepository) apiKeyListByUserIDQuery(userID int64, filters service.APIKeyListFilters) *dbent.APIKeyQuery { q := r.activeQuery().Where(apikey.UserIDEQ(userID)) - // Apply filters if filters.Search != "" { q = q.Where(apikey.Or( apikey.NameContainsFold(filters.Search), @@ -409,6 +408,12 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param } } + return q +} + +func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) { + q := r.apiKeyListByUserIDQuery(userID, filters) + total, err := q.Count(ctx) if err != nil { return nil, nil, err @@ -435,6 +440,22 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param return outKeys, paginationResultFromTotal(int64(total), params), nil } +func (r *apiKeyRepository) ListAllByUserID(ctx context.Context, userID int64, filters service.APIKeyListFilters) ([]service.APIKey, error) { + keys, err := r.apiKeyListByUserIDQuery(userID, filters). + WithGroup(). + Order(dbent.Asc(apikey.FieldID)). + All(ctx) + if err != nil { + return nil, err + } + + outKeys := make([]service.APIKey, 0, len(keys)) + for i := range keys { + outKeys = append(outKeys, *apiKeyEntityToService(keys[i])) + } + return outKeys, nil +} + func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { if len(apiKeyIDs) == 0 { return []int64{}, nil @@ -504,14 +525,24 @@ func apiKeyListOrder(params pagination.PaginationParams) []func(*entsql.Selector field = apikey.FieldLastUsedAt case "created_at": field = apikey.FieldCreatedAt + case "id": + field = apikey.FieldID default: field = apikey.FieldID } if sortOrder == pagination.SortOrderAsc { - return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(apikey.FieldID)} + orders := []func(*entsql.Selector){dbent.Asc(field)} + if field != apikey.FieldID { + orders = append(orders, dbent.Asc(apikey.FieldID)) + } + return orders } - return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(apikey.FieldID)} + orders := []func(*entsql.Selector){dbent.Desc(field)} + if field != apikey.FieldID { + orders = append(orders, dbent.Desc(apikey.FieldID)) + } + return orders } // SearchAPIKeys searches API keys by user ID and/or keyword (name) diff --git a/backend/internal/repository/api_key_repo_sort_integration_test.go b/backend/internal/repository/api_key_repo_sort_integration_test.go index 69812882fe..e636fd848b 100644 --- a/backend/internal/repository/api_key_repo_sort_integration_test.go +++ b/backend/internal/repository/api_key_repo_sort_integration_test.go @@ -23,3 +23,20 @@ func (s *APIKeyRepoSuite) TestListByUserID_SortByNameAsc() { s.Require().Equal("a-key", keys[0].Name) s.Require().Equal("z-key", keys[1].Name) } + +func (s *APIKeyRepoSuite) TestListByUserID_SortByID() { + user := s.mustCreateUser("sort-id@example.com") + first := s.mustCreateApiKey(user.ID, "sk-id-a", "a-key", nil) + second := s.mustCreateApiKey(user.ID, "sk-id-b", "b-key", nil) + + keys, _, err := s.repo.ListByUserID(s.ctx, user.ID, pagination.PaginationParams{ + Page: 1, + PageSize: 10, + SortBy: "id", + SortOrder: "desc", + }, service.APIKeyListFilters{}) + s.Require().NoError(err) + s.Require().Len(keys, 2) + s.Require().Equal(second.ID, keys[0].ID) + s.Require().Equal(first.ID, keys[1].ID) +} diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 8903be65ee..b13ca6b3d0 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "html" + "sort" "strconv" "strings" "sync" @@ -40,8 +41,9 @@ var ( ) const ( - apiKeyMaxErrorsPerHour = 20 - apiKeyLastUsedMinTouch = 30 * time.Second + apiKeyMaxErrorsPerHour = 20 + apiKeyLastUsedMinTouch = 30 * time.Second + apiKeySortCurrentConcurrency = "current_concurrency" // DB 写失败后的短退避,避免请求路径持续同步重试造成写风暴与高延迟。 apiKeyLastUsedFailBackoff = 5 * time.Second ) @@ -82,6 +84,10 @@ type APIKeyRepository interface { GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error) } +type apiKeyAllByUserIDLister interface { + ListAllByUserID(ctx context.Context, userID int64, filters APIKeyListFilters) ([]APIKey, error) +} + // APIKeyRateLimitData holds rate limit usage and window state for an API key. type APIKeyRateLimitData struct { Usage5h float64 @@ -437,6 +443,10 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK // List 获取用户的API Key列表 func (s *APIKeyService) List(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) { + if normalizedAPIKeySortBy(params.SortBy) == apiKeySortCurrentConcurrency { + return s.listByCurrentConcurrency(ctx, userID, params, filters) + } + keys, pagination, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, filters) if err != nil { return nil, nil, fmt.Errorf("list api keys: %w", err) @@ -445,6 +455,75 @@ func (s *APIKeyService) List(ctx context.Context, userID int64, params paginatio return keys, pagination, nil } +func (s *APIKeyService) listByCurrentConcurrency(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) { + repo, ok := s.apiKeyRepo.(apiKeyAllByUserIDLister) + if !ok { + return nil, nil, fmt.Errorf("list api keys by current concurrency: repository does not support unpaginated API key listing") + } + + keys, err := repo.ListAllByUserID(ctx, userID, filters) + if err != nil { + return nil, nil, fmt.Errorf("list api keys: %w", err) + } + s.fillCurrentConcurrency(ctx, keys) + sortAPIKeysByCurrentConcurrency(keys, params.NormalizedSortOrder(pagination.SortOrderDesc)) + return paginateAPIKeys(keys, params), apiKeyPaginationResult(int64(len(keys)), params), nil +} + +func normalizedAPIKeySortBy(sortBy string) string { + return strings.ToLower(strings.TrimSpace(sortBy)) +} + +func sortAPIKeysByCurrentConcurrency(keys []APIKey, sortOrder string) { + desc := sortOrder != pagination.SortOrderAsc + sort.SliceStable(keys, func(i, j int) bool { + if keys[i].CurrentConcurrency == keys[j].CurrentConcurrency { + if desc { + return keys[i].ID > keys[j].ID + } + return keys[i].ID < keys[j].ID + } + if desc { + return keys[i].CurrentConcurrency > keys[j].CurrentConcurrency + } + return keys[i].CurrentConcurrency < keys[j].CurrentConcurrency + }) +} + +func paginateAPIKeys(keys []APIKey, params pagination.PaginationParams) []APIKey { + if len(keys) == 0 { + return []APIKey{} + } + limit := params.Limit() + page := params.Page + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + if offset >= len(keys) { + return []APIKey{} + } + end := offset + limit + if end > len(keys) { + end = len(keys) + } + return keys[offset:end] +} + +func apiKeyPaginationResult(total int64, params pagination.PaginationParams) *pagination.PaginationResult { + limit := params.Limit() + pages := int(total) / limit + if int(total)%limit > 0 { + pages++ + } + return &pagination.PaginationResult{ + Total: total, + Page: params.Page, + PageSize: limit, + Pages: pages, + } +} + func (s *APIKeyService) fillCurrentConcurrency(ctx context.Context, keys []APIKey) { if s == nil || s.concurrencyService == nil || len(keys) == 0 { return diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go index 25ad1edb15..a6ac75d60e 100644 --- a/backend/internal/service/api_key_service_delete_test.go +++ b/backend/internal/service/api_key_service_delete_test.go @@ -9,6 +9,7 @@ package service import ( "context" "errors" + "strings" "testing" "time" @@ -24,20 +25,26 @@ import ( // - deleteErr: 模拟 Delete 返回的错误 // - deletedIDs: 记录被调用删除的 API Key ID,用于断言验证 type apiKeyRepoStub struct { - apiKey *APIKey // GetKeyAndOwnerID 的返回值 - getByIDErr error // GetKeyAndOwnerID 的错误返回值 - deleteErr error // Delete 的错误返回值 - updateErr error // Update 的错误返回值 - deletedIDs []int64 // 记录已删除的 API Key ID 列表 - updatedKeys []APIKey - allowListByUserID bool - listByUserIDKeys []APIKey - listByUserIDErr error - listByUserIDCalls []int64 - listByUserIDParams []pagination.PaginationParams - updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error - touchedIDs []int64 - touchedUsedAts []time.Time + apiKey *APIKey // GetKeyAndOwnerID 的返回值 + getByIDErr error // GetKeyAndOwnerID 的错误返回值 + deleteErr error // Delete 的错误返回值 + updateErr error // Update 的错误返回值 + deletedIDs []int64 // 记录已删除的 API Key ID 列表 + updatedKeys []APIKey + allowListByUserID bool + listByUserIDKeys []APIKey + listByUserIDErr error + listByUserIDCalls []int64 + listByUserIDParams []pagination.PaginationParams + listByUserIDFilters []APIKeyListFilters + allowListAllByUserID bool + listAllByUserIDKeys []APIKey + listAllByUserIDErr error + listAllByUserIDCalls []int64 + listAllByUserIDFilters []APIKeyListFilters + updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error + touchedIDs []int64 + touchedUsedAts []time.Time } // 以下方法在本测试中不应被调用,使用 panic 确保测试失败时能快速定位问题 @@ -103,6 +110,7 @@ func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params } s.listByUserIDCalls = append(s.listByUserIDCalls, userID) s.listByUserIDParams = append(s.listByUserIDParams, params) + s.listByUserIDFilters = append(s.listByUserIDFilters, filters) if s.listByUserIDErr != nil { return nil, nil, s.listByUserIDErr } @@ -115,6 +123,51 @@ func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params }, nil } +func (s *apiKeyRepoStub) ListAllByUserID(ctx context.Context, userID int64, filters APIKeyListFilters) ([]APIKey, error) { + if !s.allowListAllByUserID { + panic("unexpected ListAllByUserID call") + } + s.listAllByUserIDCalls = append(s.listAllByUserIDCalls, userID) + s.listAllByUserIDFilters = append(s.listAllByUserIDFilters, filters) + if s.listAllByUserIDErr != nil { + return nil, s.listAllByUserIDErr + } + source := s.listByUserIDKeys + if s.listAllByUserIDKeys != nil { + source = s.listAllByUserIDKeys + } + return filterAPIKeyStubKeys(userID, source, filters), nil +} + +func filterAPIKeyStubKeys(userID int64, keys []APIKey, filters APIKeyListFilters) []APIKey { + result := make([]APIKey, 0, len(keys)) + search := strings.ToLower(filters.Search) + for _, key := range keys { + if key.UserID != userID { + continue + } + if search != "" && + !strings.Contains(strings.ToLower(key.Name), search) && + !strings.Contains(strings.ToLower(key.Key), search) { + continue + } + if filters.Status != "" && key.Status != filters.Status { + continue + } + if filters.GroupID != nil { + if *filters.GroupID == 0 { + if key.GroupID != nil { + continue + } + } else if key.GroupID == nil || *key.GroupID != *filters.GroupID { + continue + } + } + result = append(result, key) + } + return result +} + func (s *apiKeyRepoStub) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { panic("unexpected VerifyOwnership call") } @@ -320,6 +373,96 @@ func TestAPIKeyService_List_FillsCurrentConcurrency(t *testing.T) { require.Equal(t, 0, keys[1].CurrentConcurrency) } +func TestAPIKeyService_List_SortByCurrentConcurrency(t *testing.T) { + groupID := int64(42) + keys := []APIKey{ + {ID: 1, UserID: 7, Key: "sk-target-1", Name: "target-one", GroupID: &groupID, Status: StatusActive}, + {ID: 2, UserID: 7, Key: "sk-target-2", Name: "target-two", GroupID: &groupID, Status: StatusActive}, + {ID: 3, UserID: 7, Key: "sk-target-3", Name: "target-three", GroupID: &groupID, Status: StatusActive}, + {ID: 4, UserID: 7, Key: "sk-target-4", Name: "target-four", GroupID: &groupID, Status: StatusActive}, + {ID: 9, UserID: 7, Key: "sk-target-9", Name: "target-inactive", GroupID: &groupID, Status: StatusDisabled}, + {ID: 10, UserID: 7, Key: "sk-other-10", Name: "other", GroupID: &groupID, Status: StatusActive}, + {ID: 11, UserID: 7, Key: "sk-target-11", Name: "target-no-group", Status: StatusActive}, + {ID: 12, UserID: 8, Key: "sk-target-12", Name: "target-other-user", GroupID: &groupID, Status: StatusActive}, + } + filters := APIKeyListFilters{ + Search: "target", + Status: StatusActive, + GroupID: &groupID, + } + repo := &apiKeyRepoStub{ + allowListAllByUserID: true, + listAllByUserIDKeys: keys, + } + concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{ + apiKeyConcurrency: map[int64]int{ + 1: 5, + 2: 5, + 3: 2, + 4: 8, + 9: 99, + 10: 99, + 11: 99, + 12: 99, + }, + }) + svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency} + + got, page, err := svc.List(context.Background(), 7, pagination.PaginationParams{ + Page: 2, + PageSize: 2, + SortBy: "current_concurrency", + SortOrder: "desc", + }, filters) + require.NoError(t, err) + require.Equal(t, []int64{1, 3}, apiKeyTestIDs(got)) + require.Equal(t, int64(4), page.Total) + require.Equal(t, 2, page.Page) + require.Equal(t, 2, page.PageSize) + require.Equal(t, 2, page.Pages) + require.Empty(t, repo.listByUserIDCalls) + require.Equal(t, []int64{7}, repo.listAllByUserIDCalls) + require.Len(t, repo.listAllByUserIDFilters, 1) + require.Equal(t, filters.Search, repo.listAllByUserIDFilters[0].Search) + require.Equal(t, filters.Status, repo.listAllByUserIDFilters[0].Status) + require.NotNil(t, repo.listAllByUserIDFilters[0].GroupID) + require.Equal(t, groupID, *repo.listAllByUserIDFilters[0].GroupID) +} + +func TestAPIKeyService_List_SortByCurrentConcurrencyAscTiesByID(t *testing.T) { + repo := &apiKeyRepoStub{ + allowListAllByUserID: true, + listAllByUserIDKeys: []APIKey{ + {ID: 1, UserID: 7, Key: "sk-1", Name: "one", Status: StatusActive}, + {ID: 2, UserID: 7, Key: "sk-2", Name: "two", Status: StatusActive}, + {ID: 3, UserID: 7, Key: "sk-3", Name: "three", Status: StatusActive}, + {ID: 4, UserID: 7, Key: "sk-4", Name: "four", Status: StatusActive}, + }, + } + concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{ + apiKeyConcurrency: map[int64]int{1: 5, 2: 5, 3: 2, 4: 8}, + }) + svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency} + + got, page, err := svc.List(context.Background(), 7, pagination.PaginationParams{ + Page: 1, + PageSize: 4, + SortBy: "current_concurrency", + SortOrder: "asc", + }, APIKeyListFilters{}) + require.NoError(t, err) + require.Equal(t, []int64{3, 1, 2, 4}, apiKeyTestIDs(got)) + require.Equal(t, 4, page.PageSize) +} + +func apiKeyTestIDs(keys []APIKey) []int64 { + ids := make([]int64, 0, len(keys)) + for _, key := range keys { + ids = append(ids, key.ID) + } + return ids +} + func TestAPIKeyService_GetByID_FillsCurrentConcurrency(t *testing.T) { repo := &apiKeyRepoStub{ apiKey: &APIKey{ID: 10, UserID: 7, Key: "sk-10", Name: "key-10"}, diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 087e0e4175..1879fe2a4c 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -1168,7 +1168,7 @@ const allColumns = computed(() => [ { key: 'name', label: t('common.name'), sortable: true }, { key: 'key', label: t('keys.apiKey'), sortable: false }, { key: 'group', label: t('keys.group'), sortable: false }, - { key: 'current_concurrency', label: t('keys.currentConcurrency'), sortable: false }, + { key: 'current_concurrency', label: t('keys.currentConcurrency'), sortable: true }, { key: 'usage', label: t('keys.usage'), sortable: false }, { key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false }, { key: 'expires_at', label: t('keys.expiresAt'), sortable: true }, diff --git a/frontend/src/views/user/__tests__/KeysView.spec.ts b/frontend/src/views/user/__tests__/KeysView.spec.ts index 2417cd9e5c..d23bd0896d 100644 --- a/frontend/src/views/user/__tests__/KeysView.spec.ts +++ b/frontend/src/views/user/__tests__/KeysView.spec.ts @@ -149,11 +149,16 @@ const TablePageLayoutStub = { } const DataTableStub = { + name: 'DataTable', props: ['columns', 'data'], emits: ['sort'], template: `
{{ columns.map((col) => col.key).join(',') }}
+
{{ JSON.stringify(columns.map((col) => ({ key: col.key, sortable: !!col.sortable }))) }}
+
@@ -166,17 +171,30 @@ const DataTableStub = { } const SelectStub = { + name: 'Select', props: ['modelValue', 'options'], emits: ['update:modelValue'], template: '', } const SearchInputStub = { + name: 'SearchInput', props: ['modelValue'], emits: ['update:modelValue', 'search'], template: '', } +const PaginationStub = { + name: 'Pagination', + props: ['page', 'total', 'pageSize'], + emits: ['update:page', 'update:pageSize'], + template: ` +
+ +
+ `, +} + const IconStub = { props: ['name'], template: '{{ name }}', @@ -189,7 +207,7 @@ const mountView = async () => { AppLayout: AppLayoutStub, TablePageLayout: TablePageLayoutStub, DataTable: DataTableStub, - Pagination: true, + Pagination: PaginationStub, BaseDialog: true, ConfirmDialog: true, EmptyState: true, @@ -212,6 +230,9 @@ const mountView = async () => { const visibleColumnKeys = (wrapper: VueWrapper) => wrapper.get('[data-test="columns"]').text().split(',').filter(Boolean) +const visibleColumnMeta = (wrapper: VueWrapper): Array<{ key: string; sortable: boolean }> => + JSON.parse(wrapper.get('[data-test="columns-meta"]').text()) + const getButtonByText = (wrapper: VueWrapper, text: string) => { const button = wrapper.findAll('button').find((item) => item.text().includes(text)) if (!button) { @@ -317,4 +338,49 @@ describe('user KeysView column settings', () => { expect(wrapper.get('[data-test="current-concurrency"]').text()).toBe('3') }) + + it('marks current concurrency as sortable', async () => { + const wrapper = await mountView() + + const currentConcurrencyColumn = visibleColumnMeta(wrapper).find( + (column) => column.key === 'current_concurrency' + ) + expect(currentConcurrencyColumn?.sortable).toBe(true) + }) + + it('keeps filters and selected page size when sorting by current concurrency', async () => { + getAvailableGroups.mockResolvedValue([{ id: 42, name: 'OpenAI' }]) + const wrapper = await mountView() + + await wrapper.get('[data-test="page-size-50"]').trigger('click') + await flushPromises() + + await wrapper.findComponent({ name: 'SearchInput' }).vm.$emit('update:modelValue', 'target') + await wrapper.findComponent({ name: 'SearchInput' }).vm.$emit('search') + await flushPromises() + + const selects = wrapper.findAllComponents({ name: 'Select' }) + await selects[0].vm.$emit('update:modelValue', 42) + await flushPromises() + await selects[1].vm.$emit('update:modelValue', 'active') + await flushPromises() + + listKeys.mockClear() + + await wrapper.get('[data-test="sort-current-concurrency"]').trigger('click') + await flushPromises() + + expect(listKeys).toHaveBeenLastCalledWith( + 1, + 50, + { + search: 'target', + status: 'active', + group_id: 42, + sort_by: 'current_concurrency', + sort_order: 'asc', + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + }) })