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

@@ -24,6 +24,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
@@ -162,6 +163,15 @@ type Options struct {
// 空即视为**未接线**:带文件的消息照旧能跑(模型会收到一行「读不到」的诚实
// 提示),只是不落库、不抽取 —— 绝不假装读到了。
DocumentStore documents.StoreConfig
// EventStore 是**渠道事件留存**面(design/26 §4/§5;切片 E3):所有非消息事件
// (含未知类型与解析失败)经 channelbridge/events.go 的 recordEvent **无条件**
// 写进它 —— T2/T3 与 unknown 逐条落行、T1 只累加计数。
//
// 它只依赖 `events.Store` 接口,因此为 nil 时桥接器走**降级路径**(内存计数 +
// 首次一条 DEBUG),入站流水线**绝不**因为没有存储而失败或丢帧(fail-visible 而
// 非 fail-crash,§4.3)。注入必须传**真 nil 接口**而不是装了 nil 指针的接口,
// 否则这条判据会失效(见 cmd/cyrene/wire_channel_events.go 的 channelEventStore)。
EventStore events.Store
}
// Bridge 掌管渠道适配器生命周期与入站消息流水线。它
@@ -180,13 +190,19 @@ type Bridge struct {
quietStartHour int
quietEndHour int
now func() time.Time
// noticeLog 是系统通知传入前审查(design/12 §6 策略一补充)的子功能日志器,
// 与 voiceLog 同款:`source=services/channelbridge/notice` 便于跨模块定位。
// noticeLog 是系统通知传入前审查(design/12 §6 策略一补充)的子功能日志器:
// `source=services/channelbridge/notice` 便于跨模块定位。三个通知类日志
// (notice / flow / event)都在构造期一次性标好来源,使「这条为什么没进她上下文」
// 「这一帧有没有留下可查痕迹」在日志里可追溯。
noticeLog *slog.Logger
// flowLog 是流控解析(design/12 §6 策略三,含账号级 directMsgPerMin)的子功能
// 日志器:`source=services/channelbridge/flowgate`,使「主人显式关掉某项流控」
// 在日志里可追溯(见 flowgate.go 的 directLimit)。
flowLog *slog.Logger
// eventLog 是渠道事件留存(design/26 §4;切片 E3)的子功能日志器:
// `source=services/channelbridge/events`。留存成功走 DEBUG(含 NoticeClass 的
// 裁决结果),未接线 / 落库失败走 DEBUG(首帧)+ WARN(失败),全部带来源。
eventLog *slog.Logger
// 参与度引擎状态(design/18):按会话的内存状态机,
// 加上约束它的角色提醒词 / 参与度参数,以及概率触发
@@ -219,6 +235,11 @@ type Bridge struct {
// documentsCfg 是入站附件落盘配置(design/24 §3;切片 M1)。零值 = 未接线:
// 带文件的消息照旧能跑,模型会收到一行「读不到」的诚实提示(绝不静默)。
documentsCfg documents.StoreConfig
// eventStore 是渠道事件留存面(design/26 §4;切片 E3)。**真 nil 接口** = 未接线:
// 非消息事件走内存计数降级,入站照常(见 events.go 的 recordEvent)。
// eventFallback 是那条降级路径的计数面(有界,fail-visible)。
eventStore events.Store
eventFallback *eventFallback
// voiceSTT / voiceSTTTimeout / voiceCircuit 是入站语音转写面(切片 IM-ASR;
// design/06 §1.1):注入点、独立短超时,以及按原因分类的**临时熔断**。
// voiceLog 是带 `services/channelbridge/voice` 来源的子功能日志器。
@@ -286,6 +307,7 @@ func New(opts Options) (*Bridge, error) {
now: now,
noticeLog: logger.WithSource(log, "services/channelbridge/notice"),
flowLog: logger.WithSource(log, flowgateLogSource),
eventLog: logger.WithSource(log, "services/channelbridge/events"),
triggerWords: opts.TriggerWords,
personaPrompt: opts.PersonaPrompt,
engage: opts.Engage,
@@ -301,6 +323,8 @@ func New(opts Options) (*Bridge, error) {
channelPersona: opts.ChannelPersona,
adapterPersona: make(map[string]string),
documentsCfg: opts.DocumentStore,
eventStore: opts.EventStore,
eventFallback: newEventFallback(),
voiceSTT: opts.VoiceSTT,
voiceSTTTimeout: voiceSTTTimeout(opts.VoiceSTTTimeout),
voiceLog: logger.WithSource(log, "services/channelbridge/voice"),
+404
View File
@@ -0,0 +1,404 @@
package channelbridge
// 本文件承载**渠道事件留存**在桥接层的唯一接线点(design/26 §4/§5;切片 E3):
// 把适配器归一化出来的结构化事件**无条件**写进 `Options.EventStore`(T2/T3 与 unknown
// 逐条落行、T1 只累加计数),两轴各归各位 —— 留存档(本文件按归一化类别 + SelfRelated
// 映射,§2.3)与上下文档(notice.go 的 preEntryReview,既有)**互不决定**。
//
// 三条不变量写在这里:
//
// 1. **留存无条件**:留存与「进不进上下文」是两个独立动作(§4.3)。因此本层在任何
// 早退分支(仅登记 / fail-safe 静默 / 升级真会话)**之前**记账,且**绝不**决定 loop
// 走不走、也绝不改写消息内容。
// 2. **绝不静默,也绝不 fail-crash**(§1.1/§4.3):未接线(store 为 nil)或落库失败
// 都只降级为「内存计数 + 日志」——**绝不**让入站流水线失败、丢帧或 panic。
// 这正是 §0.2 事故要的:看不见可以,但必须留得下痕、答得出「上游推过没有」。
// 3. **脱敏唯一出处**(§4.2):摘要一律过 `adapter.SanitizeNoticeText`。`events.Event`
// 里**没有**承载平台正文 / 媒体 URL / OneBot `flag` 的字段,因此凭据不可能随这条
// 链路流出去(§9.2 第 8 条:连结构都没有那些字段)。
//
// 类别命名归 `adapter`(`adapter.NoticeCategory*`),本层**逐字搬运**、绝不改名
// (§9.2 第 7 条:两轴常量分居两包,防两表漂移)。
import (
"context"
"log/slog"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// 摘要的固定文案(design/26 §4.2 的「能落」清单:一句话常量,**不含任何平台原文**)。
const (
// heartbeatSummary / lifecycleSummary / selfEchoSummary 覆盖 T1 三类刻意留空
// SafeText 的类别(§9.2 第 9 条:适配器留空,摘要由**留存侧**按平台原生类型生成)。
heartbeatSummary = "心跳(链路遥测)"
lifecycleSummary = "生命周期(链路遥测)"
selfEchoSummary = "她自己发出消息的回声"
// decodeErrorSummary 对应 §3.7 的「JSON 坏」:把「解析失败」这件事本身说清楚。
decodeErrorSummary = "事件载荷解析失败,只留结构痕迹"
// unknownSummaryPrefix 后面拼**平台原生类型码**(notice_type / extra.type /
// Telegram 的 update 键名):那是「上游到底报了什么类型」的答案(§2.4),
// 而且它是平台枚举值,不是用户文本。
unknownSummaryPrefix = "未识别的事件类型:"
unknownSummary = "未识别的事件类型"
// platformEventSummary 是类别与种类都归不了类的兜底文案(既非未知类型、也无安全文本)。
platformEventSummary = "平台系统事件"
)
// recordEvent 是渠道事件留存的**唯一插入点**(design/26 §5.1/§6.2):在入站归一化
// 之后、任何早退分支之前调用一次,把一条结构化事件交给 `Options.EventStore`。
//
// 它只影响**留存面**:不改写 msg、不决定 loop 走不走、不返回值 —— 调用方照旧走既有
// 流水线(仅登记 / 限定注入 / 升级真会话)。普通聊天消息(非 ContentAction)在这里
// 直接返回:主消息路径**零副作用**。
func (b *Bridge) recordEvent(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, out noticeOutcome) {
if !out.Applicable {
return
}
ev := b.buildEvent(msg, a, out)
if b.eventStore == nil {
// 未接线(§4.3 的 fail-visible 降级):内存计数 + **首次**一条 DEBUG,
// 绝不中断入站、绝不丢帧。
b.noteUnrecorded(ev, "no event store wired")
return
}
if err := b.eventStore.Record(ctx, ev); err != nil {
// 逐条留存的失败**必须可见**(§4.3):WARN + 内存计数。但既**不**中断入站,
// 也**不**关掉留存面 —— 下一帧照旧尝试。
b.eventLog.Warn("channel event not persisted",
"platform", ev.Platform, "platform_type", ev.PlatformType,
"tier", string(ev.Tier), "category", string(ev.Category), "err", err)
b.noteUnrecorded(ev, "record failed")
return
}
b.logEventRecorded(ctx, ev, out)
}
// buildEvent 把归一化结果投影成一条待留存事件(design/26 §4.1 的列,逐列对应)。
//
// 投影**只取结构化字段**:平台 / 账号 / 会话 id、归一化类别与平台原生类型、当事人 id、
// 是否命中她自己、关联消息 id,以及一句由安全文本(T2/T3)或固定文案(T1)得来的摘要。
// CreatedAt 刻意留零值:由存储层填**我们的接收时刻**(适配器给的平台时刻可能是 0 ⇒
// 1970,一条 1970 的行会被保留期立刻扫掉,反而丢掉刚发生的证据)。
func (b *Bridge) buildEvent(msg adapter.UnifiedMessage, a adapter.PlatformAdapter, out noticeOutcome) events.Event {
notice := out.Notice
return events.Event{
Platform: msg.Platform,
AccountID: accountIDFromAdapter(a),
Tier: eventTier(notice),
Category: eventCategory(notice),
PlatformType: notice.PlatformType,
ChannelID: msg.ChannelID,
ChannelType: string(msg.ChannelType),
GroupID: msg.GroupID,
ActorID: notice.Actor.UserID,
ActorRole: b.eventActorRole(msg.Platform, notice.Actor.UserID),
TargetID: notice.Target.UserID,
SelfRelated: notice.SelfRelated,
MessageID: firstNonEmptyStr(notice.RefMessage, msg.MessageID),
Summary: eventSummary(notice),
}
}
// logEventRecorded 是这一帧成功进入留存面后的**结构级 DEBUG**(不留存则走 noteUnrecorded)。
//
// 它记的是 §9.2 第 2 条要求的那条日志:**NoticeClass 的裁决结果只进日志、不入事件行**
// (§4.1 的 DDL 本就没有该列),连留存档一起记,方便回答「这条为什么没进她上下文」。
// 只在真的开了 DEBUG 时才组装字段(与前缀一致,默认 INFO 下零开销),
// 且**绝不**记 SafeText 之外的平台文本,更绝不记 flag / 正文 / URL。
func (b *Bridge) logEventRecorded(ctx context.Context, ev events.Event, out noticeOutcome) {
if !b.eventLog.Enabled(ctx, slog.LevelDebug) {
return
}
b.eventLog.Debug("channel event recorded",
"platform", ev.Platform, "platform_type", ev.PlatformType,
"category", string(ev.Category), "tier", string(ev.Tier),
"self_related", ev.SelfRelated,
"notice_class", noticeClassName(out.Class), "notice_reason", out.Reason)
}
// noteUnrecorded 是「这一帧没能进入留存面」的唯一收口(未接线 / 落库失败):
// 内存计数**永远**累加(fail-visible:有数可查),日志只在首帧记一条 DEBUG ——
// 心跳每 ~15s 一帧,逐帧刷日志只会把日志淹没(AGENTS §10 的压测纪律:有界、不刷屏)。
func (b *Bridge) noteUnrecorded(ev events.Event, reason string) {
stats := b.eventFallback.append(ev.Platform, ev.PlatformType)
if stats.Frames != 1 {
return
}
b.eventLog.Debug("channel event not recorded, counting in memory instead",
"reason", reason, "platform", ev.Platform, "platform_type", ev.PlatformType,
"tier", string(ev.Tier), "category", string(ev.Category))
}
// EventFallbackStats 是「没能进入留存面」的计数快照(design/26 §4.3 的降级面)。
type EventFallbackStats struct {
// Keys 是按 `"<platform>/<platform_type>"` 聚合的帧数快照(与计数表的键同形)。
Keys map[string]int64
// Frames 是**未进入留存面**的总帧数(含下面溢出键的那部分)。
Frames int64
// OverflowKeys 是键上界用尽后未能按类记账的帧数:它们仍算进 Frames,
// 只是不再有各自的分类(键数**有界**,绝不无界增长)。
OverflowKeys int64
}
// EventFallback 返回降级计数的快照。未接线(Options.EventStore 为 nil)或落库失败时
// 才有内容 —— 它是「有多少帧没留下可查痕迹」的**唯一**答案:面板与排障据此知道留存面
// 当时是活的还是降级的(design/26 §1.1 的诚实边界)。
func (b *Bridge) EventFallback() EventFallbackStats {
return b.eventFallback.snapshot()
}
// eventFallback 是未接线 / 落库失败时的内存计数面(§4.3 的降级)。
//
// 键数上界复用 `events.DefaultMaxPendingKeys`(与存储层的内存计数缓冲同界:绝不因为
// 一直失败而无界增长),键口径与计数表的唯一键同形(平台 + 平台原生类型;不含账号
// —— 降级面连账号归属都未必取得准,键越简单越不会误导)。
type eventFallback struct {
mu sync.Mutex
keys map[string]int64
frames int64
overflow int64
}
// newEventFallback 构造空的内存计数面。
func newEventFallback() *eventFallback {
return &eventFallback{keys: make(map[string]int64)}
}
// append 累加一帧并返回**当前**快照(日志据此判断「是不是首帧」)。
func (f *eventFallback) append(platform, platformType string) EventFallbackStats {
key := platform + "/" + platformType
f.mu.Lock()
defer f.mu.Unlock()
f.frames++
if _, ok := f.keys[key]; ok {
f.keys[key]++
} else if len(f.keys) < events.DefaultMaxPendingKeys {
f.keys[key] = 1
} else {
f.overflow++
}
return f.snapshotLocked()
}
// snapshot 返回快照(拷贝键值,调用方拿到的是私有副本)。
func (f *eventFallback) snapshot() EventFallbackStats {
f.mu.Lock()
defer f.mu.Unlock()
return f.snapshotLocked()
}
// snapshotLocked 是 snapshot 的加锁内核(append 已在锁内,直接复用)。
func (f *eventFallback) snapshotLocked() EventFallbackStats {
keys := make(map[string]int64, len(f.keys))
for k, v := range f.keys {
keys[k] = v
}
return EventFallbackStats{Keys: keys, Frames: f.frames, OverflowKeys: f.overflow}
}
// eventTier 是 design/26 §2 的两轴换算:**归一化类别 → 留存档**(§2.3 的换算表)。
//
// 判据顺序刻意固定为「先按类别定档、再按 self 命中升档」:
//
// 1. 类别(缺类别时按 Kind)给出基础档;
// 2. `SelfRelated`(结构化 id 命中她自己 / 她的账号)对**平台事件类**做 T1 → T2 升档
// (§2.2:T1 → T2、T2 → 高价值;T3 不因 self 命中改变档位);
// 3. **帧级链路遥测不升档**(见 isFrameTelemetry):见那里的取舍说明。
func eventTier(notice adapter.SystemNotice) events.Tier {
tier := baseEventTier(notice)
if tier != events.TierT1 || !notice.SelfRelated {
return tier
}
if isFrameTelemetry(notice.Category) {
return events.TierT1
}
return events.TierT2
}
// baseEventTier 给出归一化类别的基础留存档(design/26 §3 逐平台映射表收敛后的共同口径)。
//
// 类别为零值时按 Kind 兜底 —— `adapter.SystemNotice.Category` 的注释明写「零值表示
// 适配器未给类别,留存侧按 Kind 兜底」。
//
// default 分两支刻意都取**更全的留存**(T2 = 逐条落行):新增类别漏登记时会多落行,
// 绝不静默丢帧(design/26 §1.1)。E5 新增平台类别时**必须**在这里补档位。
func baseEventTier(notice adapter.SystemNotice) events.Tier {
if notice.Category == "" {
return kindTier(notice.Kind)
}
switch notice.Category {
case adapter.NoticeCategoryHeartbeat, adapter.NoticeCategoryLifecycle,
adapter.NoticeCategoryMessageSent, adapter.NoticeCategoryClientStatus,
adapter.NoticeCategoryUnknown, adapter.NoticeCategoryDecodeError:
return events.TierT1
case adapter.NoticeCategoryPoke, adapter.NoticeCategoryLuckyKing:
return events.TierT3
default:
// join / friend_add / leave / recall / admin / ban / ban_all / upload / essence /
// card / offline_file / honor / title / friend_request / group_request:一律 T2。
return events.TierT2
}
}
// kindTier 是类别缺失时按 Kind 兜底(与 §3 的逐条映射同口径):成员 / 权限 / 消息状态的
// 变动与请求一律 T2,纯遥测 T1,定向交互 T3。default 取 T1 —— 此时类别会兜底成
// `unknown`,而 `unknown` 在存储层本就是**逐条落行**的(§3.7),留痕一条不少。
func kindTier(kind adapter.NoticeKind) events.Tier {
switch kind {
case adapter.NoticeInteraction:
return events.TierT3
case adapter.NoticeJoin, adapter.NoticeLeave, adapter.NoticeRecall,
adapter.NoticeBotAdd, adapter.NoticeRequest:
return events.TierT2
default:
return events.TierT1
}
}
// isFrameTelemetry 报告该类别是否属于**帧级链路遥测**:心跳(~15s 一帧)、生命周期、
// 她自己发出消息的回声。这三类的 self 命中是**恒定**的(帧帧都命中 self_id),所以
// 按 §2.2 升档等于「每帧写一行 channel_events」—— 而 §4.3 明令「心跳 / 在线状态逐帧
// 写库是纯浪费」(计数表存在的理由),§3.1 给这三类的档位也正是 T1。
//
// ⚠️ **由此登记的债**(design/26 §2.6「她自己账号的在线 / 离线」= T2 高价值):
// 那是**状态迁移**(在线→离线 / 离线→在线),不是每一帧。判定它需要迁移信号
// (heartbeat 的 `status.online` / lifecycle 的 `sub_type`),而 `adapter.SystemNotice`
// 目前没有投影这两个字段(E2 定稿,本片不许改 plugins/**),`qq_obv11/probe.go` 的
// 主动探活也还没有留存入点。本片只能保证**每一帧都进了计数**(「上游推过没有」答得上),
// 迁移事件(T2)留待后续切片;已在切片报告「未做 / 存疑」中列明。
func isFrameTelemetry(cat adapter.NoticeCategory) bool {
switch cat {
case adapter.NoticeCategoryHeartbeat, adapter.NoticeCategoryLifecycle,
adapter.NoticeCategoryMessageSent:
return true
default:
return false
}
}
// eventCategory 把适配器的归一化类别投影到 events 的类别轴。
//
// 两轴常量分居两包(§9.2 第 7 条):**类别的命名归 adapter**,events 只承载类型与存储。
// 这里逐字搬运、绝不改名 —— adapter 的类别比 events 的常量集**更细一档**
// (ban_all / honor / title / lucky_king …),改名只能靠丢信息来对齐,而丢掉的正是
// 「这条为什么没进她上下文」的答案。适配器未给类别(零值)时兜底成 `unknown`:
// 「读不出具体类别」本身就该如实记为未知(存储层对 `unknown` **逐条落行**,§3.7)。
func eventCategory(notice adapter.SystemNotice) events.Category {
cat := strings.TrimSpace(string(notice.Category))
if cat == "" {
return events.CategoryUnknown
}
return events.Category(cat)
}
// eventSummary 给出一条事件的**脱敏结构级摘要**(§4.1 的 summary 列)。
//
// 适配器给了安全文本就用它(T2/T3 的固定文案,如「群文件上传」);T1 四类刻意留空
// (§9.2 第 9 条)⇒ 由本函数按类别 / 平台原生类型**生成固定文案**。
//
// 净化唯一出处是 `adapter.SanitizeNoticeText`(§4.2)。即便 SafeText 已在
// preEntryReview 里净化过一遍,这里再过一次:保证「Event.Summary 恒已净化」与调用
// 顺序无关(该函数幂等)。
func eventSummary(notice adapter.SystemNotice) string {
text := strings.TrimSpace(notice.SafeText)
if text == "" {
text = fallbackSummary(notice)
}
return adapter.SanitizeNoticeText(text)
}
// fallbackSummary 按归一化类别生成固定文案(T1 那几类 SafeText 恒为空)。
// 只描述「这是什么类型的事件」,绝不夹带平台原文 / URL / 凭据;未知类型拼的是
// **平台原生类型码**(平台枚举值,回答「上游报了哪种类型」)。
func fallbackSummary(notice adapter.SystemNotice) string {
switch notice.Category {
case adapter.NoticeCategoryHeartbeat:
return heartbeatSummary
case adapter.NoticeCategoryLifecycle:
return lifecycleSummary
case adapter.NoticeCategoryMessageSent:
return selfEchoSummary
case adapter.NoticeCategoryDecodeError:
return decodeErrorSummary
case adapter.NoticeCategoryUnknown:
if notice.PlatformType != "" {
return unknownSummaryPrefix + notice.PlatformType
}
return unknownSummary
default:
return kindSummary(notice.Kind)
}
}
// kindSummary 是类别与安全文本都缺时的兜底文案(按 Kind 说清是哪一类系统事件)。
func kindSummary(kind adapter.NoticeKind) string {
switch kind {
case adapter.NoticeJoin:
return "有成员加入了会话"
case adapter.NoticeLeave:
return "有成员离开了会话"
case adapter.NoticeRecall:
return "有人撤回了一条消息"
case adapter.NoticeTyping:
return "有会话正在输入"
case adapter.NoticeBotAdd:
return "她的账号成员身份发生变更"
case adapter.NoticeRequest:
return "有一条好友 / 入群请求"
case adapter.NoticeInteraction:
return "有一个定向互动事件"
case adapter.NoticeUnknown:
return unknownSummary
default:
return platformEventSummary
}
}
// eventActorRole 只按**白名单**判定事件当事人的角色(owner.yaml 的账号白名单 +
// 每渠道 adminIds 层,与 session.ResolveActor 同源),**绝不看正文、也绝不采信平台
// 上报的 role 字段**(design/26 §1.2 / AGENTS §9 红线:身份只由结构化元数据 + 白名单
// + 策略引擎决定)。
//
// 三种取值:没有当事人的事件(心跳 / 链路遥测)来自平台系统本身 ⇒ system;白名单命中
// ⇒ owner;其余 ⇒ **unknown**。刻意不填 member:「不是主人」不等于「是某个群的成员」
// (加好友请求的申请人本就不是任何群的成员),判不出来时如实说判不出来。
func (b *Bridge) eventActorRole(platform, actorID string) string {
if strings.TrimSpace(actorID) == "" {
return events.RoleSystem
}
if b.owner.IsOwner(platform, actorID) {
return events.RoleOwner
}
return events.RoleUnknown
}
// accountIDFromAdapter 从适配器注册名里取配置账号 id("telegram:conf0" → "conf0",
// 多账号命名口径见 cmd/cyrene/channels.go)。单账号(名字里没有 ':')时返回空串:
// `events.Event.AccountID` 把空串定义为「单账号 / 未知」(§4.1),绝不编一个 id 出来。
func accountIDFromAdapter(a adapter.PlatformAdapter) string {
_, accountID, ok := strings.Cut(adapterName(a), ":")
if !ok {
return ""
}
return accountID
}
// noticeClassName 把 `adapter.NoticeClass`(上下文档的裁决结果)渲染成日志里的可读名。
// 它**只进日志、不入事件行**(§9.2 第 2 条):留存档记 tier + category 两列,
// 这一轴是另一个正交轴的结论,回答「这条为什么没进她上下文」。
func noticeClassName(class adapter.NoticeClass) string {
switch class {
case adapter.NoticeRegisterOnly:
return "register_only"
case adapter.NoticeLimitedInject:
return "limited_inject"
case adapter.NoticeEscalate:
return "escalate"
default:
return "unknown"
}
}
@@ -0,0 +1,230 @@
package channelbridge
// 本文件是**两轴映射与脱敏红线**的验收(design/26 §2/§4.2;切片 E3):
// 类别 → 留存档的唯映射(含 self 命中升档与帧级遥测不升档)、类别逐字搬运、
// 摘要净化且绝不夹带平台原文 / URL / OneBot `flag`、当事人角色只认白名单、
// 账号 id 的解析口径。
//
// 它与 events_test.go 的分工:那边验「留存这件事真的发生了」(落行 / 计数 / 降级),
// 这边验「记进去的东西对不对」(映射、脱敏、字段口径)。
import (
"context"
"fmt"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// TestEventTier_两轴映射_正常:类别 → 留存档的唯映射(§2.1/§2.3/§3),含
// `SelfRelated` 升档与「帧级遥测不升档」两侧。
func TestEventTier_两轴映射_正常(t *testing.T) {
t.Parallel()
cases := []struct {
name string
notice adapter.SystemNotice
want events.Tier
}{
{"心跳是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat}, events.TierT1},
{"生命周期是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle}, events.TierT1},
{"自身回声是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent}, events.TierT1},
{"他人在线状态是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryClientStatus}, events.TierT1},
{"未知类型是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryUnknown}, events.TierT1},
{"解析失败是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryDecodeError}, events.TierT1},
{"群名片变更是 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryCard}, events.TierT2},
{"消息撤回是 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryRecall}, events.TierT2},
{"加好友请求是 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryFriendRequest}, events.TierT2},
{"全员禁言是 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryBanAll}, events.TierT2},
{"戳一戳是 T3", adapter.SystemNotice{Category: adapter.NoticeCategoryPoke}, events.TierT3},
{"运气王是 T3", adapter.SystemNotice{Category: adapter.NoticeCategoryLuckyKing}, events.TierT3},
{"他人存在性命中自己 ⇒ 升档 T2",
adapter.SystemNotice{Category: adapter.NoticeCategoryClientStatus, SelfRelated: true}, events.TierT2},
{"资源类命中自己 ⇒ 升档 T2",
adapter.SystemNotice{Category: adapter.NoticeCategoryCard, SelfRelated: true}, events.TierT2},
{"心跳命中自己 ⇒ 不升档(帧级遥测)",
adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat, SelfRelated: true}, events.TierT1},
{"生命周期命中自己 ⇒ 不升档(帧级遥测)",
adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle, SelfRelated: true}, events.TierT1},
{"自身回声命中自己 ⇒ 不升档(帧级遥测)",
adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT1},
{"类别缺失按 Kind 兜底:join ⇒ T2",
adapter.SystemNotice{Kind: adapter.NoticeJoin}, events.TierT2},
{"类别缺失按 Kind 兜底:request ⇒ T2",
adapter.SystemNotice{Kind: adapter.NoticeRequest}, events.TierT2},
{"类别缺失按 Kind 兜底:interaction ⇒ T3",
adapter.SystemNotice{Kind: adapter.NoticeInteraction}, events.TierT3},
{"类别缺失按 Kind 兜底:typing ⇒ T1",
adapter.SystemNotice{Kind: adapter.NoticeTyping}, events.TierT1},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := eventTier(tc.notice); got != tc.want {
t.Fatalf("eventTier = %s, want %s", got, tc.want)
}
})
}
}
// TestEventCategory_逐字搬运适配器类别_正常:类别的命名归 adapter(§9.2 第 7 条),
// 留存侧只做类型转换、**绝不改名**(改名会让同一件事在适配器与库里各有一个名字)。
// 类别为零值时兜底成 unknown —— 那正是「读不出具体类别」的诚实表达。
func TestEventCategory_逐字搬运适配器类别_正常(t *testing.T) {
t.Parallel()
cases := []struct {
category adapter.NoticeCategory
want events.Category
}{
{adapter.NoticeCategoryBanAll, events.Category("ban_all")},
{adapter.NoticeCategoryLuckyKing, events.Category("lucky_king")},
{adapter.NoticeCategoryHonor, events.Category("honor")},
{adapter.NoticeCategoryJoin, events.Category("join")},
{adapter.NoticeCategoryUnknown, events.CategoryUnknown},
{adapter.NoticeCategoryDecodeError, events.CategoryDecodeError},
{"", events.CategoryUnknown},
}
for _, tc := range cases {
tc := tc
t.Run(string(tc.want), func(t *testing.T) {
t.Parallel()
if got := eventCategory(adapter.SystemNotice{Category: tc.category}); got != tc.want {
t.Fatalf("eventCategory(%q) = %q, want %q", tc.category, got, tc.want)
}
})
}
}
// TestEventSummary_净化且绝不夹带平台原文_正常:§4.2 的红线逐条钉——
// 正文 / 平台动作凭据(OneBot `request.flag`)/ 媒体 URL 绝不入事件(连结构都没有那些
// 字段),摘要必须过 `adapter.SanitizeNoticeText`(唯一净化出处)。
func TestEventSummary_净化且绝不夹带平台原文_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
const (
secretFlag = "SECRET-FLAG-9f2c"
bodyText = "这是群消息正文片段"
mediaURL = "https://cdn.example.com/f.mp4?sign=abc"
)
raw := []byte(`{"post_type":"request","request_type":"friend","flag":"` + secretFlag +
`","comment":"` + bodyText + `","file":{"url":"` + mediaURL + `"}}`)
msg := groupNoticeMessage()
msg.RawData, msg.Content = raw, bodyText
dirty := "群文件\u200b上传\x1b[31m\x1b[0m\n\x07" + strings.Repeat("啊", 300)
ad := eventAdapter("qq-obv11", adapter.SystemNotice{
Kind: adapter.NoticeRequest, Category: adapter.NoticeCategoryFriendRequest,
PlatformType: "friend", SafeText: dirty, HighValue: true,
})
b, _, _, _ := eventBridgeFixture(t, ad, nil)
out := b.preEntryReview(ctx, &msg, ad)
ev := b.buildEvent(msg, ad, out)
// 逐字段取证:把整条事件打成一行文本,验证三个红线取值一个都不在里面。
dump := fmt.Sprintf("%+v", ev)
for _, secret := range []string{secretFlag, bodyText, mediaURL} {
if strings.Contains(dump, secret) {
t.Fatalf("事件里出现了绝不该落的内容 %q:%s", secret, dump)
}
}
// 第二次净化是**本层**的责任(§4.2 的「脱敏唯一出处」),不能靠调用顺序兜底:
// 即便 SafeText 未经 preEntryReview(顺序变了、或别的调用方直接问它),
// `Event.Summary` 也必须已经净化。未知类型的兜底摘要(纯文本由本层生成)同样如此。
for _, notice := range []adapter.SystemNotice{
{Category: adapter.NoticeCategoryUnknown, PlatformType: "weird\x1b[31m\u200btype"},
{Kind: adapter.NoticeOther, SafeText: dirty},
} {
got := eventSummary(notice)
if strings.ContainsAny(got, "\u200b\x1b\x07\n") {
t.Fatalf("eventSummary 没净化(依赖调用顺序会让这条不变量随时失效):%q", got)
}
if got == "" {
t.Fatalf("eventSummary 生成了空摘要:%+v", notice)
}
}
if strings.ContainsAny(ev.Summary, "\u200b\x1b\x07\n") {
t.Fatalf("摘要未净化(含零宽/控制字符):%q", ev.Summary)
}
if len([]rune(ev.Summary)) > adapter.NoticeSafeTextMaxRunes+1 {
t.Fatalf("摘要未按 NoticeSafeTextMaxRunes 截断:%d runes", len([]rune(ev.Summary)))
}
if !strings.Contains(ev.Summary, "群文件上传") {
t.Fatalf("摘要 = %q, want 保留安全文本的可读部分", ev.Summary)
}
}
// TestEventActorRole_只认白名单_正常:事件当事人的角色**只**由白名单判定(与
// session.ResolveActor 同源),绝不看正文、也绝不采信平台上报的 role 字段;
// 「不是主人」不等于「是某个群的成员」,故非主人一律如实记 unknown。
func TestEventActorRole_只认白名单_正常(t *testing.T) {
t.Parallel()
b, _, _, _ := eventBridgeFixture(t, eventAdapter("qq-obv11", adapter.SystemNotice{}), nil)
cases := []struct {
name string
actorID string
want string
}{
{"白名单命中 ⇒ owner", "1000", events.RoleOwner},
{"不在白名单 ⇒ unknown(判不出来就说判不出来)", "2000", events.RoleUnknown},
{"无当事人 ⇒ system(事件来自平台系统本身)", "", events.RoleSystem},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := b.eventActorRole("qq-obv11", tc.actorID); got != tc.want {
t.Fatalf("eventActorRole(%q) = %q, want %q", tc.actorID, got, tc.want)
}
})
}
}
// TestAccountIDFromAdapter_多账号与单账号_正常:账号 id 从适配器注册名解析
// (`platform:confID`);单账号与 nil 适配器一律空串 —— 空串在 events 里定义为
// 「单账号 / 未知」,绝不编一个 id 出来。
func TestAccountIDFromAdapter_多账号与单账号_正常(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ad adapter.PlatformAdapter
want string
}{
{"多账号取配置 id", &noticeAdapter{fakeAdapter: fakeAdapter{name: "qq-obv11:conf0"}}, "conf0"},
{"单账号留空", &noticeAdapter{fakeAdapter: fakeAdapter{name: "qq-obv11"}}, ""},
{"无适配器留空", nil, ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := accountIDFromAdapter(tc.ad); got != tc.want {
t.Fatalf("accountIDFromAdapter = %q, want %q", got, tc.want)
}
})
}
}
// TestNoticeClassName_可读名_正常:上下文档(NoticeClass)的裁决结果只进日志、不入事件行
// (§9.2 第 2 条),因此它的可读名是日志可读性的唯一依赖。
func TestNoticeClassName_可读名_正常(t *testing.T) {
t.Parallel()
cases := map[adapter.NoticeClass]string{
adapter.NoticeRegisterOnly: "register_only",
adapter.NoticeLimitedInject: "limited_inject",
adapter.NoticeEscalate: "escalate",
}
for class, want := range cases {
if got := noticeClassName(class); got != want {
t.Fatalf("noticeClassName(%d) = %q, want %q", class, got, want)
}
}
}
@@ -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)
}
}
@@ -144,6 +144,14 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
// 事件只登记、绝不驱动 loop;被放行的事件已就地换成**剥离后的安全文本**,
// 继续走常规流水线(限定注入 / 升级真会话)。
notice := b.preEntryReview(ctx, &msg, a)
// design/26 §4/§5(切片 E3):**渠道事件留存**的唯一插入点。
//
// 位置说明(与 §5.1 示意图的一行之差):preEntryReview **就是**那一步归一化
// (它调用适配器的 SystemNoticeNormalizer 并净化 SafeText),把 recordEvent 放在
// 它之后一行可以**只归一化一次**,并且 §9.2 第 2 条要求把 NoticeClass 的裁决结果
// 记进日志 —— 那只有这一行之后才有结论。关键性质不变:留存**无条件**,在任何
// 早退分支(仅登记 / 静默 / 升级)之前,且**绝不**决定 loop 走不走。
b.recordEvent(ctx, msg, a, notice)
if notice.Applicable && !notice.Proceed {
b.registerNoticeOnly(ctx, msg, a, notice)
return