Files
Cyrene_v2/plugins/channels/qq_obv11/ratelimit.go
T
AskaEth af8591b78a fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
2026-10-04 12:07:40 +08:00

77 lines
2.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}