@@ -177,6 +177,7 @@ type AICredit struct {
177177
178178// UsageInfo 账号使用量信息
179179type UsageInfo struct {
180+ Source string `json:"source,omitempty"` // "passive" or "active"
180181 UpdatedAt * time.Time `json:"updated_at,omitempty"` // 更新时间
181182 FiveHour * UsageProgress `json:"five_hour"` // 5小时窗口
182183 SevenDay * UsageProgress `json:"seven_day,omitempty"` // 7天窗口
@@ -393,6 +394,9 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
393394 // 4. 添加窗口统计(有独立缓存,1 分钟)
394395 s .addWindowStats (ctx , account , usage )
395396
397+ // 5. 将主动查询结果同步到被动缓存,下次 passive 加载即为最新值
398+ s .syncActiveToPassive (ctx , account .ID , usage )
399+
396400 s .tryClearRecoverableAccountError (ctx , account )
397401 return usage , nil
398402 }
@@ -409,6 +413,81 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
409413 return nil , fmt .Errorf ("account type %s does not support usage query" , account .Type )
410414}
411415
416+ // GetPassiveUsage 从 Account.Extra 中的被动采样数据构建 UsageInfo,不调用外部 API。
417+ // 仅适用于 Anthropic OAuth / SetupToken 账号。
418+ func (s * AccountUsageService ) GetPassiveUsage (ctx context.Context , accountID int64 ) (* UsageInfo , error ) {
419+ account , err := s .accountRepo .GetByID (ctx , accountID )
420+ if err != nil {
421+ return nil , fmt .Errorf ("get account failed: %w" , err )
422+ }
423+
424+ if ! account .IsAnthropicOAuthOrSetupToken () {
425+ return nil , fmt .Errorf ("passive usage only supported for Anthropic OAuth/SetupToken accounts" )
426+ }
427+
428+ // 复用 estimateSetupTokenUsage 构建 5h 窗口(OAuth 和 SetupToken 逻辑一致)
429+ info := s .estimateSetupTokenUsage (account )
430+ info .Source = "passive"
431+
432+ // 设置采样时间
433+ if raw , ok := account .Extra ["passive_usage_sampled_at" ]; ok {
434+ if str , ok := raw .(string ); ok {
435+ if t , err := time .Parse (time .RFC3339 , str ); err == nil {
436+ info .UpdatedAt = & t
437+ }
438+ }
439+ }
440+
441+ // 构建 7d 窗口(从被动采样数据)
442+ util7d := parseExtraFloat64 (account .Extra ["passive_usage_7d_utilization" ])
443+ reset7dRaw := parseExtraFloat64 (account .Extra ["passive_usage_7d_reset" ])
444+ if util7d > 0 || reset7dRaw > 0 {
445+ var resetAt * time.Time
446+ var remaining int
447+ if reset7dRaw > 0 {
448+ t := time .Unix (int64 (reset7dRaw ), 0 )
449+ resetAt = & t
450+ remaining = int (time .Until (t ).Seconds ())
451+ if remaining < 0 {
452+ remaining = 0
453+ }
454+ }
455+ info .SevenDay = & UsageProgress {
456+ Utilization : util7d * 100 ,
457+ ResetsAt : resetAt ,
458+ RemainingSeconds : remaining ,
459+ }
460+ }
461+
462+ // 添加窗口统计
463+ s .addWindowStats (ctx , account , info )
464+
465+ return info , nil
466+ }
467+
468+ // syncActiveToPassive 将主动查询的最新数据回写到 Extra 被动缓存,
469+ // 这样下次被动加载时能看到最新值。
470+ func (s * AccountUsageService ) syncActiveToPassive (ctx context.Context , accountID int64 , usage * UsageInfo ) {
471+ extraUpdates := make (map [string ]any , 4 )
472+
473+ if usage .FiveHour != nil {
474+ extraUpdates ["session_window_utilization" ] = usage .FiveHour .Utilization / 100
475+ }
476+ if usage .SevenDay != nil {
477+ extraUpdates ["passive_usage_7d_utilization" ] = usage .SevenDay .Utilization / 100
478+ if usage .SevenDay .ResetsAt != nil {
479+ extraUpdates ["passive_usage_7d_reset" ] = usage .SevenDay .ResetsAt .Unix ()
480+ }
481+ }
482+
483+ if len (extraUpdates ) > 0 {
484+ extraUpdates ["passive_usage_sampled_at" ] = time .Now ().UTC ().Format (time .RFC3339 )
485+ if err := s .accountRepo .UpdateExtra (ctx , accountID , extraUpdates ); err != nil {
486+ slog .Warn ("sync_active_to_passive_failed" , "account_id" , accountID , "error" , err )
487+ }
488+ }
489+ }
490+
412491func (s * AccountUsageService ) getOpenAIUsage (ctx context.Context , account * Account ) (* UsageInfo , error ) {
413492 now := time .Now ()
414493 usage := & UsageInfo {UpdatedAt : & now }
0 commit comments