mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:08:02 +08:00
Remove optional upload_url / fake connectivity-only success from admin video tests. Stamp Grok CLI headers only on the CLI proxy so OAuth media against api.x.ai can complete and preview video like the gateway path.
3072 lines
95 KiB
Go
3072 lines
95 KiB
Go
// Package admin provides HTTP handlers for administrative operations.
|
||
package admin
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"log/slog"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||
"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/pkg/xai"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"golang.org/x/sync/errgroup"
|
||
)
|
||
|
||
// OAuthHandler handles OAuth-related operations for accounts
|
||
type OAuthHandler struct {
|
||
oauthService *service.OAuthService
|
||
}
|
||
|
||
// NewOAuthHandler creates a new OAuth handler
|
||
func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
|
||
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
|
||
grokOAuthService service.GrokOAuthTokenService
|
||
rateLimitService *service.RateLimitService
|
||
accountUsageService *service.AccountUsageService
|
||
accountTestService *service.AccountTestService
|
||
concurrencyService *service.ConcurrencyService
|
||
crsSyncService *service.CRSSyncService
|
||
sessionLimitCache service.SessionLimitCache
|
||
rpmCache service.RPMCache
|
||
tokenCacheInvalidator service.TokenCacheInvalidator
|
||
grokImportProber grokImportProber
|
||
upstreamBillingProbe *service.UpstreamBillingProbeService
|
||
ollamaCloudUsage *service.OllamaCloudUsageService
|
||
}
|
||
|
||
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
|
||
func (h *AccountHandler) SetUpstreamBillingProbeService(probe *service.UpstreamBillingProbeService) {
|
||
h.upstreamBillingProbe = probe
|
||
}
|
||
|
||
func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUsageService) {
|
||
h.ollamaCloudUsage = usage
|
||
}
|
||
|
||
// NewAccountHandler creates a new admin account handler
|
||
func NewAccountHandler(
|
||
adminService service.AdminService,
|
||
oauthService *service.OAuthService,
|
||
openaiOAuthService *service.OpenAIOAuthService,
|
||
geminiOAuthService *service.GeminiOAuthService,
|
||
antigravityOAuthService *service.AntigravityOAuthService,
|
||
grokOAuthService service.GrokOAuthTokenService,
|
||
rateLimitService *service.RateLimitService,
|
||
accountUsageService *service.AccountUsageService,
|
||
accountTestService *service.AccountTestService,
|
||
concurrencyService *service.ConcurrencyService,
|
||
crsSyncService *service.CRSSyncService,
|
||
sessionLimitCache service.SessionLimitCache,
|
||
rpmCache service.RPMCache,
|
||
tokenCacheInvalidator service.TokenCacheInvalidator,
|
||
) *AccountHandler {
|
||
return &AccountHandler{
|
||
adminService: adminService,
|
||
oauthService: oauthService,
|
||
openaiOAuthService: openaiOAuthService,
|
||
geminiOAuthService: geminiOAuthService,
|
||
antigravityOAuthService: antigravityOAuthService,
|
||
grokOAuthService: grokOAuthService,
|
||
rateLimitService: rateLimitService,
|
||
accountUsageService: accountUsageService,
|
||
accountTestService: accountTestService,
|
||
concurrencyService: concurrencyService,
|
||
crsSyncService: crsSyncService,
|
||
sessionLimitCache: sessionLimitCache,
|
||
rpmCache: rpmCache,
|
||
tokenCacheInvalidator: tokenCacheInvalidator,
|
||
}
|
||
}
|
||
|
||
// CreateAccountRequest represents create account request
|
||
type CreateAccountRequest struct {
|
||
Name string `json:"name" binding:"required"`
|
||
Notes *string `json:"notes"`
|
||
Platform string `json:"platform" binding:"required"`
|
||
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock service_account"`
|
||
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"`
|
||
LoadFactor *int `json:"load_factor"`
|
||
GroupIDs []int64 `json:"group_ids"`
|
||
ExpiresAt *int64 `json:"expires_at"`
|
||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
|
||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||
}
|
||
|
||
// UpdateAccountRequest represents update account request
|
||
// 使用指针类型来区分"未提供"和"设置为0"
|
||
type UpdateAccountRequest struct {
|
||
Name string `json:"name"`
|
||
Notes *string `json:"notes"`
|
||
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock service_account"`
|
||
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"`
|
||
LoadFactor *int `json:"load_factor"`
|
||
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
||
GroupIDs *[]int64 `json:"group_ids"`
|
||
ExpiresAt *int64 `json:"expires_at"`
|
||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
|
||
RateSyncEnabled *bool `json:"upstream_billing_rate_sync_enabled"`
|
||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||
}
|
||
|
||
// BulkUpdateAccountsRequest represents the payload for bulk editing accounts
|
||
type BulkUpdateAccountsRequest struct {
|
||
AccountIDs []int64 `json:"account_ids"`
|
||
Filters *BulkUpdateAccountFilters `json:"filters"`
|
||
Name string `json:"name"`
|
||
ProxyID *int64 `json:"proxy_id"`
|
||
Concurrency *int `json:"concurrency"`
|
||
Priority *int `json:"priority"`
|
||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||
LoadFactor *int `json:"load_factor"`
|
||
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"`
|
||
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
|
||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||
}
|
||
|
||
type BulkUpdateAccountFilters struct {
|
||
Platform string `json:"platform"`
|
||
Type string `json:"type"`
|
||
Status string `json:"status"`
|
||
Group string `json:"group"`
|
||
Search string `json:"search"`
|
||
PrivacyMode string `json:"privacy_mode"`
|
||
}
|
||
|
||
// 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"`
|
||
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
|
||
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
|
||
// 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回
|
||
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` // 当前窗口费用
|
||
ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
|
||
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
|
||
}
|
||
|
||
type AccountSchedulerScore struct {
|
||
BaseScore float64 `json:"base_score"`
|
||
StickyScore float64 `json:"sticky_score"`
|
||
StickyScoreInfinity bool `json:"sticky_score_infinity"`
|
||
StickyWeightedEnabled bool `json:"sticky_weighted_enabled"`
|
||
}
|
||
|
||
type AccountSchedulerGroupScore struct {
|
||
GroupID *int64 `json:"group_id"`
|
||
GroupName string `json:"group_name,omitempty"`
|
||
GroupPriority *int `json:"group_priority,omitempty"`
|
||
AccountSchedulerScore
|
||
}
|
||
|
||
const accountListGroupUngroupedQueryValue = "ungrouped"
|
||
|
||
func (h *AccountHandler) accountResponseFromService(account *service.Account) *dto.Account {
|
||
out := dto.AccountFromService(account)
|
||
if h != nil && h.ollamaCloudUsage != nil && out != nil {
|
||
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
|
||
item := AccountWithConcurrency{
|
||
Account: h.accountResponseFromService(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
|
||
}
|
||
}
|
||
}
|
||
|
||
if h.rpmCache != nil && account.GetBaseRPM() > 0 {
|
||
if rpm, err := h.rpmCache.GetRPM(ctx, account.ID); err == nil {
|
||
item.CurrentRPM = &rpm
|
||
}
|
||
}
|
||
}
|
||
|
||
h.enrichShadowParents(ctx, []AccountWithConcurrency{item})
|
||
|
||
return item
|
||
}
|
||
|
||
// scoreOpenAIAccountSchedulerPool 对池内 OpenAI 账号计算调度分数快照。
|
||
// loadMap 为共享的账号负载数据(含池内全部账号即可,多余条目无害);传 nil 时自行批查。
|
||
func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, accounts []service.Account, loadMap map[int64]*service.AccountLoadInfo) map[int64]AccountSchedulerScore {
|
||
if len(accounts) == 0 {
|
||
return nil
|
||
}
|
||
|
||
openAIAccounts := make([]*service.Account, 0, len(accounts))
|
||
for i := range accounts {
|
||
account := &accounts[i]
|
||
if account.Platform != service.PlatformOpenAI {
|
||
continue
|
||
}
|
||
openAIAccounts = append(openAIAccounts, account)
|
||
}
|
||
if len(openAIAccounts) == 0 {
|
||
return nil
|
||
}
|
||
|
||
if loadMap == nil {
|
||
loadMap = h.fetchOpenAIAccountLoadMap(ctx, openAIAccounts)
|
||
}
|
||
|
||
var scores map[int64]service.OpenAIAccountSchedulerScoreSnapshot
|
||
if h.rateLimitService != nil {
|
||
scores = h.rateLimitService.BuildOpenAIAccountSchedulerScoreSnapshot(ctx, openAIAccounts, loadMap)
|
||
} else {
|
||
scores = service.BuildOpenAIAccountSchedulerScoreSnapshot(openAIAccounts, loadMap)
|
||
}
|
||
result := make(map[int64]AccountSchedulerScore, len(scores))
|
||
for accountID, score := range scores {
|
||
result[accountID] = AccountSchedulerScore{
|
||
BaseScore: score.BaseScore,
|
||
StickyScore: score.StickyScore,
|
||
StickyScoreInfinity: score.StickyScoreInfinity,
|
||
StickyWeightedEnabled: score.StickyWeightedEnabled,
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// fetchOpenAIAccountLoadMap 一次性批查给定 OpenAI 账号的负载数据;
|
||
// 失败时记录日志并返回空表(分数按零负载计算,属可接受降级)。
|
||
func (h *AccountHandler) fetchOpenAIAccountLoadMap(ctx context.Context, openAIAccounts []*service.Account) map[int64]*service.AccountLoadInfo {
|
||
loadMap := map[int64]*service.AccountLoadInfo{}
|
||
if h.concurrencyService == nil || len(openAIAccounts) == 0 {
|
||
return loadMap
|
||
}
|
||
seen := make(map[int64]struct{}, len(openAIAccounts))
|
||
loadReq := make([]service.AccountWithConcurrency, 0, len(openAIAccounts))
|
||
for _, account := range openAIAccounts {
|
||
if account == nil {
|
||
continue
|
||
}
|
||
if _, ok := seen[account.ID]; ok {
|
||
continue
|
||
}
|
||
seen[account.ID] = struct{}{}
|
||
loadReq = append(loadReq, service.AccountWithConcurrency{
|
||
ID: account.ID,
|
||
MaxConcurrency: account.EffectiveLoadFactor(),
|
||
})
|
||
}
|
||
if batchLoad, err := h.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); err != nil {
|
||
slog.Warn("openai_scheduler_score_load_batch_failed", "error", err)
|
||
} else if batchLoad != nil {
|
||
loadMap = batchLoad
|
||
}
|
||
return loadMap
|
||
}
|
||
|
||
func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
|
||
ctx context.Context,
|
||
accounts []service.Account,
|
||
filterPool []service.Account,
|
||
) (map[int64]*AccountSchedulerScore, map[int64][]AccountSchedulerGroupScore) {
|
||
if len(accounts) == 0 {
|
||
return nil, nil
|
||
}
|
||
if len(filterPool) == 0 {
|
||
filterPool = accounts
|
||
}
|
||
|
||
pageOpenAIAccountIDs := make(map[int64]struct{})
|
||
groupIDs := make(map[int64]struct{})
|
||
for i := range accounts {
|
||
account := &accounts[i]
|
||
if account.Platform != service.PlatformOpenAI {
|
||
continue
|
||
}
|
||
pageOpenAIAccountIDs[account.ID] = struct{}{}
|
||
if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 {
|
||
continue
|
||
}
|
||
for _, accountGroup := range account.AccountGroups {
|
||
if accountGroup.GroupID > 0 {
|
||
groupIDs[accountGroup.GroupID] = struct{}{}
|
||
}
|
||
}
|
||
for _, groupID := range account.GroupIDs {
|
||
if groupID > 0 {
|
||
groupIDs[groupID] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
if len(pageOpenAIAccountIDs) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
// 先取各分组池,再对"过滤池 ∪ 分组池"的账号并集做一次负载批查,
|
||
// 避免每个池各查一次 Redis 的 N+1。
|
||
groupIDList := make([]int64, 0, len(groupIDs))
|
||
for groupID := range groupIDs {
|
||
groupIDList = append(groupIDList, groupID)
|
||
}
|
||
sort.Slice(groupIDList, func(i, j int) bool { return groupIDList[i] < groupIDList[j] })
|
||
|
||
groupPools := make(map[int64][]service.Account, len(groupIDList))
|
||
if h.adminService != nil {
|
||
for _, groupID := range groupIDList {
|
||
gid := groupID
|
||
pool, err := h.adminService.ListOpenAISchedulableAccountsForSchedulerScore(ctx, &gid)
|
||
if err != nil {
|
||
slog.Warn("openai_scheduler_group_score_pool_failed", "group_id", gid, "error", err)
|
||
continue
|
||
}
|
||
groupPools[gid] = pool
|
||
}
|
||
}
|
||
|
||
loadUnion := make([]*service.Account, 0, len(filterPool))
|
||
collectOpenAIAccounts := func(pool []service.Account) {
|
||
for i := range pool {
|
||
if pool[i].Platform == service.PlatformOpenAI {
|
||
loadUnion = append(loadUnion, &pool[i])
|
||
}
|
||
}
|
||
}
|
||
collectOpenAIAccounts(filterPool)
|
||
for _, pool := range groupPools {
|
||
collectOpenAIAccounts(pool)
|
||
}
|
||
loadMap := h.fetchOpenAIAccountLoadMap(ctx, loadUnion)
|
||
|
||
baseScores := make(map[int64]*AccountSchedulerScore)
|
||
for accountID, score := range h.scoreOpenAIAccountSchedulerPool(ctx, filterPool, loadMap) {
|
||
copiedScore := score
|
||
baseScores[accountID] = &copiedScore
|
||
}
|
||
|
||
groupScoresByAccount := make(map[int64][]AccountSchedulerGroupScore)
|
||
scoreGroupPool := func(groupID *int64, groupNameByID map[int64]string, groupPriorityByAccount map[int64]int, pool []service.Account) {
|
||
if len(pool) == 0 {
|
||
return
|
||
}
|
||
scores := h.scoreOpenAIAccountSchedulerPool(ctx, pool, loadMap)
|
||
for accountID, schedulerScore := range scores {
|
||
if _, ok := pageOpenAIAccountIDs[accountID]; !ok {
|
||
continue
|
||
}
|
||
groupScore := AccountSchedulerGroupScore{
|
||
GroupID: groupID,
|
||
AccountSchedulerScore: schedulerScore,
|
||
}
|
||
if groupID != nil {
|
||
groupScore.GroupName = groupNameByID[*groupID]
|
||
if priority, ok := groupPriorityByAccount[accountID]; ok {
|
||
groupScore.GroupPriority = &priority
|
||
}
|
||
}
|
||
groupScoresByAccount[accountID] = append(groupScoresByAccount[accountID], groupScore)
|
||
}
|
||
}
|
||
|
||
for _, groupID := range groupIDList {
|
||
gid := groupID
|
||
pool, ok := groupPools[gid]
|
||
if !ok {
|
||
continue
|
||
}
|
||
groupNameByID := make(map[int64]string)
|
||
groupPriorityByAccount := make(map[int64]int)
|
||
for i := range pool {
|
||
account := &pool[i]
|
||
for _, accountGroup := range account.AccountGroups {
|
||
if accountGroup.GroupID != gid {
|
||
continue
|
||
}
|
||
groupPriorityByAccount[account.ID] = accountGroup.Priority
|
||
if accountGroup.Group != nil {
|
||
groupNameByID[gid] = accountGroup.Group.Name
|
||
}
|
||
}
|
||
}
|
||
scoreGroupPool(&gid, groupNameByID, groupPriorityByAccount, pool)
|
||
}
|
||
|
||
for accountID := range groupScoresByAccount {
|
||
sort.SliceStable(groupScoresByAccount[accountID], func(i, j int) bool {
|
||
left := groupScoresByAccount[accountID][i]
|
||
right := groupScoresByAccount[accountID][j]
|
||
return *left.GroupID < *right.GroupID
|
||
})
|
||
}
|
||
return baseScores, groupScoresByAccount
|
||
}
|
||
|
||
func (h *AccountHandler) listAccountSchedulerScoreFilterPool(
|
||
ctx context.Context,
|
||
platform, accountType, status, search string,
|
||
groupID int64,
|
||
privacyMode string,
|
||
) []service.Account {
|
||
if h.adminService == nil || (platform != "" && platform != service.PlatformOpenAI) {
|
||
return nil
|
||
}
|
||
// 池只用于 OpenAI 分数计算(非 OpenAI 账号会在打分时被丢弃),
|
||
// 无论列表页平台过滤为何,查询一律限定 openai,避免无过滤时全表扫描。
|
||
accounts, err := h.adminService.ListAccountsForSchedulerScoreFilter(ctx, service.PlatformOpenAI, accountType, status, search, groupID, privacyMode)
|
||
if err != nil {
|
||
slog.Warn("openai_scheduler_filter_score_pool_failed", "error", err)
|
||
return nil
|
||
}
|
||
return accounts
|
||
}
|
||
|
||
// 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")
|
||
privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
|
||
sortBy := c.DefaultQuery("sort_by", "name")
|
||
sortOrder := c.DefaultQuery("sort_order", "asc")
|
||
// 标准化和验证 search 参数
|
||
search = strings.TrimSpace(search)
|
||
if len(search) > 100 {
|
||
search = search[:100]
|
||
}
|
||
lite := parseBoolQueryWithDefault(c.Query("lite"), false)
|
||
// 调度分需要跨候选池批量打分并读取负载,默认列表不计算;只有前端列可见时才显式开启。
|
||
includeSchedulerScore := parseBoolQueryWithDefault(c.Query("include_scheduler_score"), false)
|
||
|
||
var groupID int64
|
||
if groupIDStr := c.Query("group"); groupIDStr != "" {
|
||
if groupIDStr == accountListGroupUngroupedQueryValue {
|
||
groupID = service.AccountListGroupUngrouped
|
||
} else {
|
||
parsedGroupID, parseErr := strconv.ParseInt(groupIDStr, 10, 64)
|
||
if parseErr != nil {
|
||
response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter"))
|
||
return
|
||
}
|
||
if parsedGroupID < 0 {
|
||
response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter"))
|
||
return
|
||
}
|
||
groupID = parsedGroupID
|
||
}
|
||
}
|
||
|
||
accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID, privacyMode, sortBy, sortOrder)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if h.ollamaCloudUsage != nil && len(accounts) > 0 {
|
||
accountPointers := make([]*service.Account, len(accounts))
|
||
for index := range accounts {
|
||
accountPointers[index] = &accounts[index]
|
||
}
|
||
if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Get current concurrency counts for all accounts
|
||
accountIDs := make([]int64, len(accounts))
|
||
for i, acc := range accounts {
|
||
accountIDs[i] = acc.ID
|
||
}
|
||
|
||
concurrencyCounts := make(map[int64]int)
|
||
var windowCosts map[int64]float64
|
||
var activeSessions map[int64]int
|
||
var rpmCounts map[int64]int
|
||
// 双重门控:用户要看该列,且当前页确实有 OpenAI 账号,才进入昂贵的候选池打分路径。
|
||
var schedulerScores map[int64]*AccountSchedulerScore
|
||
var schedulerGroupScores map[int64][]AccountSchedulerGroupScore
|
||
pageHasOpenAIAccounts := false
|
||
for i := range accounts {
|
||
if accounts[i].Platform == service.PlatformOpenAI {
|
||
pageHasOpenAIAccounts = true
|
||
break
|
||
}
|
||
}
|
||
if includeSchedulerScore && pageHasOpenAIAccounts {
|
||
schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode)
|
||
schedulerScores, schedulerGroupScores = h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool)
|
||
}
|
||
|
||
// 始终获取并发数(Redis ZCARD,极低开销)
|
||
if h.concurrencyService != nil {
|
||
if cc, ccErr := h.concurrencyService.GetAccountConcurrencyBatch(c.Request.Context(), accountIDs); ccErr == nil && cc != nil {
|
||
concurrencyCounts = cc
|
||
}
|
||
}
|
||
|
||
// 识别需要查询窗口费用、会话数和 RPM 的账号(Anthropic OAuth/SetupToken 且启用了相应功能)
|
||
windowCostAccountIDs := make([]int64, 0)
|
||
sessionLimitAccountIDs := make([]int64, 0)
|
||
rpmAccountIDs := make([]int64, 0)
|
||
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)
|
||
sessionIdleTimeouts[acc.ID] = time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute
|
||
}
|
||
if acc.GetBaseRPM() > 0 {
|
||
rpmAccountIDs = append(rpmAccountIDs, acc.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 始终获取 RPM 计数(Redis GET,极低开销)
|
||
if len(rpmAccountIDs) > 0 && h.rpmCache != nil {
|
||
rpmCounts, _ = h.rpmCache.GetRPMBatch(c.Request.Context(), rpmAccountIDs)
|
||
if rpmCounts == nil {
|
||
rpmCounts = make(map[int64]int)
|
||
}
|
||
}
|
||
|
||
// 始终获取活跃会话数(Redis ZCARD,低开销)
|
||
if len(sessionLimitAccountIDs) > 0 && h.sessionLimitCache != nil {
|
||
activeSessions, _ = h.sessionLimitCache.GetActiveSessionCountBatch(c.Request.Context(), sessionLimitAccountIDs, sessionIdleTimeouts)
|
||
if activeSessions == nil {
|
||
activeSessions = make(map[int64]int)
|
||
}
|
||
}
|
||
|
||
// 始终获取窗口费用(PostgreSQL 聚合查询)
|
||
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 {
|
||
// 使用统一的窗口开始时间计算逻辑(考虑窗口过期情况)
|
||
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: h.accountResponseFromService(acc),
|
||
CurrentConcurrency: concurrencyCounts[acc.ID],
|
||
SchedulerScore: schedulerScores[acc.ID],
|
||
SchedulerScores: schedulerGroupScores[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
|
||
}
|
||
}
|
||
|
||
// 添加 RPM 计数(仅当启用时)
|
||
if rpmCounts != nil {
|
||
if rpm, ok := rpmCounts[acc.ID]; ok {
|
||
item.CurrentRPM = &rpm
|
||
}
|
||
}
|
||
|
||
result[i] = item
|
||
}
|
||
|
||
h.enrichShadowParents(c.Request.Context(), result)
|
||
|
||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
|
||
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)
|
||
}
|
||
|
||
func buildAccountsListETag(
|
||
items []AccountWithConcurrency,
|
||
total int64,
|
||
page, pageSize int,
|
||
platform, accountType, status, search string,
|
||
lite bool,
|
||
) 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"`
|
||
Lite bool `json:"lite"`
|
||
Items []AccountWithConcurrency `json:"items"`
|
||
}{
|
||
Total: total,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
Platform: platform,
|
||
AccountType: accountType,
|
||
Status: status,
|
||
Search: search,
|
||
Lite: lite,
|
||
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
|
||
}
|
||
|
||
// 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 {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if h.ollamaCloudUsage != nil {
|
||
if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), []*service.Account{account}); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||
}
|
||
|
||
// 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})
|
||
}
|
||
|
||
// 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 err := service.ValidateOpenAILongContextBillingExtra(req.Platform, req.Extra); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
|
||
response.BadRequest(c, "rate_multiplier must be >= 0")
|
||
return
|
||
}
|
||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||
sanitizeExtraBaseRPM(req.Extra)
|
||
|
||
// 确定是否跳过混合渠道检查
|
||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||
|
||
// 捕获闭包内创建的账号引用,用于创建成功后触发异步探测。
|
||
// 幂等重放时闭包不会执行 → createdAccount 为 nil → 不重复调度。
|
||
var createdAccount *service.Account
|
||
|
||
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,
|
||
LoadFactor: req.LoadFactor,
|
||
GroupIDs: req.GroupIDs,
|
||
ExpiresAt: req.ExpiresAt,
|
||
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
||
ProbeEnabled: req.ProbeEnabled,
|
||
SkipMixedChannelCheck: skipCheck,
|
||
})
|
||
if execErr != nil {
|
||
return nil, execErr
|
||
}
|
||
createdAccount = account
|
||
// Antigravity OAuth: 新账号直接设置隐私
|
||
h.adminService.ForceAntigravityPrivacy(ctx, account)
|
||
// OpenAI OAuth: 新账号直接设置隐私
|
||
h.adminService.ForceOpenAIPrivacy(ctx, account)
|
||
return h.buildAccountResponseWithRuntime(ctx, account), nil
|
||
})
|
||
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))
|
||
}
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
if result != nil && result.Replayed {
|
||
c.Header("X-Idempotency-Replayed", "true")
|
||
}
|
||
// OpenAI APIKey 账号创建后异步探测上游 /v1/responses 能力。
|
||
// 探测失败不影响账号创建响应。
|
||
h.scheduleOpenAIResponsesProbe(createdAccount)
|
||
h.scheduleGrokImportProbe(createdAccount)
|
||
response.Success(c, result.Data)
|
||
}
|
||
|
||
// Duplicate handles creating an independent account from an existing account's configuration.
|
||
// POST /api/v1/admin/accounts/:id/duplicate
|
||
func (h *AccountHandler) Duplicate(c *gin.Context) {
|
||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.BadRequest(c, "Invalid account ID")
|
||
return
|
||
}
|
||
actorScope := adminActorScope(c)
|
||
|
||
result, err := executeAdminIdempotent(
|
||
c,
|
||
"admin.accounts.duplicate",
|
||
struct {
|
||
AccountID int64 `json:"account_id"`
|
||
}{AccountID: accountID},
|
||
service.DefaultWriteIdempotencyTTL(),
|
||
func(ctx context.Context) (any, error) {
|
||
account, execErr := h.adminService.DuplicateAccount(ctx, accountID, actorScope, c.GetHeader("Idempotency-Key"))
|
||
if execErr != nil {
|
||
return nil, execErr
|
||
}
|
||
return h.buildAccountResponseWithRuntime(ctx, account), nil
|
||
},
|
||
)
|
||
if err != nil {
|
||
reason := infraerrors.Reason(err)
|
||
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
|
||
recovered, recoverErr := h.adminService.RecoverDuplicateAccount(c.Request.Context(), accountID, actorScope, c.GetHeader("Idempotency-Key"))
|
||
if recoverErr != nil {
|
||
slog.Warn("account_duplicate_recovery_failed", "account_id", accountID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
|
||
} else if recovered != nil {
|
||
c.Header("X-Idempotency-Recovered", "true")
|
||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), recovered))
|
||
return
|
||
}
|
||
}
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
if result != nil && result.Replayed {
|
||
c.Header("X-Idempotency-Replayed", "true")
|
||
}
|
||
response.Success(c, result.Data)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||
sanitizeExtraBaseRPM(req.Extra)
|
||
|
||
// 确定是否跳过混合渠道检查
|
||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||
|
||
account, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||
Name: req.Name,
|
||
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,
|
||
LoadFactor: req.LoadFactor,
|
||
Status: req.Status,
|
||
GroupIDs: req.GroupIDs,
|
||
ExpiresAt: req.ExpiresAt,
|
||
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
||
ProbeEnabled: req.ProbeEnabled,
|
||
RateSyncEnabled: req.RateSyncEnabled,
|
||
SkipMixedChannelCheck: skipCheck,
|
||
})
|
||
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
|
||
}
|
||
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// OpenAI APIKey: credentials 修改后重新探测上游能力(base_url/api_key 可能变更)。
|
||
// 异步执行,探测失败不影响账号更新响应。
|
||
if len(req.Credentials) > 0 {
|
||
h.scheduleOpenAIResponsesProbe(account)
|
||
}
|
||
|
||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||
}
|
||
|
||
// scheduleOpenAIResponsesProbe 异步触发 OpenAI APIKey 账号的 Responses API 能力探测。
|
||
//
|
||
// 仅对 platform=openai && type=apikey 账号生效;其他账号无操作。
|
||
// 探测本身在 goroutine 中执行(会发一次 HTTP 请求到上游),不会阻塞
|
||
// 当前请求。探测错误仅记录日志,不向上下文传播:探测失败时标记保持缺失,
|
||
// 网关会按"现状即证据"默认走 Responses。
|
||
func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) {
|
||
if account == nil || account.Platform != service.PlatformOpenAI || account.Type != service.AccountTypeAPIKey {
|
||
return
|
||
}
|
||
if h.accountTestService == nil {
|
||
return
|
||
}
|
||
accountID := account.ID
|
||
go func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
slog.Error("openai_responses_probe_panic", "account_id", accountID, "recover", r)
|
||
}
|
||
}()
|
||
h.accountTestService.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), accountID)
|
||
}()
|
||
}
|
||
|
||
// 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 {
|
||
response.ErrorFrom(c, err)
|
||
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"`
|
||
Prompt string `json:"prompt"`
|
||
Mode string `json:"mode"`
|
||
// Optional media for Grok (and future) real generation tests.
|
||
// ImageDataURL / AudioDataURL are data:<mime>;base64,... payloads.
|
||
ImageDataURL string `json:"image_data_url"`
|
||
AudioDataURL string `json:"audio_data_url"`
|
||
}
|
||
|
||
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"`
|
||
}
|
||
|
||
// 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)
|
||
|
||
opts := service.AccountTestOptions{
|
||
ImageDataURL: req.ImageDataURL,
|
||
AudioDataURL: req.AudioDataURL,
|
||
}
|
||
|
||
// Use AccountTestService to test the account with SSE streaming
|
||
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode, opts); err != nil {
|
||
// Error already sent via SSE, just log
|
||
return
|
||
}
|
||
|
||
if h.rateLimitService != nil {
|
||
if _, err := h.rateLimitService.RecoverAccountAfterSuccessfulTest(c.Request.Context(), accountID); err != nil {
|
||
_ = c.Error(err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// RecoverState handles unified recovery of recoverable account runtime state.
|
||
// POST /api/v1/admin/accounts/:id/recover-state
|
||
func (h *AccountHandler) RecoverState(c *gin.Context) {
|
||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.BadRequest(c, "Invalid account ID")
|
||
return
|
||
}
|
||
|
||
if h.rateLimitService == nil {
|
||
response.Error(c, http.StatusServiceUnavailable, "Rate limit service unavailable")
|
||
return
|
||
}
|
||
|
||
if _, err := h.rateLimitService.RecoverAccountState(c.Request.Context(), accountID, service.AccountRecoveryOptions{
|
||
InvalidateToken: true,
|
||
}); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
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))
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// refreshSingleAccount refreshes credentials for a single OAuth account.
|
||
// Returns (updatedAccount, warning, error) where warning is used for Antigravity ProjectIDMissing scenario.
|
||
func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *service.Account) (*service.Account, string, error) {
|
||
if !account.IsOAuth() {
|
||
return nil, "", infraerrors.BadRequest("NOT_OAUTH", "cannot refresh non-OAuth account")
|
||
}
|
||
// spark 影子凭据由母账号管理、自身恒空,刷新无意义且会先打上游;在调用上游前早拒
|
||
// (覆盖单账号与批量两入口;批量侧将其计为 failed 并附说明)(外审第6轮)。
|
||
if account.IsCredentialShadow() {
|
||
return nil, "", infraerrors.BadRequest("SPARK_SHADOW_NO_REFRESH",
|
||
"cannot refresh spark shadow account; its credentials are managed by the parent account")
|
||
}
|
||
|
||
var newCredentials map[string]any
|
||
|
||
if account.IsOpenAI() {
|
||
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account)
|
||
if err != nil {
|
||
// 刷新失败但 access_token 可能仍有效,尝试设置隐私
|
||
h.adminService.EnsureOpenAIPrivacy(ctx, account)
|
||
return nil, "", err
|
||
}
|
||
|
||
newCredentials = h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
||
for k, v := range account.Credentials {
|
||
if _, exists := newCredentials[k]; !exists {
|
||
newCredentials[k] = v
|
||
}
|
||
}
|
||
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
|
||
} else if account.Platform == service.PlatformGemini {
|
||
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("failed to refresh credentials: %w", err)
|
||
}
|
||
|
||
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(ctx, account)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
newCredentials = h.antigravityOAuthService.BuildAccountCredentials(tokenInfo)
|
||
for k, v := range account.Credentials {
|
||
if _, exists := newCredentials[k]; !exists {
|
||
newCredentials[k] = v
|
||
}
|
||
}
|
||
|
||
// 特殊处理 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
|
||
if tokenInfo.ProjectIDMissing {
|
||
updatedAccount, updateErr := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{
|
||
Credentials: newCredentials,
|
||
})
|
||
if updateErr != nil {
|
||
return nil, "", fmt.Errorf("failed to update credentials: %w", updateErr)
|
||
}
|
||
h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
|
||
return updatedAccount, "missing_project_id_temporary", nil
|
||
}
|
||
|
||
// 成功获取到 project_id,如果之前是 missing_project_id 错误则清除
|
||
if account.Status == service.StatusError && strings.Contains(account.ErrorMessage, "missing_project_id:") {
|
||
if _, clearErr := h.adminService.ClearAccountError(ctx, account.ID); clearErr != nil {
|
||
return nil, "", fmt.Errorf("failed to clear account error: %w", clearErr)
|
||
}
|
||
}
|
||
} else if account.Platform == service.PlatformGrok {
|
||
if h.grokOAuthService == nil {
|
||
return nil, "", fmt.Errorf("grok oauth service is not configured")
|
||
}
|
||
tokenInfo, err := h.grokOAuthService.RefreshAccountToken(ctx, account)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("failed to refresh Grok credentials: %w", err)
|
||
}
|
||
|
||
newCredentials = service.MergeCredentials(account.Credentials, h.grokOAuthService.BuildAccountCredentials(tokenInfo))
|
||
if baseURL := strings.TrimSpace(account.GetCredential("base_url")); baseURL != "" {
|
||
newCredentials["base_url"] = baseURL
|
||
}
|
||
} else {
|
||
// Use Anthropic/Claude OAuth service to refresh token
|
||
tokenInfo, err := h.oauthService.RefreshAccountToken(ctx, account)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
// 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(ctx, account.ID, &service.UpdateAccountInput{
|
||
Credentials: newCredentials,
|
||
})
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
// 刷新成功后,清除 token 缓存,确保下次请求使用新 token
|
||
if h.tokenCacheInvalidator != nil {
|
||
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updatedAccount); invalidateErr != nil {
|
||
log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", updatedAccount.ID, invalidateErr)
|
||
}
|
||
}
|
||
|
||
// OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
|
||
h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
|
||
// Antigravity OAuth: 刷新成功后检查并设置 privacy_mode
|
||
h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
|
||
|
||
return updatedAccount, "", nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
updatedAccount, warning, err := h.refreshSingleAccount(c.Request.Context(), account)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
if warning == "missing_project_id_temporary" {
|
||
response.Success(c, gin.H{
|
||
"message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)",
|
||
"warning": "missing_project_id_temporary",
|
||
})
|
||
return
|
||
}
|
||
|
||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount))
|
||
}
|
||
|
||
// ApplyOAuthCredentialsRequest is the payload for persisting re-authorized OAuth credentials.
|
||
type ApplyOAuthCredentialsRequest struct {
|
||
Type string `json:"type" binding:"required,oneof=oauth setup-token"`
|
||
Credentials map[string]any `json:"credentials" binding:"required"`
|
||
Extra map[string]any `json:"extra"`
|
||
}
|
||
|
||
// ApplyOAuthCredentials 将"重新授权"得到的新凭据原子落库。
|
||
// POST /api/v1/admin/accounts/:id/apply-oauth-credentials
|
||
//
|
||
// 与通用 PUT /:id (Update) 接口的关键区别:
|
||
// - 仅接收 type / credentials / extra 三个字段(不接受 concurrency / rpm / quota_* 等可能误传的字段)
|
||
// - Extra 走 UpdateAccountExtra(JSONB key 级合并),**绝不**全量覆盖;
|
||
// 避免 base_rpm / window_cost_limit / max_sessions / quota_* / privacy_mode
|
||
// 等持久化配置在重新授权后丢失
|
||
// - 内置 ClearError + InvalidateToken,避免前端额外两次调用,
|
||
// 并修复旧路径未失效 token 缓存导致重新授权后立即 401 的隐性 bug
|
||
//
|
||
// 与 /refresh 的区别:/refresh 用现有 refresh_token 换 access_token(无用户交互),
|
||
// 本接口承接前端完成完整 OAuth 流程后的落库步骤。
|
||
func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
|
||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.BadRequest(c, "Invalid account ID")
|
||
return
|
||
}
|
||
|
||
var req ApplyOAuthCredentialsRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
ctx := c.Request.Context()
|
||
|
||
// 预检查账号存在 + OAuth 类型(与 Refresh handler 语义一致,提供更友好的错误信息)。
|
||
existing, err := h.adminService.GetAccount(ctx, accountID)
|
||
if err != nil {
|
||
response.NotFound(c, "Account not found")
|
||
return
|
||
}
|
||
if !existing.IsOAuth() {
|
||
response.ErrorFrom(c, infraerrors.BadRequest("NOT_OAUTH", "cannot apply oauth credentials to non-OAuth account"))
|
||
return
|
||
}
|
||
if err := service.ValidateOpenAILongContextBillingExtra(existing.Platform, req.Extra); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
|
||
req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
|
||
|
||
updatedAccount, err := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{
|
||
Type: req.Type,
|
||
Credentials: req.Credentials,
|
||
})
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// 增量合并 Extra(JSONB key 级 merge,绝不覆盖 base_rpm / window_cost_limit /
|
||
// max_sessions / quota_* / privacy_mode 等持久化键)。
|
||
// best-effort:失败仅记日志;下方 ClearAccountError 会从 DB 重新读取最新 account,
|
||
// 因此响应里的 extra 始终以 DB 为准——这里不需要手动维护内存快照。
|
||
if len(req.Extra) > 0 {
|
||
if extraErr := h.adminService.UpdateAccountExtra(ctx, accountID, req.Extra); extraErr != nil {
|
||
extraKeys := make([]string, 0, len(req.Extra))
|
||
for k := range req.Extra {
|
||
extraKeys = append(extraKeys, k)
|
||
}
|
||
slog.Error("apply_oauth_credentials.update_extra_failed",
|
||
"account_id", accountID,
|
||
"extra_keys", extraKeys,
|
||
"err", extraErr,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Successful re-auth clears the soft spending-limit reauth flag for Grok.
|
||
if existing.Platform == service.PlatformGrok {
|
||
if clearErr := h.adminService.UpdateAccountExtra(ctx, accountID, map[string]any{
|
||
"grok_needs_reauth": false,
|
||
"grok_needs_reauth_reason": "",
|
||
"grok_needs_reauth_at": "",
|
||
}); clearErr != nil {
|
||
slog.Warn("apply_oauth_credentials.clear_grok_reauth_failed",
|
||
"account_id", accountID,
|
||
"err", clearErr,
|
||
)
|
||
}
|
||
}
|
||
|
||
if cleared, clearErr := h.adminService.ClearAccountError(ctx, accountID); clearErr != nil {
|
||
slog.Warn("apply_oauth_credentials.clear_error_failed",
|
||
"account_id", accountID,
|
||
"err", clearErr,
|
||
)
|
||
} else if cleared != nil {
|
||
updatedAccount = cleared
|
||
}
|
||
|
||
if h.tokenCacheInvalidator != nil && updatedAccount.IsOAuth() {
|
||
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updatedAccount); invalidateErr != nil {
|
||
slog.Warn("apply_oauth_credentials.invalidate_token_failed",
|
||
"account_id", accountID,
|
||
"err", invalidateErr,
|
||
)
|
||
}
|
||
}
|
||
|
||
response.Success(c, h.buildAccountResponseWithRuntime(ctx, updatedAccount))
|
||
}
|
||
|
||
// 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 {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, stats)
|
||
}
|
||
|
||
// 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 {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// 清除错误后,同时清除 token 缓存,确保下次请求会获取最新的 token(触发刷新或从 DB 读取)
|
||
// 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
|
||
if h.tokenCacheInvalidator != nil && account.IsOAuth() {
|
||
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), account); invalidateErr != nil {
|
||
log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", accountID, invalidateErr)
|
||
}
|
||
}
|
||
|
||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||
}
|
||
|
||
// RevertProxyFallback handles reverting account proxy to original before fallback.
|
||
// POST /api/v1/admin/accounts/:id/revert-proxy-fallback
|
||
func (h *AccountHandler) RevertProxyFallback(c *gin.Context) {
|
||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.BadRequest(c, "Invalid account ID")
|
||
return
|
||
}
|
||
if err := h.adminService.RevertAccountProxyFallback(c.Request.Context(), id); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
response.Success(c, gin.H{"message": "reverted"})
|
||
}
|
||
|
||
// BatchDelete handles deleting multiple accounts with bounded concurrency.
|
||
// POST /api/v1/admin/accounts/batch-delete
|
||
func (h *AccountHandler) BatchDelete(c *gin.Context) {
|
||
var req struct {
|
||
AccountIDs []int64 `json:"account_ids"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
accountIDs := normalizeInt64IDList(req.AccountIDs)
|
||
if len(accountIDs) == 0 {
|
||
response.BadRequest(c, "account_ids is required")
|
||
return
|
||
}
|
||
|
||
accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), accountIDs)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
type deleteError struct {
|
||
AccountID int64 `json:"account_id"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
requestedIDs := make(map[int64]struct{}, len(accountIDs))
|
||
for _, accountID := range accountIDs {
|
||
requestedIDs[accountID] = struct{}{}
|
||
}
|
||
accountsByID := make(map[int64]*service.Account, len(accounts))
|
||
for _, account := range accounts {
|
||
if account != nil {
|
||
accountsByID[account.ID] = account
|
||
}
|
||
}
|
||
|
||
rootIDs := make([]int64, 0, len(accountIDs))
|
||
dependentIDs := make(map[int64][]int64)
|
||
failedIDs := make([]int64, 0)
|
||
errorsByAccount := make([]deleteError, 0)
|
||
for _, accountID := range accountIDs {
|
||
account := accountsByID[accountID]
|
||
if account == nil {
|
||
failedIDs = append(failedIDs, accountID)
|
||
errorsByAccount = append(errorsByAccount, deleteError{
|
||
AccountID: accountID,
|
||
Error: "account not found",
|
||
})
|
||
continue
|
||
}
|
||
|
||
rootID := accountID
|
||
visited := map[int64]struct{}{accountID: {}}
|
||
for {
|
||
current := accountsByID[rootID]
|
||
if current == nil || current.ParentAccountID == nil {
|
||
break
|
||
}
|
||
parentID := *current.ParentAccountID
|
||
if _, selected := requestedIDs[parentID]; !selected {
|
||
break
|
||
}
|
||
if _, exists := accountsByID[parentID]; !exists {
|
||
break
|
||
}
|
||
if _, cyclic := visited[parentID]; cyclic {
|
||
rootID = accountID
|
||
break
|
||
}
|
||
visited[parentID] = struct{}{}
|
||
rootID = parentID
|
||
}
|
||
|
||
if rootID != accountID {
|
||
dependentIDs[rootID] = append(dependentIDs[rootID], accountID)
|
||
continue
|
||
}
|
||
rootIDs = append(rootIDs, accountID)
|
||
}
|
||
|
||
const maxConcurrency = 5
|
||
g, gctx := errgroup.WithContext(c.Request.Context())
|
||
g.SetLimit(maxConcurrency)
|
||
|
||
var mu sync.Mutex
|
||
successIDs := make([]int64, 0, len(accountIDs))
|
||
|
||
// Every worker returns nil so one account failure does not cancel the remaining deletions.
|
||
for _, id := range rootIDs {
|
||
accountID := id
|
||
g.Go(func() error {
|
||
err := h.adminService.DeleteAccount(gctx, accountID)
|
||
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
affectedIDs := append([]int64{accountID}, dependentIDs[accountID]...)
|
||
if err != nil {
|
||
for _, affectedID := range affectedIDs {
|
||
failedIDs = append(failedIDs, affectedID)
|
||
errorsByAccount = append(errorsByAccount, deleteError{
|
||
AccountID: affectedID,
|
||
Error: err.Error(),
|
||
})
|
||
}
|
||
return nil
|
||
}
|
||
successIDs = append(successIDs, affectedIDs...)
|
||
return nil
|
||
})
|
||
}
|
||
|
||
if err := g.Wait(); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
sort.Slice(successIDs, func(i, j int) bool { return successIDs[i] < successIDs[j] })
|
||
sort.Slice(failedIDs, func(i, j int) bool { return failedIDs[i] < failedIDs[j] })
|
||
sort.Slice(errorsByAccount, func(i, j int) bool {
|
||
return errorsByAccount[i].AccountID < errorsByAccount[j].AccountID
|
||
})
|
||
|
||
response.Success(c, gin.H{
|
||
"total": len(accountIDs),
|
||
"success": len(successIDs),
|
||
"failed": len(failedIDs),
|
||
"success_ids": successIDs,
|
||
"failed_ids": failedIDs,
|
||
"errors": errorsByAccount,
|
||
})
|
||
}
|
||
|
||
// BatchClearError handles batch clearing account errors
|
||
// POST /api/v1/admin/accounts/batch-clear-error
|
||
func (h *AccountHandler) BatchClearError(c *gin.Context) {
|
||
var req struct {
|
||
AccountIDs []int64 `json:"account_ids"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
if len(req.AccountIDs) == 0 {
|
||
response.BadRequest(c, "account_ids is required")
|
||
return
|
||
}
|
||
|
||
ctx := c.Request.Context()
|
||
|
||
const maxConcurrency = 10
|
||
g, gctx := errgroup.WithContext(ctx)
|
||
g.SetLimit(maxConcurrency)
|
||
|
||
var mu sync.Mutex
|
||
var successCount, failedCount int
|
||
var errors []gin.H
|
||
|
||
// 注意:所有 goroutine 必须 return nil,避免 errgroup cancel 其他并发任务
|
||
for _, id := range req.AccountIDs {
|
||
accountID := id // 闭包捕获
|
||
g.Go(func() error {
|
||
account, err := h.adminService.ClearAccountError(gctx, accountID)
|
||
if err != nil {
|
||
mu.Lock()
|
||
failedCount++
|
||
errors = append(errors, gin.H{
|
||
"account_id": accountID,
|
||
"error": err.Error(),
|
||
})
|
||
mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// 清除错误后,同时清除 token 缓存
|
||
if h.tokenCacheInvalidator != nil && account.IsOAuth() {
|
||
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(gctx, account); invalidateErr != nil {
|
||
log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", accountID, invalidateErr)
|
||
}
|
||
}
|
||
|
||
mu.Lock()
|
||
successCount++
|
||
mu.Unlock()
|
||
return nil
|
||
})
|
||
}
|
||
|
||
if err := g.Wait(); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{
|
||
"total": len(req.AccountIDs),
|
||
"success": successCount,
|
||
"failed": failedCount,
|
||
"errors": errors,
|
||
})
|
||
}
|
||
|
||
// BatchRefresh handles batch refreshing account credentials
|
||
// POST /api/v1/admin/accounts/batch-refresh
|
||
func (h *AccountHandler) BatchRefresh(c *gin.Context) {
|
||
var req struct {
|
||
AccountIDs []int64 `json:"account_ids"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
if len(req.AccountIDs) == 0 {
|
||
response.BadRequest(c, "account_ids is required")
|
||
return
|
||
}
|
||
|
||
ctx := c.Request.Context()
|
||
|
||
accounts, err := h.adminService.GetAccountsByIDs(ctx, req.AccountIDs)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// 建立已获取账号的 ID 集合,检测缺失的 ID
|
||
foundIDs := make(map[int64]bool, len(accounts))
|
||
for _, acc := range accounts {
|
||
if acc != nil {
|
||
foundIDs[acc.ID] = true
|
||
}
|
||
}
|
||
|
||
const maxConcurrency = 10
|
||
g, gctx := errgroup.WithContext(ctx)
|
||
g.SetLimit(maxConcurrency)
|
||
|
||
var mu sync.Mutex
|
||
var successCount, failedCount int
|
||
var errors []gin.H
|
||
var warnings []gin.H
|
||
|
||
// 将不存在的账号 ID 标记为失败
|
||
for _, id := range req.AccountIDs {
|
||
if !foundIDs[id] {
|
||
failedCount++
|
||
errors = append(errors, gin.H{
|
||
"account_id": id,
|
||
"error": "account not found",
|
||
})
|
||
}
|
||
}
|
||
|
||
// 注意:所有 goroutine 必须 return nil,避免 errgroup cancel 其他并发任务
|
||
for _, account := range accounts {
|
||
acc := account // 闭包捕获
|
||
if acc == nil {
|
||
continue
|
||
}
|
||
g.Go(func() error {
|
||
_, warning, err := h.refreshSingleAccount(gctx, acc)
|
||
mu.Lock()
|
||
if err != nil {
|
||
failedCount++
|
||
errors = append(errors, gin.H{
|
||
"account_id": acc.ID,
|
||
"error": err.Error(),
|
||
})
|
||
} else {
|
||
successCount++
|
||
if warning != "" {
|
||
warnings = append(warnings, gin.H{
|
||
"account_id": acc.ID,
|
||
"warning": warning,
|
||
})
|
||
}
|
||
}
|
||
mu.Unlock()
|
||
return nil
|
||
})
|
||
}
|
||
|
||
if err := g.Wait(); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{
|
||
"total": len(req.AccountIDs),
|
||
"success": successCount,
|
||
"failed": failedCount,
|
||
"errors": errors,
|
||
"warnings": warnings,
|
||
})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
for _, item := range req.Accounts {
|
||
if err := service.ValidateOpenAILongContextBillingExtra(item.Platform, item.Extra); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
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))
|
||
// 收集需要异步设置隐私的 OAuth 账号
|
||
var antigravityPrivacyAccounts []*service.Account
|
||
var openaiPrivacyAccounts []*service.Account
|
||
|
||
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
|
||
}
|
||
|
||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||
sanitizeExtraBaseRPM(item.Extra)
|
||
|
||
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,
|
||
})
|
||
if err != nil {
|
||
failed++
|
||
results = append(results, gin.H{
|
||
"name": item.Name,
|
||
"success": false,
|
||
"error": err.Error(),
|
||
})
|
||
continue
|
||
}
|
||
// 收集需要异步设置隐私的 OAuth 账号
|
||
if account.Type == service.AccountTypeOAuth {
|
||
switch account.Platform {
|
||
case service.PlatformAntigravity:
|
||
antigravityPrivacyAccounts = append(antigravityPrivacyAccounts, account)
|
||
case service.PlatformOpenAI:
|
||
openaiPrivacyAccounts = append(openaiPrivacyAccounts, account)
|
||
}
|
||
}
|
||
// OpenAI APIKey 账号异步探测 /v1/responses 能力。
|
||
h.scheduleOpenAIResponsesProbe(account)
|
||
h.scheduleGrokImportProbe(account)
|
||
success++
|
||
results = append(results, gin.H{
|
||
"name": item.Name,
|
||
"id": account.ID,
|
||
"success": true,
|
||
})
|
||
}
|
||
|
||
// 异步设置隐私,避免批量创建时阻塞请求
|
||
adminSvc := h.adminService
|
||
if len(antigravityPrivacyAccounts) > 0 {
|
||
accounts := antigravityPrivacyAccounts
|
||
go func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
slog.Error("batch_create_antigravity_privacy_panic", "recover", r)
|
||
}
|
||
}()
|
||
bgCtx := context.Background()
|
||
for _, acc := range accounts {
|
||
adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
|
||
}
|
||
}()
|
||
}
|
||
if len(openaiPrivacyAccounts) > 0 {
|
||
accounts := openaiPrivacyAccounts
|
||
go func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
slog.Error("batch_create_openai_privacy_panic", "recover", r)
|
||
}
|
||
}()
|
||
bgCtx := context.Background()
|
||
for _, acc := range accounts {
|
||
adminSvc.ForceOpenAIPrivacy(bgCtx, acc)
|
||
}
|
||
}()
|
||
}
|
||
|
||
return gin.H{
|
||
"success": success,
|
||
"failed": failed,
|
||
"results": results,
|
||
}, nil
|
||
})
|
||
}
|
||
|
||
// 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()
|
||
|
||
// 阶段一:预验证所有账号存在,收集 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 {
|
||
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
|
||
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))
|
||
for _, u := range updates {
|
||
updateInput := &service.UpdateAccountInput{Credentials: u.Credentials}
|
||
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
|
||
}
|
||
if len(req.AccountIDs) == 0 && req.Filters == nil {
|
||
response.BadRequest(c, "account_ids or filters is required")
|
||
return
|
||
}
|
||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||
sanitizeExtraBaseRPM(req.Extra)
|
||
|
||
// 确定是否跳过混合渠道检查
|
||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||
|
||
hasUpdates := req.Name != "" ||
|
||
req.ProxyID != nil ||
|
||
req.Concurrency != nil ||
|
||
req.Priority != nil ||
|
||
req.RateMultiplier != nil ||
|
||
req.LoadFactor != nil ||
|
||
req.Status != "" ||
|
||
req.Schedulable != nil ||
|
||
req.GroupIDs != nil ||
|
||
len(req.Credentials) > 0 ||
|
||
len(req.Extra) > 0 ||
|
||
req.ProbeEnabled != nil
|
||
|
||
if !hasUpdates {
|
||
response.BadRequest(c, "No updates provided")
|
||
return
|
||
}
|
||
|
||
result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
|
||
AccountIDs: req.AccountIDs,
|
||
Filters: toServiceBulkUpdateAccountFilters(req.Filters),
|
||
Name: req.Name,
|
||
ProxyID: req.ProxyID,
|
||
Concurrency: req.Concurrency,
|
||
Priority: req.Priority,
|
||
RateMultiplier: req.RateMultiplier,
|
||
LoadFactor: req.LoadFactor,
|
||
Status: req.Status,
|
||
Schedulable: req.Schedulable,
|
||
GroupIDs: req.GroupIDs,
|
||
Credentials: req.Credentials,
|
||
Extra: req.Extra,
|
||
ProbeEnabled: req.ProbeEnabled,
|
||
SkipMixedChannelCheck: skipCheck,
|
||
})
|
||
if err != nil {
|
||
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,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, result)
|
||
}
|
||
|
||
func toServiceBulkUpdateAccountFilters(filters *BulkUpdateAccountFilters) *service.BulkUpdateAccountFilters {
|
||
if filters == nil {
|
||
return nil
|
||
}
|
||
return &service.BulkUpdateAccountFilters{
|
||
Platform: filters.Platform,
|
||
Type: filters.Type,
|
||
Status: filters.Status,
|
||
Group: filters.Group,
|
||
Search: filters.Search,
|
||
PrivacyMode: filters.PrivacyMode,
|
||
}
|
||
}
|
||
|
||
// ========== 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 {
|
||
response.ErrorFrom(c, err)
|
||
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 {
|
||
response.ErrorFrom(c, err)
|
||
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 {
|
||
response.ErrorFrom(c, err)
|
||
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 {
|
||
response.ErrorFrom(c, err)
|
||
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 {
|
||
response.ErrorFrom(c, err)
|
||
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 {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, tokenInfo)
|
||
}
|
||
|
||
// GetUsage handles getting account usage information
|
||
// GET /api/v1/admin/accounts/:id/usage?source=passive|active&force=true
|
||
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
|
||
}
|
||
|
||
source := c.DefaultQuery("source", "active")
|
||
force := c.Query("force") == "true"
|
||
|
||
var usage *service.UsageInfo
|
||
if source == "passive" {
|
||
usage, err = h.accountUsageService.GetPassiveUsage(c.Request.Context(), accountID)
|
||
} else {
|
||
usage, err = h.accountUsageService.GetUsage(c.Request.Context(), accountID, force)
|
||
}
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
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 {
|
||
response.ErrorFrom(c, err)
|
||
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))
|
||
}
|
||
|
||
// ResetQuota handles resetting account quota usage
|
||
// POST /api/v1/admin/accounts/:id/reset-quota
|
||
func (h *AccountHandler) ResetQuota(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.adminService.ResetAccountQuota(c.Request.Context(), accountID); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
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))
|
||
}
|
||
|
||
// 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"})
|
||
}
|
||
|
||
// 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 {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, stats)
|
||
}
|
||
|
||
// BatchTodayStatsRequest 批量今日统计请求体。
|
||
type BatchTodayStatsRequest struct {
|
||
AccountIDs []int64 `json:"account_ids" binding:"required"`
|
||
}
|
||
|
||
type BatchUsageRequest struct {
|
||
AccountIDs []int64 `json:"account_ids" binding:"required"`
|
||
Force bool `json:"force"`
|
||
}
|
||
|
||
// GetBatchTodayStats 批量获取多个账号的今日统计。
|
||
// POST /api/v1/admin/accounts/today-stats/batch
|
||
func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) {
|
||
var req BatchTodayStatsRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
accountIDs := normalizeInt64IDList(req.AccountIDs)
|
||
if len(accountIDs) == 0 {
|
||
response.Success(c, gin.H{"stats": map[string]any{}})
|
||
return
|
||
}
|
||
|
||
cacheKey := buildAccountTodayStatsBatchCacheKey(accountIDs)
|
||
if cached, ok := accountTodayStatsBatchCache.Get(cacheKey); ok {
|
||
if cached.ETag != "" {
|
||
c.Header("ETag", cached.ETag)
|
||
c.Header("Vary", "If-None-Match")
|
||
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), cached.ETag) {
|
||
c.Status(http.StatusNotModified)
|
||
return
|
||
}
|
||
}
|
||
c.Header("X-Snapshot-Cache", "hit")
|
||
response.Success(c, cached.Payload)
|
||
return
|
||
}
|
||
|
||
stats, err := h.accountUsageService.GetTodayStatsBatch(c.Request.Context(), accountIDs)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
payload := gin.H{"stats": stats}
|
||
cached := accountTodayStatsBatchCache.Set(cacheKey, payload)
|
||
if cached.ETag != "" {
|
||
c.Header("ETag", cached.ETag)
|
||
c.Header("Vary", "If-None-Match")
|
||
}
|
||
c.Header("X-Snapshot-Cache", "miss")
|
||
response.Success(c, payload)
|
||
}
|
||
|
||
// GetBatchUsage 批量获取多个账号的 current usage。
|
||
// POST /api/v1/admin/accounts/usage/batch
|
||
func (h *AccountHandler) GetBatchUsage(c *gin.Context) {
|
||
var req BatchUsageRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
accountIDs := normalizeInt64IDList(req.AccountIDs)
|
||
if len(accountIDs) == 0 {
|
||
response.Success(c, gin.H{
|
||
"usage": map[string]any{},
|
||
"errors": map[string]string{},
|
||
})
|
||
return
|
||
}
|
||
|
||
usageByAccount, errorsByAccount, err := h.accountUsageService.GetUsageBatch(c.Request.Context(), accountIDs, req.Force)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{
|
||
"usage": usageByAccount,
|
||
"errors": errorsByAccount,
|
||
})
|
||
}
|
||
|
||
// 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 {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Handle OpenAI accounts
|
||
if account.IsOpenAI() {
|
||
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
|
||
if account.IsOpenAIPassthroughEnabled() {
|
||
response.Success(c, openai.DefaultModels)
|
||
return
|
||
}
|
||
|
||
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.DefaultModels(),与 /v1/models 端点保持同步
|
||
response.Success(c, antigravity.DefaultModels())
|
||
return
|
||
}
|
||
|
||
// Handle Grok accounts
|
||
if account.Platform == service.PlatformGrok {
|
||
defaultModels := xai.DefaultModels()
|
||
|
||
hasExplicitMapping := false
|
||
switch rawMapping := account.Credentials["model_mapping"].(type) {
|
||
case map[string]any:
|
||
hasExplicitMapping = len(rawMapping) > 0
|
||
case map[string]string:
|
||
hasExplicitMapping = len(rawMapping) > 0
|
||
}
|
||
if !hasExplicitMapping {
|
||
response.Success(c, defaultModels)
|
||
return
|
||
}
|
||
|
||
mapping := account.GetModelMapping()
|
||
if len(mapping) == 0 {
|
||
response.Success(c, defaultModels)
|
||
return
|
||
}
|
||
|
||
defaultByID := make(map[string]xai.Model, len(defaultModels))
|
||
for _, model := range defaultModels {
|
||
defaultByID[model.ID] = model
|
||
}
|
||
|
||
requestedModels := make([]string, 0, len(mapping))
|
||
for requestedModel := range mapping {
|
||
requestedModels = append(requestedModels, requestedModel)
|
||
}
|
||
sort.Strings(requestedModels)
|
||
|
||
var models []xai.Model
|
||
for _, requestedModel := range requestedModels {
|
||
if defaultModel, found := defaultByID[requestedModel]; found {
|
||
models = append(models, defaultModel)
|
||
continue
|
||
}
|
||
models = append(models, xai.Model{
|
||
ID: requestedModel,
|
||
Object: "model",
|
||
OwnedBy: "xai",
|
||
DisplayName: requestedModel,
|
||
})
|
||
}
|
||
response.Success(c, models)
|
||
return
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// SyncUpstreamModels handles syncing live supported models from an account's upstream.
|
||
// POST /api/v1/admin/accounts/:id/models/sync-upstream
|
||
func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.BadRequest(c, "Invalid account ID")
|
||
return
|
||
}
|
||
|
||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||
if err != nil {
|
||
response.NotFound(c, "Account not found")
|
||
return
|
||
}
|
||
|
||
if h.accountTestService == nil {
|
||
response.InternalError(c, "Account test service is not configured")
|
||
return
|
||
}
|
||
|
||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
|
||
if err != nil {
|
||
var syncErr *service.UpstreamModelSyncError
|
||
if errors.As(err, &syncErr) {
|
||
switch syncErr.Kind {
|
||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||
response.BadRequest(c, syncErr.SafeMessage())
|
||
default:
|
||
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
|
||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||
}
|
||
return
|
||
}
|
||
|
||
slog.Warn("sync_upstream_models_failed", "account_id", accountID)
|
||
response.Error(c, http.StatusBadGateway, "Failed to sync upstream models from upstream")
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{"models": models})
|
||
}
|
||
|
||
// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
|
||
// POST /api/v1/admin/accounts/models/sync-upstream-preview
|
||
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||
var req struct {
|
||
Platform string `json:"platform" binding:"required"`
|
||
Type string `json:"type" binding:"required"`
|
||
BaseURL string `json:"base_url"`
|
||
APIKey string `json:"api_key" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
tempAccount := &service.Account{
|
||
Platform: req.Platform,
|
||
Type: req.Type,
|
||
Credentials: map[string]any{
|
||
"api_key": req.APIKey,
|
||
"base_url": req.BaseURL,
|
||
},
|
||
}
|
||
|
||
if h.accountTestService == nil {
|
||
response.InternalError(c, "Account test service is not configured")
|
||
return
|
||
}
|
||
|
||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
|
||
if err != nil {
|
||
var syncErr *service.UpstreamModelSyncError
|
||
if errors.As(err, &syncErr) {
|
||
switch syncErr.Kind {
|
||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||
response.BadRequest(c, syncErr.SafeMessage())
|
||
default:
|
||
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
|
||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||
}
|
||
return
|
||
}
|
||
|
||
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform)
|
||
response.Error(c, http.StatusBadGateway, "Failed to sync upstream models from upstream")
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{"models": models})
|
||
}
|
||
|
||
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
|
||
// POST /api/v1/admin/accounts/:id/set-privacy
|
||
func (h *AccountHandler) SetPrivacy(c *gin.Context) {
|
||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.BadRequest(c, "Invalid account ID")
|
||
return
|
||
}
|
||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||
if err != nil {
|
||
response.NotFound(c, "Account not found")
|
||
return
|
||
}
|
||
if account.Type != service.AccountTypeOAuth {
|
||
response.BadRequest(c, "Only OAuth accounts support privacy setting")
|
||
return
|
||
}
|
||
var mode string
|
||
switch account.Platform {
|
||
case service.PlatformOpenAI:
|
||
mode = h.adminService.ForceOpenAIPrivacy(c.Request.Context(), account)
|
||
case service.PlatformAntigravity:
|
||
mode = h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account)
|
||
default:
|
||
response.BadRequest(c, "Only OpenAI and Antigravity OAuth accounts support privacy setting")
|
||
return
|
||
}
|
||
if mode == "" {
|
||
response.BadRequest(c, "Cannot set privacy: missing access_token")
|
||
return
|
||
}
|
||
// 从 DB 重新读取以确保返回最新状态
|
||
updated, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||
if err != nil {
|
||
// 隐私已设置成功但读取失败,回退到内存更新
|
||
if account.Extra == nil {
|
||
account.Extra = make(map[string]any)
|
||
}
|
||
account.Extra["privacy_mode"] = mode
|
||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||
return
|
||
}
|
||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updated))
|
||
}
|
||
|
||
// RefreshTier handles refreshing Google One tier for a single account
|
||
// POST /api/v1/admin/accounts/:id/refresh-tier
|
||
func (h *AccountHandler) RefreshTier(c *gin.Context) {
|
||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
response.BadRequest(c, "Invalid account ID")
|
||
return
|
||
}
|
||
|
||
ctx := c.Request.Context()
|
||
account, err := h.adminService.GetAccount(ctx, accountID)
|
||
if err != nil {
|
||
response.NotFound(c, "Account not found")
|
||
return
|
||
}
|
||
|
||
if account.Platform != service.PlatformGemini || account.Type != service.AccountTypeOAuth {
|
||
response.BadRequest(c, "Only Gemini OAuth accounts support tier refresh")
|
||
return
|
||
}
|
||
|
||
oauthType, _ := account.Credentials["oauth_type"].(string)
|
||
if oauthType != "google_one" {
|
||
response.BadRequest(c, "Only google_one OAuth accounts support tier refresh")
|
||
return
|
||
}
|
||
|
||
tierID, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(ctx, account)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
_, updateErr := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{
|
||
Credentials: creds,
|
||
Extra: extra,
|
||
})
|
||
if updateErr != nil {
|
||
response.ErrorFrom(c, updateErr)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{
|
||
"tier_id": tierID,
|
||
"storage_info": extra,
|
||
"drive_storage_limit": extra["drive_storage_limit"],
|
||
"drive_storage_usage": extra["drive_storage_usage"],
|
||
"updated_at": extra["drive_tier_updated_at"],
|
||
})
|
||
}
|
||
|
||
// BatchRefreshTierRequest represents batch tier refresh request
|
||
type BatchRefreshTierRequest struct {
|
||
AccountIDs []int64 `json:"account_ids"`
|
||
}
|
||
|
||
// BatchRefreshTier handles batch refreshing Google One tier
|
||
// POST /api/v1/admin/accounts/batch-refresh-tier
|
||
func (h *AccountHandler) BatchRefreshTier(c *gin.Context) {
|
||
var req BatchRefreshTierRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
req = BatchRefreshTierRequest{}
|
||
}
|
||
|
||
ctx := c.Request.Context()
|
||
accounts := make([]*service.Account, 0)
|
||
|
||
if len(req.AccountIDs) == 0 {
|
||
allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "", 0, "", "name", "asc")
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
for i := range allAccounts {
|
||
acc := &allAccounts[i]
|
||
oauthType, _ := acc.Credentials["oauth_type"].(string)
|
||
if oauthType == "google_one" {
|
||
accounts = append(accounts, acc)
|
||
}
|
||
}
|
||
} else {
|
||
fetched, err := h.adminService.GetAccountsByIDs(ctx, req.AccountIDs)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
for _, acc := range fetched {
|
||
if acc == nil {
|
||
continue
|
||
}
|
||
if acc.Platform != service.PlatformGemini || acc.Type != service.AccountTypeOAuth {
|
||
continue
|
||
}
|
||
oauthType, _ := acc.Credentials["oauth_type"].(string)
|
||
if oauthType != "google_one" {
|
||
continue
|
||
}
|
||
accounts = append(accounts, acc)
|
||
}
|
||
}
|
||
|
||
const maxConcurrency = 10
|
||
g, gctx := errgroup.WithContext(ctx)
|
||
g.SetLimit(maxConcurrency)
|
||
|
||
var mu sync.Mutex
|
||
var successCount, failedCount int
|
||
var errors []gin.H
|
||
|
||
for _, account := range accounts {
|
||
acc := account // 闭包捕获
|
||
g.Go(func() error {
|
||
_, extra, creds, err := h.geminiOAuthService.RefreshAccountGoogleOneTier(gctx, acc)
|
||
if err != nil {
|
||
mu.Lock()
|
||
failedCount++
|
||
errors = append(errors, gin.H{
|
||
"account_id": acc.ID,
|
||
"error": err.Error(),
|
||
})
|
||
mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
_, updateErr := h.adminService.UpdateAccount(gctx, acc.ID, &service.UpdateAccountInput{
|
||
Credentials: creds,
|
||
Extra: extra,
|
||
})
|
||
|
||
mu.Lock()
|
||
if updateErr != nil {
|
||
failedCount++
|
||
errors = append(errors, gin.H{
|
||
"account_id": acc.ID,
|
||
"error": updateErr.Error(),
|
||
})
|
||
} else {
|
||
successCount++
|
||
}
|
||
mu.Unlock()
|
||
|
||
return nil
|
||
})
|
||
}
|
||
|
||
if err := g.Wait(); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
results := gin.H{
|
||
"total": len(accounts),
|
||
"success": successCount,
|
||
"failed": failedCount,
|
||
"errors": errors,
|
||
}
|
||
|
||
response.Success(c, results)
|
||
}
|
||
|
||
// GetAntigravityDefaultModelMapping 获取 Antigravity 平台的默认模型映射
|
||
// GET /api/v1/admin/accounts/antigravity/default-model-mapping
|
||
func (h *AccountHandler) GetAntigravityDefaultModelMapping(c *gin.Context) {
|
||
response.Success(c, domain.DefaultAntigravityModelMapping)
|
||
}
|
||
|
||
// sanitizeExtraBaseRPM 对 extra map 中的 base_rpm 值进行范围校验和归一化。
|
||
// 负值归零,超过 10000 截断为 10000。extra 为 nil 或不含 base_rpm 时无操作。
|
||
func sanitizeExtraBaseRPM(extra map[string]any) {
|
||
if extra == nil {
|
||
return
|
||
}
|
||
raw, ok := extra["base_rpm"]
|
||
if !ok {
|
||
return
|
||
}
|
||
v := service.ParseExtraInt(raw)
|
||
if v < 0 {
|
||
v = 0
|
||
} else if v > 10000 {
|
||
v = 10000
|
||
}
|
||
extra["base_rpm"] = v
|
||
}
|