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:
AskaEth committed 2026-10-04 12:07:40 +08:00
1 parent 7e7f53bb41
commit af8591b78a
30 files changed
+1289 -144

No files matched your search

+40 -16
View File
@@ -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)
}
}
}