Files
AskaEth 99a644d545 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」**
(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
2026-10-06 09:03:53 +08:00

206 lines
8.5 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 §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)
}
}