Files
Cyrene_v2/internal/services/channelbridge/events_test.go
T
AskaEth afe9afb1b5 feat(events): 渠道事件桥接接线 + 组合根(design/26 E3+E4)
- E3:channelbridge/events.go 唯一插入点 recordEvent(两轴映射 Category→Tier、摘要恒过 SanitizeNoticeText、角色/账号只认白名单)+ Options.EventStore 注入
  - 未接线 store=nil ⇒ 内存计数 + 首次 DEBUG,绝不中断入站(变异实测:改成直接调 Record ⇒ panic,测试红)
  - 普通聊天消息零副作用(变异:去掉 Applicable 早退 ⇒ rows=1 红)
  - T1 遥测只计数不落行(变异:心跳误判 T2 ⇒ 「逐条落行 1 行,want 0」红)
- E4:wire_channel_events.go(启动期清扫 + 双 ticker + 退出 flush,逐字对齐 wire_document_gc 的三条取舍)、wire_channels 注入、main.go 加一行 startChannelEvents
- internal/config/duration_env.go:新 env 键的读取口径(进程环境 > .env > 默认;「没写」与「写了空」都当没配;非法值回落 + 报错)——避免「只写在 .env 里被静默忽略」
- 三个新 env 键:CHANNEL_EVENT_RETENTION / GC_INTERVAL / COUNTER_FLUSH(模板同批更新,测试钉住键名一致)
- 插入点比设计示意图晚一步(放 preEntryReview 之后,只归一化一次且 NoticeClass 已有结论),「留存无条件、在任何早退之前」这条性质不变
2026-10-05 14:10:24 +08:00

317 lines
12 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
// 本文件是**渠道事件留存接线**的验收(design/26 §4/§5;切片 E3):唯一插入点
// `recordEvent` 的三条要件各有用例 ——
//
// 1. **T1 只计数、不进上下文**(§4.3/§3.1);
// 2. **unknown 不进上下文但逐条落行**(§3.7「落库 + 计数 + 绝不进上下文」);
// 3. **未接线 / 落库失败时入站照常**(§4.3 的 fail-visible 降级,绝不中断入站)。
//
// 另加一道红线钉:普通聊天消息在留存面**零副作用**(主消息路径不受影响)。
// 两轴映射(类别 → 留存档)与「摘要必须净化、绝不夹带平台原文」另在 events_mapping_test.go。
//
// 行/计数的判据本身归 E1(`internal/runtime/events` 的 persistsAsRow 与计数缓冲),
// 这里刻意用**真的 SQLite 存储**(内存库 + 迁移 0021)做端到端断言,而不是假存储:
// 否则「T1 不落行」「unknown 落行」会退化成「我们调了 Record」这种同义反复。
import (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"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/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// newTestEventStore 打开一个带迁移 0021 的内存库并构造事件存储(E1 的 SQLStore)。
// GC 关闭(保留期与间隔都为 0):本文件验的是写入面,清扫归 E1 自己的用例。
func newTestEventStore(t *testing.T) *events.SQLStore {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("打开内存库: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.MigrateUp(ctx, conn, nil); err != nil {
t.Fatalf("内存库跑迁移: %v", err)
}
store, err := events.NewStore(events.Config{DB: conn, GCInterval: 0, Retention: 0})
if err != nil {
t.Fatalf("events.NewStore: %v", err)
}
return store
}
// eventBridgeFixture 组装一条「系统事件入站」所需的桥:受限群会话 + 捕获历史 +
// 记录调用的循环 + 给定的留存面(nil = 未接线)+ 捕获到 buffer 的 DEBUG 日志
// (用它断言日志带 source、且降级只记一条 DEBUG)。
func eventBridgeFixture(t *testing.T, ad adapter.PlatformAdapter, store events.Store) (*Bridge, *stubChatter, *captureHistory, *bytes.Buffer) {
t.Helper()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g1", IsGroup: true}}
hist := &captureHistory{}
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
b, err := New(Options{
Log: log, Loop: chat, Sessions: sess, History: hist,
Owner: testOwnerProfile("qq-obv11", "1000"), EventStore: store,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, chat, hist, &buf
}
// eventAdapter 是「按给定归一化结果回答」的最小适配器:它复用 notice_test.go 的
// noticeAdapter,只多一层固定适配器名(多账号命名口径:`platform:confID`)。
func eventAdapter(name string, notice adapter.SystemNotice) *noticeAdapter {
return &noticeAdapter{fakeAdapter: fakeAdapter{name: name}, notice: notice, ok: true}
}
// failingEventStore 是**一直接着失败**的留存面(库不可用 / 表缺失):用于钉住
// 「落库失败只降级,绝不中断入站」(§4.3)。计数不加锁:本文件的调用都在测试
// goroutine 上同步发生。
type failingEventStore struct {
records int
flushes int
}
func (f *failingEventStore) Record(context.Context, events.Event) error {
f.records++
return errors.New("storage unavailable")
}
func (f *failingEventStore) Count(context.Context, events.Filter) (int64, error) { return 0, nil }
func (f *failingEventStore) List(context.Context, events.Filter) ([]events.Event, error) {
return nil, nil
}
func (f *failingEventStore) Counters(context.Context, string) ([]events.Counter, error) {
return nil, nil
}
func (f *failingEventStore) Flush(context.Context) error {
f.flushes++
return nil
}
func (f *failingEventStore) Sweep(context.Context) (events.SweepStats, error) {
return events.SweepStats{}, nil
}
// TestRecordEvent_T1只计数不进上下文_正常:心跳是典型的 T1 遥测 —— 它必须**进计数**
// (「上游到底推没推」的唯一答案),但**绝不**逐条落行(每 ~15s 一帧会把库写胖,§4.3),
// 也绝不进她的上下文。用例让心跳带着 `SelfRelated=true`(真实心跳帧帧都带 self_id):
// 它同时把「帧级遥测不因 self 命中升档」这条取舍钉在这里。
func TestRecordEvent_T1只计数不进上下文_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestEventStore(t)
ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{
Kind: adapter.NoticeOther,
Category: adapter.NoticeCategoryHeartbeat,
PlatformType: "heartbeat",
Ignorable: true,
SelfRelated: true,
})
b, chat, hist, _ := eventBridgeFixture(t, ad, store)
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("心跳驱动了 loop:session=%q", chat.sessionID)
}
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("心跳被写进了登记面(会话历史):%+v", recs)
}
rows, err := store.Count(ctx, events.Filter{})
if err != nil {
t.Fatalf("Count: %v", err)
}
if rows != 0 {
t.Fatalf("T1 遥测逐条落行 %d 行,want 0(§4.3:心跳逐帧写库是纯浪费)", rows)
}
if err := store.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
counters, err := store.Counters(ctx, "qq-obv11")
if err != nil {
t.Fatalf("Counters: %v", err)
}
if len(counters) != 1 {
t.Fatalf("计数行数 = %d, want 1(T1 必须进计数)", len(counters))
}
got := counters[0]
if got.Count != 1 || got.Tier != events.TierT1 || got.Category != events.Category(adapter.NoticeCategoryHeartbeat) {
t.Fatalf("计数 = %+v, want count=1 tier=t1 category=heartbeat", got)
}
if got.AccountID != "conf0" || got.PlatformType != "heartbeat" {
t.Fatalf("计数键 = (account=%q, type=%q), want (conf0, heartbeat)", got.AccountID, got.PlatformType)
}
}
// TestRecordEvent_unknown不进上下文但落行_正常:未知类型是「兜底不等于放行」的正面用例
// (§3.7)—— 它照旧逐条落行(可查)并进计数,但**绝不**进她的上下文,且平台原生类型码
// 必须原样保留(那是「上游到底报了什么类型」的唯一凭据)。
func TestRecordEvent_unknown不进上下文但落行_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestEventStore(t)
ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{
Kind: adapter.NoticeUnknown,
Category: adapter.NoticeCategoryUnknown,
PlatformType: "mystery_frame",
Ignorable: true,
})
b, chat, hist, _ := eventBridgeFixture(t, ad, store)
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("unknown 事件进了真会话:session=%q", chat.sessionID)
}
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("unknown 事件被登记进会话历史:%+v", recs)
}
rows, err := store.List(ctx, events.Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 {
t.Fatalf("unknown 事件落行数 = %d, want 1(unknown 必须逐条留痕)", len(rows))
}
ev := rows[0]
if ev.Tier != events.TierT1 || ev.Category != events.CategoryUnknown {
t.Fatalf("落行档位/类别 = (%s,%s), want (t1,unknown)", ev.Tier, ev.Category)
}
if ev.PlatformType != "mystery_frame" {
t.Fatalf("平台原生类型码 = %q, want mystery_frame(原样保留是排障凭据)", ev.PlatformType)
}
if ev.Platform != "qq-obv11" || ev.ChannelID != "g1" || ev.ChannelType != string(adapter.ChannelGroup) {
t.Fatalf("行来源字段 = (platform=%q, channel=%q, type=%q)", ev.Platform, ev.ChannelID, ev.ChannelType)
}
if ev.AccountID != "conf0" {
t.Fatalf("account_id = %q, want conf0(多账号覆盖;空串留给单账号/未知)", ev.AccountID)
}
if !strings.Contains(ev.Summary, "mystery_frame") {
t.Fatalf("摘要 = %q, want 含未识别的平台原生类型码", ev.Summary)
}
}
// TestRecordEvent_未接线时入站照常_异常:`Options.EventStore == nil` 时降级为
// 「内存计数 + 首次一条 DEBUG」,**绝不**中断入站 —— 高价值事件的升级真会话行为必须
// 逐字不变(fail-visible 而非 fail-crash,§4.3)。
func TestRecordEvent_未接线时入站照常_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
ad := eventAdapter("qq-obv11", adapter.SystemNotice{
Kind: adapter.NoticeLeave,
Category: adapter.NoticeCategoryLeave,
PlatformType: "group_decrease",
SafeText: "系统通知:她(机器人账号)被移出了群聊",
HighValue: true,
SelfRelated: true,
})
b, chat, _, buf := eventBridgeFixture(t, ad, nil)
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.sessionID != "sess-g1" {
t.Fatalf("未接线改变了入站行为:高价值事件没进真会话(session=%q)", chat.sessionID)
}
stats := b.EventFallback()
if stats.Frames != 1 {
t.Fatalf("降级计数 = %d, want 1(未接线的帧也必须留下可查的计数)", stats.Frames)
}
if stats.Keys["qq-obv11/group_decrease"] != 1 {
t.Fatalf("降级计数键 = %v, want qq-obv11/group_decrease=1", stats.Keys)
}
logged := buf.String()
if !strings.Contains(logged, "source=services/channelbridge/events") {
t.Fatalf("降级日志没有来源标记(AGENTS §10 禁无来源裸日志):%q", logged)
}
if !strings.Contains(logged, "channel event not recorded") {
t.Fatalf("未接线时没有留下可见痕迹:%q", logged)
}
// 「DEBUG 一次」:第二帧不再刷日志,但计数继续累加(有界、不刷屏)。
buf.Reset()
b.processFrom(ctx, groupNoticeMessage(), ad)
if again := b.EventFallback().Frames; again != 2 {
t.Fatalf("降级计数 = %d, want 2", again)
}
if got := buf.String(); strings.Contains(got, "channel event not recorded") {
t.Fatalf("降级日志被逐帧刷写(应只在首帧记一条 DEBUG):%q", got)
}
}
// TestRecordEvent_落库失败仍不中断入站_异常:存储报错 ⇒ WARN + 内存计数 + 继续 ——
// **不关留存面、不中断回合、不让入站失败**(§4.3)。
func TestRecordEvent_落库失败仍不中断入站_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := &failingEventStore{}
ad := eventAdapter("qq-obv11", adapter.SystemNotice{
Kind: adapter.NoticeLeave,
Category: adapter.NoticeCategoryLeave,
PlatformType: "group_decrease",
SafeText: "系统通知:她(机器人账号)被移出了群聊",
HighValue: true,
})
b, chat, _, buf := eventBridgeFixture(t, ad, store)
b.processFrom(ctx, groupNoticeMessage(), ad)
if store.records != 1 {
t.Fatalf("留存面被调用 %d 次, want 1", store.records)
}
if chat.sessionID != "sess-g1" {
t.Fatalf("落库失败中断了入站(高价值事件没进真会话):session=%q", chat.sessionID)
}
if stats := b.EventFallback(); stats.Frames != 1 {
t.Fatalf("落库失败的帧没有进降级计数:%+v", stats)
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") ||
!strings.Contains(logged, "channel event not persisted") ||
!strings.Contains(logged, "source=services/channelbridge/events") {
t.Fatalf("落库失败的可观测面不足(要 WARN + source):%q", logged)
}
}
// TestRecordEvent_聊天消息零副作用_正常:普通聊天消息不是渠道事件 —— 留存面**一次都
// 不该被调用**(主消息路径不受影响,§5.1)。
func TestRecordEvent_聊天消息零副作用_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestEventStore(t)
ad := eventAdapter("qq-obv11", adapter.SystemNotice{Kind: adapter.NoticeJoin})
b, _, _, _ := eventBridgeFixture(t, ad, store)
msg := groupNoticeMessage()
msg.ContentType, msg.Content = adapter.ContentText, "大家好"
msg.Sender.Role, msg.SenderID = adapter.RoleMember, "42"
b.processFrom(ctx, msg, ad)
rows, err := store.Count(ctx, events.Filter{})
if err != nil {
t.Fatalf("Count: %v", err)
}
if rows != 0 || store.PendingKeys() != 0 {
t.Fatalf("普通聊天消息进了留存面:rows=%d pending=%d", rows, store.PendingKeys())
}
if stats := b.EventFallback(); stats.Frames != 0 {
t.Fatalf("普通聊天消息进了降级计数:%+v", stats)
}
}