state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」** (面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。 ⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
206 lines
8.5 KiB
Go
206 lines
8.5 KiB
Go
package events
|
||
|
||
// 本文件把「账号状态迁移」接到**实时面**(design/26 §2.6 / §4.5;切片 E7)。
|
||
//
|
||
// 三段分工,谁都不越界:
|
||
//
|
||
// - **判定与落盘**在 `StatusTracker`(account_status.go)——它不看时钟、不推任何东西;
|
||
// - **采样节拍**在本文件的 `Watcher`:立即采一次,此后按间隔采(默认 60s,与适配器
|
||
// `get_status` 探活的节奏一致:采得比探活快只会拿到同一个值,纯浪费);
|
||
// - **送达面板**走上行 bus 主题 `channel.event`:网关的 hub 把它规范成 `channel_event`
|
||
// WS 帧并过角色门(fail-closed ⇒ 只发 owner + admin)。**不新开监听端口、不做 HTTP
|
||
// 互调**(AGENTS §9 红线):整条路是「进程内 bus → hub relay → 连接」。
|
||
//
|
||
// 为什么推送**只在落库成功之后**发生(见 observe):帧是给面板看的「又发生了一条」,
|
||
// 而面板的权威列表是 REST 轮询(帧是 best-effort,缓冲满即丢)。若先推帧、落库却失败,
|
||
// 面板会出现一条**查询面永远查不到**的幽灵行 —— 那正是「画出来的与事实无关」。
|
||
// 落不下去就不声称,与 §0.2 要根除的那类错误同向。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
const (
|
||
// SubjectChannelEvent 是渠道事件实时推送的 bus 主题。
|
||
//
|
||
// 它是**领域主题**(生产者定义、网关做 WS 归一),与 `orchestration.SubagentResultSubject`
|
||
// / `observability.log` 同一个套路:生产者不必知道 WS 帧叫什么名字,`ws_bridge.go`
|
||
// 的 eventBridge 负责把主题映射成帧类型。
|
||
SubjectChannelEvent = "channel.event"
|
||
|
||
// DefaultWatchInterval 是状态采样间隔(§2.6 的探活节奏约 60s)。
|
||
// 它**不是**可配项:采样快于探活拿不到新信息,慢于探活只会推迟发现掉线。
|
||
DefaultWatchInterval = 60 * time.Second
|
||
)
|
||
|
||
// Publisher 是**进程内 bus** 的最小面(`bus.Bus` 直接满足它)。
|
||
//
|
||
// 刻意不在本包 import bus:events 只依赖「能发一个主题」这件事,谁来发(进程内 bus、
|
||
// 将来的 NATS、测试替身)由调用方决定(与 Store 接口同一取舍,§6.3)。
|
||
type Publisher interface {
|
||
Publish(ctx context.Context, subject string, payload any) error
|
||
}
|
||
|
||
// ChannelEventFrame 是实时帧的 data 本体:`{"event": <ChannelEventJSON>}`。
|
||
//
|
||
// 刻意不把事件字段摊平到 data 顶层:将来要给帧加**帧级**元信息(如「这是重放的」)
|
||
// 时,不必与事件字段抢名字;面板的增量合并逻辑也只认 `data.event` 一处。
|
||
type ChannelEventFrame struct {
|
||
Event ChannelEventJSON `json:"event"`
|
||
}
|
||
|
||
// StatusSource 报告**当前所有可观测账号**的状态快照。网关实现它(读能力注册表),
|
||
// 本包只消费 —— 于是采样与领域逻辑分开,两边各自可测。
|
||
type StatusSource interface {
|
||
AccountStatusSamples() []StatusSample
|
||
}
|
||
|
||
// WatcherConfig 是实时面的接线参数。
|
||
type WatcherConfig struct {
|
||
// Store 是事件留存面。为 nil 时只判定不落库**也不推帧**(见 observe 的取舍),
|
||
// 调用方应据此不启动 Watcher(网关的 channelEventWriter 就返回 nil)。
|
||
Store Store
|
||
// Source 是状态快照来源。为 nil 时每拍都拿到空快照(等于不观测),不 panic。
|
||
Source StatusSource
|
||
// Publisher 是 bus 出口;nil 时只落库不推帧(例如未接 bus 的测试构造)。
|
||
Publisher Publisher
|
||
// Log 是来源已标注的 logger;nil 时用丢弃 logger(与 store.go 的 sourcedLog 同形)。
|
||
Log *slog.Logger
|
||
// Interval 是采样间隔;≤0 取 DefaultWatchInterval。
|
||
Interval time.Duration
|
||
// Now 是可注入时钟(测试用);nil 时用 time.Now。
|
||
Now func() time.Time
|
||
}
|
||
|
||
// Watcher 按节拍采样账号状态、判定迁移、落行并把新行推上 bus。
|
||
//
|
||
// 它只被自己的 `Run` goroutine 使用(tracker 自带互斥量),因此没有额外的并发约定;
|
||
// `Run` 返回即代表已停止(ctx 取消或 store 未接线)。
|
||
type Watcher struct {
|
||
tracker *StatusTracker
|
||
source StatusSource
|
||
publisher Publisher
|
||
log *slog.Logger
|
||
interval time.Duration
|
||
// mu 只保护 running/done 这对一次性标志,让 Start 幂等(重复 Start 不会起第二个
|
||
// 采样循环 —— 两个循环会把「首次观测」判成两次迁移,落两行)。
|
||
mu sync.Mutex
|
||
running bool
|
||
done chan struct{}
|
||
}
|
||
|
||
// NewWatcher 构造 Watcher。唯一的失败是**没有留存面**(Store 为 nil):那就没有
|
||
// 「可查的历史」这件事可做,宁可明确失败,也不给一个只推帧不落库的替身(与
|
||
// `NewStore` 的 ErrNilDB 同一口径)。Publisher 为 nil 是合法的:只落库不推帧。
|
||
func NewWatcher(cfg WatcherConfig) (*Watcher, error) {
|
||
if cfg.Store == nil {
|
||
return nil, fmt.Errorf("events: 账号状态实时面: %w", ErrNilDB)
|
||
}
|
||
interval := cfg.Interval
|
||
if interval <= 0 {
|
||
interval = DefaultWatchInterval
|
||
}
|
||
log := logger.Scoped(sourcedLog(cfg.Log), eventsLogSource, "account_status")
|
||
return &Watcher{
|
||
tracker: NewStatusTracker(cfg.Store, cfg.Now),
|
||
source: cfg.Source,
|
||
publisher: cfg.Publisher,
|
||
log: log,
|
||
interval: interval,
|
||
done: make(chan struct{}),
|
||
}, nil
|
||
}
|
||
|
||
// Interval 返回生效的采样间隔。调用方(网关的启动日志)用它,而不是再写一份
|
||
// `DefaultWatchInterval` 字面量 —— 两份字面量迟早会漂移成「日志说的」与「实际跑的」。
|
||
func (w *Watcher) Interval() time.Duration { return w.interval }
|
||
|
||
// Start 起一个采样 goroutine 并立即返回。重复调用只生效一次(见 Watcher.mu 的注释)。
|
||
// ctx 取消后循环干净退出(不泄漏 goroutine)。
|
||
func (w *Watcher) Start(ctx context.Context) {
|
||
w.mu.Lock()
|
||
start := !w.running
|
||
if start {
|
||
w.running = true
|
||
}
|
||
w.mu.Unlock()
|
||
if !start {
|
||
return
|
||
}
|
||
go w.run(ctx)
|
||
}
|
||
|
||
// Done 在采样循环退出后关闭。给测试与组合根一个「它真的停了」的判据,
|
||
// 而不是靠 sleep 猜。
|
||
func (w *Watcher) Done() <-chan struct{} { return w.done }
|
||
|
||
// run 是采样循环:**先立即采一次**(进程刚起来时的那次观测不该等一个间隔),
|
||
// 此后按节拍采,直到 ctx 取消。
|
||
func (w *Watcher) run(ctx context.Context) {
|
||
defer close(w.done)
|
||
w.ObserveOnce(ctx)
|
||
ticker := time.NewTicker(w.interval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
w.ObserveOnce(ctx)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ObserveOnce 采一轮:遍历来源快照,逐个判定迁移,迁移且落库成功的推一帧。
|
||
//
|
||
// 它**不阻塞**在 I/O 上更久于一次落库:源为 nil 或返回空快照时什么都不做。
|
||
// 每一类失败都只影响它自己那一个账号(缺平台的样本记 WARN 后继续),
|
||
// 绝不让一个坏样本拖掉整轮的其余账号 —— 「整类丢弃」是本项目反复点名的病。
|
||
//
|
||
// **推帧的门就是那条错误分支**:`StatusTracker.Observe` 只在行确实落进库时才回
|
||
// nil(落库失败、样本缺平台都回错误),因此「推了帧却没落库」在这条路上不可能发生。
|
||
// `StatusResult.Recorded` 因此不在这里再查一遍 —— 两个等价的判据并排写只会让
|
||
// 后来的人以为其中一个是可以删的。
|
||
func (w *Watcher) ObserveOnce(ctx context.Context) {
|
||
if w.source == nil {
|
||
return
|
||
}
|
||
for _, sample := range w.source.AccountStatusSamples() {
|
||
res, err := w.tracker.Observe(ctx, sample)
|
||
if err != nil {
|
||
w.log.Warn("channel account status not recorded",
|
||
"platform", sample.Platform, "account", sample.AccountID, "err", err)
|
||
continue
|
||
}
|
||
if !res.Changed {
|
||
continue
|
||
}
|
||
w.log.Info("channel account status changed",
|
||
"platform", res.Event.Platform, "account", res.Event.AccountID,
|
||
"state", string(res.State), "previous", string(res.Previous),
|
||
"source", res.Event.PlatformType, "recorded", res.Recorded)
|
||
w.publish(ctx, res.Event)
|
||
}
|
||
}
|
||
|
||
// publish 把一条**已落库**的事件推上 bus。失败只记 WARN:帧是 best-effort
|
||
// (Bus 的订阅缓冲满时自行丢弃),落库那一行才是权威证据;绝不因为推不出去而
|
||
// 让采样循环失败或回滚任何东西。
|
||
func (w *Watcher) publish(ctx context.Context, ev Event) {
|
||
if w.publisher == nil {
|
||
return
|
||
}
|
||
payload := ChannelEventFrame{Event: ev.JSON()}
|
||
if err := w.publisher.Publish(ctx, SubjectChannelEvent, payload); err != nil {
|
||
w.log.Warn("publish channel event frame",
|
||
"subject", SubjectChannelEvent, "platform", ev.Platform,
|
||
"platform_type", ev.PlatformType, "err", err)
|
||
}
|
||
}
|