diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index 9de22768bf..bb79b38695 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -140,7 +140,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
rpmCache := repository.NewRPMCache(redisClient)
- accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
+ accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, gatewayCache)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService()
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
diff --git a/backend/internal/handler/admin/account_data_handler_test.go b/backend/internal/handler/admin/account_data_handler_test.go
index 285033a17d..e516b275f2 100644
--- a/backend/internal/handler/admin/account_data_handler_test.go
+++ b/backend/internal/handler/admin/account_data_handler_test.go
@@ -65,6 +65,7 @@ func setupAccountDataRouter() (*gin.Engine, *stubAdminService) {
nil,
nil,
nil,
+ nil,
)
router.GET("/api/v1/admin/accounts/data", h.ExportData)
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 843d51c516..3c8548346f 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -55,6 +55,7 @@ type AccountHandler struct {
sessionLimitCache service.SessionLimitCache
rpmCache service.RPMCache
tokenCacheInvalidator service.TokenCacheInvalidator
+ gatewayCache service.GatewayCache
}
// NewAccountHandler creates a new admin account handler
@@ -72,6 +73,7 @@ func NewAccountHandler(
sessionLimitCache service.SessionLimitCache,
rpmCache service.RPMCache,
tokenCacheInvalidator service.TokenCacheInvalidator,
+ gatewayCache service.GatewayCache,
) *AccountHandler {
return &AccountHandler{
adminService: adminService,
@@ -87,6 +89,7 @@ func NewAccountHandler(
sessionLimitCache: sessionLimitCache,
rpmCache: rpmCache,
tokenCacheInvalidator: tokenCacheInvalidator,
+ gatewayCache: gatewayCache,
}
}
@@ -204,6 +207,18 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac
}
}
+ // 亲和客户端数据
+ if h.gatewayCache != nil && account.IsClientAffinityEnabled() && len(account.GroupIDs) > 0 {
+ accountGroups := map[int64][]int64{account.ID: account.GroupIDs}
+ if clients, err := h.gatewayCache.GetAccountAffinityClientsBatch(ctx, accountGroups, service.ClientAffinityTTL); err == nil {
+ if cl, ok := clients[account.ID]; ok {
+ count := int64(len(cl))
+ item.Account.AffinityClientCount = &count
+ item.Account.AffinityClients = cl
+ }
+ }
+ }
+
return item
}
@@ -332,6 +347,21 @@ func (h *AccountHandler) List(c *gin.Context) {
}
}
+ // 获取亲和客户端数据(Redis Pipeline,低开销)
+ var affinityClients map[int64][]string
+ if h.gatewayCache != nil {
+ accountGroups := make(map[int64][]int64)
+ for i := range accounts {
+ acc := &accounts[i]
+ if acc.IsClientAffinityEnabled() && len(acc.GroupIDs) > 0 {
+ accountGroups[acc.ID] = acc.GroupIDs
+ }
+ }
+ if len(accountGroups) > 0 {
+ affinityClients, _ = h.gatewayCache.GetAccountAffinityClientsBatch(c.Request.Context(), accountGroups, service.ClientAffinityTTL)
+ }
+ }
+
// Build response with concurrency info
result := make([]AccountWithConcurrency, len(accounts))
for i := range accounts {
@@ -362,6 +392,15 @@ func (h *AccountHandler) List(c *gin.Context) {
}
}
+ // 注入亲和客户端数据到 DTO
+ if affinityClients != nil {
+ if clients, ok := affinityClients[acc.ID]; ok {
+ count := int64(len(clients))
+ item.Account.AffinityClientCount = &count
+ item.Account.AffinityClients = clients
+ }
+ }
+
result[i] = item
}
diff --git a/backend/internal/handler/admin/account_handler_mixed_channel_test.go b/backend/internal/handler/admin/account_handler_mixed_channel_test.go
index 5b81db2aad..5e45619064 100644
--- a/backend/internal/handler/admin/account_handler_mixed_channel_test.go
+++ b/backend/internal/handler/admin/account_handler_mixed_channel_test.go
@@ -15,7 +15,7 @@ import (
func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
- accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/accounts/check-mixed-channel", accountHandler.CheckMixedChannel)
router.POST("/api/v1/admin/accounts", accountHandler.Create)
router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update)
diff --git a/backend/internal/handler/admin/account_handler_passthrough_test.go b/backend/internal/handler/admin/account_handler_passthrough_test.go
index d86501c047..a11f2c5195 100644
--- a/backend/internal/handler/admin/account_handler_passthrough_test.go
+++ b/backend/internal/handler/admin/account_handler_passthrough_test.go
@@ -29,6 +29,7 @@ func TestAccountHandler_Create_AnthropicAPIKeyPassthroughExtraForwarded(t *testi
nil,
nil,
nil,
+ nil,
)
router := gin.New()
diff --git a/backend/internal/handler/admin/batch_update_credentials_test.go b/backend/internal/handler/admin/batch_update_credentials_test.go
index 0b1b669174..f2cd1e3a1c 100644
--- a/backend/internal/handler/admin/batch_update_credentials_test.go
+++ b/backend/internal/handler/admin/batch_update_credentials_test.go
@@ -36,7 +36,7 @@ func (f *failingAdminService) UpdateAccount(ctx context.Context, id int64, input
func setupAccountHandlerWithService(adminSvc service.AdminService) (*gin.Engine, *AccountHandler) {
gin.SetMode(gin.TestMode)
router := gin.New()
- handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/accounts/batch-update-credentials", handler.BatchUpdateCredentials)
return router, handler
}
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index c2bb849feb..17a67d4ed1 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -192,6 +192,10 @@ type Account struct {
// 启用后新会话会优先调度到客户端之前使用过的账号
ClientAffinityEnabled *bool `json:"client_affinity_enabled,omitempty"`
+ // 亲和客户端数据(仅 admin 列表端点注入,不由 mapper 填充)
+ AffinityClientCount *int64 `json:"affinity_client_count,omitempty"`
+ AffinityClients []string `json:"affinity_clients,omitempty"`
+
// API Key 账号配额限制
QuotaLimit *float64 `json:"quota_limit,omitempty"`
QuotaUsed *float64 `json:"quota_used,omitempty"`
diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go
index 9efa923e8f..a226e7e435 100644
--- a/backend/internal/repository/gateway_cache.go
+++ b/backend/internal/repository/gateway_cache.go
@@ -24,10 +24,13 @@ var (
updateAffinityLua string
//go:embed lua/get_affinity_count.lua
getAffinityCountLua string
+ //go:embed lua/get_affinity_clients.lua
+ getAffinityClientsLua string
- getAffinityScript = redis.NewScript(getAffinityLua)
- updateAffinityScript = redis.NewScript(updateAffinityLua)
- getAffinityCountScript = redis.NewScript(getAffinityCountLua)
+ getAffinityScript = redis.NewScript(getAffinityLua)
+ updateAffinityScript = redis.NewScript(updateAffinityLua)
+ getAffinityCountScript = redis.NewScript(getAffinityCountLua)
+ getAffinityClientsScript = redis.NewScript(getAffinityClientsLua)
)
type gatewayCache struct {
@@ -140,3 +143,57 @@ func (c *gatewayCache) GetAccountAffinityCountBatch(ctx context.Context, groupID
}
return result, nil
}
+
+// GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和客户端列表(去重)。
+// accountGroups: map[accountID][]groupID,对每个 (groupID, accountID) 组合查询反向索引。
+func (c *gatewayCache) GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error) {
+ if len(accountGroups) == 0 {
+ return map[int64][]string{}, nil
+ }
+
+ now := time.Now().Unix()
+ expireThreshold := now - int64(ttl.Seconds())
+
+ // 构建所有 (accountID, groupID) 组合的查询
+ type queryItem struct {
+ accountID int64
+ groupID int64
+ }
+ var queries []queryItem
+ for accID, groupIDs := range accountGroups {
+ for _, gID := range groupIDs {
+ queries = append(queries, queryItem{accountID: accID, groupID: gID})
+ }
+ }
+
+ pipe := c.rdb.Pipeline()
+ cmds := make([]*redis.Cmd, len(queries))
+ for i, q := range queries {
+ key := buildAffinityReverseKey(q.groupID, q.accountID)
+ cmds[i] = getAffinityClientsScript.Run(ctx, pipe, []string{key}, expireThreshold)
+ }
+ _, err := pipe.Exec(ctx)
+ if err != nil && err != redis.Nil {
+ return nil, err
+ }
+
+ // 合并结果:同一个 accountID 跨多个 group 的 clientID 去重
+ result := make(map[int64][]string, len(accountGroups))
+ seen := make(map[int64]map[string]struct{}, len(accountGroups))
+ for i, q := range queries {
+ clients, _ := cmds[i].StringSlice()
+ if len(clients) == 0 {
+ continue
+ }
+ if seen[q.accountID] == nil {
+ seen[q.accountID] = make(map[string]struct{})
+ }
+ for _, clientID := range clients {
+ if _, exists := seen[q.accountID][clientID]; !exists {
+ seen[q.accountID][clientID] = struct{}{}
+ result[q.accountID] = append(result[q.accountID], clientID)
+ }
+ }
+ }
+ return result, nil
+}
diff --git a/backend/internal/repository/lua/get_affinity_clients.lua b/backend/internal/repository/lua/get_affinity_clients.lua
new file mode 100644
index 0000000000..049b1c5b7c
--- /dev/null
+++ b/backend/internal/repository/lua/get_affinity_clients.lua
@@ -0,0 +1,5 @@
+-- 清理过期成员后返回反向索引的 clientID 列表(按最近使用降序)
+-- KEYS[1] = client_affinity_rev:{groupID}:{accountID}
+-- ARGV[1] = 过期阈值时间戳 (now - ttl)
+redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
+return redis.call('ZREVRANGE', KEYS[1], 0, -1)
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 7e7c898220..14de1dc5d4 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -648,7 +648,7 @@ func newContractDeps(t *testing.T) *contractDeps {
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil)
- adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
jwtAuth := func(c *gin.Context) {
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{
diff --git a/backend/internal/service/antigravity_smart_retry_test.go b/backend/internal/service/antigravity_smart_retry_test.go
index c2f68e9bc4..4e3fdb781d 100644
--- a/backend/internal/service/antigravity_smart_retry_test.go
+++ b/backend/internal/service/antigravity_smart_retry_test.go
@@ -39,6 +39,9 @@ func (c *stubSmartRetryCache) UpdateClientAffinity(_ context.Context, _ int64, _
func (c *stubSmartRetryCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
+func (c *stubSmartRetryCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
+ return map[int64][]string{}, 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 c276d7a926..b55201d5fd 100644
--- a/backend/internal/service/gateway_affinity_scheduling_test.go
+++ b/backend/internal/service/gateway_affinity_scheduling_test.go
@@ -49,6 +49,9 @@ func (m *mockAffinityCache) GetAccountAffinityCountBatch(ctx context.Context, gr
}
return map[int64]int64{}, nil
}
+func (m *mockAffinityCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
+ return map[int64][]string{}, nil
+}
// ---------------------------------------------------------------------------
// Helper: 构造启用了客户端亲和的 Anthropic 账号
diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go
index f58cfed1d9..697520c4f0 100644
--- a/backend/internal/service/gateway_hotpath_optimization_test.go
+++ b/backend/internal/service/gateway_hotpath_optimization_test.go
@@ -152,6 +152,9 @@ func (s *stickyGatewayCacheHotpathStub) UpdateClientAffinity(_ context.Context,
func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
+func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
+ return map[int64][]string{}, 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 c867609d74..75ec14d358 100644
--- a/backend/internal/service/gateway_multiplatform_test.go
+++ b/backend/internal/service/gateway_multiplatform_test.go
@@ -244,6 +244,9 @@ func (m *mockGatewayCacheForPlatform) UpdateClientAffinity(_ context.Context, _
func (m *mockGatewayCacheForPlatform) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
+func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
+ return map[int64][]string{}, 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 bc12e092df..0c57274597 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -41,7 +41,7 @@ const (
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
stickySessionTTL = time.Hour // 粘性会话TTL
- clientAffinityTTL = 24 * time.Hour // 客户端亲和TTL
+ ClientAffinityTTL = 24 * time.Hour // 客户端亲和TTL
defaultMaxLineSize = 40 * 1024 * 1024
// Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines)
// to match real Claude CLI traffic as closely as possible. When we need a visual
@@ -377,6 +377,9 @@ type GatewayCache interface {
UpdateClientAffinity(ctx context.Context, groupID int64, clientID string, accountID int64, ttl time.Duration) error
// GetAccountAffinityCountBatch 批量获取账号的亲和客户端数量(惰性清理过期成员)
GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error)
+ // GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和客户端列表(去重)
+ // accountGroups: map[accountID][]groupID
+ GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error)
}
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
@@ -1411,7 +1414,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
}
if affinityClientID != "" && s.cache != nil && item.account.IsClientAffinityEnabled() {
- _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, item.account.ID, clientAffinityTTL)
+ _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, item.account.ID, ClientAffinityTTL)
}
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
@@ -1511,7 +1514,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
// ============ Layer 1.6: 客户端亲和(仅在粘性会话未命中时生效) ============
if affinityClientID != "" && s.cache != nil && stickyAccountID <= 0 {
- affinityAccountIDs, err := s.cache.GetClientAffinityAccounts(ctx, derefGroupID(groupID), affinityClientID, clientAffinityTTL)
+ affinityAccountIDs, err := s.cache.GetClientAffinityAccounts(ctx, derefGroupID(groupID), affinityClientID, ClientAffinityTTL)
if err == nil && len(affinityAccountIDs) > 0 {
for _, affinityAccID := range affinityAccountIDs {
if isExcluded(affinityAccID) {
@@ -1550,7 +1553,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
continue
}
// 亲和命中:更新亲和 score + 绑定粘性会话
- _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, affinityAccID, clientAffinityTTL)
+ _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, affinityAccID, ClientAffinityTTL)
if sessionHash != "" {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, affinityAccID, stickySessionTTL)
}
@@ -1622,7 +1625,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if err != nil {
if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok {
if affinityClientID != "" && s.cache != nil && result.Account != nil && result.Account.IsClientAffinityEnabled() {
- _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, result.Account.ID, clientAffinityTTL)
+ _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, result.Account.ID, ClientAffinityTTL)
}
return result, nil
}
@@ -1669,7 +1672,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
// 更新客户端亲和关系
if affinityClientID != "" && s.cache != nil && selected.account.IsClientAffinityEnabled() {
- _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, selected.account.ID, clientAffinityTTL)
+ _ = s.cache.UpdateClientAffinity(ctx, derefGroupID(groupID), affinityClientID, selected.account.ID, ClientAffinityTTL)
}
return &AccountSelectionResult{
Account: selected.account,
@@ -2449,7 +2452,7 @@ func (s *GatewayService) populateAffinityCounts(ctx context.Context, accounts []
for i, acc := range accounts {
accountIDs[i] = acc.account.ID
}
- countMap, err := s.cache.GetAccountAffinityCountBatch(ctx, groupID, accountIDs, clientAffinityTTL)
+ countMap, err := s.cache.GetAccountAffinityCountBatch(ctx, groupID, accountIDs, ClientAffinityTTL)
if err != nil {
return // 查询失败不影响调度,affinityCount 保持 0
}
diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go
index 7071256490..3ba5aa9f52 100644
--- a/backend/internal/service/gemini_multiplatform_test.go
+++ b/backend/internal/service/gemini_multiplatform_test.go
@@ -297,6 +297,9 @@ func (m *mockGatewayCacheForGemini) UpdateClientAffinity(_ context.Context, _ in
func (m *mockGatewayCacheForGemini) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
+func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
+ return map[int64][]string{}, 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 fc47f8e356..43ff234151 100644
--- a/backend/internal/service/openai_gateway_service_test.go
+++ b/backend/internal/service/openai_gateway_service_test.go
@@ -274,6 +274,9 @@ func (c *stubGatewayCache) UpdateClientAffinity(_ context.Context, _ int64, _ st
func (c *stubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
+func (c *stubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
+ return map[int64][]string{}, 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 00d6295db6..c85c9aae48 100644
--- a/backend/internal/service/openai_ws_state_store_test.go
+++ b/backend/internal/service/openai_ws_state_store_test.go
@@ -202,6 +202,9 @@ func (c *openAIWSStateStoreTimeoutProbeCache) UpdateClientAffinity(_ context.Con
func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
+func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
+ return map[int64][]string{}, nil
+}
func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) {
probe := &openAIWSStateStoreTimeoutProbeCache{}
diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go
index c6b4c0f2f0..44242c1aaa 100644
--- a/backend/internal/testutil/stubs.go
+++ b/backend/internal/testutil/stubs.go
@@ -106,6 +106,9 @@ func (c StubGatewayCache) UpdateClientAffinity(_ context.Context, _ int64, _ str
func (c StubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
+func (c StubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
+ return map[int64][]string{}, nil
+}
// ============================================================
// StubSessionLimitCache — service.SessionLimitCache 的空实现
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index d72597d64e..8d2b0476a2 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -2017,6 +2017,8 @@ export default {
hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching'
}
},
+ affinityNoClients: 'No affinity clients',
+ affinityClients: '{count} affinity clients:',
expired: 'Expired',
proxy: 'Proxy',
noProxy: 'No Proxy',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 3cf3190c6f..10f0e1651e 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -2151,6 +2151,8 @@ export default {
hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号'
}
},
+ affinityNoClients: '无亲和客户端',
+ affinityClients: '{count} 个亲和客户端:',
expired: '已过期',
proxy: '代理',
noProxy: '无代理',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index a9704afaf6..8312b7bc95 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -713,6 +713,8 @@ export interface Account {
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
// 启用后新会话会优先调度到客户端之前使用过的账号
client_affinity_enabled?: boolean | null
+ affinity_client_count?: number | null
+ affinity_clients?: string[] | null
// API Key 账号配额限制
quota_limit?: number | null
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index 0173ea0a86..a93fc5d665 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -176,7 +176,15 @@
-
+
+
+ {{ row.affinity_client_count }}
+
+
+