From ced90e1d8438ac72688e326d09f9ee6d6572ad20 Mon Sep 17 00:00:00 2001 From: SilentFlower Date: Sun, 15 Mar 2026 23:50:28 +0800 Subject: [PATCH] feat: add AI Credits balance handling and update model status indicators --- backend/internal/pkg/antigravity/client.go | 68 ++++++++- .../internal/pkg/antigravity/client_test.go | 30 +++- .../internal/service/account_usage_service.go | 10 ++ .../service/antigravity_quota_fetcher.go | 31 ++-- .../service/antigravity_quota_fetcher_test.go | 51 +++++-- .../account/AccountStatusIndicator.vue | 60 ++++++-- .../components/account/AccountUsageCell.vue | 41 +++++ .../__tests__/AccountStatusIndicator.spec.ts | 99 ++++++++++++ .../__tests__/AccountUsageCell.spec.ts | 142 +++++++++++++----- frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/types/index.ts | 6 + 12 files changed, 465 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index 1e63315b..44f563a3 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -124,10 +124,68 @@ type IneligibleTier struct { type LoadCodeAssistResponse struct { CloudAICompanionProject string `json:"cloudaicompanionProject"` CurrentTier *TierInfo `json:"currentTier,omitempty"` - PaidTier *TierInfo `json:"paidTier,omitempty"` + PaidTier *PaidTierInfo `json:"paidTier,omitempty"` IneligibleTiers []*IneligibleTier `json:"ineligibleTiers,omitempty"` } +// PaidTierInfo 付费等级信息,包含 AI Credits 余额。 +type PaidTierInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AvailableCredits []AvailableCredit `json:"availableCredits,omitempty"` +} + +// UnmarshalJSON 兼容 paidTier 既可能是字符串也可能是对象的情况。 +func (p *PaidTierInfo) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || string(data) == "null" { + return nil + } + if data[0] == '"' { + var id string + if err := json.Unmarshal(data, &id); err != nil { + return err + } + p.ID = id + return nil + } + type alias PaidTierInfo + var raw alias + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *p = PaidTierInfo(raw) + return nil +} + +// AvailableCredit 表示一条 AI Credits 余额记录。 +type AvailableCredit struct { + CreditType string `json:"creditType,omitempty"` + CreditAmount string `json:"creditAmount,omitempty"` + MinimumCreditAmountForUsage string `json:"minimumCreditAmountForUsage,omitempty"` +} + +// GetAmount 将 creditAmount 解析为浮点数。 +func (c *AvailableCredit) GetAmount() float64 { + if c.CreditAmount == "" { + return 0 + } + var value float64 + fmt.Sscanf(c.CreditAmount, "%f", &value) + return value +} + +// GetMinimumAmount 将 minimumCreditAmountForUsage 解析为浮点数。 +func (c *AvailableCredit) GetMinimumAmount() float64 { + if c.MinimumCreditAmountForUsage == "" { + return 0 + } + var value float64 + fmt.Sscanf(c.MinimumCreditAmountForUsage, "%f", &value) + return value +} + // OnboardUserRequest onboardUser 请求 type OnboardUserRequest struct { TierID string `json:"tierId"` @@ -157,6 +215,14 @@ func (r *LoadCodeAssistResponse) GetTier() string { return "" } +// GetAvailableCredits 返回 paid tier 中的 AI Credits 余额列表。 +func (r *LoadCodeAssistResponse) GetAvailableCredits() []AvailableCredit { + if r.PaidTier == nil { + return nil + } + return r.PaidTier.AvailableCredits +} + // Client Antigravity API 客户端 type Client struct { httpClient *http.Client diff --git a/backend/internal/pkg/antigravity/client_test.go b/backend/internal/pkg/antigravity/client_test.go index 20b57833..7d5bba93 100644 --- a/backend/internal/pkg/antigravity/client_test.go +++ b/backend/internal/pkg/antigravity/client_test.go @@ -190,7 +190,7 @@ func TestTierInfo_UnmarshalJSON_通过JSON嵌套结构(t *testing.T) { func TestGetTier_PaidTier优先(t *testing.T) { resp := &LoadCodeAssistResponse{ CurrentTier: &TierInfo{ID: "free-tier"}, - PaidTier: &TierInfo{ID: "g1-pro-tier"}, + PaidTier: &PaidTierInfo{ID: "g1-pro-tier"}, } if got := resp.GetTier(); got != "g1-pro-tier" { t.Errorf("应返回 paidTier: got %s", got) @@ -209,7 +209,7 @@ func TestGetTier_回退到CurrentTier(t *testing.T) { func TestGetTier_PaidTier为空ID(t *testing.T) { resp := &LoadCodeAssistResponse{ CurrentTier: &TierInfo{ID: "free-tier"}, - PaidTier: &TierInfo{ID: ""}, + PaidTier: &PaidTierInfo{ID: ""}, } // paidTier.ID 为空时应回退到 currentTier if got := resp.GetTier(); got != "free-tier" { @@ -217,6 +217,32 @@ func TestGetTier_PaidTier为空ID(t *testing.T) { } } +func TestGetAvailableCredits(t *testing.T) { + resp := &LoadCodeAssistResponse{ + PaidTier: &PaidTierInfo{ + ID: "g1-pro-tier", + AvailableCredits: []AvailableCredit{ + { + CreditType: "GOOGLE_ONE_AI", + CreditAmount: "25", + MinimumCreditAmountForUsage: "5", + }, + }, + }, + } + + credits := resp.GetAvailableCredits() + if len(credits) != 1 { + t.Fatalf("AI Credits 数量不匹配: got %d", len(credits)) + } + if credits[0].GetAmount() != 25 { + t.Errorf("CreditAmount 解析不正确: got %v", credits[0].GetAmount()) + } + if credits[0].GetMinimumAmount() != 5 { + t.Errorf("MinimumCreditAmountForUsage 解析不正确: got %v", credits[0].GetMinimumAmount()) + } +} + func TestGetTier_两者都为nil(t *testing.T) { resp := &LoadCodeAssistResponse{} if got := resp.GetTier(); got != "" { diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index b0a04d47..f117abfd 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -166,6 +166,13 @@ type AntigravityModelDetail struct { SupportedMimeTypes map[string]bool `json:"supported_mime_types,omitempty"` } +// AICredit 表示 Antigravity 账号的 AI Credits 余额信息。 +type AICredit struct { + CreditType string `json:"credit_type,omitempty"` + Amount float64 `json:"amount,omitempty"` + MinimumBalance float64 `json:"minimum_balance,omitempty"` +} + // UsageInfo 账号使用量信息 type UsageInfo struct { UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间 @@ -189,6 +196,9 @@ type UsageInfo struct { // Antigravity 模型详细能力信息(与 antigravity_quota 同 key) AntigravityQuotaDetails map[string]*AntigravityModelDetail `json:"antigravity_quota_details,omitempty"` + // Antigravity AI Credits 余额 + AICredits []AICredit `json:"ai_credits,omitempty"` + // Antigravity 废弃模型转发规则 (old_model_id -> new_model_id) ModelForwardingRules map[string]string `json:"model_forwarding_rules,omitempty"` diff --git a/backend/internal/service/antigravity_quota_fetcher.go b/backend/internal/service/antigravity_quota_fetcher.go index f8990b1a..9e09c904 100644 --- a/backend/internal/service/antigravity_quota_fetcher.go +++ b/backend/internal/service/antigravity_quota_fetcher.go @@ -78,11 +78,11 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou return nil, err } - // 调用 LoadCodeAssist 获取订阅等级(非关键路径,失败不影响主流程) - tierRaw, tierNormalized := f.fetchSubscriptionTier(ctx, client, accessToken) + // 调用 LoadCodeAssist 获取订阅等级和 AI Credits 余额(非关键路径,失败不影响主流程) + tierRaw, tierNormalized, loadResp := f.fetchSubscriptionTier(ctx, client, accessToken) // 转换为 UsageInfo - usageInfo := f.buildUsageInfo(modelsResp, tierRaw, tierNormalized) + usageInfo := f.buildUsageInfo(modelsResp, tierRaw, tierNormalized, loadResp) return &QuotaResult{ UsageInfo: usageInfo, @@ -90,20 +90,21 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou }, nil } -// fetchSubscriptionTier 获取账号订阅等级,失败返回空字符串 -func (f *AntigravityQuotaFetcher) fetchSubscriptionTier(ctx context.Context, client *antigravity.Client, accessToken string) (raw, normalized string) { +// fetchSubscriptionTier 获取账号订阅等级,失败返回空字符串。 +// 同时返回 LoadCodeAssistResponse,以便提取 AI Credits 余额。 +func (f *AntigravityQuotaFetcher) fetchSubscriptionTier(ctx context.Context, client *antigravity.Client, accessToken string) (raw, normalized string, loadResp *antigravity.LoadCodeAssistResponse) { loadResp, _, err := client.LoadCodeAssist(ctx, accessToken) if err != nil { slog.Warn("failed to fetch subscription tier", "error", err) - return "", "" + return "", "", nil } if loadResp == nil { - return "", "" + return "", "", nil } raw = loadResp.GetTier() // 已有方法:paidTier > currentTier normalized = normalizeTier(raw) - return raw, normalized + return raw, normalized, loadResp } // normalizeTier 将原始 tier 字符串归一化为 FREE/PRO/ULTRA/UNKNOWN @@ -124,8 +125,8 @@ func normalizeTier(raw string) string { } } -// buildUsageInfo 将 API 响应转换为 UsageInfo -func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse, tierRaw, tierNormalized string) *UsageInfo { +// buildUsageInfo 将 API 响应转换为 UsageInfo。 +func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse, tierRaw, tierNormalized string, loadResp *antigravity.LoadCodeAssistResponse) *UsageInfo { now := time.Now() info := &UsageInfo{ UpdatedAt: &now, @@ -190,6 +191,16 @@ func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAv } } + if loadResp != nil { + for _, credit := range loadResp.GetAvailableCredits() { + info.AICredits = append(info.AICredits, AICredit{ + CreditType: credit.CreditType, + Amount: credit.GetAmount(), + MinimumBalance: credit.GetMinimumAmount(), + }) + } + } + return info } diff --git a/backend/internal/service/antigravity_quota_fetcher_test.go b/backend/internal/service/antigravity_quota_fetcher_test.go index 5ead8e60..e0f57051 100644 --- a/backend/internal/service/antigravity_quota_fetcher_test.go +++ b/backend/internal/service/antigravity_quota_fetcher_test.go @@ -81,7 +81,7 @@ func TestBuildUsageInfo_BasicModels(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO") + info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO", nil) // 基本字段 require.NotNil(t, info.UpdatedAt, "UpdatedAt should be set") @@ -141,7 +141,7 @@ func TestBuildUsageInfo_DeprecatedModels(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.Len(t, info.ModelForwardingRules, 2) require.Equal(t, "claude-sonnet-4-20250514", info.ModelForwardingRules["claude-3-sonnet-20240229"]) @@ -159,7 +159,7 @@ func TestBuildUsageInfo_NoDeprecatedModels(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.Nil(t, info.ModelForwardingRules, "ModelForwardingRules should be nil when no deprecated models") } @@ -171,7 +171,7 @@ func TestBuildUsageInfo_EmptyModels(t *testing.T) { Models: map[string]antigravity.ModelInfo{}, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.NotNil(t, info) require.NotNil(t, info.AntigravityQuota) @@ -193,7 +193,7 @@ func TestBuildUsageInfo_ModelWithNilQuotaInfo(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.NotNil(t, info) require.Empty(t, info.AntigravityQuota, "models with nil QuotaInfo should be skipped") @@ -222,7 +222,7 @@ func TestBuildUsageInfo_FiveHourPriorityOrder(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.NotNil(t, info.FiveHour, "FiveHour should be set when a priority model exists") // claude-sonnet-4-20250514 is first in priority list, so it should be used @@ -251,7 +251,7 @@ func TestBuildUsageInfo_FiveHourFallbackToClaude4(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.NotNil(t, info.FiveHour) expectedUtilization := (1.0 - 0.60) * 100 // 40 @@ -277,7 +277,7 @@ func TestBuildUsageInfo_FiveHourFallbackToGemini(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.NotNil(t, info.FiveHour) expectedUtilization := (1.0 - 0.30) * 100 // 70 @@ -298,7 +298,7 @@ func TestBuildUsageInfo_FiveHourNoPriorityModel(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.Nil(t, info.FiveHour, "FiveHour should be nil when no priority model exists") } @@ -317,7 +317,7 @@ func TestBuildUsageInfo_FiveHourWithEmptyResetTime(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) require.NotNil(t, info.FiveHour) require.Nil(t, info.FiveHour.ResetsAt, "ResetsAt should be nil when ResetTime is empty") @@ -338,7 +338,7 @@ func TestBuildUsageInfo_FullUtilization(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) quota := info.AntigravityQuota["claude-sonnet-4-20250514"] require.NotNil(t, quota) @@ -358,13 +358,38 @@ func TestBuildUsageInfo_ZeroUtilization(t *testing.T) { }, } - info := fetcher.buildUsageInfo(modelsResp, "", "") - + info := fetcher.buildUsageInfo(modelsResp, "", "", nil) quota := info.AntigravityQuota["claude-sonnet-4-20250514"] require.NotNil(t, quota) require.Equal(t, 0, quota.Utilization) } +func TestBuildUsageInfo_AICredits(t *testing.T) { + fetcher := &AntigravityQuotaFetcher{} + modelsResp := &antigravity.FetchAvailableModelsResponse{ + Models: map[string]antigravity.ModelInfo{}, + } + loadResp := &antigravity.LoadCodeAssistResponse{ + PaidTier: &antigravity.PaidTierInfo{ + ID: "g1-pro-tier", + AvailableCredits: []antigravity.AvailableCredit{ + { + CreditType: "GOOGLE_ONE_AI", + CreditAmount: "25", + MinimumCreditAmountForUsage: "5", + }, + }, + }, + } + + info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO", loadResp) + + require.Len(t, info.AICredits, 1) + require.Equal(t, "GOOGLE_ONE_AI", info.AICredits[0].CreditType) + require.Equal(t, 25.0, info.AICredits[0].Amount) + require.Equal(t, 5.0, info.AICredits[0].MinimumBalance) +} + func TestFetchQuota_ForbiddenReturnsIsForbidden(t *testing.T) { // 模拟 FetchQuota 遇到 403 时的行为: // FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 220b5c8b..9dfb9078 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -76,19 +76,28 @@ - +
-
+
+ + {{ formatScopeName(item.model) }} + {{ formatModelResetTime(item.reset_at) }} + + @@ -99,7 +108,11 @@
- {{ t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) }} + {{ + item.kind === 'overages' + ? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) + : t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) + }}
@@ -131,6 +144,7 @@