diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index ac938f8c..f2d8a287 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" + "sync" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -13,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 @@ -989,3 +991,164 @@ 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 + } + + ctx := c.Request.Context() + account, err := h.adminService.GetAccount(ctx, accountID) + if err != nil { + response.NotFound(c, "Account not found") + return + } + + if account.Platform != service.PlatformGemini || account.Type != service.AccountTypeOAuth { + response.BadRequest(c, "Only Gemini OAuth accounts support tier refresh") + return + } + + oauthType, _ := account.Credentials["oauth_type"].(string) + if oauthType != "google_one" { + response.BadRequest(c, "Only google_one OAuth accounts support tier refresh") + return + } + + tierID, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(ctx, account) + if err != nil { + response.ErrorFrom(c, err) + return + } + + _, updateErr := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{ + Credentials: creds, + Extra: extra, + }) + if updateErr != nil { + response.ErrorFrom(c, updateErr) + return + } + + response.Success(c, gin.H{ + "tier_id": tierID, + "storage_info": extra, + "drive_storage_limit": extra["drive_storage_limit"], + "drive_storage_usage": extra["drive_storage_usage"], + "updated_at": 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() + accounts := make([]*service.Account, 0) + + if len(req.AccountIDs) == 0 { + allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "") + if err != nil { + response.ErrorFrom(c, err) + return + } + for i := range allAccounts { + acc := &allAccounts[i] + oauthType, _ := acc.Credentials["oauth_type"].(string) + if oauthType == "google_one" { + accounts = append(accounts, acc) + } + } + } else { + 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.Platform != service.PlatformGemini || acc.Type != service.AccountTypeOAuth { + continue + } + oauthType, _ := acc.Credentials["oauth_type"].(string) + if oauthType != "google_one" { + continue + } + accounts = append(accounts, acc) + } + } + + const maxConcurrency = 10 + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(maxConcurrency) + + var mu sync.Mutex + var successCount, failedCount int + var errors []gin.H + + for _, account := range accounts { + acc := account // 闭包捕获 + g.Go(func() error { + _, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(gctx, acc) + if err != nil { + mu.Lock() + failedCount++ + errors = append(errors, 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, + }) + + mu.Lock() + if updateErr != nil { + failedCount++ + errors = append(errors, gin.H{ + "account_id": acc.ID, + "error": updateErr.Error(), + }) + } else { + successCount++ + } + mu.Unlock() + + return nil + }) + } + + if err := g.Wait(); err != nil { + response.ErrorFrom(c, err) + return + } + + results := gin.H{ + "total": len(accounts), + "success": successCount, + "failed": failedCount, + "errors": errors, + } + + response.Success(c, results) +} 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..a6cbc3ab --- /dev/null +++ b/backend/internal/pkg/geminicli/drive_client.go @@ -0,0 +1,157 @@ +package geminicli + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "strconv" + "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{} + +// NewDriveClient creates a new Drive API client +func NewDriveClient() DriveClient { + return &driveClient{} +} + +// 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) + } + + 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 { + // Network error retry + if attempt < maxRetries-1 { + backoff := time.Duration(1< StorageTierUnlimited { + return TierGoogleOneUnlimited + } + if storageBytes >= StorageTierAIPremium { + return TierAIPremium + } + if storageBytes >= StorageTierStandard { + return TierGoogleOneStandard + } + if storageBytes >= StorageTierBasic { + return TierGoogleOneBasic + } + if storageBytes >= StorageTierFree { + 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 +} + +// 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 { @@ -272,9 +392,11 @@ 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" { + switch oauthType { + case "code_assist": if projectID == "" { var err error projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL) @@ -298,7 +420,37 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch if tierID == "" { tierID = "LEGACY" } + case "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, @@ -427,7 +579,8 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A // 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" { + switch oauthType { + case "code_assist": // 先设置默认值或保留旧值,确保 tier_id 始终有值 if existingTierID != "" { tokenInfo.TierID = existingTierID @@ -455,6 +608,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") } + case "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 +675,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/backend/internal/service/gemini_oauth_service_test.go b/backend/internal/service/gemini_oauth_service_test.go new file mode 100644 index 00000000..026e6dc2 --- /dev/null +++ b/backend/internal/service/gemini_oauth_service_test.go @@ -0,0 +1,51 @@ +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/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 0511d950..57d606fb 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -18,7 +18,7 @@ type RateLimitService struct { usageRepo UsageLogRepository cfg *config.Config geminiQuotaService *GeminiQuotaService - usageCacheMu sync.Mutex + usageCacheMu sync.RWMutex usageCache map[int64]*geminiUsageCacheEntry } @@ -138,8 +138,8 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, } func (s *RateLimitService) getGeminiUsageTotals(accountID int64, windowStart, now time.Time) (GeminiUsageTotals, bool) { - s.usageCacheMu.Lock() - defer s.usageCacheMu.Unlock() + s.usageCacheMu.RLock() + defer s.usageCacheMu.RUnlock() if s.usageCache == nil { return GeminiUsageTotals{}, false 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 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: '提示',