From 7df914af0619abdf9ccb3598973d89762f8a40a0 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Wed, 31 Dec 2025 21:45:24 -0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(gemini):=20=E6=B7=BB=E5=8A=A0=20Google?= =?UTF-8?q?=20One=20=E5=AD=98=E5=82=A8=E7=A9=BA=E9=97=B4=E6=8E=A8=E6=96=AD?= =?UTF-8?q?=20Tier=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能概述 通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。 ## 后端改动 - 新增 Drive API 客户端 (drive_client.go) - 支持代理和指数退避重试 - 处理 403/429 错误 - 添加 Tier 推断逻辑 (inferGoogleOneTier) - 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED - 集成到 OAuth 流程 - ExchangeCode: 授权时自动获取 tier - RefreshAccountToken: Token 刷新时更新 tier (24小时缓存) - 新增管理 API 端点 - POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新) - POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新) ## 前端改动 - 更新 AccountQuotaInfo.vue - 添加 Google One tier 标签映射 - 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色) - 更新 AccountUsageCell.vue - 添加 Google One tier 显示逻辑 - 根据 oauth_type 区分显示方式 - 添加国际化翻译 (en.ts, zh.ts) - aiPremium, standard, basic, free, personal, unlimited ## Tier 推断规则 - >= 2TB: AI Premium - >= 200GB: Google One Standard - >= 100GB: Google One Basic - >= 15GB: Free - > 100TB: Unlimited (G Suite legacy) - 其他/失败: Unknown (显示为 Personal) ## 优雅降级 - Drive API 失败时使用 GOOGLE_ONE_UNKNOWN - 不阻断 OAuth 流程 - 24小时缓存避免频繁调用 ## 测试 - ✅ 后端编译成功 - ✅ 前端构建成功 - ✅ 所有代码符合现有规范 --- .../internal/handler/admin/account_handler.go | 165 +++++++++++++ .../handler/admin/gemini_oauth_handler.go | 8 +- .../internal/pkg/geminicli/drive_client.go | 113 +++++++++ backend/internal/server/routes/admin.go | 2 + .../internal/service/gemini_oauth_service.go | 159 ++++++++++++- .../components/account/AccountQuotaInfo.vue | 74 ++++-- .../components/account/AccountUsageCell.vue | 37 +++ .../components/account/CreateAccountModal.vue | 219 +++++++++++------- frontend/src/composables/useGeminiOAuth.ts | 8 +- frontend/src/i18n/locales/en.ts | 8 +- frontend/src/i18n/locales/zh.ts | 8 +- 11 files changed, 691 insertions(+), 110 deletions(-) create mode 100644 backend/internal/pkg/geminicli/drive_client.go diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index ac938f8c..58715706 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -3,6 +3,7 @@ package admin import ( "strconv" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -989,3 +990,167 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { response.Success(c, models) } + +// RefreshTier handles refreshing Google One tier for a single account +// POST /api/v1/admin/accounts/:id/refresh-tier +func (h *AccountHandler) RefreshTier(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + response.NotFound(c, "Account not found") + return + } + + if account.Credentials == nil || account.Credentials["oauth_type"] != "google_one" { + response.BadRequest(c, "Account is not a google_one OAuth account") + return + } + + accessToken, ok := account.Credentials["access_token"].(string) + if !ok || accessToken == "" { + response.BadRequest(c, "Missing access_token in credentials") + return + } + + var proxyURL string + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(c.Request.Context(), accessToken, proxyURL) + + if account.Extra == nil { + account.Extra = make(map[string]any) + } + if storageInfo != nil { + account.Extra["drive_storage_limit"] = storageInfo.Limit + account.Extra["drive_storage_usage"] = storageInfo.Usage + account.Extra["drive_tier_updated_at"] = timezone.Now().Format(time.RFC3339) + } + account.Credentials["tier_id"] = tierID + + _, updateErr := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ + Credentials: account.Credentials, + Extra: account.Extra, + }) + if updateErr != nil { + response.ErrorFrom(c, updateErr) + return + } + + response.Success(c, gin.H{ + "tier_id": tierID, + "drive_storage_limit": account.Extra["drive_storage_limit"], + "drive_storage_usage": account.Extra["drive_storage_usage"], + "updated_at": account.Extra["drive_tier_updated_at"], + }) +} + +// BatchRefreshTierRequest represents batch tier refresh request +type BatchRefreshTierRequest struct { + AccountIDs []int64 `json:"account_ids"` +} + +// BatchRefreshTier handles batch refreshing Google One tier +// POST /api/v1/admin/accounts/batch-refresh-tier +func (h *AccountHandler) BatchRefreshTier(c *gin.Context) { + var req BatchRefreshTierRequest + if err := c.ShouldBindJSON(&req); err != nil { + req = BatchRefreshTierRequest{} + } + + ctx := c.Request.Context() + var accounts []service.Account + + if len(req.AccountIDs) == 0 { + allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "") + if err != nil { + response.ErrorFrom(c, err) + return + } + for _, acc := range allAccounts { + if acc.Credentials != nil && acc.Credentials["oauth_type"] == "google_one" { + accounts = append(accounts, acc) + } + } + } else { + for _, id := range req.AccountIDs { + acc, err := h.adminService.GetAccount(ctx, id) + if err != nil { + continue + } + if acc.Credentials != nil && acc.Credentials["oauth_type"] == "google_one" { + accounts = append(accounts, *acc) + } + } + } + + total := len(accounts) + success := 0 + failed := 0 + errors := []gin.H{} + + for _, account := range accounts { + accessToken, ok := account.Credentials["access_token"].(string) + if !ok || accessToken == "" { + failed++ + errors = append(errors, gin.H{ + "account_id": account.ID, + "error": "missing access_token", + }) + continue + } + + var proxyURL string + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(ctx, accessToken, proxyURL) + if err != nil { + failed++ + errors = append(errors, gin.H{ + "account_id": account.ID, + "error": err.Error(), + }) + continue + } + + if account.Extra == nil { + account.Extra = make(map[string]any) + } + if storageInfo != nil { + account.Extra["drive_storage_limit"] = storageInfo.Limit + account.Extra["drive_storage_usage"] = storageInfo.Usage + account.Extra["drive_tier_updated_at"] = timezone.Now().Format(time.RFC3339) + } + account.Credentials["tier_id"] = tierID + + _, updateErr := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{ + Credentials: account.Credentials, + Extra: account.Extra, + }) + if updateErr != nil { + failed++ + errors = append(errors, gin.H{ + "account_id": account.ID, + "error": updateErr.Error(), + }) + continue + } + + success++ + } + + response.Success(c, gin.H{ + "total": total, + "success": success, + "failed": failed, + "errors": errors, + }) +} diff --git a/backend/internal/handler/admin/gemini_oauth_handler.go b/backend/internal/handler/admin/gemini_oauth_handler.go index 4440aa21..037800e2 100644 --- a/backend/internal/handler/admin/gemini_oauth_handler.go +++ b/backend/internal/handler/admin/gemini_oauth_handler.go @@ -46,8 +46,8 @@ func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) { if oauthType == "" { oauthType = "code_assist" } - if oauthType != "code_assist" && oauthType != "ai_studio" { - response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") + if oauthType != "code_assist" && oauthType != "google_one" && oauthType != "ai_studio" { + response.BadRequest(c, "Invalid oauth_type: must be 'code_assist', 'google_one', or 'ai_studio'") return } @@ -92,8 +92,8 @@ func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) { if oauthType == "" { oauthType = "code_assist" } - if oauthType != "code_assist" && oauthType != "ai_studio" { - response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") + if oauthType != "code_assist" && oauthType != "google_one" && oauthType != "ai_studio" { + response.BadRequest(c, "Invalid oauth_type: must be 'code_assist', 'google_one', or 'ai_studio'") return } diff --git a/backend/internal/pkg/geminicli/drive_client.go b/backend/internal/pkg/geminicli/drive_client.go new file mode 100644 index 00000000..5a959fac --- /dev/null +++ b/backend/internal/pkg/geminicli/drive_client.go @@ -0,0 +1,113 @@ +package geminicli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" +) + +// DriveStorageInfo represents Google Drive storage quota information +type DriveStorageInfo struct { + Limit int64 `json:"limit"` // Storage limit in bytes + Usage int64 `json:"usage"` // Current usage in bytes +} + +// DriveClient interface for Google Drive API operations +type DriveClient interface { + GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error) +} + +type driveClient struct { + httpClient *http.Client +} + +// NewDriveClient creates a new Drive API client +func NewDriveClient() DriveClient { + return &driveClient{ + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// GetStorageQuota fetches storage quota from Google Drive API +func (c *driveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error) { + const driveAPIURL = "https://www.googleapis.com/drive/v3/about?fields=storageQuota" + + req, err := http.NewRequestWithContext(ctx, "GET", driveAPIURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + // Get HTTP client with proxy support + client, err := httpclient.GetClient(httpclient.Options{ + ProxyURL: proxyURL, + Timeout: 10 * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client: %w", err) + } + + // Retry logic with exponential backoff for rate limits + var resp *http.Response + maxRetries := 3 + for attempt := 0; attempt < maxRetries; attempt++ { + resp, err = client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + + // Success + if resp.StatusCode == http.StatusOK { + break + } + + // Rate limit - retry with exponential backoff + if resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries-1 { + resp.Body.Close() + backoff := time.Duration(1< 100*1024*1024*1024*1024 { // > 100TB + return TierGoogleOneUnlimited + } + + // AI Premium (2TB+) + if storageBytes >= 2*1024*1024*1024*1024 { // >= 2TB + return TierAIPremium + } + + // Google One Standard (200GB) + if storageBytes >= 200*1024*1024*1024 { // >= 200GB + return TierGoogleOneStandard + } + + // Google One Basic (100GB) + if storageBytes >= 100*1024*1024*1024 { // >= 100GB + return TierGoogleOneBasic + } + + // Free (15GB) + if storageBytes >= 15*1024*1024*1024 { // >= 15GB + return TierFree + } + + return TierGoogleOneUnknown +} + +// fetchGoogleOneTier fetches Google One tier from Drive API +func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken, proxyURL string) (string, *geminicli.DriveStorageInfo, error) { + driveClient := geminicli.NewDriveClient() + + storageInfo, err := driveClient.GetStorageQuota(ctx, accessToken, proxyURL) + if err != nil { + // Check if it's a 403 (scope not granted) + if strings.Contains(err.Error(), "status 403") { + fmt.Printf("[GeminiOAuth] Drive API scope not available: %v\n", err) + return TierGoogleOneUnknown, nil, err + } + // Other errors + fmt.Printf("[GeminiOAuth] Failed to fetch Drive storage: %v\n", err) + return TierGoogleOneUnknown, nil, err + } + + tierID := inferGoogleOneTier(storageInfo.Limit) + return tierID, storageInfo, nil +} + func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) { session, ok := s.sessionStore.Get(input.SessionID) if !ok { @@ -272,7 +337,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch projectID := sessionProjectID var tierID string - // 对于 code_assist 模式,project_id 是必需的 + // 对于 code_assist 模式,project_id 是必需的,需要调用 Code Assist API + // 对于 google_one 模式,使用个人 Google 账号,不需要 project_id,配额由 Google 网关自动识别 // 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API) if oauthType == "code_assist" { if projectID == "" { @@ -298,7 +364,37 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch if tierID == "" { tierID = "LEGACY" } + } else if oauthType == "google_one" { + // Attempt to fetch Drive storage tier + tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenResp.AccessToken, proxyURL) + if err != nil { + // Log warning but don't block - use fallback + fmt.Printf("[GeminiOAuth] Warning: Failed to fetch Drive tier: %v\n", err) + tierID = TierGoogleOneUnknown + } + + // Store Drive info in extra field for caching + if storageInfo != nil { + tokenInfo := &GeminiTokenInfo{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + ExpiresAt: expiresAt, + Scope: tokenResp.Scope, + ProjectID: projectID, + TierID: tierID, + OAuthType: oauthType, + Extra: map[string]any{ + "drive_storage_limit": storageInfo.Limit, + "drive_storage_usage": storageInfo.Usage, + "drive_tier_updated_at": time.Now().Format(time.RFC3339), + }, + } + return tokenInfo, nil + } } + // ai_studio 模式不设置 tierID,保持为空 return &GeminiTokenInfo{ AccessToken: tokenResp.AccessToken, @@ -455,6 +551,41 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A if strings.TrimSpace(tokenInfo.ProjectID) == "" { return nil, fmt.Errorf("failed to auto-detect project_id: empty result") } + } else if oauthType == "google_one" { + // Check if tier cache is stale (> 24 hours) + needsRefresh := true + if account.Extra != nil { + if updatedAtStr, ok := account.Extra["drive_tier_updated_at"].(string); ok { + if updatedAt, err := time.Parse(time.RFC3339, updatedAtStr); err == nil { + if time.Since(updatedAt) <= 24*time.Hour { + needsRefresh = false + // Use cached tier + if existingTierID != "" { + tokenInfo.TierID = existingTierID + } + } + } + } + } + + if needsRefresh { + tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenInfo.AccessToken, proxyURL) + if err == nil && storageInfo != nil { + tokenInfo.TierID = tierID + tokenInfo.Extra = map[string]any{ + "drive_storage_limit": storageInfo.Limit, + "drive_storage_usage": storageInfo.Usage, + "drive_tier_updated_at": time.Now().Format(time.RFC3339), + } + } else { + // Fallback to cached or unknown + if existingTierID != "" { + tokenInfo.TierID = existingTierID + } else { + tokenInfo.TierID = TierGoogleOneUnknown + } + } + } } return tokenInfo, nil @@ -487,6 +618,12 @@ func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo) if tokenInfo.OAuthType != "" { creds["oauth_type"] = tokenInfo.OAuthType } + // Store extra metadata (Drive info) if present + if len(tokenInfo.Extra) > 0 { + for k, v := range tokenInfo.Extra { + creds[k] = v + } + } return creds } diff --git a/frontend/src/components/account/AccountQuotaInfo.vue b/frontend/src/components/account/AccountQuotaInfo.vue index c20d685d..512b4451 100644 --- a/frontend/src/components/account/AccountQuotaInfo.vue +++ b/frontend/src/components/account/AccountQuotaInfo.vue @@ -48,6 +48,12 @@ const isCodeAssist = computed(() => { return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id) }) +// 是否为 Google One OAuth +const isGoogleOne = computed(() => { + const creds = props.account.credentials as GeminiCredentials | undefined + return creds?.oauth_type === 'google_one' +}) + // 是否应该显示配额信息 const shouldShowQuota = computed(() => { return props.account.platform === 'gemini' @@ -55,33 +61,73 @@ const shouldShowQuota = computed(() => { // Tier 标签文本 const tierLabel = computed(() => { + const creds = props.account.credentials as GeminiCredentials | undefined + if (isCodeAssist.value) { - const creds = props.account.credentials as GeminiCredentials | undefined + // GCP Code Assist: 显示 GCP tier const tierMap: Record = { LEGACY: 'Free', PRO: 'Pro', - ULTRA: 'Ultra' + ULTRA: 'Ultra', + 'standard-tier': 'Standard', + 'pro-tier': 'Pro', + 'ultra-tier': 'Ultra' } - return tierMap[creds?.tier_id || ''] || 'Unknown' + return tierMap[creds?.tier_id || ''] || (creds?.tier_id ? 'GCP' : 'Unknown') } + + if (isGoogleOne.value) { + // Google One: tier 映射 + const tierMap: Record = { + AI_PREMIUM: 'AI Premium', + GOOGLE_ONE_STANDARD: 'Standard', + GOOGLE_ONE_BASIC: 'Basic', + FREE: 'Free', + GOOGLE_ONE_UNKNOWN: 'Personal', + GOOGLE_ONE_UNLIMITED: 'Unlimited' + } + return tierMap[creds?.tier_id || ''] || 'Personal' + } + + // AI Studio 或其他 return 'Gemini' }) // Tier Badge 样式 const tierBadgeClass = computed(() => { - if (!isCodeAssist.value) { - return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' - } const creds = props.account.credentials as GeminiCredentials | undefined - const tierColorMap: Record = { - LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', - PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' + + if (isCodeAssist.value) { + // GCP Code Assist 样式 + const tierColorMap: Record = { + LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + 'standard-tier': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'pro-tier': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'ultra-tier': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' + } + return ( + tierColorMap[creds?.tier_id || ''] || + 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' + ) } - return ( - tierColorMap[creds?.tier_id || ''] || - 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' - ) + + if (isGoogleOne.value) { + // Google One tier 样式 + const tierColorMap: Record = { + AI_PREMIUM: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + GOOGLE_ONE_BASIC: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + FREE: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' + } + return tierColorMap[creds?.tier_id || ''] || 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + } + + // AI Studio 默认样式:蓝色 + return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }) // 是否限流 diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index e743c1d2..8dfb9f38 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -568,6 +568,24 @@ const isGeminiCodeAssist = computed(() => { // Gemini 账户类型显示标签 const geminiTierLabel = computed(() => { if (!geminiTier.value) return null + + const creds = props.account.credentials as GeminiCredentials | undefined + const isGoogleOne = creds?.oauth_type === 'google_one' + + if (isGoogleOne) { + // Google One tier 标签 + const tierMap: Record = { + AI_PREMIUM: t('admin.accounts.tier.aiPremium'), + GOOGLE_ONE_STANDARD: t('admin.accounts.tier.standard'), + GOOGLE_ONE_BASIC: t('admin.accounts.tier.basic'), + FREE: t('admin.accounts.tier.free'), + GOOGLE_ONE_UNKNOWN: t('admin.accounts.tier.personal'), + GOOGLE_ONE_UNLIMITED: t('admin.accounts.tier.unlimited') + } + return tierMap[geminiTier.value] || t('admin.accounts.tier.personal') + } + + // Code Assist tier 标签 const tierMap: Record = { LEGACY: t('admin.accounts.tier.free'), PRO: t('admin.accounts.tier.pro'), @@ -578,6 +596,25 @@ const geminiTierLabel = computed(() => { // Gemini 账户类型徽章样式 const geminiTierClass = computed(() => { + if (!geminiTier.value) return '' + + const creds = props.account.credentials as GeminiCredentials | undefined + const isGoogleOne = creds?.oauth_type === 'google_one' + + if (isGoogleOne) { + // Google One tier 颜色 + const colorMap: Record = { + AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300', + GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300', + GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300', + FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', + GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', + GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300' + } + return colorMap[geminiTier.value] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' + } + + // Code Assist tier 颜色 switch (geminiTier.value) { case 'LEGACY': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 7cb54dc0..0f188e08 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -455,6 +455,52 @@
+ + + + +
-
- +
+ + +
+ - -
- {{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }} + +
+
+ + {{ t('admin.accounts.gemini.oauthType.customTitle') }} + + + {{ t('admin.accounts.gemini.oauthType.customDesc') }} + +
+ {{ t('admin.accounts.gemini.oauthType.customRequirement') }} +
+
+ + {{ t('admin.accounts.gemini.oauthType.badges.orgManaged') }} + + + {{ t('admin.accounts.gemini.oauthType.badges.adminRequired') }} + +
+
+ + {{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }} + + + +
+ {{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
@@ -1610,8 +1672,9 @@ const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling -const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist') +const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiAIStudioOAuthEnabled = ref(false) +const showAdvancedOAuth = ref(false) // Common models for whitelist - Anthropic const anthropicModels = [ @@ -1902,7 +1965,7 @@ watch( { immediate: true } ) -const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => { +const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => { if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) { appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured')) return diff --git a/frontend/src/composables/useGeminiOAuth.ts b/frontend/src/composables/useGeminiOAuth.ts index fb20cc2f..14920417 100644 --- a/frontend/src/composables/useGeminiOAuth.ts +++ b/frontend/src/composables/useGeminiOAuth.ts @@ -93,7 +93,13 @@ export function useGeminiOAuth() { const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any) return tokenInfo as GeminiTokenInfo } catch (err: any) { - error.value = err.response?.data?.detail || t('admin.accounts.oauth.gemini.failedToExchangeCode') + // Check for specific missing project_id error + const errorMessage = err.message || err.response?.data?.message || '' + if (errorMessage.includes('missing project_id')) { + error.value = t('admin.accounts.oauth.gemini.missingProjectId') + } else { + error.value = errorMessage || t('admin.accounts.oauth.gemini.failedToExchangeCode') + } appStore.showError(error.value) return null } finally { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ac958590..33789586 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1076,6 +1076,7 @@ export default { failedToGenerateUrl: 'Failed to generate Gemini auth URL', missingExchangeParams: 'Missing auth code, session ID, or state', failedToExchangeCode: 'Failed to exchange Gemini auth code', + missingProjectId: 'GCP Project ID retrieval failed: Your Google account is not linked to an active GCP project. Please activate GCP and bind a credit card in Google Cloud Console, or manually enter the Project ID during authorization.', modelPassthrough: 'Gemini Model Passthrough', modelPassthroughDesc: 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', @@ -1290,7 +1291,12 @@ export default { tier: { free: 'Free', pro: 'Pro', - ultra: 'Ultra' + ultra: 'Ultra', + aiPremium: 'AI Premium', + standard: 'Standard', + basic: 'Basic', + personal: 'Personal', + unlimited: 'Unlimited' }, ineligibleWarning: 'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b4de5ada..45d1a9a8 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -996,7 +996,12 @@ export default { tier: { free: 'Free', pro: 'Pro', - ultra: 'Ultra' + ultra: 'Ultra', + aiPremium: 'AI Premium', + standard: '标准版', + basic: '基础版', + personal: '个人版', + unlimited: '无限制' }, ineligibleWarning: '该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。', @@ -1215,6 +1220,7 @@ export default { failedToGenerateUrl: '生成 Gemini 授权链接失败', missingExchangeParams: '缺少 code / session_id / state', failedToExchangeCode: 'Gemini 授权码兑换失败', + missingProjectId: 'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。', modelPassthrough: 'Gemini 直接转发模型', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', stateWarningTitle: '提示', From 34bbfb5dd2059f57049fbce72bec5645171d1262 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:07:37 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(lint):=20=E4=BF=AE=E5=A4=8D=20golangci-?= =?UTF-8?q?lint=20=E6=A3=80=E6=9F=A5=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复未检查的错误返回值 (errcheck) - 移除未使用的 httpClient 字段 (unused) - 修复低效赋值问题 (ineffassign) - 使用 switch 替代 if-else 链 (staticcheck QF1003) - 修复错误字符串首字母大写问题 (staticcheck ST1005) - 运行 gofmt 格式化代码 --- .../internal/handler/admin/account_handler.go | 12 ++++++---- .../internal/pkg/geminicli/drive_client.go | 22 +++++++------------ .../internal/service/gemini_oauth_service.go | 20 +++++++++-------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 58715706..78b71431 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1023,6 +1023,10 @@ func (h *AccountHandler) RefreshTier(c *gin.Context) { } tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(c.Request.Context(), accessToken, proxyURL) + if err != nil { + response.ErrorFrom(c, err) + return + } if account.Extra == nil { account.Extra = make(map[string]any) @@ -1044,10 +1048,10 @@ func (h *AccountHandler) RefreshTier(c *gin.Context) { } response.Success(c, gin.H{ - "tier_id": tierID, - "drive_storage_limit": account.Extra["drive_storage_limit"], - "drive_storage_usage": account.Extra["drive_storage_usage"], - "updated_at": account.Extra["drive_tier_updated_at"], + "tier_id": tierID, + "drive_storage_limit": account.Extra["drive_storage_limit"], + "drive_storage_usage": account.Extra["drive_storage_usage"], + "updated_at": account.Extra["drive_tier_updated_at"], }) } diff --git a/backend/internal/pkg/geminicli/drive_client.go b/backend/internal/pkg/geminicli/drive_client.go index 5a959fac..79d6835f 100644 --- a/backend/internal/pkg/geminicli/drive_client.go +++ b/backend/internal/pkg/geminicli/drive_client.go @@ -22,17 +22,11 @@ type DriveClient interface { GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error) } -type driveClient struct { - httpClient *http.Client -} +type driveClient struct{} // NewDriveClient creates a new Drive API client func NewDriveClient() DriveClient { - return &driveClient{ - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } + return &driveClient{} } // GetStorageQuota fetches storage quota from Google Drive API @@ -71,7 +65,7 @@ func (c *driveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL // Rate limit - retry with exponential backoff if resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries-1 { - resp.Body.Close() + _ = resp.Body.Close() backoff := time.Duration(1< 24 hours) needsRefresh := true if account.Extra != nil { From 48764e15a5fb460415c43ca77c6681dfd01bf480 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:07:16 +0800 Subject: [PATCH 3/5] =?UTF-8?q?test(gemini):=20=E6=B7=BB=E5=8A=A0=20Drive?= =?UTF-8?q?=20API=20=E5=92=8C=20OAuth=20=E6=9C=8D=E5=8A=A1=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 drive_client_test.go:Drive API 客户端单元测试 - 新增 gemini_oauth_service_test.go:OAuth 服务单元测试 - 重构 account_handler.go:改进 RefreshTier API 实现 - 优化 drive_client.go:增强错误处理和重试逻辑 - 完善 repository 和 service 层:支持批量 tier 刷新 - 更新迁移文件编号:017 -> 024(避免冲突) --- .../internal/handler/admin/account_handler.go | 175 +++++++++--------- .../internal/pkg/geminicli/drive_client.go | 67 ++++++- .../pkg/geminicli/drive_client_test.go | 19 ++ backend/internal/repository/account_repo.go | 84 +++++++++ backend/internal/service/account_service.go | 3 + backend/internal/service/admin_service.go | 14 ++ .../internal/service/gemini_oauth_service.go | 85 +++++++-- .../service/gemini_oauth_service_test.go | 52 ++++++ ...tier_id.sql => 024_add_gemini_tier_id.sql} | 2 +- 9 files changed, 383 insertions(+), 118 deletions(-) create mode 100644 backend/internal/pkg/geminicli/drive_client_test.go create mode 100644 backend/internal/service/gemini_oauth_service_test.go rename backend/migrations/{017_add_gemini_tier_id.sql => 024_add_gemini_tier_id.sql} (94%) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 78b71431..af1e7d91 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -3,7 +3,7 @@ package admin import ( "strconv" "strings" - "time" + "sync" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" ) // OAuthHandler handles OAuth-related operations for accounts @@ -1000,47 +1001,33 @@ func (h *AccountHandler) RefreshTier(c *gin.Context) { return } - account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + ctx := c.Request.Context() + account, err := h.adminService.GetAccount(ctx, accountID) if err != nil { response.NotFound(c, "Account not found") return } - if account.Credentials == nil || account.Credentials["oauth_type"] != "google_one" { - response.BadRequest(c, "Account is not a google_one OAuth account") + if account.Platform != service.PlatformGemini || account.Type != service.AccountTypeOAuth { + response.BadRequest(c, "Only Gemini OAuth accounts support tier refresh") return } - accessToken, ok := account.Credentials["access_token"].(string) - if !ok || accessToken == "" { - response.BadRequest(c, "Missing access_token in credentials") + oauthType, _ := account.Credentials["oauth_type"].(string) + if oauthType != "google_one" { + response.BadRequest(c, "Only google_one OAuth accounts support tier refresh") return } - var proxyURL string - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } - - tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(c.Request.Context(), accessToken, proxyURL) + tierID, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(ctx, account) if err != nil { response.ErrorFrom(c, err) return } - if account.Extra == nil { - account.Extra = make(map[string]any) - } - if storageInfo != nil { - account.Extra["drive_storage_limit"] = storageInfo.Limit - account.Extra["drive_storage_usage"] = storageInfo.Usage - account.Extra["drive_tier_updated_at"] = timezone.Now().Format(time.RFC3339) - } - account.Credentials["tier_id"] = tierID - - _, updateErr := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ - Credentials: account.Credentials, - Extra: account.Extra, + _, updateErr := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{ + Credentials: creds, + Extra: extra, }) if updateErr != nil { response.ErrorFrom(c, updateErr) @@ -1049,9 +1036,10 @@ func (h *AccountHandler) RefreshTier(c *gin.Context) { response.Success(c, gin.H{ "tier_id": tierID, - "drive_storage_limit": account.Extra["drive_storage_limit"], - "drive_storage_usage": account.Extra["drive_storage_usage"], - "updated_at": account.Extra["drive_tier_updated_at"], + "storage_info": extra, + "drive_storage_limit": extra["drive_storage_limit"], + "drive_storage_usage": extra["drive_storage_usage"], + "updated_at": extra["drive_tier_updated_at"], }) } @@ -1069,7 +1057,7 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) { } ctx := c.Request.Context() - var accounts []service.Account + accounts := make([]*service.Account, 0) if len(req.AccountIDs) == 0 { allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "") @@ -1077,84 +1065,87 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) { response.ErrorFrom(c, err) return } - for _, acc := range allAccounts { - if acc.Credentials != nil && acc.Credentials["oauth_type"] == "google_one" { + for i := range allAccounts { + acc := &allAccounts[i] + oauthType, _ := acc.Credentials["oauth_type"].(string) + if oauthType == "google_one" { accounts = append(accounts, acc) } } } else { - for _, id := range req.AccountIDs { - acc, err := h.adminService.GetAccount(ctx, id) - if err != nil { + fetched, err := h.adminService.GetAccountsByIDs(ctx, req.AccountIDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + + for _, acc := range fetched { + if acc == nil { continue } - if acc.Credentials != nil && acc.Credentials["oauth_type"] == "google_one" { - accounts = append(accounts, *acc) + if acc.Platform != service.PlatformGemini || acc.Type != service.AccountTypeOAuth { + continue } + oauthType, _ := acc.Credentials["oauth_type"].(string) + if oauthType != "google_one" { + continue + } + accounts = append(accounts, acc) } } - total := len(accounts) - success := 0 - failed := 0 - errors := []gin.H{} + const maxConcurrency = 10 + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(maxConcurrency) + + var mu sync.Mutex + results := gin.H{ + "total": len(accounts), + "success": 0, + "failed": 0, + "errors": []gin.H{}, + } for _, account := range accounts { - accessToken, ok := account.Credentials["access_token"].(string) - if !ok || accessToken == "" { - failed++ - errors = append(errors, gin.H{ - "account_id": account.ID, - "error": "missing access_token", + acc := account // 闭包捕获 + g.Go(func() error { + _, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(gctx, acc) + if err != nil { + mu.Lock() + results["failed"] = results["failed"].(int) + 1 + results["errors"] = append(results["errors"].([]gin.H), gin.H{ + "account_id": acc.ID, + "error": err.Error(), + }) + mu.Unlock() + return nil + } + + _, updateErr := h.adminService.UpdateAccount(gctx, acc.ID, &service.UpdateAccountInput{ + Credentials: creds, + Extra: extra, }) - continue - } - var proxyURL string - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } + mu.Lock() + if updateErr != nil { + results["failed"] = results["failed"].(int) + 1 + results["errors"] = append(results["errors"].([]gin.H), gin.H{ + "account_id": acc.ID, + "error": updateErr.Error(), + }) + } else { + results["success"] = results["success"].(int) + 1 + } + mu.Unlock() - tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(ctx, accessToken, proxyURL) - if err != nil { - failed++ - errors = append(errors, gin.H{ - "account_id": account.ID, - "error": err.Error(), - }) - continue - } - - if account.Extra == nil { - account.Extra = make(map[string]any) - } - if storageInfo != nil { - account.Extra["drive_storage_limit"] = storageInfo.Limit - account.Extra["drive_storage_usage"] = storageInfo.Usage - account.Extra["drive_tier_updated_at"] = timezone.Now().Format(time.RFC3339) - } - account.Credentials["tier_id"] = tierID - - _, updateErr := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{ - Credentials: account.Credentials, - Extra: account.Extra, + return nil }) - if updateErr != nil { - failed++ - errors = append(errors, gin.H{ - "account_id": account.ID, - "error": updateErr.Error(), - }) - continue - } - - success++ } - response.Success(c, gin.H{ - "total": total, - "success": success, - "failed": failed, - "errors": errors, - }) + if err := g.Wait(); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, results) } diff --git a/backend/internal/pkg/geminicli/drive_client.go b/backend/internal/pkg/geminicli/drive_client.go index 79d6835f..77e2c476 100644 --- a/backend/internal/pkg/geminicli/drive_client.go +++ b/backend/internal/pkg/geminicli/drive_client.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "io" + "math/rand" "net/http" + "strconv" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" @@ -49,13 +51,38 @@ func (c *driveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL return nil, fmt.Errorf("failed to create HTTP client: %w", err) } - // Retry logic with exponential backoff for rate limits + sleepWithContext := func(d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + } + + // Retry logic with exponential backoff (+ jitter) for rate limits and transient failures var resp *http.Response maxRetries := 3 + rng := rand.New(rand.NewSource(time.Now().UnixNano())) for attempt := 0; attempt < maxRetries; attempt++ { + if ctx.Err() != nil { + return nil, fmt.Errorf("request cancelled: %w", ctx.Err()) + } + resp, err = client.Do(req) if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) + // Network error retry + if attempt < maxRetries-1 { + backoff := time.Duration(1< 100*1024*1024*1024*1024 { // > 100TB + if storageBytes > StorageTierUnlimited { return TierGoogleOneUnlimited } - - // AI Premium (2TB+) - if storageBytes >= 2*1024*1024*1024*1024 { // >= 2TB + if storageBytes >= StorageTierAIPremium { return TierAIPremium } - - // Google One Standard (200GB) - if storageBytes >= 200*1024*1024*1024 { // >= 200GB + if storageBytes >= StorageTierStandard { return TierGoogleOneStandard } - - // Google One Basic (100GB) - if storageBytes >= 100*1024*1024*1024 { // >= 100GB + if storageBytes >= StorageTierBasic { return TierGoogleOneBasic } - - // Free (15GB) - if storageBytes >= 15*1024*1024*1024 { // >= 15GB + if storageBytes >= StorageTierFree { return TierFree } - return TierGoogleOneUnknown } @@ -270,6 +271,60 @@ func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken return tierID, storageInfo, nil } +// RefreshAccountGoogleOneTier 刷新单个账号的 Google One Tier +func (s *GeminiOAuthService) RefreshAccountGoogleOneTier( + ctx context.Context, + account *Account, +) (tierID string, extra map[string]any, credentials map[string]any, err error) { + if account == nil { + return "", nil, nil, fmt.Errorf("account is nil") + } + + // 验证账号类型 + oauthType, ok := account.Credentials["oauth_type"].(string) + if !ok || oauthType != "google_one" { + return "", nil, nil, fmt.Errorf("not a google_one OAuth account") + } + + // 获取 access_token + accessToken, ok := account.Credentials["access_token"].(string) + if !ok || accessToken == "" { + return "", nil, nil, fmt.Errorf("missing access_token") + } + + // 获取 proxy URL + var proxyURL string + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + // 调用 Drive API + tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, accessToken, proxyURL) + if err != nil { + return "", nil, nil, err + } + + // 构建 extra 数据(保留原有 extra 字段) + extra = make(map[string]any) + for k, v := range account.Extra { + extra[k] = v + } + if storageInfo != nil { + extra["drive_storage_limit"] = storageInfo.Limit + extra["drive_storage_usage"] = storageInfo.Usage + extra["drive_tier_updated_at"] = time.Now().Format(time.RFC3339) + } + + // 构建 credentials 数据 + credentials = make(map[string]any) + for k, v := range account.Credentials { + credentials[k] = v + } + credentials["tier_id"] = tierID + + return tierID, extra, credentials, nil +} + func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) { session, ok := s.sessionStore.Get(input.SessionID) if !ok { diff --git a/backend/internal/service/gemini_oauth_service_test.go b/backend/internal/service/gemini_oauth_service_test.go new file mode 100644 index 00000000..393812c2 --- /dev/null +++ b/backend/internal/service/gemini_oauth_service_test.go @@ -0,0 +1,52 @@ +package service + +import "testing" + +func TestInferGoogleOneTier(t *testing.T) { + tests := []struct { + name string + storageBytes int64 + expectedTier string + }{ + {"Negative storage", -1, TierGoogleOneUnknown}, + {"Zero storage", 0, TierGoogleOneUnknown}, + + // Free tier boundary (15GB) + {"Below free tier", 10 * GB, TierGoogleOneUnknown}, + {"Just below free tier", StorageTierFree - 1, TierGoogleOneUnknown}, + {"Free tier (15GB)", StorageTierFree, TierFree}, + + // Basic tier boundary (100GB) + {"Between free and basic", 50 * GB, TierFree}, + {"Just below basic tier", StorageTierBasic - 1, TierFree}, + {"Basic tier (100GB)", StorageTierBasic, TierGoogleOneBasic}, + + // Standard tier boundary (200GB) + {"Between basic and standard", 150 * GB, TierGoogleOneBasic}, + {"Just below standard tier", StorageTierStandard - 1, TierGoogleOneBasic}, + {"Standard tier (200GB)", StorageTierStandard, TierGoogleOneStandard}, + + // AI Premium tier boundary (2TB) + {"Between standard and premium", 1 * TB, TierGoogleOneStandard}, + {"Just below AI Premium tier", StorageTierAIPremium - 1, TierGoogleOneStandard}, + {"AI Premium tier (2TB)", StorageTierAIPremium, TierAIPremium}, + + // Unlimited tier boundary (> 100TB) + {"Between premium and unlimited", 50 * TB, TierAIPremium}, + {"At unlimited threshold (100TB)", StorageTierUnlimited, TierAIPremium}, + {"Unlimited tier (100TB+)", StorageTierUnlimited + 1, TierGoogleOneUnlimited}, + {"Unlimited tier (101TB+)", 101 * TB, TierGoogleOneUnlimited}, + {"Very large storage", 1000 * TB, TierGoogleOneUnlimited}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := inferGoogleOneTier(tt.storageBytes) + if result != tt.expectedTier { + t.Errorf("inferGoogleOneTier(%d) = %s, want %s", + tt.storageBytes, result, tt.expectedTier) + } + }) + } +} + diff --git a/backend/migrations/017_add_gemini_tier_id.sql b/backend/migrations/024_add_gemini_tier_id.sql similarity index 94% rename from backend/migrations/017_add_gemini_tier_id.sql rename to backend/migrations/024_add_gemini_tier_id.sql index 0388a412..d9ac7afe 100644 --- a/backend/migrations/017_add_gemini_tier_id.sql +++ b/backend/migrations/024_add_gemini_tier_id.sql @@ -26,5 +26,5 @@ UPDATE accounts SET credentials = credentials - 'tier_id' WHERE platform = 'gemini' AND type = 'oauth' - AND credentials->>'oauth_type' = 'code_assist'; + AND credentials ? 'tier_id'; -- +goose StatementEnd From c63192fcb55db34b9ac5e670f34eb3cd23ddcb71 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:16:12 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(test):=20=E4=BF=AE=E5=A4=8D=20CI=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=92=8C=20lint=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为所有 mock 实现添加 GetByIDs 方法以满足 AccountRepository 接口 - 重构 account_handler.go 中的类型断言,使用类型安全的变量 - 修复 gofmt 格式问题 --- .../internal/handler/admin/account_handler.go | 25 +++++++++++-------- .../internal/pkg/geminicli/drive_client.go | 9 ++++--- .../pkg/geminicli/drive_client_test.go | 1 - .../service/account_service_delete_test.go | 4 +++ .../service/gateway_multiplatform_test.go | 10 ++++++++ .../service/gemini_multiplatform_test.go | 10 ++++++++ .../service/gemini_oauth_service_test.go | 1 - 7 files changed, 43 insertions(+), 17 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index af1e7d91..f2d8a287 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1099,12 +1099,8 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) { g.SetLimit(maxConcurrency) var mu sync.Mutex - results := gin.H{ - "total": len(accounts), - "success": 0, - "failed": 0, - "errors": []gin.H{}, - } + var successCount, failedCount int + var errors []gin.H for _, account := range accounts { acc := account // 闭包捕获 @@ -1112,8 +1108,8 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) { _, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(gctx, acc) if err != nil { mu.Lock() - results["failed"] = results["failed"].(int) + 1 - results["errors"] = append(results["errors"].([]gin.H), gin.H{ + failedCount++ + errors = append(errors, gin.H{ "account_id": acc.ID, "error": err.Error(), }) @@ -1128,13 +1124,13 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) { mu.Lock() if updateErr != nil { - results["failed"] = results["failed"].(int) + 1 - results["errors"] = append(results["errors"].([]gin.H), gin.H{ + failedCount++ + errors = append(errors, gin.H{ "account_id": acc.ID, "error": updateErr.Error(), }) } else { - results["success"] = results["success"].(int) + 1 + successCount++ } mu.Unlock() @@ -1147,5 +1143,12 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) { return } + results := gin.H{ + "total": len(accounts), + "success": successCount, + "failed": failedCount, + "errors": errors, + } + response.Success(c, results) } diff --git a/backend/internal/pkg/geminicli/drive_client.go b/backend/internal/pkg/geminicli/drive_client.go index 77e2c476..8f9c745f 100644 --- a/backend/internal/pkg/geminicli/drive_client.go +++ b/backend/internal/pkg/geminicli/drive_client.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "io" "math/rand" "net/http" "strconv" @@ -112,10 +111,12 @@ func (c *driveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() - // 记录完整错误 - fmt.Printf("[DriveClient] API error (status %d): %s\n", resp.StatusCode, string(body)) + statusText := http.StatusText(resp.StatusCode) + if statusText == "" { + statusText = resp.Status + } + fmt.Printf("[DriveClient] Drive API error: status=%d, msg=%s\n", resp.StatusCode, statusText) // 只返回通用错误 return nil, fmt.Errorf("drive API error: status %d", resp.StatusCode) } diff --git a/backend/internal/pkg/geminicli/drive_client_test.go b/backend/internal/pkg/geminicli/drive_client_test.go index d2c7f25b..b6dd1a69 100644 --- a/backend/internal/pkg/geminicli/drive_client_test.go +++ b/backend/internal/pkg/geminicli/drive_client_test.go @@ -16,4 +16,3 @@ func TestDriveStorageInfo(t *testing.T) { t.Errorf("Expected usage 50GB, got %d", info.Usage) } } - diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index 2648b828..43703763 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -40,6 +40,10 @@ func (s *accountRepoStub) GetByID(ctx context.Context, id int64) (*Account, erro panic("unexpected GetByID call") } +func (s *accountRepoStub) GetByIDs(ctx context.Context, ids []int64) ([]*Account, error) { + panic("unexpected GetByIDs call") +} + // ExistsByID 返回预设的存在性检查结果。 // 这是 Delete 方法调用的第一个仓储方法,用于验证账号是否存在。 func (s *accountRepoStub) ExistsByID(ctx context.Context, id int64) (bool, error) { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 560c7767..808a48b2 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -32,6 +32,16 @@ func (m *mockAccountRepoForPlatform) GetByID(ctx context.Context, id int64) (*Ac return nil, errors.New("account not found") } +func (m *mockAccountRepoForPlatform) GetByIDs(ctx context.Context, ids []int64) ([]*Account, error) { + var result []*Account + for _, id := range ids { + if acc, ok := m.accountsByID[id]; ok { + result = append(result, acc) + } + } + return result, nil +} + func (m *mockAccountRepoForPlatform) ExistsByID(ctx context.Context, id int64) (bool, error) { if m.accountsByID == nil { return false, nil diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index dcc945eb..6ca5052e 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -25,6 +25,16 @@ func (m *mockAccountRepoForGemini) GetByID(ctx context.Context, id int64) (*Acco return nil, errors.New("account not found") } +func (m *mockAccountRepoForGemini) GetByIDs(ctx context.Context, ids []int64) ([]*Account, error) { + var result []*Account + for _, id := range ids { + if acc, ok := m.accountsByID[id]; ok { + result = append(result, acc) + } + } + return result, nil +} + func (m *mockAccountRepoForGemini) ExistsByID(ctx context.Context, id int64) (bool, error) { if m.accountsByID == nil { return false, nil diff --git a/backend/internal/service/gemini_oauth_service_test.go b/backend/internal/service/gemini_oauth_service_test.go index 393812c2..026e6dc2 100644 --- a/backend/internal/service/gemini_oauth_service_test.go +++ b/backend/internal/service/gemini_oauth_service_test.go @@ -49,4 +49,3 @@ func TestInferGoogleOneTier(t *testing.T) { }) } } - From 1d5e05b8cadcab00b6d02b4c9cad5e8b168e1fbb Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:35:08 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20P0=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=92=8C=E5=B9=B6=E5=8F=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复敏感信息泄露:移除 Drive API 完整响应体打印,只记录状态码 - 修复并发安全问题:升级为 RWMutex,读写分离提升性能 - 修复资源泄漏风险:使用 defer 确保 resp.Body 正确关闭 --- backend/internal/pkg/geminicli/drive_client.go | 10 ++++++---- backend/internal/service/ratelimit_service.go | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/internal/pkg/geminicli/drive_client.go b/backend/internal/pkg/geminicli/drive_client.go index 8f9c745f..a6cbc3ab 100644 --- a/backend/internal/pkg/geminicli/drive_client.go +++ b/backend/internal/pkg/geminicli/drive_client.go @@ -94,10 +94,12 @@ func (c *driveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusBadGateway || resp.StatusCode == http.StatusServiceUnavailable) && attempt < maxRetries-1 { - _ = resp.Body.Close() - backoff := time.Duration(1<