From 9b382a2dc78fbb13b7e6cec71202ab504e049253 Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 7 Mar 2026 13:50:39 +0800 Subject: [PATCH] feat: add affinity clients hover popover with last active times - New API: GET /accounts/:id/affinity-clients returns client list with last_active timestamps from Redis ZSET scores - New Lua script: get_affinity_clients_with_scores.lua uses ZREVRANGEBYSCORE WITHSCORES to return both client IDs and their timestamps - New frontend component: AffinityBadge.vue replaces inline badge with hover popover that lazy-loads client details on first hover - Shows client ID (monospace) + relative time (e.g. "3h ago") Co-Authored-By: Claude Opus 4.6 --- .../internal/handler/admin/account_handler.go | 36 ++++ backend/internal/repository/gateway_cache.go | 66 ++++++- .../lua/get_affinity_clients_with_scores.lua | 6 + backend/internal/server/routes/admin.go | 1 + .../service/antigravity_smart_retry_test.go | 3 + .../gateway_affinity_scheduling_test.go | 3 + .../gateway_hotpath_optimization_test.go | 3 + .../service/gateway_multiplatform_test.go | 3 + backend/internal/service/gateway_service.go | 15 ++ .../service/gemini_multiplatform_test.go | 3 + .../service/openai_gateway_service_test.go | 3 + .../service/openai_ws_state_store_test.go | 3 + backend/internal/testutil/stubs.go | 3 + frontend/src/api/admin/accounts.ts | 15 +- .../src/components/account/AffinityBadge.vue | 172 ++++++++++++++++++ frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/views/admin/AccountsView.vue | 25 +-- 18 files changed, 338 insertions(+), 24 deletions(-) create mode 100644 backend/internal/repository/lua/get_affinity_clients_with_scores.lua create mode 100644 frontend/src/components/account/AffinityBadge.vue diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 5c453940e0..897854033f 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1417,6 +1417,42 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) { response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } +// GetAffinityClients returns the list of affinity clients for an account with last active timestamps. +// GET /api/v1/admin/accounts/:id/affinity-clients +func (h *AccountHandler) GetAffinityClients(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + if !account.IsClientAffinityEnabled() { + response.Success(c, []service.AffinityClient{}) + return + } + + if h.gatewayCache == nil || len(account.GroupIDs) == 0 { + response.Success(c, []service.AffinityClient{}) + return + } + + clients, err := h.gatewayCache.GetAccountAffinityClientsWithScores( + c.Request.Context(), accountID, account.GroupIDs, service.ClientAffinityTTL, + ) + if err != nil { + response.Success(c, []service.AffinityClient{}) + return + } + + response.Success(c, clients) +} + // GetTempUnschedulable handles getting temporary unschedulable status // GET /api/v1/admin/accounts/:id/temp-unschedulable func (h *AccountHandler) GetTempUnschedulable(c *gin.Context) { diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index 8aca561ae2..0d064c924a 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -26,11 +26,14 @@ var ( getAffinityCountLua string //go:embed lua/get_affinity_clients.lua getAffinityClientsLua string + //go:embed lua/get_affinity_clients_with_scores.lua + getAffinityClientsWithScoresLua string - getAffinityScript = redis.NewScript(getAffinityLua) - updateAffinityScript = redis.NewScript(updateAffinityLua) - getAffinityCountScript = redis.NewScript(getAffinityCountLua) - getAffinityClientsScript = redis.NewScript(getAffinityClientsLua) + getAffinityScript = redis.NewScript(getAffinityLua) + updateAffinityScript = redis.NewScript(updateAffinityLua) + getAffinityCountScript = redis.NewScript(getAffinityCountLua) + getAffinityClientsScript = redis.NewScript(getAffinityClientsLua) + getAffinityClientsWithScoresScript = redis.NewScript(getAffinityClientsWithScoresLua) ) type gatewayCache struct { @@ -211,3 +214,58 @@ func (c *gatewayCache) GetAccountAffinityClientsBatch(ctx context.Context, accou } return result, nil } + +// GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间戳,去重取最近)。 +func (c *gatewayCache) GetAccountAffinityClientsWithScores( + ctx context.Context, + accountID int64, + groupIDs []int64, + ttl time.Duration, +) ([]service.AffinityClient, error) { + if len(groupIDs) == 0 { + return nil, nil + } + + now := time.Now().Unix() + expireThreshold := now - int64(ttl.Seconds()) + + ensureScriptLoaded(ctx, c.rdb, getAffinityClientsWithScoresScript) + + pipe := c.rdb.Pipeline() + cmds := make([]*redis.Cmd, len(groupIDs)) + for i, gID := range groupIDs { + key := buildAffinityReverseKey(gID, accountID) + cmds[i] = getAffinityClientsWithScoresScript.Run(ctx, pipe, []string{key}, expireThreshold) + } + _, err := pipe.Exec(ctx) + if err != nil && err != redis.Nil { + return nil, err + } + + // 合并跨组结果,同一 clientID 取最近的 lastActive + seen := make(map[string]int64) // clientID → max timestamp + for _, cmd := range cmds { + vals, _ := cmd.StringSlice() + // vals 格式: [clientID1, score1, clientID2, score2, ...] + for j := 0; j+1 < len(vals); j += 2 { + clientID := vals[j] + ts, _ := strconv.ParseInt(vals[j+1], 10, 64) + if existing, ok := seen[clientID]; !ok || ts > existing { + seen[clientID] = ts + } + } + } + + result := make([]service.AffinityClient, 0, len(seen)) + for clientID, ts := range seen { + result = append(result, service.AffinityClient{ + ClientID: clientID, + LastActive: time.Unix(ts, 0), + }) + } + + // 按最后活跃时间降序排序 + service.SortAffinityClients(result) + + return result, nil +} diff --git a/backend/internal/repository/lua/get_affinity_clients_with_scores.lua b/backend/internal/repository/lua/get_affinity_clients_with_scores.lua new file mode 100644 index 0000000000..8e70708efb --- /dev/null +++ b/backend/internal/repository/lua/get_affinity_clients_with_scores.lua @@ -0,0 +1,6 @@ +-- 清理过期成员后返回反向索引的 clientID 列表及其 score(最后活跃时间戳) +-- KEYS[1] = client_affinity_rev:{groupID}:{accountID} +-- ARGV[1] = 过期阈值时间戳 (now - ttl) +-- 返回: {clientID1, score1, clientID2, score2, ...}(按最近使用降序) +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) +return redis.call('ZREVRANGEBYSCORE', KEYS[1], '+inf', '-inf', 'WITHSCORES') diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 8898fac3ca..74292e2d1f 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -253,6 +253,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/today-stats/batch", h.Admin.Account.GetBatchTodayStats) accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit) accounts.POST("/:id/reset-quota", h.Admin.Account.ResetQuota) + accounts.GET("/:id/affinity-clients", h.Admin.Account.GetAffinityClients) accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable) accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable) accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable) diff --git a/backend/internal/service/antigravity_smart_retry_test.go b/backend/internal/service/antigravity_smart_retry_test.go index 4e3fdb781d..07d55031b0 100644 --- a/backend/internal/service/antigravity_smart_retry_test.go +++ b/backend/internal/service/antigravity_smart_retry_test.go @@ -42,6 +42,9 @@ func (c *stubSmartRetryCache) GetAccountAffinityCountBatch(_ context.Context, _ func (c *stubSmartRetryCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { return map[int64][]string{}, nil } +func (c *stubSmartRetryCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) { + return nil, nil +} // mockSmartRetryUpstream 用于 handleSmartRetry 测试的 mock upstream type mockSmartRetryUpstream struct { diff --git a/backend/internal/service/gateway_affinity_scheduling_test.go b/backend/internal/service/gateway_affinity_scheduling_test.go index b55201d5fd..4af54d0a46 100644 --- a/backend/internal/service/gateway_affinity_scheduling_test.go +++ b/backend/internal/service/gateway_affinity_scheduling_test.go @@ -52,6 +52,9 @@ func (m *mockAffinityCache) GetAccountAffinityCountBatch(ctx context.Context, gr func (m *mockAffinityCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { return map[int64][]string{}, nil } +func (m *mockAffinityCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) { + return nil, nil +} // --------------------------------------------------------------------------- // Helper: 构造启用了客户端亲和的 Anthropic 账号 diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 697520c4f0..4745b6248f 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -155,6 +155,9 @@ func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityCountBatch(_ context.C func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { return map[int64][]string{}, nil } +func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) { + return nil, nil +} func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) { s.listByGroupCalls.Add(1) diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 75ec14d358..2b5803c784 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -247,6 +247,9 @@ func (m *mockGatewayCacheForPlatform) GetAccountAffinityCountBatch(_ context.Con func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { return map[int64][]string{}, nil } +func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) { + return nil, nil +} type mockGroupRepoForGateway struct { groups map[int64]*Group diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 34f9f2e6f3..2847ca411b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -380,6 +380,21 @@ type GatewayCache interface { // GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和客户端列表(去重) // accountGroups: map[accountID][]groupID GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error) + // GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间) + GetAccountAffinityClientsWithScores(ctx context.Context, accountID int64, groupIDs []int64, ttl time.Duration) ([]AffinityClient, error) +} + +// AffinityClient 亲和客户端信息(含最后活跃时间) +type AffinityClient struct { + ClientID string `json:"client_id"` + LastActive time.Time `json:"last_active"` +} + +// SortAffinityClients 按最后活跃时间降序排序 +func SortAffinityClients(clients []AffinityClient) { + sort.Slice(clients, func(i, j int) bool { + return clients[i].LastActive.After(clients[j].LastActive) + }) } // derefGroupID safely dereferences *int64 to int64, returning 0 if nil diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 3ba5aa9f52..f53bf5c17c 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -300,6 +300,9 @@ func (m *mockGatewayCacheForGemini) GetAccountAffinityCountBatch(_ context.Conte func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { return map[int64][]string{}, nil } +func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) { + return nil, nil +} // TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择 func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index fbd0e8f146..25a86ec85e 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -293,6 +293,9 @@ func (c *stubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int func (c *stubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { return map[int64][]string{}, nil } +func (c *stubGatewayCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) { + return nil, nil +} func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) { now := time.Now() diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go index c85c9aae48..d7e5d69145 100644 --- a/backend/internal/service/openai_ws_state_store_test.go +++ b/backend/internal/service/openai_ws_state_store_test.go @@ -205,6 +205,9 @@ func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityCountBatch(_ con func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { return map[int64][]string{}, nil } +func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) { + return nil, nil +} func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) { probe := &openAIWSStateStoreTimeoutProbeCache{} diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index 44242c1aaa..bae3e7d6f9 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -109,6 +109,9 @@ func (c StubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int6 func (c StubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) { return map[int64][]string{}, nil } +func (c StubGatewayCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]service.AffinityClient, error) { + return nil, nil +} // ============================================================ // StubSessionLimitCache — service.SessionLimitCache 的空实现 diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 5524e0cbe1..1a26d0123c 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -571,6 +571,18 @@ export async function validateSoraSessionToken( return data } +/** + * Get affinity clients for an account with last active timestamps + * @param id - Account ID + * @returns List of affinity clients + */ +export async function getAffinityClients(id: number): Promise<{ client_id: string; last_active: string }[]> { + const { data } = await apiClient.get<{ client_id: string; last_active: string }[]>( + `/admin/accounts/${id}/affinity-clients` + ) + return data +} + export const accountsAPI = { list, listWithEtag, @@ -604,7 +616,8 @@ export const accountsAPI = { syncFromCrs, exportData, importData, - getAntigravityDefaultModelMapping + getAntigravityDefaultModelMapping, + getAffinityClients } export default accountsAPI diff --git a/frontend/src/components/account/AffinityBadge.vue b/frontend/src/components/account/AffinityBadge.vue new file mode 100644 index 0000000000..5d17cf0f93 --- /dev/null +++ b/frontend/src/components/account/AffinityBadge.vue @@ -0,0 +1,172 @@ + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 8d2b0476a2..010838874e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -244,6 +244,7 @@ export default { // Common common: { loading: 'Loading...', + justNow: 'just now', save: 'Save', cancel: 'Cancel', delete: 'Delete', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4bb6ef3f8d..a465e97da1 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -244,6 +244,7 @@ export default { // Common common: { loading: '加载中...', + justNow: '刚刚', save: '保存', cancel: '取消', delete: '删除', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index a93fc5d665..d071ea0c53 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -177,12 +177,11 @@ @@ -310,6 +309,7 @@ import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue' import ScheduledTestsPanel from '@/components/admin/account/ScheduledTestsPanel.vue' import type { SelectOption } from '@/components/common/Select.vue' import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue' +import AffinityBadge from '@/components/account/AffinityBadge.vue' import AccountUsageCell from '@/components/account/AccountUsageCell.vue' import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue' import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue' @@ -406,19 +406,6 @@ const buildDefaultTodayStats = (): WindowStats => ({ user_cost: 0 }) -function affinityBadgeClass(count: number): string { - if (count >= 16) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' - if (count >= 6) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' - if (count > 0) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' - return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' -} - -function affinityTooltip(row: Account): string { - const clients = row.affinity_clients - if (!clients || clients.length === 0) return t('admin.accounts.affinityNoClients') - return t('admin.accounts.affinityClients', { count: clients.length }) + '\n' + clients.join('\n') -} - const refreshTodayStatsBatch = async () => { if (hiddenColumns.has('today_stats')) { todayStatsLoading.value = false