Merge PR #118: feat(gemini): 添加 Google One 存储空间推断 Tier 功能
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
157
backend/internal/pkg/geminicli/drive_client.go
Normal file
157
backend/internal/pkg/geminicli/drive_client.go
Normal 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
|
||||||
|
}
|
||||||
18
backend/internal/pkg/geminicli/drive_client_test.go
Normal file
18
backend/internal/pkg/geminicli/drive_client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 查询,只返回布尔值
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
backend/internal/service/gemini_oauth_service_test.go
Normal file
51
backend/internal/service/gemini_oauth_service_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 是否限流
|
// 是否限流
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
|||||||
@@ -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: '提示',
|
||||||
|
|||||||
Reference in New Issue
Block a user