feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)

主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
This commit is contained in:
AskaEth committed 2026-10-24 21:11:22 +08:00
1 parent 679c8abf1d
commit ca8577f05b
21 files changed
+3616 -244

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
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/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
}
// coalesceWait 在车道头部等一段静默期,把等待期间到达的消息**全部**并进这一批,
// 并返回发车原因。
//
// 语义(M23 蓝图 §4):
// - **滑动**:新消息把计时重置到 w.window(「等她把话说完」);
// - **绝对上限**:w.maxWait 锚定在批内**第一条**的到达时刻 —— 一个不停说话的群
// 因此不会让计时永不归零(宁可晚回,不许不回)。
//
// 它只做「等」与「收」:不判内容、不丢弃、不改顺序(收进来的顺序就是到达顺序:
// 车道 FIFO ⇒ takeAll 的切片本身有序)。
func (b *Bridge) coalesceWait(ctx context.Context, l *lane, held []laneItem, w coalesceWindow) ([]laneItem, 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, coalesceFlushMaxWait
}
quietLeft := w.window - now.Sub(lastArrival)
if quietLeft <= 0 {
return items, coalesceFlushQuiet
}
wait := quietLeft
if maxLeft < wait {
wait = maxLeft
}
select {
case <-l.batcher.after(wait):
more := b.takeAll(l)
if len(more) == 0 {
// 没有新消息:到点即发车,原因由「哪条上限先到」决定。
if w.maxWait-b.now().Sub(anchor) <= w.window-b.now().Sub(lastArrival) {
return items, coalesceFlushMaxWait
}
return items, coalesceFlushQuiet
}
items = append(items, more...)
lastArrival = items[len(items)-1].arrivedAt
case item := <-l.ch:
items = append(items, item)
if more := b.takeAll(l); len(more) > 0 {
items = append(items, more...)
}
lastArrival = items[len(items)-1].arrivedAt
case <-ctx.Done():
return items, 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)
}
}