package qq_obv11 // 本文件实现**按会话(群聊/私聊)独立计数**的发送滑窗限流器:它防止 QQ OneBot 的 // 主动/回复流量突发触发风控(design/08 §3「注意风控」),并落地「每会话独立计数」口径 // (裁决 06.4.3)——给 A 群发消息不占用 B 群或私聊的额度,键口径与入站滑窗一致 // (adapter.ConversationKey:platform+channelID+groupID/peerID),不在适配器顶层汇总。 import ( "errors" "sync" "time" ) // ErrRateLimited is returned by SendMessage/SendProactive when the configured // per-window send quota is exhausted. var ErrRateLimited = errors.New("qq-obv11: send rate limit exceeded") // sendSessionsGCThreshold 是触发空闲会话清理的会话键数量阈值:超过它就在放行路径上 // 扫一遍、丢掉窗口内已无记录的会话,避免长跑进程里会话键无界增长。 const sendSessionsGCThreshold = 256 // rateLimiter 为每个会话键(adapter.ConversationKey)各维护一份滑窗时间戳;窗口内该 // 会话的发送条数超过 max 即拒绝。max 非正数表示关闭限流(一律放行)。 type rateLimiter struct { max int window time.Duration mu sync.Mutex times map[string][]time.Time } func newRateLimiter(max int, window time.Duration) *rateLimiter { return &rateLimiter{max: max, window: window, times: make(map[string][]time.Time)} } // allow 记录 key 会话在 now 的一次发送,并报告它是否落在该会话的窗口额度内。 // 限流关闭(max <= 0)或接收者为 nil 时一律放行;只有被接受的发送才记入时间戳。 // 各会话额度互相独立(裁决 06.4.3)。 func (r *rateLimiter) allow(key string, now time.Time) bool { if r == nil || r.max <= 0 { return true } r.mu.Lock() defer r.mu.Unlock() cutoff := now.Add(-r.window) r.gcLocked(cutoff) hits := r.times[key] i := 0 for i < len(hits) && !hits[i].After(cutoff) { i++ } hits = hits[i:] if len(hits) >= r.max { r.times[key] = hits return false } if r.times == nil { r.times = make(map[string][]time.Time) } r.times[key] = append(hits, now) return true } // gcLocked 在会话键数量超过阈值时丢弃窗口内已无记录的会话。时间戳按插入顺序递增, // 因此某个会话最后一条记录都不晚于 cutoff 时,它整条都可以删掉。调用方须持有 r.mu。 func (r *rateLimiter) gcLocked(cutoff time.Time) { if len(r.times) <= sendSessionsGCThreshold { return } for key, hits := range r.times { if len(hits) == 0 || !hits[len(hits)-1].After(cutoff) { delete(r.times, key) } } }