feat(keys): sort by current concurrency

This commit is contained in:
Bestony@Homelab
2026-07-08 15:51:22 +08:00
parent 6f43986c37
commit 5debe1db33
6 changed files with 358 additions and 22 deletions
+35 -4
View File
@@ -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)
@@ -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)
}
+81 -2
View File
@@ -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
@@ -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"},
+1 -1
View File
@@ -1168,7 +1168,7 @@ const allColumns = computed<Column[]>(() => [
{ 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 },
@@ -149,11 +149,16 @@ const TablePageLayoutStub = {
}
const DataTableStub = {
name: 'DataTable',
props: ['columns', 'data'],
emits: ['sort'],
template: `
<div>
<div data-test="columns">{{ columns.map((col) => col.key).join(',') }}</div>
<div data-test="columns-meta">{{ JSON.stringify(columns.map((col) => ({ key: col.key, sortable: !!col.sortable }))) }}</div>
<button data-test="sort-current-concurrency" @click="$emit('sort', 'current_concurrency', 'asc')">
Sort Current Concurrency
</button>
<div v-for="row in data" :key="row.id">
<slot name="cell-name" :value="row.name" :row="row" />
<div data-test="current-concurrency">
@@ -166,17 +171,30 @@ const DataTableStub = {
}
const SelectStub = {
name: 'Select',
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"></select>',
}
const SearchInputStub = {
name: 'SearchInput',
props: ['modelValue'],
emits: ['update:modelValue', 'search'],
template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
}
const PaginationStub = {
name: 'Pagination',
props: ['page', 'total', 'pageSize'],
emits: ['update:page', 'update:pageSize'],
template: `
<div>
<button data-test="page-size-50" @click="$emit('update:pageSize', 50)">50</button>
</div>
`,
}
const IconStub = {
props: ['name'],
template: '<span data-test="icon">{{ name }}</span>',
@@ -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) })
)
})
})