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

+62 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
@@ -114,6 +115,12 @@ func (a *app) wireChannels() error {
ownerLane := runtimeconfig.NewOwnerLaneStore(
runtimeconfig.DefaultOwnerLanePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/ownerlane"))
// 静默窗口合并(M23)的落盘面:$DATA_DIR/config/coalesce.json(零新 env 键,
// design/13 §2.1)。它只承载**调优**(窗口/绝对上限/单批上限/给不给陌生人),
// **不承载身份**,也**翻不开**主人私聊的 direct 车道(那是 actor 元数据决定的类型)。
coalesceStore := runtimeconfig.NewCoalesceStore(
runtimeconfig.DefaultCoalescePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/coalesce"))
bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop,
@@ -168,6 +175,15 @@ func (a *app) wireChannels() error {
MinReplyGapSec: p.MinReplyGapSec,
})
},
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数。
// 车道类型不在这里决定 —— 主人私聊恒为 direct 车道(无窗口),由 actor 元数据
// 判定,配置无权翻转;这里只给 batch 车道(群聊/陌生人私聊)的窗口取值。
Coalesce: channelbridge.CoalesceOptions{
For: func(laneKey string) *runtimeconfig.CoalescePolicy {
p := coalesceStore.PolicyFor(laneKey)
return &p
},
},
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
Factory: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
@@ -207,6 +223,7 @@ func (a *app) wireChannels() error {
fallback: factory.fallback,
bridge: bridge,
owner: a.owner,
coalesce: coalesceStore,
log: a.log,
}
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
@@ -227,11 +244,55 @@ type runtimeChannels struct {
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile
log *slog.Logger
// coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
coalesce *runtimeconfig.CoalesceStore
log *slog.Logger
}
func (r *runtimeChannels) Path() string { return r.path }
// CoalescePath 返回静默窗口配置文件(coalesce.json)的绝对落点,供面板如实回显。
func (r *runtimeChannels) CoalescePath() string {
if r.coalesce == nil {
return ""
}
return r.coalesce.Path()
}
// CoalescePresent 报告静默窗口配置文件是否已存在(面板据此显示「还没配置过」)。
func (r *runtimeChannels) CoalescePresent() bool {
if r.coalesce == nil {
return false
}
return r.coalesce.Present()
}
// CoalesceSnapshot 返回盘上那份静默窗口配置(未 clamp 的原值),供面板回显。
func (r *runtimeChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
if r.coalesce == nil {
return runtimeconfig.CoalesceFile{}
}
return r.coalesce.Snapshot()
}
// ApplyCoalesce 应用一份三态补丁:内存与落盘在同一个临界区,桥下一次发车即读到新值
// (store 与 bridge 是**同一个**实例,因此不需要任何重启或重载)。
func (r *runtimeChannels) ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error) {
if r.coalesce == nil {
return false, errors.New("channel coalesce config store not wired")
}
wrote, err := r.coalesce.Apply(patch)
if err != nil {
return wrote, fmt.Errorf("apply coalesce patch: %w", err)
}
if wrote {
r.log.Info("channel coalesce config saved (hot: the bridge reads it on the next departure)",
"path", r.CoalescePath())
}
return wrote, nil
}
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
accounts := r.loadAccounts()
r.applyAdminIDs(accounts)