From 30b95cf5ce52f07331ad402ba5ce24be49b65795 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Dec 2025 23:12:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(usage):=20=E5=88=86=E7=A6=BB=20API=20?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=92=8C=E7=AA=97=E5=8F=A3=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E7=BC=93=E5=AD=98=EF=BC=8C=E4=BF=AE=E5=A4=8D=205h=20=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E6=9C=AA=E6=BF=80=E6=B4=BB=E6=97=B6=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 1. WindowStats 与 API 响应一起缓存 10 分钟,导致费用数据更新延迟 2. 当 5h 窗口未激活(ResetsAt 为空)时,FiveHour 为 nil,导致所有窗口的 WindowStats 都无法显示 修复: - 分离缓存:API 响应缓存 10 分钟,窗口统计独立缓存 1 分钟 - RemainingSeconds 每次请求时实时计算 - FiveHour 对象始终创建(即使 ResetsAt 为空) - addWindowStats 增强防护,支持 FiveHour 为 nil 时仍处理其他窗口 --- .../internal/service/account_usage_service.go | 145 ++++++++++-------- 1 file changed, 85 insertions(+), 60 deletions(-) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 575e72b1..94d4c747 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -54,15 +54,23 @@ type UsageLogRepository interface { GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) } -// usageCache 用于缓存usage数据 -type usageCache struct { - data *UsageInfo +// apiUsageCache 缓存从 Anthropic API 获取的使用率数据(utilization, resets_at) +type apiUsageCache struct { + response *ClaudeUsageResponse + timestamp time.Time +} + +// windowStatsCache 缓存从本地数据库查询的窗口统计(requests, tokens, cost) +type windowStatsCache struct { + stats *WindowStats timestamp time.Time } var ( - usageCacheMap = sync.Map{} - cacheTTL = 10 * time.Minute + apiCacheMap = sync.Map{} // 缓存 API 响应 + windowStatsCacheMap = sync.Map{} // 缓存窗口统计 + apiCacheTTL = 10 * time.Minute + windowStatsCacheTTL = 1 * time.Minute ) // WindowStats 窗口期统计 @@ -126,7 +134,7 @@ func NewAccountUsageService(accountRepo AccountRepository, usageLogRepo UsageLog } // GetUsage 获取账号使用量 -// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),缓存10分钟 +// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),API响应缓存10分钟,窗口统计缓存1分钟 // Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope) // API Key账号: 不支持usage查询 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) if account.CanGetUsage() { - // 检查缓存 - if cached, ok := usageCacheMap.Load(accountID); ok { - cache, ok := cached.(*usageCache) - if !ok { - usageCacheMap.Delete(accountID) - } else if time.Since(cache.timestamp) < cacheTTL { - return cache.data, nil + var apiResp *ClaudeUsageResponse + + // 1. 检查 API 缓存(10 分钟) + if cached, ok := apiCacheMap.Load(accountID); ok { + if cache, ok := cached.(*apiUsageCache); ok && time.Since(cache.timestamp) < apiCacheTTL { + apiResp = cache.response } } - // 从API获取数据 - usage, err := s.fetchOAuthUsage(ctx, account) - if err != nil { - return nil, err + // 2. 如果没有缓存,从 API 获取 + if apiResp == nil { + apiResp, err = s.fetchOAuthUsageRaw(ctx, account) + if err != nil { + return nil, err + } + // 缓存 API 响应 + apiCacheMap.Store(accountID, &apiUsageCache{ + response: apiResp, + timestamp: time.Now(), + }) } - // 添加5h窗口统计数据 - s.addWindowStats(ctx, account, usage) + // 3. 构建 UsageInfo(每次都重新计算 RemainingSeconds) + now := time.Now() + usage := s.buildUsageInfo(apiResp, &now) - // 缓存结果 - usageCacheMap.Store(accountID, &usageCache{ - data: usage, - timestamp: time.Now(), - }) + // 4. 添加窗口统计(有独立缓存,1 分钟) + s.addWindowStats(ctx, account, usage) 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) } -// addWindowStats 为usage数据添加窗口期统计 +// addWindowStats 为 usage 数据添加窗口期统计 +// 使用独立缓存(1 分钟),与 API 缓存分离 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 } - // 使用session_window_start作为统计起始时间 - var startTime time.Time - if account.SessionWindowStart != nil { - startTime = *account.SessionWindowStart - } else { - // 如果没有窗口信息,使用5小时前作为默认 - startTime = time.Now().Add(-5 * time.Hour) + // 检查窗口统计缓存(1 分钟) + var windowStats *WindowStats + if cached, ok := windowStatsCacheMap.Load(account.ID); ok { + if cache, ok := cached.(*windowStatsCache); ok && time.Since(cache.timestamp) < windowStatsCacheTTL { + windowStats = cache.stats + } } - 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 + // 如果没有缓存,从数据库查询 + if windowStats == nil { + 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 { + 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{ - Requests: stats.Requests, - Tokens: stats.Tokens, - Cost: stats.Cost, + // 为 FiveHour 添加 WindowStats(5h 窗口统计) + if usage.FiveHour != nil { + usage.FiveHour.WindowStats = windowStats } } @@ -227,8 +262,8 @@ func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountI return stats, nil } -// fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量 -func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Account) (*UsageInfo, error) { +// fetchOAuthUsageRaw 从 Anthropic API 获取原始响应(不构建 UsageInfo) +func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *Account) (*ClaudeUsageResponse, error) { accessToken := account.GetCredential("access_token") if accessToken == "" { 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() } - usageResp, err := s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL) - if err != nil { - return nil, err - } - - now := time.Now() - return s.buildUsageInfo(usageResp, &now), nil + return s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL) } // parseTime 尝试多种格式解析时间 @@ -270,20 +299,16 @@ func (s *AccountUsageService) buildUsageInfo(resp *ClaudeUsageResponse, updatedA UpdatedAt: updatedAt, } - // 5小时窗口 + // 5小时窗口 - 始终创建对象(即使 ResetsAt 为空) + info.FiveHour = &UsageProgress{ + Utilization: resp.FiveHour.Utilization, + } if resp.FiveHour.ResetsAt != "" { if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil { - info.FiveHour = &UsageProgress{ - Utilization: resp.FiveHour.Utilization, - ResetsAt: &fiveHourReset, - RemainingSeconds: int(time.Until(fiveHourReset).Seconds()), - } + info.FiveHour.ResetsAt = &fiveHourReset + info.FiveHour.RemainingSeconds = int(time.Until(fiveHourReset).Seconds()) } else { log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err) - // 即使解析失败也返回utilization - info.FiveHour = &UsageProgress{ - Utilization: resp.FiveHour.Utilization, - } } }