Merge PR #118: feat(gemini): 添加 Google One 存储空间推断 Tier 功能

This commit is contained in:
shaw
2026-01-01 16:54:30 +08:00
21 changed files with 990 additions and 116 deletions

View File

@@ -3,6 +3,7 @@ package admin
import ( import (
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
@@ -13,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
) )
// OAuthHandler handles OAuth-related operations for accounts // OAuthHandler handles OAuth-related operations for accounts
@@ -989,3 +991,164 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
response.Success(c, models) 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)
}

View File

@@ -46,8 +46,8 @@ func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) {
if oauthType == "" { if oauthType == "" {
oauthType = "code_assist" oauthType = "code_assist"
} }
if oauthType != "code_assist" && oauthType != "ai_studio" { if oauthType != "code_assist" && oauthType != "google_one" && oauthType != "ai_studio" {
response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") response.BadRequest(c, "Invalid oauth_type: must be 'code_assist', 'google_one', or 'ai_studio'")
return return
} }
@@ -92,8 +92,8 @@ func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) {
if oauthType == "" { if oauthType == "" {
oauthType = "code_assist" oauthType = "code_assist"
} }
if oauthType != "code_assist" && oauthType != "ai_studio" { if oauthType != "code_assist" && oauthType != "google_one" && oauthType != "ai_studio" {
response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") response.BadRequest(c, "Invalid oauth_type: must be 'code_assist', 'google_one', or 'ai_studio'")
return return
} }

View File

@@ -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<<uint(attempt)) * time.Second
jitter := time.Duration(rng.Intn(1000)) * time.Millisecond
if err := sleepWithContext(backoff + jitter); err != nil {
return nil, fmt.Errorf("request cancelled: %w", err)
}
continue
}
return nil, fmt.Errorf("network error after %d attempts: %w", maxRetries, err)
}
// Success
if resp.StatusCode == http.StatusOK {
break
}
// Retry 429, 500, 502, 503 with exponential backoff + jitter
if (resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode == http.StatusInternalServerError ||
resp.StatusCode == http.StatusBadGateway ||
resp.StatusCode == http.StatusServiceUnavailable) && attempt < maxRetries-1 {
if err := func() error {
defer func() { _ = resp.Body.Close() }()
backoff := time.Duration(1<<uint(attempt)) * time.Second
jitter := time.Duration(rng.Intn(1000)) * time.Millisecond
return sleepWithContext(backoff + jitter)
}(); err != nil {
return nil, fmt.Errorf("request cancelled: %w", err)
}
continue
}
break
}
if resp == nil {
return nil, fmt.Errorf("request failed: no response received")
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
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)
}
defer func() { _ = resp.Body.Close() }()
// Parse response
var result struct {
StorageQuota struct {
Limit string `json:"limit"` // Can be string or number
Usage string `json:"usage"`
} `json:"storageQuota"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Parse limit and usage (handle both string and number formats)
var limit, usage int64
if result.StorageQuota.Limit != "" {
if val, err := strconv.ParseInt(result.StorageQuota.Limit, 10, 64); err == nil {
limit = val
}
}
if result.StorageQuota.Usage != "" {
if val, err := strconv.ParseInt(result.StorageQuota.Usage, 10, 64); err == nil {
usage = val
}
}
return &DriveStorageInfo{
Limit: limit,
Usage: usage,
}, nil
}

View File

@@ -0,0 +1,18 @@
package geminicli
import "testing"
func TestDriveStorageInfo(t *testing.T) {
// 测试 DriveStorageInfo 结构体
info := &DriveStorageInfo{
Limit: 100 * 1024 * 1024 * 1024, // 100GB
Usage: 50 * 1024 * 1024 * 1024, // 50GB
}
if info.Limit != 100*1024*1024*1024 {
t.Errorf("Expected limit 100GB, got %d", info.Limit)
}
if info.Usage != 50*1024*1024*1024 {
t.Errorf("Expected usage 50GB, got %d", info.Usage)
}
}

View File

@@ -124,6 +124,90 @@ func (r *accountRepository) GetByID(ctx context.Context, id int64) (*service.Acc
return &accounts[0], nil return &accounts[0], nil
} }
func (r *accountRepository) GetByIDs(ctx context.Context, ids []int64) ([]*service.Account, error) {
if len(ids) == 0 {
return []*service.Account{}, nil
}
// De-duplicate while preserving order of first occurrence.
uniqueIDs := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
uniqueIDs = append(uniqueIDs, id)
}
if len(uniqueIDs) == 0 {
return []*service.Account{}, nil
}
entAccounts, err := r.client.Account.
Query().
Where(dbaccount.IDIn(uniqueIDs...)).
WithProxy().
All(ctx)
if err != nil {
return nil, err
}
if len(entAccounts) == 0 {
return []*service.Account{}, nil
}
accountIDs := make([]int64, 0, len(entAccounts))
entByID := make(map[int64]*dbent.Account, len(entAccounts))
for _, acc := range entAccounts {
entByID[acc.ID] = acc
accountIDs = append(accountIDs, acc.ID)
}
groupsByAccount, groupIDsByAccount, accountGroupsByAccount, err := r.loadAccountGroups(ctx, accountIDs)
if err != nil {
return nil, err
}
outByID := make(map[int64]*service.Account, len(entAccounts))
for _, entAcc := range entAccounts {
out := accountEntityToService(entAcc)
if out == nil {
continue
}
// Prefer the preloaded proxy edge when available.
if entAcc.Edges.Proxy != nil {
out.Proxy = proxyEntityToService(entAcc.Edges.Proxy)
}
if groups, ok := groupsByAccount[entAcc.ID]; ok {
out.Groups = groups
}
if groupIDs, ok := groupIDsByAccount[entAcc.ID]; ok {
out.GroupIDs = groupIDs
}
if ags, ok := accountGroupsByAccount[entAcc.ID]; ok {
out.AccountGroups = ags
}
outByID[entAcc.ID] = out
}
// Preserve input order (first occurrence), and ignore missing IDs.
out := make([]*service.Account, 0, len(uniqueIDs))
for _, id := range uniqueIDs {
if _, ok := entByID[id]; !ok {
continue
}
if acc, ok := outByID[id]; ok && acc != nil {
out = append(out, acc)
}
}
return out, nil
}
// ExistsByID 检查指定 ID 的账号是否存在。 // ExistsByID 检查指定 ID 的账号是否存在。
// 相比 GetByID此方法性能更优因为 // 相比 GetByID此方法性能更优因为
// - 使用 Exist() 方法生成 SELECT EXISTS 查询,只返回布尔值 // - 使用 Exist() 方法生成 SELECT EXISTS 查询,只返回布尔值

View File

@@ -110,6 +110,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.DELETE("/:id", h.Admin.Account.Delete)
accounts.POST("/:id/test", h.Admin.Account.Test) accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/refresh", h.Admin.Account.Refresh) accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
accounts.GET("/:id/stats", h.Admin.Account.GetStats) accounts.GET("/:id/stats", h.Admin.Account.GetStats)
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError) accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)
accounts.GET("/:id/usage", h.Admin.Account.GetUsage) accounts.GET("/:id/usage", h.Admin.Account.GetUsage)
@@ -119,6 +120,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels) accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
accounts.POST("/batch", h.Admin.Account.BatchCreate) accounts.POST("/batch", h.Admin.Account.BatchCreate)
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
// Claude OAuth routes // Claude OAuth routes

View File

@@ -17,6 +17,9 @@ var (
type AccountRepository interface { type AccountRepository interface {
Create(ctx context.Context, account *Account) error Create(ctx context.Context, account *Account) error
GetByID(ctx context.Context, id int64) (*Account, error) GetByID(ctx context.Context, id int64) (*Account, error)
// GetByIDs fetches accounts by IDs in a single query.
// It should return all accounts found (missing IDs are ignored).
GetByIDs(ctx context.Context, ids []int64) ([]*Account, error)
// ExistsByID 检查账号是否存在,仅返回布尔值,用于删除前的轻量级存在性检查 // ExistsByID 检查账号是否存在,仅返回布尔值,用于删除前的轻量级存在性检查
ExistsByID(ctx context.Context, id int64) (bool, error) ExistsByID(ctx context.Context, id int64) (bool, error)
// GetByCRSAccountID finds an account previously synced from CRS. // GetByCRSAccountID finds an account previously synced from CRS.

View File

@@ -40,6 +40,10 @@ func (s *accountRepoStub) GetByID(ctx context.Context, id int64) (*Account, erro
panic("unexpected GetByID call") panic("unexpected GetByID call")
} }
func (s *accountRepoStub) GetByIDs(ctx context.Context, ids []int64) ([]*Account, error) {
panic("unexpected GetByIDs call")
}
// ExistsByID 返回预设的存在性检查结果。 // ExistsByID 返回预设的存在性检查结果。
// 这是 Delete 方法调用的第一个仓储方法,用于验证账号是否存在。 // 这是 Delete 方法调用的第一个仓储方法,用于验证账号是否存在。
func (s *accountRepoStub) ExistsByID(ctx context.Context, id int64) (bool, error) { func (s *accountRepoStub) ExistsByID(ctx context.Context, id int64) (bool, error) {

View File

@@ -35,6 +35,7 @@ type AdminService interface {
// Account management // Account management
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error)
GetAccount(ctx context.Context, id int64) (*Account, error) GetAccount(ctx context.Context, id int64) (*Account, error)
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error)
DeleteAccount(ctx context.Context, id int64) error DeleteAccount(ctx context.Context, id int64) error
@@ -611,6 +612,19 @@ func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account,
return s.accountRepo.GetByID(ctx, id) return s.accountRepo.GetByID(ctx, id)
} }
func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) {
if len(ids) == 0 {
return []*Account{}, nil
}
accounts, err := s.accountRepo.GetByIDs(ctx, ids)
if err != nil {
return nil, fmt.Errorf("failed to get accounts by IDs: %w", err)
}
return accounts, nil
}
func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
account := &Account{ account := &Account{
Name: input.Name, Name: input.Name,

View File

@@ -32,6 +32,16 @@ func (m *mockAccountRepoForPlatform) GetByID(ctx context.Context, id int64) (*Ac
return nil, errors.New("account not found") 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) { func (m *mockAccountRepoForPlatform) ExistsByID(ctx context.Context, id int64) (bool, error) {
if m.accountsByID == nil { if m.accountsByID == nil {
return false, nil return false, nil

View File

@@ -25,6 +25,16 @@ func (m *mockAccountRepoForGemini) GetByID(ctx context.Context, id int64) (*Acco
return nil, errors.New("account not found") 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) { func (m *mockAccountRepoForGemini) ExistsByID(ctx context.Context, id int64) (bool, error) {
if m.accountsByID == nil { if m.accountsByID == nil {
return false, nil return false, nil

View File

@@ -17,6 +17,26 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "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"
)
const (
GB = 1024 * 1024 * 1024
TB = 1024 * GB
StorageTierUnlimited = 100 * TB // 100TB
StorageTierAIPremium = 2 * TB // 2TB
StorageTierStandard = 200 * GB // 200GB
StorageTierBasic = 100 * GB // 100GB
StorageTierFree = 15 * GB // 15GB
)
type GeminiOAuthService struct { type GeminiOAuthService struct {
sessionStore *geminicli.SessionStore sessionStore *geminicli.SessionStore
proxyRepo ProxyRepository proxyRepo ProxyRepository
@@ -89,13 +109,14 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
// OAuth client selection: // OAuth client selection:
// - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret. // - 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. // - ai_studio: requires a user-provided OAuth client.
oauthCfg := geminicli.OAuthConfig{ oauthCfg := geminicli.OAuthConfig{
ClientID: s.cfg.Gemini.OAuth.ClientID, ClientID: s.cfg.Gemini.OAuth.ClientID,
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret, ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
Scopes: s.cfg.Gemini.OAuth.Scopes, Scopes: s.cfg.Gemini.OAuth.Scopes,
} }
if oauthType == "code_assist" { if oauthType == "code_assist" || oauthType == "google_one" {
oauthCfg.ClientID = "" oauthCfg.ClientID = ""
oauthCfg.ClientSecret = "" oauthCfg.ClientSecret = ""
} }
@@ -156,15 +177,16 @@ type GeminiExchangeCodeInput struct {
} }
type GeminiTokenInfo struct { type GeminiTokenInfo struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"` ExpiresIn int64 `json:"expires_in"`
ExpiresAt int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
ProjectID string `json:"project_id,omitempty"` ProjectID string `json:"project_id,omitempty"`
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio" OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
TierID string `json:"tier_id,omitempty"` // Gemini Code Assist tier: LEGACY/PRO/ULTRA 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 // validateTierID validates tier_id format and length
@@ -205,6 +227,104 @@ func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string
return tierID return tierID
} }
// inferGoogleOneTier infers Google One tier from Drive storage limit
func inferGoogleOneTier(storageBytes int64) string {
if storageBytes <= 0 {
return TierGoogleOneUnknown
}
if storageBytes > 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) { func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) {
session, ok := s.sessionStore.Get(input.SessionID) session, ok := s.sessionStore.Get(input.SessionID)
if !ok { if !ok {
@@ -272,9 +392,11 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
projectID := sessionProjectID projectID := sessionProjectID
var tierID string 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 // 对于 ai_studio 模式project_id 是可选的(不影响使用 AI Studio API
if oauthType == "code_assist" { switch oauthType {
case "code_assist":
if projectID == "" { if projectID == "" {
var err error var err error
projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL) projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
@@ -298,7 +420,37 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
if tierID == "" { if tierID == "" {
tierID = "LEGACY" 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{ return &GeminiTokenInfo{
AccessToken: tokenResp.AccessToken, 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 Code Assist, project_id is required. Auto-detect if missing.
// For AI Studio OAuth, project_id is optional and should not block refresh. // For AI Studio OAuth, project_id is optional and should not block refresh.
if oauthType == "code_assist" { switch oauthType {
case "code_assist":
// 先设置默认值或保留旧值,确保 tier_id 始终有值 // 先设置默认值或保留旧值,确保 tier_id 始终有值
if existingTierID != "" { if existingTierID != "" {
tokenInfo.TierID = existingTierID tokenInfo.TierID = existingTierID
@@ -455,6 +608,41 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
if strings.TrimSpace(tokenInfo.ProjectID) == "" { if strings.TrimSpace(tokenInfo.ProjectID) == "" {
return nil, fmt.Errorf("failed to auto-detect project_id: empty result") 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 return tokenInfo, nil
@@ -487,6 +675,12 @@ func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo)
if tokenInfo.OAuthType != "" { if tokenInfo.OAuthType != "" {
creds["oauth_type"] = 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 return creds
} }

View File

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

View File

@@ -18,7 +18,7 @@ type RateLimitService struct {
usageRepo UsageLogRepository usageRepo UsageLogRepository
cfg *config.Config cfg *config.Config
geminiQuotaService *GeminiQuotaService geminiQuotaService *GeminiQuotaService
usageCacheMu sync.Mutex usageCacheMu sync.RWMutex
usageCache map[int64]*geminiUsageCacheEntry 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) { func (s *RateLimitService) getGeminiUsageTotals(accountID int64, windowStart, now time.Time) (GeminiUsageTotals, bool) {
s.usageCacheMu.Lock() s.usageCacheMu.RLock()
defer s.usageCacheMu.Unlock() defer s.usageCacheMu.RUnlock()
if s.usageCache == nil { if s.usageCache == nil {
return GeminiUsageTotals{}, false return GeminiUsageTotals{}, false

View File

@@ -26,5 +26,5 @@ UPDATE accounts
SET credentials = credentials - 'tier_id' SET credentials = credentials - 'tier_id'
WHERE platform = 'gemini' WHERE platform = 'gemini'
AND type = 'oauth' AND type = 'oauth'
AND credentials->>'oauth_type' = 'code_assist'; AND credentials ? 'tier_id';
-- +goose StatementEnd -- +goose StatementEnd

View File

@@ -48,6 +48,12 @@ const isCodeAssist = computed(() => {
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id) 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(() => { const shouldShowQuota = computed(() => {
return props.account.platform === 'gemini' return props.account.platform === 'gemini'
@@ -55,33 +61,73 @@ const shouldShowQuota = computed(() => {
// Tier 标签文本 // Tier 标签文本
const tierLabel = computed(() => { const tierLabel = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) { if (isCodeAssist.value) {
const creds = props.account.credentials as GeminiCredentials | undefined // GCP Code Assist: 显示 GCP tier
const tierMap: Record<string, string> = { const tierMap: Record<string, string> = {
LEGACY: 'Free', LEGACY: 'Free',
PRO: 'Pro', 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<string, string> = {
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' return 'Gemini'
}) })
// Tier Badge 样式 // Tier Badge 样式
const tierBadgeClass = computed(() => { 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 creds = props.account.credentials as GeminiCredentials | undefined
const tierColorMap: Record<string, string> = {
LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', if (isCodeAssist.value) {
PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', // GCP Code Assist 样式
ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' const tierColorMap: Record<string, string> = {
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 || ''] || if (isGoogleOne.value) {
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' // Google One tier 样式
) const tierColorMap: Record<string, string> = {
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'
}) })
// 是否限流 // 是否限流

View File

@@ -568,6 +568,24 @@ const isGeminiCodeAssist = computed(() => {
// Gemini 账户类型显示标签 // Gemini 账户类型显示标签
const geminiTierLabel = computed(() => { const geminiTierLabel = computed(() => {
if (!geminiTier.value) return null 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<string, string> = {
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<string, string> = { const tierMap: Record<string, string> = {
LEGACY: t('admin.accounts.tier.free'), LEGACY: t('admin.accounts.tier.free'),
PRO: t('admin.accounts.tier.pro'), PRO: t('admin.accounts.tier.pro'),
@@ -578,6 +596,25 @@ const geminiTierLabel = computed(() => {
// Gemini 账户类型徽章样式 // Gemini 账户类型徽章样式
const geminiTierClass = computed(() => { 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<string, string> = {
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) { switch (geminiTier.value) {
case 'LEGACY': case 'LEGACY':
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'

View File

@@ -455,6 +455,52 @@
<div v-if="accountCategory === 'oauth-based'" class="mt-4"> <div v-if="accountCategory === 'oauth-based'" class="mt-4">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label> <label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
<div class="mt-2 grid grid-cols-2 gap-3"> <div class="mt-2 grid grid-cols-2 gap-3">
<!-- Google One OAuth -->
<button
type="button"
@click="handleSelectGeminiOAuthType('google_one')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
Google One
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
个人账号享受 Google One 订阅配额
</span>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-purple-100 px-2 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
>
推荐个人用户
</span>
<span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
无需 GCP
</span>
</div>
</div>
</button>
<!-- GCP Code Assist OAuth -->
<button <button
type="button" type="button"
@click="handleSelectGeminiOAuthType('code_assist')" @click="handleSelectGeminiOAuthType('code_assist')"
@@ -479,13 +525,13 @@
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white"> <span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }} GCP Code Assist
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }} 企业级需要 GCP 项目
</span> </span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInRequirement') }} 需要激活 GCP 项目并绑定信用卡
<a <a
:href="geminiHelpLinks.gcpProject" :href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400" class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
@@ -499,94 +545,110 @@
<span <span
class="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" class="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
> >
{{ t('admin.accounts.gemini.oauthType.badges.recommended') }} 企业用户
</span> </span>
<span <span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
> >
{{ t('admin.accounts.gemini.oauthType.badges.highConcurrency') }} 高并发
</span>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.noAdmin') }}
</span> </span>
</div> </div>
</div> </div>
</button> </button>
</div>
<div class="group relative"> <!-- Advanced Options Toggle -->
<button <div class="mt-3">
type="button" <button
:disabled="!geminiAIStudioOAuthEnabled" type="button"
@click="handleSelectGeminiOAuthType('ai_studio')" @click="showAdvancedOAuth = !showAdvancedOAuth"
class="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
<svg
:class="['h-4 w-4 transition-transform', showAdvancedOAuth ? 'rotate-90' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span>{{ showAdvancedOAuth ? '隐藏' : '显示' }}高级选项自建 OAuth Client</span>
</button>
</div>
<!-- Custom OAuth Client (Advanced) -->
<div v-if="showAdvancedOAuth" class="mt-3 group relative">
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
:class="[
'flex w-full items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'border-gray-200 hover:border-amber-300 dark:border-dark-600 dark:hover:border-amber-700'
]"
>
<div
:class="[ :class="[
'flex w-full items-center gap-3 rounded-lg border-2 p-3 text-left transition-all', 'flex h-8 w-8 items-center justify-center rounded-lg',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio' geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' ? 'bg-amber-500 text-white'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<div <svg
:class="[ class="h-4 w-4"
'flex h-8 w-8 items-center justify-center rounded-lg', fill="none"
geminiOAuthType === 'ai_studio' viewBox="0 0 24 24"
? 'bg-purple-500 text-white' stroke="currentColor"
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' stroke-width="1.5"
]"
> >
<svg <path
class="h-4 w-4" stroke-linecap="round"
fill="none" stroke-linejoin="round"
viewBox="0 0 24 24" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
stroke="currentColor" />
stroke-width="1.5" </svg>
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customRequirement') }}
</div>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-purple-100 px-2 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.orgManaged') }}
</span>
<span
class="rounded bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.adminRequired') }}
</span>
</div>
</div>
<span
v-if="!geminiAIStudioOAuthEnabled"
class="ml-auto shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
</span>
</button>
<div
v-if="!geminiAIStudioOAuthEnabled"
class="pointer-events-none absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</div> </div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customRequirement') }}
</div>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.orgManaged') }}
</span>
<span
class="rounded bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.adminRequired') }}
</span>
</div>
</div>
<span
v-if="!geminiAIStudioOAuthEnabled"
class="ml-auto shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
</span>
</button>
<div
v-if="!geminiAIStudioOAuthEnabled"
class="pointer-events-none absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</div> </div>
</div> </div>
</div> </div>
@@ -1610,8 +1672,9 @@ const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling 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 geminiAIStudioOAuthEnabled = ref(false)
const showAdvancedOAuth = ref(false)
// Common models for whitelist - Anthropic // Common models for whitelist - Anthropic
const anthropicModels = [ const anthropicModels = [
@@ -1902,7 +1965,7 @@ watch(
{ immediate: true } { immediate: true }
) )
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => { const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) { if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured')) appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return return

View File

@@ -93,7 +93,13 @@ export function useGeminiOAuth() {
const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any) const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
return tokenInfo as GeminiTokenInfo return tokenInfo as GeminiTokenInfo
} catch (err: any) { } 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) appStore.showError(error.value)
return null return null
} finally { } finally {

View File

@@ -1076,6 +1076,7 @@ export default {
failedToGenerateUrl: 'Failed to generate Gemini auth URL', failedToGenerateUrl: 'Failed to generate Gemini auth URL',
missingExchangeParams: 'Missing auth code, session ID, or state', missingExchangeParams: 'Missing auth code, session ID, or state',
failedToExchangeCode: 'Failed to exchange Gemini auth code', 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', modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc: modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
@@ -1290,7 +1291,12 @@ export default {
tier: { tier: {
free: 'Free', free: 'Free',
pro: 'Pro', pro: 'Pro',
ultra: 'Ultra' ultra: 'Ultra',
aiPremium: 'AI Premium',
standard: 'Standard',
basic: 'Basic',
personal: 'Personal',
unlimited: 'Unlimited'
}, },
ineligibleWarning: ineligibleWarning:
'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.' 'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.'

View File

@@ -996,7 +996,12 @@ export default {
tier: { tier: {
free: 'Free', free: 'Free',
pro: 'Pro', pro: 'Pro',
ultra: 'Ultra' ultra: 'Ultra',
aiPremium: 'AI Premium',
standard: '标准版',
basic: '基础版',
personal: '个人版',
unlimited: '无限制'
}, },
ineligibleWarning: ineligibleWarning:
'该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。', '该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。',
@@ -1215,6 +1220,7 @@ export default {
failedToGenerateUrl: '生成 Gemini 授权链接失败', failedToGenerateUrl: '生成 Gemini 授权链接失败',
missingExchangeParams: '缺少 code / session_id / state', missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Gemini 授权码兑换失败', failedToExchangeCode: 'Gemini 授权码兑换失败',
missingProjectId: 'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。',
modelPassthrough: 'Gemini 直接转发模型', modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。',
stateWarningTitle: '提示', stateWarningTitle: '提示',