Files
AskaEth 4c9cabce1b fix(channelbridge): 窗口准入落到车道头 + lane.go 按职责拆分(M23 复审 r1 P2)
P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在
**窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样
进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话,
她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。

- `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType +
  isSystemNoticeMessage,绝不解析正文);
- `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半;
- 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos,
  这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃);
- prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时,
  仍会在组批前被切出去)。

P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行):
lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/
lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/
lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合
(34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。

顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据
(ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。

验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿;
新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
2026-10-24 21:28:26 +08:00

302 lines
13 KiB
Go
Raw Permalink 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 channelbridge
// 本文件承载**静默窗口合并**(M23)的窗口引擎:生效参数的解析、等待(滑动窗口 +
// 绝对上限)、发车原因与计数。它回答主人 2026-10-23/24 的原话「要让群聊聊天很自然,
// 很流畅的做法」——群里「一鼓作气打三句」不该让她回三次,也不该让她等很久才开口。
//
// 落点(M23 蓝图 §2 候选 C):**车道工作者头部**,也就是 `enqueueInbound` 之后、
// 回合之前。理由:车道本身就是那条会话唯一的缓冲,窗口只是「何时把队列里的东西发车」;
// 不新造第二套缓冲,也不会把「本来就会被流控丢掉的消息」拉进窗口。
//
// 三条结构保证(不是靠自觉):
//
// ① **窗口内零丢失**:消息只存在于车道队列(lane.ch / lane.spill / lane.front),
// 入队后除了车道的单消费者 takeAll 没有任何移除路径;本文件与 coalesce_*.go
// 的合并阶段没有「跳过某条」的分支(超量的尾部回插队首,见 requeueFront)。
// ② **一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复**,但批内 N 条仍是
// **各自独立的 user 行**(design/15 §2.1 明文禁止把多条拼成一条)。
// ③ **主人私聊没有窗口**:那条车道是 direct 车道(`lane.batcher == nil`),
// 连定时器工厂都拿不到(见 lane.go 的 newDirectLane / newBatchLane),
// 类型由 actor 元数据决定,**配置无权翻转**。
//
// 时间基准:窗口用**到达时刻**(Bridge.now,可注入),**不**用 msg.Timestamp ——
// qq/telegram/kook 是平台秒级时间、weixin 是本机时钟,精度与漂移都不可靠。
// 陈旧度闸仍按 M22 口径用到达时刻判,两者互不掺杂。
import (
"context"
"sync/atomic"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
const (
// coalesceLogSource 是窗口引擎的子功能日志来源(AGENTS §10:日志必须标来源)。
// 车道自身(背压/陈旧度)的来源归 M22(services/channelbridge/lane)。
coalesceLogSource = "services/channelbridge/coalesce"
// 发车原因(`flush_reason` 的闭集,进日志与计数):
// quiet —— 静默窗口到点(「她把话说完了」);
// max_wait —— 绝对上限到点(「说得没完没了也得先回一句」);
// stop —— 桥停止,未发车的批如实登记丢弃;
// all_stale —— 批内逐条都超龄(M22 口径:太旧不补答),整批不发车;
// off —— 该车道/该会话没有窗口(direct 车道,或开关关闭),到达即发车。
coalesceFlushQuiet = "quiet"
coalesceFlushMaxWait = "max_wait"
coalesceFlushStop = "stop"
coalesceFlushAllStale = "all_stale"
coalesceFlushOff = "off"
)
// batchWindow 是**只有 batch 车道**才持有的窗口状态:它持有定时器工厂。
//
// 这是 M23 最硬的一条结构约束(红线级):direct 车道(主人私聊)的类型里**没有**
// 这个字段,也没有任何能拿到它的路径 ⇒「主人私聊不会建窗口」不是 `if enabled`
// 判出来的结果,而是**类型上不可达**。配置因此无权把它翻成 batch。
type batchWindow struct {
// after 是定时器工厂(默认 time.After)。测试注入手动触发的实现,使
// 「2.0s 静默」与「6s 绝对上限」都能被确定性地推进,且能数出调用次数 ——
// TestOwnerPrivateNeverCreatesWindow / TestOwnerPrivateIgnoresEnabledConfig
// 断言的正是「这个工厂一次都没被调用」。
after func(time.Duration) <-chan time.Time
}
// coalesceWindow 是一条车道**本次发车**实际使用的窗口参数(已归一为 Duration)。
type coalesceWindow struct {
window time.Duration
maxWait time.Duration
maxBatch int
}
// CoalesceOptions 是静默窗口合并的注入面(M23)。
//
// 时钟走 Options.Now(与陈旧度闸同一条注入时钟:一片里有两个时间源必然漂移)。
type CoalesceOptions struct {
// For 按**车道键**返回该会话生效的窗口参数;nil 表示未接线(走代码默认)。
// 每次发车前重读它 ⇒ 面板保存后热生效,不需要重启后端。
//
// 这里刻意用 runtimeconfig 的策略**类型**(而不是桥内再定义一份):默认值与 clamp
// 只有一处实现(`runtimeconfig.DefaultCoalescePolicy` / `ClampCoalescePolicy`),
// 面板读到的规则与运行期真正生效的区间因此不可能分叉。桥只依赖这个**值类型**,
// 不持有 store、不做 I/O(store 由组合根注入)。
For func(laneKey string) *runtimeconfig.CoalescePolicy
// After 是定时器工厂(测试缝)。nil ⇒ time.After。
After func(time.Duration) <-chan time.Time
}
// coalesceCounters 是窗口引擎的进程内计数(原子,读侧无需锁)。
type coalesceCounters struct {
windowsCreated atomic.Int64
batchesTotal atomic.Int64
batchMessagesTotal atomic.Int64
flushQuiet atomic.Int64
flushMaxWait atomic.Int64
batchesSplit atomic.Int64
continuations atomic.Int64
coalescedDropped atomic.Int64
abandonedAtStop atomic.Int64
batchesAllStale atomic.Int64
}
// CoalesceStats 是窗口引擎的计数快照(M23 蓝图 §8)。它给进程内调用方(测试、
// 将来的运维探针)读同一组数字的窄口,**不新开 HTTP 端点**(那是契约变更,归塔)。
//
// CoalescedDroppedTotal **必须恒为 0**:合并阶段没有任何丢弃点 —— 它存在的意义
// 就是让「有人不小心加了一条 continue」有一个能被断言的出口。
type CoalesceStats struct {
// WindowsCreated 是「真正等过窗口」的批数(direct 车道与开关关闭时恒不增加)。
WindowsCreated int64
// BatchesTotal 是发过车的批数(含单条批)。
BatchesTotal int64
// BatchMessagesTotal 是这些批里的入站条数合计。
BatchMessagesTotal int64
// FlushQuietTotal / FlushMaxWaitTotal 按发车原因分开计数。
FlushQuietTotal int64
FlushMaxWaitTotal int64
// BatchesSplitTotal 是因单批上限被拆开、尾部回插队首的次数。
BatchesSplitTotal int64
// ContinuationsTotal 是「批内首条在该会话上一轮结束之前就已到达」的批数(⑤)。
ContinuationsTotal int64
// CoalescedDroppedTotal 必须恒为 0。
CoalescedDroppedTotal int64
// AbandonedAtStopTotal 是桥停止时未发车、随进程一起作废的入站条数(诚实登记)。
AbandonedAtStopTotal int64
// BatchesAllStaleTotal 是整批都超龄、因此一条都没跑的批数。
BatchesAllStaleTotal int64
}
// CoalesceStats 返回窗口引擎计数的快照。
func (b *Bridge) CoalesceStats() CoalesceStats {
c := b.coalesceStats
if c == nil {
return CoalesceStats{}
}
return CoalesceStats{
WindowsCreated: c.windowsCreated.Load(),
BatchesTotal: c.batchesTotal.Load(),
BatchMessagesTotal: c.batchMessagesTotal.Load(),
FlushQuietTotal: c.flushQuiet.Load(),
FlushMaxWaitTotal: c.flushMaxWait.Load(),
BatchesSplitTotal: c.batchesSplit.Load(),
ContinuationsTotal: c.continuations.Load(),
CoalescedDroppedTotal: c.coalescedDropped.Load(),
AbandonedAtStopTotal: c.abandonedAtStop.Load(),
BatchesAllStaleTotal: c.batchesAllStale.Load(),
}
}
// coalesceWindowFor 解析某条车道**此刻**生效的窗口:代码默认 → 注入的 store
// (面板配置文件)→ 归一/夹紧 → 按会话类型决定「这条车道要不要窗口」。
//
// 返回 ok=false 的三种情形都表示「到达即发车」(行为回到 M22):
// - 开关关闭(`enabled:false`,也是「回退到步 1 行为」的唯一旋钮);
// - 陌生人私聊且 `applyToStrangers:false`;
// - 配置算出窗口 0(归一后不会出现,防御性兜底)。
//
// 🔴 它**不**决定车道类型:主人私聊根本不进这里(direct 车道没有 batcher)。
func (b *Bridge) coalesceWindowFor(laneKey string, strangerPrivate bool) (coalesceWindow, bool) {
p := runtimeconfig.DefaultCoalescePolicy()
if b.coalesceFor != nil {
if got := b.coalesceFor(laneKey); got != nil {
p = *got
}
}
p, _ = runtimeconfig.ClampCoalescePolicy(p)
if !p.Enabled {
return coalesceWindow{}, false
}
if strangerPrivate && !p.ApplyToStrangers {
return coalesceWindow{}, false
}
windowMs := p.WindowMs
if strangerPrivate && p.StrangerWindowMs > 0 {
windowMs = p.StrangerWindowMs
}
w := coalesceWindow{
window: time.Duration(windowMs) * time.Millisecond,
maxWait: time.Duration(p.MaxWaitMs) * time.Millisecond,
maxBatch: p.MaxBatchMessages,
}
if w.window <= 0 || w.maxWait < w.window || w.maxBatch <= 0 {
return coalesceWindow{}, false
}
return w, true
}
// laneWindowAdmits 是**窗口准入的唯一判据**(M23 任务书:「窗口只收 chat/image/voice/file;
// **不收**系统通知 / action 帧 / 我们自己出站回执」)。
//
// 它只读消息自身的**结构化事实** —— ContentType 与「是不是平台帧」(isSystemNoticeMessage
// 同样只按 ContentType 判定),**绝不解析正文**、绝不猜语义。拒绝的两类理由:
//
// - **平台帧 / 系统通知 / 我们自己的 `message_sent` 回执**(ContentType=action):延迟
// 2s 只为合并它们毫无收益,更糟的是**一条回执就能把窗口一直续住**(它们和群聊共用
// 同一条车道键);
// - 其余非聊天类型:本来就没有「等她把话说完」的语义。
//
// 不进窗口的条目**不是被丢弃**:它们走「到达即发车」的 solo 路径(见 coalesce_lane.go
// 的 runBatchLaneItem / coalesceWait 的 solos)。
func laneWindowAdmits(msg adapter.UnifiedMessage) bool {
if isSystemNoticeMessage(msg) {
return false
}
switch msg.ContentType {
case adapter.ContentText, adapter.ContentImage, adapter.ContentVoice, adapter.ContentFile:
return true
default:
return false
}
}
// splitWindowItems 把一片条目按**窗口准入**分成两半:进窗口的(保持原序)与不进窗口的
// (同样保持原序)。两半都会被处理 —— 这是分类,不是剔除。
func splitWindowItems(items []laneItem) (admitted, others []laneItem) {
for _, item := range items {
if laneWindowAdmits(item.msg) {
admitted = append(admitted, item)
continue
}
others = append(others, item)
}
return admitted, others
}
// coalesceWait 在车道头部等一段静默期,把等待期间到达的**可进窗口**的消息并进这一批,
// 并返回(这一批、不进窗口的条目、发车原因)。
//
// 语义(M23 蓝图 §4):
// - **滑动**:新消息把计时重置到 w.window(「等她把话说完」);
// - **绝对上限**:w.maxWait 锚定在批内**第一条**的到达时刻 —— 一个不停说话的群
// 因此不会让计时永不归零(宁可晚回,不许不回);
// - **准入**:只有 `laneWindowAdmits` 为真的条目参与攒批与重置计时;通知 / action 帧 /
// 出站回执只被**收进 solos**(原序),既不改计时也不进批 —— 否则「一条我们自己的
// 回执把窗口续住」就是生产上的真实现场。
//
// 它只做「等」与「收」:不判内容、不丢弃、不改顺序(收进来的顺序就是到达顺序:
// 车道 FIFO ⇒ takeAll 的切片本身有序)。
func (b *Bridge) coalesceWait(ctx context.Context, l *lane, held []laneItem, w coalesceWindow) (items, solos []laneItem, reason string) {
items = held
anchor := items[0].arrivedAt
lastArrival := items[len(items)-1].arrivedAt
b.coalesceStats.windowsCreated.Add(1)
for {
now := b.now()
// 绝对上限先判:到点必发车(哪怕静默窗口因为不停有新消息而一直没归零)。
maxLeft := w.maxWait - now.Sub(anchor)
if maxLeft <= 0 {
return items, solos, coalesceFlushMaxWait
}
quietLeft := w.window - now.Sub(lastArrival)
if quietLeft <= 0 {
return items, solos, coalesceFlushQuiet
}
wait := quietLeft
if maxLeft < wait {
wait = maxLeft
}
select {
case <-l.batcher.after(wait):
seen, others := splitWindowItems(b.takeAll(l))
solos = append(solos, others...)
if len(seen) == 0 {
// 没有新的**聊天**消息:到点即发车,原因由「哪条上限先到」决定
// (不进窗口的帧不推迟这个判断)。
if w.maxWait-b.now().Sub(anchor) <= w.window-b.now().Sub(lastArrival) {
return items, solos, coalesceFlushMaxWait
}
return items, solos, coalesceFlushQuiet
}
items = append(items, seen...)
lastArrival = items[len(items)-1].arrivedAt
case item := <-l.ch:
if !laneWindowAdmits(item.msg) {
// 不进窗口:只登记到 solos,**绝不重置计时**。
solos = append(solos, item)
continue
}
items = append(items, item)
seen, others := splitWindowItems(b.takeAll(l))
items = append(items, seen...)
solos = append(solos, others...)
lastArrival = items[len(items)-1].arrivedAt
case <-ctx.Done():
return items, solos, coalesceFlushStop
}
}
}
// noteFlushReason 把发车原因记进对应的计数(未知原因不计数:宁可少一个数,
// 也不要把它归到一个「看起来像」的桶里)。
func (b *Bridge) noteFlushReason(reason string) {
switch reason {
case coalesceFlushQuiet:
b.coalesceStats.flushQuiet.Add(1)
case coalesceFlushMaxWait:
b.coalesceStats.flushMaxWait.Add(1)
case coalesceFlushAllStale:
b.coalesceStats.batchesAllStale.Add(1)
}
}