Files
Cyrene_v2/internal/services/channelbridge/coalesce_fixture_test.go
T
AskaEth ca8577f05b 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/。
2026-10-24 21:11:22 +08:00

282 lines
9.7 KiB
Go
Raw 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
// 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)
}