Files
sub2api/backend/internal/handler/admin/account_handler.go

1708 lines
52 KiB
Go
Raw Normal View History

// Package admin provides HTTP handlers for administrative operations.
2025-12-18 13:50:39 +08:00
package admin
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
fix(audit): 第二批审计修复 — P0 生产 Bug、安全加固、性能优化、缓存一致性、代码质量 基于 backend-code-audit 审计报告,修复剩余 P0/P1/P2 共 34 项问题: P0 生产 Bug: - 修复 time.Since(time.Now()) 计时逻辑错误 (P0-03) - generateRandomID 改用 crypto/rand 替代固定索引 (P0-04) - IncrementQuotaUsed 重写为 Ent 原子操作消除 TOCTOU 竞态 (P0-05) 安全加固: - gateway/openai handler 错误响应替换为泛化消息,防止内部信息泄露 (P1-14) - usage_log_repo dateFormat 参数改用白名单映射,防止 SQL 注入 (P1-16) - 默认配置安全加固:sslmode=prefer、response_headers=true、mode=release (P1-18/19, P2-15) 性能优化: - gateway handler 循环内 defer 替换为显式 releaseWait 闭包 (P1-02) - group_repo/promo_code_repo Count 前 Clone 查询避免状态污染 (P1-03) - usage_log_repo 四个查询添加 LIMIT 10000 防止 OOM (P1-07) - GetBatchUsageStats 添加时间范围参数,默认最近 30 天 (P1-10) - ip.go CIDR 预编译为包级变量 (P1-11) - BatchUpdateCredentials 重构为先验证后更新 (P1-13) 缓存一致性: - billing_cache 添加 jitteredTTL 防止缓存雪崩 (P2-10) - DeductUserBalance/UpdateSubscriptionUsage 错误传播修复 (P2-12) - UserService.UpdateBalance 成功后异步失效 billingCache (P2-13) 代码质量: - search 截断改为按 rune 处理,支持多字节字符 (P2-01) - TLS Handshake 改为 HandshakeContext 支持 context 取消 (P2-07) - CORS 预检添加 Access-Control-Max-Age: 86400 (P2-16) 测试覆盖: - 新增 user_service_test.go(UpdateBalance 缓存失效 6 个用例) - 新增 batch_update_credentials_test.go(fail-fast + 类型验证 7 个用例) - 新增 response_transformer_test.go、ip_test.go、usage_log_repo_unit_test.go、search_truncate_test.go - 集成测试:IncrementQuotaUsed 并发测试、billing_cache 错误传播测试 - config_test.go 补充 server.mode/sslmode 默认值断言 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:46:42 +08:00
"fmt"
"net/http"
2025-12-18 13:50:39 +08:00
"strconv"
"strings"
"sync"
"time"
2025-12-18 13:50:39 +08:00
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
2025-12-24 21:07:21 +08:00
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"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"
"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 {
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
sessionLimitCache service.SessionLimitCache
2026-02-02 22:13:50 +08:00
tokenCacheInvalidator service.TokenCacheInvalidator
2025-12-18 13:50:39 +08:00
}
// NewAccountHandler creates a new admin account handler
func NewAccountHandler(
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,
sessionLimitCache service.SessionLimitCache,
2026-02-02 22:13:50 +08:00
tokenCacheInvalidator service.TokenCacheInvalidator,
) *AccountHandler {
2025-12-18 13:50:39 +08:00
return &AccountHandler{
adminService: adminService,
oauthService: oauthService,
openaiOAuthService: openaiOAuthService,
geminiOAuthService: geminiOAuthService,
antigravityOAuthService: antigravityOAuthService,
rateLimitService: rateLimitService,
accountUsageService: accountUsageService,
accountTestService: accountTestService,
concurrencyService: concurrencyService,
crsSyncService: crsSyncService,
sessionLimitCache: sessionLimitCache,
2026-02-02 22:13:50 +08:00
tokenCacheInvalidator: tokenCacheInvalidator,
2025-12-18 13:50:39 +08:00
}
}
// CreateAccountRequest represents create account request
type CreateAccountRequest struct {
Name string `json:"name" binding:"required"`
2026-01-05 14:07:33 +08:00
Notes *string `json:"notes"`
Platform string `json:"platform" binding:"required"`
2026-02-02 22:20:08 +08:00
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream"`
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"`
RateMultiplier *float64 `json:"rate_multiplier"`
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"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
2025-12-18 13:50:39 +08:00
}
// UpdateAccountRequest represents update account request
// 使用指针类型来区分"未提供"和"设置为0"
type UpdateAccountRequest struct {
Name string `json:"name"`
2026-01-05 14:07:33 +08:00
Notes *string `json:"notes"`
2026-02-02 22:20:08 +08:00
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream"`
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"`
RateMultiplier *float64 `json:"rate_multiplier"`
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"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
2025-12-18 13:50:39 +08:00
}
// BulkUpdateAccountsRequest represents the payload for bulk editing accounts
type BulkUpdateAccountsRequest struct {
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"`
RateMultiplier *float64 `json:"rate_multiplier"`
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
Schedulable *bool `json:"schedulable"`
GroupIDs *[]int64 `json:"group_ids"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
}
// CheckMixedChannelRequest represents check mixed channel risk request
type CheckMixedChannelRequest struct {
Platform string `json:"platform" binding:"required"`
GroupIDs []int64 `json:"group_ids"`
AccountID *int64 `json:"account_id"`
}
// AccountWithConcurrency extends Account with real-time concurrency info
type AccountWithConcurrency struct {
*dto.Account
CurrentConcurrency int `json:"current_concurrency"`
// 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` // 当前窗口费用
ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
}
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
item := AccountWithConcurrency{
Account: dto.AccountFromService(account),
CurrentConcurrency: 0,
}
if account == nil {
return item
}
if h.concurrencyService != nil {
if counts, err := h.concurrencyService.GetAccountConcurrencyBatch(ctx, []int64{account.ID}); err == nil {
item.CurrentConcurrency = counts[account.ID]
}
}
if account.IsAnthropicOAuthOrSetupToken() {
if h.accountUsageService != nil && account.GetWindowCostLimit() > 0 {
startTime := account.GetCurrentWindowStartTime()
if stats, err := h.accountUsageService.GetAccountWindowStats(ctx, account.ID, startTime); err == nil && stats != nil {
cost := stats.StandardCost
item.CurrentWindowCost = &cost
}
}
if h.sessionLimitCache != nil && account.GetMaxSessions() > 0 {
idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute
idleTimeouts := map[int64]time.Duration{account.ID: idleTimeout}
if sessions, err := h.sessionLimitCache.GetActiveSessionCountBatch(ctx, []int64{account.ID}, idleTimeouts); err == nil {
if count, ok := sessions[account.ID]; ok {
item.ActiveSessions = &count
}
}
}
}
return item
}
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")
// 标准化和验证 search 参数
search = strings.TrimSpace(search)
if len(search) > 100 {
search = search[:100]
}
2025-12-18 13:50:39 +08:00
var groupID int64
if groupIDStr := c.Query("group"); groupIDStr != "" {
groupID, _ = strconv.ParseInt(groupIDStr, 10, 64)
}
accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID)
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
}
// 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)
}
// 识别需要查询窗口费用和会话数的账号Anthropic OAuth/SetupToken 且启用了相应功能)
windowCostAccountIDs := make([]int64, 0)
sessionLimitAccountIDs := make([]int64, 0)
2026-02-02 22:13:50 +08:00
sessionIdleTimeouts := make(map[int64]time.Duration) // 各账号的会话空闲超时配置
for i := range accounts {
acc := &accounts[i]
if acc.IsAnthropicOAuthOrSetupToken() {
if acc.GetWindowCostLimit() > 0 {
windowCostAccountIDs = append(windowCostAccountIDs, acc.ID)
}
if acc.GetMaxSessions() > 0 {
sessionLimitAccountIDs = append(sessionLimitAccountIDs, acc.ID)
2026-02-02 22:13:50 +08:00
sessionIdleTimeouts[acc.ID] = time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute
}
}
}
// 并行获取窗口费用和活跃会话数
var windowCosts map[int64]float64
var activeSessions map[int64]int
2026-02-02 22:13:50 +08:00
// 获取活跃会话数(批量查询,传入各账号的 idleTimeout 配置)
if len(sessionLimitAccountIDs) > 0 && h.sessionLimitCache != nil {
2026-02-02 22:13:50 +08:00
activeSessions, _ = h.sessionLimitCache.GetActiveSessionCountBatch(c.Request.Context(), sessionLimitAccountIDs, sessionIdleTimeouts)
if activeSessions == nil {
activeSessions = make(map[int64]int)
}
}
// 获取窗口费用(并行查询)
if len(windowCostAccountIDs) > 0 {
windowCosts = make(map[int64]float64)
var mu sync.Mutex
g, gctx := errgroup.WithContext(c.Request.Context())
g.SetLimit(10) // 限制并发数
for i := range accounts {
acc := &accounts[i]
if !acc.IsAnthropicOAuthOrSetupToken() || acc.GetWindowCostLimit() <= 0 {
continue
}
accCopy := acc // 闭包捕获
g.Go(func() error {
2026-02-02 22:13:50 +08:00
// 使用统一的窗口开始时间计算逻辑(考虑窗口过期情况)
startTime := accCopy.GetCurrentWindowStartTime()
stats, err := h.accountUsageService.GetAccountWindowStats(gctx, accCopy.ID, startTime)
if err == nil && stats != nil {
mu.Lock()
windowCosts[accCopy.ID] = stats.StandardCost // 使用标准费用
mu.Unlock()
}
return nil // 不返回错误,允许部分失败
})
}
_ = g.Wait()
}
// Build response with concurrency info
result := make([]AccountWithConcurrency, len(accounts))
for i := range accounts {
acc := &accounts[i]
item := AccountWithConcurrency{
Account: dto.AccountFromService(acc),
CurrentConcurrency: concurrencyCounts[acc.ID],
}
// 添加窗口费用(仅当启用时)
if windowCosts != nil {
if cost, ok := windowCosts[acc.ID]; ok {
item.CurrentWindowCost = &cost
}
}
// 添加活跃会话数(仅当启用时)
if activeSessions != nil {
if count, ok := activeSessions[acc.ID]; ok {
item.ActiveSessions = &count
}
}
result[i] = item
}
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search)
if etag != "" {
c.Header("ETag", etag)
c.Header("Vary", "If-None-Match")
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
}
response.Paginated(c, result, total, page, pageSize)
2025-12-18 13:50:39 +08:00
}
func buildAccountsListETag(
items []AccountWithConcurrency,
total int64,
page, pageSize int,
platform, accountType, status, search string,
) string {
payload := struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Platform string `json:"platform"`
AccountType string `json:"type"`
Status string `json:"status"`
Search string `json:"search"`
Items []AccountWithConcurrency `json:"items"`
}{
Total: total,
Page: page,
PageSize: pageSize,
Platform: platform,
AccountType: accountType,
Status: status,
Search: search,
Items: items,
}
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
sum := sha256.Sum256(raw)
return "\"" + hex.EncodeToString(sum[:]) + "\""
}
func ifNoneMatchMatched(ifNoneMatch, etag string) bool {
if etag == "" || ifNoneMatch == "" {
return false
}
for _, token := range strings.Split(ifNoneMatch, ",") {
candidate := strings.TrimSpace(token)
if candidate == "*" {
return true
}
if candidate == etag {
return true
}
if strings.HasPrefix(candidate, "W/") && strings.TrimPrefix(candidate, "W/") == etag {
return true
}
}
return false
}
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
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
2025-12-18 13:50:39 +08:00
}
// CheckMixedChannel handles checking mixed channel risk for account-group binding.
// POST /api/v1/admin/accounts/check-mixed-channel
func (h *AccountHandler) CheckMixedChannel(c *gin.Context) {
var req CheckMixedChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.GroupIDs) == 0 {
response.Success(c, gin.H{"has_risk": false})
return
}
accountID := int64(0)
if req.AccountID != nil {
accountID = *req.AccountID
}
err := h.adminService.CheckMixedChannelRisk(c.Request.Context(), accountID, req.Platform, req.GroupIDs)
if err != nil {
var mixedErr *service.MixedChannelError
if errors.As(err, &mixedErr) {
response.Success(c, gin.H{
"has_risk": true,
"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,
},
})
return
}
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"has_risk": false})
}
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
}
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
response.BadRequest(c, "rate_multiplier must be >= 0")
return
}
2025-12-18 13:50:39 +08:00
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
result, err := executeAdminIdempotent(c, "admin.accounts.create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
account, execErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
Name: req.Name,
Notes: req.Notes,
Platform: req.Platform,
Type: req.Type,
Credentials: req.Credentials,
Extra: req.Extra,
ProxyID: req.ProxyID,
Concurrency: req.Concurrency,
Priority: req.Priority,
RateMultiplier: req.RateMultiplier,
GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
SkipMixedChannelCheck: skipCheck,
})
if execErr != nil {
return nil, execErr
}
return h.buildAccountResponseWithRuntime(ctx, account), nil
2025-12-18 13:50:39 +08:00
})
if err != nil {
// 检查是否为混合渠道错误
var mixedErr *service.MixedChannelError
if errors.As(err, &mixedErr) {
// 创建接口仅返回最小必要字段,详细信息由专门检查接口提供
c.JSON(409, gin.H{
"error": "mixed_channel_warning",
"message": mixedErr.Error(),
})
return
}
if retryAfter := service.RetryAfterSecondsFromError(err); retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
2025-12-25 20:52:47 +08:00
response.ErrorFrom(c, err)
2025-12-18 13:50:39 +08:00
return
}
if result != nil && result.Replayed {
c.Header("X-Idempotency-Replayed", "true")
}
response.Success(c, result.Data)
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
}
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
response.BadRequest(c, "rate_multiplier must be >= 0")
return
}
2025-12-18 13:50:39 +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{
Name: req.Name,
2026-01-05 14:07:33 +08:00
Notes: req.Notes,
Type: req.Type,
Credentials: req.Credentials,
Extra: req.Extra,
ProxyID: req.ProxyID,
Concurrency: req.Concurrency, // 指针类型nil 表示未提供
Priority: req.Priority, // 指针类型nil 表示未提供
RateMultiplier: req.RateMultiplier,
Status: req.Status,
GroupIDs: req.GroupIDs,
2026-01-07 16:59:35 +08:00
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
SkipMixedChannelCheck: skipCheck,
2025-12-18 13:50:39 +08:00
})
if err != nil {
// 检查是否为混合渠道错误
var mixedErr *service.MixedChannelError
if errors.As(err, &mixedErr) {
// 更新接口仅返回最小必要字段,详细信息由专门检查接口提供
c.JSON(409, gin.H{
"error": "mixed_channel_warning",
"message": mixedErr.Error(),
})
return
}
2025-12-25 20:52:47 +08:00
response.ErrorFrom(c, err)
2025-12-18 13:50:39 +08:00
return
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), 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"})
}
// TestAccountRequest represents the request body for testing an account
type TestAccountRequest struct {
ModelID string `json:"model_id"`
}
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"`
SelectedAccountIDs []string `json:"selected_account_ids"`
}
type PreviewFromCRSRequest struct {
BaseURL string `json:"base_url" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
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
}
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
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
}
}
// 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,
SelectedAccountIDs: req.SelectedAccountIDs,
})
if err != nil {
// Provide detailed error message for CRS sync failures
response.InternalError(c, "CRS sync failed: "+err.Error())
return
}
response.Success(c, result)
}
// PreviewFromCRS handles previewing accounts from CRS before sync
// POST /api/v1/admin/accounts/sync/crs/preview
func (h *AccountHandler) PreviewFromCRS(c *gin.Context) {
var req PreviewFromCRSRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.crsSyncService.PreviewFromCRS(c.Request.Context(), service.SyncFromCRSInput{
BaseURL: req.BaseURL,
Username: req.Username,
Password: req.Password,
})
if err != nil {
response.InternalError(c, "CRS preview failed: "+err.Error())
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
}
}
} else if account.Platform == service.PlatformGemini {
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
}
}
} 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
}
}
2026-02-02 22:13:50 +08:00
// 特殊处理 project_id如果新值为空但旧值非空保留旧值
// 这确保了即使 LoadCodeAssist 失败project_id 也不会丢失
if newProjectID, _ := newCredentials["project_id"].(string); newProjectID == "" {
if oldProjectID := strings.TrimSpace(account.GetCredential("project_id")); oldProjectID != "" {
newCredentials["project_id"] = oldProjectID
}
}
// 如果 project_id 获取失败,更新凭证但不标记为 error
// LoadCodeAssist 失败可能是临时网络问题,给它机会在下次自动刷新时重试
if tokenInfo.ProjectIDMissing {
2026-02-02 22:13:50 +08:00
// 先更新凭证token 本身刷新成功了)
_, updateErr := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
Credentials: newCredentials,
})
if updateErr != nil {
response.InternalError(c, "Failed to update credentials: "+updateErr.Error())
return
}
2026-02-02 22:13:50 +08:00
// 不标记为 error只返回警告信息
response.Success(c, gin.H{
2026-02-02 22:13:50 +08:00
"message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)",
"warning": "missing_project_id_temporary",
})
return
}
// 成功获取到 project_id如果之前是 missing_project_id 错误则清除
if account.Status == service.StatusError && strings.Contains(account.ErrorMessage, "missing_project_id:") {
if _, clearErr := h.adminService.ClearAccountError(c.Request.Context(), accountID); clearErr != nil {
response.InternalError(c, "Failed to clear account error: "+clearErr.Error())
return
}
}
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
}
// 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
}
}
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
}
2026-02-02 22:13:50 +08:00
// 刷新成功后,清除 token 缓存,确保下次请求使用新 token
if h.tokenCacheInvalidator != nil {
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), updatedAccount); invalidateErr != nil {
// 缓存失效失败只记录日志,不影响主流程
_ = c.Error(invalidateErr)
}
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), 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
}
// 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))
stats, err := h.accountUsageService.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime)
if err != nil {
2025-12-25 20:52:47 +08:00
response.ErrorFrom(c, err)
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
}
2026-02-02 22:13:50 +08:00
// 清除错误后,同时清除 token 缓存,确保下次请求会获取最新的 token触发刷新或从 DB 读取)
// 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
if h.tokenCacheInvalidator != nil && account.IsOAuth() {
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), account); invalidateErr != nil {
// 缓存失效失败只记录日志,不影响主流程
_ = c.Error(invalidateErr)
}
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), 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
}
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
success := 0
failed := 0
results := make([]gin.H, 0, len(req.Accounts))
2026-02-05 17:46:08 +08:00
for _, item := range req.Accounts {
if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
failed++
results = append(results, gin.H{
"name": item.Name,
"success": false,
"error": "rate_multiplier must be >= 0",
})
continue
}
skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk
account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
Name: item.Name,
Notes: item.Notes,
Platform: item.Platform,
Type: item.Type,
Credentials: item.Credentials,
Extra: item.Extra,
ProxyID: item.ProxyID,
Concurrency: item.Concurrency,
Priority: item.Priority,
RateMultiplier: item.RateMultiplier,
GroupIDs: item.GroupIDs,
ExpiresAt: item.ExpiresAt,
AutoPauseOnExpired: item.AutoPauseOnExpired,
SkipMixedChannelCheck: skipCheck,
2026-02-05 17:46:08 +08:00
})
if err != nil {
failed++
results = append(results, gin.H{
"name": item.Name,
"success": false,
"error": err.Error(),
})
continue
}
success++
2026-02-05 17:46:08 +08:00
results = append(results, gin.H{
"name": item.Name,
"id": account.ID,
"success": true,
2026-02-05 17:46:08 +08:00
})
}
return gin.H{
"success": success,
"failed": failed,
"results": results,
}, nil
2025-12-18 13:50:39 +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()
fix(audit): 第二批审计修复 — P0 生产 Bug、安全加固、性能优化、缓存一致性、代码质量 基于 backend-code-audit 审计报告,修复剩余 P0/P1/P2 共 34 项问题: P0 生产 Bug: - 修复 time.Since(time.Now()) 计时逻辑错误 (P0-03) - generateRandomID 改用 crypto/rand 替代固定索引 (P0-04) - IncrementQuotaUsed 重写为 Ent 原子操作消除 TOCTOU 竞态 (P0-05) 安全加固: - gateway/openai handler 错误响应替换为泛化消息,防止内部信息泄露 (P1-14) - usage_log_repo dateFormat 参数改用白名单映射,防止 SQL 注入 (P1-16) - 默认配置安全加固:sslmode=prefer、response_headers=true、mode=release (P1-18/19, P2-15) 性能优化: - gateway handler 循环内 defer 替换为显式 releaseWait 闭包 (P1-02) - group_repo/promo_code_repo Count 前 Clone 查询避免状态污染 (P1-03) - usage_log_repo 四个查询添加 LIMIT 10000 防止 OOM (P1-07) - GetBatchUsageStats 添加时间范围参数,默认最近 30 天 (P1-10) - ip.go CIDR 预编译为包级变量 (P1-11) - BatchUpdateCredentials 重构为先验证后更新 (P1-13) 缓存一致性: - billing_cache 添加 jitteredTTL 防止缓存雪崩 (P2-10) - DeductUserBalance/UpdateSubscriptionUsage 错误传播修复 (P2-12) - UserService.UpdateBalance 成功后异步失效 billingCache (P2-13) 代码质量: - search 截断改为按 rune 处理,支持多字节字符 (P2-01) - TLS Handshake 改为 HandshakeContext 支持 context 取消 (P2-07) - CORS 预检添加 Access-Control-Max-Age: 86400 (P2-16) 测试覆盖: - 新增 user_service_test.go(UpdateBalance 缓存失效 6 个用例) - 新增 batch_update_credentials_test.go(fail-fast + 类型验证 7 个用例) - 新增 response_transformer_test.go、ip_test.go、usage_log_repo_unit_test.go、search_truncate_test.go - 集成测试:IncrementQuotaUsed 并发测试、billing_cache 错误传播测试 - config_test.go 补充 server.mode/sslmode 默认值断言 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:46:42 +08:00
// 阶段一:预验证所有账号存在,收集 credentials
type accountUpdate struct {
ID int64
Credentials map[string]any
}
updates := make([]accountUpdate, 0, len(req.AccountIDs))
for _, accountID := range req.AccountIDs {
account, err := h.adminService.GetAccount(ctx, accountID)
if err != nil {
fix(audit): 第二批审计修复 — P0 生产 Bug、安全加固、性能优化、缓存一致性、代码质量 基于 backend-code-audit 审计报告,修复剩余 P0/P1/P2 共 34 项问题: P0 生产 Bug: - 修复 time.Since(time.Now()) 计时逻辑错误 (P0-03) - generateRandomID 改用 crypto/rand 替代固定索引 (P0-04) - IncrementQuotaUsed 重写为 Ent 原子操作消除 TOCTOU 竞态 (P0-05) 安全加固: - gateway/openai handler 错误响应替换为泛化消息,防止内部信息泄露 (P1-14) - usage_log_repo dateFormat 参数改用白名单映射,防止 SQL 注入 (P1-16) - 默认配置安全加固:sslmode=prefer、response_headers=true、mode=release (P1-18/19, P2-15) 性能优化: - gateway handler 循环内 defer 替换为显式 releaseWait 闭包 (P1-02) - group_repo/promo_code_repo Count 前 Clone 查询避免状态污染 (P1-03) - usage_log_repo 四个查询添加 LIMIT 10000 防止 OOM (P1-07) - GetBatchUsageStats 添加时间范围参数,默认最近 30 天 (P1-10) - ip.go CIDR 预编译为包级变量 (P1-11) - BatchUpdateCredentials 重构为先验证后更新 (P1-13) 缓存一致性: - billing_cache 添加 jitteredTTL 防止缓存雪崩 (P2-10) - DeductUserBalance/UpdateSubscriptionUsage 错误传播修复 (P2-12) - UserService.UpdateBalance 成功后异步失效 billingCache (P2-13) 代码质量: - search 截断改为按 rune 处理,支持多字节字符 (P2-01) - TLS Handshake 改为 HandshakeContext 支持 context 取消 (P2-07) - CORS 预检添加 Access-Control-Max-Age: 86400 (P2-16) 测试覆盖: - 新增 user_service_test.go(UpdateBalance 缓存失效 6 个用例) - 新增 batch_update_credentials_test.go(fail-fast + 类型验证 7 个用例) - 新增 response_transformer_test.go、ip_test.go、usage_log_repo_unit_test.go、search_truncate_test.go - 集成测试:IncrementQuotaUsed 并发测试、billing_cache 错误传播测试 - config_test.go 补充 server.mode/sslmode 默认值断言 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:46:42 +08:00
response.Error(c, 404, fmt.Sprintf("Account %d not found", accountID))
return
}
if account.Credentials == nil {
account.Credentials = make(map[string]any)
}
account.Credentials[req.Field] = req.Value
fix(audit): 第二批审计修复 — P0 生产 Bug、安全加固、性能优化、缓存一致性、代码质量 基于 backend-code-audit 审计报告,修复剩余 P0/P1/P2 共 34 项问题: P0 生产 Bug: - 修复 time.Since(time.Now()) 计时逻辑错误 (P0-03) - generateRandomID 改用 crypto/rand 替代固定索引 (P0-04) - IncrementQuotaUsed 重写为 Ent 原子操作消除 TOCTOU 竞态 (P0-05) 安全加固: - gateway/openai handler 错误响应替换为泛化消息,防止内部信息泄露 (P1-14) - usage_log_repo dateFormat 参数改用白名单映射,防止 SQL 注入 (P1-16) - 默认配置安全加固:sslmode=prefer、response_headers=true、mode=release (P1-18/19, P2-15) 性能优化: - gateway handler 循环内 defer 替换为显式 releaseWait 闭包 (P1-02) - group_repo/promo_code_repo Count 前 Clone 查询避免状态污染 (P1-03) - usage_log_repo 四个查询添加 LIMIT 10000 防止 OOM (P1-07) - GetBatchUsageStats 添加时间范围参数,默认最近 30 天 (P1-10) - ip.go CIDR 预编译为包级变量 (P1-11) - BatchUpdateCredentials 重构为先验证后更新 (P1-13) 缓存一致性: - billing_cache 添加 jitteredTTL 防止缓存雪崩 (P2-10) - DeductUserBalance/UpdateSubscriptionUsage 错误传播修复 (P2-12) - UserService.UpdateBalance 成功后异步失效 billingCache (P2-13) 代码质量: - search 截断改为按 rune 处理,支持多字节字符 (P2-01) - TLS Handshake 改为 HandshakeContext 支持 context 取消 (P2-07) - CORS 预检添加 Access-Control-Max-Age: 86400 (P2-16) 测试覆盖: - 新增 user_service_test.go(UpdateBalance 缓存失效 6 个用例) - 新增 batch_update_credentials_test.go(fail-fast + 类型验证 7 个用例) - 新增 response_transformer_test.go、ip_test.go、usage_log_repo_unit_test.go、search_truncate_test.go - 集成测试:IncrementQuotaUsed 并发测试、billing_cache 错误传播测试 - config_test.go 补充 server.mode/sslmode 默认值断言 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:46:42 +08:00
updates = append(updates, accountUpdate{ID: accountID, Credentials: account.Credentials})
}
// 阶段二:依次更新,返回每个账号的成功/失败明细,便于调用方重试
success := 0
failed := 0
successIDs := make([]int64, 0, len(updates))
failedIDs := make([]int64, 0, len(updates))
results := make([]gin.H, 0, len(updates))
fix(audit): 第二批审计修复 — P0 生产 Bug、安全加固、性能优化、缓存一致性、代码质量 基于 backend-code-audit 审计报告,修复剩余 P0/P1/P2 共 34 项问题: P0 生产 Bug: - 修复 time.Since(time.Now()) 计时逻辑错误 (P0-03) - generateRandomID 改用 crypto/rand 替代固定索引 (P0-04) - IncrementQuotaUsed 重写为 Ent 原子操作消除 TOCTOU 竞态 (P0-05) 安全加固: - gateway/openai handler 错误响应替换为泛化消息,防止内部信息泄露 (P1-14) - usage_log_repo dateFormat 参数改用白名单映射,防止 SQL 注入 (P1-16) - 默认配置安全加固:sslmode=prefer、response_headers=true、mode=release (P1-18/19, P2-15) 性能优化: - gateway handler 循环内 defer 替换为显式 releaseWait 闭包 (P1-02) - group_repo/promo_code_repo Count 前 Clone 查询避免状态污染 (P1-03) - usage_log_repo 四个查询添加 LIMIT 10000 防止 OOM (P1-07) - GetBatchUsageStats 添加时间范围参数,默认最近 30 天 (P1-10) - ip.go CIDR 预编译为包级变量 (P1-11) - BatchUpdateCredentials 重构为先验证后更新 (P1-13) 缓存一致性: - billing_cache 添加 jitteredTTL 防止缓存雪崩 (P2-10) - DeductUserBalance/UpdateSubscriptionUsage 错误传播修复 (P2-12) - UserService.UpdateBalance 成功后异步失效 billingCache (P2-13) 代码质量: - search 截断改为按 rune 处理,支持多字节字符 (P2-01) - TLS Handshake 改为 HandshakeContext 支持 context 取消 (P2-07) - CORS 预检添加 Access-Control-Max-Age: 86400 (P2-16) 测试覆盖: - 新增 user_service_test.go(UpdateBalance 缓存失效 6 个用例) - 新增 batch_update_credentials_test.go(fail-fast + 类型验证 7 个用例) - 新增 response_transformer_test.go、ip_test.go、usage_log_repo_unit_test.go、search_truncate_test.go - 集成测试:IncrementQuotaUsed 并发测试、billing_cache 错误传播测试 - config_test.go 补充 server.mode/sslmode 默认值断言 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:46:42 +08:00
for _, u := range updates {
updateInput := &service.UpdateAccountInput{Credentials: u.Credentials}
fix(audit): 第二批审计修复 — P0 生产 Bug、安全加固、性能优化、缓存一致性、代码质量 基于 backend-code-audit 审计报告,修复剩余 P0/P1/P2 共 34 项问题: P0 生产 Bug: - 修复 time.Since(time.Now()) 计时逻辑错误 (P0-03) - generateRandomID 改用 crypto/rand 替代固定索引 (P0-04) - IncrementQuotaUsed 重写为 Ent 原子操作消除 TOCTOU 竞态 (P0-05) 安全加固: - gateway/openai handler 错误响应替换为泛化消息,防止内部信息泄露 (P1-14) - usage_log_repo dateFormat 参数改用白名单映射,防止 SQL 注入 (P1-16) - 默认配置安全加固:sslmode=prefer、response_headers=true、mode=release (P1-18/19, P2-15) 性能优化: - gateway handler 循环内 defer 替换为显式 releaseWait 闭包 (P1-02) - group_repo/promo_code_repo Count 前 Clone 查询避免状态污染 (P1-03) - usage_log_repo 四个查询添加 LIMIT 10000 防止 OOM (P1-07) - GetBatchUsageStats 添加时间范围参数,默认最近 30 天 (P1-10) - ip.go CIDR 预编译为包级变量 (P1-11) - BatchUpdateCredentials 重构为先验证后更新 (P1-13) 缓存一致性: - billing_cache 添加 jitteredTTL 防止缓存雪崩 (P2-10) - DeductUserBalance/UpdateSubscriptionUsage 错误传播修复 (P2-12) - UserService.UpdateBalance 成功后异步失效 billingCache (P2-13) 代码质量: - search 截断改为按 rune 处理,支持多字节字符 (P2-01) - TLS Handshake 改为 HandshakeContext 支持 context 取消 (P2-07) - CORS 预检添加 Access-Control-Max-Age: 86400 (P2-16) 测试覆盖: - 新增 user_service_test.go(UpdateBalance 缓存失效 6 个用例) - 新增 batch_update_credentials_test.go(fail-fast + 类型验证 7 个用例) - 新增 response_transformer_test.go、ip_test.go、usage_log_repo_unit_test.go、search_truncate_test.go - 集成测试:IncrementQuotaUsed 并发测试、billing_cache 错误传播测试 - config_test.go 补充 server.mode/sslmode 默认值断言 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:46:42 +08:00
if _, err := h.adminService.UpdateAccount(ctx, u.ID, updateInput); err != nil {
failed++
failedIDs = append(failedIDs, u.ID)
results = append(results, gin.H{
"account_id": u.ID,
"success": false,
"error": err.Error(),
})
continue
}
success++
successIDs = append(successIDs, u.ID)
results = append(results, gin.H{
"account_id": u.ID,
"success": true,
})
}
response.Success(c, gin.H{
"success": success,
"failed": failed,
"success_ids": successIDs,
"failed_ids": failedIDs,
"results": results,
})
}
// 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
}
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
response.BadRequest(c, "rate_multiplier must be >= 0")
return
}
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
hasUpdates := req.Name != "" ||
req.ProxyID != nil ||
req.Concurrency != nil ||
req.Priority != nil ||
req.RateMultiplier != nil ||
req.Status != "" ||
req.Schedulable != nil ||
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{
AccountIDs: req.AccountIDs,
Name: req.Name,
ProxyID: req.ProxyID,
Concurrency: req.Concurrency,
Priority: req.Priority,
RateMultiplier: req.RateMultiplier,
Status: req.Status,
Schedulable: req.Schedulable,
GroupIDs: req.GroupIDs,
Credentials: req.Credentials,
Extra: req.Extra,
SkipMixedChannelCheck: skipCheck,
})
if err != nil {
2025-12-25 20:52:47 +08:00
response.ErrorFrom(c, err)
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
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
2025-12-18 13:50:39 +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
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
2025-12-18 13:50: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
}
// 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
}
// 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-low", Type: "model", DisplayName: "Gemini 3 Pro Low"},
{ID: "gemini-3-pro-high", Type: "model", DisplayName: "Gemini 3 Pro High"},
{ID: "gemini-3.1-pro-low", Type: "model", DisplayName: "Gemini 3.1 Pro Low"},
{ID: "gemini-3.1-pro-high", Type: "model", DisplayName: "Gemini 3.1 Pro High"},
{ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview"},
}
models = append(models, geminiTestModels...)
response.Success(c, models)
return
}
// Handle Sora accounts
if account.Platform == service.PlatformSora {
response.Success(c, service.DefaultSoraModels(nil))
return
}
2025-12-22 22:58:31 +08:00
// Handle Claude/Anthropic accounts
// 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()
if len(mapping) == 0 {
// 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
}
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
}
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
}
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
}
tierID, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(ctx, account)
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
_, 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{
"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"],
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()
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", "", "", 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 err != nil {
response.ErrorFrom(c, err)
return
}
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 {
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
}
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
}
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
}
}
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
var mu sync.Mutex
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
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
}
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
_, 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
})
mu.Lock()
if updateErr != nil {
failedCount++
errors = append(errors, gin.H{
"account_id": acc.ID,
"error": updateErr.Error(),
})
} else {
successCount++
}
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
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
})
}
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 := 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
}
results := gin.H{
"total": len(accounts),
"success": successCount,
"failed": failedCount,
"errors": errors,
}
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
}
// GetAntigravityDefaultModelMapping 获取 Antigravity 平台的默认模型映射
// GET /api/v1/admin/accounts/antigravity/default-model-mapping
func (h *AccountHandler) GetAntigravityDefaultModelMapping(c *gin.Context) {
response.Success(c, domain.DefaultAntigravityModelMapping)
}