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

@@ -264,8 +264,17 @@ func ownerMsg(content string) adapter.UnifiedMessage {
return m
}
// newLaneBridge 构造一个**只跑 M22 车道语义**的桥接器:本文件(lane_test.go)钉的是
// 车道本身(顺序/串行/背压/陈旧度),因此默认把 M23 的静默窗口**关掉** ——
// 开着窗口时「2s 静默」会让这几条用例从毫秒级变成秒级,并且把「陈旧度何时判」的
// 时间口径搅在一起。窗口/合并/续跑自己的断言都在 coalesce_test.go。
//
// 需要窗口的用例显式传 Options.Coalesce(见 newCoalesceBridge)。
func newLaneBridge(t *testing.T, opts Options, ad *laneAdapter) *Bridge {
t.Helper()
if opts.Coalesce.For == nil {
opts.Coalesce.For = coalesceOffFor
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)