mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat: display client affinity count badge in account status column
Add colored circle badge showing affinity client count before the account status indicator. Badge color reflects count severity (gray=0, green=1-5, yellow=6-15, red=16+). Tooltip shows full client ID list on hover. Backend: AccountHandler batch queries reverse affinity index via GetAccountAffinityClientsBatch, returns affinity_client_count and affinity_clients in DTO. GatewayCache interface extended with the new batch method backed by a Lua script (get_affinity_clients.lua). All test mocks synchronized with updated GatewayCache interface. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -65,6 +65,7 @@ func setupAccountDataRouter() (*gin.Engine, *stubAdminService) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
router.GET("/api/v1/admin/accounts/data", h.ExportData)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestAccountHandler_Create_AnthropicAPIKeyPassthroughExtraForwarded(t *testi
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
router := gin.New()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 账号
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 的空实现
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2151,6 +2151,8 @@ export default {
|
||||
hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号'
|
||||
}
|
||||
},
|
||||
affinityNoClients: '无亲和客户端',
|
||||
affinityClients: '{count} 个亲和客户端:',
|
||||
expired: '已过期',
|
||||
proxy: '代理',
|
||||
noProxy: '无代理',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -176,7 +176,15 @@
|
||||
<AccountCapacityCell :account="row" />
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span v-if="row.client_affinity_enabled && row.affinity_client_count != null"
|
||||
:class="affinityBadgeClass(row.affinity_client_count)"
|
||||
:title="affinityTooltip(row)"
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold shrink-0">
|
||||
{{ row.affinity_client_count }}
|
||||
</span>
|
||||
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-schedulable="{ row }">
|
||||
<button @click="handleToggleSchedulable(row)" :disabled="togglingSchedulable === row.id" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800" :class="[row.schedulable ? 'bg-primary-500 hover:bg-primary-600' : 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500']" :title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')">
|
||||
@@ -398,6 +406,19 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user