Files
AskaEth ec3f6e810a feat(events): 渠道事件存储地基(Event/Store + SQLite 实现 + 有界计数聚合 + 保留期 GC + 迁移 0021)
- 新包 internal/runtime/events:Tier/Category/Platform/ChannelType/ActorRole 具名常量 + Event/Filter/Counter + Store 六方法
- 留存口径:T2/T3 逐条落行;T1 只计数;unknown / decode_error / link 三例外逐条落行(链路是否活着是事故定案唯一证据)
- 计数:内存聚合 + 上限 512 的有界缓冲 + 满即整批 upsert,落盘失败回填而不丢;GC:保留期 720h/间隔 1h,≤0 = 关闭
- 迁移 0021_channel_events(两表 + 6 索引,含计数 upsert 唯一键);Event 无 flag/正文/URL 字段(design/26 §4.2 红线)
2026-10-05 13:47:25 +08:00

448 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 events
// 本文件是**事件存储写入与查询**的验收(design/26 §4.1/§4.3/§4.5;切片 E1)。
// 断言的是**库里的事实**(行在不在、字段读回来是不是原值、计数是不是累计值),
// 不按日志文本断言 —— 与 `internal/runtime/documents` 的测试同一口径。
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// testNow 是夹具的固定时钟(可复现:所有时间断言都基于它)。
func testNow() time.Time {
return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
}
// newTestStore 建一套隔离夹具:一个跑过迁移的内存库 + 一个把时钟钉在 testNow 的
// 存储。默认 **关闭 GC**(事件存储的用例与保留期无关,GC 由 gc_test.go 专门覆盖),
// tune 可覆盖任何配置项。第二个返回值是底层连接,供用例做结构级断言。
func newTestStore(t *testing.T, tune func(*Config)) (*SQLStore, *sql.DB) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("打开内存库: %v", err)
}
if err := db.MigrateUp(ctx, conn, nil); err != nil {
_ = conn.Close()
t.Fatalf("内存库跑迁移: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
cfg := Config{DB: conn, Now: testNow, GCInterval: 0, Retention: 0}
if tune != nil {
tune(&cfg)
}
st, err := NewStore(cfg)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return st, conn
}
// sampleT2 是一条带全部字段的 T2 事件(用于往返断言)。
func sampleT2() Event {
return Event{
Platform: PlatformQQ,
AccountID: "bot-1",
Tier: TierT2,
Category: CategoryRecall,
PlatformType: "group_recall",
ChannelID: "group-1070898847",
ChannelType: ChannelGroup,
GroupID: "1070898847",
ActorID: "10001",
ActorRole: RoleMember,
TargetID: "10002",
SelfRelated: true,
MessageID: "msg-9",
Summary: "群消息撤回",
CreatedAt: testNow().Add(-time.Minute),
}
}
// TestNewStore_NilDB_异常:没有数据库就没有存储 —— 宁可明确失败,也不给一个
// 写不进去的替身(§4.3 的「store 为 nil 的降级」由桥接层负责,不在存储层伪造)。
func TestNewStore_NilDB_异常(t *testing.T) {
t.Parallel()
st, err := NewStore(Config{})
if !errors.Is(err, ErrNilDB) {
t.Fatalf("NewStore(nil db) err = %v, want wrap of ErrNilDB", err)
}
if st != nil {
t.Fatalf("NewStore(nil db) returned %#v, want nil", st)
}
}
// TestStore_InterfaceUsable_正常:E3/E4 只依赖 `Store` 接口,因此这里**经接口**
// 走一遍全部六个方法(否则「实现满足接口」这件事只在编译期被偶然发现)。
func TestStore_InterfaceUsable_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = DefaultGCInterval
cfg.Retention = DefaultRetention
})
var store Store = st
if err := store.Record(ctx, sampleT2()); err != nil {
t.Fatalf("Record: %v", err)
}
if err := store.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
n, err := store.Count(ctx, Filter{Platform: PlatformQQ})
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 1 {
t.Fatalf("Count = %d, want 1", n)
}
events, err := store.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(events) != 1 {
t.Fatalf("List = %d rows, want 1", len(events))
}
counters, err := store.Counters(ctx, PlatformQQ)
if err != nil {
t.Fatalf("Counters: %v", err)
}
if len(counters) != 1 {
t.Fatalf("Counters = %d rows, want 1", len(counters))
}
if _, err := store.Sweep(ctx); err != nil {
t.Fatalf("Sweep: %v", err)
}
}
// TestRecordT2_WriteAndList_RoundTrip_正常:T2 逐条落行,且**每个字段原样读回**
// (字段错位是这类写入面最危险的静默 bug)。
func TestRecordT2_WriteAndList_RoundTrip_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
want := sampleT2()
if err := st.Record(ctx, want); err != nil {
t.Fatalf("Record: %v", err)
}
got, err := st.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("List = %d rows, want 1", len(got))
}
ev := got[0]
if ev.ID == 0 {
t.Fatal("ID should be filled by the database")
}
checks := []struct {
name string
got, want string
}{
{"platform", ev.Platform, want.Platform},
{"account_id", ev.AccountID, want.AccountID},
{"tier", string(ev.Tier), string(want.Tier)},
{"category", string(ev.Category), string(want.Category)},
{"platform_type", ev.PlatformType, want.PlatformType},
{"channel_id", ev.ChannelID, want.ChannelID},
{"channel_type", ev.ChannelType, want.ChannelType},
{"group_id", ev.GroupID, want.GroupID},
{"actor_id", ev.ActorID, want.ActorID},
{"actor_role", ev.ActorRole, want.ActorRole},
{"target_id", ev.TargetID, want.TargetID},
{"message_id", ev.MessageID, want.MessageID},
{"summary", ev.Summary, want.Summary},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if !ev.SelfRelated {
t.Error("self_related = false, want true")
}
if !ev.CreatedAt.Equal(want.CreatedAt) {
t.Errorf("created_at = %v, want %v", ev.CreatedAt, want.CreatedAt)
}
if !ev.UpdatedAt.Equal(want.CreatedAt) {
t.Errorf("updated_at = %v, want %v (events are immutable)", ev.UpdatedAt, want.CreatedAt)
}
}
// TestRecord_ZeroClock_FillsNow_正常:CreatedAt 零值时由存储填当前时刻(调用方
// 不必自己取时钟),且同一帧仍进计数。
func TestRecord_ZeroClock_FillsNow_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
ev := sampleT2()
ev.CreatedAt = time.Time{}
if err := st.Record(ctx, ev); err != nil {
t.Fatalf("Record: %v", err)
}
got, err := st.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || !got[0].CreatedAt.Equal(testNow()) {
t.Fatalf("created_at = %v, want the injected clock %v", got[0].CreatedAt, testNow())
}
}
// TestRecordT1_CountsWithoutRow_正常:T1 是**只计数**(design/26 §4.3)—— 行表里
// 没有它,计数表里有它。这条用例钉住「心跳不把库写胖」这个设计取舍。
func TestRecordT1_CountsWithoutRow_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
for i := 0; i < 3; i++ {
if err := st.Record(ctx, Event{
Platform: PlatformQQ,
Tier: TierT1,
Category: CategoryHeartbeat,
PlatformType: "heartbeat",
Summary: "链路心跳",
}); err != nil {
t.Fatalf("Record T1 (#%d): %v", i, err)
}
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 0 {
t.Fatalf("T1 wrote %d rows into channel_events, want 0", n)
}
if err := st.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
counters, err := st.Counters(ctx, PlatformQQ)
if err != nil {
t.Fatalf("Counters: %v", err)
}
if len(counters) != 1 {
t.Fatalf("Counters = %d rows, want 1", len(counters))
}
got := counters[0]
if got.Count != 3 {
t.Fatalf("count = %d, want 3", got.Count)
}
if got.Tier != TierT1 || got.Category != CategoryHeartbeat {
t.Fatalf("tier/category = (%q, %q), want (t1, heartbeat)", got.Tier, got.Category)
}
if !got.FirstSeenAt.Equal(testNow()) || !got.LastSeenAt.Equal(testNow()) {
t.Fatalf("first/last seen = (%v, %v), want %v", got.FirstSeenAt, got.LastSeenAt, testNow())
}
}
// TestRecordT1_FallbackCategoriesPersist_边界:T1 里三个**例外**类别仍要逐条落行
// (design/26 §3.7 的 unknown/decode_error「留痕 + 计数」,§3.6 的链路事件
// 「落 channel_events(category=link)」)。这条用例是那三处例外在代码里的证据。
func TestRecordT1_FallbackCategoriesPersist_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
for _, category := range []Category{CategoryUnknown, CategoryDecodeError, CategoryLink} {
if err := st.Record(ctx, Event{
Platform: PlatformQQ,
Tier: TierT1,
Category: category,
PlatformType: string(category),
}); err != nil {
t.Fatalf("Record (%s): %v", category, err)
}
}
n, err := st.Count(ctx, Filter{})
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 3 {
t.Fatalf("rows = %d, want 3 (unknown / decode_error / link 必须留痕)", n)
}
// 对照:同样 T1、但类别不在例外里的帧**不**落行。
if err := st.Record(ctx, Event{
Platform: PlatformQQ, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: "heartbeat",
}); err != nil {
t.Fatalf("Record (heartbeat): %v", err)
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 3 {
t.Fatalf("rows = %d, want 3 (heartbeat 仍应只计数)", n)
}
}
// TestRecord_NoPlatform_异常:缺平台是调用方的编程错误,必须显式失败且**不写任何行**
// (写一行 platform=” 的行只会让查询面多出一个查不到的垃圾桶)。
func TestRecord_NoPlatform_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
ev := sampleT2()
ev.Platform = " "
if err := st.Record(ctx, ev); !errors.Is(err, ErrNoPlatform) {
t.Fatalf("Record(no platform) err = %v, want wrap of ErrNoPlatform", err)
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 0 {
t.Fatalf("rows = %d, want 0", n)
}
}
// TestRecord_InvalidTier_FallsBackToT1_边界:非法档**降级成 T1**(fail-safe):宁可
// 只计数,也不让一个字段写错把整帧丢出留存面。T2 类别 + 非法档 ⇒ 不落行、但计数在。
func TestRecord_InvalidTier_FallsBackToT1_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
ev := sampleT2()
ev.Tier = Tier("t9")
if err := st.Record(ctx, ev); err != nil {
t.Fatalf("Record: %v", err)
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 0 {
t.Fatalf("rows = %d, want 0 (非法档按 T1 处理 ⇒ 只计数)", n)
}
if st.PendingKeys() != 1 {
t.Fatalf("PendingKeys = %d, want 1 (降级后的帧仍必须计数)", st.PendingKeys())
}
}
// TestList_FilterAndOrder_正常:筛选(平台 / 档 / 类别 / 会话 / 时间窗)与倒序排序。
// 它们是查询面(E6)与「事后定案」直接依赖的行为。
func TestList_FilterAndOrder_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
base := testNow()
seeds := []Event{
{Platform: PlatformQQ, Tier: TierT2, Category: CategoryRecall, PlatformType: "group_recall",
ChannelID: "group-a", CreatedAt: base.Add(-3 * time.Minute), Summary: "群消息撤回"},
{Platform: PlatformQQ, Tier: TierT3, Category: CategoryPoke, PlatformType: "notify.poke",
ChannelID: "group-a", CreatedAt: base.Add(-2 * time.Minute), Summary: "戳一戳"},
{Platform: PlatformKook, Tier: TierT2, Category: CategoryUnknown, PlatformType: "weird",
ChannelID: "channel-b", CreatedAt: base.Add(-time.Minute), Summary: "未知事件"},
}
for i, ev := range seeds {
if err := st.Record(ctx, ev); err != nil {
t.Fatalf("Record(#%d): %v", i, err)
}
}
cases := []struct {
name string
filter Filter
want int
}{
{"无筛选", Filter{}, 3},
{"按平台", Filter{Platform: PlatformQQ}, 2},
{"按平台(无匹配)", Filter{Platform: PlatformTelegram}, 0},
{"按档", Filter{Tier: TierT2}, 2},
{"按类别", Filter{Category: CategoryPoke}, 1},
{"按会话", Filter{ChannelID: "group-a"}, 2},
{"时间窗(闭区间单点)", Filter{Since: base.Add(-2 * time.Minute), Until: base.Add(-2 * time.Minute)}, 1},
{"时间窗(早于下界的不返回)", Filter{Since: base.Add(-2*time.Minute - time.Second)}, 2},
{"多条件合取", Filter{Platform: PlatformQQ, Tier: TierT3}, 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
rows, err := st.List(ctx, c.filter)
if err != nil {
t.Fatalf("List(%+v): %v", c.filter, err)
}
if len(rows) != c.want {
t.Fatalf("List(%+v) = %d rows, want %d", c.filter, len(rows), c.want)
}
n, err := st.Count(ctx, c.filter)
if err != nil {
t.Fatalf("Count(%+v): %v", c.filter, err)
}
if int(n) != c.want {
t.Fatalf("Count(%+v) = %d, want %d", c.filter, n, c.want)
}
})
}
// 倒序:最新的在前(design/26 §4.5 的「事件列表(倒序)」)。
rows, err := st.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
for i := 1; i < len(rows); i++ {
if rows[i-1].CreatedAt.Before(rows[i].CreatedAt) {
t.Fatalf("rows are not in descending time order: %v then %v", rows[i-1].CreatedAt, rows[i].CreatedAt)
}
}
if rows[0].Platform != PlatformKook {
t.Fatalf("first row platform = %q, want the newest (%q)", rows[0].Platform, PlatformKook)
}
}
// TestList_Paging_边界:Limit/Offset 生效,且 Limit 被钳在硬上界内、超过范围的
// Offset 返回空而不是报错(分页尾页是正常请求)。
func TestList_Paging_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
base := testNow()
for i := 0; i < 5; i++ {
if err := st.Record(ctx, Event{
Platform: PlatformQQ, Tier: TierT2, Category: CategoryJoin, PlatformType: "group_increase",
CreatedAt: base.Add(time.Duration(i) * time.Minute),
}); err != nil {
t.Fatalf("Record(#%d): %v", i, err)
}
}
first, err := st.List(ctx, Filter{Limit: 2})
if err != nil {
t.Fatalf("List(limit 2): %v", err)
}
second, err := st.List(ctx, Filter{Limit: 2, Offset: 2})
if err != nil {
t.Fatalf("List(limit 2 offset 2): %v", err)
}
if len(first) != 2 || len(second) != 2 {
t.Fatalf("page sizes = (%d, %d), want (2, 2)", len(first), len(second))
}
if !first[0].CreatedAt.After(second[0].CreatedAt) {
t.Fatal("the second page should contain older rows than the first")
}
if got, err := st.List(ctx, Filter{Limit: -1}); err != nil {
t.Fatalf("List(limit -1): %v", err)
} else if len(got) != 5 {
t.Fatalf("List(limit -1) = %d rows, want all 5 (≤0 取缺省)", len(got))
}
if got, err := st.List(ctx, Filter{Limit: MaxListLimit * 4}); err != nil {
t.Fatalf("List(huge limit): %v", err)
} else if len(got) != 5 {
t.Fatalf("List(huge limit) = %d rows, want 5 (超上界即钳住,但不是报错)", len(got))
}
if got, err := st.List(ctx, Filter{Offset: 99}); err != nil {
t.Fatalf("List(offset 99): %v", err)
} else if len(got) != 0 {
t.Fatalf("List(offset 99) = %d rows, want 0", len(got))
}
}