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 已有结论),「留存无条件、在任何早退之前」这条性质不变
This commit is contained in:
AskaEth committed 2026-10-05 14:10:24 +08:00
1 parent 7aaf7731dc
commit afe9afb1b5
12 files changed
+1689 -2

No files matched your search

@@ -0,0 +1,316 @@
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)
}
}