Skip to content

Commit a9285b8

Browse files
author
QTom
committed
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、单元测试、集成测试。
1 parent 7abec18 commit a9285b8

21 files changed

Lines changed: 1099 additions & 15 deletions

backend/cmd/server/wire_gen.go

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/config/config.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ const (
3030
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
3131
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
3232

33+
// UMQ(用户消息队列)模式常量
34+
const (
35+
// UMQModeSerialize: 账号级串行锁 + RPM 自适应延迟
36+
UMQModeSerialize = "serialize"
37+
// UMQModeThrottle: 仅 RPM 自适应前置延迟,不阻塞并发
38+
UMQModeThrottle = "throttle"
39+
)
40+
3341
// 连接池隔离策略常量
3442
// 用于控制上游 HTTP 连接池的隔离粒度,影响连接复用和资源消耗
3543
const (
@@ -455,6 +463,52 @@ type GatewayConfig struct {
455463
UserGroupRateCacheTTLSeconds int `mapstructure:"user_group_rate_cache_ttl_seconds"`
456464
// ModelsListCacheTTLSeconds: /v1/models 模型列表短缓存 TTL(秒)
457465
ModelsListCacheTTLSeconds int `mapstructure:"models_list_cache_ttl_seconds"`
466+
467+
// UserMessageQueue: 用户消息串行队列配置
468+
// 对 role:"user" 的真实用户消息实施账号级串行化 + RPM 自适应延迟
469+
UserMessageQueue UserMessageQueueConfig `mapstructure:"user_message_queue"`
470+
}
471+
472+
// UserMessageQueueConfig 用户消息串行队列配置
473+
// 用于 Anthropic OAuth/SetupToken 账号的用户消息串行化发送
474+
type UserMessageQueueConfig struct {
475+
// Mode: 模式选择
476+
// "serialize" = 账号级串行锁 + RPM 自适应延迟
477+
// "throttle" = 仅 RPM 自适应前置延迟,不阻塞并发
478+
// "" = 禁用(默认)
479+
Mode string `mapstructure:"mode"`
480+
// Enabled: 已废弃,仅向后兼容(等同于 mode: "serialize")
481+
Enabled bool `mapstructure:"enabled"`
482+
// LockTTLMs: 串行锁 TTL(毫秒),应大于最长请求时间
483+
LockTTLMs int `mapstructure:"lock_ttl_ms"`
484+
// WaitTimeoutMs: 等待获取锁的超时时间(毫秒)
485+
WaitTimeoutMs int `mapstructure:"wait_timeout_ms"`
486+
// MinDelayMs: RPM 自适应延迟下限(毫秒)
487+
MinDelayMs int `mapstructure:"min_delay_ms"`
488+
// MaxDelayMs: RPM 自适应延迟上限(毫秒)
489+
MaxDelayMs int `mapstructure:"max_delay_ms"`
490+
// CleanupIntervalSeconds: 孤儿锁清理间隔(秒),0 表示禁用
491+
CleanupIntervalSeconds int `mapstructure:"cleanup_interval_seconds"`
492+
}
493+
494+
// WaitTimeout 返回等待超时的 time.Duration
495+
func (c *UserMessageQueueConfig) WaitTimeout() time.Duration {
496+
if c.WaitTimeoutMs <= 0 {
497+
return 30 * time.Second
498+
}
499+
return time.Duration(c.WaitTimeoutMs) * time.Millisecond
500+
}
501+
502+
// GetEffectiveMode 返回生效的模式
503+
// 注意:Mode 字段已在 load() 中做过白名单校验和规范化,此处无需重复验证
504+
func (c *UserMessageQueueConfig) GetEffectiveMode() string {
505+
if c.Mode == UMQModeSerialize || c.Mode == UMQModeThrottle {
506+
return c.Mode
507+
}
508+
if c.Enabled {
509+
return UMQModeSerialize // 向后兼容
510+
}
511+
return ""
458512
}
459513

460514
// GatewayOpenAIWSConfig OpenAI Responses WebSocket 配置。
@@ -994,6 +1048,14 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
9941048
cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds
9951049
}
9961050

1051+
// Normalize UMQ mode: 白名单校验,非法值在加载时一次性 warn 并清空
1052+
if m := cfg.Gateway.UserMessageQueue.Mode; m != "" && m != UMQModeSerialize && m != UMQModeThrottle {
1053+
slog.Warn("invalid user_message_queue mode, disabling",
1054+
"mode", m,
1055+
"valid_modes", []string{UMQModeSerialize, UMQModeThrottle})
1056+
cfg.Gateway.UserMessageQueue.Mode = ""
1057+
}
1058+
9971059
// Auto-generate TOTP encryption key if not set (32 bytes = 64 hex chars for AES-256)
9981060
cfg.Totp.EncryptionKey = strings.TrimSpace(cfg.Totp.EncryptionKey)
9991061
if cfg.Totp.EncryptionKey == "" {
@@ -1372,6 +1434,14 @@ func setDefaults() {
13721434
viper.SetDefault("gateway.user_group_rate_cache_ttl_seconds", 30)
13731435
viper.SetDefault("gateway.models_list_cache_ttl_seconds", 15)
13741436
// TLS指纹伪装配置(默认关闭,需要账号级别单独启用)
1437+
// 用户消息串行队列默认值
1438+
viper.SetDefault("gateway.user_message_queue.enabled", false)
1439+
viper.SetDefault("gateway.user_message_queue.lock_ttl_ms", 120000)
1440+
viper.SetDefault("gateway.user_message_queue.wait_timeout_ms", 30000)
1441+
viper.SetDefault("gateway.user_message_queue.min_delay_ms", 200)
1442+
viper.SetDefault("gateway.user_message_queue.max_delay_ms", 2000)
1443+
viper.SetDefault("gateway.user_message_queue.cleanup_interval_seconds", 60)
1444+
13751445
viper.SetDefault("gateway.tls_fingerprint.enabled", true)
13761446
viper.SetDefault("concurrency.ping_interval", 10)
13771447

backend/internal/handler/dto/mappers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ func AccountFromServiceShallow(a *service.Account) *Account {
216216
buffer := a.GetRPMStickyBuffer()
217217
out.RPMStickyBuffer = &buffer
218218
}
219+
// 用户消息队列模式
220+
if mode := a.GetUserMsgQueueMode(); mode != "" {
221+
out.UserMsgQueueMode = &mode
222+
}
219223
// TLS指纹伪装开关
220224
if a.IsTLSFingerprintEnabled() {
221225
enabled := true

backend/internal/handler/dto/types.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,10 @@ type Account struct {
155155

156156
// RPM 限制(仅 Anthropic OAuth/SetupToken 账号有效)
157157
// 从 extra 字段提取,方便前端显示和编辑
158-
BaseRPM *int `json:"base_rpm,omitempty"`
159-
RPMStrategy *string `json:"rpm_strategy,omitempty"`
160-
RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"`
158+
BaseRPM *int `json:"base_rpm,omitempty"`
159+
RPMStrategy *string `json:"rpm_strategy,omitempty"`
160+
RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"`
161+
UserMsgQueueMode *string `json:"user_msg_queue_mode,omitempty"`
161162

162163
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
163164
// 从 extra 字段提取,方便前端显示和编辑

backend/internal/handler/gateway_handler.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type GatewayHandler struct {
4545
usageRecordWorkerPool *service.UsageRecordWorkerPool
4646
errorPassthroughService *service.ErrorPassthroughService
4747
concurrencyHelper *ConcurrencyHelper
48+
userMsgQueueHelper *UserMsgQueueHelper
4849
maxAccountSwitches int
4950
maxAccountSwitchesGemini int
5051
cfg *config.Config
@@ -63,6 +64,7 @@ func NewGatewayHandler(
6364
apiKeyService *service.APIKeyService,
6465
usageRecordWorkerPool *service.UsageRecordWorkerPool,
6566
errorPassthroughService *service.ErrorPassthroughService,
67+
userMsgQueueService *service.UserMessageQueueService,
6668
cfg *config.Config,
6769
settingService *service.SettingService,
6870
) *GatewayHandler {
@@ -78,6 +80,13 @@ func NewGatewayHandler(
7880
maxAccountSwitchesGemini = cfg.Gateway.MaxAccountSwitchesGemini
7981
}
8082
}
83+
84+
// 初始化用户消息串行队列 helper
85+
var umqHelper *UserMsgQueueHelper
86+
if userMsgQueueService != nil && cfg != nil {
87+
umqHelper = NewUserMsgQueueHelper(userMsgQueueService, SSEPingFormatClaude, pingInterval)
88+
}
89+
8190
return &GatewayHandler{
8291
gatewayService: gatewayService,
8392
geminiCompatService: geminiCompatService,
@@ -89,6 +98,7 @@ func NewGatewayHandler(
8998
usageRecordWorkerPool: usageRecordWorkerPool,
9099
errorPassthroughService: errorPassthroughService,
91100
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval),
101+
userMsgQueueHelper: umqHelper,
92102
maxAccountSwitches: maxAccountSwitches,
93103
maxAccountSwitchesGemini: maxAccountSwitchesGemini,
94104
cfg: cfg,
@@ -566,6 +576,58 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
566576
// 账号槽位/等待计数需要在超时或断开时安全回收
567577
accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc)
568578

579+
// ===== 用户消息串行队列 START =====
580+
var queueRelease func()
581+
umqMode := h.getUserMsgQueueMode(account, parsedReq)
582+
583+
switch umqMode {
584+
case config.UMQModeSerialize:
585+
// 串行模式:获取锁 + RPM 延迟 + 释放(当前行为不变)
586+
baseRPM := account.GetBaseRPM()
587+
release, qErr := h.userMsgQueueHelper.AcquireWithWait(
588+
c, account.ID, baseRPM, reqStream, &streamStarted,
589+
h.cfg.Gateway.UserMessageQueue.WaitTimeout(),
590+
reqLog,
591+
)
592+
if qErr != nil {
593+
// fail-open: 记录 warn,不阻止请求
594+
reqLog.Warn("gateway.umq_acquire_failed",
595+
zap.Int64("account_id", account.ID),
596+
zap.Error(qErr),
597+
)
598+
} else {
599+
queueRelease = release
600+
}
601+
602+
case config.UMQModeThrottle:
603+
// 软性限速:仅施加 RPM 自适应延迟,不阻塞并发
604+
baseRPM := account.GetBaseRPM()
605+
if tErr := h.userMsgQueueHelper.ThrottleWithPing(
606+
c, account.ID, baseRPM, reqStream, &streamStarted,
607+
h.cfg.Gateway.UserMessageQueue.WaitTimeout(),
608+
reqLog,
609+
); tErr != nil {
610+
reqLog.Warn("gateway.umq_throttle_failed",
611+
zap.Int64("account_id", account.ID),
612+
zap.Error(tErr),
613+
)
614+
}
615+
616+
default:
617+
if umqMode != "" {
618+
reqLog.Warn("gateway.umq_unknown_mode",
619+
zap.String("mode", umqMode),
620+
zap.Int64("account_id", account.ID),
621+
)
622+
}
623+
}
624+
625+
// 用 wrapReleaseOnDone 确保 context 取消时自动释放(仅 serialize 模式有 queueRelease)
626+
queueRelease = wrapReleaseOnDone(c.Request.Context(), queueRelease)
627+
// 注入回调到 ParsedRequest:使用外层 wrapper 以便提前清理 AfterFunc
628+
parsedReq.OnUpstreamAccepted = queueRelease
629+
// ===== 用户消息串行队列 END =====
630+
569631
// 转发请求 - 根据账号平台分流
570632
var result *service.ForwardResult
571633
requestCtx := c.Request.Context()
@@ -577,6 +639,14 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
577639
} else {
578640
result, err = h.gatewayService.Forward(requestCtx, c, account, parsedReq)
579641
}
642+
643+
// 兜底释放串行锁(正常情况已通过回调提前释放)
644+
if queueRelease != nil {
645+
queueRelease()
646+
}
647+
// 清理回调引用,防止 failover 重试时旧回调被错误调用
648+
parsedReq.OnUpstreamAccepted = nil
649+
580650
if accountReleaseFunc != nil {
581651
accountReleaseFunc()
582652
}
@@ -1431,3 +1501,24 @@ func (h *GatewayHandler) submitUsageRecordTask(task service.UsageRecordTask) {
14311501
}()
14321502
task(ctx)
14331503
}
1504+
1505+
// getUserMsgQueueMode 获取当前请求的 UMQ 模式
1506+
// 返回 "serialize" | "throttle" | ""
1507+
func (h *GatewayHandler) getUserMsgQueueMode(account *service.Account, parsed *service.ParsedRequest) string {
1508+
if h.userMsgQueueHelper == nil {
1509+
return ""
1510+
}
1511+
// 仅适用于 Anthropic OAuth/SetupToken 账号
1512+
if !account.IsAnthropicOAuthOrSetupToken() {
1513+
return ""
1514+
}
1515+
if !service.IsRealUserMessage(parsed) {
1516+
return ""
1517+
}
1518+
// 账号级模式优先,fallback 到全局配置
1519+
mode := account.GetUserMsgQueueMode()
1520+
if mode == "" {
1521+
mode = h.cfg.Gateway.UserMessageQueue.GetEffectiveMode()
1522+
}
1523+
return mode
1524+
}

0 commit comments

Comments
 (0)