主人 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/。
137 lines
5.3 KiB
Go
137 lines
5.3 KiB
Go
package channelbridge
|
||
|
||
// coalesce_owner_test.go 是 M23 的**红线**断言集:主人私聊必须**结构性**地没有静默窗口。
|
||
//
|
||
// 断言的层次(塔 2026-10-24 要求「断言状态,不是断言结果」):
|
||
//
|
||
// ① `timers.callCount() == 0` —— 定时器工厂一次都没被调用(窗口**从未被创建**);
|
||
// ② `lane.batcher == nil` —— 那条车道的类型里根本没有窗口状态;
|
||
// ③ `CoalesceStats().WindowsCreated == 0` —— 计数面也证明没有窗口;
|
||
// ④ 消息立刻被回答(没有 2s 迟滞)。
|
||
//
|
||
// 变异检验(塔说会亲自做):把车道类型判定(ownerPrivateLane)改成「主人私聊也走
|
||
// batch 车道」⇒ ①②③ 立刻红。
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// laneForTest 在锁内取一条车道(测试用;车道可能已退休 ⇒ nil)。
|
||
func laneForTest(b *Bridge, key string) *lane {
|
||
b.lanes.mu.Lock()
|
||
defer b.lanes.mu.Unlock()
|
||
return b.lanes.lanes[key]
|
||
}
|
||
|
||
// sent 返回该适配器已经送出去的回复(并发安全)。
|
||
func (a *laneAdapter) sent() []adapter.UnifiedMessage {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
return append([]adapter.UnifiedMessage(nil), a.sends...)
|
||
}
|
||
|
||
// ownerPrivateBridge 构造一个「主人私聊」夹具:主人白名单 telegram/100,会话层报
|
||
// Owner=true(与生产 SessionManager 的派生一致)。
|
||
func ownerPrivateBridge(t *testing.T, chat *laneChatter, p func(string) *runtimeconfig.CoalescePolicy) *coalesceBridge {
|
||
t.Helper()
|
||
return newCoalesceBridge(t, Options{
|
||
Loop: chat,
|
||
Sessions: &laneSessions{owner: true},
|
||
Owner: testOwnerProfile("telegram", "100"),
|
||
Coalesce: CoalesceOptions{For: p},
|
||
})
|
||
}
|
||
|
||
// assertNoTimer 在「等第一轮开跑」的同时盯住定时器工厂:一旦它被调用就立刻失败
|
||
// (变异体的**第一种红**正是这里:主人私聊被塞进 batch 车道 ⇒ 窗口被建出来)。
|
||
func assertNoTimer(t *testing.T, f *coalesceBridge, chat *laneChatter) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(300 * time.Millisecond)
|
||
for time.Now().Before(deadline) {
|
||
if calls := f.timers.callCount(); calls != 0 {
|
||
t.Fatalf("coalesce timer factory called %d times for owner private traffic, want 0", calls)
|
||
}
|
||
if chat.runCount() > 0 {
|
||
return
|
||
}
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
}
|
||
|
||
// TestOwnerPrivateNeverCreatesWindow 断言红线:主人私聊**不建窗口、不建定时器**,
|
||
// 且消息立刻被回答。
|
||
func TestOwnerPrivateNeverCreatesWindow(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
gate := make(chan struct{})
|
||
chat := &laneChatter{}
|
||
chat.onRun = func(string) { <-gate }
|
||
|
||
f := ownerPrivateBridge(t, chat, coalesceFor(coalesceOn(2000, 6000, 24)))
|
||
f.send(ownerMsg("主人说话"))
|
||
assertNoTimer(t, f, chat)
|
||
chat.waitRuns(t, 1, 3*time.Second)
|
||
|
||
// 车道还在(回合卡住)⇒ 这一刻的类型判定是可观察的。
|
||
if l := laneForTest(f.b, ownerPrivateLaneKey); l == nil {
|
||
t.Fatalf("owner private lane %q is missing while its turn is running", ownerPrivateLaneKey)
|
||
} else if l.batcher != nil {
|
||
t.Fatalf("owner private lane has a batch window (%+v): the type must be direct", l.batcher)
|
||
}
|
||
if calls := f.timers.callCount(); calls != 0 {
|
||
t.Fatalf("coalesce timer factory called %d times for owner private traffic, want 0", calls)
|
||
}
|
||
if stats := f.b.CoalesceStats(); stats.WindowsCreated != 0 {
|
||
t.Fatalf("windows_created = %d for owner private traffic, want 0", stats.WindowsCreated)
|
||
}
|
||
close(gate)
|
||
f.waitIdle(t)
|
||
if got := f.ad.sent(); len(got) != 1 {
|
||
t.Fatalf("replies = %d, want 1 (owner private must be answered immediately)", len(got))
|
||
}
|
||
}
|
||
|
||
// TestOwnerPrivateIgnoresEnabledConfig 断言「配置无权翻转车道类型」:把配置开到最激进
|
||
// (enabled / applyToStrangers 全开、窗口 10s),主人私聊仍然没有定时器、没有窗口;
|
||
// 而同一条配置对**群聊**是生效的(同一个定时器工厂会被调用)—— 后者是这条用例的
|
||
// **对照组**,用来证明 calls==0 不是「工厂没接上」这种伪结论。
|
||
func TestOwnerPrivateIgnoresEnabledConfig(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
aggressive := coalesceOn(10000, 30000, 24)
|
||
aggressive.Enabled = true
|
||
aggressive.ApplyToStrangers = true
|
||
|
||
gate := make(chan struct{})
|
||
chat := &laneChatter{}
|
||
chat.onRun = func(string) { <-gate }
|
||
f := ownerPrivateBridge(t, chat, coalesceFor(aggressive))
|
||
|
||
f.send(ownerMsg("主人私聊"))
|
||
assertNoTimer(t, f, chat)
|
||
chat.waitRuns(t, 1, 3*time.Second)
|
||
if calls := f.timers.callCount(); calls != 0 {
|
||
t.Fatalf("timer factory called %d times with an aggressive config, want 0", calls)
|
||
}
|
||
if l := laneForTest(f.b, ownerPrivateLaneKey); l == nil || l.batcher != nil {
|
||
t.Fatalf("owner private lane = %+v, want a direct lane (batcher == nil)", l)
|
||
}
|
||
|
||
// 对照组:同一条配置对群聊**生效**(定时器被请求并按点到发车)。
|
||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "群里的消息"))
|
||
f.timers.advanceAndFire(t, f.clock)
|
||
close(gate)
|
||
chat.waitRuns(t, 2, 3*time.Second)
|
||
f.waitIdle(t)
|
||
if calls := f.timers.callCount(); calls == 0 {
|
||
t.Fatal("control group: group traffic created no timer, so the fixture proves nothing")
|
||
}
|
||
if stats := f.b.CoalesceStats(); stats.WindowsCreated != 1 {
|
||
t.Fatalf("windows_created = %d, want 1 (only the group batch waited)", stats.WindowsCreated)
|
||
}
|
||
}
|