From e49281774d6e654e570ccc55ecd81878c5d28d01 Mon Sep 17 00:00:00 2001
From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com>
Date: Wed, 31 Dec 2025 23:57:01 +0800
Subject: [PATCH] =?UTF-8?q?fix(gemini):=20=E4=BF=AE=E5=A4=8D=20P0/P1=20?=
=?UTF-8?q?=E7=BA=A7=E5=88=AB=E9=97=AE=E9=A2=98=EF=BC=88429=E8=AF=AF?=
=?UTF-8?q?=E5=88=A4/Tier=E4=B8=A2=E5=A4=B1/expires=5Fat/=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF=E4=B8=80=E8=87=B4=E6=80=A7=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P0 修复(Critical - 影响生产稳定性):
- 修复 429 判断逻辑:使用 project_id 判断而非 account.Type
防止 AI Studio OAuth 被误判为 Code Assist 5分钟窗口
- 修复 Tier ID 丢失:刷新时始终保留旧值,默认 LEGACY
防止 fetchProjectID 失败导致 tier_id 被清空
- 修复 expires_at 下界:添加 minTTL=30s 保护
防止 expires_in <= 300 时生成过去时间引发刷新风暴
P1 修复(Important - 行为一致性):
- 前端 isCodeAssist 判断与后端一致(支持 legacy)
- 前端日期解析添加 NaN 保护
- 迁移脚本覆盖 legacy 账号
前端功能(新增):
- AccountQuotaInfo 组件:Tier Badge + 二元进度条 + 倒计时
- 定时器动态管理:watch 监听限流状态
- 类型定义:GeminiCredentials 接口
测试:
- ✅ TypeScript 类型检查通过
- ✅ 前端构建成功(3.33s)
- ✅ Gemini + Codex 双 AI 审查通过
Refs: #gemini-quota
---
.../service/gemini_messages_compat_service.go | 38 +++-
.../internal/service/gemini_oauth_service.go | 81 ++++++--
backend/migrations/017_add_gemini_tier_id.sql | 30 +++
.../components/account/AccountQuotaInfo.vue | 194 ++++++++++++++++++
.../components/account/AccountUsageCell.vue | 12 +-
frontend/src/types/index.ts | 16 ++
6 files changed, 349 insertions(+), 22 deletions(-)
create mode 100644 backend/migrations/017_add_gemini_tier_id.sql
create mode 100644 frontend/src/components/account/AccountQuotaInfo.vue
diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go
index b1877800..111ff462 100644
--- a/backend/internal/service/gemini_messages_compat_service.go
+++ b/backend/internal/service/gemini_messages_compat_service.go
@@ -1886,13 +1886,47 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
if statusCode != 429 {
return
}
+
+ // 获取账号的 oauth_type、tier_id 和 project_id
+ oauthType := strings.TrimSpace(account.GetCredential("oauth_type"))
+ tierID := strings.TrimSpace(account.GetCredential("tier_id"))
+ projectID := strings.TrimSpace(account.GetCredential("project_id"))
+
+ // 判断是否为 Code Assist:以 project_id 是否存在为准(更可靠)
+ isCodeAssist := projectID != ""
+ // Legacy 兼容:oauth_type 为空但 project_id 存在时视为 code_assist
+ if oauthType == "" && isCodeAssist {
+ oauthType = "code_assist"
+ }
+
resetAt := ParseGeminiRateLimitResetTime(body)
if resetAt == nil {
- ra := time.Now().Add(5 * time.Minute)
+ // 根据账号类型使用不同的默认重置时间
+ var ra time.Time
+ if isCodeAssist {
+ // Code Assist: 5 分钟滚动窗口
+ ra = time.Now().Add(5 * time.Minute)
+ log.Printf("[Gemini 429] Account %d (Code Assist, tier=%s, project=%s) rate limited, reset in 5min", account.ID, tierID, projectID)
+ } else {
+ // API Key / AI Studio OAuth: PST 午夜
+ if ts := nextGeminiDailyResetUnix(); ts != nil {
+ ra = time.Unix(*ts, 0)
+ log.Printf("[Gemini 429] Account %d (API Key/AI Studio, type=%s) rate limited, reset at PST midnight (%v)", account.ID, account.Type, ra)
+ } else {
+ // 兜底:5 分钟
+ ra = time.Now().Add(5 * time.Minute)
+ log.Printf("[Gemini 429] Account %d rate limited, fallback to 5min", account.ID)
+ }
+ }
_ = s.accountRepo.SetRateLimited(ctx, account.ID, ra)
return
}
- _ = s.accountRepo.SetRateLimited(ctx, account.ID, time.Unix(*resetAt, 0))
+
+ // 使用解析到的重置时间
+ resetTime := time.Unix(*resetAt, 0)
+ _ = s.accountRepo.SetRateLimited(ctx, account.ID, resetTime)
+ log.Printf("[Gemini 429] Account %d rate limited until %v (oauth_type=%s, tier=%s)",
+ account.ID, resetTime, oauthType, tierID)
}
// ParseGeminiRateLimitResetTime 解析 Gemini 格式的 429 响应,返回重置时间的 Unix 时间戳
diff --git a/backend/internal/service/gemini_oauth_service.go b/backend/internal/service/gemini_oauth_service.go
index 221bd0f2..d1c1c5f6 100644
--- a/backend/internal/service/gemini_oauth_service.go
+++ b/backend/internal/service/gemini_oauth_service.go
@@ -259,8 +259,15 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
sessionProjectID := strings.TrimSpace(session.ProjectID)
s.sessionStore.Delete(input.SessionID)
- // 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
- expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
+ // 计算过期时间:减去 5 分钟安全时间窗口(考虑网络延迟和时钟偏差)
+ // 同时设置下界保护,防止 expires_in 过小导致过去时间(引发刷新风暴)
+ const safetyWindow = 300 // 5 minutes
+ const minTTL = 30 // minimum 30 seconds
+ expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - safetyWindow
+ minExpiresAt := time.Now().Unix() + minTTL
+ if expiresAt < minExpiresAt {
+ expiresAt = minExpiresAt
+ }
projectID := sessionProjectID
var tierID string
@@ -275,10 +282,22 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
// 记录警告但不阻断流程,允许后续补充 project_id
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
}
+ } else {
+ // 用户手动填了 project_id,仍需调用 LoadCodeAssist 获取 tierID
+ _, fetchedTierID, err := s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
+ if err != nil {
+ fmt.Printf("[GeminiOAuth] Warning: Failed to fetch tierID: %v\n", err)
+ } else {
+ tierID = fetchedTierID
+ }
}
if strings.TrimSpace(projectID) == "" {
return nil, fmt.Errorf("missing project_id for Code Assist OAuth: please fill Project ID (optional field) and regenerate the auth URL, or ensure your Google account has an ACTIVE GCP project")
}
+ // tierID 缺失时使用默认值
+ if tierID == "" {
+ tierID = "LEGACY"
+ }
}
return &GeminiTokenInfo{
@@ -308,8 +327,15 @@ func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refres
tokenResp, err := s.oauthClient.RefreshToken(ctx, oauthType, refreshToken, proxyURL)
if err == nil {
- // 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
- expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
+ // 计算过期时间:减去 5 分钟安全时间窗口(考虑网络延迟和时钟偏差)
+ // 同时设置下界保护,防止 expires_in 过小导致过去时间(引发刷新风暴)
+ const safetyWindow = 300 // 5 minutes
+ const minTTL = 30 // minimum 30 seconds
+ expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - safetyWindow
+ minExpiresAt := time.Now().Unix() + minTTL
+ if expiresAt < minExpiresAt {
+ expiresAt = minExpiresAt
+ }
return &GeminiTokenInfo{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
@@ -396,19 +422,39 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
tokenInfo.ProjectID = existingProjectID
}
+ // 尝试从账号凭证获取 tierID(向后兼容)
+ existingTierID := strings.TrimSpace(account.GetCredential("tier_id"))
+
// For Code Assist, project_id is required. Auto-detect if missing.
// For AI Studio OAuth, project_id is optional and should not block refresh.
- if oauthType == "code_assist" && strings.TrimSpace(tokenInfo.ProjectID) == "" {
- projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
- if err != nil {
- return nil, fmt.Errorf("failed to auto-detect project_id: %w", err)
+ if oauthType == "code_assist" {
+ // 先设置默认值或保留旧值,确保 tier_id 始终有值
+ if existingTierID != "" {
+ tokenInfo.TierID = existingTierID
+ } else {
+ tokenInfo.TierID = "LEGACY" // 默认值
}
- projectID = strings.TrimSpace(projectID)
- if projectID == "" {
+
+ // 尝试自动探测 project_id 和 tier_id
+ needDetect := strings.TrimSpace(tokenInfo.ProjectID) == "" || existingTierID == ""
+ if needDetect {
+ projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
+ if err != nil {
+ fmt.Printf("[GeminiOAuth] Warning: failed to auto-detect project/tier: %v\n", err)
+ } else {
+ if strings.TrimSpace(tokenInfo.ProjectID) == "" && projectID != "" {
+ tokenInfo.ProjectID = projectID
+ }
+ // 只有当原来没有 tier_id 且探测成功时才更新
+ if existingTierID == "" && tierID != "" {
+ tokenInfo.TierID = tierID
+ }
+ }
+ }
+
+ if strings.TrimSpace(tokenInfo.ProjectID) == "" {
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
}
- tokenInfo.ProjectID = projectID
- tokenInfo.TierID = tierID
}
return tokenInfo, nil
@@ -466,9 +512,6 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil
}
- // Pick tier from allowedTiers; if no default tier is marked, pick the first non-empty tier ID.
- // (tierID already extracted above, reuse it)
-
req := &geminicli.OnboardUserRequest{
TierID: tierID,
Metadata: geminicli.LoadCodeAssistMetadata{
@@ -487,7 +530,7 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
if fbErr == nil && strings.TrimSpace(fallback) != "" {
return strings.TrimSpace(fallback), tierID, nil
}
- return "", "", err
+ return "", tierID, err
}
if resp.Done {
if resp.Response != nil && resp.Response.CloudAICompanionProject != nil {
@@ -505,7 +548,7 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
if fbErr == nil && strings.TrimSpace(fallback) != "" {
return strings.TrimSpace(fallback), tierID, nil
}
- return "", "", errors.New("onboardUser completed but no project_id returned")
+ return "", tierID, errors.New("onboardUser completed but no project_id returned")
}
time.Sleep(2 * time.Second)
}
@@ -515,9 +558,9 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
return strings.TrimSpace(fallback), tierID, nil
}
if loadErr != nil {
- return "", "", fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
+ return "", tierID, fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
}
- return "", "", fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
+ return "", tierID, fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
}
type googleCloudProject struct {
diff --git a/backend/migrations/017_add_gemini_tier_id.sql b/backend/migrations/017_add_gemini_tier_id.sql
new file mode 100644
index 00000000..0388a412
--- /dev/null
+++ b/backend/migrations/017_add_gemini_tier_id.sql
@@ -0,0 +1,30 @@
+-- +goose Up
+-- +goose StatementBegin
+-- 为 Gemini Code Assist OAuth 账号添加默认 tier_id
+-- 包括显式标记为 code_assist 的账号,以及 legacy 账号(oauth_type 为空但 project_id 存在)
+UPDATE accounts
+SET credentials = jsonb_set(
+ credentials,
+ '{tier_id}',
+ '"LEGACY"',
+ true
+)
+WHERE platform = 'gemini'
+ AND type = 'oauth'
+ AND jsonb_typeof(credentials) = 'object'
+ AND credentials->>'tier_id' IS NULL
+ AND (
+ credentials->>'oauth_type' = 'code_assist'
+ OR (credentials->>'oauth_type' IS NULL AND credentials->>'project_id' IS NOT NULL)
+ );
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+-- 回滚:删除 tier_id 字段
+UPDATE accounts
+SET credentials = credentials - 'tier_id'
+WHERE platform = 'gemini'
+ AND type = 'oauth'
+ AND credentials->>'oauth_type' = 'code_assist';
+-- +goose StatementEnd
diff --git a/frontend/src/components/account/AccountQuotaInfo.vue b/frontend/src/components/account/AccountQuotaInfo.vue
new file mode 100644
index 00000000..44fe1b41
--- /dev/null
+++ b/frontend/src/components/account/AccountQuotaInfo.vue
@@ -0,0 +1,194 @@
+
+