diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 22d3f1f0..632ee454 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -204,6 +204,17 @@ func AccountFromServiceShallow(a *service.Account) *Account { } } + if scopeLimits := a.GetAntigravityScopeRateLimits(); len(scopeLimits) > 0 { + out.ScopeRateLimits = make(map[string]ScopeRateLimitInfo, len(scopeLimits)) + now := time.Now() + for scope, remainingSec := range scopeLimits { + out.ScopeRateLimits[scope] = ScopeRateLimitInfo{ + ResetAt: now.Add(time.Duration(remainingSec) * time.Second), + RemainingSec: remainingSec, + } + } + } + return out } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 64979189..d3f706b3 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -2,6 +2,11 @@ package dto import "time" +type ScopeRateLimitInfo struct { + ResetAt time.Time `json:"reset_at"` + RemainingSec int64 `json:"remaining_sec"` +} + type User struct { ID int64 `json:"id"` Email string `json:"email"` @@ -108,6 +113,9 @@ type Account struct { RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` OverloadUntil *time.Time `json:"overload_until"` + // Antigravity scope 级限流状态(从 extra 提取) + ScopeRateLimits map[string]ScopeRateLimitInfo `json:"scope_rate_limits,omitempty"` + TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"` TempUnschedulableReason string `json:"temp_unschedulable_reason"` diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index c11c079b..e4e837e2 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -809,12 +809,21 @@ func (r *accountRepository) SetAntigravityQuotaScopeLimit(ctx context.Context, i return err } - path := "{antigravity_quota_scopes," + string(scope) + "}" + scopeKey := string(scope) client := clientFromContext(ctx, r.client) result, err := client.ExecContext( ctx, - "UPDATE accounts SET extra = jsonb_set(COALESCE(extra, '{}'::jsonb), $1::text[], $2::jsonb, true), updated_at = NOW() WHERE id = $3 AND deleted_at IS NULL", - path, + `UPDATE accounts SET + extra = jsonb_set( + jsonb_set(COALESCE(extra, '{}'::jsonb), '{antigravity_quota_scopes}'::text[], COALESCE(extra->'antigravity_quota_scopes', '{}'::jsonb), true), + ARRAY['antigravity_quota_scopes', $1]::text[], + $2::jsonb, + true + ), + updated_at = NOW(), + last_used_at = NOW() + WHERE id = $3 AND deleted_at IS NULL`, + scopeKey, raw, id, ) @@ -829,6 +838,7 @@ func (r *accountRepository) SetAntigravityQuotaScopeLimit(ctx context.Context, i if affected == 0 { return service.ErrAccountNotFound } + if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil { log.Printf("[SchedulerOutbox] enqueue quota scope failed: account=%d err=%v", id, err) } @@ -849,12 +859,19 @@ func (r *accountRepository) SetModelRateLimit(ctx context.Context, id int64, sco return err } - path := "{model_rate_limits," + scope + "}" client := clientFromContext(ctx, r.client) result, err := client.ExecContext( ctx, - "UPDATE accounts SET extra = jsonb_set(COALESCE(extra, '{}'::jsonb), $1::text[], $2::jsonb, true), updated_at = NOW() WHERE id = $3 AND deleted_at IS NULL", - path, + `UPDATE accounts SET + extra = jsonb_set( + jsonb_set(COALESCE(extra, '{}'::jsonb), '{model_rate_limits}'::text[], COALESCE(extra->'model_rate_limits', '{}'::jsonb), true), + ARRAY['model_rate_limits', $1]::text[], + $2::jsonb, + true + ), + updated_at = NOW() + WHERE id = $3 AND deleted_at IS NULL`, + scope, raw, id, ) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 487f546f..9b8156e6 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -1537,7 +1537,11 @@ func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { func antigravityUseScopeRateLimit() bool { v := strings.ToLower(strings.TrimSpace(os.Getenv(antigravityScopeRateLimitEnv))) - return v == "1" || v == "true" || v == "yes" || v == "on" + // 默认开启按配额域限流,只有明确设置为禁用值时才关闭 + if v == "0" || v == "false" || v == "no" || v == "off" { + return false + } + return true } func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope) { diff --git a/backend/internal/service/antigravity_quota_scope.go b/backend/internal/service/antigravity_quota_scope.go index a3b2ec66..34cd9a4c 100644 --- a/backend/internal/service/antigravity_quota_scope.go +++ b/backend/internal/service/antigravity_quota_scope.go @@ -89,3 +89,30 @@ func (a *Account) antigravityQuotaScopeResetAt(scope AntigravityQuotaScope) *tim } return &resetAt } + +var antigravityAllScopes = []AntigravityQuotaScope{ + AntigravityQuotaScopeClaude, + AntigravityQuotaScopeGeminiText, + AntigravityQuotaScopeGeminiImage, +} + +func (a *Account) GetAntigravityScopeRateLimits() map[string]int64 { + if a == nil || a.Platform != PlatformAntigravity { + return nil + } + now := time.Now() + result := make(map[string]int64) + for _, scope := range antigravityAllScopes { + resetAt := a.antigravityQuotaScopeResetAt(scope) + if resetAt != nil && now.Before(*resetAt) { + remainingSec := int64(time.Until(*resetAt).Seconds()) + if remainingSec > 0 { + result[string(scope)] = remainingSec + } + } + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/backend/internal/service/ops_account_availability.go b/backend/internal/service/ops_account_availability.go index da66ec4d..9be06c15 100644 --- a/backend/internal/service/ops_account_availability.go +++ b/backend/internal/service/ops_account_availability.go @@ -67,6 +67,8 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi isAvailable := acc.Status == StatusActive && acc.Schedulable && !isRateLimited && !isOverloaded && !isTempUnsched + scopeRateLimits := acc.GetAntigravityScopeRateLimits() + if acc.Platform != "" { if _, ok := platform[acc.Platform]; !ok { platform[acc.Platform] = &PlatformAvailability{ @@ -84,6 +86,14 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi if hasError { p.ErrorCount++ } + if len(scopeRateLimits) > 0 { + if p.ScopeRateLimitCount == nil { + p.ScopeRateLimitCount = make(map[string]int64) + } + for scope := range scopeRateLimits { + p.ScopeRateLimitCount[scope]++ + } + } } for _, grp := range acc.Groups { @@ -108,6 +118,14 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi if hasError { g.ErrorCount++ } + if len(scopeRateLimits) > 0 { + if g.ScopeRateLimitCount == nil { + g.ScopeRateLimitCount = make(map[string]int64) + } + for scope := range scopeRateLimits { + g.ScopeRateLimitCount[scope]++ + } + } } displayGroupID := int64(0) @@ -140,6 +158,9 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi item.RateLimitRemainingSec = &remainingSec } } + if len(scopeRateLimits) > 0 { + item.ScopeRateLimits = scopeRateLimits + } if isOverloaded && acc.OverloadUntil != nil { item.OverloadUntil = acc.OverloadUntil remainingSec := int64(time.Until(*acc.OverloadUntil).Seconds()) diff --git a/backend/internal/service/ops_realtime_models.go b/backend/internal/service/ops_realtime_models.go index f7514a24..c7e5715b 100644 --- a/backend/internal/service/ops_realtime_models.go +++ b/backend/internal/service/ops_realtime_models.go @@ -39,22 +39,24 @@ type AccountConcurrencyInfo struct { // PlatformAvailability aggregates account availability by platform. type PlatformAvailability struct { - Platform string `json:"platform"` - TotalAccounts int64 `json:"total_accounts"` - AvailableCount int64 `json:"available_count"` - RateLimitCount int64 `json:"rate_limit_count"` - ErrorCount int64 `json:"error_count"` + Platform string `json:"platform"` + TotalAccounts int64 `json:"total_accounts"` + AvailableCount int64 `json:"available_count"` + RateLimitCount int64 `json:"rate_limit_count"` + ScopeRateLimitCount map[string]int64 `json:"scope_rate_limit_count,omitempty"` + ErrorCount int64 `json:"error_count"` } // GroupAvailability aggregates account availability by group. type GroupAvailability struct { - GroupID int64 `json:"group_id"` - GroupName string `json:"group_name"` - Platform string `json:"platform"` - TotalAccounts int64 `json:"total_accounts"` - AvailableCount int64 `json:"available_count"` - RateLimitCount int64 `json:"rate_limit_count"` - ErrorCount int64 `json:"error_count"` + GroupID int64 `json:"group_id"` + GroupName string `json:"group_name"` + Platform string `json:"platform"` + TotalAccounts int64 `json:"total_accounts"` + AvailableCount int64 `json:"available_count"` + RateLimitCount int64 `json:"rate_limit_count"` + ScopeRateLimitCount map[string]int64 `json:"scope_rate_limit_count,omitempty"` + ErrorCount int64 `json:"error_count"` } // AccountAvailability represents current availability for a single account. @@ -72,10 +74,11 @@ type AccountAvailability struct { IsOverloaded bool `json:"is_overloaded"` HasError bool `json:"has_error"` - RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` - RateLimitRemainingSec *int64 `json:"rate_limit_remaining_sec"` - OverloadUntil *time.Time `json:"overload_until"` - OverloadRemainingSec *int64 `json:"overload_remaining_sec"` - ErrorMessage string `json:"error_message"` - TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until,omitempty"` + RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` + RateLimitRemainingSec *int64 `json:"rate_limit_remaining_sec"` + ScopeRateLimits map[string]int64 `json:"scope_rate_limits,omitempty"` + OverloadUntil *time.Time `json:"overload_until"` + OverloadRemainingSec *int64 `json:"overload_remaining_sec"` + ErrorMessage string `json:"error_message"` + TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until,omitempty"` } diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 9e0444b1..bf2c246c 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -353,6 +353,7 @@ export interface PlatformAvailability { total_accounts: number available_count: number rate_limit_count: number + scope_rate_limit_count?: Record error_count: number } @@ -363,6 +364,7 @@ export interface GroupAvailability { total_accounts: number available_count: number rate_limit_count: number + scope_rate_limit_count?: Record error_count: number } @@ -377,6 +379,7 @@ export interface AccountAvailability { is_rate_limited: boolean rate_limit_reset_at?: string rate_limit_remaining_sec?: number + scope_rate_limits?: Record is_overloaded: boolean overload_until?: string overload_remaining_sec?: number diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 02c962f1..8e525fa3 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -56,6 +56,65 @@ > + + +
+ + + 429 + + +
+ {{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }} +
+
+
+ + + + + +
+ + + 529 + + +
+ {{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }} +
+
+
@@ -63,7 +122,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { Account } from '@/types' -import { formatCountdownWithSuffix } from '@/utils/format' +import { formatCountdownWithSuffix, formatTime } from '@/utils/format' const { t } = useI18n() @@ -81,6 +140,25 @@ const isRateLimited = computed(() => { return new Date(props.account.rate_limit_reset_at) > new Date() }) +// Computed: active scope rate limits (Antigravity) +const activeScopeRateLimits = computed(() => { + const scopeLimits = props.account.scope_rate_limits + if (!scopeLimits) return [] + const now = new Date() + return Object.entries(scopeLimits) + .filter(([, info]) => new Date(info.reset_at) > now) + .map(([scope, info]) => ({ scope, reset_at: info.reset_at })) +}) + +const formatScopeName = (scope: string): string => { + const names: Record = { + claude: 'Claude', + gemini_text: 'Gemini', + gemini_image: 'Image' + } + return names[scope] || scope +} + // Computed: is overloaded (529) const isOverloaded = computed(() => { if (!props.account.overload_until) return false diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f91699ec..e9b51b69 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1191,6 +1191,7 @@ export default { overloaded: 'Overloaded', tempUnschedulable: 'Temp Unschedulable', rateLimitedUntil: 'Rate limited until {time}', + scopeRateLimitedUntil: '{scope} rate limited until {time}', overloadedUntil: 'Overloaded until {time}', viewTempUnschedDetails: 'View temp unschedulable details' }, @@ -2840,6 +2841,7 @@ export default { empty: 'No data', queued: 'Queue {count}', rateLimited: 'Rate-limited {count}', + scopeRateLimitedTooltip: '{scope} rate-limited ({count} accounts)', errorAccounts: 'Errors {count}', loadFailed: 'Failed to load concurrency data' }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 491309f4..d57b4c02 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1313,6 +1313,7 @@ export default { overloaded: '过载中', tempUnschedulable: '临时不可调度', rateLimitedUntil: '限流中,重置时间:{time}', + scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}', overloadedUntil: '负载过重,重置时间:{time}', viewTempUnschedDetails: '查看临时不可调度详情' }, @@ -2993,6 +2994,7 @@ export default { empty: '暂无数据', queued: '队列 {count}', rateLimited: '限流 {count}', + scopeRateLimitedTooltip: '{scope} 限流中 ({count} 个账号)', errorAccounts: '异常 {count}', loadFailed: '加载并发数据失败' }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c4318550..cc083215 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -561,6 +561,9 @@ export interface Account { temp_unschedulable_until: string | null temp_unschedulable_reason: string | null + // Antigravity scope 级限流状态 + scope_rate_limits?: Record + // Session window fields (5-hour window) session_window_start: string | null session_window_end: string | null diff --git a/frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue b/frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue index acb0de1b..9c1ae1c1 100644 --- a/frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue +++ b/frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue @@ -49,6 +49,7 @@ interface SummaryRow { total_accounts: number available_accounts: number rate_limited_accounts: number + scope_rate_limit_count?: Record error_accounts: number // 并发统计 total_concurrency: number @@ -102,6 +103,7 @@ const platformRows = computed((): SummaryRow[] => { total_accounts: totalAccounts, available_accounts: availableAccounts, rate_limited_accounts: safeNumber(avail.rate_limit_count), + scope_rate_limit_count: avail.scope_rate_limit_count, error_accounts: safeNumber(avail.error_count), total_concurrency: totalConcurrency, used_concurrency: usedConcurrency, @@ -141,6 +143,7 @@ const groupRows = computed((): SummaryRow[] => { total_accounts: totalAccounts, available_accounts: availableAccounts, rate_limited_accounts: safeNumber(avail.rate_limit_count), + scope_rate_limit_count: avail.scope_rate_limit_count, error_accounts: safeNumber(avail.error_count), total_concurrency: totalConcurrency, used_concurrency: usedConcurrency, @@ -269,6 +272,15 @@ function formatDuration(seconds: number): string { return `${hours}h` } +function formatScopeName(scope: string): string { + const names: Record = { + claude: 'Claude', + gemini_text: 'Gemini', + gemini_image: 'Image' + } + return names[scope] || scope +} + watch( () => realtimeEnabled.value, async (enabled) => { @@ -387,6 +399,18 @@ watch( {{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }} + + +