主人 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/。
282 lines
9.7 KiB
Go
282 lines
9.7 KiB
Go
package channelbridge
|
||
|
||
// coalesce_fixture_test.go 是 M23(静默窗口合并 + 回合中续跑)全部用例共用的夹具:
|
||
//
|
||
// - **注入时钟**(laneClock):窗口与陈旧度共用同一个时间源(Options.Now),
|
||
// 因此「2.0s 静默」「6s 绝对上限」「30s 超龄」都能被确定性推进;
|
||
// - **注入定时器工厂**(coalesceTimers):它既让窗口等待可以被手动触发,
|
||
// **又是「主人私聊从未建窗口」的证据面** —— calls 必须为 0;
|
||
// - **群消息夹具**:非主人、带提及(⇒ 必然进回合)、同一群共用一个隔离会话。
|
||
//
|
||
// 夹具全部落在 *_test.go 里,不进生产二进制。
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// coalesceOffFor 是「把静默窗口关掉」的注入面:lane_test.go 的 M22 语义用例用它,
|
||
// 使那些用例仍以毫秒级钉住车道本身(顺序/串行/背压/陈旧度)。
|
||
func coalesceOffFor(string) *runtimeconfig.CoalescePolicy {
|
||
p := runtimeconfig.DefaultCoalescePolicy()
|
||
p.Enabled = false
|
||
return &p
|
||
}
|
||
|
||
// coalesceOn 返回一组「群聊与陌生人私聊都开窗口」的生效参数。
|
||
func coalesceOn(windowMs, maxWaitMs, maxBatch int) runtimeconfig.CoalescePolicy {
|
||
p := runtimeconfig.DefaultCoalescePolicy()
|
||
p.Enabled = true
|
||
p.WindowMs = windowMs
|
||
p.MaxWaitMs = maxWaitMs
|
||
p.MaxBatchMessages = maxBatch
|
||
p.ApplyToStrangers = true
|
||
p.StrangerWindowMs = windowMs
|
||
return p
|
||
}
|
||
|
||
// coalesceFor 把一组固定参数包成注入面(同一实例可被多条车道共用)。
|
||
func coalesceFor(p runtimeconfig.CoalescePolicy) func(string) *runtimeconfig.CoalescePolicy {
|
||
return func(string) *runtimeconfig.CoalescePolicy {
|
||
c := p
|
||
return &c
|
||
}
|
||
}
|
||
|
||
// newCoalesceClock 返回一个**本地正午**的注入时钟。
|
||
//
|
||
// 起点刻意避开免打扰时段(23-07):本文件测的是窗口合并,而凌晨的注入时钟会让
|
||
// 非主人群消息先在「免打扰」那一档被静默掉 —— 那样每条用例都变成了空断言
|
||
// (测试要观察的只是「什么时候发车」,不是「说不说」)。
|
||
func newCoalesceClock() *laneClock {
|
||
c := &laneClock{}
|
||
c.t = time.Date(2026, 5, 1, 12, 0, 0, 0, time.Local)
|
||
return c
|
||
}
|
||
|
||
// coalesceTimers 是注入的定时器工厂:记录每次请求的时长,交回一个由测试手动触发的
|
||
// 通道(带缓冲,测试的发送永远不会阻塞)。
|
||
//
|
||
// 🔴 它同时是红线用例的证据面:主人私聊**一次都不该**调用它
|
||
// (TestOwnerPrivateNeverCreatesWindow 断言 calls==0)。
|
||
type coalesceTimers struct {
|
||
mu sync.Mutex
|
||
calls []time.Duration
|
||
pending []coalesceTimer
|
||
}
|
||
|
||
// coalesceTimer 是一次定时器请求:通道 + 当时请求的时长(测试按它推进注入时钟,
|
||
// 因此「到点」与「请求的时长」永远一致,不存在测试把时钟推多了的伪影)。
|
||
type coalesceTimer struct {
|
||
ch chan time.Time
|
||
d time.Duration
|
||
}
|
||
|
||
func (t *coalesceTimers) after(d time.Duration) <-chan time.Time {
|
||
ch := make(chan time.Time, 1)
|
||
t.mu.Lock()
|
||
t.calls = append(t.calls, d)
|
||
t.pending = append(t.pending, coalesceTimer{ch: ch, d: d})
|
||
t.mu.Unlock()
|
||
return ch
|
||
}
|
||
|
||
// callCount 返回定时器被请求的总次数。
|
||
func (t *coalesceTimers) callCount() int {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
return len(t.calls)
|
||
}
|
||
|
||
// durations 返回每次请求的时长(按请求顺序)。
|
||
func (t *coalesceTimers) durations() []time.Duration {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
return append([]time.Duration(nil), t.calls...)
|
||
}
|
||
|
||
// take 取走**最早一个尚未触发**的定时器(等它有请求为止)。
|
||
func (t *coalesceTimers) take(tb *testing.T, timeout time.Duration) coalesceTimer {
|
||
tb.Helper()
|
||
deadline := time.Now().Add(timeout)
|
||
for time.Now().Before(deadline) {
|
||
t.mu.Lock()
|
||
if len(t.pending) > 0 {
|
||
timer := t.pending[0]
|
||
t.pending = t.pending[1:]
|
||
t.mu.Unlock()
|
||
return timer
|
||
}
|
||
t.mu.Unlock()
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
tb.Fatalf("no coalesce timer registered within %s (calls=%d)", timeout, t.callCount())
|
||
return coalesceTimer{}
|
||
}
|
||
|
||
// advanceAndFire 按**该定时器请求的时长**推进注入时钟,然后触发它
|
||
// (= 「静默窗口正好到点」)。
|
||
func (t *coalesceTimers) advanceAndFire(tb *testing.T, clock *laneClock) {
|
||
tb.Helper()
|
||
timer := t.take(tb, 3*time.Second)
|
||
clock.advance(timer.d)
|
||
timer.ch <- clock.Now()
|
||
}
|
||
|
||
// tryTake 非阻塞地取走最早一个尚未触发的定时器(没有就返回 false)。
|
||
func (t *coalesceTimers) tryTake() (coalesceTimer, bool) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
if len(t.pending) == 0 {
|
||
return coalesceTimer{}, false
|
||
}
|
||
timer := t.pending[0]
|
||
t.pending = t.pending[1:]
|
||
return timer, true
|
||
}
|
||
|
||
// pump 反复「有定时器就按它请求的时长推进时钟并触发」,直到 done() 成立(或 3s 超时)。
|
||
//
|
||
// 为什么不是「按次数触发」:窗口到达的方式有两种 —— 静默到点(要定时器)与**绝对上限
|
||
// 已过**(循环头直接发车,**根本不建定时器**)。测试若假定「一定会有定时器」,
|
||
// 就会被调度时序(worker 什么时候启动)绊倒;pump 因此只看**结果条件**。
|
||
func (f *coalesceBridge) pump(t *testing.T, done func() bool) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if done() {
|
||
return
|
||
}
|
||
if timer, ok := f.timers.tryTake(); ok {
|
||
f.clock.advance(timer.d)
|
||
timer.ch <- f.clock.Now()
|
||
continue
|
||
}
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
t.Fatalf("condition never met within 3s (runs=%d, stats=%+v)", f.chat.runCount(), f.b.CoalesceStats())
|
||
}
|
||
|
||
// coalesceGroupSessions 给**每个群**一个稳定的隔离会话:同一群的多个成员 = 同一个会话
|
||
// (真实 SessionManager 就是这么派生的),因此「合成一轮」的落库行落在同一处。
|
||
type coalesceGroupSessions struct{}
|
||
|
||
func (coalesceGroupSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, _ *user.OwnerProfile) (session.Session, error) {
|
||
return session.Session{
|
||
Platform: msg.Platform,
|
||
ChannelID: msg.ChannelID,
|
||
SessionID: "sess-" + msg.GroupID,
|
||
IsGroup: true,
|
||
MemWritable: false,
|
||
}, nil
|
||
}
|
||
|
||
// coalesceGroupMsg 是一条**非主人、指向她本人**的群消息:它因此必然进回合
|
||
// (不受最小打扰分级影响),是静默窗口最干净的夹具。
|
||
func coalesceGroupMsg(platform, groupID, sender, senderName, content string) adapter.UnifiedMessage {
|
||
return adapter.UnifiedMessage{
|
||
Platform: platform,
|
||
ChannelID: groupID,
|
||
ChannelType: adapter.ChannelGroup,
|
||
GroupID: groupID,
|
||
SenderID: sender,
|
||
SenderName: senderName,
|
||
Sender: adapter.Actor{Role: adapter.RoleMember},
|
||
Mentions: []adapter.Mention{{UserID: sender}},
|
||
Content: content,
|
||
ContentType: adapter.ContentText,
|
||
}
|
||
}
|
||
|
||
// coalesceBridge 是一个装好注入时钟与定时器工厂的桥接器(外加它的日志/历史/审计面)。
|
||
type coalesceBridge struct {
|
||
b *Bridge
|
||
chat *laneChatter
|
||
ad *laneAdapter
|
||
clock *laneClock
|
||
timers *coalesceTimers
|
||
}
|
||
|
||
// newCoalesceBridge 构造夹具:opts.Loop 必须是 *laneChatter(用例要读回合记录),
|
||
// 时钟与定时器工厂由夹具注入(opts.Now / opts.Coalesce.After 若已给出则不覆盖)。
|
||
func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge {
|
||
t.Helper()
|
||
chat, ok := opts.Loop.(*laneChatter)
|
||
if !ok {
|
||
t.Fatalf("newCoalesceBridge needs a *laneChatter loop, got %T", opts.Loop)
|
||
}
|
||
clock := newCoalesceClock()
|
||
timers := &coalesceTimers{}
|
||
if opts.Now == nil {
|
||
opts.Now = clock.Now
|
||
}
|
||
opts.Coalesce.After = timers.after
|
||
if opts.Sessions == nil {
|
||
opts.Sessions = coalesceGroupSessions{}
|
||
}
|
||
if opts.Owner == nil {
|
||
opts.Owner = &user.OwnerProfile{}
|
||
}
|
||
b, err := New(opts)
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
ad := &laneAdapter{name: "telegram"}
|
||
b.Add(ad)
|
||
return &coalesceBridge{b: b, chat: chat, ad: ad, clock: clock, timers: timers}
|
||
}
|
||
|
||
// snapshot 在锁内返回某个会话已落库的行(测试直接读 h.rows 会与车道 worker 的
|
||
// 写入构成数据竞争 —— -race 下必红,因此统一走这里)。
|
||
func (h *laneHistory) snapshot(sessionID string) []history.Message {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
return append([]history.Message(nil), h.rows[sessionID]...)
|
||
}
|
||
|
||
// send 投递一条消息(走真实的 onMessage 入口:Validate → 流控闸 → 车道)。
|
||
//
|
||
// ctx 取 b.ctx(与生产的读循环一致:`readLoop` 传的就是桥自己的生命周期 context),
|
||
// 这样 Stop 取消的车道 worker 与用例投递的消息用的是同一棵树 —— 否则「停机诚实」
|
||
// 这条用例根本验证不到真实路径(b.ctx 为 nil 时退回 Background)。
|
||
func (f *coalesceBridge) send(msg adapter.UnifiedMessage) {
|
||
ctx := f.b.ctx
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
f.b.onMessage(ctx, msg, f.ad)
|
||
}
|
||
|
||
// userTextsIn 返回一次回合的模型可见消息里的 user 文本(按顺序)。
|
||
func userTextsIn(msgs []model.Message) []string {
|
||
out := make([]string, 0, len(msgs))
|
||
for _, m := range msgs {
|
||
if m.Role != model.RoleUser {
|
||
continue
|
||
}
|
||
out = append(out, lastText(m))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// waitRuns 等至少 n 个回合开跑(封装 chatter 的同名方法,省得到处传超时)。
|
||
func (f *coalesceBridge) waitRuns(t *testing.T, n int) {
|
||
t.Helper()
|
||
f.chat.waitRuns(t, n, 3*time.Second)
|
||
}
|
||
|
||
// waitIdle 等到车道全部退休。
|
||
func (f *coalesceBridge) waitIdle(t *testing.T) {
|
||
t.Helper()
|
||
waitLaneIdle(t, f.b, 2*time.Second)
|
||
}
|