fix(usage): 分离 API 响应和窗口统计缓存,修复 5h 窗口未激活时的显示 bug

问题:
1. WindowStats 与 API 响应一起缓存 10 分钟,导致费用数据更新延迟
2. 当 5h 窗口未激活(ResetsAt 为空)时,FiveHour 为 nil,导致所有窗口的 WindowStats 都无法显示

修复:
- 分离缓存:API 响应缓存 10 分钟,窗口统计独立缓存 1 分钟
- RemainingSeconds 每次请求时实时计算
- FiveHour 对象始终创建(即使 ResetsAt 为空)
- addWindowStats 增强防护,支持 FiveHour 为 nil 时仍处理其他窗口
This commit is contained in:
shaw
2025-12-28 23:12:44 +08:00
parent 25b8a22648
commit 30b95cf5ce

View File

@@ -54,15 +54,23 @@ type UsageLogRepository interface {
GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
} }
// usageCache 用于缓存usage数据 // apiUsageCache 缓存从 Anthropic API 获取的使用率数据utilization, resets_at
type usageCache struct { type apiUsageCache struct {
data *UsageInfo response *ClaudeUsageResponse
timestamp time.Time
}
// windowStatsCache 缓存从本地数据库查询的窗口统计requests, tokens, cost
type windowStatsCache struct {
stats *WindowStats
timestamp time.Time timestamp time.Time
} }
var ( var (
usageCacheMap = sync.Map{} apiCacheMap = sync.Map{} // 缓存 API 响应
cacheTTL = 10 * time.Minute windowStatsCacheMap = sync.Map{} // 缓存窗口统计
apiCacheTTL = 10 * time.Minute
windowStatsCacheTTL = 1 * time.Minute
) )
// WindowStats 窗口期统计 // WindowStats 窗口期统计
@@ -126,7 +134,7 @@ func NewAccountUsageService(accountRepo AccountRepository, usageLogRepo UsageLog
} }
// GetUsage 获取账号使用量 // GetUsage 获取账号使用量
// OAuth账号: 调用Anthropic API获取真实数据需要profile scope缓存10分钟 // OAuth账号: 调用Anthropic API获取真实数据需要profile scopeAPI响应缓存10分钟窗口统计缓存1分钟
// Setup Token账号: 根据session_window推算5h窗口7d数据不可用没有profile scope // Setup Token账号: 根据session_window推算5h窗口7d数据不可用没有profile scope
// API Key账号: 不支持usage查询 // API Key账号: 不支持usage查询
func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*UsageInfo, error) { func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*UsageInfo, error) {
@@ -137,30 +145,34 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
// 只有oauth类型账号可以通过API获取usage有profile scope // 只有oauth类型账号可以通过API获取usage有profile scope
if account.CanGetUsage() { if account.CanGetUsage() {
// 检查缓存 var apiResp *ClaudeUsageResponse
if cached, ok := usageCacheMap.Load(accountID); ok {
cache, ok := cached.(*usageCache) // 1. 检查 API 缓存10 分钟)
if !ok { if cached, ok := apiCacheMap.Load(accountID); ok {
usageCacheMap.Delete(accountID) if cache, ok := cached.(*apiUsageCache); ok && time.Since(cache.timestamp) < apiCacheTTL {
} else if time.Since(cache.timestamp) < cacheTTL { apiResp = cache.response
return cache.data, nil
} }
} }
// API获取数据 // 2. 如果没有缓存,从 API 获取
usage, err := s.fetchOAuthUsage(ctx, account) if apiResp == nil {
if err != nil { apiResp, err = s.fetchOAuthUsageRaw(ctx, account)
return nil, err if err != nil {
return nil, err
}
// 缓存 API 响应
apiCacheMap.Store(accountID, &apiUsageCache{
response: apiResp,
timestamp: time.Now(),
})
} }
// 添加5h窗口统计数据 // 3. 构建 UsageInfo每次都重新计算 RemainingSeconds
s.addWindowStats(ctx, account, usage) now := time.Now()
usage := s.buildUsageInfo(apiResp, &now)
// 缓存结果 // 4. 添加窗口统计有独立缓存1 分钟)
usageCacheMap.Store(accountID, &usageCache{ s.addWindowStats(ctx, account, usage)
data: usage,
timestamp: time.Now(),
})
return usage, nil return usage, nil
} }
@@ -177,31 +189,54 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
return nil, fmt.Errorf("account type %s does not support usage query", account.Type) return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
} }
// addWindowStats 为usage数据添加窗口期统计 // addWindowStats 为 usage 数据添加窗口期统计
// 使用独立缓存1 分钟),与 API 缓存分离
func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) { func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) {
if usage.FiveHour == nil { // 修复:即使 FiveHour 为 nil也要尝试获取统计数据
// 因为 SevenDay/SevenDaySonnet 可能需要
if usage.FiveHour == nil && usage.SevenDay == nil && usage.SevenDaySonnet == nil {
return return
} }
// 使用session_window_start作为统计起始时间 // 检查窗口统计缓存1 分钟)
var startTime time.Time var windowStats *WindowStats
if account.SessionWindowStart != nil { if cached, ok := windowStatsCacheMap.Load(account.ID); ok {
startTime = *account.SessionWindowStart if cache, ok := cached.(*windowStatsCache); ok && time.Since(cache.timestamp) < windowStatsCacheTTL {
} else { windowStats = cache.stats
// 如果没有窗口信息使用5小时前作为默认 }
startTime = time.Now().Add(-5 * time.Hour)
} }
stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime) // 如果没有缓存,从数据库查询
if err != nil { if windowStats == nil {
log.Printf("Failed to get window stats for account %d: %v", account.ID, err) var startTime time.Time
return 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 {
log.Printf("Failed to get window stats for account %d: %v", account.ID, err)
return
}
windowStats = &WindowStats{
Requests: stats.Requests,
Tokens: stats.Tokens,
Cost: stats.Cost,
}
// 缓存窗口统计1 分钟)
windowStatsCacheMap.Store(account.ID, &windowStatsCache{
stats: windowStats,
timestamp: time.Now(),
})
} }
usage.FiveHour.WindowStats = &WindowStats{ // 为 FiveHour 添加 WindowStats5h 窗口统计)
Requests: stats.Requests, if usage.FiveHour != nil {
Tokens: stats.Tokens, usage.FiveHour.WindowStats = windowStats
Cost: stats.Cost,
} }
} }
@@ -227,8 +262,8 @@ func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountI
return stats, nil return stats, nil
} }
// fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量 // fetchOAuthUsageRaw Anthropic API 获取原始响应(不构建 UsageInfo
func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Account) (*UsageInfo, error) { func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *Account) (*ClaudeUsageResponse, error) {
accessToken := account.GetCredential("access_token") accessToken := account.GetCredential("access_token")
if accessToken == "" { if accessToken == "" {
return nil, fmt.Errorf("no access token available") return nil, fmt.Errorf("no access token available")
@@ -239,13 +274,7 @@ func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Acco
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
} }
usageResp, err := s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL) return s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL)
if err != nil {
return nil, err
}
now := time.Now()
return s.buildUsageInfo(usageResp, &now), nil
} }
// parseTime 尝试多种格式解析时间 // parseTime 尝试多种格式解析时间
@@ -270,20 +299,16 @@ func (s *AccountUsageService) buildUsageInfo(resp *ClaudeUsageResponse, updatedA
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
} }
// 5小时窗口 // 5小时窗口 - 始终创建对象(即使 ResetsAt 为空)
info.FiveHour = &UsageProgress{
Utilization: resp.FiveHour.Utilization,
}
if resp.FiveHour.ResetsAt != "" { if resp.FiveHour.ResetsAt != "" {
if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil { if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil {
info.FiveHour = &UsageProgress{ info.FiveHour.ResetsAt = &fiveHourReset
Utilization: resp.FiveHour.Utilization, info.FiveHour.RemainingSeconds = int(time.Until(fiveHourReset).Seconds())
ResetsAt: &fiveHourReset,
RemainingSeconds: int(time.Until(fiveHourReset).Seconds()),
}
} else { } else {
log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err) log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err)
// 即使解析失败也返回utilization
info.FiveHour = &UsageProgress{
Utilization: resp.FiveHour.Utilization,
}
} }
} }