feat(channels): 陌生人私聊正常聊天(跳概率门)+ 强制流控 6条/分与回合槽位 + 安静时段不再硬静默 + 受限车道边界行前置
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合
- 被限流只登记不回,且留带 source 的可见告警(不静默)
- 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默
- 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
This commit is contained in:
AskaEth committed 2026-10-05 13:26:15 +08:00
1 parent c6f5c3fea0
commit 7ae4298871
10 files changed
+654 -118

No files matched your search

+101 -20
View File
@@ -44,6 +44,13 @@ 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
@@ -76,6 +83,12 @@ const (
// 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
@@ -113,16 +126,22 @@ func ConversationKey(msg UnifiedMessage) string {
// 每个会话保存一份,因此两个群并发互不挤占(裁决 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). Over-limit messages are dropped (downgraded) rather
// than blocking a flood of messages from saturating the loop.
// 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.
@@ -131,15 +150,18 @@ type flowSession struct {
//
// 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
msgPerMin int
maxMessages int
reviewAt int
window time.Duration
review Reviewer
onPruned func(UnifiedMessage, string)
now func() time.Time
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
@@ -160,14 +182,15 @@ type queuedMsg struct {
// except Window which defaults to one minute when left zero.
func NewFlowGate(opts FlowGateOptions) *FlowGate {
g := &FlowGate{
groupPerMin: opts.GroupMsgPerMin,
msgPerMin: opts.MsgPerMin,
maxMessages: opts.MaxMessages,
window: opts.Window,
review: opts.Review,
onPruned: opts.OnPruned,
now: opts.Now,
sessions: make(map[string]*flowSession),
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
@@ -258,6 +281,61 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
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
}
// 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 {
@@ -281,7 +359,8 @@ func (g *FlowGate) sessionLocked(key string) *flowSession {
}
// maybeGCLocked 每 sessionGCInterval 次放行清理一次空闲会话,避免会话状态表随历史会话
// 无界增长:滑窗时间戳全部过期且待处理队列为空的会话直接删除。调用方须持有 g.mu。
// 无界增长:滑窗时间戳全部过期、待处理队列为空、且没有回合在跑的会话直接删除。
// 调用方须持有 g.mu。
func (g *FlowGate) maybeGCLocked(now time.Time) {
g.admitted++
if g.admitted%sessionGCInterval != 0 {
@@ -289,7 +368,9 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
}
for key, st := range g.sessions {
st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) == 0 && len(st.buffer) == 0 {
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)
}
}