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 红线)
This commit is contained in:
AskaEth committed 2026-10-05 13:47:25 +08:00
1 parent 52dac4575f
commit ec3f6e810a
10 files changed
+2172

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
package db
import (
"context"
"database/sql"
"testing"
)
// channelEventsMigrationID 是渠道事件留存两张表的迁移 id(design/26 §4.1,切片 E1)。
const channelEventsMigrationID = "0021_channel_events"
// schemaObjectExists 报告 sqlite_master 里是否存在某类对象(table / index)。
// 与 columnExists 同族,都只读元数据、不碰 data/cyrene.db。
func schemaObjectExists(t *testing.T, ctx context.Context, conn *sql.DB, kind, name string) bool {
t.Helper()
var n int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", kind, name).Scan(&n); err != nil {
t.Fatalf("sqlite_master(%s %s): %v", kind, name, err)
}
return n == 1
}
// TestMigrateChannelEventsTablesReversible 钉住 0021(channel_events +
// channel_event_counters,design/26 §4.1):up 建两张表与全部索引、重复 up 是空操作、
// down 整表消失、再 up 恢复端到端可用。它用自己的内存库,绝不碰 data/cyrene.db。
//
// 断言的是**库里的结构事实**(表/列/索引在不在、默认值读出来是什么),不按日志文本
// 断言 —— 与 migrate_turn_id_test.go / migrate_document_test.go 同一口径。
func TestMigrateChannelEventsTablesReversible(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
// 两张表与它们的列必须逐个到位(列名错一个,写入面就会静默写错地方)。
columns := map[string][]string{
"channel_events": {
"id", "platform", "account_id", "tier", "category", "platform_type",
"channel_id", "channel_type", "group_id", "actor_id", "actor_role",
"target_id", "self_related", "message_id", "summary", "created_at", "updated_at",
},
"channel_event_counters": {
"id", "platform", "account_id", "tier", "platform_type", "category",
"count", "first_seen_at", "last_seen_at",
},
}
for table, cols := range columns {
if !schemaObjectExists(t, ctx, conn, "table", table) {
t.Fatalf("%s should exist after MigrateUp", table)
}
for _, col := range cols {
if !columnExists(t, ctx, conn, table, col) {
t.Fatalf("%s.%s should exist after MigrateUp", table, col)
}
}
}
// 索引:查询面与 GC 依赖 idx_channel_events_created_at,upsert 依赖计数表的唯一键。
for _, idx := range []string{
"idx_channel_events_created_at",
"idx_channel_events_platform_category",
"idx_channel_events_channel_id",
"idx_channel_events_tier",
"idx_channel_event_counters_key",
"idx_channel_event_counters_category",
} {
if !schemaObjectExists(t, ctx, conn, "index", idx) {
t.Fatalf("%s should exist after MigrateUp", idx)
}
}
// 默认值:只写必填列也能落行,可选列读回来是 ''/0,时间戳由 SQLite 默认填。
if _, err := conn.ExecContext(ctx,
`INSERT INTO channel_events (platform) VALUES (?)`, "qq-obv11"); err != nil {
t.Fatalf("insert minimal channel event: %v", err)
}
var (
tier, category, summary, createdAt string
selfRelated int
)
if err := conn.QueryRowContext(ctx,
`SELECT tier, category, summary, self_related, created_at FROM channel_events WHERE platform = ?`,
"qq-obv11").Scan(&tier, &category, &summary, &selfRelated, &createdAt); err != nil {
t.Fatalf("read minimal channel event: %v", err)
}
if tier != "" || category != "" || summary != "" || selfRelated != 0 {
t.Fatalf("defaults = (%q, %q, %q, %d), want ('', '', '', 0)", tier, category, summary, selfRelated)
}
if createdAt == "" {
t.Fatal("created_at should be filled by the migration default")
}
// 重复 up:已记录的迁移被跳过,结构仍在且不报错(幂等)。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp second call: %v", err)
}
if !schemaObjectExists(t, ctx, conn, "table", "channel_events") {
t.Fatal("channel_events should still exist after idempotent re-run")
}
// 恰好回滚到 0021(含它之后新加的迁移),日后再加迁移也不会让本测试失效。
ids := upMigrationIDs(t)
idx := -1
for i, id := range ids {
if id == channelEventsMigrationID {
idx = i
}
}
if idx < 0 {
t.Fatalf("%s not found in %v", channelEventsMigrationID, ids)
}
if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err)
}
for _, table := range []string{"channel_events", "channel_event_counters"} {
if schemaObjectExists(t, ctx, conn, "table", table) {
t.Fatalf("%s should be dropped by the 0021 down", table)
}
}
// 重新 up 恢复端到端可用。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("re-MigrateUp: %v", err)
}
for _, table := range []string{"channel_events", "channel_event_counters"} {
if !schemaObjectExists(t, ctx, conn, "table", table) {
t.Fatalf("%s should exist again after re-MigrateUp", table)
}
}
}
// TestMigrate0021CountersUniqueKey_正常 钉住计数表唯一键的**行为**(不只是「索引
// 存在」):同一 `(platform, account_id, platform_type)` 二次 upsert 必须累加成
// **一行**,否则「上游推过几次」的答案会分裂成多行、计数上界也随之失控
// (design/26 §4.1 表二的全部理由)。
func TestMigrate0021CountersUniqueKey_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
const upsert = `INSERT INTO channel_event_counters
(platform, account_id, tier, platform_type, category, count, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(platform, account_id, platform_type) DO UPDATE SET
count = count + excluded.count,
last_seen_at = excluded.last_seen_at`
for _, count := range []int{1, 2} {
if _, err := conn.ExecContext(ctx, upsert,
"qq-obv11", "bot1", "t1", "heartbeat", "heartbeat", count,
"2026-10-05T00:00:00.000Z", "2026-10-05T00:00:00.000Z"); err != nil {
t.Fatalf("upsert counter (%d): %v", count, err)
}
}
var rows, total int
if err := conn.QueryRowContext(ctx,
`SELECT count(*), COALESCE(sum(count), 0) FROM channel_event_counters WHERE platform = ?`,
"qq-obv11").Scan(&rows, &total); err != nil {
t.Fatalf("read counters: %v", err)
}
if rows != 1 {
t.Fatalf("counter rows = %d, want 1 (the unique key must merge, not duplicate)", rows)
}
if total != 3 {
t.Fatalf("counter total = %d, want 3 (1 + 2)", total)
}
}
@@ -0,0 +1,10 @@
-- 0021 的回滚:先删索引、再删表(与 0019 的回滚同一顺序口径)。
-- 两张表都是本迁移新建的,回滚不触碰任何既有表。
DROP INDEX IF EXISTS idx_channel_event_counters_category;
DROP INDEX IF EXISTS idx_channel_event_counters_key;
DROP TABLE IF EXISTS channel_event_counters;
DROP INDEX IF EXISTS idx_channel_events_tier;
DROP INDEX IF EXISTS idx_channel_events_channel_id;
DROP INDEX IF EXISTS idx_channel_events_platform_category;
DROP INDEX IF EXISTS idx_channel_events_created_at;
DROP TABLE IF EXISTS channel_events;
@@ -0,0 +1,71 @@
-- 0021_channel_events: 渠道非消息事件的**留存地基**(design/26 §4.1;切片 E1)。
--
-- 动因(design/26 §0.2,已确证事故):qq_obv11 的入站总闸曾把 post_type != message
-- 的帧零日志、零计数地丢掉,于是「上游推过没有 / 是不是被我们过滤了」在数据里
-- 再也答不上来。本表就是那块地基:让到达适配器的每一帧至少留下可查的结构级痕迹。
--
-- 两张表**刻意不合并**(design/26 §4.1 已论证):
-- ① 写放大 —— 心跳 / client_status / 在线状态是高频遥测,逐帧一行会把库写胖;
-- 计数表按「平台+账号+平台原生类型」upsert,行数上界是「平台数 × 事件类型数」。
-- ② 保留期不同 —— channel_events 有审计价值、按保留期清扫;
-- 计数表本身就是「历史已浓缩成一行」的形态,**删了就再也答不上「推过没有」**,
-- 因此它保留 first_seen_at / last_seen_at,**不参与 GC**。
-- ③ 两张表不同构(一张逐条、一张聚合),不是「同一份东西抄了两遍」。
-- T3 与 T2 的列**完全相同**(只差 tier 取值),故合在同一张表里:拆开会让「单一
-- 查询面 / 单一保留期 / 单一 GC」变成三份实现(design/26 §4.1)。
--
-- 命名口径:表名复数、主键 id、时间戳 created_at/updated_at、索引 idx_{table}_{col}
-- (援引 AGENTS §10 已登记例外,与既有迁移一致)。时间戳沿用 0001 起的
-- `strftime('%Y-%m-%dT%H:%M:%fZ','now')`(UTC ISO-8601 毫秒),定宽 ⇒ 字典序比较
-- 等价于时间序,时间窗筛选因此能直接吃上 idx_channel_events_created_at。
--
-- 脱敏(design/26 §4.2,**表结构层面也留了痕**):本表**没有**承载平台原文的列 ——
-- summary 只存适配器按事件类型生成的**固定文案**,OneBot request.flag(可执行凭据)
-- 与平台正文**绝不入库**(故本迁移里连一个 flag/comment/raw/content 列都不存在)。
CREATE TABLE IF NOT EXISTS channel_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL, -- qq-obv11 / kook / telegram / weixin-ilink / webhook
account_id TEXT NOT NULL DEFAULT '', -- 多账号(CHANNELS_JSON 的账号 id);空 = 单账号/未知
tier TEXT NOT NULL DEFAULT '', -- t1 / t2 / t3(design/26 §2 的留存档)
category TEXT NOT NULL DEFAULT '', -- 归一化类别(join/leave/recall/ban/admin/friend_request/
-- group_request/poke/reaction/button/item/vote/link/unknown)
platform_type TEXT NOT NULL DEFAULT '', -- 平台原生类型码(notice_type / meta_event_type / request_type /
-- KOOK extra.type / Telegram update 键名),原样保留便于排障
channel_id TEXT NOT NULL DEFAULT '',
channel_type TEXT NOT NULL DEFAULT '', -- direct / group
group_id TEXT NOT NULL DEFAULT '',
actor_id TEXT NOT NULL DEFAULT '', -- 结构化字段,**绝不来自文本**
actor_role TEXT NOT NULL DEFAULT '', -- owner/member/system/unknown(结构化解析所得)
target_id TEXT NOT NULL DEFAULT '', -- 被作用者(poke / 禁言 / 运气王…)
self_related INTEGER NOT NULL DEFAULT 0, -- 是否命中 self_id / 她自己
message_id TEXT NOT NULL DEFAULT '', -- 关联消息 id(撤回 / 回应 / 按钮 / 置顶)
summary TEXT NOT NULL DEFAULT '', -- 脱敏结构级摘要(固定文案,不含平台原文)
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- 查询面与 GC 的**唯一依赖**:列表倒序、时间窗筛选、过期行清扫都靠它。
CREATE INDEX IF NOT EXISTS idx_channel_events_created_at ON channel_events (created_at);
-- 面板/端点的「平台 + 类别」筛选(design/26 §4.5 的查询参数)。
CREATE INDEX IF NOT EXISTS idx_channel_events_platform_category ON channel_events (platform, category);
CREATE INDEX IF NOT EXISTS idx_channel_events_channel_id ON channel_events (channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_events_tier ON channel_events (tier);
CREATE TABLE IF NOT EXISTS channel_event_counters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
account_id TEXT NOT NULL DEFAULT '',
tier TEXT NOT NULL DEFAULT '',
platform_type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- 唯一约束承载 upsert(`ON CONFLICT(platform,account_id,platform_type) DO UPDATE`):
-- 一次 flush 因此是同键累加,而不是每次心跳插一行。
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_event_counters_key
ON channel_event_counters (platform, account_id, platform_type);
CREATE INDEX IF NOT EXISTS idx_channel_event_counters_category ON channel_event_counters (category);
+261
View File
@@ -0,0 +1,261 @@
package events
// 本文件归属**计数面**(design/26 §4.1 表二 + §4.3;切片 E1):内存聚合 + 有界
// 缓冲 + 定期 flush + 逐类汇总读取。
//
// 为什么计数不逐帧落行:心跳 / 在线状态是**高频遥测**,逐帧插一行会把库写胖,而
// 问题「上游到底推过没有」只要求「某个平台某类事件推过几次」。按
// `(platform, account_id, platform_type)` upsert 之后,行数上界是「平台数 × 事件
// 类型数」,与帧数无关(§4.1 表二)。
//
// 三条不变量(每条都有用例钉住):
//
// 1. **有界**:待落盘的键数绝不超过 `Config.MaxPendingKeys`。缓冲满了先整批落盘,
// 绝不无界增长(§4.3 的背压;对齐 AGENTS §10 的压测纪律:有界,不靠堆进程)。
// 2. **尽力而为**:落盘失败只记 WARN / 丢键记 DEBUG,**绝不**让 `Record` 失败 ——
// 入站流水线不该因为计数写不进去而失败或丢帧(§4.3)。
// 3. **首次权威**:同一个键的 tier/category 只认第一次(upsert 不刷写这两列),
// 与 `documents` 的 `ON CONFLICT(name) DO NOTHING` 同口径。
import (
"context"
"fmt"
"sync"
"time"
)
// counterKey 是计数聚合的键,**逐列对齐**迁移 0021 的唯一索引
// `idx_channel_event_counters_key (platform, account_id, platform_type)`。
// 键里刻意不含 tier/category:它们由「平台原生类型」决定,不该让同一类帧分裂成
// 两行(那会让计数上界失控,也让「推过几次」的答案分叉)。
type counterKey struct {
platform string
accountID string
platformType string
}
// counterEntry 是一个键的累计值与归类。
type counterEntry struct {
tier Tier
category Category
count int64
firstSeen time.Time
lastSeen time.Time
}
// counterBuffer 是内存计数聚合。它由互斥量保护(Record 可能来自多条渠道 goroutine)。
//
// 锁的纪律(AGENTS §六):**锁内绝不做 IO** —— drain 在锁内只做「搬运 + 清空」,
// 真正的落盘在锁外进行;失败时的回填也走一次短暂的加锁。
type counterBuffer struct {
mu sync.Mutex
max int
pending map[counterKey]counterEntry
}
// newCounterBuffer 构造聚合缓冲;最大键数 ≤0 时取 DefaultMaxPendingKeys。
func newCounterBuffer(max int) *counterBuffer {
if max <= 0 {
max = DefaultMaxPendingKeys
}
return &counterBuffer{max: max, pending: make(map[counterKey]counterEntry, max)}
}
// add 把一帧的计数累加进缓冲。返回值报告**这次计数是否被吞掉**:缓冲已达最大键数
// 且这是一个新键时为 false(该键不落内存)。它是「有界」在代码里的落点 ——
// 无论落盘成功与否,`len()` 都绝不超过 `max`。
func (b *counterBuffer) add(key counterKey, tier Tier, category Category, at time.Time) bool {
b.mu.Lock()
defer b.mu.Unlock()
if entry, ok := b.pending[key]; ok {
entry.count++
entry.lastSeen = at
b.pending[key] = entry
return true
}
if len(b.pending) >= b.max {
return false
}
b.pending[key] = counterEntry{tier: tier, category: category, count: 1, firstSeen: at, lastSeen: at}
return true
}
// len 返回待落盘的键数(≤ max)。
func (b *counterBuffer) len() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.pending)
}
// drain 取走全部待落盘项并清空缓冲(锁内只搬运,不 IO)。
func (b *counterBuffer) drain() map[counterKey]counterEntry {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.pending) == 0 {
return nil
}
out := b.pending
b.pending = make(map[counterKey]counterEntry, b.max)
return out
}
// restore 把一次**落盘失败**的项回填进缓冲,使计数不在瞬时 DB 故障里丢掉。
// 回填同样受 `max` 约束:装不下的项被丢弃并返回丢弃数(调用方记 WARN)——
// 「尽力而为」与「有界」冲突时,**有界**优先(§4.3 的背压是硬约束)。
func (b *counterBuffer) restore(entries map[counterKey]counterEntry) int {
b.mu.Lock()
defer b.mu.Unlock()
dropped := 0
for key, entry := range entries {
if existing, ok := b.pending[key]; ok {
// 落盘失败期间同一键可能又累加了新计数:合并而不是覆盖。
existing.count += entry.count
if entry.lastSeen.After(existing.lastSeen) {
existing.lastSeen = entry.lastSeen
}
if entry.firstSeen.Before(existing.firstSeen) {
existing.firstSeen = entry.firstSeen
}
b.pending[key] = existing
continue
}
if len(b.pending) >= b.max {
dropped++
continue
}
b.pending[key] = entry
}
return dropped
}
// recordCounter 把一帧计入内存聚合(design/26 §4.3:T1 与**全部帧**都计数)。
//
// 它**不返回错误**:计数是尽力而为,绝不让入站流水线因计数而失败。缓冲满时先尝试
// 整批落盘;落盘失败记 WARN(DB 出问题时每次 Record 都会试一次,这也让「DB 恢复后
// 自动收敛」不需要额外的重试器)。被吞掉的新键记 DEBUG —— 单个键的丢弃是上述 WARN
// 的后果,不在这里重复刷 WARN(避免 DB 长时间不可用时日志洪泛)。
func (s *SQLStore) recordCounter(ctx context.Context, ev Event) {
if s.counters.len() >= s.counters.max {
if err := s.flushCounters(ctx); err != nil {
s.log.Warn("channel event counter flush failed",
"err", err, "pending", s.counters.len(), "max_pending", s.counters.max)
}
}
key := counterKey{platform: ev.Platform, accountID: ev.AccountID, platformType: ev.PlatformType}
if !s.counters.add(key, ev.Tier, ev.Category, ev.CreatedAt) {
s.log.Debug("channel event counter key dropped: buffer full",
"platform", ev.Platform, "platform_type", ev.PlatformType)
}
}
// Flush 把内存里待落盘的计数写进库(组合根的常驻 ticker 与退出前的最后一次调用
// 都走它)。没有待落盘项时是空操作。
func (s *SQLStore) Flush(ctx context.Context) error {
return s.flushCounters(ctx)
}
// flushCounters 是 Flush 的实现:取走 → 落盘 → 失败回填。
func (s *SQLStore) flushCounters(ctx context.Context) error {
pending := s.counters.drain()
if len(pending) == 0 {
return nil
}
if err := s.writeCounters(ctx, pending); err != nil {
if dropped := s.counters.restore(pending); dropped > 0 {
s.log.Warn("channel event counter entries dropped after failed flush",
"dropped", dropped, "max_pending", s.counters.max)
}
return fmt.Errorf("events: flush 计数: %w", err)
}
s.log.Debug("channel event counters flushed", "keys", len(pending))
return nil
}
// upsertCounterSQL 是批量落盘的唯一语句:单事务 + 同一条语句反复执行,避免 N 次
// 往返以外的组织开销(design/26 §4.3)。`count = count + excluded.count` 使同键
// 累加;tier/category 只在**插入**时写入,冲突时保持首次的归类(首次权威)。
const upsertCounterSQL = `INSERT INTO channel_event_counters
(platform, account_id, tier, platform_type, category, count, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(platform, account_id, platform_type) DO UPDATE SET
count = count + excluded.count,
last_seen_at = excluded.last_seen_at`
// writeCounters 在一个事务里 upsert 整批计数:要么全落,要么全不落(失败时由
// flushCounters 回填缓冲,交给下一次 flush 重试)。
func (s *SQLStore) writeCounters(ctx context.Context, pending map[counterKey]counterEntry) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("events: 计数落盘开事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
for key, entry := range pending {
if _, err := tx.ExecContext(ctx, upsertCounterSQL,
key.platform, key.accountID, string(entry.tier), key.platformType, string(entry.category),
entry.count, sqliteTimeStamp(entry.firstSeen), sqliteTimeStamp(entry.lastSeen)); err != nil {
return fmt.Errorf("events: 计数落盘(platform=%s type=%s): %w", key.platform, key.platformType, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("events: 计数落盘提交: %w", err)
}
return nil
}
// listCountersSQL 的列显式列出(禁 `SELECT *`),排序固定,便于客户端做稳定展示。
const listCountersSQL = `SELECT platform, account_id, tier, platform_type, category, count,
first_seen_at, last_seen_at
FROM channel_event_counters`
// Counters 返回逐类计数汇总,按 `(platform, account_id, platform_type)` 升序。
// platform 为空表示不过滤(返回全部平台)。
//
// 它读的是**已落盘**的表,不含内存里尚未 flush 的部分 —— 这正是「计数为近似值」
// 的来源,查询面必须如实标注(design/26 §4.3/§4.5)。需要新鲜数据时先 Flush。
func (s *SQLStore) Counters(ctx context.Context, platform string) ([]Counter, error) {
query := listCountersSQL
args := make([]any, 0, 1)
if platform != "" {
query += " WHERE platform = ?"
args = append(args, platform)
}
query += " ORDER BY platform, account_id, platform_type"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("events: 查计数行: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]Counter, 0, 16)
for rows.Next() {
var (
c Counter
tier, category string
first, last string
)
if err := rows.Scan(&c.Platform, &c.AccountID, &tier, &c.PlatformType, &category,
&c.Count, &first, &last); err != nil {
return nil, fmt.Errorf("events: 读计数行: %w", err)
}
c.Tier = Tier(tier)
c.Category = Category(category)
at, ok := parseSQLiteTime(first)
if !ok {
return nil, fmt.Errorf("events: 计数行 %s/%s 的 first_seen_at 不可解析(%q): %w",
c.Platform, c.PlatformType, first, ErrBadTimestamp)
}
c.FirstSeenAt = at
if ts, ok := parseSQLiteTime(last); ok {
c.LastSeenAt = ts
} else {
c.LastSeenAt = at
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("events: 遍历计数行: %w", err)
}
return out, nil
}
+210
View File
@@ -0,0 +1,210 @@
package events
// 本文件是**计数面**的验收(design/26 §4.1 表二 + §4.3;切片 E1)。三条不变量在这里
// 各自有对应用例:**有界**(缓冲满即落盘、绝不无界增长)、**尽力而为**(落盘失败
// 不上抛、不丢帧)、**近似值语义**(读的是已落盘的表,不是内存里的实时值)。
import (
"context"
"fmt"
"testing"
"time"
)
// TestCounters_BoundedBuffer_边界:缓冲设上界后,**必须**在满时整批落盘,且待落盘
// 键数在任何时刻都不超过上界(design/26 §4.3 的背压;对齐 AGENTS §10 压测纪律的
// 「有界」)。同时验证落盘不是「丢计数」:最终总数与录制条数一致。
func TestCounters_BoundedBuffer_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
const maxKeys = 4
st, _ := newTestStore(t, func(cfg *Config) { cfg.MaxPendingKeys = maxKeys })
const frames = 20
for i := 0; i < frames; i++ {
if err := st.Record(ctx, Event{
Platform: PlatformQQ,
Tier: TierT1,
Category: CategoryHeartbeat,
PlatformType: fmt.Sprintf("type-%02d", i),
}); err != nil {
t.Fatalf("Record(#%d): %v", i, err)
}
if got := st.PendingKeys(); got > maxKeys {
t.Fatalf("PendingKeys = %d after frame #%d, want ≤ %d(缓冲必须有界)", got, i, maxKeys)
}
}
// 「有界」的直接推论:这一路必须**已经落过盘**(否则内存里会攒着 20 个键)。
flushed, err := st.Counters(ctx, PlatformQQ)
if err != nil {
t.Fatalf("Counters: %v", err)
}
if len(flushed) == 0 {
t.Fatal("no counters flushed to the database: a bounded buffer must flush when full")
}
// 落盘 = 计数搬家而不是丢掉:最后一次 flush 后总数必须恰为 frames。
if err := st.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
var total int64
for _, c := range mustCounters(t, st, PlatformQQ) {
total += c.Count
}
if total != frames {
t.Fatalf("flushed count total = %d, want %d", total, frames)
}
if got := st.PendingKeys(); got != 0 {
t.Fatalf("PendingKeys = %d after Flush, want 0", got)
}
}
// TestCounters_FlushFailureStaysBounded_异常:数据库不可用时,计数落盘失败只记 WARN、
// **不上抛**(Record 仍返回 nil —— 计数是尽力而为,绝不让入站流水线失败,§4.3),
// 并且缓冲**仍然有界**(绝不因为一直失败而无界增长)。
func TestCounters_FlushFailureStaysBounded_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
const maxKeys = 2
st, conn := newTestStore(t, func(cfg *Config) { cfg.MaxPendingKeys = maxKeys })
if err := conn.Close(); err != nil {
t.Fatalf("关闭内存库: %v", err)
}
for i := 0; i < 5; i++ {
if err := st.Record(ctx, Event{
Platform: PlatformQQ,
Tier: TierT1,
Category: CategoryHeartbeat,
PlatformType: fmt.Sprintf("type-%02d", i),
}); err != nil {
t.Fatalf("Record(#%d) err = %v, want nil(计数失败绝不能上抛)", i, err)
}
if got := st.PendingKeys(); got > maxKeys {
t.Fatalf("PendingKeys = %d after failing flush #%d, want ≤ %d", got, i, maxKeys)
}
}
if got := st.PendingKeys(); got != maxKeys {
t.Fatalf("PendingKeys = %d, want %d (失败项回填后仍受上界约束)", got, maxKeys)
}
}
// TestCounters_SameKeyAccumulates_正常:同一个键(平台+账号+原生类型)反复计数是
// **累加同一行**,且 tier/category 首次权威、first/last_seen_at 如实推进。
func TestCounters_SameKeyAccumulates_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
ev := Event{
Platform: PlatformQQ,
AccountID: "bot-1",
Tier: TierT1,
Category: CategoryHeartbeat,
PlatformType: "heartbeat",
}
for i := 0; i < 3; i++ {
if err := st.Record(ctx, ev); err != nil {
t.Fatalf("Record(#%d): %v", i, err)
}
}
if err := st.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
later := ev
later.CreatedAt = testNow().Add(time.Minute)
for i := 0; i < 2; i++ {
if err := st.Record(ctx, later); err != nil {
t.Fatalf("Record(later #%d): %v", i, err)
}
}
if err := st.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
rows := mustCounters(t, st, PlatformQQ)
if len(rows) != 1 {
t.Fatalf("counter rows = %d, want 1 (同键必须合并)", len(rows))
}
got := rows[0]
if got.Count != 5 {
t.Fatalf("count = %d, want 5 (3 + 2)", got.Count)
}
if !got.FirstSeenAt.Equal(testNow()) {
t.Fatalf("first_seen_at = %v, want %v (首次权威)", got.FirstSeenAt, testNow())
}
if !got.LastSeenAt.Equal(later.CreatedAt) {
t.Fatalf("last_seen_at = %v, want %v", got.LastSeenAt, later.CreatedAt)
}
if got.AccountID != "bot-1" || got.PlatformType != "heartbeat" {
t.Fatalf("key columns = (%q, %q), want (bot-1, heartbeat)", got.AccountID, got.PlatformType)
}
}
// TestCounters_PlatformFilter_正常:Counters 的 platform 参数为空表示全部平台,
// 非空即精确过滤(design/26 §4.5 的查询参数语义)。
func TestCounters_PlatformFilter_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
for _, platform := range []string{PlatformQQ, PlatformKook} {
if err := st.Record(ctx, Event{
Platform: platform, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: "heartbeat",
}); err != nil {
t.Fatalf("Record(%s): %v", platform, err)
}
}
if err := st.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
if rows := mustCounters(t, st, ""); len(rows) != 2 {
t.Fatalf("all platforms = %d rows, want 2", len(rows))
}
rows := mustCounters(t, st, PlatformKook)
if len(rows) != 1 || rows[0].Platform != PlatformKook {
t.Fatalf("filtered rows = %+v, want exactly the kook counter", rows)
}
if rows := mustCounters(t, st, PlatformTelegram); len(rows) != 0 {
t.Fatalf("unknown platform = %d rows, want 0", len(rows))
}
}
// TestCounters_PendingNotVisibleBeforeFlush_边界:Counters 读的是**已落盘**的表 ——
// 这正是「计数为近似值」的来源(design/26 §4.3/§4.5),查询面必须如实标注。
func TestCounters_PendingNotVisibleBeforeFlush_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
if err := st.Record(ctx, Event{
Platform: PlatformQQ, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: "heartbeat",
}); err != nil {
t.Fatalf("Record: %v", err)
}
if rows := mustCounters(t, st, PlatformQQ); len(rows) != 0 {
t.Fatalf("rows before Flush = %d, want 0(内存计数不算已落盘)", len(rows))
}
if st.PendingKeys() != 1 {
t.Fatalf("PendingKeys = %d, want 1", st.PendingKeys())
}
if err := st.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
if rows := mustCounters(t, st, PlatformQQ); len(rows) != 1 {
t.Fatalf("rows after Flush = %d, want 1", len(rows))
}
}
// mustCounters 是 Counters 的测试包装(失败即 Fatal)。
func mustCounters(t *testing.T, st *SQLStore, platform string) []Counter {
t.Helper()
rows, err := st.Counters(context.Background(), platform)
if err != nil {
t.Fatalf("Counters(%q): %v", platform, err)
}
return rows
}
+264
View File
@@ -0,0 +1,264 @@
// Package events 是**渠道事件留存**的存储地基(design/26 §4;切片 E1)。
//
// 它补的是 design/26 §0.2 那次无法定案的事故:适配器把非消息帧静默丢掉之后,
// 「上游推过没有 / 是不是被我们过滤了」在数据里再也答不上来。本包只做一件事 ——
// 让到达适配器的每一帧至少留下**可查**(逐条落行)且**可计数**(按平台+账号+
// 平台原生类型聚合)的脱敏结构痕迹。
//
// 两张表的分工(design/26 §4.1,刻意不合并,理由见迁移 0021):
//
// - `channel_events`:T2/T3 逐条、全部 unknown 与链路事件;低频、有审计价值、
// 参与保留期 GC(Sweep);
// - `channel_event_counters`:所有帧的计数,行数上界 = 平台数 × 事件类型数,
// **不参与 GC**(删了就答不上「上游推过没有」)。
//
// 职责边界(别在这里越界):
//
// - **不判身份、不判权限**:actor/role 由 channel 层的结构化元数据 + 白名单 +
// 策略引擎决定,本包只如实落列(AGENTS §9 红线);
// - **不决定进不进上下文**:那是 `adapter.NoticeClass`(design/26 §2.3 的另一个
// 正交轴)与 channelbridge 的事;
// - **适配器不依赖本包**:design/26 §6.1 要求适配器对存储零依赖,写入统一由
// 桥接层(E3)经 `channelbridge.Options.EventStore` 调用。
//
// 脱敏是**调用方的责任**(design/26 §4.2):`Event.Summary` 必须是适配器按事件类型
// 生成的固定文案,净化走既有的 `adapter.SanitizeNoticeText`(唯一出处,本包不另造)。
// 因此 `Event` 里**没有任何承载平台原文的字段** —— 尤其 OneBot `request.flag`
// (可执行凭据,等同 token)**不在本结构里,也绝不得入表**(design/26 §4.2 特例治理)。
package events
import (
"context"
"errors"
"time"
)
// 存储侧的哨兵错误。调用方一律用 `errors.Is` 分类,**绝不按错误文本匹配**。
var (
// ErrNilDB 报告构造 Store 时没有可用的数据库连接。宁可明确失败,也不给出
// 一个写不进去的替身(与 `history.NewStore` 同一口径)。
ErrNilDB = errors.New("events: 缺少数据库连接")
// ErrNoPlatform 报告一条事件没有来源平台。平台是「哪条链路」的唯一标识,
// 缺了它这一行既查不到也归不了类 —— 这是调用方的编程错误,必须显式失败,
// 而不是写一行 platform='' 的行进库。
ErrNoPlatform = errors.New("events: 事件缺少来源平台")
// ErrBadTimestamp 报告读回的事件行携带一个无法解析的时间戳。宁可如实报错,
// 也不静默给一个零值时间(那会让 GC 与查询面基于假时刻做判断)。
ErrBadTimestamp = errors.New("events: 事件行时间戳不可解析")
)
// Tier 是 design/26 §2 的**留存档**:回答「要不要留在本地、要不要告知、怎么归档」。
// 它与 `adapter.NoticeClass`(回答「进不进她的上下文」)是**两个正交轴**(§2.3),
// 两轴的结果都落库:前者是本包的行/计数归属,后者记进日志与事件行之外。
type Tier string
// 三档留存(design/26 §2.1)。取值即数据库列 `tier` 的字面值。
const (
// TierT1 只留存:纯遥测 / 存在性 / 资源(心跳、生命周期、他人在线状态、
// 资料与频道/角色/表情增删改…)。**绝不进模型上下文,绝不推送**。
TierT1 Tier = "t1"
// TierT2 留存 + 上行:成员 / 权限 / 消息状态的变动(成员进出、撤回、禁言、
// 管理员变更、加好友/加群请求…)。落行 + 以独立 system 行告知她。
TierT2 Tier = "t2"
// TierT3 交互事件:人对人的定向动作(戳一戳、表情回应、按钮、道具、投票…),
// 统一形态是「谁对谁做了什么」。默认只登记、不自动回应(§5.5)。
TierT3 Tier = "t3"
)
// Category 是**归一化类别**:把各平台的原生类型码收敛成一套跨平台可查的词汇。
//
// 每个常量的取值都取自 design/26 §4.1 的列注释与 §2.4 的归一化产出(不自行发明);
// 平台原生码原样留在 `Event.PlatformType` 里,排障时两者并用(design/26 §2.4)。
type Category string
// 归一化类别。前 15 项逐个对应 design/26 §4.1 的 category 注释,
// 后 2 项取自 §3.7(帧解析失败)与 §3.6(适配器链路事件)。
const (
// CategoryHeartbeat 心跳(OneBot meta_event.heartbeat)。
CategoryHeartbeat Category = "heartbeat"
// CategoryLifecycle 生命周期(meta_event.lifecycle 的 connect/enable/disable)。
CategoryLifecycle Category = "lifecycle"
// CategoryJoin 成员加入(进群 / 进服务器)。
CategoryJoin Category = "join"
// CategoryLeave 成员离开(退群 / 被踢 / 退服)。
CategoryLeave Category = "leave"
// CategoryRecall 消息被撤回。
CategoryRecall Category = "recall"
// CategoryBan 禁言 / 解禁(含全员禁言归一成的独立类别)。
CategoryBan Category = "ban"
// CategoryAdmin 管理员身份变更。
CategoryAdmin Category = "admin"
// CategoryFriendRequest 加好友请求(OneBot request.friend;**默认不自动同意**)。
CategoryFriendRequest Category = "friend_request"
// CategoryGroupRequest 加群请求 / 邀请入群(OneBot request.group)。
CategoryGroupRequest Category = "group_request"
// CategoryPoke 戳一戳(定向交互)。
CategoryPoke Category = "poke"
// CategoryReaction 表情回应(Telegram message_reaction / KOOK added_reaction…)。
CategoryReaction Category = "reaction"
// CategoryButton 按钮点击(callback_query / message_btn_click)。
CategoryButton Category = "button"
// CategoryItem 道具 / 礼物 / 支付(KOOK type=12 道具消息、Telegram 礼物流程…)。
CategoryItem Category = "item"
// CategoryVote 投票状态与投票作答(poll / poll_answer)。
CategoryVote Category = "vote"
// CategoryLink 适配器自身链路事件(连接 / 断开 / 重连 / 会话被拒),§3.6。
CategoryLink Category = "link"
// CategoryUnknown 未知类型(未知 post_type / notice_type / extra.type /
// Update 字段)。**兜底不等于放行**:它照旧落行计数,但绝不进上下文(§3.7)。
CategoryUnknown Category = "unknown"
// CategoryDecodeError 帧解析失败(JSON 坏)。按 §3.7 留痕 + 计数,
// **只记错误类型与字节数,绝不记原文**。
CategoryDecodeError Category = "decode_error"
)
// 平台标识(取值即 design/26 §4.1 的 platform 列注释)。它们同时也是
// `channel_event_counters` 唯一键的第一列。
const (
// PlatformQQ 是 OneBot v11(go-cqhttp 家族)渠道。
PlatformQQ = "qq-obv11"
// PlatformKook 是 KOOK 渠道。
PlatformKook = "kook"
// PlatformTelegram 是 Telegram 渠道。
PlatformTelegram = "telegram"
// PlatformWeixinILink 是微信 iLink 渠道(平台事实:该渠道**没有** T2/T3 事件,
// design/26 §3.4 要求查询面如实标注,**绝不伪造、绝不用别的渠道顶替**)。
PlatformWeixinILink = "weixin-ilink"
// PlatformWebhook 是通用 webhook 渠道。
PlatformWebhook = "webhook"
)
// 会话形态与行为者角色(取值即迁移 0021 的列注释)。
const (
// ChannelDirect 是私聊会话。
ChannelDirect = "direct"
// ChannelGroup 是群聊 / 频道会话。
ChannelGroup = "group"
// RoleOwner 是被白名单认定为主人的行为者。
RoleOwner = "owner"
// RoleMember 是普通成员。
RoleMember = "member"
// RoleSystem 是平台系统自身(合成 id "system",**任何平台字段都不得冒充主人**)。
RoleSystem = "system"
// RoleUnknown 是未能结构化归类的行为者。它**不是**「陌生人」的对等物:
// 判主与授权永远由结构化字段 + 白名单 + 策略引擎决定(design/26 §1.2)。
RoleUnknown = "unknown"
)
// Event 是写入 `channel_events` 的一行(迁移 0021)。列与 design/26 §4.1 一一对应。
//
// 它**不是**帧的容器:原始帧留在适配器侧的脱敏 DEBUG 日志与 `RawData` 里,本结构
// 只承载「可查的结构级事实」(§4.2 的「能落」清单)。两个字段刻意缺席:
//
// - OneBot `request.flag` —— 可执行凭据,等同 token,**绝不入库**(§4.2);
// - 任何平台正文 / 验证信息 / 媒体 URL —— 属会话内容或临时凭据,事件表不是它们的家。
type Event struct {
// ID 是数据库自增主键。**写侧忽略它**,只在 List 的结果里回填,便于排障与分页定位。
ID int64
// Platform 是来源平台(platform 常量之一)。**必填**,否则 Record 返回 ErrNoPlatform。
Platform string
// AccountID 是多账号标识(CHANNELS_JSON 的账号 id);空 = 单账号 / 未知。
AccountID string
// Tier 是留存档。**取值非法时按 TierT1 处理并记 WARN**(fail-safe:宁可只计数,
// 也绝不因为一个字段写错而把整帧丢出留存面)。
Tier Tier
// Category 是归一化类别。
Category Category
// PlatformType 是平台原生类型码,原样保留便于排障(notice_type / meta_event_type /
// request_type / KOOK extra.type / Telegram update 键名)。
PlatformType string
// ChannelID 是会话 / 频道标识。
ChannelID string
// ChannelType 是会话形态(direct / group)。
ChannelType string
// GroupID 是群标识(会话之外单独留一列,便于按群查询)。
GroupID string
// ActorID 是行为者的**结构化 id**(绝不来自昵称或正文)。
ActorID string
// ActorRole 是行为者角色(结构化解析所得,取值见 Role* 常量)。
ActorRole string
// TargetID 是被作用者(poke 的 target、被禁言者、运气王…);无则空。
TargetID string
// SelfRelated 报告该事件是否命中 self_id / 她自己(design/26 §2.2 的升档依据)。
SelfRelated bool
// MessageID 是关联消息 id(撤回 / 回应 / 按钮 / 置顶)。
MessageID string
// Summary 是**脱敏结构级摘要**:适配器按事件类型生成的固定文案,一行常量,
// 不含任何平台原文。净化唯一出处是 `adapter.SanitizeNoticeText`(§4.2)。
Summary string
// CreatedAt 是事件发生时刻。零值 ⇒ 由 Store 填当前时刻(测试可注入时钟)。
CreatedAt time.Time
// UpdatedAt 是行被读回时的更新时间戳。事件行不可变,写侧忽略它(写时与
// CreatedAt 同值),只在 List 的结果里回填。
UpdatedAt time.Time
}
// Filter 是事件查询条件(List / Count 共用)。零值字段一律表示「不过滤」。
type Filter struct {
// Platform / Tier / Category / ChannelID 精确匹配;空串(或零值档)不过滤。
Platform string
Tier Tier
Category Category
ChannelID string
// Since / Until 是闭区间时间窗;零值不过滤。比较按 UTC 定宽字符串的字典序
// 进行(与 `llm/usage` 同口径,定宽 ⇒ 字典序等价于时间序,且吃得上
// idx_channel_events_created_at)。
Since time.Time
Until time.Time
// Limit / Offset 只作用于 List。Limit ≤0 取 DefaultListLimit,超过
// MaxListLimit 被**钳到上限**(绝不静默返回全表)。
Limit int
Offset int
}
// Counter 是 `channel_event_counters` 的一行:某个平台某类事件的累计计数。
//
// 它是**近似值**:计数在内存里聚合、定期 flush(design/26 §4.3),crash 会丢
// ≤一个 flush 间隔的计数。查询面(E6)必须对此如实标注 `approx:true`,**绝不假装
// 它是精确账**。
type Counter struct {
// Platform / AccountID / PlatformType 是唯一键的三列。
Platform string
AccountID string
PlatformType string
// Tier / Category 是**首次**见到该键时记下的归类(同键后续帧不刷写它们,
// 与 `documents` 的 `ON CONFLICT DO NOTHING` 同一「首次权威」口径)。
Tier Tier
Category Category
// Count 是累计帧数。
Count int64
// FirstSeenAt / LastSeenAt 是该键首次 / 最近一次被计数的时刻。它们让「上游
// 到底推没推」在**没有逐条行**(T1)时依然答得出来(§4.1 不删计数表的理由)。
FirstSeenAt time.Time
LastSeenAt time.Time
}
// Store 是事件留存的**唯一对外面**(design/26 §6.3)。桥接层(E3)与组合根(E4)
// 只依赖它,不依赖 `SQLStore` 这个具体类型(便于测试替身与「未接线」的 nil 判定)。
//
// 实现必须同时满足两条不变量(design/26 §4.3):
//
// - **逐条留存的失败必须上抛**(`Record`):T2/T3/unknown 有审计价值,丢不起;
// - **计数是尽力而为**:内存聚合 + 有界缓冲,绝不因为计数落盘失败而让调用方的
// 入站流水线失败或丢帧。
type Store interface {
// Record 记录一帧事件:按档决定逐条落行(T2/T3/unknown/链路)还是只累加计数
// (T1),两者都同时进计数。逐条落行失败时返回错误;计数侧失败只记日志。
Record(ctx context.Context, ev Event) error
// Count 统计符合条件的事件行数(List 的分页配套)。
Count(ctx context.Context, f Filter) (int64, error)
// List 按时间倒序返回符合条件的事件行(Limit/Offset 见 Filter)。
List(ctx context.Context, f Filter) ([]Event, error)
// Counters 返回逐类计数汇总;platform 为空表示全部平台。它读的是**已 flush
// 的表**,不含内存里尚未落盘的部分(那是近似值语义的一部分,见 Counter)。
Counters(ctx context.Context, platform string) ([]Counter, error)
// Flush 把内存里待落盘的计数写进库。组合根的常驻 ticker 与退出前的最后一次
// 调用都走它。
Flush(ctx context.Context) error
// Sweep 按保留期清扫 `channel_events` 的过期行。保留期或间隔 ≤0(GC 关闭)
// 时是空操作;计数表**不参与 GC**。
Sweep(ctx context.Context) (SweepStats, error)
}
+121
View File
@@ -0,0 +1,121 @@
package events
// 本文件归属**保留期清扫(Sweep)**(design/26 §4.4;切片 E1)。它逐字沿用
// `internal/runtime/documents/gc.go` + `cmd/cyrene/wire_document_gc.go` 的既有 GC
// 范式(design/26 §4.4 明写「不另造」),三条取舍也照抄:
//
// 1. **启动期扫一次、此后按间隔定时扫**(间隔默认 DefaultGCInterval)。启动期那次
// 与定时循环都**不**阻塞启动、不绑在磁盘上 —— 常驻循环住在组合根(E4 的
// `wire_channel_events.go` 逐字对齐 `wire_document_gc.go`),本包只提供
// `Sweep` 与它需要的三个口径(Enabled / Interval / Retention)。
// 2. **失败只记 WARN、绝不让进程退出**:`Sweep` 把查询/删除失败**上抛**给调用方
// (组合根记 WARN 后继续等下一轮);已经删掉的行不会因为后续批次失败而「回滚」,
// 未删的行也不会被清空 —— 一轮失败就只是「这一轮没扫干净」。
// 3. **≤0 = 关闭**(显式),关闭原因在 NewStore 里记进日志 —— 「配了却不生效」
// 不存在(与文档 GC 同口径)。
//
// 清理条件只有一条:`created_at < now − 保留期` 的 `channel_events` 行逐批删除。
// **计数表不参与 GC**:它本身就是「历史已浓缩成一行」的形态,删了就再也答不上
// 「上游推过没有」——那正是本切片存在的理由(design/26 §4.4)。
import (
"context"
"fmt"
"time"
)
const (
// DefaultGCInterval 是 `CHANNEL_EVENT_GC_INTERVAL` 的默认值:每小时清扫一次。
DefaultGCInterval = time.Hour
// DefaultRetention 是 `CHANNEL_EVENT_RETENTION` 的默认值:30 天。它比文档保留期
// (168h)长得多,因为事件表的核心用途是**事后定案**(design/26 §0.2)——
// 一周太短,事故复盘常常发生在一周之后(§4.4)。
DefaultRetention = 720 * time.Hour
// sweepBatchSize 是单批删除的行数上限;maxSweepBatches 是一轮清扫的批数上限。
// 两者一起把「一轮清扫」的时长与单次内存占用**有界**(AGENTS §10 的压测纪律:
// 有界,不靠堆进程):积压百万行时不是一次 DELETE 全表,而是每轮最多
// maxSweepBatches × sweepBatchSize 行,剩下的留给下一轮。
sweepBatchSize = 500
maxSweepBatches = 100
)
// SweepStats 是一轮清扫的汇总(成功件只记总数,绝不记任何行内容)。
type SweepStats struct {
// Batches 是实际执行的删除批数。
Batches int
// Deleted 是删除的过期行数。
Deleted int64
}
// Enabled 报告清扫是否真的会删行(保留期与间隔都 > 0)。
func (s *SQLStore) Enabled() bool { return s != nil && s.gcEnabled }
// Interval 返回定时清扫间隔(关闭时为 0)。
func (s *SQLStore) Interval() time.Duration {
if s == nil {
return 0
}
return s.gcInterval
}
// Retention 返回事件行保留期(关闭时为 0)。
func (s *SQLStore) Retention() time.Duration {
if s == nil {
return 0
}
return s.retention
}
// sweepSQL 按时间戳删一批过期行。子查询带 `ORDER BY created_at LIMIT ?`,因此它
// 吃得上 `idx_channel_events_created_at`,且每批的扫描面有界(design/26 §4.4)。
// 时间戳用定宽 UTC 字符串比较:字典序等价于时间序(见 store.go 的口径注释)。
const sweepSQL = `DELETE FROM channel_events WHERE id IN (
SELECT id FROM channel_events WHERE created_at < ? ORDER BY created_at LIMIT ?)`
// Sweep 按保留期清扫 `channel_events` 的过期行,返回一轮汇总。
//
// 关闭时(Enabled() 为 false)是**空操作**:返回零值与 nil 错误、不碰任何一行。
// 这与「配了却不生效」是两回事 —— 关闭原因在 NewStore 里已记 WARN。
//
// 失败语义:查询/删除失败返回错误并**带上已完成的统计**(调用方据此知道这一轮
// 走到了哪一步,记 WARN 后等下一轮)。ctx 取消时立刻停下:**已删的保持删除、
// 未删的绝不因此被清空** —— 这是「失败不清空」在代码里的落点。
func (s *SQLStore) Sweep(ctx context.Context) (SweepStats, error) {
if !s.Enabled() {
return SweepStats{}, nil
}
cutoff := sqliteTimeStamp(s.now().Add(-s.retention))
var stats SweepStats
for i := 0; i < maxSweepBatches; i++ {
if err := ctx.Err(); err != nil {
return stats, fmt.Errorf("events: sweep: %w", err)
}
deleted, err := s.sweepBatch(ctx, cutoff)
if err != nil {
return stats, err
}
stats.Batches++
stats.Deleted += deleted
if deleted < int64(sweepBatchSize) {
break
}
}
s.log.Info("channel event sweep",
"deleted", stats.Deleted, "batches", stats.Batches, "retention", s.retention.String())
return stats, nil
}
// sweepBatch 删一批过期行并返回本批删掉的行数。
func (s *SQLStore) sweepBatch(ctx context.Context, cutoff string) (int64, error) {
res, err := s.db.ExecContext(ctx, sweepSQL, cutoff, sweepBatchSize)
if err != nil {
return 0, fmt.Errorf("events: sweep 删过期行: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("events: sweep 读取删除行数: %w", err)
}
return n, nil
}
+209
View File
@@ -0,0 +1,209 @@
package events
// 本文件是**保留期清扫**的验收(design/26 §4.4;切片 E1)。三类必测:
//
// 1. **≤0 = 关闭**(保留期 ≤0、间隔 ≤0 各一条):`Enabled()` 为 false 且 `Sweep`
// 不动任何一行 —— 「配了却不生效」不存在(关闭原因由 NewStore 记 WARN);
// 2. **正数清扫**:超期行按 `created_at < now − 保留期` 逐批删除,未超期的行与原样
// 保留,计数表**不参与 GC**;
// 3. **失败不退出、不清空**:库不可用 / ctx 取消时 `Sweep` 返回错误(组合根记 WARN
// 后等下一轮,绝不因此退出进程),**已经删的保持删除、未删的绝不被清空**。
import (
"context"
"testing"
"time"
)
// seedExpired 写入一条指定时刻的 T2 事件(走真实写入路径,不直接拼 SQL)。
func seedExpired(t *testing.T, st *SQLStore, at time.Time, platformType string) {
t.Helper()
ev := sampleT2()
ev.PlatformType = platformType
ev.CreatedAt = at
if err := st.Record(context.Background(), ev); err != nil {
t.Fatalf("Record(%s): %v", platformType, err)
}
}
// TestSweep_RetentionDisabled_边界:保留期 ≤0 ⇒ GC 关闭(显式),Sweep 是空操作。
func TestSweep_RetentionDisabled_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = time.Hour
cfg.Retention = 0
})
if st.Enabled() {
t.Fatal("Enabled() = true, want false(保留期 ≤0 = 关闭 GC)")
}
if st.Interval() != time.Hour || st.Retention() != 0 {
t.Fatalf("Interval/Retention = %v/%v, want 1h/0", st.Interval(), st.Retention())
}
seedExpired(t, st, testNow().Add(-1000*time.Hour), "group_recall")
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep(disabled) err = %v, want nil", err)
}
if stats.Batches != 0 || stats.Deleted != 0 {
t.Fatalf("Sweep(disabled) = %+v, want zero stats", stats)
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 1 {
t.Fatalf("rows = %d, want 1(GC 关闭时一行都不许动)", n)
}
}
// TestSweep_IntervalDisabled_边界:间隔 ≤0 ⇒ 同样是关闭(与文档 GC 逐字同口径)。
func TestSweep_IntervalDisabled_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = 0
cfg.Retention = DefaultRetention
})
if st.Enabled() {
t.Fatal("Enabled() = true, want false(间隔 ≤0 = 关闭 GC)")
}
seedExpired(t, st, testNow().Add(-1000*time.Hour), "group_recall")
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep(disabled) err = %v, want nil", err)
}
if stats.Deleted != 0 {
t.Fatalf("Sweep(disabled) deleted = %d, want 0", stats.Deleted)
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 1 {
t.Fatalf("rows = %d, want 1", n)
}
}
// TestSweep_DeletesExpiredOnly_正常:正数保留期 ⇒ 只删超期行;未超期的行保留,
// **计数表不参与 GC**(design/26 §4.4:删了就答不上「上游推过没有」)。
func TestSweep_DeletesExpiredOnly_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
const retention = 24 * time.Hour
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = time.Hour
cfg.Retention = retention
})
if !st.Enabled() {
t.Fatal("Enabled() = false, want true")
}
seedExpired(t, st, testNow().Add(-48*time.Hour), "expired")
seedExpired(t, st, testNow().Add(-time.Hour), "fresh")
if err := st.Record(ctx, Event{
Platform: PlatformQQ, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: "heartbeat",
}); err != nil {
t.Fatalf("Record(heartbeat): %v", err)
}
if err := st.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("Deleted = %d, want 1", stats.Deleted)
}
rows, err := st.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 || rows[0].PlatformType != "fresh" {
t.Fatalf("rows after sweep = %+v, want exactly the fresh one", rows)
}
// 计数表不参与 GC:即便某个键的**事件行**被清扫掉,它的计数行照旧在(design/26
// §4.4 —— 删了就再也答不上「上游推过没有」)。
counters := mustCounters(t, st, PlatformQQ)
seen := make(map[string]int64, len(counters))
for _, c := range counters {
seen[c.PlatformType] = c.Count
}
for _, key := range []string{"expired", "fresh", "heartbeat"} {
if seen[key] != 1 {
t.Fatalf("counter %q = %d, want 1(全部计数行都必须留着): %+v", key, seen[key], counters)
}
}
}
// TestSweep_MultipleBatches_边界:积压超过单批上限时按批删除(一轮的时长与内存占用
// 有界),且一轮内能把超过一批的量扫完。
func TestSweep_MultipleBatches_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = time.Hour
cfg.Retention = time.Hour
})
const rows = sweepBatchSize + 1
old := testNow().Add(-100 * time.Hour)
for i := 0; i < rows; i++ {
seedExpired(t, st, old, "expired")
}
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != int64(rows) {
t.Fatalf("Deleted = %d, want %d", stats.Deleted, rows)
}
if stats.Batches != 2 {
t.Fatalf("Batches = %d, want 2 (单批上限 %d)", stats.Batches, sweepBatchSize)
}
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)
}
}
// TestSweep_FailureDoesNotClear_异常:库不可用时 Sweep 返回错误(组合根记 WARN 后
// 继续),ctx 取消时立刻停下且**不清空**未删的行。
func TestSweep_FailureDoesNotClear_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, conn := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = time.Hour
cfg.Retention = time.Hour
})
seedExpired(t, st, testNow().Add(-100*time.Hour), "expired")
// ① ctx 取消:立即停下、返回错误、一行都不删(未删的绝不因此被清空)。
canceled, cancel := context.WithCancel(ctx)
cancel()
stats, err := st.Sweep(canceled)
if err == nil {
t.Fatal("Sweep(canceled ctx) err = nil, want an error")
}
if stats.Deleted != 0 {
t.Fatalf("Sweep(canceled ctx) deleted = %d, want 0", stats.Deleted)
}
if n, cerr := st.Count(ctx, Filter{}); cerr != nil {
t.Fatalf("Count: %v", cerr)
} else if n != 1 {
t.Fatalf("rows = %d, want 1 (取消不得清空)", n)
}
// ② 库不可用:同样返回错误(调用方等下一轮),且不 panic。
if err := conn.Close(); err != nil {
t.Fatalf("关闭内存库: %v", err)
}
if _, err := st.Sweep(ctx); err == nil {
t.Fatal("Sweep(closed db) err = nil, want an error")
}
}
+404
View File
@@ -0,0 +1,404 @@
package events
// 本文件归属**事件存储核心**(design/26 §4.1/§4.3;切片 E1):构造、写入路径
// (`Record` 的逐条落行 + 计数归属)与两个只读查询(`Count` / `List`)。
//
// 三条取舍写在这里:
//
// 1. **T2/T3/unknown 同步逐条落行,失败上抛**(§4.3):这些行有审计价值、丢不起,
// 它们的失败必须让调用方看见(E3 据此记 WARN,但**不**因此丢帧 —— 留存与
// 「进不进上下文」是两个独立动作)。
// 2. **T1 只累加计数**(§4.3):心跳/在线状态是高频遥测,逐帧写库是纯浪费。
// 两个**例外**逐条落行(见 persistsAsRow):`unknown`/`decode_error`(§3.7 明令
// 「留痕 + 计数」)与 §3.6 的链路事件(`link`,它回答的正是「链路当时是不是
// 活的」——design/26 §0.2 事故定案所依赖的那份证据)。
// 3. **时间戳口径与既有迁移逐字同形**(`strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
// UTC 毫秒 + 字面量 Z):定宽 ⇒ 字典序比较等价于时间序,时间窗筛选因此能直接
// 吃上 `idx_channel_events_created_at`(与 `runtime/model` 的 `llm/usage` 同口径)。
import (
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// DefaultFlushInterval 是 `CHANNEL_EVENT_COUNTER_FLUSH` 的默认值(design/26 §4.3):
// 内存计数每 30s 落盘一次。30s 是「心跳不把库写胖」与「crash 后还能答上近期
// 有没有推」之间的取舍值。
DefaultFlushInterval = 30 * time.Second
// DefaultMaxPendingKeys 是计数缓冲的**键数上界**(design/26 §4.3 的背压):
// 不同 `(platform, account_id, platform_type)` 键超过它就整批落盘。有了它,
// 内存占用与「DB 长时间不可用」时长脱钩 —— 绝不无界增长。
DefaultMaxPendingKeys = 512
// DefaultListLimit 是 List 的缺省页大小;MaxListLimit 是它的硬上界
// (超过即钳住,绝不静默返回全表)。
DefaultListLimit = 100
MaxListLimit = 500
// sqliteTimeLayout 与迁移 0021 的默认值 `strftime('%Y-%m-%dT%H:%M:%fZ','now')`
// 逐字同形(UTC、毫秒、字面量 Z)。写入与解析共用它,使「我们写进去的」与
// 「SQLite 默认生成的」可以直接比较(与 `documents` 同一手法)。
sqliteTimeLayout = "2006-01-02T15:04:05.000"
// eventsLogSource 是全部日志的 source 标签(AGENTS §10:禁无来源裸日志)。
eventsLogSource = "runtime/events"
)
// Config 是事件存储的接线参数。时间与开关口径由调用方(组合根)给出:本包**不读
// 环境变量**、不猜数据目录布局(与 `documents.StoreConfig` / `documents.GCConfig`
// 同一理由)。
type Config struct {
// DB 是活连接(组合根注入既有 SQLite 连接池;测试注入临时库)。为 nil 时
// NewStore 返回 ErrNilDB —— 计数的 fail-visible 降级(§4.3「store 为 nil ⇒
// 只记 DEBUG + 内存计数」)由桥接层(E3)负责,不在本包伪造替身。
DB *sql.DB
// Log 是基础 logger;nil 时替换为标记来源 runtime/events 的丢弃 logger。
Log *slog.Logger
// Now 是可注入时钟(测试用);nil 时用 time.Now。
Now func() time.Time
// FlushInterval 是内存计数的定期落盘间隔(`CHANNEL_EVENT_COUNTER_FLUSH`)。
// **≤0 取 DefaultFlushInterval**(与 GC 不同:计数 flush 不是破坏性动作,
// 「配了 0 就永远不落盘」只会悄悄丢掉计数证据,因此这里不做「显式关闭」)。
FlushInterval time.Duration
// MaxPendingKeys 是计数缓冲的键数上界;≤0 取 DefaultMaxPendingKeys。
MaxPendingKeys int
// GCInterval 是保留期清扫的间隔(`CHANNEL_EVENT_GC_INTERVAL`)。
// **≤0 = 关闭 GC**(含启动期清扫),默认 DefaultGCInterval。
GCInterval time.Duration
// Retention 是 `channel_events` 的保留期(`CHANNEL_EVENT_RETENTION`)。
// **≤0 = 关闭 GC**,默认 DefaultRetention。0 刻意**不**解释成「立即清扫」:
// 那会让一次笔误把整张事件表清空 —— 而它正是「上游推过没有」的唯一证据。
Retention time.Duration
}
// SQLStore 是基于 SQLite 的事件存储实现。它可安全并发使用:连接池本身串行化写
// (`db.Open` 的 SetMaxOpenConns(1)),内存计数缓冲由自身互斥量保护。
type SQLStore struct {
db *sql.DB
log *slog.Logger
now func() time.Time
counters *counterBuffer
flushInterval time.Duration
gcInterval time.Duration
retention time.Duration
gcEnabled bool
}
// NewStore 构造事件存储。唯一的失败是 DB 为 nil(ErrNilDB);GC 的关闭**不是**
// 构造失败,它是可见的正常状态(Enabled() 为 false + 一条记明原因的 WARN)。
func NewStore(cfg Config) (*SQLStore, error) {
if cfg.DB == nil {
return nil, fmt.Errorf("events: new store: %w", ErrNilDB)
}
log := sourcedLog(cfg.Log)
now := cfg.Now
if now == nil {
now = time.Now
}
flush := cfg.FlushInterval
if flush <= 0 {
flush = DefaultFlushInterval
}
s := &SQLStore{
db: cfg.DB,
log: log,
now: now,
counters: newCounterBuffer(cfg.MaxPendingKeys),
flushInterval: flush,
gcInterval: cfg.GCInterval,
retention: cfg.Retention,
}
switch {
case cfg.GCInterval <= 0:
log.Warn("channel event gc disabled: interval <= 0", "interval", cfg.GCInterval.String())
case cfg.Retention <= 0:
log.Warn("channel event gc disabled: retention <= 0", "retention", cfg.Retention.String())
default:
s.gcEnabled = true
}
log.Info("channel event store ready",
"flush_interval", s.flushInterval.String(),
"max_pending_keys", s.counters.max,
"gc_enabled", s.gcEnabled,
"gc_interval", s.gcInterval.String(),
"retention", s.retention.String())
return s, nil
}
// sourcedLog 把基础 logger 标上 source=runtime/events;nil 时给出丢弃 logger,
// 使调用方永远可以安全记日志(与 `documents` / `history` 同一手法)。
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, eventsLogSource)
}
// FlushInterval 返回生效的计数落盘间隔(组合根的常驻 ticker 用它)。
func (s *SQLStore) FlushInterval() time.Duration {
if s == nil {
return 0
}
return s.flushInterval
}
// PendingKeys 返回内存里待落盘的计数的键数。它有上界(MaxPendingKeys),因此可以
// 直接作为健康指标暴露给面板:只涨不落 = DB 出了问题。
func (s *SQLStore) PendingKeys() int {
if s == nil {
return 0
}
return s.counters.len()
}
// Record 记录一帧事件(design/26 §4.3 的唯一写入口)。
//
// 判定顺序刻意固定:**先校验 → 再归一化 → 再按档落行 → 最后计数**。这样即便逐条
// 落行失败(返回错误),调用方也能明确知道「这一帧没能留下可查痕迹」,而不会拿到
// 一个「成功了但其实什么都没落」的假象。
//
// 计数侧的错误**不上抛**:计数是尽力而为(§4.3),绝不因为计数落盘失败而让入站
// 流水线失败或丢帧。缓冲满了先整批落盘,失败则记 WARN。
func (s *SQLStore) Record(ctx context.Context, ev Event) error {
if strings.TrimSpace(ev.Platform) == "" {
return fmt.Errorf("events: record: %w", ErrNoPlatform)
}
ev = s.normalize(ev)
if persistsAsRow(ev.Tier, ev.Category) {
if err := s.insertEvent(ctx, ev); err != nil {
return fmt.Errorf("events: record 落行: %w", err)
}
}
s.recordCounter(ctx, ev)
return nil
}
// normalize 补齐一条待写事件:时钟零值填当前时刻;tier 取值非法时归一为 TierT1
// 并记 WARN —— 这是 fail-safe(§1.5):看不懂的档**只**降级成「只计数」,绝不让
// 一个字段写错演变成「这一帧什么都没有留下」。
func (s *SQLStore) normalize(ev Event) Event {
switch ev.Tier {
case TierT1, TierT2, TierT3:
default:
s.log.Warn("channel event tier invalid, falling back to t1",
"platform", ev.Platform, "platform_type", ev.PlatformType, "tier", string(ev.Tier))
ev.Tier = TierT1
}
if ev.CreatedAt.IsZero() {
ev.CreatedAt = s.now()
}
return ev
}
// persistsAsRow 判定一帧是否要逐条落 `channel_events`(design/26 §4.1/§4.3/§3.6/§3.7):
//
// - T2 / T3:逐条(有审计价值,丢不起);
// - unknown / decode_error:逐条(§3.7「落库 + 计数 + 脱敏结构留痕」,且
// **绝不进上下文**——兜底不等于放行);
// - link:逐条(§3.6 明写「落 channel_events(category=link)」;链路事件是
// §0.2 事故里「当时链路活没活」的唯一答案,即便它是 T1 也不能只留一个计数)。
//
// 它是纯函数(按 AGENTS §10 不引入包级可变状态)。
func persistsAsRow(tier Tier, category Category) bool {
switch tier {
case TierT2, TierT3:
return true
}
switch category {
case CategoryUnknown, CategoryDecodeError, CategoryLink:
return true
default:
return false
}
}
// insertEventSQL 是逐条落行的唯一语句:列名显式列出(禁 `SELECT *` 的同一条纪律
// 在写侧的表现),值全部参数化。updated_at 与 created_at 同值:事件行不可变,
// 两列同形使「读回时的 updated_at」不会比事实更晚。
const insertEventSQL = `INSERT INTO channel_events
(platform, account_id, tier, category, platform_type, channel_id, channel_type,
group_id, actor_id, actor_role, target_id, self_related, message_id, summary,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// insertEvent 写一行 `channel_events`。
func (s *SQLStore) insertEvent(ctx context.Context, ev Event) error {
stamp := sqliteTimeStamp(ev.CreatedAt)
if _, err := s.db.ExecContext(ctx, insertEventSQL,
ev.Platform, ev.AccountID, string(ev.Tier), string(ev.Category), ev.PlatformType,
ev.ChannelID, ev.ChannelType, ev.GroupID, ev.ActorID, ev.ActorRole, ev.TargetID,
boolToInt(ev.SelfRelated), ev.MessageID, ev.Summary, stamp, stamp); err != nil {
return fmt.Errorf("events: 落事件行(platform=%s type=%s): %w", ev.Platform, ev.PlatformType, err)
}
return nil
}
// Count 统计符合条件的事件行数。
func (s *SQLStore) Count(ctx context.Context, f Filter) (int64, error) {
where, args := f.where()
var n int64
if err := s.db.QueryRowContext(ctx, "SELECT count(*) FROM channel_events"+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("events: 统计事件行: %w", err)
}
return n, nil
}
// listEventsSQL 的列显式列出(与迁移 0021 同序),便于与 scanEvent 对齐核对。
const listEventsSQL = `SELECT id, platform, account_id, tier, category, platform_type,
channel_id, channel_type, group_id, actor_id, actor_role, target_id, self_related,
message_id, summary, created_at, updated_at
FROM channel_events`
// List 按 `created_at DESC, id DESC`(倒序,design/26 §4.5)返回符合条件的事件行。
// id 参与排序是为了让同一毫秒内的多行有确定顺序,分页因此不会重复或漏行。
func (s *SQLStore) List(ctx context.Context, f Filter) ([]Event, error) {
where, args := f.where()
limit, offset := f.pageLimit(), f.pageOffset()
query := listEventsSQL + where + " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("events: 查事件行: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]Event, 0, limit)
for rows.Next() {
ev, err := scanEvent(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("events: 遍历事件行: %w", err)
}
return out, nil
}
// scanEvent 读出一行 `channel_events`。时间戳读不出来时返回错误而不是零值时间:
// 一个读不懂的时间戳意味着这一行被谁改坏了,「如实报错」比「静默给一个 1970 年」
// 更接近 design/26 的诚实口径(与 `documents` GC 的「读不懂的时间绝不当成已超期」
// 同一个保守方向)。
func scanEvent(rows *sql.Rows) (Event, error) {
var (
ev Event
tier, category string
selfRelated int64
created, updated string
)
if err := rows.Scan(&ev.ID, &ev.Platform, &ev.AccountID, &tier, &category, &ev.PlatformType,
&ev.ChannelID, &ev.ChannelType, &ev.GroupID, &ev.ActorID, &ev.ActorRole, &ev.TargetID,
&selfRelated, &ev.MessageID, &ev.Summary, &created, &updated); err != nil {
return Event{}, fmt.Errorf("events: 读事件行: %w", err)
}
ev.Tier = Tier(tier)
ev.Category = Category(category)
ev.SelfRelated = selfRelated != 0
at, ok := parseSQLiteTime(created)
if !ok {
return Event{}, fmt.Errorf("events: 事件行 %d 的 created_at 不可解析(%q): %w", ev.ID, created, ErrBadTimestamp)
}
ev.CreatedAt = at
if ts, ok := parseSQLiteTime(updated); ok {
ev.UpdatedAt = ts
} else {
ev.UpdatedAt = at
}
return ev, nil
}
// where 把 Filter 编译成参数化的 WHERE 子句(列名是常量,值全部走占位符)。
// 有意的取舍:**只拼列名与运算符,绝不拼值**(SQL 全参数化,AGENTS §10)。
func (f Filter) where() (string, []any) {
clauses := make([]string, 0, 6)
args := make([]any, 0, 6)
if platform := strings.TrimSpace(f.Platform); platform != "" {
clauses = append(clauses, "platform = ?")
args = append(args, platform)
}
if f.Tier != "" {
clauses = append(clauses, "tier = ?")
args = append(args, string(f.Tier))
}
if f.Category != "" {
clauses = append(clauses, "category = ?")
args = append(args, string(f.Category))
}
if channelID := strings.TrimSpace(f.ChannelID); channelID != "" {
clauses = append(clauses, "channel_id = ?")
args = append(args, channelID)
}
if !f.Since.IsZero() {
clauses = append(clauses, "created_at >= ?")
args = append(args, sqliteTimeStamp(f.Since))
}
if !f.Until.IsZero() {
clauses = append(clauses, "created_at <= ?")
args = append(args, sqliteTimeStamp(f.Until))
}
if len(clauses) == 0 {
return "", nil
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// pageLimit 把 Limit 归一:≤0 取缺省、超过硬上界即钳住。
func (f Filter) pageLimit() int {
switch {
case f.Limit <= 0:
return DefaultListLimit
case f.Limit > MaxListLimit:
return MaxListLimit
default:
return f.Limit
}
}
// pageOffset 把 Offset 归一到 ≥0。
func (f Filter) pageOffset() int {
if f.Offset < 0 {
return 0
}
return f.Offset
}
// sqliteTimeStamp 把时刻格式化成库里的时间戳口径(UTC 毫秒 + 字面量 Z)。
func sqliteTimeStamp(t time.Time) string {
return t.UTC().Format(sqliteTimeLayout) + "Z"
}
// parseSQLiteTime 解析库里的时间戳列。它先试本包写出的口径,再退回 RFC3339
// (覆盖带偏移或非毫秒精度的历史值);两者都不成立时返回 false,调用方据此保守
// 处理(绝不把读不懂的时间当成一个真实时刻)。
func parseSQLiteTime(raw string) (time.Time, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return time.Time{}, false
}
if t, err := time.Parse(sqliteTimeLayout+"Z", trimmed); err == nil {
return t.UTC(), true
}
if t, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return t.UTC(), true
}
return time.Time{}, false
}
// boolToInt 把布尔列写成 SQLite 的 0/1(列本身是 INTEGER NOT NULL DEFAULT 0)。
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+447
View File
@@ -0,0 +1,447 @@
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))
}
}