// 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) }