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 条/分钟
This commit is contained in:
1 parent
7e7f53bb41
commit
af8591b78a
30 files changed
+1289
-144
No files matched your search
@@ -1,8 +1,9 @@
|
||||
package qq_obv11
|
||||
|
||||
// This file implements the sliding-window send rate limiter that keeps QQ
|
||||
// OneBot proactive/reply traffic from bursting (see DevDocs/design/08 section
|
||||
// 3, "注意风控").
|
||||
// 本文件实现**按会话(群聊/私聊)独立计数**的发送滑窗限流器:它防止 QQ OneBot 的
|
||||
// 主动/回复流量突发触发风控(design/08 §3「注意风控」),并落地「每会话独立计数」口径
|
||||
// (裁决 06.4.3)——给 A 群发消息不占用 B 群或私聊的额度,键口径与入站滑窗一致
|
||||
// (adapter.ConversationKey:platform+channelID+groupID/peerID),不在适配器顶层汇总。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -14,23 +15,27 @@ import (
|
||||
// per-window send quota is exhausted.
|
||||
var ErrRateLimited = errors.New("qq-obv11: send rate limit exceeded")
|
||||
|
||||
// rateLimiter enforces a maximum number of events within a sliding window. It
|
||||
// is disabled when max is non-positive.
|
||||
// sendSessionsGCThreshold 是触发空闲会话清理的会话键数量阈值:超过它就在放行路径上
|
||||
// 扫一遍、丢掉窗口内已无记录的会话,避免长跑进程里会话键无界增长。
|
||||
const sendSessionsGCThreshold = 256
|
||||
|
||||
// rateLimiter 为每个会话键(adapter.ConversationKey)各维护一份滑窗时间戳;窗口内该
|
||||
// 会话的发送条数超过 max 即拒绝。max 非正数表示关闭限流(一律放行)。
|
||||
type rateLimiter struct {
|
||||
max int
|
||||
window time.Duration
|
||||
mu sync.Mutex
|
||||
times []time.Time
|
||||
times map[string][]time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{max: max, window: window}
|
||||
return &rateLimiter{max: max, window: window, times: make(map[string][]time.Time)}
|
||||
}
|
||||
|
||||
// allow records a send at now and reports whether it fits within the window
|
||||
// quota. A disabled limiter (max <= 0) always allows. The timestamp is only
|
||||
// recorded when the send is accepted.
|
||||
func (r *rateLimiter) allow(now time.Time) bool {
|
||||
// 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
|
||||
}
|
||||
@@ -38,15 +43,34 @@ func (r *rateLimiter) allow(now time.Time) bool {
|
||||
defer r.mu.Unlock()
|
||||
|
||||
cutoff := now.Add(-r.window)
|
||||
r.gcLocked(cutoff)
|
||||
|
||||
hits := r.times[key]
|
||||
i := 0
|
||||
for i < len(r.times) && !r.times[i].After(cutoff) {
|
||||
for i < len(hits) && !hits[i].After(cutoff) {
|
||||
i++
|
||||
}
|
||||
r.times = r.times[i:]
|
||||
|
||||
if len(r.times) >= r.max {
|
||||
hits = hits[i:]
|
||||
if len(hits) >= r.max {
|
||||
r.times[key] = hits
|
||||
return false
|
||||
}
|
||||
r.times = append(r.times, now)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user