Files
Cyrene_v2/internal/channel/adapter/flowgate.go
T
AskaEth a057a0f61f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 流控可配 —— 陌生人私聊额度走账号配置 directMsgPerMin(裁决 13)
- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
2026-10-05 13:50:29 +08:00

483 lines
18 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 adapter
import (
"context"
"sync"
"time"
)
// Reviewer decides which buffered messages are worth keeping. It receives the
// current pending set (in arrival order) and returns the indices it wants to keep.
// Messages at a returned index survive; all others are pruned and audited.
//
// It is a pure noise/importance filter (design/12 §6 策略四). Implementations must
// never decide identity: who the sender is was already resolved structurally by the
// channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap.
type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error)
// GateVerdict reports how the gate classified one inbound message.
type GateVerdict struct {
// Proceed reports whether the message should be delivered downstream.
Proceed bool
// Reason is a short token describing a drop: "rate_limited", "review_pruned"
// or "capped". It is empty when Proceed is true.
Reason string
// DroppedMsg is the message that failed admission (nil when Proceed is true).
DroppedMsg *UnifiedMessage
// Pruned lists every message the gate dropped during this admission. On the
// review path it is the noise the reviewer rejected; on the cap path it is the
// oldest messages evicted to honor the total cap. Each is recorded to the
// OnPruned audit hook before being dropped, so nothing is lost silently.
Pruned []UnifiedMessage
}
// FlowGateOptions configures a per-account ingress gate. Every limit is
// independent and a zero value disables that limit, so an account that does not
// declare the new fields keeps its previous unlocked behavior (backward
// compatible, design/12 §6 实现要点).
//
// **计数键 = 会话级**(裁决 06.4.3):GroupMsgPerMin 与 MaxMessages 都按
// ConversationKey(`platform+channelID+groupID/peerID`)每个群聊/私聊独立计数,不再在
// 适配器顶层汇总——两个群并发互不挤占。MsgPerMin 是防刷屏的**账号级总量护栏**,按设计
// 口径仍留在适配器顶层,不随会话拆分。
type FlowGateOptions struct {
// GroupMsgPerMin caps group-chat inbound messages per minute **per
// conversation** (design/12 §6 策略三 / 裁决 06.4.3). Zero disables the limit.
GroupMsgPerMin int
// DirectMsgPerMin caps **direct (private)** inbound messages per minute
// **per conversation** — and for a private message the conversation key is
// (platform, channel, sender), so the limit throttles one sender rather than
// the whole account. It is the mandatory flow control for stranger private
// chats (主人 2026-10-05 裁决③): strangers chat normally, so a sender must be
// unable to flood her. Zero disables the limit.
DirectMsgPerMin int
// MsgPerMin caps total inbound non-owner messages per minute across the whole
// account — the anti-flood total guard. Zero disables it.
MsgPerMin int
// MaxMessages is the **per-conversation** pending-message cap (design/12 §6
// 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer
// is invoked to prune noise; with no Reviewer the oldest messages are evicted
// FIFO. Zero disables the cap.
MaxMessages int
// Window is the sliding window over which the per-minute limits are counted.
// Zero uses defaultRateWindow.
Window time.Duration
// Review is the optional review model. When nil the gate enforces the cap with
// FIFO eviction instead of model judgment.
Review Reviewer
// OnPruned is called once per pruned/dropped message with a short reason token
// ("review_pruned" or "capped") so the caller can record an audit trail.
// It may be nil (then the caller only sees Pruned on the verdict).
OnPruned func(UnifiedMessage, string)
// Now overrides the clock for tests. Nil uses time.Now.
Now func() time.Time
}
const (
// defaultRateWindow is the sliding window over which per-minute limits count.
defaultRateWindow = time.Minute
// defaultReviewPct is how full the pending set must be before the review model
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
// that keeps review from firing on every message.
defaultReviewPct = 80
// FlowLimitDefault is the protective default of 6 group messages per minute
// (design/12 §6 策略三). It is applied when an account pins no group rate limit.
FlowLimitDefault = 6
// FlowDirectDefault is the protective default of 6 direct (private) messages
// per minute **per sender** — the mandatory flow control for stranger private
// chats (主人 2026-10-05 裁决③). 6/分钟与群聊保护性默认同一口径:一个人正常
// 一来一回打字到不了 6 条/分钟,而刷屏/机器人会在第 7 条被挡下。它不随
// groupMsgPerMin 变化(陌生人流控不允许被「没配那项」静默关掉)。
FlowDirectDefault = 6
// FlowCapDefault is the protective default total-cap of 50 pending messages
// (design/12 §6 策略四).
FlowCapDefault = 50
// sessionGCInterval is how many admissions pass between idle-conversation sweeps,
// so a long-lived gate that has seen many conversations does not grow unbounded.
sessionGCInterval = 256
)
// ConversationKey 返回一条消息所属会话的节流键(裁决 06.4.3):
// `platform+channelID+groupID/peerID`——群聊按 (平台, 群) 独立计数,私聊按 (平台, 对端)
// 独立计数。它同时被入站滑窗(本文件的 FlowGate)与出处参与引擎
// (channelbridge.engageKey) 使用,使「每会话独立计数」只有一处口径。
// 多账号(platform:confId)之间的隔离由每个账号各自的 FlowGate/适配器实例承担。
func ConversationKey(msg UnifiedMessage) string {
conv := msg.GroupID
if msg.IsGroup() {
if conv == "" {
// 群聊缺 group id 时退回 channel id(qq-obv11 里 channelID 就是群号),
// 避免同一个群被拆成多个桶而让限流失效。
conv = msg.ChannelID
}
} else {
conv = msg.SenderID
if conv == "" {
conv = msg.Sender.UserID
}
if conv == "" {
conv = msg.ChannelID
}
}
return msg.Platform + "|" + msg.ChannelID + "|" + conv
}
// flowSession 是一个会话(群聊/私聊)独立的节流状态。FlowGate 按 ConversationKey 为
// 每个会话保存一份,因此两个群并发互不挤占(裁决 06.4.3)。
type flowSession struct {
groupHits []time.Time
directHits []time.Time
buffer []queuedMsg
lastReview time.Time
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
inFlight int
}
// FlowGate is a concurrency-safe, per-account ingress gate implementing the two
// measures from design/12 §6 策略三/四:
//
// - 策略三: a sliding-window rate limit on group chat (and, optionally, all
// non-owner messages), plus the mandatory **per-sender** rate limit on direct
// (private) messages that backs the stranger private-chat flow control
// (AllowDirect). Over-limit messages are dropped (downgraded) rather than
// blocking a flood of messages from saturating the loop.
// - 策略四: a bounded pending-message set. When it nears its cap the Reviewer is
// invoked to prune low-value messages; with no Reviewer the oldest messages are
// evicted FIFO. Either way every dropped message is audited.
//
// 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。
//
// Owner-private messages are not routed through the gate (the caller bypasses it),
// so the owner is never throttled or pruned. Identity is never examined here.
// The gate also carries the per-conversation running-turn slot (AcquireTurn /
// ReleaseTurn) used by the stranger private-chat flow control.
type FlowGate struct {
groupPerMin int
directPerMin int
msgPerMin int
maxMessages int
reviewAt int
window time.Duration
review Reviewer
onPruned func(UnifiedMessage, string)
now func() time.Time
mu sync.Mutex
totalHits []time.Time
sessions map[string]*flowSession
admitted uint64
seq uint64
}
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
// be audited and the fate of the just-admitted message be tracked across index
// shifts (value equality on UnifiedMessage is unsafe because it holds slices).
type queuedMsg struct {
seq uint64
msg UnifiedMessage
}
// NewFlowGate builds a gate from opts. Options that are zero disable their limit,
// except Window which defaults to one minute when left zero.
func NewFlowGate(opts FlowGateOptions) *FlowGate {
g := &FlowGate{
groupPerMin: opts.GroupMsgPerMin,
directPerMin: opts.DirectMsgPerMin,
msgPerMin: opts.MsgPerMin,
maxMessages: opts.MaxMessages,
window: opts.Window,
review: opts.Review,
onPruned: opts.OnPruned,
now: opts.Now,
sessions: make(map[string]*flowSession),
}
if g.window <= 0 {
g.window = defaultRateWindow
}
if g.maxMessages > 0 {
g.reviewAt = g.maxMessages * defaultReviewPct / 100
if g.reviewAt < 1 {
g.reviewAt = 1
}
}
if g.now == nil {
g.now = time.Now
}
return g
}
// Admit applies the rate limits then the per-conversation cap/review gate to one
// inbound message. It returns a verdict telling the caller whether to proceed;
// messages that the gate drops are returned (and audited) so nothing is lost
// silently.
//
// 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的
// flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。
func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
now := g.now()
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
g.maybeGCLocked(now)
// 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin
// 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door
// (downgraded) instead of blocking.
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
st := g.sessionLocked(key)
st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) >= g.groupPerMin {
return g.drop("rate_limited", msg)
}
st.groupHits = append(st.groupHits, now)
}
if g.msgPerMin > 0 {
g.totalHits = g.trimHits(g.totalHits, now)
if len(g.totalHits) >= g.msgPerMin {
return g.drop("rate_limited", msg)
}
g.totalHits = append(g.totalHits, now)
}
if g.maxMessages <= 0 {
return GateVerdict{Proceed: true}
}
// 策略四(会话级): add to this conversation's pending set, then prune when it
// nears the cap.
st := g.sessionLocked(key)
g.seq++
seq := g.seq
st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg})
var dropped []queuedMsg
if g.review != nil && len(st.buffer) >= g.reviewAt && !g.reviewRecently(st, now) {
keep, err := g.review(ctx, queueMessages(st.buffer))
if err == nil {
st.buffer, dropped = g.applyReview(st.buffer, keep)
st.lastReview = now
g.auditDropped(dropped, "review_pruned")
}
}
if len(st.buffer) > g.maxMessages {
n := len(st.buffer) - g.maxMessages
var evicted []queuedMsg
st.buffer, evicted = g.dropFront(st.buffer, n)
dropped = append(dropped, evicted...)
g.auditDropped(evicted, "capped")
}
if !bufferHasSeq(st.buffer, seq) {
reason := "capped"
if containsSeq(dropped, seq) {
reason = "review_pruned"
}
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
c := msg
v.DroppedMsg = &c
return v
}
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
}
// AllowDirect enforces the per-sender sliding-window rate limit for a **direct
// (private)** message (主人 2026-10-05 裁决③:陌生人私聊=正常聊天,因此必须有流控).
// For a private message the conversation key is (platform, channel, sender), so
// the window throttles one sender instead of the whole account and two strangers
// never crowd each other out. It returns false when that sender is over
// DirectMsgPerMin; the refused message is recorded to the OnPruned audit hook so
// nothing is dropped silently. A zero limit or a group message always allows.
func (g *FlowGate) AllowDirect(msg UnifiedMessage) bool {
if g.directPerMin <= 0 || msg.ChannelType != ChannelDirect {
return true
}
now := g.now()
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
st.directHits = g.trimHits(st.directHits, now)
if len(st.directHits) >= g.directPerMin {
g.auditMsg(msg, "rate_limited")
return false
}
st.directHits = append(st.directHits, now)
return true
}
// DirectPerMin 返回当前生效的私聊(非主人)每分钟每发送者上限,0 = 该限制已关闭。
// 调用方据此**如实报出实际额度**(例如 channelbridge 被限流时的告警日志),而不是
// 写死保护性默认 6 —— 主人 2026-10-05 裁决 13 把该额度变成按账号可配
// (`directMsgPerMin`),硬编码会让日志报出一个与真实生效值不符的数字。
// 该值在构造后不再变化,因此这里直接读,无需加锁。
func (g *FlowGate) DirectPerMin() int {
return g.directPerMin
}
// AcquireTurn reserves the single running-turn slot of a conversation, returning
// false when a turn is already in flight for it. Callers must pair every true
// result with ReleaseTurn. It is the second half of the mandatory stranger flow
// control (主人 2026-10-05 裁决③:「同一发送者同时最多 1 个在跑的回合」): a stranger
// can send several messages inside the per-minute allowance, yet only one of them
// may occupy an expensive agent turn at a time. It never decides identity — the
// key is the same structural ConversationKey the rest of the gate uses.
func (g *FlowGate) AcquireTurn(msg UnifiedMessage) bool {
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
if st.inFlight >= 1 {
return false
}
st.inFlight++
return true
}
// ReleaseTurn releases a slot reserved by AcquireTurn.
func (g *FlowGate) ReleaseTurn(msg UnifiedMessage) {
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
if st.inFlight > 0 {
st.inFlight--
}
}
// drop builds a refused verdict from a single message that never entered the
// buffer (rate-limited at the door), so the audit hook still sees it.
func (g *FlowGate) drop(reason string, msg UnifiedMessage) GateVerdict {
c := msg
v := GateVerdict{Proceed: false, Reason: reason, DroppedMsg: &c, Pruned: []UnifiedMessage{msg}}
g.auditMsg(msg, reason)
return v
}
// sessionLocked 返回 key 对应的会话节流状态,首次使用时惰性创建。调用方须持有 g.mu。
func (g *FlowGate) sessionLocked(key string) *flowSession {
if g.sessions == nil {
g.sessions = make(map[string]*flowSession)
}
st := g.sessions[key]
if st == nil {
st = &flowSession{}
g.sessions[key] = st
}
return st
}
// maybeGCLocked 每 sessionGCInterval 次放行清理一次空闲会话,避免会话状态表随历史会话
// 无界增长:滑窗时间戳全部过期、待处理队列为空、且没有回合在跑的会话直接删除。
// 调用方须持有 g.mu。
func (g *FlowGate) maybeGCLocked(now time.Time) {
g.admitted++
if g.admitted%sessionGCInterval != 0 {
return
}
for key, st := range g.sessions {
st.groupHits = g.trimHits(st.groupHits, now)
st.directHits = g.trimHits(st.directHits, now)
if len(st.groupHits) == 0 && len(st.directHits) == 0 &&
len(st.buffer) == 0 && st.inFlight == 0 {
delete(g.sessions, key)
}
}
}
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window per conversation, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
if st == nil || st.lastReview.IsZero() {
return false
}
return now.Sub(st.lastReview) < g.window
}
// trimHits drops event timestamps older than the sliding window.
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-g.window)
keep := 0
for _, t := range hits {
if t.After(cutoff) {
hits[keep] = t
keep++
}
}
return hits[:keep]
}
// applyReview keeps only the entries at the returned indices (validated, de-duped,
// ascending) and returns the kept slice plus the dropped entries in original order.
func (g *FlowGate) applyReview(buffer []queuedMsg, keep []int) (kept, dropped []queuedMsg) {
seen := make(map[int]bool, len(keep))
for _, i := range keep {
if i >= 0 && i < len(buffer) {
seen[i] = true
}
}
out := make([]queuedMsg, 0, len(buffer))
drop := make([]queuedMsg, 0)
for i, entry := range buffer {
if seen[i] {
out = append(out, entry)
} else {
drop = append(drop, entry)
}
}
return out, drop
}
// dropFront removes n entries from the front (oldest) and returns the rest plus the
// dropped ones.
func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) {
if n >= len(buffer) {
return nil, append([]queuedMsg(nil), buffer...)
}
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
}
// bufferHasSeq reports whether an entry with the given sequence is still buffered
// in the conversation's pending set.
func bufferHasSeq(buffer []queuedMsg, seq uint64) bool {
for _, e := range buffer {
if e.seq == seq {
return true
}
}
return false
}
// containsSeq reports whether seq appears among the dropped entries.
func containsSeq(dropped []queuedMsg, seq uint64) bool {
for _, e := range dropped {
if e.seq == seq {
return true
}
}
return false
}
// auditDropped records every dropped entry to the OnPruned audit hook.
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
for _, e := range dropped {
g.auditMsg(e.msg, reason)
}
}
// auditMsg records one dropped message to the OnPruned audit hook.
func (g *FlowGate) auditMsg(msg UnifiedMessage, reason string) {
if g.onPruned != nil {
g.onPruned(msg, reason)
}
}
// queueMessages extracts the payload messages of queued entries in order.
func queueMessages(queue []queuedMsg) []UnifiedMessage {
out := make([]UnifiedMessage, 0, len(queue))
for _, e := range queue {
out = append(out, e.msg)
}
return out
}