Files
Cyrene_v2/internal/services/channelbridge/events_mapping_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

231 lines
10 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 §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)
}
}
}