feat(channel): 微信 iLink 账号状态三态(诚实口径)

state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」**
(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:53 +08:00
1 parent 7505c46b70
commit 99a644d545
12 files changed
+2041 -57

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
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)
}
}