- 适配器只描述平台身份: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 条/分钟
77 lines
2.6 KiB
Go
77 lines
2.6 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|