mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 10:36:31 +00:00
merge: resolve conflicts with main
This commit is contained in:
@@ -118,7 +118,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
||||
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
||||
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
@@ -140,7 +141,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
identityService := service.NewIdentityService(identityCache)
|
||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||
claudeTokenProvider := service.NewClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache)
|
||||
openAITokenProvider := service.NewOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||
|
||||
@@ -234,6 +234,10 @@ type GatewayConfig struct {
|
||||
// ConcurrencySlotTTLMinutes: 并发槽位过期时间(分钟)
|
||||
// 应大于最长 LLM 请求时间,防止请求完成前槽位过期
|
||||
ConcurrencySlotTTLMinutes int `mapstructure:"concurrency_slot_ttl_minutes"`
|
||||
// SessionIdleTimeoutMinutes: 会话空闲超时时间(分钟),默认 5 分钟
|
||||
// 用于 Anthropic OAuth/SetupToken 账号的会话数量限制功能
|
||||
// 空闲超过此时间的会话将被自动释放
|
||||
SessionIdleTimeoutMinutes int `mapstructure:"session_idle_timeout_minutes"`
|
||||
|
||||
// StreamDataIntervalTimeout: 流数据间隔超时(秒),0表示禁用
|
||||
StreamDataIntervalTimeout int `mapstructure:"stream_data_interval_timeout"`
|
||||
|
||||
@@ -44,6 +44,7 @@ type AccountHandler struct {
|
||||
accountTestService *service.AccountTestService
|
||||
concurrencyService *service.ConcurrencyService
|
||||
crsSyncService *service.CRSSyncService
|
||||
sessionLimitCache service.SessionLimitCache
|
||||
}
|
||||
|
||||
// NewAccountHandler creates a new admin account handler
|
||||
@@ -58,6 +59,7 @@ func NewAccountHandler(
|
||||
accountTestService *service.AccountTestService,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
crsSyncService *service.CRSSyncService,
|
||||
sessionLimitCache service.SessionLimitCache,
|
||||
) *AccountHandler {
|
||||
return &AccountHandler{
|
||||
adminService: adminService,
|
||||
@@ -70,6 +72,7 @@ func NewAccountHandler(
|
||||
accountTestService: accountTestService,
|
||||
concurrencyService: concurrencyService,
|
||||
crsSyncService: crsSyncService,
|
||||
sessionLimitCache: sessionLimitCache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +133,9 @@ type BulkUpdateAccountsRequest struct {
|
||||
type AccountWithConcurrency struct {
|
||||
*dto.Account
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
// 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回
|
||||
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` // 当前窗口费用
|
||||
ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
|
||||
}
|
||||
|
||||
// List handles listing all accounts with pagination
|
||||
@@ -164,13 +170,89 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
concurrencyCounts = make(map[int64]int)
|
||||
}
|
||||
|
||||
// 识别需要查询窗口费用和会话数的账号(Anthropic OAuth/SetupToken 且启用了相应功能)
|
||||
windowCostAccountIDs := make([]int64, 0)
|
||||
sessionLimitAccountIDs := make([]int64, 0)
|
||||
for i := range accounts {
|
||||
acc := &accounts[i]
|
||||
if acc.IsAnthropicOAuthOrSetupToken() {
|
||||
if acc.GetWindowCostLimit() > 0 {
|
||||
windowCostAccountIDs = append(windowCostAccountIDs, acc.ID)
|
||||
}
|
||||
if acc.GetMaxSessions() > 0 {
|
||||
sessionLimitAccountIDs = append(sessionLimitAccountIDs, acc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 并行获取窗口费用和活跃会话数
|
||||
var windowCosts map[int64]float64
|
||||
var activeSessions map[int64]int
|
||||
|
||||
// 获取活跃会话数(批量查询)
|
||||
if len(sessionLimitAccountIDs) > 0 && h.sessionLimitCache != nil {
|
||||
activeSessions, _ = h.sessionLimitCache.GetActiveSessionCountBatch(c.Request.Context(), sessionLimitAccountIDs)
|
||||
if activeSessions == nil {
|
||||
activeSessions = make(map[int64]int)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取窗口费用(并行查询)
|
||||
if len(windowCostAccountIDs) > 0 {
|
||||
windowCosts = make(map[int64]float64)
|
||||
var mu sync.Mutex
|
||||
g, gctx := errgroup.WithContext(c.Request.Context())
|
||||
g.SetLimit(10) // 限制并发数
|
||||
|
||||
for i := range accounts {
|
||||
acc := &accounts[i]
|
||||
if !acc.IsAnthropicOAuthOrSetupToken() || acc.GetWindowCostLimit() <= 0 {
|
||||
continue
|
||||
}
|
||||
accCopy := acc // 闭包捕获
|
||||
g.Go(func() error {
|
||||
var startTime time.Time
|
||||
if accCopy.SessionWindowStart != nil {
|
||||
startTime = *accCopy.SessionWindowStart
|
||||
} else {
|
||||
startTime = time.Now().Add(-5 * time.Hour)
|
||||
}
|
||||
stats, err := h.accountUsageService.GetAccountWindowStats(gctx, accCopy.ID, startTime)
|
||||
if err == nil && stats != nil {
|
||||
mu.Lock()
|
||||
windowCosts[accCopy.ID] = stats.StandardCost // 使用标准费用
|
||||
mu.Unlock()
|
||||
}
|
||||
return nil // 不返回错误,允许部分失败
|
||||
})
|
||||
}
|
||||
_ = g.Wait()
|
||||
}
|
||||
|
||||
// Build response with concurrency info
|
||||
result := make([]AccountWithConcurrency, len(accounts))
|
||||
for i := range accounts {
|
||||
result[i] = AccountWithConcurrency{
|
||||
Account: dto.AccountFromService(&accounts[i]),
|
||||
CurrentConcurrency: concurrencyCounts[accounts[i].ID],
|
||||
acc := &accounts[i]
|
||||
item := AccountWithConcurrency{
|
||||
Account: dto.AccountFromService(acc),
|
||||
CurrentConcurrency: concurrencyCounts[acc.ID],
|
||||
}
|
||||
|
||||
// 添加窗口费用(仅当启用时)
|
||||
if windowCosts != nil {
|
||||
if cost, ok := windowCosts[acc.ID]; ok {
|
||||
item.CurrentWindowCost = &cost
|
||||
}
|
||||
}
|
||||
|
||||
// 添加活跃会话数(仅当启用时)
|
||||
if activeSessions != nil {
|
||||
if count, ok := activeSessions[acc.ID]; ok {
|
||||
item.ActiveSessions = &count
|
||||
}
|
||||
}
|
||||
|
||||
result[i] = item
|
||||
}
|
||||
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
|
||||
@@ -116,7 +116,7 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return &Account{
|
||||
out := &Account{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Notes: a.Notes,
|
||||
@@ -146,6 +146,24 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
SessionWindowStatus: a.SessionWindowStatus,
|
||||
GroupIDs: a.GroupIDs,
|
||||
}
|
||||
|
||||
// 提取 5h 窗口费用控制和会话数量控制配置(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
if a.IsAnthropicOAuthOrSetupToken() {
|
||||
if limit := a.GetWindowCostLimit(); limit > 0 {
|
||||
out.WindowCostLimit = &limit
|
||||
}
|
||||
if reserve := a.GetWindowCostStickyReserve(); reserve > 0 {
|
||||
out.WindowCostStickyReserve = &reserve
|
||||
}
|
||||
if maxSessions := a.GetMaxSessions(); maxSessions > 0 {
|
||||
out.MaxSessions = &maxSessions
|
||||
}
|
||||
if idleTimeout := a.GetSessionIdleTimeoutMinutes(); idleTimeout > 0 {
|
||||
out.SessionIdleTimeoutMin = &idleTimeout
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func AccountFromService(a *service.Account) *Account {
|
||||
|
||||
@@ -102,6 +102,16 @@ type Account struct {
|
||||
SessionWindowEnd *time.Time `json:"session_window_end"`
|
||||
SessionWindowStatus string `json:"session_window_status"`
|
||||
|
||||
// 5h窗口费用控制(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 从 extra 字段提取,方便前端显示和编辑
|
||||
WindowCostLimit *float64 `json:"window_cost_limit,omitempty"`
|
||||
WindowCostStickyReserve *float64 `json:"window_cost_sticky_reserve,omitempty"`
|
||||
|
||||
// 会话数量控制(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 从 extra 字段提取,方便前端显示和编辑
|
||||
MaxSessions *int `json:"max_sessions,omitempty"`
|
||||
SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"`
|
||||
|
||||
Proxy *Proxy `json:"proxy,omitempty"`
|
||||
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
lastFailoverStatus := 0
|
||||
|
||||
for {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, failedAccountIDs)
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, failedAccountIDs, "") // Gemini 不使用会话限制
|
||||
if err != nil {
|
||||
if len(failedAccountIDs) == 0 {
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||
@@ -320,7 +320,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
|
||||
for {
|
||||
// 选择支持该模型的账号
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, failedAccountIDs)
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, failedAccountIDs, parsedReq.MetadataUserID)
|
||||
if err != nil {
|
||||
if len(failedAccountIDs) == 0 {
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||
|
||||
@@ -226,7 +226,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
lastFailoverStatus := 0
|
||||
|
||||
for {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, failedAccountIDs)
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, failedAccountIDs, "") // Gemini 不使用会话限制
|
||||
if err != nil {
|
||||
if len(failedAccountIDs) == 0 {
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
||||
|
||||
321
backend/internal/repository/session_limit_cache.go
Normal file
321
backend/internal/repository/session_limit_cache.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// 会话限制缓存常量定义
|
||||
//
|
||||
// 设计说明:
|
||||
// 使用 Redis 有序集合(Sorted Set)跟踪每个账号的活跃会话:
|
||||
// - Key: session_limit:account:{accountID}
|
||||
// - Member: sessionUUID(从 metadata.user_id 中提取)
|
||||
// - Score: Unix 时间戳(会话最后活跃时间)
|
||||
//
|
||||
// 通过 ZREMRANGEBYSCORE 自动清理过期会话,无需手动管理 TTL
|
||||
const (
|
||||
// 会话限制键前缀
|
||||
// 格式: session_limit:account:{accountID}
|
||||
sessionLimitKeyPrefix = "session_limit:account:"
|
||||
|
||||
// 窗口费用缓存键前缀
|
||||
// 格式: window_cost:account:{accountID}
|
||||
windowCostKeyPrefix = "window_cost:account:"
|
||||
|
||||
// 窗口费用缓存 TTL(30秒)
|
||||
windowCostCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
// registerSessionScript 注册会话活动
|
||||
// 使用 Redis TIME 命令获取服务器时间,避免多实例时钟不同步
|
||||
// KEYS[1] = session_limit:account:{accountID}
|
||||
// ARGV[1] = maxSessions
|
||||
// ARGV[2] = idleTimeout(秒)
|
||||
// ARGV[3] = sessionUUID
|
||||
// 返回: 1 = 允许, 0 = 拒绝
|
||||
registerSessionScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local maxSessions = tonumber(ARGV[1])
|
||||
local idleTimeout = tonumber(ARGV[2])
|
||||
local sessionUUID = ARGV[3]
|
||||
|
||||
-- 使用 Redis 服务器时间,确保多实例时钟一致
|
||||
local timeResult = redis.call('TIME')
|
||||
local now = tonumber(timeResult[1])
|
||||
local expireBefore = now - idleTimeout
|
||||
|
||||
-- 清理过期会话
|
||||
redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore)
|
||||
|
||||
-- 检查会话是否已存在(支持刷新时间戳)
|
||||
local exists = redis.call('ZSCORE', key, sessionUUID)
|
||||
if exists ~= false then
|
||||
-- 会话已存在,刷新时间戳
|
||||
redis.call('ZADD', key, now, sessionUUID)
|
||||
redis.call('EXPIRE', key, idleTimeout + 60)
|
||||
return 1
|
||||
end
|
||||
|
||||
-- 检查是否达到会话数量上限
|
||||
local count = redis.call('ZCARD', key)
|
||||
if count < maxSessions then
|
||||
-- 未达上限,添加新会话
|
||||
redis.call('ZADD', key, now, sessionUUID)
|
||||
redis.call('EXPIRE', key, idleTimeout + 60)
|
||||
return 1
|
||||
end
|
||||
|
||||
-- 达到上限,拒绝新会话
|
||||
return 0
|
||||
`)
|
||||
|
||||
// refreshSessionScript 刷新会话时间戳
|
||||
// KEYS[1] = session_limit:account:{accountID}
|
||||
// ARGV[1] = idleTimeout(秒)
|
||||
// ARGV[2] = sessionUUID
|
||||
refreshSessionScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local idleTimeout = tonumber(ARGV[1])
|
||||
local sessionUUID = ARGV[2]
|
||||
|
||||
local timeResult = redis.call('TIME')
|
||||
local now = tonumber(timeResult[1])
|
||||
|
||||
-- 检查会话是否存在
|
||||
local exists = redis.call('ZSCORE', key, sessionUUID)
|
||||
if exists ~= false then
|
||||
redis.call('ZADD', key, now, sessionUUID)
|
||||
redis.call('EXPIRE', key, idleTimeout + 60)
|
||||
end
|
||||
return 1
|
||||
`)
|
||||
|
||||
// getActiveSessionCountScript 获取活跃会话数
|
||||
// KEYS[1] = session_limit:account:{accountID}
|
||||
// ARGV[1] = idleTimeout(秒)
|
||||
getActiveSessionCountScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local idleTimeout = tonumber(ARGV[1])
|
||||
|
||||
local timeResult = redis.call('TIME')
|
||||
local now = tonumber(timeResult[1])
|
||||
local expireBefore = now - idleTimeout
|
||||
|
||||
-- 清理过期会话
|
||||
redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore)
|
||||
|
||||
return redis.call('ZCARD', key)
|
||||
`)
|
||||
|
||||
// isSessionActiveScript 检查会话是否活跃
|
||||
// KEYS[1] = session_limit:account:{accountID}
|
||||
// ARGV[1] = idleTimeout(秒)
|
||||
// ARGV[2] = sessionUUID
|
||||
isSessionActiveScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local idleTimeout = tonumber(ARGV[1])
|
||||
local sessionUUID = ARGV[2]
|
||||
|
||||
local timeResult = redis.call('TIME')
|
||||
local now = tonumber(timeResult[1])
|
||||
local expireBefore = now - idleTimeout
|
||||
|
||||
-- 获取会话的时间戳
|
||||
local score = redis.call('ZSCORE', key, sessionUUID)
|
||||
if score == false then
|
||||
return 0
|
||||
end
|
||||
|
||||
-- 检查是否过期
|
||||
if tonumber(score) <= expireBefore then
|
||||
return 0
|
||||
end
|
||||
|
||||
return 1
|
||||
`)
|
||||
)
|
||||
|
||||
type sessionLimitCache struct {
|
||||
rdb *redis.Client
|
||||
defaultIdleTimeout time.Duration // 默认空闲超时(用于 GetActiveSessionCount)
|
||||
}
|
||||
|
||||
// NewSessionLimitCache 创建会话限制缓存
|
||||
// defaultIdleTimeoutMinutes: 默认空闲超时时间(分钟),用于无参数查询
|
||||
func NewSessionLimitCache(rdb *redis.Client, defaultIdleTimeoutMinutes int) service.SessionLimitCache {
|
||||
if defaultIdleTimeoutMinutes <= 0 {
|
||||
defaultIdleTimeoutMinutes = 5 // 默认 5 分钟
|
||||
}
|
||||
return &sessionLimitCache{
|
||||
rdb: rdb,
|
||||
defaultIdleTimeout: time.Duration(defaultIdleTimeoutMinutes) * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionLimitKey 生成会话限制的 Redis 键
|
||||
func sessionLimitKey(accountID int64) string {
|
||||
return fmt.Sprintf("%s%d", sessionLimitKeyPrefix, accountID)
|
||||
}
|
||||
|
||||
// windowCostKey 生成窗口费用缓存的 Redis 键
|
||||
func windowCostKey(accountID int64) string {
|
||||
return fmt.Sprintf("%s%d", windowCostKeyPrefix, accountID)
|
||||
}
|
||||
|
||||
// RegisterSession 注册会话活动
|
||||
func (c *sessionLimitCache) RegisterSession(ctx context.Context, accountID int64, sessionUUID string, maxSessions int, idleTimeout time.Duration) (bool, error) {
|
||||
if sessionUUID == "" || maxSessions <= 0 {
|
||||
return true, nil // 无效参数,默认允许
|
||||
}
|
||||
|
||||
key := sessionLimitKey(accountID)
|
||||
idleTimeoutSeconds := int(idleTimeout.Seconds())
|
||||
if idleTimeoutSeconds <= 0 {
|
||||
idleTimeoutSeconds = int(c.defaultIdleTimeout.Seconds())
|
||||
}
|
||||
|
||||
result, err := registerSessionScript.Run(ctx, c.rdb, []string{key}, maxSessions, idleTimeoutSeconds, sessionUUID).Int()
|
||||
if err != nil {
|
||||
return true, err // 失败开放:缓存错误时允许请求通过
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
|
||||
// RefreshSession 刷新会话时间戳
|
||||
func (c *sessionLimitCache) RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error {
|
||||
if sessionUUID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := sessionLimitKey(accountID)
|
||||
idleTimeoutSeconds := int(idleTimeout.Seconds())
|
||||
if idleTimeoutSeconds <= 0 {
|
||||
idleTimeoutSeconds = int(c.defaultIdleTimeout.Seconds())
|
||||
}
|
||||
|
||||
_, err := refreshSessionScript.Run(ctx, c.rdb, []string{key}, idleTimeoutSeconds, sessionUUID).Result()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetActiveSessionCount 获取活跃会话数
|
||||
func (c *sessionLimitCache) GetActiveSessionCount(ctx context.Context, accountID int64) (int, error) {
|
||||
key := sessionLimitKey(accountID)
|
||||
idleTimeoutSeconds := int(c.defaultIdleTimeout.Seconds())
|
||||
|
||||
result, err := getActiveSessionCountScript.Run(ctx, c.rdb, []string{key}, idleTimeoutSeconds).Int()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetActiveSessionCountBatch 批量获取多个账号的活跃会话数
|
||||
func (c *sessionLimitCache) GetActiveSessionCountBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||
if len(accountIDs) == 0 {
|
||||
return make(map[int64]int), nil
|
||||
}
|
||||
|
||||
results := make(map[int64]int, len(accountIDs))
|
||||
|
||||
// 使用 pipeline 批量执行
|
||||
pipe := c.rdb.Pipeline()
|
||||
idleTimeoutSeconds := int(c.defaultIdleTimeout.Seconds())
|
||||
|
||||
cmds := make(map[int64]*redis.Cmd, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
key := sessionLimitKey(accountID)
|
||||
cmds[accountID] = getActiveSessionCountScript.Run(ctx, pipe, []string{key}, idleTimeoutSeconds)
|
||||
}
|
||||
|
||||
// 执行 pipeline,即使部分失败也尝试获取成功的结果
|
||||
_, _ = pipe.Exec(ctx)
|
||||
|
||||
for accountID, cmd := range cmds {
|
||||
if result, err := cmd.Int(); err == nil {
|
||||
results[accountID] = result
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// IsSessionActive 检查会话是否活跃
|
||||
func (c *sessionLimitCache) IsSessionActive(ctx context.Context, accountID int64, sessionUUID string) (bool, error) {
|
||||
if sessionUUID == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
key := sessionLimitKey(accountID)
|
||||
idleTimeoutSeconds := int(c.defaultIdleTimeout.Seconds())
|
||||
|
||||
result, err := isSessionActiveScript.Run(ctx, c.rdb, []string{key}, idleTimeoutSeconds, sessionUUID).Int()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
|
||||
// ========== 5h窗口费用缓存实现 ==========
|
||||
|
||||
// GetWindowCost 获取缓存的窗口费用
|
||||
func (c *sessionLimitCache) GetWindowCost(ctx context.Context, accountID int64) (float64, bool, error) {
|
||||
key := windowCostKey(accountID)
|
||||
val, err := c.rdb.Get(ctx, key).Float64()
|
||||
if err == redis.Nil {
|
||||
return 0, false, nil // 缓存未命中
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return val, true, nil
|
||||
}
|
||||
|
||||
// SetWindowCost 设置窗口费用缓存
|
||||
func (c *sessionLimitCache) SetWindowCost(ctx context.Context, accountID int64, cost float64) error {
|
||||
key := windowCostKey(accountID)
|
||||
return c.rdb.Set(ctx, key, cost, windowCostCacheTTL).Err()
|
||||
}
|
||||
|
||||
// GetWindowCostBatch 批量获取窗口费用缓存
|
||||
func (c *sessionLimitCache) GetWindowCostBatch(ctx context.Context, accountIDs []int64) (map[int64]float64, error) {
|
||||
if len(accountIDs) == 0 {
|
||||
return make(map[int64]float64), nil
|
||||
}
|
||||
|
||||
// 构建批量查询的 keys
|
||||
keys := make([]string, len(accountIDs))
|
||||
for i, accountID := range accountIDs {
|
||||
keys[i] = windowCostKey(accountID)
|
||||
}
|
||||
|
||||
// 使用 MGET 批量获取
|
||||
vals, err := c.rdb.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make(map[int64]float64, len(accountIDs))
|
||||
for i, val := range vals {
|
||||
if val == nil {
|
||||
continue // 缓存未命中
|
||||
}
|
||||
// 尝试解析为 float64
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
if cost, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
results[accountIDs[i]] = cost
|
||||
}
|
||||
case float64:
|
||||
results[accountIDs[i]] = v
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -37,6 +37,16 @@ func ProvidePricingRemoteClient(cfg *config.Config) service.PricingRemoteClient
|
||||
return NewPricingRemoteClient(cfg.Update.ProxyURL)
|
||||
}
|
||||
|
||||
// ProvideSessionLimitCache 创建会话限制缓存
|
||||
// 用于 Anthropic OAuth/SetupToken 账号的并发会话数量控制
|
||||
func ProvideSessionLimitCache(rdb *redis.Client, cfg *config.Config) service.SessionLimitCache {
|
||||
defaultIdleTimeoutMinutes := 5 // 默认 5 分钟空闲超时
|
||||
if cfg != nil && cfg.Gateway.SessionIdleTimeoutMinutes > 0 {
|
||||
defaultIdleTimeoutMinutes = cfg.Gateway.SessionIdleTimeoutMinutes
|
||||
}
|
||||
return NewSessionLimitCache(rdb, defaultIdleTimeoutMinutes)
|
||||
}
|
||||
|
||||
// ProviderSet is the Wire provider set for all repositories
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewUserRepository,
|
||||
@@ -61,6 +71,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewTempUnschedCache,
|
||||
NewTimeoutCounterCache,
|
||||
ProvideConcurrencyCache,
|
||||
ProvideSessionLimitCache,
|
||||
NewDashboardCache,
|
||||
NewEmailCache,
|
||||
NewIdentityCache,
|
||||
|
||||
@@ -441,7 +441,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil)
|
||||
adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
jwtAuth := func(c *gin.Context) {
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{
|
||||
|
||||
@@ -573,3 +573,141 @@ func (a *Account) IsMixedSchedulingEnabled() bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WindowCostSchedulability 窗口费用调度状态
|
||||
type WindowCostSchedulability int
|
||||
|
||||
const (
|
||||
// WindowCostSchedulable 可正常调度
|
||||
WindowCostSchedulable WindowCostSchedulability = iota
|
||||
// WindowCostStickyOnly 仅允许粘性会话
|
||||
WindowCostStickyOnly
|
||||
// WindowCostNotSchedulable 完全不可调度
|
||||
WindowCostNotSchedulable
|
||||
)
|
||||
|
||||
// IsAnthropicOAuthOrSetupToken 判断是否为 Anthropic OAuth 或 SetupToken 类型账号
|
||||
// 仅这两类账号支持 5h 窗口额度控制和会话数量控制
|
||||
func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
|
||||
return a.Platform == PlatformAnthropic && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
|
||||
}
|
||||
|
||||
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
|
||||
// 返回 0 表示未启用
|
||||
func (a *Account) GetWindowCostLimit() float64 {
|
||||
if a.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := a.Extra["window_cost_limit"]; ok {
|
||||
return parseExtraFloat64(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetWindowCostStickyReserve 获取粘性会话预留额度(美元)
|
||||
// 默认值为 10
|
||||
func (a *Account) GetWindowCostStickyReserve() float64 {
|
||||
if a.Extra == nil {
|
||||
return 10.0
|
||||
}
|
||||
if v, ok := a.Extra["window_cost_sticky_reserve"]; ok {
|
||||
val := parseExtraFloat64(v)
|
||||
if val > 0 {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return 10.0
|
||||
}
|
||||
|
||||
// GetMaxSessions 获取最大并发会话数
|
||||
// 返回 0 表示未启用
|
||||
func (a *Account) GetMaxSessions() int {
|
||||
if a.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := a.Extra["max_sessions"]; ok {
|
||||
return parseExtraInt(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetSessionIdleTimeoutMinutes 获取会话空闲超时分钟数
|
||||
// 默认值为 5 分钟
|
||||
func (a *Account) GetSessionIdleTimeoutMinutes() int {
|
||||
if a.Extra == nil {
|
||||
return 5
|
||||
}
|
||||
if v, ok := a.Extra["session_idle_timeout_minutes"]; ok {
|
||||
val := parseExtraInt(v)
|
||||
if val > 0 {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
// CheckWindowCostSchedulability 根据当前窗口费用检查调度状态
|
||||
// - 费用 < 阈值: WindowCostSchedulable(可正常调度)
|
||||
// - 费用 >= 阈值 且 < 阈值+预留: WindowCostStickyOnly(仅粘性会话)
|
||||
// - 费用 >= 阈值+预留: WindowCostNotSchedulable(不可调度)
|
||||
func (a *Account) CheckWindowCostSchedulability(currentWindowCost float64) WindowCostSchedulability {
|
||||
limit := a.GetWindowCostLimit()
|
||||
if limit <= 0 {
|
||||
return WindowCostSchedulable
|
||||
}
|
||||
|
||||
if currentWindowCost < limit {
|
||||
return WindowCostSchedulable
|
||||
}
|
||||
|
||||
stickyReserve := a.GetWindowCostStickyReserve()
|
||||
if currentWindowCost < limit+stickyReserve {
|
||||
return WindowCostStickyOnly
|
||||
}
|
||||
|
||||
return WindowCostNotSchedulable
|
||||
}
|
||||
|
||||
// parseExtraFloat64 从 extra 字段解析 float64 值
|
||||
func parseExtraFloat64(value any) float64 {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v
|
||||
case float32:
|
||||
return float64(v)
|
||||
case int:
|
||||
return float64(v)
|
||||
case int64:
|
||||
return float64(v)
|
||||
case json.Number:
|
||||
if f, err := v.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
case string:
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseExtraInt 从 extra 字段解析 int 值
|
||||
func parseExtraInt(value any) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case json.Number:
|
||||
if i, err := v.Int64(); err == nil {
|
||||
return int(i)
|
||||
}
|
||||
case string:
|
||||
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -575,3 +575,9 @@ func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccountWindowStats 获取账号在指定时间窗口内的使用统计
|
||||
// 用于账号列表页面显示当前窗口费用
|
||||
func (s *AccountUsageService) GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error) {
|
||||
return s.usageLogRepo.GetAccountWindowStats(ctx, accountID, startTime)
|
||||
}
|
||||
|
||||
@@ -1052,7 +1052,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
concurrencyService: nil, // No concurrency service
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
@@ -1105,7 +1105,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
concurrencyService: nil, // legacy path
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-b", nil)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, sessionHash, "claude-b", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
@@ -1137,7 +1137,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
concurrencyService: nil,
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
@@ -1169,7 +1169,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
}
|
||||
|
||||
excludedIDs := map[int64]struct{}{1: {}}
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", excludedIDs)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", excludedIDs, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
@@ -1203,7 +1203,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
@@ -1239,7 +1239,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
@@ -1266,7 +1266,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
concurrencyService: nil,
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "")
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Contains(t, err.Error(), "no available accounts")
|
||||
@@ -1298,7 +1298,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
concurrencyService: nil,
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
@@ -1331,7 +1331,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
concurrencyService: nil,
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil)
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
|
||||
@@ -213,6 +213,7 @@ type GatewayService struct {
|
||||
deferredService *DeferredService
|
||||
concurrencyService *ConcurrencyService
|
||||
claudeTokenProvider *ClaudeTokenProvider
|
||||
sessionLimitCache SessionLimitCache // 会话数量限制缓存(仅 Anthropic OAuth/SetupToken)
|
||||
}
|
||||
|
||||
// NewGatewayService creates a new GatewayService
|
||||
@@ -233,6 +234,7 @@ func NewGatewayService(
|
||||
httpUpstream HTTPUpstream,
|
||||
deferredService *DeferredService,
|
||||
claudeTokenProvider *ClaudeTokenProvider,
|
||||
sessionLimitCache SessionLimitCache,
|
||||
) *GatewayService {
|
||||
return &GatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@@ -251,6 +253,7 @@ func NewGatewayService(
|
||||
httpUpstream: httpUpstream,
|
||||
deferredService: deferredService,
|
||||
claudeTokenProvider: claudeTokenProvider,
|
||||
sessionLimitCache: sessionLimitCache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,8 +819,12 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
|
||||
}
|
||||
|
||||
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
|
||||
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) {
|
||||
// metadataUserID: 原始 metadata.user_id 字段(用于提取会话 UUID 进行会话数量限制)
|
||||
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string) (*AccountSelectionResult, error) {
|
||||
cfg := s.schedulingConfig()
|
||||
// 提取会话 UUID(用于会话数量限制)
|
||||
sessionUUID := extractSessionUUID(metadataUserID)
|
||||
|
||||
var stickyAccountID int64
|
||||
if sessionHash != "" && s.cache != nil {
|
||||
if accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash); err == nil {
|
||||
@@ -936,7 +943,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
if len(routingAccountIDs) > 0 && s.concurrencyService != nil {
|
||||
// 1. 过滤出路由列表中可调度的账号
|
||||
var routingCandidates []*Account
|
||||
var filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping int
|
||||
var filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredWindowCost int
|
||||
for _, routingAccountID := range routingAccountIDs {
|
||||
if isExcluded(routingAccountID) {
|
||||
filteredExcluded++
|
||||
@@ -963,13 +970,18 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
filteredModelMapping++
|
||||
continue
|
||||
}
|
||||
// 窗口费用检查(非粘性会话路径)
|
||||
if !s.isAccountSchedulableForWindowCost(ctx, account, false) {
|
||||
filteredWindowCost++
|
||||
continue
|
||||
}
|
||||
routingCandidates = append(routingCandidates, account)
|
||||
}
|
||||
|
||||
if s.debugModelRoutingEnabled() {
|
||||
log.Printf("[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d)",
|
||||
log.Printf("[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d window_cost=%d)",
|
||||
derefGroupID(groupID), requestedModel, len(routingAccountIDs), len(routingCandidates),
|
||||
filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping)
|
||||
filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredWindowCost)
|
||||
}
|
||||
|
||||
if len(routingCandidates) > 0 {
|
||||
@@ -982,18 +994,25 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
if stickyAccount.IsSchedulable() &&
|
||||
s.isAccountAllowedForPlatform(stickyAccount, platform, useMixed) &&
|
||||
stickyAccount.IsSchedulableForModel(requestedModel) &&
|
||||
(requestedModel == "" || s.isModelSupportedByAccount(stickyAccount, requestedModel)) {
|
||||
(requestedModel == "" || s.isModelSupportedByAccount(stickyAccount, requestedModel)) &&
|
||||
s.isAccountSchedulableForWindowCost(ctx, stickyAccount, true) { // 粘性会话窗口费用检查
|
||||
result, err := s.tryAcquireAccountSlot(ctx, stickyAccountID, stickyAccount.Concurrency)
|
||||
if err == nil && result.Acquired {
|
||||
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
||||
if s.debugModelRoutingEnabled() {
|
||||
log.Printf("[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), stickyAccountID)
|
||||
// 会话数量限制检查
|
||||
if !s.checkAndRegisterSession(ctx, stickyAccount, sessionUUID) {
|
||||
result.ReleaseFunc() // 释放槽位
|
||||
// 继续到负载感知选择
|
||||
} else {
|
||||
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
||||
if s.debugModelRoutingEnabled() {
|
||||
log.Printf("[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), stickyAccountID)
|
||||
}
|
||||
return &AccountSelectionResult{
|
||||
Account: stickyAccount,
|
||||
Acquired: true,
|
||||
ReleaseFunc: result.ReleaseFunc,
|
||||
}, nil
|
||||
}
|
||||
return &AccountSelectionResult{
|
||||
Account: stickyAccount,
|
||||
Acquired: true,
|
||||
ReleaseFunc: result.ReleaseFunc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, stickyAccountID)
|
||||
@@ -1066,6 +1085,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
for _, item := range routingAvailable {
|
||||
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
||||
if err == nil && result.Acquired {
|
||||
// 会话数量限制检查
|
||||
if !s.checkAndRegisterSession(ctx, item.account, sessionUUID) {
|
||||
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
||||
continue
|
||||
}
|
||||
if sessionHash != "" && s.cache != nil {
|
||||
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
|
||||
}
|
||||
@@ -1108,15 +1132,21 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
if ok && s.isAccountInGroup(account, groupID) &&
|
||||
s.isAccountAllowedForPlatform(account, platform, useMixed) &&
|
||||
account.IsSchedulableForModel(requestedModel) &&
|
||||
(requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||
(requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) &&
|
||||
s.isAccountSchedulableForWindowCost(ctx, account, true) { // 粘性会话窗口费用检查
|
||||
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
||||
if err == nil && result.Acquired {
|
||||
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
||||
return &AccountSelectionResult{
|
||||
Account: account,
|
||||
Acquired: true,
|
||||
ReleaseFunc: result.ReleaseFunc,
|
||||
}, nil
|
||||
// 会话数量限制检查
|
||||
if !s.checkAndRegisterSession(ctx, account, sessionUUID) {
|
||||
result.ReleaseFunc() // 释放槽位,继续到 Layer 2
|
||||
} else {
|
||||
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
||||
return &AccountSelectionResult{
|
||||
Account: account,
|
||||
Acquired: true,
|
||||
ReleaseFunc: result.ReleaseFunc,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID)
|
||||
@@ -1157,6 +1187,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
||||
continue
|
||||
}
|
||||
// 窗口费用检查(非粘性会话路径)
|
||||
if !s.isAccountSchedulableForWindowCost(ctx, acc, false) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, acc)
|
||||
}
|
||||
|
||||
@@ -1174,7 +1208,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
|
||||
loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads)
|
||||
if err != nil {
|
||||
if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok {
|
||||
if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth, sessionUUID); ok {
|
||||
return result, nil
|
||||
}
|
||||
} else {
|
||||
@@ -1223,6 +1257,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
for _, item := range available {
|
||||
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
||||
if err == nil && result.Acquired {
|
||||
// 会话数量限制检查
|
||||
if !s.checkAndRegisterSession(ctx, item.account, sessionUUID) {
|
||||
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
||||
continue
|
||||
}
|
||||
if sessionHash != "" && s.cache != nil {
|
||||
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
|
||||
}
|
||||
@@ -1252,13 +1291,18 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
return nil, errors.New("no available accounts")
|
||||
}
|
||||
|
||||
func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool) (*AccountSelectionResult, bool) {
|
||||
func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool, sessionUUID string) (*AccountSelectionResult, bool) {
|
||||
ordered := append([]*Account(nil), candidates...)
|
||||
sortAccountsByPriorityAndLastUsed(ordered, preferOAuth)
|
||||
|
||||
for _, acc := range ordered {
|
||||
result, err := s.tryAcquireAccountSlot(ctx, acc.ID, acc.Concurrency)
|
||||
if err == nil && result.Acquired {
|
||||
// 会话数量限制检查
|
||||
if !s.checkAndRegisterSession(ctx, acc, sessionUUID) {
|
||||
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
||||
continue
|
||||
}
|
||||
if sessionHash != "" && s.cache != nil {
|
||||
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, acc.ID, stickySessionTTL)
|
||||
}
|
||||
@@ -1490,6 +1534,107 @@ func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID in
|
||||
return s.concurrencyService.AcquireAccountSlot(ctx, accountID, maxConcurrency)
|
||||
}
|
||||
|
||||
// isAccountSchedulableForWindowCost 检查账号是否可根据窗口费用进行调度
|
||||
// 仅适用于 Anthropic OAuth/SetupToken 账号
|
||||
// 返回 true 表示可调度,false 表示不可调度
|
||||
func (s *GatewayService) isAccountSchedulableForWindowCost(ctx context.Context, account *Account, isSticky bool) bool {
|
||||
// 只检查 Anthropic OAuth/SetupToken 账号
|
||||
if !account.IsAnthropicOAuthOrSetupToken() {
|
||||
return true
|
||||
}
|
||||
|
||||
limit := account.GetWindowCostLimit()
|
||||
if limit <= 0 {
|
||||
return true // 未启用窗口费用限制
|
||||
}
|
||||
|
||||
// 尝试从缓存获取窗口费用
|
||||
var currentCost float64
|
||||
if s.sessionLimitCache != nil {
|
||||
if cost, hit, err := s.sessionLimitCache.GetWindowCost(ctx, account.ID); err == nil && hit {
|
||||
currentCost = cost
|
||||
goto checkSchedulability
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中,从数据库查询
|
||||
{
|
||||
var startTime time.Time
|
||||
if account.SessionWindowStart != nil {
|
||||
startTime = *account.SessionWindowStart
|
||||
} else {
|
||||
startTime = time.Now().Add(-5 * time.Hour)
|
||||
}
|
||||
|
||||
stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime)
|
||||
if err != nil {
|
||||
// 失败开放:查询失败时允许调度
|
||||
return true
|
||||
}
|
||||
|
||||
// 使用标准费用(不含账号倍率)
|
||||
currentCost = stats.StandardCost
|
||||
|
||||
// 设置缓存(忽略错误)
|
||||
if s.sessionLimitCache != nil {
|
||||
_ = s.sessionLimitCache.SetWindowCost(ctx, account.ID, currentCost)
|
||||
}
|
||||
}
|
||||
|
||||
checkSchedulability:
|
||||
schedulability := account.CheckWindowCostSchedulability(currentCost)
|
||||
|
||||
switch schedulability {
|
||||
case WindowCostSchedulable:
|
||||
return true
|
||||
case WindowCostStickyOnly:
|
||||
return isSticky
|
||||
case WindowCostNotSchedulable:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// checkAndRegisterSession 检查并注册会话,用于会话数量限制
|
||||
// 仅适用于 Anthropic OAuth/SetupToken 账号
|
||||
// 返回 true 表示允许(在限制内或会话已存在),false 表示拒绝(超出限制且是新会话)
|
||||
func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *Account, sessionUUID string) bool {
|
||||
// 只检查 Anthropic OAuth/SetupToken 账号
|
||||
if !account.IsAnthropicOAuthOrSetupToken() {
|
||||
return true
|
||||
}
|
||||
|
||||
maxSessions := account.GetMaxSessions()
|
||||
if maxSessions <= 0 || sessionUUID == "" {
|
||||
return true // 未启用会话限制或无会话ID
|
||||
}
|
||||
|
||||
if s.sessionLimitCache == nil {
|
||||
return true // 缓存不可用时允许通过
|
||||
}
|
||||
|
||||
idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute
|
||||
|
||||
allowed, err := s.sessionLimitCache.RegisterSession(ctx, account.ID, sessionUUID, maxSessions, idleTimeout)
|
||||
if err != nil {
|
||||
// 失败开放:缓存错误时允许通过
|
||||
return true
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// extractSessionUUID 从 metadata.user_id 中提取会话 UUID
|
||||
// 格式: user_{64位hex}_account__session_{uuid}
|
||||
func extractSessionUUID(metadataUserID string) string {
|
||||
if metadataUserID == "" {
|
||||
return ""
|
||||
}
|
||||
if match := sessionIDRegex.FindStringSubmatch(metadataUserID); len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) {
|
||||
if s.schedulerSnapshot != nil {
|
||||
return s.schedulerSnapshot.GetAccount(ctx, accountID)
|
||||
@@ -2384,9 +2529,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
retryStart := time.Now()
|
||||
for attempt := 1; attempt <= maxRetryAttempts; attempt++ {
|
||||
// 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
|
||||
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
// Capture upstream request body for ops retry of this attempt.
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1067,15 +1067,29 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
// 记录上次收到上游数据的时间,用于控制 keepalive 发送频率
|
||||
lastDataAt := time.Now()
|
||||
|
||||
// 仅发送一次错误事件,避免多次写入导致协议混乱(写失败时尽力通知客户端)
|
||||
// 仅发送一次错误事件,避免多次写入导致协议混乱。
|
||||
// 注意:OpenAI `/v1/responses` streaming 事件必须符合 OpenAI Responses schema;
|
||||
// 否则下游 SDK(例如 OpenCode)会因为类型校验失败而报错。
|
||||
errorEventSent := false
|
||||
clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage
|
||||
sendErrorEvent := func(reason string) {
|
||||
if errorEventSent {
|
||||
if errorEventSent || clientDisconnected {
|
||||
return
|
||||
}
|
||||
errorEventSent = true
|
||||
_, _ = fmt.Fprintf(w, "event: error\ndata: {\"error\":\"%s\"}\n\n", reason)
|
||||
flusher.Flush()
|
||||
payload := map[string]any{
|
||||
"type": "error",
|
||||
"sequence_number": 0,
|
||||
"error": map[string]any{
|
||||
"type": "upstream_error",
|
||||
"message": reason,
|
||||
"code": reason,
|
||||
},
|
||||
}
|
||||
if b, err := json.Marshal(payload); err == nil {
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
needModelReplace := originalModel != mappedModel
|
||||
@@ -1087,6 +1101,17 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
|
||||
}
|
||||
if ev.err != nil {
|
||||
// 客户端断开/取消请求时,上游读取往往会返回 context canceled。
|
||||
// /v1/responses 的 SSE 事件必须符合 OpenAI 协议;这里不注入自定义 error event,避免下游 SDK 解析失败。
|
||||
if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) {
|
||||
log.Printf("Context canceled during streaming, returning collected usage")
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
|
||||
}
|
||||
// 客户端已断开时,上游出错仅影响体验,不影响计费;返回已收集 usage
|
||||
if clientDisconnected {
|
||||
log.Printf("Upstream read error after client disconnect: %v, returning collected usage", ev.err)
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
|
||||
}
|
||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||
log.Printf("SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err)
|
||||
sendErrorEvent("response_too_large")
|
||||
@@ -1110,15 +1135,19 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
|
||||
// Correct Codex tool calls if needed (apply_patch -> edit, etc.)
|
||||
if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEData(data); corrected {
|
||||
data = correctedData
|
||||
line = "data: " + correctedData
|
||||
}
|
||||
|
||||
// Forward line
|
||||
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
|
||||
sendErrorEvent("write_failed")
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err
|
||||
// 写入客户端(客户端断开后继续 drain 上游)
|
||||
if !clientDisconnected {
|
||||
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
|
||||
clientDisconnected = true
|
||||
log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
|
||||
} else {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
// Record first token time
|
||||
if firstTokenMs == nil && data != "" && data != "[DONE]" {
|
||||
@@ -1128,11 +1157,14 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
s.parseSSEUsage(data, usage)
|
||||
} else {
|
||||
// Forward non-data lines as-is
|
||||
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
|
||||
sendErrorEvent("write_failed")
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err
|
||||
if !clientDisconnected {
|
||||
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
|
||||
clientDisconnected = true
|
||||
log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
|
||||
} else {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
case <-intervalCh:
|
||||
@@ -1140,6 +1172,10 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
if time.Since(lastRead) < streamInterval {
|
||||
continue
|
||||
}
|
||||
if clientDisconnected {
|
||||
log.Printf("Upstream timeout after client disconnect, returning collected usage")
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
|
||||
}
|
||||
log.Printf("Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval)
|
||||
// 处理流超时,可能标记账户为临时不可调度或错误状态
|
||||
if s.rateLimitService != nil {
|
||||
@@ -1149,11 +1185,16 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout")
|
||||
|
||||
case <-keepaliveCh:
|
||||
if clientDisconnected {
|
||||
continue
|
||||
}
|
||||
if time.Since(lastDataAt) < keepaliveInterval {
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err
|
||||
clientDisconnected = true
|
||||
log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
|
||||
continue
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,25 @@ type stubConcurrencyCache struct {
|
||||
ConcurrencyCache
|
||||
}
|
||||
|
||||
type cancelReadCloser struct{}
|
||||
|
||||
func (c cancelReadCloser) Read(p []byte) (int, error) { return 0, context.Canceled }
|
||||
func (c cancelReadCloser) Close() error { return nil }
|
||||
|
||||
type failingGinWriter struct {
|
||||
gin.ResponseWriter
|
||||
failAfter int
|
||||
writes int
|
||||
}
|
||||
|
||||
func (w *failingGinWriter) Write(p []byte) (int, error) {
|
||||
if w.writes >= w.failAfter {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
w.writes++
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
func (c stubConcurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
@@ -169,8 +188,85 @@ func TestOpenAIStreamingTimeout(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
|
||||
t.Fatalf("expected stream timeout error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "stream_timeout") {
|
||||
t.Fatalf("expected stream_timeout SSE error, got %q", rec.Body.String())
|
||||
if !strings.Contains(rec.Body.String(), "\"type\":\"error\"") || !strings.Contains(rec.Body.String(), "stream_timeout") {
|
||||
t.Fatalf("expected OpenAI-compatible error SSE event, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingContextCanceledDoesNotInjectErrorEvent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 0,
|
||||
StreamKeepaliveInterval: 0,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{cfg: cfg}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: cancelReadCloser{},
|
||||
Header: http.Header{},
|
||||
}
|
||||
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "event: error") || strings.Contains(rec.Body.String(), "stream_read_error") {
|
||||
t.Fatalf("expected no injected SSE error event, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingClientDisconnectDrainsUpstreamUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 0,
|
||||
StreamKeepaliveInterval: 0,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{cfg: cfg}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
c.Writer = &failingGinWriter{ResponseWriter: c.Writer, failAfter: 0}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: pr,
|
||||
Header: http.Header{},
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n"))
|
||||
}()
|
||||
|
||||
result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
|
||||
_ = pr.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if result == nil || result.usage == nil {
|
||||
t.Fatalf("expected usage result")
|
||||
}
|
||||
if result.usage.InputTokens != 3 || result.usage.OutputTokens != 5 || result.usage.CacheReadInputTokens != 1 {
|
||||
t.Fatalf("unexpected usage: %+v", *result.usage)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "event: error") || strings.Contains(rec.Body.String(), "write_failed") {
|
||||
t.Fatalf("expected no injected SSE error event, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,8 +305,8 @@ func TestOpenAIStreamingTooLong(t *testing.T) {
|
||||
if !errors.Is(err, bufio.ErrTooLong) {
|
||||
t.Fatalf("expected ErrTooLong, got %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "response_too_large") {
|
||||
t.Fatalf("expected response_too_large SSE error, got %q", rec.Body.String())
|
||||
if !strings.Contains(rec.Body.String(), "\"type\":\"error\"") || !strings.Contains(rec.Body.String(), "response_too_large") {
|
||||
t.Fatalf("expected OpenAI-compatible error SSE event, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -514,7 +514,7 @@ func (s *OpsService) selectAccountForRetry(ctx context.Context, reqType opsRetry
|
||||
if s.gatewayService == nil {
|
||||
return nil, fmt.Errorf("gateway service not available")
|
||||
}
|
||||
return s.gatewayService.SelectAccountWithLoadAwareness(ctx, groupID, "", model, excludedIDs)
|
||||
return s.gatewayService.SelectAccountWithLoadAwareness(ctx, groupID, "", model, excludedIDs, "") // 重试不使用会话限制
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported retry type: %s", reqType)
|
||||
}
|
||||
|
||||
63
backend/internal/service/session_limit_cache.go
Normal file
63
backend/internal/service/session_limit_cache.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionLimitCache 管理账号级别的活跃会话跟踪
|
||||
// 用于 Anthropic OAuth/SetupToken 账号的会话数量限制
|
||||
//
|
||||
// Key 格式: session_limit:account:{accountID}
|
||||
// 数据结构: Sorted Set (member=sessionUUID, score=timestamp)
|
||||
//
|
||||
// 会话在空闲超时后自动过期,无需手动清理
|
||||
type SessionLimitCache interface {
|
||||
// RegisterSession 注册会话活动
|
||||
// - 如果会话已存在,刷新其时间戳并返回 true
|
||||
// - 如果会话不存在且活跃会话数 < maxSessions,添加新会话并返回 true
|
||||
// - 如果会话不存在且活跃会话数 >= maxSessions,返回 false(拒绝)
|
||||
//
|
||||
// 参数:
|
||||
// accountID: 账号 ID
|
||||
// sessionUUID: 从 metadata.user_id 中提取的会话 UUID
|
||||
// maxSessions: 最大并发会话数限制
|
||||
// idleTimeout: 会话空闲超时时间
|
||||
//
|
||||
// 返回:
|
||||
// allowed: true 表示允许(在限制内或会话已存在),false 表示拒绝(超出限制且是新会话)
|
||||
// error: 操作错误
|
||||
RegisterSession(ctx context.Context, accountID int64, sessionUUID string, maxSessions int, idleTimeout time.Duration) (allowed bool, err error)
|
||||
|
||||
// RefreshSession 刷新现有会话的时间戳
|
||||
// 用于活跃会话保持活动状态
|
||||
RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error
|
||||
|
||||
// GetActiveSessionCount 获取当前活跃会话数
|
||||
// 返回未过期的会话数量
|
||||
GetActiveSessionCount(ctx context.Context, accountID int64) (int, error)
|
||||
|
||||
// GetActiveSessionCountBatch 批量获取多个账号的活跃会话数
|
||||
// 返回 map[accountID]count,查询失败的账号不在 map 中
|
||||
GetActiveSessionCountBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error)
|
||||
|
||||
// IsSessionActive 检查特定会话是否活跃(未过期)
|
||||
IsSessionActive(ctx context.Context, accountID int64, sessionUUID string) (bool, error)
|
||||
|
||||
// ========== 5h窗口费用缓存 ==========
|
||||
// Key 格式: window_cost:account:{accountID}
|
||||
// 用于缓存账号在当前5h窗口内的标准费用,减少数据库聚合查询压力
|
||||
|
||||
// GetWindowCost 获取缓存的窗口费用
|
||||
// 返回 (cost, true, nil) 如果缓存命中
|
||||
// 返回 (0, false, nil) 如果缓存未命中
|
||||
// 返回 (0, false, err) 如果发生错误
|
||||
GetWindowCost(ctx context.Context, accountID int64) (cost float64, hit bool, err error)
|
||||
|
||||
// SetWindowCost 设置窗口费用缓存
|
||||
SetWindowCost(ctx context.Context, accountID int64, cost float64) error
|
||||
|
||||
// GetWindowCostBatch 批量获取窗口费用缓存
|
||||
// 返回 map[accountID]cost,缓存未命中的账号不在 map 中
|
||||
GetWindowCostBatch(ctx context.Context, accountIDs []int64) (map[int64]float64, error)
|
||||
}
|
||||
@@ -33,6 +33,22 @@
|
||||
|
||||
# 修改为你的域名
|
||||
example.com {
|
||||
# =========================================================================
|
||||
# 静态资源长期缓存(高优先级,放在最前面)
|
||||
# 带 hash 的文件可以永久缓存,浏览器和 CDN 都会缓存
|
||||
# =========================================================================
|
||||
@static {
|
||||
path /assets/*
|
||||
path /logo.png
|
||||
path /favicon.ico
|
||||
}
|
||||
header @static {
|
||||
Cache-Control "public, max-age=31536000, immutable"
|
||||
# 移除可能干扰缓存的头
|
||||
-Pragma
|
||||
-Expires
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# TLS 安全配置
|
||||
# =========================================================================
|
||||
|
||||
1650
frontend/package-lock.json
generated
1650
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,10 @@
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobehub/icons": "^4.0.2",
|
||||
@@ -29,17 +32,21 @@
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.25.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^5.0.10",
|
||||
"vite-plugin-checker": "^0.9.1",
|
||||
"vitest": "^2.1.9",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
937
frontend/pnpm-lock.yaml
generated
937
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { onMounted, watch } from 'vue'
|
||||
import Toast from '@/components/common/Toast.vue'
|
||||
import NavigationProgress from '@/components/common/NavigationProgress.vue'
|
||||
import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores'
|
||||
import { getSetupStatus } from '@/api/setup'
|
||||
|
||||
@@ -84,6 +85,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationProgress />
|
||||
<RouterView />
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
478
frontend/src/__tests__/integration/navigation.spec.ts
Normal file
478
frontend/src/__tests__/integration/navigation.spec.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* 导航集成测试
|
||||
* 测试完整的页面导航流程、预加载和错误恢复机制
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createRouter, createWebHistory, type Router } from 'vue-router'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { useNavigationLoadingState, _resetNavigationLoadingInstance } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
|
||||
// Mock 视图组件
|
||||
const MockDashboard = defineComponent({
|
||||
name: 'MockDashboard',
|
||||
render() {
|
||||
return h('div', { class: 'dashboard' }, 'Dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
const MockKeys = defineComponent({
|
||||
name: 'MockKeys',
|
||||
render() {
|
||||
return h('div', { class: 'keys' }, 'Keys')
|
||||
}
|
||||
})
|
||||
|
||||
const MockUsage = defineComponent({
|
||||
name: 'MockUsage',
|
||||
render() {
|
||||
return h('div', { class: 'usage' }, 'Usage')
|
||||
}
|
||||
})
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
checkAuth: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
siteName: 'Test Site'
|
||||
})
|
||||
}))
|
||||
|
||||
// 创建测试路由
|
||||
function createTestRouter(): Router {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard,
|
||||
meta: { requiresAuth: true, title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/keys',
|
||||
name: 'Keys',
|
||||
component: MockKeys,
|
||||
meta: { requiresAuth: true, title: 'Keys' }
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
component: MockUsage,
|
||||
meta: { requiresAuth: true, title: 'Usage' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 测试用 App 组件
|
||||
const TestApp = defineComponent({
|
||||
name: 'TestApp',
|
||||
setup() {
|
||||
return () => h('div', { id: 'app' }, [h('router-view')])
|
||||
}
|
||||
})
|
||||
|
||||
describe('Navigation Integration Tests', () => {
|
||||
let router: Router
|
||||
let originalRequestIdleCallback: typeof window.requestIdleCallback
|
||||
let originalCancelIdleCallback: typeof window.cancelIdleCallback
|
||||
|
||||
beforeEach(() => {
|
||||
// 设置 Pinia
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// 重置导航加载状态
|
||||
_resetNavigationLoadingInstance()
|
||||
|
||||
// 创建新的路由实例
|
||||
router = createTestRouter()
|
||||
|
||||
// Mock requestIdleCallback
|
||||
originalRequestIdleCallback = window.requestIdleCallback
|
||||
originalCancelIdleCallback = window.cancelIdleCallback
|
||||
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
|
||||
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
window.requestIdleCallback = originalRequestIdleCallback
|
||||
window.cancelIdleCallback = originalCancelIdleCallback
|
||||
})
|
||||
|
||||
describe('完整页面导航流程', () => {
|
||||
it('导航时应该触发加载状态变化', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
// 初始状态
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
// 挂载应用
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
// 等待路由初始化
|
||||
await router.isReady()
|
||||
await flushPromises()
|
||||
|
||||
// 导航到 /dashboard
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 导航结束后状态应该重置
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('导航到新页面应该正确渲染组件', async () => {
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 检查当前路由
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('连续快速导航应该正确处理路由状态', async () => {
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
|
||||
// 快速连续导航
|
||||
router.push('/keys')
|
||||
router.push('/usage')
|
||||
router.push('/dashboard')
|
||||
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 应该最终停在 /dashboard
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('路由预加载', () => {
|
||||
it('导航后应该触发相关路由预加载', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
const triggerSpy = vi.spyOn(routePrefetch, 'triggerPrefetch')
|
||||
|
||||
// 设置 afterEach 守卫
|
||||
router.afterEach((to) => {
|
||||
routePrefetch.triggerPrefetch(to)
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 应该触发预加载
|
||||
expect(triggerSpy).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('已预加载的路由不应重复预加载', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 手动触发预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const prefetchedCount = routePrefetch.prefetchedRoutes.value.size
|
||||
|
||||
// 再次触发相同路由预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 预加载数量不应增加
|
||||
expect(routePrefetch.prefetchedRoutes.value.size).toBe(prefetchedCount)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('路由变化时应取消之前的预加载任务', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
|
||||
// 触发预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
|
||||
// 立即导航到新路由(这会在内部调用 cancelPendingPrefetch)
|
||||
routePrefetch.triggerPrefetch({ path: '/keys' } as any)
|
||||
|
||||
// 由于 triggerPrefetch 内部调用 cancelPendingPrefetch,检查是否有预加载被正确管理
|
||||
expect(routePrefetch.prefetchedRoutes.value.size).toBeLessThanOrEqual(2)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chunk 加载错误恢复', () => {
|
||||
it('chunk 加载失败应该被正确捕获', async () => {
|
||||
const errorHandler = vi.fn()
|
||||
|
||||
// 创建带错误处理的路由
|
||||
const errorRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/error-page',
|
||||
name: 'ErrorPage',
|
||||
// 模拟加载失败的组件
|
||||
component: () => Promise.reject(new Error('Failed to fetch dynamically imported module'))
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
errorRouter.onError(errorHandler)
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [errorRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await errorRouter.isReady()
|
||||
await errorRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 尝试导航到会失败的页面
|
||||
try {
|
||||
await errorRouter.push('/error-page')
|
||||
} catch {
|
||||
// 预期会失败
|
||||
}
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// 错误处理器应该被调用
|
||||
expect(errorHandler).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('chunk 加载错误应该包含正确的错误信息', async () => {
|
||||
let capturedError: Error | null = null
|
||||
|
||||
const errorRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/chunk-error',
|
||||
name: 'ChunkError',
|
||||
component: () => {
|
||||
const error = new Error('Loading chunk failed')
|
||||
error.name = 'ChunkLoadError'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
errorRouter.onError((error) => {
|
||||
capturedError = error
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [errorRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await errorRouter.isReady()
|
||||
|
||||
try {
|
||||
await errorRouter.push('/chunk-error')
|
||||
} catch {
|
||||
// 预期会失败
|
||||
}
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(capturedError).not.toBeNull()
|
||||
expect(capturedError!.name).toBe('ChunkLoadError')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('导航状态管理', () => {
|
||||
it('导航开始时 isLoading 应该变为 true', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
// 创建一个延迟加载的组件来模拟真实场景
|
||||
const DelayedComponent = defineComponent({
|
||||
name: 'DelayedComponent',
|
||||
async setup() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
return () => h('div', 'Delayed')
|
||||
}
|
||||
})
|
||||
|
||||
const delayRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/delayed',
|
||||
name: 'Delayed',
|
||||
component: DelayedComponent
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 设置导航守卫
|
||||
delayRouter.beforeEach(() => {
|
||||
navigationLoading.startNavigation()
|
||||
})
|
||||
|
||||
delayRouter.afterEach(() => {
|
||||
navigationLoading.endNavigation()
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [delayRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await delayRouter.isReady()
|
||||
await delayRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 导航结束后 isLoading 应该为 false
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('导航取消时应该正确重置状态', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
const testRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/keys',
|
||||
name: 'Keys',
|
||||
component: MockKeys,
|
||||
beforeEnter: (_to, _from, next) => {
|
||||
// 模拟导航取消
|
||||
next(false)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
testRouter.beforeEach(() => {
|
||||
navigationLoading.startNavigation()
|
||||
})
|
||||
|
||||
testRouter.afterEach(() => {
|
||||
navigationLoading.endNavigation()
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [testRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await testRouter.isReady()
|
||||
await testRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 尝试导航到被取消的路由
|
||||
await testRouter.push('/keys').catch(() => {})
|
||||
await flushPromises()
|
||||
|
||||
// 导航被取消后,状态应该被重置
|
||||
// 注意:由于 afterEach 仍然会被调用,isLoading 应该为 false
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
45
frontend/src/__tests__/setup.ts
Normal file
45
frontend/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Vitest 测试环境设置
|
||||
* 提供全局 mock 和测试工具
|
||||
*/
|
||||
import { config } from '@vue/test-utils'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock requestIdleCallback (Safari < 15 不支持)
|
||||
if (typeof globalThis.requestIdleCallback === 'undefined') {
|
||||
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
|
||||
return window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 1)
|
||||
}) as unknown as typeof requestIdleCallback
|
||||
}
|
||||
|
||||
if (typeof globalThis.cancelIdleCallback === 'undefined') {
|
||||
globalThis.cancelIdleCallback = ((id: number) => {
|
||||
window.clearTimeout(id)
|
||||
}) as unknown as typeof cancelIdleCallback
|
||||
}
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
|
||||
|
||||
// Mock ResizeObserver
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
|
||||
|
||||
// Vue Test Utils 全局配置
|
||||
config.global.stubs = {
|
||||
// 可以在这里添加全局 stub
|
||||
}
|
||||
|
||||
// 设置全局测试超时
|
||||
vi.setConfig({ testTimeout: 10000 })
|
||||
199
frontend/src/components/account/AccountCapacityCell.vue
Normal file
199
frontend/src/components/account/AccountCapacityCell.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<!-- 并发槽位 -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
|
||||
concurrencyClass
|
||||
]"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ currentConcurrency }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ account.concurrency }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 5h窗口费用限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
|
||||
<div v-if="showWindowCost" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
windowCostClass
|
||||
]"
|
||||
:title="windowCostTooltip"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="font-mono">${{ formatCost(currentWindowCost) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">${{ formatCost(account.window_cost_limit) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 会话数量限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
|
||||
<div v-if="showSessionLimit" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
sessionLimitClass
|
||||
]"
|
||||
:title="sessionLimitTooltip"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ activeSessions }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ account.max_sessions }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前并发数
|
||||
const currentConcurrency = computed(() => props.account.current_concurrency || 0)
|
||||
|
||||
// 是否为 Anthropic OAuth/SetupToken 账号
|
||||
const isAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return (
|
||||
props.account.platform === 'anthropic' &&
|
||||
(props.account.type === 'oauth' || props.account.type === 'setup-token')
|
||||
)
|
||||
})
|
||||
|
||||
// 是否显示窗口费用限制
|
||||
const showWindowCost = computed(() => {
|
||||
return (
|
||||
isAnthropicOAuthOrSetupToken.value &&
|
||||
props.account.window_cost_limit !== undefined &&
|
||||
props.account.window_cost_limit !== null &&
|
||||
props.account.window_cost_limit > 0
|
||||
)
|
||||
})
|
||||
|
||||
// 当前窗口费用
|
||||
const currentWindowCost = computed(() => props.account.current_window_cost ?? 0)
|
||||
|
||||
// 是否显示会话限制
|
||||
const showSessionLimit = computed(() => {
|
||||
return (
|
||||
isAnthropicOAuthOrSetupToken.value &&
|
||||
props.account.max_sessions !== undefined &&
|
||||
props.account.max_sessions !== null &&
|
||||
props.account.max_sessions > 0
|
||||
)
|
||||
})
|
||||
|
||||
// 当前活跃会话数
|
||||
const activeSessions = computed(() => props.account.active_sessions ?? 0)
|
||||
|
||||
// 并发状态样式
|
||||
const concurrencyClass = computed(() => {
|
||||
const current = currentConcurrency.value
|
||||
const max = props.account.concurrency
|
||||
|
||||
if (current >= max) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
if (current > 0) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
})
|
||||
|
||||
// 窗口费用状态样式
|
||||
const windowCostClass = computed(() => {
|
||||
if (!showWindowCost.value) return ''
|
||||
|
||||
const current = currentWindowCost.value
|
||||
const limit = props.account.window_cost_limit || 0
|
||||
const reserve = props.account.window_cost_sticky_reserve || 10
|
||||
|
||||
// >= 阈值+预留: 完全不可调度 (红色)
|
||||
if (current >= limit + reserve) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
// >= 阈值: 仅粘性会话 (橙色)
|
||||
if (current >= limit) {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
}
|
||||
// >= 80% 阈值: 警告 (黄色)
|
||||
if (current >= limit * 0.8) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
// 正常 (绿色)
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
})
|
||||
|
||||
// 窗口费用提示文字
|
||||
const windowCostTooltip = computed(() => {
|
||||
if (!showWindowCost.value) return ''
|
||||
|
||||
const current = currentWindowCost.value
|
||||
const limit = props.account.window_cost_limit || 0
|
||||
const reserve = props.account.window_cost_sticky_reserve || 10
|
||||
|
||||
if (current >= limit + reserve) {
|
||||
return t('admin.accounts.capacity.windowCost.blocked')
|
||||
}
|
||||
if (current >= limit) {
|
||||
return t('admin.accounts.capacity.windowCost.stickyOnly')
|
||||
}
|
||||
return t('admin.accounts.capacity.windowCost.normal')
|
||||
})
|
||||
|
||||
// 会话限制状态样式
|
||||
const sessionLimitClass = computed(() => {
|
||||
if (!showSessionLimit.value) return ''
|
||||
|
||||
const current = activeSessions.value
|
||||
const max = props.account.max_sessions || 0
|
||||
|
||||
// >= 最大: 完全占满 (红色)
|
||||
if (current >= max) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
// >= 80%: 警告 (黄色)
|
||||
if (current >= max * 0.8) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
// 正常 (绿色)
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
})
|
||||
|
||||
// 会话限制提示文字
|
||||
const sessionLimitTooltip = computed(() => {
|
||||
if (!showSessionLimit.value) return ''
|
||||
|
||||
const current = activeSessions.value
|
||||
const max = props.account.max_sessions || 0
|
||||
const idle = props.account.session_idle_timeout_minutes || 5
|
||||
|
||||
if (current >= max) {
|
||||
return t('admin.accounts.capacity.sessions.full', { idle })
|
||||
}
|
||||
return t('admin.accounts.capacity.sessions.normal', { idle })
|
||||
})
|
||||
|
||||
// 格式化费用显示
|
||||
const formatCost = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
return value.toFixed(2)
|
||||
}
|
||||
</script>
|
||||
@@ -604,6 +604,136 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Window Cost Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.windowCost.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.windowCost.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="windowCostEnabled = !windowCostEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 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',
|
||||
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="windowCostEnabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.limit') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
v-model.number="windowCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaControl.windowCost.limitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.limitHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.stickyReserve') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
v-model.number="windowCostStickyReserve"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.stickyReserveHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionLimit.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.sessionLimit.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sessionLimitEnabled = !sessionLimitEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 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',
|
||||
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionLimitEnabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessions') }}</label>
|
||||
<input
|
||||
v-model.number="maxSessions"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessionsHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeout') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="sessionIdleTimeout"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input pr-12"
|
||||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">{{ t('common.minutes') }}</span>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeoutHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div>
|
||||
<label class="input-label">{{ t('common.status') }}</label>
|
||||
@@ -767,6 +897,14 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
|
||||
// Quota control state (Anthropic OAuth/SetupToken only)
|
||||
const windowCostEnabled = ref(false)
|
||||
const windowCostLimit = ref<number | null>(null)
|
||||
const windowCostStickyReserve = ref<number | null>(null)
|
||||
const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||
const tempUnschedPresets = computed(() => [
|
||||
@@ -854,6 +992,9 @@ watch(
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||
|
||||
// Load quota control settings (Anthropic OAuth/SetupToken only)
|
||||
loadQuotaControlSettings(newAccount)
|
||||
|
||||
loadTempUnschedRules(credentials)
|
||||
|
||||
// Initialize API Key fields for apikey type
|
||||
@@ -1087,6 +1228,35 @@ function loadTempUnschedRules(credentials?: Record<string, unknown>) {
|
||||
})
|
||||
}
|
||||
|
||||
// Load quota control settings from account (Anthropic OAuth/SetupToken only)
|
||||
function loadQuotaControlSettings(account: Account) {
|
||||
// Reset all quota control state first
|
||||
windowCostEnabled.value = false
|
||||
windowCostLimit.value = null
|
||||
windowCostStickyReserve.value = null
|
||||
sessionLimitEnabled.value = false
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
|
||||
// Only applies to Anthropic OAuth/SetupToken accounts
|
||||
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load from extra field (via backend DTO fields)
|
||||
if (account.window_cost_limit != null && account.window_cost_limit > 0) {
|
||||
windowCostEnabled.value = true
|
||||
windowCostLimit.value = account.window_cost_limit
|
||||
windowCostStickyReserve.value = account.window_cost_sticky_reserve ?? 10
|
||||
}
|
||||
|
||||
if (account.max_sessions != null && account.max_sessions > 0) {
|
||||
sessionLimitEnabled.value = true
|
||||
maxSessions.value = account.max_sessions
|
||||
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
|
||||
}
|
||||
}
|
||||
|
||||
function formatTempUnschedKeywords(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -1214,6 +1384,32 @@ const handleSubmit = async () => {
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
|
||||
if (props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
|
||||
// Window cost limit settings
|
||||
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||
newExtra.window_cost_limit = windowCostLimit.value
|
||||
newExtra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||
} else {
|
||||
delete newExtra.window_cost_limit
|
||||
delete newExtra.window_cost_sticky_reserve
|
||||
}
|
||||
|
||||
// Session limit settings
|
||||
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||
newExtra.max_sessions = maxSessions.value
|
||||
newExtra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
} else {
|
||||
delete newExtra.max_sessions
|
||||
delete newExtra.session_idle_timeout_minutes
|
||||
}
|
||||
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
await adminAPI.accounts.update(props.account.id, updatePayload)
|
||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||||
emit('updated')
|
||||
|
||||
109
frontend/src/components/common/NavigationProgress.vue
Normal file
109
frontend/src/components/common/NavigationProgress.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 导航进度条组件
|
||||
* 在页面顶部显示加载进度,提供导航反馈
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
|
||||
|
||||
const { isLoading } = useNavigationLoadingState()
|
||||
|
||||
// 进度条可见性
|
||||
const isVisible = computed(() => isLoading.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="progress-fade">
|
||||
<div
|
||||
v-show="isVisible"
|
||||
class="navigation-progress"
|
||||
role="progressbar"
|
||||
aria-label="Loading"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div class="navigation-progress-bar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navigation-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.navigation-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
theme('colors.primary.400') 20%,
|
||||
theme('colors.primary.500') 50%,
|
||||
theme('colors.primary.400') 80%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: progress-slide 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 暗色模式下的进度条颜色 */
|
||||
:root.dark .navigation-progress-bar {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
theme('colors.primary.500') 20%,
|
||||
theme('colors.primary.400') 50%,
|
||||
theme('colors.primary.500') 80%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 进度条滑动动画 */
|
||||
@keyframes progress-slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 淡入淡出过渡 */
|
||||
.progress-fade-enter-active {
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.progress-fade-leave-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
.progress-fade-enter-from,
|
||||
.progress-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 减少动画模式 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.navigation-progress-bar {
|
||||
animation: progress-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* NavigationProgress 组件单元测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import NavigationProgress from '../../common/NavigationProgress.vue'
|
||||
|
||||
// Mock useNavigationLoadingState
|
||||
const mockIsLoading = ref(false)
|
||||
|
||||
vi.mock('@/composables/useNavigationLoading', () => ({
|
||||
useNavigationLoadingState: () => ({
|
||||
isLoading: mockIsLoading
|
||||
})
|
||||
}))
|
||||
|
||||
describe('NavigationProgress', () => {
|
||||
beforeEach(() => {
|
||||
mockIsLoading.value = false
|
||||
})
|
||||
|
||||
it('isLoading=false 时进度条应该隐藏', () => {
|
||||
mockIsLoading.value = false
|
||||
const wrapper = mount(NavigationProgress)
|
||||
|
||||
const progressBar = wrapper.find('.navigation-progress')
|
||||
// v-show 会设置 display: none
|
||||
expect(progressBar.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('isLoading=true 时进度条应该可见', async () => {
|
||||
mockIsLoading.value = true
|
||||
const wrapper = mount(NavigationProgress)
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const progressBar = wrapper.find('.navigation-progress')
|
||||
expect(progressBar.exists()).toBe(true)
|
||||
expect(progressBar.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('应该有正确的 ARIA 属性', () => {
|
||||
mockIsLoading.value = true
|
||||
const wrapper = mount(NavigationProgress)
|
||||
|
||||
const progressBar = wrapper.find('.navigation-progress')
|
||||
expect(progressBar.attributes('role')).toBe('progressbar')
|
||||
expect(progressBar.attributes('aria-label')).toBe('Loading')
|
||||
expect(progressBar.attributes('aria-valuemin')).toBe('0')
|
||||
expect(progressBar.attributes('aria-valuemax')).toBe('100')
|
||||
})
|
||||
|
||||
it('进度条应该有动画 class', () => {
|
||||
mockIsLoading.value = true
|
||||
const wrapper = mount(NavigationProgress)
|
||||
|
||||
const bar = wrapper.find('.navigation-progress-bar')
|
||||
expect(bar.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('应该正确响应 isLoading 状态变化', async () => {
|
||||
// 测试初始状态为 false
|
||||
mockIsLoading.value = false
|
||||
const wrapper = mount(NavigationProgress)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// 初始状态隐藏
|
||||
expect(wrapper.find('.navigation-progress').isVisible()).toBe(false)
|
||||
|
||||
// 卸载后重新挂载以测试 true 状态
|
||||
wrapper.unmount()
|
||||
|
||||
// 改变为 true 后重新挂载
|
||||
mockIsLoading.value = true
|
||||
const wrapper2 = mount(NavigationProgress)
|
||||
await wrapper2.vm.$nextTick()
|
||||
expect(wrapper2.find('.navigation-progress').isVisible()).toBe(true)
|
||||
|
||||
// 清理
|
||||
wrapper2.unmount()
|
||||
})
|
||||
})
|
||||
176
frontend/src/composables/__tests__/useNavigationLoading.spec.ts
Normal file
176
frontend/src/composables/__tests__/useNavigationLoading.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* useNavigationLoading 组合式函数单元测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
useNavigationLoading,
|
||||
_resetNavigationLoadingInstance
|
||||
} from '../useNavigationLoading'
|
||||
|
||||
describe('useNavigationLoading', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
_resetNavigationLoadingInstance()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('startNavigation', () => {
|
||||
it('导航开始时 isNavigating 应变为 true', () => {
|
||||
const { isNavigating, startNavigation } = useNavigationLoading()
|
||||
|
||||
expect(isNavigating.value).toBe(false)
|
||||
|
||||
startNavigation()
|
||||
|
||||
expect(isNavigating.value).toBe(true)
|
||||
})
|
||||
|
||||
it('导航开始后延迟显示加载指示器(防闪烁)', () => {
|
||||
const { isLoading, startNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
|
||||
// 立即检查,不应该显示
|
||||
expect(isLoading.value).toBe(false)
|
||||
|
||||
// 经过防闪烁延迟后应该显示
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('endNavigation', () => {
|
||||
it('导航结束时 isLoading 应变为 false', () => {
|
||||
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
endNavigation()
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('导航结束时 isNavigating 应变为 false', () => {
|
||||
const { isNavigating, startNavigation, endNavigation } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
expect(isNavigating.value).toBe(true)
|
||||
|
||||
endNavigation()
|
||||
expect(isNavigating.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('快速导航(< 100ms)防闪烁', () => {
|
||||
it('快速导航不应触发显示加载指示器', () => {
|
||||
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
|
||||
// 在防闪烁延迟之前结束导航
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY - 50)
|
||||
endNavigation()
|
||||
|
||||
// 不应该显示加载指示器
|
||||
expect(isLoading.value).toBe(false)
|
||||
|
||||
// 即使继续等待也不应该显示
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelNavigation', () => {
|
||||
it('导航取消时应正确重置状态', () => {
|
||||
const { isLoading, startNavigation, cancelNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY / 2)
|
||||
|
||||
cancelNavigation()
|
||||
|
||||
// 取消后不应该触发显示
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNavigationDuration', () => {
|
||||
it('应该返回正确的导航持续时间', () => {
|
||||
const { startNavigation, getNavigationDuration } = useNavigationLoading()
|
||||
|
||||
expect(getNavigationDuration()).toBeNull()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
const duration = getNavigationDuration()
|
||||
expect(duration).toBe(500)
|
||||
})
|
||||
|
||||
it('导航结束后应返回 null', () => {
|
||||
const { startNavigation, endNavigation, getNavigationDuration } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(500)
|
||||
endNavigation()
|
||||
|
||||
expect(getNavigationDuration()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetState', () => {
|
||||
it('应该重置所有状态', () => {
|
||||
const { isLoading, isNavigating, startNavigation, resetState, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
expect(isNavigating.value).toBe(true)
|
||||
|
||||
resetState()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(isNavigating.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('连续导航场景', () => {
|
||||
it('连续快速导航应正确处理状态', () => {
|
||||
const { isLoading, startNavigation, cancelNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
// 第一次导航
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(30)
|
||||
|
||||
// 第二次导航(取消第一次)
|
||||
cancelNavigation()
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(30)
|
||||
|
||||
// 第三次导航(取消第二次)
|
||||
cancelNavigation()
|
||||
startNavigation()
|
||||
|
||||
// 这次等待足够长时间
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
// 结束导航
|
||||
endNavigation()
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ANTI_FLICKER_DELAY 常量', () => {
|
||||
it('应该为 100ms', () => {
|
||||
const { ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
expect(ANTI_FLICKER_DELAY).toBe(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
244
frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
Normal file
244
frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* useRoutePrefetch 组合式函数单元测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { RouteLocationNormalized, Router, RouteRecordNormalized } from 'vue-router'
|
||||
|
||||
import { useRoutePrefetch, _adminPrefetchMap, _userPrefetchMap } from '../useRoutePrefetch'
|
||||
|
||||
// Mock 路由对象
|
||||
const createMockRoute = (path: string): RouteLocationNormalized => ({
|
||||
path,
|
||||
name: undefined,
|
||||
params: {},
|
||||
query: {},
|
||||
hash: '',
|
||||
fullPath: path,
|
||||
matched: [],
|
||||
meta: {},
|
||||
redirectedFrom: undefined
|
||||
})
|
||||
|
||||
// Mock Router
|
||||
const createMockRouter = (): Router => {
|
||||
const mockImportFn = vi.fn().mockResolvedValue({ default: {} })
|
||||
|
||||
const routes: Partial<RouteRecordNormalized>[] = [
|
||||
{ path: '/admin/dashboard', components: { default: mockImportFn } },
|
||||
{ path: '/admin/accounts', components: { default: mockImportFn } },
|
||||
{ path: '/admin/users', components: { default: mockImportFn } },
|
||||
{ path: '/admin/groups', components: { default: mockImportFn } },
|
||||
{ path: '/admin/subscriptions', components: { default: mockImportFn } },
|
||||
{ path: '/admin/redeem', components: { default: mockImportFn } },
|
||||
{ path: '/dashboard', components: { default: mockImportFn } },
|
||||
{ path: '/keys', components: { default: mockImportFn } },
|
||||
{ path: '/usage', components: { default: mockImportFn } },
|
||||
{ path: '/redeem', components: { default: mockImportFn } },
|
||||
{ path: '/profile', components: { default: mockImportFn } }
|
||||
]
|
||||
|
||||
return {
|
||||
getRoutes: () => routes as RouteRecordNormalized[]
|
||||
} as Router
|
||||
}
|
||||
|
||||
describe('useRoutePrefetch', () => {
|
||||
let originalRequestIdleCallback: typeof window.requestIdleCallback
|
||||
let originalCancelIdleCallback: typeof window.cancelIdleCallback
|
||||
let mockRouter: Router
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouter = createMockRouter()
|
||||
|
||||
// 保存原始函数
|
||||
originalRequestIdleCallback = window.requestIdleCallback
|
||||
originalCancelIdleCallback = window.cancelIdleCallback
|
||||
|
||||
// Mock requestIdleCallback 立即执行
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
|
||||
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// 恢复原始函数
|
||||
window.requestIdleCallback = originalRequestIdleCallback
|
||||
window.cancelIdleCallback = originalCancelIdleCallback
|
||||
})
|
||||
|
||||
describe('_isAdminRoute', () => {
|
||||
it('应该正确识别管理员路由', () => {
|
||||
const { _isAdminRoute } = useRoutePrefetch(mockRouter)
|
||||
expect(_isAdminRoute('/admin/dashboard')).toBe(true)
|
||||
expect(_isAdminRoute('/admin/users')).toBe(true)
|
||||
expect(_isAdminRoute('/admin/accounts')).toBe(true)
|
||||
})
|
||||
|
||||
it('应该正确识别非管理员路由', () => {
|
||||
const { _isAdminRoute } = useRoutePrefetch(mockRouter)
|
||||
expect(_isAdminRoute('/dashboard')).toBe(false)
|
||||
expect(_isAdminRoute('/keys')).toBe(false)
|
||||
expect(_isAdminRoute('/usage')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getPrefetchConfig', () => {
|
||||
it('管理员 dashboard 应该返回正确的预加载配置', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('普通用户 dashboard 应该返回正确的预加载配置', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/dashboard')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('未定义的路由应该返回空数组', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/unknown-route')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('triggerPrefetch', () => {
|
||||
it('应该在浏览器空闲时触发预加载', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
|
||||
// 等待 requestIdleCallback 执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(prefetchedRoutes.value.has('/admin/dashboard')).toBe(true)
|
||||
})
|
||||
|
||||
it('应该避免重复预加载同一路由', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 第二次触发
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 只应该预加载一次
|
||||
expect(prefetchedRoutes.value.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelPendingPrefetch', () => {
|
||||
it('应该取消挂起的预加载任务', () => {
|
||||
const { triggerPrefetch, cancelPendingPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
cancelPendingPrefetch()
|
||||
|
||||
// 不应该有预加载完成
|
||||
expect(prefetchedRoutes.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('路由变化时取消之前的预加载', () => {
|
||||
it('应该在路由变化时取消之前的预加载任务', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
|
||||
// 触发第一个路由的预加载
|
||||
triggerPrefetch(createMockRoute('/admin/dashboard'))
|
||||
|
||||
// 立即切换到另一个路由
|
||||
triggerPrefetch(createMockRoute('/admin/users'))
|
||||
|
||||
// 等待执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 只有最后一个路由应该被预加载
|
||||
expect(prefetchedRoutes.value.has('/admin/users')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetPrefetchState', () => {
|
||||
it('应该重置所有预加载状态', async () => {
|
||||
const { triggerPrefetch, resetPrefetchState, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(prefetchedRoutes.value.size).toBeGreaterThan(0)
|
||||
|
||||
resetPrefetchState()
|
||||
|
||||
expect(prefetchedRoutes.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('预加载映射表', () => {
|
||||
it('管理员预加载映射表应该包含正确的路由', () => {
|
||||
expect(_adminPrefetchMap).toHaveProperty('/admin/dashboard')
|
||||
expect(_adminPrefetchMap['/admin/dashboard']).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('用户预加载映射表应该包含正确的路由', () => {
|
||||
expect(_userPrefetchMap).toHaveProperty('/dashboard')
|
||||
expect(_userPrefetchMap['/dashboard']).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestIdleCallback 超时处理', () => {
|
||||
it('超时后仍能正常执行预加载', async () => {
|
||||
// 模拟超时情况
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback, options?: IdleRequestOptions) => {
|
||||
const timeout = options?.timeout || 2000
|
||||
return setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), timeout)
|
||||
})
|
||||
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
|
||||
// 等待超时执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100))
|
||||
|
||||
expect(prefetchedRoutes.value.has('/dashboard')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('预加载失败处理', () => {
|
||||
it('预加载失败时应该静默处理不影响页面功能', async () => {
|
||||
const { triggerPrefetch } = useRoutePrefetch(mockRouter)
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
// 不应该抛出异常
|
||||
expect(() => triggerPrefetch(route)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('无 router 时的行为', () => {
|
||||
it('没有传入 router 时应该正常工作但不执行预加载', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 没有 router,无法获取组件,所以不会预加载
|
||||
expect(prefetchedRoutes.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
132
frontend/src/composables/useNavigationLoading.ts
Normal file
132
frontend/src/composables/useNavigationLoading.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 导航加载状态组合式函数
|
||||
* 管理路由切换时的加载状态,支持防闪烁逻辑
|
||||
*/
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 导航加载状态管理
|
||||
*
|
||||
* 功能:
|
||||
* 1. 在路由切换时显示加载状态
|
||||
* 2. 快速导航(< 100ms)不显示加载指示器(防闪烁)
|
||||
* 3. 导航取消时正确重置状态
|
||||
*/
|
||||
export function useNavigationLoading() {
|
||||
// 内部加载状态
|
||||
const _isLoading = ref(false)
|
||||
|
||||
// 导航开始时间(用于防闪烁计算)
|
||||
let navigationStartTime: number | null = null
|
||||
|
||||
// 防闪烁延迟计时器
|
||||
let showLoadingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 是否应该显示加载指示器(考虑防闪烁逻辑)
|
||||
const shouldShowLoading = ref(false)
|
||||
|
||||
// 防闪烁延迟时间(毫秒)
|
||||
const ANTI_FLICKER_DELAY = 100
|
||||
|
||||
/**
|
||||
* 清理计时器
|
||||
*/
|
||||
const clearTimer = (): void => {
|
||||
if (showLoadingTimer !== null) {
|
||||
clearTimeout(showLoadingTimer)
|
||||
showLoadingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航开始时调用
|
||||
*/
|
||||
const startNavigation = (): void => {
|
||||
navigationStartTime = Date.now()
|
||||
_isLoading.value = true
|
||||
|
||||
// 延迟显示加载指示器,实现防闪烁
|
||||
clearTimer()
|
||||
showLoadingTimer = setTimeout(() => {
|
||||
if (_isLoading.value) {
|
||||
shouldShowLoading.value = true
|
||||
}
|
||||
}, ANTI_FLICKER_DELAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航结束时调用
|
||||
*/
|
||||
const endNavigation = (): void => {
|
||||
clearTimer()
|
||||
_isLoading.value = false
|
||||
shouldShowLoading.value = false
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航取消时调用(比如快速连续点击不同链接)
|
||||
*/
|
||||
const cancelNavigation = (): void => {
|
||||
clearTimer()
|
||||
// 保持加载状态,因为新的导航会立即开始
|
||||
// 但重置导航开始时间
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有状态(用于测试)
|
||||
*/
|
||||
const resetState = (): void => {
|
||||
clearTimer()
|
||||
_isLoading.value = false
|
||||
shouldShowLoading.value = false
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导航持续时间(毫秒)
|
||||
*/
|
||||
const getNavigationDuration = (): number | null => {
|
||||
if (navigationStartTime === null) {
|
||||
return null
|
||||
}
|
||||
return Date.now() - navigationStartTime
|
||||
}
|
||||
|
||||
// 公开的加载状态(只读)
|
||||
const isLoading = computed(() => shouldShowLoading.value)
|
||||
|
||||
// 内部加载状态(用于测试,不考虑防闪烁)
|
||||
const isNavigating = readonly(_isLoading)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isNavigating,
|
||||
startNavigation,
|
||||
endNavigation,
|
||||
cancelNavigation,
|
||||
resetState,
|
||||
getNavigationDuration,
|
||||
// 导出常量用于测试
|
||||
ANTI_FLICKER_DELAY
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例,供全局使用
|
||||
let navigationLoadingInstance: ReturnType<typeof useNavigationLoading> | null = null
|
||||
|
||||
export function useNavigationLoadingState() {
|
||||
if (!navigationLoadingInstance) {
|
||||
navigationLoadingInstance = useNavigationLoading()
|
||||
}
|
||||
return navigationLoadingInstance
|
||||
}
|
||||
|
||||
// 导出重置函数(用于测试)
|
||||
export function _resetNavigationLoadingInstance(): void {
|
||||
if (navigationLoadingInstance) {
|
||||
navigationLoadingInstance.resetState()
|
||||
}
|
||||
navigationLoadingInstance = null
|
||||
}
|
||||
202
frontend/src/composables/useRoutePrefetch.ts
Normal file
202
frontend/src/composables/useRoutePrefetch.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 路由预加载组合式函数
|
||||
* 在浏览器空闲时预加载可能访问的下一个页面,提升导航体验
|
||||
*
|
||||
* 优化说明:
|
||||
* - 不使用静态 import() 映射表,避免增加入口文件大小
|
||||
* - 通过路由配置动态获取组件的 import 函数
|
||||
* - 只在实际需要预加载时才执行
|
||||
*/
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { RouteLocationNormalized, Router } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 组件导入函数类型
|
||||
*/
|
||||
type ComponentImportFn = () => Promise<unknown>
|
||||
|
||||
/**
|
||||
* 预加载邻接表:定义每个路由应该预加载哪些相邻路由
|
||||
* 只存储路由路径,不存储 import 函数,避免打包问题
|
||||
*/
|
||||
const PREFETCH_ADJACENCY: Record<string, string[]> = {
|
||||
// Admin routes - 预加载最常访问的相邻页面
|
||||
'/admin/dashboard': ['/admin/accounts', '/admin/users'],
|
||||
'/admin/accounts': ['/admin/dashboard', '/admin/users'],
|
||||
'/admin/users': ['/admin/groups', '/admin/dashboard'],
|
||||
'/admin/groups': ['/admin/subscriptions', '/admin/users'],
|
||||
'/admin/subscriptions': ['/admin/groups', '/admin/redeem'],
|
||||
// User routes
|
||||
'/dashboard': ['/keys', '/usage'],
|
||||
'/keys': ['/dashboard', '/usage'],
|
||||
'/usage': ['/keys', '/redeem'],
|
||||
'/redeem': ['/usage', '/profile'],
|
||||
'/profile': ['/dashboard', '/keys']
|
||||
}
|
||||
|
||||
/**
|
||||
* requestIdleCallback 的返回类型
|
||||
*/
|
||||
type IdleCallbackHandle = number | ReturnType<typeof setTimeout>
|
||||
|
||||
/**
|
||||
* requestIdleCallback polyfill (Safari < 15)
|
||||
*/
|
||||
const scheduleIdleCallback = (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions
|
||||
): IdleCallbackHandle => {
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
return window.requestIdleCallback(callback, options)
|
||||
}
|
||||
return setTimeout(() => {
|
||||
callback({ didTimeout: false, timeRemaining: () => 50 })
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const cancelScheduledCallback = (handle: IdleCallbackHandle): void => {
|
||||
if (typeof window.cancelIdleCallback === 'function' && typeof handle === 'number') {
|
||||
window.cancelIdleCallback(handle)
|
||||
} else {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由预加载组合式函数
|
||||
*
|
||||
* @param router - Vue Router 实例,用于获取路由组件
|
||||
*/
|
||||
export function useRoutePrefetch(router?: Router) {
|
||||
// 当前挂起的预加载任务句柄
|
||||
const pendingPrefetchHandle = ref<IdleCallbackHandle | null>(null)
|
||||
|
||||
// 已预加载的路由集合
|
||||
const prefetchedRoutes = ref<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* 从路由配置中获取组件的 import 函数
|
||||
*/
|
||||
const getComponentImporter = (path: string): ComponentImportFn | null => {
|
||||
if (!router) return null
|
||||
|
||||
const routes = router.getRoutes()
|
||||
const route = routes.find((r) => r.path === path)
|
||||
|
||||
if (route && route.components?.default) {
|
||||
const component = route.components.default
|
||||
// 检查是否是懒加载组件(函数形式)
|
||||
if (typeof component === 'function') {
|
||||
return component as ComponentImportFn
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前路由应该预加载的路由路径列表
|
||||
*/
|
||||
const getPrefetchPaths = (route: RouteLocationNormalized): string[] => {
|
||||
return PREFETCH_ADJACENCY[route.path] || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个组件的预加载
|
||||
*/
|
||||
const prefetchComponent = async (importFn: ComponentImportFn): Promise<void> => {
|
||||
try {
|
||||
await importFn()
|
||||
} catch (error) {
|
||||
// 静默处理预加载错误
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[Prefetch] Failed to prefetch component:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消挂起的预加载任务
|
||||
*/
|
||||
const cancelPendingPrefetch = (): void => {
|
||||
if (pendingPrefetchHandle.value !== null) {
|
||||
cancelScheduledCallback(pendingPrefetchHandle.value)
|
||||
pendingPrefetchHandle.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发路由预加载
|
||||
*/
|
||||
const triggerPrefetch = (route: RouteLocationNormalized): void => {
|
||||
cancelPendingPrefetch()
|
||||
|
||||
const prefetchPaths = getPrefetchPaths(route)
|
||||
if (prefetchPaths.length === 0) return
|
||||
|
||||
pendingPrefetchHandle.value = scheduleIdleCallback(
|
||||
() => {
|
||||
pendingPrefetchHandle.value = null
|
||||
|
||||
const routePath = route.path
|
||||
if (prefetchedRoutes.value.has(routePath)) return
|
||||
|
||||
// 获取需要预加载的组件 import 函数
|
||||
const importFns: ComponentImportFn[] = []
|
||||
for (const path of prefetchPaths) {
|
||||
const importFn = getComponentImporter(path)
|
||||
if (importFn) {
|
||||
importFns.push(importFn)
|
||||
}
|
||||
}
|
||||
|
||||
if (importFns.length > 0) {
|
||||
Promise.all(importFns.map(prefetchComponent)).then(() => {
|
||||
prefetchedRoutes.value.add(routePath)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置预加载状态
|
||||
*/
|
||||
const resetPrefetchState = (): void => {
|
||||
cancelPendingPrefetch()
|
||||
prefetchedRoutes.value.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为管理员路由
|
||||
*/
|
||||
const isAdminRoute = (path: string): boolean => {
|
||||
return path.startsWith('/admin')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预加载配置(兼容旧 API)
|
||||
*/
|
||||
const getPrefetchConfig = (route: RouteLocationNormalized): ComponentImportFn[] => {
|
||||
const paths = getPrefetchPaths(route)
|
||||
const importFns: ComponentImportFn[] = []
|
||||
for (const path of paths) {
|
||||
const importFn = getComponentImporter(path)
|
||||
if (importFn) importFns.push(importFn)
|
||||
}
|
||||
return importFns
|
||||
}
|
||||
|
||||
return {
|
||||
prefetchedRoutes: readonly(prefetchedRoutes),
|
||||
triggerPrefetch,
|
||||
cancelPendingPrefetch,
|
||||
resetPrefetchState,
|
||||
_getPrefetchConfig: getPrefetchConfig,
|
||||
_isAdminRoute: isAdminRoute
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧测试的导出
|
||||
export const _adminPrefetchMap = PREFETCH_ADJACENCY
|
||||
export const _userPrefetchMap = PREFETCH_ADJACENCY
|
||||
@@ -163,6 +163,7 @@ export default {
|
||||
notAvailable: 'N/A',
|
||||
now: 'Now',
|
||||
unknown: 'Unknown',
|
||||
minutes: 'min',
|
||||
time: {
|
||||
never: 'Never',
|
||||
justNow: 'Just now',
|
||||
@@ -1082,7 +1083,7 @@ export default {
|
||||
platformType: 'Platform/Type',
|
||||
platform: 'Platform',
|
||||
type: 'Type',
|
||||
concurrencyStatus: 'Concurrency',
|
||||
capacity: 'Capacity',
|
||||
notes: 'Notes',
|
||||
priority: 'Priority',
|
||||
billingRateMultiplier: 'Billing Rate',
|
||||
@@ -1096,6 +1097,18 @@ export default {
|
||||
expiresAt: 'Expires At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
// Capacity status tooltips
|
||||
capacity: {
|
||||
windowCost: {
|
||||
blocked: '5h window cost exceeded, account scheduling paused',
|
||||
stickyOnly: '5h window cost at threshold, only sticky sessions allowed',
|
||||
normal: '5h window cost normal'
|
||||
},
|
||||
sessions: {
|
||||
full: 'Active sessions full, new sessions must wait (idle timeout: {idle} min)',
|
||||
normal: 'Active sessions normal (idle timeout: {idle} min)'
|
||||
}
|
||||
},
|
||||
tempUnschedulable: {
|
||||
title: 'Temp Unschedulable',
|
||||
statusTitle: 'Temp Unschedulable Status',
|
||||
@@ -1247,6 +1260,31 @@ export default {
|
||||
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
|
||||
autoPauseOnExpired: 'Auto Pause On Expired',
|
||||
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
|
||||
// Quota control (Anthropic OAuth/SetupToken only)
|
||||
quotaControl: {
|
||||
title: 'Quota Control',
|
||||
hint: 'Only applies to Anthropic OAuth/Setup Token accounts',
|
||||
windowCost: {
|
||||
label: '5h Window Cost Limit',
|
||||
hint: 'Limit account cost usage within the 5-hour window',
|
||||
limit: 'Cost Threshold',
|
||||
limitPlaceholder: '50',
|
||||
limitHint: 'Account will not participate in new scheduling after reaching threshold',
|
||||
stickyReserve: 'Sticky Reserve',
|
||||
stickyReservePlaceholder: '10',
|
||||
stickyReserveHint: 'Additional reserve for sticky sessions'
|
||||
},
|
||||
sessionLimit: {
|
||||
label: 'Session Count Limit',
|
||||
hint: 'Limit the number of active concurrent sessions',
|
||||
maxSessions: 'Max Sessions',
|
||||
maxSessionsPlaceholder: '3',
|
||||
maxSessionsHint: 'Maximum number of active concurrent sessions',
|
||||
idleTimeout: 'Idle Timeout',
|
||||
idleTimeoutPlaceholder: '5',
|
||||
idleTimeoutHint: 'Sessions will be released after idle timeout'
|
||||
}
|
||||
},
|
||||
expired: 'Expired',
|
||||
proxy: 'Proxy',
|
||||
noProxy: 'No Proxy',
|
||||
|
||||
@@ -160,6 +160,7 @@ export default {
|
||||
notAvailable: '不可用',
|
||||
now: '现在',
|
||||
unknown: '未知',
|
||||
minutes: '分钟',
|
||||
time: {
|
||||
never: '从未',
|
||||
justNow: '刚刚',
|
||||
@@ -1131,7 +1132,7 @@ export default {
|
||||
platformType: '平台/类型',
|
||||
platform: '平台',
|
||||
type: '类型',
|
||||
concurrencyStatus: '并发',
|
||||
capacity: '容量',
|
||||
notes: '备注',
|
||||
priority: '优先级',
|
||||
billingRateMultiplier: '账号倍率',
|
||||
@@ -1145,6 +1146,18 @@ export default {
|
||||
expiresAt: '过期时间',
|
||||
actions: '操作'
|
||||
},
|
||||
// 容量状态提示
|
||||
capacity: {
|
||||
windowCost: {
|
||||
blocked: '5h窗口费用超限,账号暂停调度',
|
||||
stickyOnly: '5h窗口费用达阈值,仅允许粘性会话',
|
||||
normal: '5h窗口费用正常'
|
||||
},
|
||||
sessions: {
|
||||
full: '活跃会话已满,新会话需等待(空闲超时:{idle}分钟)',
|
||||
normal: '活跃会话正常(空闲超时:{idle}分钟)'
|
||||
}
|
||||
},
|
||||
clearRateLimit: '清除速率限制',
|
||||
testConnection: '测试连接',
|
||||
reAuthorize: '重新授权',
|
||||
@@ -1380,6 +1393,31 @@ export default {
|
||||
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
|
||||
autoPauseOnExpired: '过期自动暂停调度',
|
||||
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
|
||||
// Quota control (Anthropic OAuth/SetupToken only)
|
||||
quotaControl: {
|
||||
title: '配额控制',
|
||||
hint: '仅适用于 Anthropic OAuth/Setup Token 账号',
|
||||
windowCost: {
|
||||
label: '5h窗口费用控制',
|
||||
hint: '限制账号在5小时窗口内的费用使用',
|
||||
limit: '费用阈值',
|
||||
limitPlaceholder: '50',
|
||||
limitHint: '达到阈值后不参与新请求调度',
|
||||
stickyReserve: '粘性预留额度',
|
||||
stickyReservePlaceholder: '10',
|
||||
stickyReserveHint: '为粘性会话预留的额外额度'
|
||||
},
|
||||
sessionLimit: {
|
||||
label: '会话数量控制',
|
||||
hint: '限制同时活跃的会话数量',
|
||||
maxSessions: '最大会话数',
|
||||
maxSessionsPlaceholder: '3',
|
||||
maxSessionsHint: '同时活跃的最大会话数量',
|
||||
idleTimeout: '空闲超时',
|
||||
idleTimeoutPlaceholder: '5',
|
||||
idleTimeoutHint: '会话空闲超时后自动释放'
|
||||
}
|
||||
},
|
||||
expired: '已过期',
|
||||
proxy: '代理',
|
||||
noProxy: '无代理',
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
|
||||
/**
|
||||
* Route definitions with lazy loading
|
||||
@@ -326,7 +328,15 @@ const router = createRouter({
|
||||
*/
|
||||
let authInitialized = false
|
||||
|
||||
// 初始化导航加载状态和预加载
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
// 延迟初始化预加载,传入 router 实例
|
||||
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
// 开始导航加载状态
|
||||
navigationLoading.startNavigation()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Restore auth state from localStorage on first navigation (page refresh)
|
||||
@@ -398,6 +408,21 @@ router.beforeEach((to, _from, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigation guard: End loading and trigger prefetch
|
||||
*/
|
||||
router.afterEach((to) => {
|
||||
// 结束导航加载状态
|
||||
navigationLoading.endNavigation()
|
||||
|
||||
// 懒初始化预加载(首次导航时创建,传入 router 实例)
|
||||
if (!routePrefetch) {
|
||||
routePrefetch = useRoutePrefetch(router)
|
||||
}
|
||||
// 触发路由预加载(在浏览器空闲时执行)
|
||||
routePrefetch.triggerPrefetch(to)
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigation guard: Error handling
|
||||
* Handles dynamic import failures caused by deployment updates
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -11,7 +9,6 @@
|
||||
|
||||
html {
|
||||
@apply scroll-smooth antialiased;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -471,6 +471,18 @@ export interface Account {
|
||||
session_window_start: string | null
|
||||
session_window_end: string | null
|
||||
session_window_status: 'allowed' | 'allowed_warning' | 'rejected' | null
|
||||
|
||||
// 5h窗口费用控制(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
window_cost_limit?: number | null
|
||||
window_cost_sticky_reserve?: number | null
|
||||
|
||||
// 会话数量控制(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
max_sessions?: number | null
|
||||
session_idle_timeout_minutes?: number | null
|
||||
|
||||
// 运行时状态(仅当启用对应限制时返回)
|
||||
current_window_cost?: number | null // 当前窗口费用
|
||||
active_sessions?: number | null // 当前活跃会话数
|
||||
}
|
||||
|
||||
// Account Usage types
|
||||
|
||||
@@ -34,15 +34,8 @@
|
||||
<template #cell-platform_type="{ row }">
|
||||
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
|
||||
</template>
|
||||
<template #cell-concurrency="{ row }">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span :class="['inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium', (row.current_concurrency || 0) >= row.concurrency ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : (row.current_concurrency || 0) > 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400']">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /></svg>
|
||||
<span class="font-mono">{{ row.current_concurrency || 0 }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ row.concurrency }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<template #cell-capacity="{ row }">
|
||||
<AccountCapacityCell :account="row" />
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
|
||||
@@ -148,6 +141,7 @@ import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.
|
||||
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
|
||||
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
@@ -187,7 +181,7 @@ const cols = computed(() => {
|
||||
{ key: 'select', label: '', sortable: false },
|
||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
|
||||
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
|
||||
{ key: 'capacity', label: t('admin.accounts.columns.capacity'), sortable: false },
|
||||
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
||||
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
||||
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false }
|
||||
|
||||
@@ -50,16 +50,19 @@ export default {
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Helvetica Neue',
|
||||
'Arial',
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
'sans-serif'
|
||||
],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', 'monospace']
|
||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace']
|
||||
},
|
||||
boxShadow: {
|
||||
glass: '0 8px 32px rgba(0, 0, 0, 0.08)',
|
||||
|
||||
@@ -58,7 +58,49 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/internal/web/dist',
|
||||
emptyOutDir: true
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
/**
|
||||
* 手动分包配置
|
||||
* 分离第三方库并按功能合并应用代码,避免循环依赖
|
||||
*/
|
||||
manualChunks(id: string) {
|
||||
if (id.includes('node_modules')) {
|
||||
// Vue 核心库
|
||||
if (
|
||||
id.includes('/vue/') ||
|
||||
id.includes('/vue-router/') ||
|
||||
id.includes('/pinia/') ||
|
||||
id.includes('/@vue/')
|
||||
) {
|
||||
return 'vendor-vue'
|
||||
}
|
||||
|
||||
// UI 工具库(较大,单独分离)
|
||||
if (id.includes('/@vueuse/') || id.includes('/xlsx/')) {
|
||||
return 'vendor-ui'
|
||||
}
|
||||
|
||||
// 图表库
|
||||
if (id.includes('/chart.js/') || id.includes('/vue-chartjs/')) {
|
||||
return 'vendor-chart'
|
||||
}
|
||||
|
||||
// 国际化
|
||||
if (id.includes('/vue-i18n/') || id.includes('/@intlify/')) {
|
||||
return 'vendor-i18n'
|
||||
}
|
||||
|
||||
// 其他小型第三方库合并
|
||||
return 'vendor-misc'
|
||||
}
|
||||
|
||||
// 应用代码:按入口点自动分包,不手动干预
|
||||
// 这样可以避免循环依赖,同时保持合理的 chunk 数量
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
|
||||
35
frontend/vitest.config.ts
Normal file
35
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.{js,ts,vue}'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'src/**/*.d.ts',
|
||||
'src/**/*.spec.ts',
|
||||
'src/**/*.test.ts',
|
||||
'src/main.ts'
|
||||
],
|
||||
thresholds: {
|
||||
global: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80
|
||||
}
|
||||
}
|
||||
},
|
||||
setupFiles: ['./src/__tests__/setup.ts']
|
||||
}
|
||||
})
|
||||
)
|
||||
Reference in New Issue
Block a user