Files
AskaEth 1d49d266f0 fix(channel): message_sent 回声升 T2 落行,出站消息 id 不再被丢
塔转 M12 登记的缺口(塔裁定「归你」,文件都在本片 scope 内):她自己发出消息
的回声(message_sent)被当成「帧级链路遥测」一并豁免 self 升档 ⇒ 停在 T1,
只进计数、不落行,`message_id` 被丢,「她最近发了哪些消息」在库里查不到。

- events.go: 把 MessageSent 从 isFrameTelemetry 的豁免里摘出(它不是帧级遥测:
  一帧 = 她真发出的一条消息,回声带的是那条出站消息的 id)。
  心跳 / 生命周期的豁免**保持不变**(其登记的迁移债注释原样保留)。
- events_mapping_test.go: 两条 MessageSent 断言同步更新
  (:52 自身回声命中自己 ⇒ 由 T1 改 T2;:33 无 self 信号仍走基础档 T1,标签改精确)。
- events_test.go: 新增端到端用例(真 SQLite):self 命中 ⇒ 落 1 行、tier=t2、
  MessageID=out-msg-42 留在库里、不进上下文、不驱动 loop。
2026-10-13 20:50:31 +08:00

359 lines
14 KiB
Go
Raw Permalink 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_自身回声升T2落行带出站id_正常 钉住切 M13 修掉的缺口(原 M12 登记):
// 她自己发出消息的回声(message_sent)**不是帧级遥测** —— self 命中必须把它从 T1 升到 T2
// 逐条落行,`message_id`(出站消息 id)随之留在库里;此前它被帧级豁免挡回 T1,id 被丢。
func TestRecordEvent_自身回声升T2落行带出站id_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestEventStore(t)
ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{
Kind: adapter.NoticeOther,
Category: adapter.NoticeCategoryMessageSent,
PlatformType: "message_sent",
Ignorable: true,
SelfRelated: true,
RefMessage: "out-msg-42",
})
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.List(ctx, events.Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 {
t.Fatalf("出站回声落行数 = %d, want 1(self 命中 ⇒ T2 逐条落行)", len(rows))
}
ev := rows[0]
if ev.Tier != events.TierT2 || ev.Category != events.Category(adapter.NoticeCategoryMessageSent) {
t.Fatalf("落行档位/类别 = (%s,%s), want (t2,message_sent)", ev.Tier, ev.Category)
}
if ev.MessageID != "out-msg-42" {
t.Fatalf("出站消息 id = %q, want out-msg-42(id 必须留在库里)", ev.MessageID)
}
}
// 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)
}
}