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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user