2026-01-04 19:27:53 +08:00
|
|
|
|
// Package admin provides HTTP handlers for administrative operations.
|
2025-12-18 13:50:39 +08:00
|
|
|
|
package admin
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-01-03 06:34:00 -08:00
|
|
|
|
"errors"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"strconv"
|
2025-12-25 08:39:32 -08:00
|
|
|
|
"strings"
|
2026-01-01 15:07:16 +08:00
|
|
|
|
"sync"
|
2026-01-03 06:34:00 -08:00
|
|
|
|
"time"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
2025-12-25 21:24:53 -08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
2026-01-01 15:07:16 +08:00
|
|
|
|
"golang.org/x/sync/errgroup"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// OAuthHandler handles OAuth-related operations for accounts
|
|
|
|
|
|
type OAuthHandler struct {
|
|
|
|
|
|
oauthService *service.OAuthService
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewOAuthHandler creates a new OAuth handler
|
2025-12-20 16:12:44 +08:00
|
|
|
|
func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &OAuthHandler{
|
|
|
|
|
|
oauthService: oauthService,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// AccountHandler handles admin account management
|
|
|
|
|
|
type AccountHandler struct {
|
2026-01-05 18:29:37 +08:00
|
|
|
|
adminService service.AdminService
|
|
|
|
|
|
oauthService *service.OAuthService
|
|
|
|
|
|
openaiOAuthService *service.OpenAIOAuthService
|
|
|
|
|
|
geminiOAuthService *service.GeminiOAuthService
|
|
|
|
|
|
antigravityOAuthService *service.AntigravityOAuthService
|
|
|
|
|
|
rateLimitService *service.RateLimitService
|
|
|
|
|
|
accountUsageService *service.AccountUsageService
|
|
|
|
|
|
accountTestService *service.AccountTestService
|
|
|
|
|
|
concurrencyService *service.ConcurrencyService
|
|
|
|
|
|
crsSyncService *service.CRSSyncService
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewAccountHandler creates a new admin account handler
|
2025-12-24 08:48:58 -08:00
|
|
|
|
func NewAccountHandler(
|
|
|
|
|
|
adminService service.AdminService,
|
|
|
|
|
|
oauthService *service.OAuthService,
|
|
|
|
|
|
openaiOAuthService *service.OpenAIOAuthService,
|
2025-12-25 06:45:03 -08:00
|
|
|
|
geminiOAuthService *service.GeminiOAuthService,
|
2026-01-05 18:29:37 +08:00
|
|
|
|
antigravityOAuthService *service.AntigravityOAuthService,
|
2025-12-24 08:48:58 -08:00
|
|
|
|
rateLimitService *service.RateLimitService,
|
|
|
|
|
|
accountUsageService *service.AccountUsageService,
|
|
|
|
|
|
accountTestService *service.AccountTestService,
|
|
|
|
|
|
concurrencyService *service.ConcurrencyService,
|
|
|
|
|
|
crsSyncService *service.CRSSyncService,
|
|
|
|
|
|
) *AccountHandler {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &AccountHandler{
|
2026-01-05 18:29:37 +08:00
|
|
|
|
adminService: adminService,
|
|
|
|
|
|
oauthService: oauthService,
|
|
|
|
|
|
openaiOAuthService: openaiOAuthService,
|
|
|
|
|
|
geminiOAuthService: geminiOAuthService,
|
|
|
|
|
|
antigravityOAuthService: antigravityOAuthService,
|
|
|
|
|
|
rateLimitService: rateLimitService,
|
|
|
|
|
|
accountUsageService: accountUsageService,
|
|
|
|
|
|
accountTestService: accountTestService,
|
|
|
|
|
|
concurrencyService: concurrencyService,
|
|
|
|
|
|
crsSyncService: crsSyncService,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CreateAccountRequest represents create account request
|
|
|
|
|
|
type CreateAccountRequest struct {
|
2026-01-03 06:34:00 -08:00
|
|
|
|
Name string `json:"name" binding:"required"`
|
2026-01-05 14:07:33 +08:00
|
|
|
|
Notes *string `json:"notes"`
|
2026-01-03 06:34:00 -08:00
|
|
|
|
Platform string `json:"platform" binding:"required"`
|
|
|
|
|
|
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey"`
|
|
|
|
|
|
Credentials map[string]any `json:"credentials" binding:"required"`
|
|
|
|
|
|
Extra map[string]any `json:"extra"`
|
|
|
|
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
|
|
|
|
Concurrency int `json:"concurrency"`
|
|
|
|
|
|
Priority int `json:"priority"`
|
|
|
|
|
|
GroupIDs []int64 `json:"group_ids"`
|
2026-01-07 16:59:35 +08:00
|
|
|
|
ExpiresAt *int64 `json:"expires_at"`
|
|
|
|
|
|
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
2026-01-03 06:34:00 -08:00
|
|
|
|
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UpdateAccountRequest represents update account request
|
|
|
|
|
|
// 使用指针类型来区分"未提供"和"设置为0"
|
|
|
|
|
|
type UpdateAccountRequest struct {
|
2026-01-03 06:34:00 -08:00
|
|
|
|
Name string `json:"name"`
|
2026-01-05 14:07:33 +08:00
|
|
|
|
Notes *string `json:"notes"`
|
2026-01-03 06:34:00 -08:00
|
|
|
|
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey"`
|
|
|
|
|
|
Credentials map[string]any `json:"credentials"`
|
|
|
|
|
|
Extra map[string]any `json:"extra"`
|
|
|
|
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
|
|
|
|
Concurrency *int `json:"concurrency"`
|
|
|
|
|
|
Priority *int `json:"priority"`
|
|
|
|
|
|
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
|
|
|
|
|
GroupIDs *[]int64 `json:"group_ids"`
|
2026-01-07 16:59:35 +08:00
|
|
|
|
ExpiresAt *int64 `json:"expires_at"`
|
|
|
|
|
|
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
2026-01-03 06:34:00 -08:00
|
|
|
|
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 17:16:19 -08:00
|
|
|
|
// BulkUpdateAccountsRequest represents the payload for bulk editing accounts
|
|
|
|
|
|
type BulkUpdateAccountsRequest struct {
|
2026-01-03 06:34:00 -08:00
|
|
|
|
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
|
|
|
|
Concurrency *int `json:"concurrency"`
|
|
|
|
|
|
Priority *int `json:"priority"`
|
|
|
|
|
|
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
2026-01-09 19:26:32 +08:00
|
|
|
|
Schedulable *bool `json:"schedulable"`
|
2026-01-03 06:34:00 -08:00
|
|
|
|
GroupIDs *[]int64 `json:"group_ids"`
|
|
|
|
|
|
Credentials map[string]any `json:"credentials"`
|
|
|
|
|
|
Extra map[string]any `json:"extra"`
|
|
|
|
|
|
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
2025-12-24 17:16:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 15:44:45 +08:00
|
|
|
|
// AccountWithConcurrency extends Account with real-time concurrency info
|
|
|
|
|
|
type AccountWithConcurrency struct {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
*dto.Account
|
2025-12-24 15:44:45 +08:00
|
|
|
|
CurrentConcurrency int `json:"current_concurrency"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// List handles listing all accounts with pagination
|
|
|
|
|
|
// GET /api/v1/admin/accounts
|
|
|
|
|
|
func (h *AccountHandler) List(c *gin.Context) {
|
|
|
|
|
|
page, pageSize := response.ParsePagination(c)
|
|
|
|
|
|
platform := c.Query("platform")
|
|
|
|
|
|
accountType := c.Query("type")
|
|
|
|
|
|
status := c.Query("status")
|
|
|
|
|
|
search := c.Query("search")
|
2026-01-09 19:26:32 +08:00
|
|
|
|
// 标准化和验证 search 参数
|
|
|
|
|
|
search = strings.TrimSpace(search)
|
|
|
|
|
|
if len(search) > 100 {
|
|
|
|
|
|
search = search[:100]
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 15:44:45 +08:00
|
|
|
|
// Get current concurrency counts for all accounts
|
|
|
|
|
|
accountIDs := make([]int64, len(accounts))
|
|
|
|
|
|
for i, acc := range accounts {
|
|
|
|
|
|
accountIDs[i] = acc.ID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
concurrencyCounts, err := h.concurrencyService.GetAccountConcurrencyBatch(c.Request.Context(), accountIDs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// Log error but don't fail the request, just use 0 for all
|
|
|
|
|
|
concurrencyCounts = make(map[int64]int)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build response with concurrency info
|
|
|
|
|
|
result := make([]AccountWithConcurrency, len(accounts))
|
|
|
|
|
|
for i := range accounts {
|
|
|
|
|
|
result[i] = AccountWithConcurrency{
|
2025-12-26 15:40:24 +08:00
|
|
|
|
Account: dto.AccountFromService(&accounts[i]),
|
2025-12-24 15:44:45 +08:00
|
|
|
|
CurrentConcurrency: concurrencyCounts[accounts[i].ID],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Paginated(c, result, total, page, pageSize)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetByID handles getting an account by ID
|
|
|
|
|
|
// GET /api/v1/admin/accounts/:id
|
|
|
|
|
|
func (h *AccountHandler) GetByID(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create handles creating a new account
|
|
|
|
|
|
// POST /api/v1/admin/accounts
|
|
|
|
|
|
func (h *AccountHandler) Create(c *gin.Context) {
|
|
|
|
|
|
var req CreateAccountRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 06:34:00 -08:00
|
|
|
|
// 确定是否跳过混合渠道检查
|
|
|
|
|
|
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
2026-01-03 06:34:00 -08:00
|
|
|
|
Name: req.Name,
|
2026-01-05 14:07:33 +08:00
|
|
|
|
Notes: req.Notes,
|
2026-01-03 06:34:00 -08:00
|
|
|
|
Platform: req.Platform,
|
|
|
|
|
|
Type: req.Type,
|
|
|
|
|
|
Credentials: req.Credentials,
|
|
|
|
|
|
Extra: req.Extra,
|
|
|
|
|
|
ProxyID: req.ProxyID,
|
|
|
|
|
|
Concurrency: req.Concurrency,
|
|
|
|
|
|
Priority: req.Priority,
|
|
|
|
|
|
GroupIDs: req.GroupIDs,
|
2026-01-07 16:59:35 +08:00
|
|
|
|
ExpiresAt: req.ExpiresAt,
|
|
|
|
|
|
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
2026-01-03 06:34:00 -08:00
|
|
|
|
SkipMixedChannelCheck: skipCheck,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2026-01-03 06:34:00 -08:00
|
|
|
|
// 检查是否为混合渠道错误
|
|
|
|
|
|
var mixedErr *service.MixedChannelError
|
|
|
|
|
|
if errors.As(err, &mixedErr) {
|
|
|
|
|
|
// 返回特殊错误码要求确认
|
|
|
|
|
|
c.JSON(409, gin.H{
|
|
|
|
|
|
"error": "mixed_channel_warning",
|
|
|
|
|
|
"message": mixedErr.Error(),
|
|
|
|
|
|
"details": gin.H{
|
|
|
|
|
|
"group_id": mixedErr.GroupID,
|
|
|
|
|
|
"group_name": mixedErr.GroupName,
|
|
|
|
|
|
"current_platform": mixedErr.CurrentPlatform,
|
|
|
|
|
|
"other_platform": mixedErr.OtherPlatform,
|
|
|
|
|
|
},
|
|
|
|
|
|
"require_confirmation": true,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update handles updating an account
|
|
|
|
|
|
// PUT /api/v1/admin/accounts/:id
|
|
|
|
|
|
func (h *AccountHandler) Update(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req UpdateAccountRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 06:34:00 -08:00
|
|
|
|
// 确定是否跳过混合渠道检查
|
|
|
|
|
|
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
account, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
2026-01-03 06:34:00 -08:00
|
|
|
|
Name: req.Name,
|
2026-01-05 14:07:33 +08:00
|
|
|
|
Notes: req.Notes,
|
2026-01-03 06:34:00 -08:00
|
|
|
|
Type: req.Type,
|
|
|
|
|
|
Credentials: req.Credentials,
|
|
|
|
|
|
Extra: req.Extra,
|
|
|
|
|
|
ProxyID: req.ProxyID,
|
|
|
|
|
|
Concurrency: req.Concurrency, // 指针类型,nil 表示未提供
|
|
|
|
|
|
Priority: req.Priority, // 指针类型,nil 表示未提供
|
|
|
|
|
|
Status: req.Status,
|
|
|
|
|
|
GroupIDs: req.GroupIDs,
|
2026-01-07 16:59:35 +08:00
|
|
|
|
ExpiresAt: req.ExpiresAt,
|
|
|
|
|
|
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
2026-01-03 06:34:00 -08:00
|
|
|
|
SkipMixedChannelCheck: skipCheck,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2026-01-03 06:34:00 -08:00
|
|
|
|
// 检查是否为混合渠道错误
|
|
|
|
|
|
var mixedErr *service.MixedChannelError
|
|
|
|
|
|
if errors.As(err, &mixedErr) {
|
|
|
|
|
|
// 返回特殊错误码要求确认
|
|
|
|
|
|
c.JSON(409, gin.H{
|
|
|
|
|
|
"error": "mixed_channel_warning",
|
|
|
|
|
|
"message": mixedErr.Error(),
|
|
|
|
|
|
"details": gin.H{
|
|
|
|
|
|
"group_id": mixedErr.GroupID,
|
|
|
|
|
|
"group_name": mixedErr.GroupName,
|
|
|
|
|
|
"current_platform": mixedErr.CurrentPlatform,
|
|
|
|
|
|
"other_platform": mixedErr.OtherPlatform,
|
|
|
|
|
|
},
|
|
|
|
|
|
"require_confirmation": true,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delete handles deleting an account
|
|
|
|
|
|
// DELETE /api/v1/admin/accounts/:id
|
|
|
|
|
|
func (h *AccountHandler) Delete(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = h.adminService.DeleteAccount(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, gin.H{"message": "Account deleted successfully"})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 15:59:39 +08:00
|
|
|
|
// TestAccountRequest represents the request body for testing an account
|
|
|
|
|
|
type TestAccountRequest struct {
|
|
|
|
|
|
ModelID string `json:"model_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 08:48:58 -08:00
|
|
|
|
type SyncFromCRSRequest struct {
|
|
|
|
|
|
BaseURL string `json:"base_url" binding:"required"`
|
|
|
|
|
|
Username string `json:"username" binding:"required"`
|
|
|
|
|
|
Password string `json:"password" binding:"required"`
|
|
|
|
|
|
SyncProxies *bool `json:"sync_proxies"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// Test handles testing account connectivity with SSE streaming
|
|
|
|
|
|
// POST /api/v1/admin/accounts/:id/test
|
|
|
|
|
|
func (h *AccountHandler) Test(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 15:59:39 +08:00
|
|
|
|
var req TestAccountRequest
|
|
|
|
|
|
// Allow empty body, model_id is optional
|
|
|
|
|
|
_ = c.ShouldBindJSON(&req)
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// Use AccountTestService to test the account with SSE streaming
|
2025-12-19 15:59:39 +08:00
|
|
|
|
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// Error already sent via SSE, just log
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 08:48:58 -08:00
|
|
|
|
// SyncFromCRS handles syncing accounts from claude-relay-service (CRS)
|
|
|
|
|
|
// POST /api/v1/admin/accounts/sync/crs
|
|
|
|
|
|
func (h *AccountHandler) SyncFromCRS(c *gin.Context) {
|
|
|
|
|
|
var req SyncFromCRSRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Default to syncing proxies (can be disabled by explicitly setting false)
|
|
|
|
|
|
syncProxies := true
|
|
|
|
|
|
if req.SyncProxies != nil {
|
|
|
|
|
|
syncProxies = *req.SyncProxies
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{
|
|
|
|
|
|
BaseURL: req.BaseURL,
|
|
|
|
|
|
Username: req.Username,
|
|
|
|
|
|
Password: req.Password,
|
|
|
|
|
|
SyncProxies: syncProxies,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2026-01-05 12:57:03 +08:00
|
|
|
|
// Provide detailed error message for CRS sync failures
|
|
|
|
|
|
response.InternalError(c, "CRS sync failed: "+err.Error())
|
2025-12-24 08:48:58 -08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// Refresh handles refreshing account credentials
|
|
|
|
|
|
// POST /api/v1/admin/accounts/:id/refresh
|
|
|
|
|
|
func (h *AccountHandler) Refresh(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get account
|
|
|
|
|
|
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.NotFound(c, "Account not found")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only refresh OAuth-based accounts (oauth and setup-token)
|
|
|
|
|
|
if !account.IsOAuth() {
|
|
|
|
|
|
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
var newCredentials map[string]any
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
if account.IsOpenAI() {
|
|
|
|
|
|
// Use OpenAI OAuth service to refresh token
|
|
|
|
|
|
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-22 22:58:31 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build new credentials from token info
|
|
|
|
|
|
newCredentials = h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
// Preserve non-token settings from existing credentials
|
|
|
|
|
|
for k, v := range account.Credentials {
|
|
|
|
|
|
if _, exists := newCredentials[k]; !exists {
|
|
|
|
|
|
newCredentials[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-26 22:07:55 +08:00
|
|
|
|
} else if account.Platform == service.PlatformGemini {
|
2025-12-25 06:45:03 -08:00
|
|
|
|
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.InternalError(c, "Failed to refresh credentials: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
newCredentials = h.geminiOAuthService.BuildAccountCredentials(tokenInfo)
|
|
|
|
|
|
for k, v := range account.Credentials {
|
|
|
|
|
|
if _, exists := newCredentials[k]; !exists {
|
|
|
|
|
|
newCredentials[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-05 18:29:37 +08:00
|
|
|
|
} else if account.Platform == service.PlatformAntigravity {
|
|
|
|
|
|
tokenInfo, err := h.antigravityOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
newCredentials = h.antigravityOAuthService.BuildAccountCredentials(tokenInfo)
|
|
|
|
|
|
for k, v := range account.Credentials {
|
|
|
|
|
|
if _, exists := newCredentials[k]; !exists {
|
|
|
|
|
|
newCredentials[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-22 22:58:31 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// Use Anthropic/Claude OAuth service to refresh token
|
|
|
|
|
|
tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-22 22:58:31 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests)
|
|
|
|
|
|
newCredentials = make(map[string]any)
|
|
|
|
|
|
for k, v := range account.Credentials {
|
|
|
|
|
|
newCredentials[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:46:02 -08:00
|
|
|
|
// Update token-related fields
|
|
|
|
|
|
newCredentials["access_token"] = tokenInfo.AccessToken
|
|
|
|
|
|
newCredentials["token_type"] = tokenInfo.TokenType
|
|
|
|
|
|
newCredentials["expires_in"] = strconv.FormatInt(tokenInfo.ExpiresIn, 10)
|
|
|
|
|
|
newCredentials["expires_at"] = strconv.FormatInt(tokenInfo.ExpiresAt, 10)
|
|
|
|
|
|
if strings.TrimSpace(tokenInfo.RefreshToken) != "" {
|
|
|
|
|
|
newCredentials["refresh_token"] = tokenInfo.RefreshToken
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(tokenInfo.Scope) != "" {
|
|
|
|
|
|
newCredentials["scope"] = tokenInfo.Scope
|
2025-12-25 08:39:32 -08:00
|
|
|
|
}
|
2025-12-25 21:46:02 -08:00
|
|
|
|
}
|
2025-12-19 16:39:25 +08:00
|
|
|
|
|
2025-12-25 21:46:02 -08:00
|
|
|
|
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
|
|
|
|
|
Credentials: newCredentials,
|
|
|
|
|
|
})
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 21:22:48 +08:00
|
|
|
|
response.Success(c, dto.AccountFromService(updatedAccount))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetStats handles getting account statistics
|
|
|
|
|
|
// GET /api/v1/admin/accounts/:id/stats
|
|
|
|
|
|
func (h *AccountHandler) GetStats(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 13:42:33 +08:00
|
|
|
|
// Parse days parameter (default 30)
|
|
|
|
|
|
days := 30
|
|
|
|
|
|
if daysStr := c.Query("days"); daysStr != "" {
|
|
|
|
|
|
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 90 {
|
|
|
|
|
|
days = d
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate time range
|
|
|
|
|
|
now := timezone.Now()
|
|
|
|
|
|
endTime := timezone.StartOfDay(now.AddDate(0, 0, 1))
|
|
|
|
|
|
startTime := timezone.StartOfDay(now.AddDate(0, 0, -days+1))
|
|
|
|
|
|
|
2025-12-24 08:41:31 +08:00
|
|
|
|
stats, err := h.accountUsageService.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime)
|
2025-12-23 13:42:33 +08:00
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-23 13:42:33 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, stats)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ClearError handles clearing account error
|
|
|
|
|
|
// POST /api/v1/admin/accounts/:id/clear-error
|
|
|
|
|
|
func (h *AccountHandler) ClearError(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
account, err := h.adminService.ClearAccountError(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BatchCreate handles batch creating accounts
|
|
|
|
|
|
// POST /api/v1/admin/accounts/batch
|
|
|
|
|
|
func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
|
|
|
|
|
var req struct {
|
|
|
|
|
|
Accounts []CreateAccountRequest `json:"accounts" binding:"required,min=1"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Return mock data for now
|
|
|
|
|
|
response.Success(c, gin.H{
|
|
|
|
|
|
"success": len(req.Accounts),
|
|
|
|
|
|
"failed": 0,
|
|
|
|
|
|
"results": []gin.H{},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 16:56:48 -08:00
|
|
|
|
// BatchUpdateCredentialsRequest represents batch credentials update request
|
|
|
|
|
|
type BatchUpdateCredentialsRequest struct {
|
|
|
|
|
|
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
|
|
|
|
|
Field string `json:"field" binding:"required,oneof=account_uuid org_uuid intercept_warmup_requests"`
|
|
|
|
|
|
Value any `json:"value"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BatchUpdateCredentials handles batch updating credentials fields
|
|
|
|
|
|
// POST /api/v1/admin/accounts/batch-update-credentials
|
|
|
|
|
|
func (h *AccountHandler) BatchUpdateCredentials(c *gin.Context) {
|
|
|
|
|
|
var req BatchUpdateCredentialsRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate value type based on field
|
|
|
|
|
|
if req.Field == "intercept_warmup_requests" {
|
|
|
|
|
|
// Must be boolean
|
|
|
|
|
|
if _, ok := req.Value.(bool); !ok {
|
|
|
|
|
|
response.BadRequest(c, "intercept_warmup_requests must be boolean")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// account_uuid and org_uuid can be string or null
|
|
|
|
|
|
if req.Value != nil {
|
|
|
|
|
|
if _, ok := req.Value.(string); !ok {
|
|
|
|
|
|
response.BadRequest(c, req.Field+" must be string or null")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
|
|
success := 0
|
|
|
|
|
|
failed := 0
|
|
|
|
|
|
results := []gin.H{}
|
|
|
|
|
|
|
|
|
|
|
|
for _, accountID := range req.AccountIDs {
|
|
|
|
|
|
// Get account
|
|
|
|
|
|
account, err := h.adminService.GetAccount(ctx, accountID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
failed++
|
|
|
|
|
|
results = append(results, gin.H{
|
|
|
|
|
|
"account_id": accountID,
|
|
|
|
|
|
"success": false,
|
|
|
|
|
|
"error": "Account not found",
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update credentials field
|
|
|
|
|
|
if account.Credentials == nil {
|
|
|
|
|
|
account.Credentials = make(map[string]any)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
account.Credentials[req.Field] = req.Value
|
|
|
|
|
|
|
|
|
|
|
|
// Update account
|
|
|
|
|
|
updateInput := &service.UpdateAccountInput{
|
|
|
|
|
|
Credentials: account.Credentials,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, err = h.adminService.UpdateAccount(ctx, accountID, updateInput)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
failed++
|
|
|
|
|
|
results = append(results, gin.H{
|
|
|
|
|
|
"account_id": accountID,
|
|
|
|
|
|
"success": false,
|
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
success++
|
|
|
|
|
|
results = append(results, gin.H{
|
|
|
|
|
|
"account_id": accountID,
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, gin.H{
|
|
|
|
|
|
"success": success,
|
|
|
|
|
|
"failed": failed,
|
|
|
|
|
|
"results": results,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 17:16:19 -08:00
|
|
|
|
// BulkUpdate handles bulk updating accounts with selected fields/credentials.
|
|
|
|
|
|
// POST /api/v1/admin/accounts/bulk-update
|
|
|
|
|
|
func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
|
|
|
|
|
var req BulkUpdateAccountsRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 06:34:00 -08:00
|
|
|
|
// 确定是否跳过混合渠道检查
|
|
|
|
|
|
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
|
|
|
|
|
|
2025-12-24 17:16:19 -08:00
|
|
|
|
hasUpdates := req.Name != "" ||
|
|
|
|
|
|
req.ProxyID != nil ||
|
|
|
|
|
|
req.Concurrency != nil ||
|
|
|
|
|
|
req.Priority != nil ||
|
|
|
|
|
|
req.Status != "" ||
|
2026-01-09 19:26:32 +08:00
|
|
|
|
req.Schedulable != nil ||
|
2025-12-24 17:16:19 -08:00
|
|
|
|
req.GroupIDs != nil ||
|
|
|
|
|
|
len(req.Credentials) > 0 ||
|
|
|
|
|
|
len(req.Extra) > 0
|
|
|
|
|
|
|
|
|
|
|
|
if !hasUpdates {
|
|
|
|
|
|
response.BadRequest(c, "No updates provided")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
|
2026-01-03 06:34:00 -08:00
|
|
|
|
AccountIDs: req.AccountIDs,
|
|
|
|
|
|
Name: req.Name,
|
|
|
|
|
|
ProxyID: req.ProxyID,
|
|
|
|
|
|
Concurrency: req.Concurrency,
|
|
|
|
|
|
Priority: req.Priority,
|
|
|
|
|
|
Status: req.Status,
|
2026-01-09 19:26:32 +08:00
|
|
|
|
Schedulable: req.Schedulable,
|
2026-01-03 06:34:00 -08:00
|
|
|
|
GroupIDs: req.GroupIDs,
|
|
|
|
|
|
Credentials: req.Credentials,
|
|
|
|
|
|
Extra: req.Extra,
|
|
|
|
|
|
SkipMixedChannelCheck: skipCheck,
|
2025-12-24 17:16:19 -08:00
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-24 17:16:19 -08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// ========== OAuth Handlers ==========
|
|
|
|
|
|
|
|
|
|
|
|
// GenerateAuthURLRequest represents the request for generating auth URL
|
|
|
|
|
|
type GenerateAuthURLRequest struct {
|
|
|
|
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GenerateAuthURL generates OAuth authorization URL with full scope
|
|
|
|
|
|
// POST /api/v1/admin/accounts/generate-auth-url
|
|
|
|
|
|
func (h *OAuthHandler) GenerateAuthURL(c *gin.Context) {
|
|
|
|
|
|
var req GenerateAuthURLRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
// Allow empty body
|
|
|
|
|
|
req = GenerateAuthURLRequest{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result, err := h.oauthService.GenerateAuthURL(c.Request.Context(), req.ProxyID)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GenerateSetupTokenURL generates OAuth authorization URL for setup token (inference only)
|
|
|
|
|
|
// POST /api/v1/admin/accounts/generate-setup-token-url
|
|
|
|
|
|
func (h *OAuthHandler) GenerateSetupTokenURL(c *gin.Context) {
|
|
|
|
|
|
var req GenerateAuthURLRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
// Allow empty body
|
|
|
|
|
|
req = GenerateAuthURLRequest{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result, err := h.oauthService.GenerateSetupTokenURL(c.Request.Context(), req.ProxyID)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ExchangeCodeRequest represents the request for exchanging auth code
|
|
|
|
|
|
type ExchangeCodeRequest struct {
|
|
|
|
|
|
SessionID string `json:"session_id" binding:"required"`
|
|
|
|
|
|
Code string `json:"code" binding:"required"`
|
|
|
|
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ExchangeCode exchanges authorization code for tokens
|
|
|
|
|
|
// POST /api/v1/admin/accounts/exchange-code
|
|
|
|
|
|
func (h *OAuthHandler) ExchangeCode(c *gin.Context) {
|
|
|
|
|
|
var req ExchangeCodeRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tokenInfo, err := h.oauthService.ExchangeCode(c.Request.Context(), &service.ExchangeCodeInput{
|
|
|
|
|
|
SessionID: req.SessionID,
|
|
|
|
|
|
Code: req.Code,
|
|
|
|
|
|
ProxyID: req.ProxyID,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, tokenInfo)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ExchangeSetupTokenCode exchanges authorization code for setup token
|
|
|
|
|
|
// POST /api/v1/admin/accounts/exchange-setup-token-code
|
|
|
|
|
|
func (h *OAuthHandler) ExchangeSetupTokenCode(c *gin.Context) {
|
|
|
|
|
|
var req ExchangeCodeRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tokenInfo, err := h.oauthService.ExchangeCode(c.Request.Context(), &service.ExchangeCodeInput{
|
|
|
|
|
|
SessionID: req.SessionID,
|
|
|
|
|
|
Code: req.Code,
|
|
|
|
|
|
ProxyID: req.ProxyID,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, tokenInfo)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CookieAuthRequest represents the request for cookie-based authentication
|
|
|
|
|
|
type CookieAuthRequest struct {
|
|
|
|
|
|
SessionKey string `json:"code" binding:"required"` // Using 'code' field as sessionKey (frontend sends it this way)
|
|
|
|
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CookieAuth performs OAuth using sessionKey (cookie-based auto-auth)
|
|
|
|
|
|
// POST /api/v1/admin/accounts/cookie-auth
|
|
|
|
|
|
func (h *OAuthHandler) CookieAuth(c *gin.Context) {
|
|
|
|
|
|
var req CookieAuthRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tokenInfo, err := h.oauthService.CookieAuth(c.Request.Context(), &service.CookieAuthInput{
|
|
|
|
|
|
SessionKey: req.SessionKey,
|
|
|
|
|
|
ProxyID: req.ProxyID,
|
|
|
|
|
|
Scope: "full",
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, tokenInfo)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetupTokenCookieAuth performs OAuth using sessionKey for setup token (inference only)
|
|
|
|
|
|
// POST /api/v1/admin/accounts/setup-token-cookie-auth
|
|
|
|
|
|
func (h *OAuthHandler) SetupTokenCookieAuth(c *gin.Context) {
|
|
|
|
|
|
var req CookieAuthRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tokenInfo, err := h.oauthService.CookieAuth(c.Request.Context(), &service.CookieAuthInput{
|
|
|
|
|
|
SessionKey: req.SessionKey,
|
|
|
|
|
|
ProxyID: req.ProxyID,
|
|
|
|
|
|
Scope: "inference",
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, tokenInfo)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetUsage handles getting account usage information
|
|
|
|
|
|
// GET /api/v1/admin/accounts/:id/usage
|
|
|
|
|
|
func (h *AccountHandler) GetUsage(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
usage, err := h.accountUsageService.GetUsage(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, usage)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ClearRateLimit handles clearing account rate limit status
|
|
|
|
|
|
// POST /api/v1/admin/accounts/:id/clear-rate-limit
|
|
|
|
|
|
func (h *AccountHandler) ClearRateLimit(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = h.rateLimitService.ClearRateLimit(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, gin.H{"message": "Rate limit cleared successfully"})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 06:34:00 -08:00
|
|
|
|
// GetTempUnschedulable handles getting temporary unschedulable status
|
|
|
|
|
|
// GET /api/v1/admin/accounts/:id/temp-unschedulable
|
|
|
|
|
|
func (h *AccountHandler) GetTempUnschedulable(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state, err := h.rateLimitService.GetTempUnschedStatus(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if state == nil || state.UntilUnix <= time.Now().Unix() {
|
|
|
|
|
|
response.Success(c, gin.H{"active": false})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, gin.H{
|
|
|
|
|
|
"active": true,
|
|
|
|
|
|
"state": state,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ClearTempUnschedulable handles clearing temporary unschedulable status
|
|
|
|
|
|
// DELETE /api/v1/admin/accounts/:id/temp-unschedulable
|
|
|
|
|
|
func (h *AccountHandler) ClearTempUnschedulable(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := h.rateLimitService.ClearTempUnschedulable(c.Request.Context(), accountID); err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, gin.H{"message": "Temp unschedulable cleared successfully"})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetTodayStats handles getting account today statistics
|
|
|
|
|
|
// GET /api/v1/admin/accounts/:id/today-stats
|
|
|
|
|
|
func (h *AccountHandler) GetTodayStats(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stats, err := h.accountUsageService.GetTodayStats(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, stats)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetSchedulableRequest represents the request body for setting schedulable status
|
|
|
|
|
|
type SetSchedulableRequest struct {
|
|
|
|
|
|
Schedulable bool `json:"schedulable"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetSchedulable handles toggling account schedulable status
|
|
|
|
|
|
// POST /api/v1/admin/accounts/:id/schedulable
|
|
|
|
|
|
func (h *AccountHandler) SetSchedulable(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req SetSchedulableRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
account, err := h.adminService.SetAccountSchedulable(c.Request.Context(), accountID, req.Schedulable)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 20:52:47 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2025-12-19 15:59:39 +08:00
|
|
|
|
|
|
|
|
|
|
// GetAvailableModels handles getting available models for an account
|
|
|
|
|
|
// GET /api/v1/admin/accounts/:id/models
|
|
|
|
|
|
func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
|
|
|
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid account ID")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.NotFound(c, "Account not found")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
// Handle OpenAI accounts
|
|
|
|
|
|
if account.IsOpenAI() {
|
|
|
|
|
|
// For OAuth accounts: return default OpenAI models
|
|
|
|
|
|
if account.IsOAuth() {
|
|
|
|
|
|
response.Success(c, openai.DefaultModels)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For API Key accounts: check model_mapping
|
|
|
|
|
|
mapping := account.GetModelMapping()
|
|
|
|
|
|
if len(mapping) == 0 {
|
|
|
|
|
|
response.Success(c, openai.DefaultModels)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Return mapped models
|
|
|
|
|
|
var models []openai.Model
|
|
|
|
|
|
for requestedModel := range mapping {
|
|
|
|
|
|
var found bool
|
|
|
|
|
|
for _, dm := range openai.DefaultModels {
|
|
|
|
|
|
if dm.ID == requestedModel {
|
|
|
|
|
|
models = append(models, dm)
|
|
|
|
|
|
found = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
models = append(models, openai.Model{
|
|
|
|
|
|
ID: requestedModel,
|
|
|
|
|
|
Object: "model",
|
|
|
|
|
|
Type: "model",
|
|
|
|
|
|
DisplayName: requestedModel,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, models)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:53 -08:00
|
|
|
|
// Handle Gemini accounts
|
|
|
|
|
|
if account.IsGemini() {
|
|
|
|
|
|
// For OAuth accounts: return default Gemini models
|
|
|
|
|
|
if account.IsOAuth() {
|
|
|
|
|
|
response.Success(c, geminicli.DefaultModels)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For API Key accounts: return models based on model_mapping
|
|
|
|
|
|
mapping := account.GetModelMapping()
|
|
|
|
|
|
if len(mapping) == 0 {
|
|
|
|
|
|
response.Success(c, geminicli.DefaultModels)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var models []geminicli.Model
|
|
|
|
|
|
for requestedModel := range mapping {
|
|
|
|
|
|
var found bool
|
|
|
|
|
|
for _, dm := range geminicli.DefaultModels {
|
|
|
|
|
|
if dm.ID == requestedModel {
|
|
|
|
|
|
models = append(models, dm)
|
|
|
|
|
|
found = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
models = append(models, geminicli.Model{
|
|
|
|
|
|
ID: requestedModel,
|
|
|
|
|
|
Type: "model",
|
|
|
|
|
|
DisplayName: requestedModel,
|
|
|
|
|
|
CreatedAt: "",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, models)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 22:42:00 +08:00
|
|
|
|
// Handle Antigravity accounts: return Claude + Gemini models
|
|
|
|
|
|
if account.Platform == service.PlatformAntigravity {
|
|
|
|
|
|
// Antigravity 支持 Claude 和部分 Gemini 模型
|
|
|
|
|
|
type UnifiedModel struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
DisplayName string `json:"display_name"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var models []UnifiedModel
|
|
|
|
|
|
|
|
|
|
|
|
// 添加 Claude 模型
|
|
|
|
|
|
for _, m := range claude.DefaultModels {
|
|
|
|
|
|
models = append(models, UnifiedModel{
|
|
|
|
|
|
ID: m.ID,
|
|
|
|
|
|
Type: m.Type,
|
|
|
|
|
|
DisplayName: m.DisplayName,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加 Gemini 3 系列模型用于测试
|
|
|
|
|
|
geminiTestModels := []UnifiedModel{
|
|
|
|
|
|
{ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash"},
|
|
|
|
|
|
{ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview"},
|
|
|
|
|
|
}
|
|
|
|
|
|
models = append(models, geminiTestModels...)
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, models)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
// Handle Claude/Anthropic accounts
|
2025-12-19 15:59:39 +08:00
|
|
|
|
// For OAuth and Setup-Token accounts: return default models
|
|
|
|
|
|
if account.IsOAuth() {
|
|
|
|
|
|
response.Success(c, claude.DefaultModels)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For API Key accounts: return models based on model_mapping
|
|
|
|
|
|
mapping := account.GetModelMapping()
|
2025-12-20 15:29:52 +08:00
|
|
|
|
if len(mapping) == 0 {
|
2025-12-19 15:59:39 +08:00
|
|
|
|
// No mapping configured, return default models
|
|
|
|
|
|
response.Success(c, claude.DefaultModels)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Return mapped models (keys of the mapping are the available model IDs)
|
|
|
|
|
|
var models []claude.Model
|
|
|
|
|
|
for requestedModel := range mapping {
|
|
|
|
|
|
// Try to find display info from default models
|
|
|
|
|
|
var found bool
|
|
|
|
|
|
for _, dm := range claude.DefaultModels {
|
|
|
|
|
|
if dm.ID == requestedModel {
|
|
|
|
|
|
models = append(models, dm)
|
|
|
|
|
|
found = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// If not found in defaults, create a basic entry
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
models = append(models, claude.Model{
|
|
|
|
|
|
ID: requestedModel,
|
|
|
|
|
|
Type: "model",
|
|
|
|
|
|
DisplayName: requestedModel,
|
|
|
|
|
|
CreatedAt: "",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, models)
|
|
|
|
|
|
}
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
|
|
account, err := h.adminService.GetAccount(ctx, accountID)
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.NotFound(c, "Account not found")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
if account.Platform != service.PlatformGemini || account.Type != service.AccountTypeOAuth {
|
|
|
|
|
|
response.BadRequest(c, "Only Gemini OAuth accounts support tier refresh")
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
oauthType, _ := account.Credentials["oauth_type"].(string)
|
|
|
|
|
|
if oauthType != "google_one" {
|
|
|
|
|
|
response.BadRequest(c, "Only google_one OAuth accounts support tier refresh")
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
tierID, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(ctx, account)
|
2026-01-01 14:07:37 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
_, updateErr := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{
|
|
|
|
|
|
Credentials: creds,
|
|
|
|
|
|
Extra: extra,
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
})
|
|
|
|
|
|
if updateErr != nil {
|
|
|
|
|
|
response.ErrorFrom(c, updateErr)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response.Success(c, gin.H{
|
2026-01-01 14:07:37 +08:00
|
|
|
|
"tier_id": tierID,
|
2026-01-01 15:07:16 +08:00
|
|
|
|
"storage_info": extra,
|
|
|
|
|
|
"drive_storage_limit": extra["drive_storage_limit"],
|
|
|
|
|
|
"drive_storage_usage": extra["drive_storage_usage"],
|
|
|
|
|
|
"updated_at": extra["drive_tier_updated_at"],
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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()
|
2026-01-01 15:07:16 +08:00
|
|
|
|
accounts := make([]*service.Account, 0)
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
|
|
|
|
|
|
if len(req.AccountIDs) == 0 {
|
|
|
|
|
|
allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-01 15:07:16 +08:00
|
|
|
|
for i := range allAccounts {
|
|
|
|
|
|
acc := &allAccounts[i]
|
|
|
|
|
|
oauthType, _ := acc.Credentials["oauth_type"].(string)
|
|
|
|
|
|
if oauthType == "google_one" {
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
accounts = append(accounts, acc)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-01-01 15:07:16 +08:00
|
|
|
|
fetched, err := h.adminService.GetAccountsByIDs(ctx, req.AccountIDs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, acc := range fetched {
|
|
|
|
|
|
if acc == nil {
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-01 15:07:16 +08:00
|
|
|
|
if acc.Platform != service.PlatformGemini || acc.Type != service.AccountTypeOAuth {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
oauthType, _ := acc.Credentials["oauth_type"].(string)
|
|
|
|
|
|
if oauthType != "google_one" {
|
|
|
|
|
|
continue
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
}
|
2026-01-01 15:07:16 +08:00
|
|
|
|
accounts = append(accounts, acc)
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
const maxConcurrency = 10
|
|
|
|
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
|
|
|
|
g.SetLimit(maxConcurrency)
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
var mu sync.Mutex
|
2026-01-01 15:16:12 +08:00
|
|
|
|
var successCount, failedCount int
|
|
|
|
|
|
var errors []gin.H
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
for _, account := range accounts {
|
|
|
|
|
|
acc := account // 闭包捕获
|
|
|
|
|
|
g.Go(func() error {
|
|
|
|
|
|
_, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(gctx, acc)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
mu.Lock()
|
2026-01-01 15:16:12 +08:00
|
|
|
|
failedCount++
|
|
|
|
|
|
errors = append(errors, gin.H{
|
2026-01-01 15:07:16 +08:00
|
|
|
|
"account_id": acc.ID,
|
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
|
})
|
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
_, updateErr := h.adminService.UpdateAccount(gctx, acc.ID, &service.UpdateAccountInput{
|
|
|
|
|
|
Credentials: creds,
|
|
|
|
|
|
Extra: extra,
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
mu.Lock()
|
|
|
|
|
|
if updateErr != nil {
|
2026-01-01 15:16:12 +08:00
|
|
|
|
failedCount++
|
|
|
|
|
|
errors = append(errors, gin.H{
|
2026-01-01 15:07:16 +08:00
|
|
|
|
"account_id": acc.ID,
|
|
|
|
|
|
"error": updateErr.Error(),
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
2026-01-01 15:16:12 +08:00
|
|
|
|
successCount++
|
2026-01-01 15:07:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
mu.Unlock()
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
return nil
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
})
|
2026-01-01 15:07:16 +08:00
|
|
|
|
}
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
if err := g.Wait(); err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 15:16:12 +08:00
|
|
|
|
results := gin.H{
|
|
|
|
|
|
"total": len(accounts),
|
|
|
|
|
|
"success": successCount,
|
|
|
|
|
|
"failed": failedCount,
|
|
|
|
|
|
"errors": errors,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 15:07:16 +08:00
|
|
|
|
response.Success(c, results)
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
|
}
|