2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
2026-01-03 06:34:00 -08:00
|
|
|
|
"encoding/json"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"log"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"strconv"
|
2026-01-01 04:22:39 +08:00
|
|
|
|
"strings"
|
2026-01-01 08:29:57 +08:00
|
|
|
|
"sync"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// RateLimitService 处理限流和过载状态管理
|
|
|
|
|
|
type RateLimitService struct {
|
2026-01-01 04:22:39 +08:00
|
|
|
|
accountRepo AccountRepository
|
|
|
|
|
|
usageRepo UsageLogRepository
|
|
|
|
|
|
cfg *config.Config
|
|
|
|
|
|
geminiQuotaService *GeminiQuotaService
|
2026-01-03 06:34:00 -08:00
|
|
|
|
tempUnschedCache TempUnschedCache
|
2026-01-01 15:35:08 +08:00
|
|
|
|
usageCacheMu sync.RWMutex
|
2026-01-01 08:29:57 +08:00
|
|
|
|
usageCache map[int64]*geminiUsageCacheEntry
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 08:29:57 +08:00
|
|
|
|
type geminiUsageCacheEntry struct {
|
|
|
|
|
|
windowStart time.Time
|
|
|
|
|
|
cachedAt time.Time
|
|
|
|
|
|
totals GeminiUsageTotals
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const geminiPrecheckCacheTTL = time.Minute
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// NewRateLimitService 创建RateLimitService实例
|
2026-01-03 06:34:00 -08:00
|
|
|
|
func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService, tempUnschedCache TempUnschedCache) *RateLimitService {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &RateLimitService{
|
2026-01-01 04:22:39 +08:00
|
|
|
|
accountRepo: accountRepo,
|
|
|
|
|
|
usageRepo: usageRepo,
|
|
|
|
|
|
cfg: cfg,
|
|
|
|
|
|
geminiQuotaService: geminiQuotaService,
|
2026-01-03 06:34:00 -08:00
|
|
|
|
tempUnschedCache: tempUnschedCache,
|
2026-01-01 08:29:57 +08:00
|
|
|
|
usageCache: make(map[int64]*geminiUsageCacheEntry),
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HandleUpstreamError 处理上游错误响应,标记账号状态
|
|
|
|
|
|
// 返回是否应该停止该账号的调度
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// apikey 类型账号:检查自定义错误码配置
|
|
|
|
|
|
// 如果启用且错误码不在列表中,则不处理(不停止调度、不标记限流/过载)
|
|
|
|
|
|
if !account.ShouldHandleErrorCode(statusCode) {
|
|
|
|
|
|
log.Printf("Account %d: error %d skipped (not in custom error codes)", account.ID, statusCode)
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 06:34:00 -08:00
|
|
|
|
tempMatched := s.tryTempUnschedulable(ctx, account, statusCode, responseBody)
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
switch statusCode {
|
|
|
|
|
|
case 401:
|
|
|
|
|
|
// 认证失败:停止调度,记录错误
|
|
|
|
|
|
s.handleAuthError(ctx, account, "Authentication failed (401): invalid or expired credentials")
|
2026-01-03 06:34:00 -08:00
|
|
|
|
shouldDisable = true
|
2025-12-31 11:46:53 +08:00
|
|
|
|
case 402:
|
|
|
|
|
|
// 支付要求:余额不足或计费问题,停止调度
|
|
|
|
|
|
s.handleAuthError(ctx, account, "Payment required (402): insufficient balance or billing issue")
|
2026-01-03 06:34:00 -08:00
|
|
|
|
shouldDisable = true
|
2025-12-18 13:50:39 +08:00
|
|
|
|
case 403:
|
|
|
|
|
|
// 禁止访问:停止调度,记录错误
|
|
|
|
|
|
s.handleAuthError(ctx, account, "Access forbidden (403): account may be suspended or lack permissions")
|
2026-01-03 06:34:00 -08:00
|
|
|
|
shouldDisable = true
|
2025-12-18 13:50:39 +08:00
|
|
|
|
case 429:
|
|
|
|
|
|
s.handle429(ctx, account, headers)
|
2026-01-03 06:34:00 -08:00
|
|
|
|
shouldDisable = false
|
2025-12-18 13:50:39 +08:00
|
|
|
|
case 529:
|
|
|
|
|
|
s.handle529(ctx, account)
|
2026-01-03 06:34:00 -08:00
|
|
|
|
shouldDisable = false
|
2025-12-18 13:50:39 +08:00
|
|
|
|
default:
|
|
|
|
|
|
// 其他5xx错误:记录但不停止调度
|
|
|
|
|
|
if statusCode >= 500 {
|
|
|
|
|
|
log.Printf("Account %d received upstream error %d", account.ID, statusCode)
|
|
|
|
|
|
}
|
2026-01-03 06:34:00 -08:00
|
|
|
|
shouldDisable = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if tempMatched {
|
|
|
|
|
|
return true
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-03 06:34:00 -08:00
|
|
|
|
return shouldDisable
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:22:39 +08:00
|
|
|
|
// PreCheckUsage proactively checks local quota before dispatching a request.
|
|
|
|
|
|
// Returns false when the account should be skipped.
|
|
|
|
|
|
func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, requestedModel string) (bool, error) {
|
2026-01-04 15:36:00 +08:00
|
|
|
|
if account == nil || account.Platform != PlatformGemini {
|
2026-01-01 04:22:39 +08:00
|
|
|
|
return true, nil
|
|
|
|
|
|
}
|
2026-01-01 08:29:57 +08:00
|
|
|
|
if s.usageRepo == nil || s.geminiQuotaService == nil {
|
2026-01-01 04:22:39 +08:00
|
|
|
|
return true, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
quota, ok := s.geminiQuotaService.QuotaForAccount(ctx, account)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return true, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
2026-01-04 15:36:00 +08:00
|
|
|
|
modelClass := geminiModelClassFromName(requestedModel)
|
|
|
|
|
|
|
|
|
|
|
|
// 1) Daily quota precheck (RPD; resets at PST midnight)
|
|
|
|
|
|
{
|
|
|
|
|
|
var limit int64
|
|
|
|
|
|
if quota.SharedRPD > 0 {
|
|
|
|
|
|
limit = quota.SharedRPD
|
|
|
|
|
|
} else {
|
|
|
|
|
|
switch modelClass {
|
|
|
|
|
|
case geminiModelFlash:
|
|
|
|
|
|
limit = quota.FlashRPD
|
|
|
|
|
|
default:
|
|
|
|
|
|
limit = quota.ProRPD
|
|
|
|
|
|
}
|
2026-01-01 08:29:57 +08:00
|
|
|
|
}
|
2026-01-01 04:22:39 +08:00
|
|
|
|
|
2026-01-04 15:36:00 +08:00
|
|
|
|
if limit > 0 {
|
|
|
|
|
|
start := geminiDailyWindowStart(now)
|
|
|
|
|
|
totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return true, err
|
|
|
|
|
|
}
|
|
|
|
|
|
totals = geminiAggregateUsage(stats)
|
|
|
|
|
|
s.setGeminiUsageTotals(account.ID, start, now, totals)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var used int64
|
|
|
|
|
|
if quota.SharedRPD > 0 {
|
|
|
|
|
|
used = totals.ProRequests + totals.FlashRequests
|
|
|
|
|
|
} else {
|
|
|
|
|
|
switch modelClass {
|
|
|
|
|
|
case geminiModelFlash:
|
|
|
|
|
|
used = totals.FlashRequests
|
|
|
|
|
|
default:
|
|
|
|
|
|
used = totals.ProRequests
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if used >= limit {
|
|
|
|
|
|
resetAt := geminiDailyResetTime(now)
|
|
|
|
|
|
// NOTE:
|
|
|
|
|
|
// - This is a local precheck to reduce upstream 429s.
|
|
|
|
|
|
// - Do NOT mark the account as rate-limited here; rate_limit_reset_at should reflect real upstream 429s.
|
|
|
|
|
|
log.Printf("[Gemini PreCheck] Account %d reached daily quota (%d/%d), skip until %v", account.ID, used, limit, resetAt)
|
|
|
|
|
|
return false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-01 04:22:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 15:36:00 +08:00
|
|
|
|
// 2) Minute quota precheck (RPM; fixed window current minute)
|
|
|
|
|
|
{
|
|
|
|
|
|
var limit int64
|
|
|
|
|
|
if quota.SharedRPM > 0 {
|
|
|
|
|
|
limit = quota.SharedRPM
|
|
|
|
|
|
} else {
|
|
|
|
|
|
switch modelClass {
|
|
|
|
|
|
case geminiModelFlash:
|
|
|
|
|
|
limit = quota.FlashRPM
|
|
|
|
|
|
default:
|
|
|
|
|
|
limit = quota.ProRPM
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if limit > 0 {
|
|
|
|
|
|
start := now.Truncate(time.Minute)
|
|
|
|
|
|
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return true, err
|
|
|
|
|
|
}
|
|
|
|
|
|
totals := geminiAggregateUsage(stats)
|
|
|
|
|
|
|
|
|
|
|
|
var used int64
|
|
|
|
|
|
if quota.SharedRPM > 0 {
|
|
|
|
|
|
used = totals.ProRequests + totals.FlashRequests
|
|
|
|
|
|
} else {
|
|
|
|
|
|
switch modelClass {
|
|
|
|
|
|
case geminiModelFlash:
|
|
|
|
|
|
used = totals.FlashRequests
|
|
|
|
|
|
default:
|
|
|
|
|
|
used = totals.ProRequests
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if used >= limit {
|
|
|
|
|
|
resetAt := start.Add(time.Minute)
|
|
|
|
|
|
// Do not persist "rate limited" status from local precheck. See note above.
|
|
|
|
|
|
log.Printf("[Gemini PreCheck] Account %d reached minute quota (%d/%d), skip until %v", account.ID, used, limit, resetAt)
|
|
|
|
|
|
return false, nil
|
|
|
|
|
|
}
|
2026-01-01 04:22:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 08:29:57 +08:00
|
|
|
|
func (s *RateLimitService) getGeminiUsageTotals(accountID int64, windowStart, now time.Time) (GeminiUsageTotals, bool) {
|
2026-01-01 15:35:08 +08:00
|
|
|
|
s.usageCacheMu.RLock()
|
|
|
|
|
|
defer s.usageCacheMu.RUnlock()
|
2026-01-01 08:29:57 +08:00
|
|
|
|
|
|
|
|
|
|
if s.usageCache == nil {
|
|
|
|
|
|
return GeminiUsageTotals{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entry, ok := s.usageCache[accountID]
|
|
|
|
|
|
if !ok || entry == nil {
|
|
|
|
|
|
return GeminiUsageTotals{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
if !entry.windowStart.Equal(windowStart) {
|
|
|
|
|
|
return GeminiUsageTotals{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
if now.Sub(entry.cachedAt) >= geminiPrecheckCacheTTL {
|
|
|
|
|
|
return GeminiUsageTotals{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
return entry.totals, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *RateLimitService) setGeminiUsageTotals(accountID int64, windowStart, now time.Time, totals GeminiUsageTotals) {
|
|
|
|
|
|
s.usageCacheMu.Lock()
|
|
|
|
|
|
defer s.usageCacheMu.Unlock()
|
|
|
|
|
|
if s.usageCache == nil {
|
|
|
|
|
|
s.usageCache = make(map[int64]*geminiUsageCacheEntry)
|
|
|
|
|
|
}
|
|
|
|
|
|
s.usageCache[accountID] = &geminiUsageCacheEntry{
|
|
|
|
|
|
windowStart: windowStart,
|
|
|
|
|
|
cachedAt: now,
|
|
|
|
|
|
totals: totals,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:22:39 +08:00
|
|
|
|
// GeminiCooldown returns the fallback cooldown duration for Gemini 429s based on tier.
|
|
|
|
|
|
func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account) time.Duration {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return 5 * time.Minute
|
|
|
|
|
|
}
|
2026-01-04 15:36:00 +08:00
|
|
|
|
if s.geminiQuotaService == nil {
|
|
|
|
|
|
return 5 * time.Minute
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.geminiQuotaService.CooldownForAccount(ctx, account)
|
2026-01-01 04:22:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// handleAuthError 处理认证类错误(401/403),停止账号调度
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account, errorMsg string) {
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.accountRepo.SetError(ctx, account.ID, errorMsg); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("SetError failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("Account %d disabled due to auth error: %s", account.ID, errorMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// handle429 处理429限流错误
|
|
|
|
|
|
// 解析响应头获取重置时间,标记账号为限流状态
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 解析重置时间戳
|
|
|
|
|
|
resetTimestamp := headers.Get("anthropic-ratelimit-unified-reset")
|
|
|
|
|
|
if resetTimestamp == "" {
|
|
|
|
|
|
// 没有重置时间,使用默认5分钟
|
|
|
|
|
|
resetAt := time.Now().Add(5 * time.Minute)
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("SetRateLimited failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析Unix时间戳
|
|
|
|
|
|
ts, err := strconv.ParseInt(resetTimestamp, 10, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Parse reset timestamp failed: %v", err)
|
|
|
|
|
|
resetAt := time.Now().Add(5 * time.Minute)
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("SetRateLimited failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resetAt := time.Unix(ts, 0)
|
|
|
|
|
|
|
|
|
|
|
|
// 标记限流状态
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("SetRateLimited failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据重置时间反推5h窗口
|
|
|
|
|
|
windowEnd := resetAt
|
|
|
|
|
|
windowStart := resetAt.Add(-5 * time.Hour)
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.accountRepo.UpdateSessionWindow(ctx, account.ID, &windowStart, &windowEnd, "rejected"); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("UpdateSessionWindow failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log.Printf("Account %d rate limited until %v", account.ID, resetAt)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// handle529 处理529过载错误
|
|
|
|
|
|
// 根据配置设置过载冷却时间
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *RateLimitService) handle529(ctx context.Context, account *Account) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
cooldownMinutes := s.cfg.RateLimit.OverloadCooldownMinutes
|
|
|
|
|
|
if cooldownMinutes <= 0 {
|
|
|
|
|
|
cooldownMinutes = 10 // 默认10分钟
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
until := time.Now().Add(time.Duration(cooldownMinutes) * time.Minute)
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.accountRepo.SetOverloaded(ctx, account.ID, until); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("SetOverloaded failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log.Printf("Account %d overloaded until %v", account.ID, until)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UpdateSessionWindow 从成功响应更新5h窗口状态
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Account, headers http.Header) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
status := headers.Get("anthropic-ratelimit-unified-5h-status")
|
|
|
|
|
|
if status == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否需要初始化时间窗口
|
|
|
|
|
|
// 对于 Setup Token 账号,首次成功请求时需要预测时间窗口
|
|
|
|
|
|
var windowStart, windowEnd *time.Time
|
|
|
|
|
|
needInitWindow := account.SessionWindowEnd == nil || time.Now().After(*account.SessionWindowEnd)
|
|
|
|
|
|
|
|
|
|
|
|
if needInitWindow && (status == "allowed" || status == "allowed_warning") {
|
|
|
|
|
|
// 预测时间窗口:从当前时间的整点开始,+5小时为结束
|
|
|
|
|
|
// 例如:现在是 14:30,窗口为 14:00 ~ 19:00
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
start := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
|
|
|
|
|
end := start.Add(5 * time.Hour)
|
|
|
|
|
|
windowStart = &start
|
|
|
|
|
|
windowEnd = &end
|
|
|
|
|
|
log.Printf("Account %d: initializing 5h window from %v to %v (status: %s)", account.ID, start, end, status)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.accountRepo.UpdateSessionWindow(ctx, account.ID, windowStart, windowEnd, status); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("UpdateSessionWindow failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果状态为allowed且之前有限流,说明窗口已重置,清除限流状态
|
|
|
|
|
|
if status == "allowed" && account.IsRateLimited() {
|
2026-01-09 22:00:14 +08:00
|
|
|
|
if err := s.ClearRateLimit(ctx, account.ID); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("ClearRateLimit failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ClearRateLimit 清除账号的限流状态
|
|
|
|
|
|
func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64) error {
|
2026-01-09 22:00:14 +08:00
|
|
|
|
if err := s.accountRepo.ClearRateLimit(ctx, accountID); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.accountRepo.ClearAntigravityQuotaScopes(ctx, accountID)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-03 06:34:00 -08:00
|
|
|
|
|
|
|
|
|
|
func (s *RateLimitService) ClearTempUnschedulable(ctx context.Context, accountID int64) error {
|
|
|
|
|
|
if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.tempUnschedCache != nil {
|
|
|
|
|
|
if err := s.tempUnschedCache.DeleteTempUnsched(ctx, accountID); err != nil {
|
|
|
|
|
|
log.Printf("DeleteTempUnsched failed for account %d: %v", accountID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *RateLimitService) GetTempUnschedStatus(ctx context.Context, accountID int64) (*TempUnschedState, error) {
|
|
|
|
|
|
now := time.Now().Unix()
|
|
|
|
|
|
if s.tempUnschedCache != nil {
|
|
|
|
|
|
state, err := s.tempUnschedCache.GetTempUnsched(ctx, accountID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if state != nil && state.UntilUnix > now {
|
|
|
|
|
|
return state, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if account.TempUnschedulableUntil == nil {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if account.TempUnschedulableUntil.Unix() <= now {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state := &TempUnschedState{
|
|
|
|
|
|
UntilUnix: account.TempUnschedulableUntil.Unix(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if account.TempUnschedulableReason != "" {
|
|
|
|
|
|
var parsed TempUnschedState
|
|
|
|
|
|
if err := json.Unmarshal([]byte(account.TempUnschedulableReason), &parsed); err == nil {
|
|
|
|
|
|
if parsed.UntilUnix == 0 {
|
|
|
|
|
|
parsed.UntilUnix = state.UntilUnix
|
|
|
|
|
|
}
|
|
|
|
|
|
state = &parsed
|
|
|
|
|
|
} else {
|
|
|
|
|
|
state.ErrorMessage = account.TempUnschedulableReason
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.tempUnschedCache != nil {
|
|
|
|
|
|
if err := s.tempUnschedCache.SetTempUnsched(ctx, accountID, state); err != nil {
|
|
|
|
|
|
log.Printf("SetTempUnsched failed for account %d: %v", accountID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return state, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *RateLimitService) HandleTempUnschedulable(ctx context.Context, account *Account, statusCode int, responseBody []byte) bool {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if !account.ShouldHandleErrorCode(statusCode) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.tryTempUnschedulable(ctx, account, statusCode, responseBody)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tempUnschedBodyMaxBytes = 64 << 10
|
|
|
|
|
|
const tempUnschedMessageMaxBytes = 2048
|
|
|
|
|
|
|
|
|
|
|
|
func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Account, statusCode int, responseBody []byte) bool {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if !account.IsTempUnschedulableEnabled() {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
rules := account.GetTempUnschedulableRules()
|
|
|
|
|
|
if len(rules) == 0 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if statusCode <= 0 || len(responseBody) == 0 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body := responseBody
|
|
|
|
|
|
if len(body) > tempUnschedBodyMaxBytes {
|
|
|
|
|
|
body = body[:tempUnschedBodyMaxBytes]
|
|
|
|
|
|
}
|
|
|
|
|
|
bodyLower := strings.ToLower(string(body))
|
|
|
|
|
|
|
|
|
|
|
|
for idx, rule := range rules {
|
|
|
|
|
|
if rule.ErrorCode != statusCode || len(rule.Keywords) == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
matchedKeyword := matchTempUnschedKeyword(bodyLower, rule.Keywords)
|
|
|
|
|
|
if matchedKeyword == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.triggerTempUnschedulable(ctx, account, rule, idx, statusCode, matchedKeyword, responseBody) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func matchTempUnschedKeyword(bodyLower string, keywords []string) string {
|
|
|
|
|
|
if bodyLower == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, keyword := range keywords {
|
|
|
|
|
|
k := strings.TrimSpace(keyword)
|
|
|
|
|
|
if k == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(bodyLower, strings.ToLower(k)) {
|
|
|
|
|
|
return k
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account *Account, rule TempUnschedulableRule, ruleIndex int, statusCode int, matchedKeyword string, responseBody []byte) bool {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if rule.DurationMinutes <= 0 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
until := now.Add(time.Duration(rule.DurationMinutes) * time.Minute)
|
|
|
|
|
|
|
|
|
|
|
|
state := &TempUnschedState{
|
|
|
|
|
|
UntilUnix: until.Unix(),
|
|
|
|
|
|
TriggeredAtUnix: now.Unix(),
|
|
|
|
|
|
StatusCode: statusCode,
|
|
|
|
|
|
MatchedKeyword: matchedKeyword,
|
|
|
|
|
|
RuleIndex: ruleIndex,
|
|
|
|
|
|
ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reason := ""
|
|
|
|
|
|
if raw, err := json.Marshal(state); err == nil {
|
|
|
|
|
|
reason = string(raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
if reason == "" {
|
|
|
|
|
|
reason = strings.TrimSpace(state.ErrorMessage)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
|
|
|
|
|
|
log.Printf("SetTempUnschedulable failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.tempUnschedCache != nil {
|
|
|
|
|
|
if err := s.tempUnschedCache.SetTempUnsched(ctx, account.ID, state); err != nil {
|
|
|
|
|
|
log.Printf("SetTempUnsched cache failed for account %d: %v", account.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log.Printf("Account %d temp unschedulable until %v (rule %d, code %d)", account.ID, until, ruleIndex, statusCode)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func truncateTempUnschedMessage(body []byte, maxBytes int) string {
|
|
|
|
|
|
if maxBytes <= 0 || len(body) == 0 {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(body) > maxBytes {
|
|
|
|
|
|
body = body[:maxBytes]
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.TrimSpace(string(body))
|
|
|
|
|
|
}
|