feat(gateway): 双模式用户消息队列 — 串行队列 + 软性限速

新增 UMQ (User Message Queue) 双模式支持:
- serialize: 账号级分布式串行锁 + RPM 自适应延迟(严格限流)
- throttle: 仅 RPM 自适应前置延迟,不阻塞并发(软性限速)

后端:
- config: 新增 Mode 字段,保留 Enabled 向后兼容
- service: 新增 UserMessageQueueService(Lua 锁/延迟算法/清理 worker)
- repository: 新增 UserMsgQueueCache(Redis Lua acquire/release/force-release)
- handler: 新增 UserMsgQueueHelper(SSE ping + 等待循环 + throttle)
- gateway: 按 mode 分支集成 serialize/throttle 逻辑
- lint: 修复 gofmt rewrite rules、errcheck 类型断言、staticcheck QF1012

前端:
- 三态选择器 UI(关闭/软性限速/串行队列)替代 toggle 开关
- BulkEdit 支持 null 语义(不修改)
- i18n 中英文文案

通过 6 轮专家评审(42 次 review)、golangci-lint、单元测试、集成测试。
This commit is contained in:
QTom
2026-03-03 01:02:39 +08:00
parent 7abec1888f
commit a9285b8a94
21 changed files with 1099 additions and 15 deletions

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
@@ -1032,6 +1033,26 @@ func (a *Account) IsTLSFingerprintEnabled() bool {
return false
}
// GetUserMsgQueueMode 获取用户消息队列模式
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
func (a *Account) GetUserMsgQueueMode() string {
if a.Extra == nil {
return ""
}
// 优先读取新字段 user_msg_queue_mode白名单校验非法值视为未设置
if mode, ok := a.Extra["user_msg_queue_mode"].(string); ok && mode != "" {
if mode == config.UMQModeSerialize || mode == config.UMQModeThrottle {
return mode
}
return "" // 非法值 fallback 到全局配置
}
// 向后兼容: user_msg_queue_enabled: true → "serialize"
if enabled, ok := a.Extra["user_msg_queue_enabled"].(bool); ok && enabled {
return config.UMQModeSerialize
}
return ""
}
// IsSessionIDMaskingEnabled 检查是否启用会话ID伪装
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将在一段时间内15分钟固定 metadata.user_id 中的 session ID

View File

@@ -61,6 +61,10 @@ type ParsedRequest struct {
ThinkingEnabled bool // 是否开启 thinking部分平台会影响最终模型名
MaxTokens int // max_tokens 值(用于探测请求拦截)
SessionContext *SessionContext // 可选请求上下文区分因子nil 时行为不变)
// OnUpstreamAccepted 上游接受请求后立即调用(用于提前释放串行锁)
// 流式请求在收到 2xx 响应头后调用,避免持锁等流完成
OnUpstreamAccepted func()
}
// ParseGatewayRequest 解析网关请求体并返回结构化结果。

View File

@@ -4305,6 +4305,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// 处理正常响应
// 触发上游接受回调(提前释放串行锁,不等流完成)
if parsed.OnUpstreamAccepted != nil {
parsed.OnUpstreamAccepted()
}
var usage *ClaudeUsage
var firstTokenMs *int
var clientDisconnect bool

View File

@@ -994,7 +994,7 @@ func (s *SettingService) GetMinClaudeCodeVersion(ctx context.Context) string {
}
}
// singleflight: 同一时刻只有一个 goroutine 查询 DB其余复用结果
result, _, _ := minVersionSF.Do("min_version", func() (any, error) {
result, err, _ := minVersionSF.Do("min_version", func() (any, error) {
// 二次检查,避免排队的 goroutine 重复查询
if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok {
if time.Now().UnixNano() < cached.expiresAt {
@@ -1020,10 +1020,14 @@ func (s *SettingService) GetMinClaudeCodeVersion(ctx context.Context) string {
})
return value, nil
})
if s, ok := result.(string); ok {
return s
if err != nil {
return ""
}
return ""
ver, ok := result.(string)
if !ok {
return ""
}
return ver
}
// SetStreamTimeoutSettings 设置流超时处理配置

View File

@@ -0,0 +1,318 @@
package service
import (
"context"
cryptorand "crypto/rand"
"encoding/hex"
"fmt"
"math"
"math/rand/v2"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// UserMsgQueueCache 用户消息串行队列 Redis 缓存接口
type UserMsgQueueCache interface {
// AcquireLock 尝试获取账号级串行锁
AcquireLock(ctx context.Context, accountID int64, requestID string, lockTtlMs int) (acquired bool, err error)
// ReleaseLock 释放锁并记录完成时间
ReleaseLock(ctx context.Context, accountID int64, requestID string) (released bool, err error)
// GetLastCompletedMs 获取上次完成时间毫秒时间戳Redis TIME 源)
GetLastCompletedMs(ctx context.Context, accountID int64) (int64, error)
// GetCurrentTimeMs 获取 Redis 服务器当前时间(毫秒),与 ReleaseLock 记录的时间源一致
GetCurrentTimeMs(ctx context.Context) (int64, error)
// ForceReleaseLock 强制释放锁(孤儿锁清理)
ForceReleaseLock(ctx context.Context, accountID int64) error
// ScanLockKeys 扫描 PTTL == -1 的孤儿锁 key返回 accountID 列表
ScanLockKeys(ctx context.Context, maxCount int) ([]int64, error)
}
// QueueLockResult 锁获取结果
type QueueLockResult struct {
Acquired bool
RequestID string
}
// UserMessageQueueService 用户消息串行队列服务
// 对真实用户消息实施账号级串行化 + RPM 自适应延迟
type UserMessageQueueService struct {
cache UserMsgQueueCache
rpmCache RPMCache
cfg *config.UserMessageQueueConfig
stopCh chan struct{} // graceful shutdown
stopOnce sync.Once // 确保 Stop() 并发安全
}
// NewUserMessageQueueService 创建用户消息串行队列服务
func NewUserMessageQueueService(cache UserMsgQueueCache, rpmCache RPMCache, cfg *config.UserMessageQueueConfig) *UserMessageQueueService {
return &UserMessageQueueService{
cache: cache,
rpmCache: rpmCache,
cfg: cfg,
stopCh: make(chan struct{}),
}
}
// IsRealUserMessage 检测是否为真实用户消息(非 tool_result
// 与 claude-relay-service 的检测逻辑一致:
// 1. messages 非空
// 2. 最后一条消息 role == "user"
// 3. 最后一条消息 content如果是数组中不含 type:"tool_result" / "tool_use_result"
func IsRealUserMessage(parsed *ParsedRequest) bool {
if parsed == nil || len(parsed.Messages) == 0 {
return false
}
lastMsg := parsed.Messages[len(parsed.Messages)-1]
msgMap, ok := lastMsg.(map[string]any)
if !ok {
return false
}
role, _ := msgMap["role"].(string)
if role != "user" {
return false
}
// 检查 content 是否包含 tool_result 类型
content, ok := msgMap["content"]
if !ok {
return true // 没有 content 字段,视为普通用户消息
}
contentArr, ok := content.([]any)
if !ok {
return true // content 不是数组(可能是 string视为普通用户消息
}
for _, item := range contentArr {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
if itemType == "tool_result" || itemType == "tool_use_result" {
return false
}
}
return true
}
// TryAcquire 尝试立即获取串行锁
func (s *UserMessageQueueService) TryAcquire(ctx context.Context, accountID int64) (*QueueLockResult, error) {
if s.cache == nil {
return &QueueLockResult{Acquired: true}, nil // fail-open
}
requestID := generateUMQRequestID()
lockTTL := s.cfg.LockTTLMs
if lockTTL <= 0 {
lockTTL = 120000
}
acquired, err := s.cache.AcquireLock(ctx, accountID, requestID, lockTTL)
if err != nil {
logger.LegacyPrintf("service.umq", "AcquireLock failed for account %d: %v", accountID, err)
return &QueueLockResult{Acquired: true}, nil // fail-open
}
return &QueueLockResult{
Acquired: acquired,
RequestID: requestID,
}, nil
}
// Release 释放串行锁
func (s *UserMessageQueueService) Release(ctx context.Context, accountID int64, requestID string) error {
if s.cache == nil || requestID == "" {
return nil
}
released, err := s.cache.ReleaseLock(ctx, accountID, requestID)
if err != nil {
logger.LegacyPrintf("service.umq", "ReleaseLock failed for account %d: %v", accountID, err)
return err
}
if !released {
logger.LegacyPrintf("service.umq", "ReleaseLock no-op for account %d (requestID mismatch or expired)", accountID)
}
return nil
}
// EnforceDelay 根据 RPM 负载执行自适应延迟
// 使用 Redis TIME 确保与 releaseLockScript 记录的时间源一致
func (s *UserMessageQueueService) EnforceDelay(ctx context.Context, accountID int64, baseRPM int) error {
if s.cache == nil {
return nil
}
// 先检查历史记录:没有历史则无需延迟,避免不必要的 RPM 查询
lastMs, err := s.cache.GetLastCompletedMs(ctx, accountID)
if err != nil {
logger.LegacyPrintf("service.umq", "GetLastCompletedMs failed for account %d: %v", accountID, err)
return nil // fail-open
}
if lastMs == 0 {
return nil // 没有历史记录,无需延迟
}
delay := s.CalculateRPMAwareDelay(ctx, accountID, baseRPM)
if delay <= 0 {
return nil
}
// 获取 Redis 当前时间(与 lastMs 同源,避免时钟偏差)
nowMs, err := s.cache.GetCurrentTimeMs(ctx)
if err != nil {
logger.LegacyPrintf("service.umq", "GetCurrentTimeMs failed: %v", err)
return nil // fail-open
}
elapsed := time.Duration(nowMs-lastMs) * time.Millisecond
if elapsed < 0 {
// 时钟异常Redis 故障转移等fail-open
return nil
}
remaining := delay - elapsed
if remaining <= 0 {
return nil
}
// 执行延迟
timer := time.NewTimer(remaining)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// CalculateRPMAwareDelay 根据当前 RPM 负载计算自适应延迟
// ratio = currentRPM / baseRPM
// ratio < 0.5 → MinDelay
// 0.5 ≤ ratio < 0.8 → 线性插值 MinDelay..MaxDelay
// ratio ≥ 0.8 → MaxDelay
// 返回值包含 ±15% 随机抖动anti-detection + 避免惊群效应)
func (s *UserMessageQueueService) CalculateRPMAwareDelay(ctx context.Context, accountID int64, baseRPM int) time.Duration {
minDelay := time.Duration(s.cfg.MinDelayMs) * time.Millisecond
maxDelay := time.Duration(s.cfg.MaxDelayMs) * time.Millisecond
if minDelay <= 0 {
minDelay = 200 * time.Millisecond
}
if maxDelay <= 0 {
maxDelay = 2000 * time.Millisecond
}
// 防止配置错误minDelay > maxDelay 时交换
if minDelay > maxDelay {
minDelay, maxDelay = maxDelay, minDelay
}
var baseDelay time.Duration
if baseRPM <= 0 || s.rpmCache == nil {
baseDelay = minDelay
} else {
currentRPM, err := s.rpmCache.GetRPM(ctx, accountID)
if err != nil {
logger.LegacyPrintf("service.umq", "GetRPM failed for account %d: %v", accountID, err)
baseDelay = minDelay // fail-open
} else {
ratio := float64(currentRPM) / float64(baseRPM)
if ratio < 0.5 {
baseDelay = minDelay
} else if ratio >= 0.8 {
baseDelay = maxDelay
} else {
// 线性插值: 0.5 → minDelay, 0.8 → maxDelay
t := (ratio - 0.5) / 0.3
interpolated := float64(minDelay) + t*(float64(maxDelay)-float64(minDelay))
baseDelay = time.Duration(math.Round(interpolated))
}
}
}
// ±15% 随机抖动
return applyJitter(baseDelay, 0.15)
}
// StartCleanupWorker 启动孤儿锁清理 worker
// 定期 SCAN umq:*:lock 并清理 PTTL == -1 的异常锁PTTL 检查在 cache.ScanLockKeys 内完成)
func (s *UserMessageQueueService) StartCleanupWorker(interval time.Duration) {
if s == nil || s.cache == nil || interval <= 0 {
return
}
runCleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
accountIDs, err := s.cache.ScanLockKeys(ctx, 1000)
if err != nil {
logger.LegacyPrintf("service.umq", "Cleanup scan failed: %v", err)
return
}
cleaned := 0
for _, accountID := range accountIDs {
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := s.cache.ForceReleaseLock(cleanCtx, accountID); err != nil {
logger.LegacyPrintf("service.umq", "Cleanup force release failed for account %d: %v", accountID, err)
} else {
cleaned++
}
cleanCancel()
}
if cleaned > 0 {
logger.LegacyPrintf("service.umq", "Cleanup completed: released %d orphaned locks", cleaned)
}
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
runCleanup()
}
}
}()
}
// Stop 停止后台 cleanup worker
func (s *UserMessageQueueService) Stop() {
if s != nil && s.stopCh != nil {
s.stopOnce.Do(func() {
close(s.stopCh)
})
}
}
// applyJitter 对延迟值施加 ±jitterPct 的随机抖动
// 使用 math/rand/v2Go 1.22+ 自动使用 crypto/rand 种子),与 nextBackoff 一致
// 例如 applyJitter(200ms, 0.15) 返回 170ms ~ 230ms
func applyJitter(d time.Duration, jitterPct float64) time.Duration {
if d <= 0 || jitterPct <= 0 {
return d
}
// [-jitterPct, +jitterPct]
jitter := (rand.Float64()*2 - 1) * jitterPct
return time.Duration(float64(d) * (1 + jitter))
}
// generateUMQRequestID 生成唯一请求 ID与 generateRequestID 一致的 fallback 模式)
func generateUMQRequestID() string {
b := make([]byte, 16)
if _, err := cryptorand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}

View File

@@ -110,6 +110,15 @@ func ProvideConcurrencyService(cache ConcurrencyCache, accountRepo AccountReposi
return svc
}
// ProvideUserMessageQueueService 创建用户消息串行队列服务并启动清理 worker
func ProvideUserMessageQueueService(cache UserMsgQueueCache, rpmCache RPMCache, cfg *config.Config) *UserMessageQueueService {
svc := NewUserMessageQueueService(cache, rpmCache, &cfg.Gateway.UserMessageQueue)
if cfg.Gateway.UserMessageQueue.CleanupIntervalSeconds > 0 {
svc.StartCleanupWorker(time.Duration(cfg.Gateway.UserMessageQueue.CleanupIntervalSeconds) * time.Second)
}
return svc
}
// ProvideSchedulerSnapshotService creates and starts SchedulerSnapshotService.
func ProvideSchedulerSnapshotService(
cache SchedulerCache,
@@ -348,6 +357,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionService,
wire.Bind(new(DefaultSubscriptionAssigner), new(*SubscriptionService)),
ProvideConcurrencyService,
ProvideUserMessageQueueService,
NewUsageRecordWorkerPool,
ProvideSchedulerSnapshotService,
NewIdentityService,