package events // 本文件是渠道事件**实时面的通用出口**(design/26 §4.5;切片 E7):一个 Store 装饰器, // 把每一次**落库成功**的事件顺带推上 bus 主题 `SubjectChannelEvent`。 // // 为什么需要它:非消息事件的写入方是桥接层(E3,`channelbridge.Options.EventStore`), // 迁移事件(本片的 `StatusTracker`)是另一个写入方。面板要实时,就必须让**所有**新行 // 都经过同一个出口 —— 而 `Store.Record` 正是那个唯一入口(§4.3)。在这里装饰,等于 // 「谁写的都在内」,不必让两个写入方各推一遍(那样迟早只推一半,且两处口径会漂移)。 // // 三条取舍写在这里: // // 1. **只在落库成功之后推**:帧是给面板看的「又发生了一条」,而权威列表是 REST 轮询。 // 先推帧、后落库失败 ⇒ 面板出现一条查询面永远查不到的幽灵行,正是「画出来的与 // 事实无关」(§0.2 要根除的那类错误)。落不下去就不声称。 // 2. **推不出去绝不影响写入**:`Publish` 的失败只记 WARN 后咽下(bus 的订阅缓冲满时 // 本来就会自行丢帧 —— 它是 best-effort 通道)。`Record` 的返回值只反映**落库** // 的成败,与推送无关。 // 3. **绝不装饰 nil**:`inner` 为 nil 时返回**真正的 nil**。桥接层用 `eventStore == nil` // 判定「未接线 ⇒ 降级为内存计数」(§4.3),把一个 nil 包成非 nil 接口会让那条 // 降级路径失效,进而变成对 nil 的解引用 —— 这正是本构造函数宁可返回 nil 的理由。 import ( "context" "log/slog" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // PublishingStore 是包装一个 Store 并顺带发布实时帧的装饰器。它实现与内层**完全 // 相同**的语义(含各方法的时间参数、错误包装),差别只有「Record 成功后多发一条 // bus 事件」。 // // ⚠️ **只装饰一处分发(接线须知)**:本片有两条推帧路径 —— 本装饰器(覆盖桥接层写入的 // 全部渠道事件)与 `Watcher`(账号状态迁移,它自己推)。两者都发在 `SubjectChannelEvent` // 上。因此**只应装饰桥接层用的那个句柄**(组合根的 `wire_channels.go` → `Options.EventStore`), // 不要同时把网关侧(`wire_frontend.go` → `Deps.EventStore`)也换成装饰器:那会让采样侧 // 写的每一行被推两次(重复帧不会丢数据,但会让面板收到重复的增量)。 type PublishingStore struct { inner Store pub Publisher log *slog.Logger } // NewPublishingStore 构造装饰器。 // // - `inner == nil` ⇒ 返回 nil(见文件头第 3 条;调用方据此保持既有的未接线降级)。 // - `pub == nil` ⇒ 仍然返回装饰器:此时它退化成**透明转发**(与直接使用 inner // 逐字等价),这样调用方不必分两种写法,也不会因为「没接 bus」而丢掉 inner。 // - `log` 为 nil ⇒ 丢弃型 logger(`sourcedLog`),调用方永远可以安全记日志。 func NewPublishingStore(inner Store, pub Publisher, log *slog.Logger) *PublishingStore { if inner == nil { return nil } return &PublishingStore{ inner: inner, pub: pub, log: logger.Scoped(sourcedLog(log), eventsLogSource, "publish_store"), } } // Record 先走内层落库,**成功之后**才推一帧。失败原样上抛(§4.3:逐条留存的失败 // 必须让调用方看见 —— E3 会据此记 WARN 并把这一帧计入内存降级计数),且**不推帧**。 func (p *PublishingStore) Record(ctx context.Context, ev Event) error { if err := p.inner.Record(ctx, ev); err != nil { return err } p.publish(ctx, ev) return nil } // publish 推一帧实时事件。`pub` 缺席或推送失败都只记 WARN:事件已经落库, // 面板还有轮询兜底,这里没有任何理由让调用方看见失败。 func (p *PublishingStore) publish(ctx context.Context, ev Event) { if p.pub == nil { return } payload := ChannelEventFrame{Event: ev.JSON()} if err := p.pub.Publish(ctx, SubjectChannelEvent, payload); err != nil { p.log.Warn("publish channel event frame", "subject", SubjectChannelEvent, "platform", ev.Platform, "platform_type", ev.PlatformType, "err", err) } } // Count 转发到内层(只读查询,与实时面无关)。 func (p *PublishingStore) Count(ctx context.Context, f Filter) (int64, error) { return p.inner.Count(ctx, f) } // List 转发到内层。 func (p *PublishingStore) List(ctx context.Context, f Filter) ([]Event, error) { return p.inner.List(ctx, f) } // Counters 转发到内层。 func (p *PublishingStore) Counters(ctx context.Context, platform string) ([]Counter, error) { return p.inner.Counters(ctx, platform) } // Flush 转发到内层(组合根的常驻 ticker 与退出前的一次调用)。 func (p *PublishingStore) Flush(ctx context.Context) error { return p.inner.Flush(ctx) } // Sweep 转发到内层(保留期清扫)。 func (p *PublishingStore) Sweep(ctx context.Context) (SweepStats, error) { return p.inner.Sweep(ctx) } // 装饰器必须仍是**一个 Store**:否则组合根把它交给桥接层时会编译不过 —— // 这条断言比注释更能防住「转发少了一个方法」。 var _ Store = (*PublishingStore)(nil)