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:
ianshaw
2025-12-31 21:45:24 -08:00
parent 4f13c8de0d
commit 7df914af06
11 changed files with 691 additions and 110 deletions

View File

@@ -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,
})
}

View File

@@ -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
}