feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述 通过 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小时缓存避免频繁调用 ## 测试 - ✅ 后端编译成功 - ✅ 前端构建成功 - ✅ 所有代码符合现有规范
This commit is contained in:
@@ -17,6 +17,15 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
)
|
||||
|
||||
const (
|
||||
TierAIPremium = "AI_PREMIUM"
|
||||
TierGoogleOneStandard = "GOOGLE_ONE_STANDARD"
|
||||
TierGoogleOneBasic = "GOOGLE_ONE_BASIC"
|
||||
TierFree = "FREE"
|
||||
TierGoogleOneUnknown = "GOOGLE_ONE_UNKNOWN"
|
||||
TierGoogleOneUnlimited = "GOOGLE_ONE_UNLIMITED"
|
||||
)
|
||||
|
||||
type GeminiOAuthService struct {
|
||||
sessionStore *geminicli.SessionStore
|
||||
proxyRepo ProxyRepository
|
||||
@@ -89,13 +98,14 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
|
||||
|
||||
// OAuth client selection:
|
||||
// - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret.
|
||||
// - google_one: same as code_assist, uses built-in client for personal Google accounts.
|
||||
// - ai_studio: requires a user-provided OAuth client.
|
||||
oauthCfg := geminicli.OAuthConfig{
|
||||
ClientID: s.cfg.Gemini.OAuth.ClientID,
|
||||
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
|
||||
Scopes: s.cfg.Gemini.OAuth.Scopes,
|
||||
}
|
||||
if oauthType == "code_assist" {
|
||||
if oauthType == "code_assist" || oauthType == "google_one" {
|
||||
oauthCfg.ClientID = ""
|
||||
oauthCfg.ClientSecret = ""
|
||||
}
|
||||
@@ -156,15 +166,16 @@ type GeminiExchangeCodeInput struct {
|
||||
}
|
||||
|
||||
type GeminiTokenInfo struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
|
||||
TierID string `json:"tier_id,omitempty"` // Gemini Code Assist tier: LEGACY/PRO/ULTRA
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
|
||||
TierID string `json:"tier_id,omitempty"` // Gemini Code Assist tier: LEGACY/PRO/ULTRA
|
||||
Extra map[string]any `json:"extra,omitempty"` // Drive metadata
|
||||
}
|
||||
|
||||
// validateTierID validates tier_id format and length
|
||||
@@ -205,6 +216,60 @@ func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string
|
||||
return tierID
|
||||
}
|
||||
|
||||
// inferGoogleOneTier infers Google One tier from Drive storage limit
|
||||
func inferGoogleOneTier(storageBytes int64) string {
|
||||
if storageBytes <= 0 {
|
||||
return TierGoogleOneUnknown
|
||||
}
|
||||
|
||||
// Unlimited storage (G Suite legacy)
|
||||
if storageBytes > 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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user