From 525cdb883096a74e88439bf7d654914146a84c66 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 19 Mar 2026 17:29:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Anthropic=20=E8=B4=A6=E5=8F=B7=E8=A2=AB?= =?UTF-8?q?=E5=8A=A8=E7=94=A8=E9=87=8F=E9=87=87=E6=A0=B7=EF=BC=8C=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E9=BB=98=E8=AE=A4=E5=B1=95=E7=A4=BA=E8=A2=AB=E5=8A=A8?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从上游 /v1/messages 响应头被动采集 5h/7d utilization 并存储到 Account.Extra,页面加载时直接读取本地数据而非调用外部 Usage API。 用户可点击"查询"按钮主动拉取最新数据,主动查询结果自动回写被动缓存。 后端: - UpdateSessionWindow 合并采集 5h + 7d headers 为单次 DB 写入 - 新增 GetPassiveUsage 从 Extra 构建 UsageInfo (复用 estimateSetupTokenUsage) - GetUsage 主动查询后 syncActiveToPassive 回写被动缓存 - passive_usage_ 前缀注册为 scheduler-neutral 前端: - Anthropic 账号 mount/refresh 默认 source=passive - 新增"被动采样"标签和"查询"按钮 (带 loading 动画) --- .../internal/handler/admin/account_handler.go | 11 ++- backend/internal/repository/account_repo.go | 1 + .../internal/service/account_usage_service.go | 79 +++++++++++++++++++ backend/internal/service/ratelimit_service.go | 36 +++++++-- frontend/src/api/admin/accounts.ts | 6 +- .../components/account/AccountUsageCell.vue | 58 +++++++++++++- frontend/src/i18n/locales/en.ts | 4 +- frontend/src/i18n/locales/zh.ts | 4 +- frontend/src/types/index.ts | 1 + 9 files changed, 183 insertions(+), 17 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 3ef213e1..8dedcefd 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1496,7 +1496,7 @@ func (h *OAuthHandler) SetupTokenCookieAuth(c *gin.Context) { } // GetUsage handles getting account usage information -// GET /api/v1/admin/accounts/:id/usage +// GET /api/v1/admin/accounts/:id/usage?source=passive|active func (h *AccountHandler) GetUsage(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -1504,7 +1504,14 @@ func (h *AccountHandler) GetUsage(c *gin.Context) { return } - usage, err := h.accountUsageService.GetUsage(c.Request.Context(), accountID) + source := c.DefaultQuery("source", "active") + + var usage *service.UsageInfo + if source == "passive" { + usage, err = h.accountUsageService.GetPassiveUsage(c.Request.Context(), accountID) + } else { + usage, err = h.accountUsageService.GetUsage(c.Request.Context(), accountID) + } if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 20ff7373..7802cce0 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{ "codex_secondary_", "codex_5h_", "codex_7d_", + "passive_usage_", } var schedulerNeutralExtraKeys = map[string]struct{}{ diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 74142700..2761d9c8 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -177,6 +177,7 @@ type AICredit struct { // UsageInfo 账号使用量信息 type UsageInfo struct { + Source string `json:"source,omitempty"` // "passive" or "active" UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间 FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口 SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口 @@ -393,6 +394,9 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U // 4. 添加窗口统计(有独立缓存,1 分钟) s.addWindowStats(ctx, account, usage) + // 5. 将主动查询结果同步到被动缓存,下次 passive 加载即为最新值 + s.syncActiveToPassive(ctx, account.ID, usage) + s.tryClearRecoverableAccountError(ctx, account) return usage, nil } @@ -409,6 +413,81 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U return nil, fmt.Errorf("account type %s does not support usage query", account.Type) } +// GetPassiveUsage 从 Account.Extra 中的被动采样数据构建 UsageInfo,不调用外部 API。 +// 仅适用于 Anthropic OAuth / SetupToken 账号。 +func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int64) (*UsageInfo, error) { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account failed: %w", err) + } + + if !account.IsAnthropicOAuthOrSetupToken() { + return nil, fmt.Errorf("passive usage only supported for Anthropic OAuth/SetupToken accounts") + } + + // 复用 estimateSetupTokenUsage 构建 5h 窗口(OAuth 和 SetupToken 逻辑一致) + info := s.estimateSetupTokenUsage(account) + info.Source = "passive" + + // 设置采样时间 + if raw, ok := account.Extra["passive_usage_sampled_at"]; ok { + if str, ok := raw.(string); ok { + if t, err := time.Parse(time.RFC3339, str); err == nil { + info.UpdatedAt = &t + } + } + } + + // 构建 7d 窗口(从被动采样数据) + util7d := parseExtraFloat64(account.Extra["passive_usage_7d_utilization"]) + reset7dRaw := parseExtraFloat64(account.Extra["passive_usage_7d_reset"]) + if util7d > 0 || reset7dRaw > 0 { + var resetAt *time.Time + var remaining int + if reset7dRaw > 0 { + t := time.Unix(int64(reset7dRaw), 0) + resetAt = &t + remaining = int(time.Until(t).Seconds()) + if remaining < 0 { + remaining = 0 + } + } + info.SevenDay = &UsageProgress{ + Utilization: util7d * 100, + ResetsAt: resetAt, + RemainingSeconds: remaining, + } + } + + // 添加窗口统计 + s.addWindowStats(ctx, account, info) + + return info, nil +} + +// syncActiveToPassive 将主动查询的最新数据回写到 Extra 被动缓存, +// 这样下次被动加载时能看到最新值。 +func (s *AccountUsageService) syncActiveToPassive(ctx context.Context, accountID int64, usage *UsageInfo) { + extraUpdates := make(map[string]any, 4) + + if usage.FiveHour != nil { + extraUpdates["session_window_utilization"] = usage.FiveHour.Utilization / 100 + } + if usage.SevenDay != nil { + extraUpdates["passive_usage_7d_utilization"] = usage.SevenDay.Utilization / 100 + if usage.SevenDay.ResetsAt != nil { + extraUpdates["passive_usage_7d_reset"] = usage.SevenDay.ResetsAt.Unix() + } + } + + if len(extraUpdates) > 0 { + extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339) + if err := s.accountRepo.UpdateExtra(ctx, accountID, extraUpdates); err != nil { + slog.Warn("sync_active_to_passive_failed", "account_id", accountID, "error", err) + } + } +} + func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Account) (*UsageInfo, error) { now := time.Now() usage := &UsageInfo{UpdatedAt: &now} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index c59dd68d..5c6c26e1 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1110,10 +1110,13 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc slog.Info("account_session_window_initialized", "account_id", account.ID, "window_start", start, "window_end", end, "status", status) } - // 窗口重置时清除旧的 utilization,避免残留上个窗口的数据 + // 窗口重置时清除旧的 utilization 和被动采样数据,避免残留上个窗口的数据 if windowEnd != nil && needInitWindow { _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ - "session_window_utilization": nil, + "session_window_utilization": nil, + "passive_usage_7d_utilization": nil, + "passive_usage_7d_reset": nil, + "passive_usage_sampled_at": nil, }) } @@ -1121,14 +1124,33 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc slog.Warn("session_window_update_failed", "account_id", account.ID, "error", err) } - // 存储真实的 utilization 值(0-1 小数),供 estimateSetupTokenUsage 使用 + // 被动采样:从响应头收集 5h + 7d utilization,合并为一次 DB 写入 + extraUpdates := make(map[string]any, 4) + // 5h utilization(0-1 小数),供 estimateSetupTokenUsage 使用 if utilStr := headers.Get("anthropic-ratelimit-unified-5h-utilization"); utilStr != "" { if util, err := strconv.ParseFloat(utilStr, 64); err == nil { - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ - "session_window_utilization": util, - }); err != nil { - slog.Warn("session_window_utilization_update_failed", "account_id", account.ID, "error", err) + extraUpdates["session_window_utilization"] = util + } + } + // 7d utilization(0-1 小数) + if utilStr := headers.Get("anthropic-ratelimit-unified-7d-utilization"); utilStr != "" { + if util, err := strconv.ParseFloat(utilStr, 64); err == nil { + extraUpdates["passive_usage_7d_utilization"] = util + } + } + // 7d reset timestamp + if resetStr := headers.Get("anthropic-ratelimit-unified-7d-reset"); resetStr != "" { + if ts, err := strconv.ParseInt(resetStr, 10, 64); err == nil { + if ts > 1e11 { + ts = ts / 1000 } + extraUpdates["passive_usage_7d_reset"] = ts + } + } + if len(extraUpdates) > 0 { + extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339) + if err := s.accountRepo.UpdateExtra(ctx, account.ID, extraUpdates); err != nil { + slog.Warn("passive_usage_update_failed", "account_id", account.ID, "error", err) } } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 23d50d3a..9857de05 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -223,8 +223,10 @@ export async function clearError(id: number): Promise { * @param id - Account ID * @returns Account usage info */ -export async function getUsage(id: number): Promise { - const { data } = await apiClient.get(`/admin/accounts/${id}/usage`) +export async function getUsage(id: number, source?: 'passive' | 'active'): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/usage`, { + params: source ? { source } : undefined + }) return data } diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 131d82b2..37e18c35 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -67,6 +67,38 @@ :resets-at="usageInfo.seven_day_sonnet.resets_at" color="purple" /> + + +
+ + {{ t('admin.accounts.usageWindow.passiveSampled') }} + + +
@@ -433,6 +465,7 @@ const props = withDefaults( const { t } = useI18n() const loading = ref(false) +const activeQueryLoading = ref(false) const error = ref(null) const usageInfo = ref(null) @@ -888,14 +921,18 @@ const copyValidationURL = async () => { } } -const loadUsage = async () => { +const isAnthropicOAuthOrSetupToken = computed(() => { + return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token') +}) + +const loadUsage = async (source?: 'passive' | 'active') => { if (!shouldFetchUsage.value) return loading.value = true error.value = null try { - usageInfo.value = await adminAPI.accounts.getUsage(props.account.id) + usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, source) } catch (e: any) { error.value = t('common.error') console.error('Failed to load usage:', e) @@ -904,6 +941,17 @@ const loadUsage = async () => { } } +const loadActiveUsage = async () => { + activeQueryLoading.value = true + try { + usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, 'active') + } catch (e: any) { + console.error('Failed to load active usage:', e) + } finally { + activeQueryLoading.value = false + } +} + // ===== API Key quota progress bars ===== interface QuotaBarInfo { @@ -993,7 +1041,8 @@ const formatKeyUserCost = computed(() => { onMounted(() => { if (!shouldAutoLoadUsageOnMount.value) return - loadUsage() + const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined + loadUsage(source) }) watch(openAIUsageRefreshKey, (nextKey, prevKey) => { @@ -1011,7 +1060,8 @@ watch( if (nextToken === prevToken) return if (!shouldFetchUsage.value) return - loadUsage().catch((e) => { + const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined + loadUsage(source).catch((e) => { console.error('Failed to refresh usage after manual refresh:', e) }) } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 2644207f..6056e104 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2760,7 +2760,9 @@ export default { gemini3Pro: 'G3P', gemini3Flash: 'G3F', gemini3Image: 'G31FI', - claude: 'Claude' + claude: 'Claude', + passiveSampled: 'Passive', + activeQuery: 'Query' }, tier: { free: 'Free', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 15ea4822..602399b5 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2163,7 +2163,9 @@ export default { gemini3Pro: 'G3P', gemini3Flash: 'G3F', gemini3Image: 'G31FI', - claude: 'Claude' + claude: 'Claude', + passiveSampled: '被动采样', + activeQuery: '查询' }, tier: { free: 'Free', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 88d6e994..056efae2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -781,6 +781,7 @@ export interface AntigravityModelQuota { } export interface AccountUsageInfo { + source?: 'passive' | 'active' updated_at: string | null five_hour: UsageProgress | null seven_day: UsageProgress | null