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

+403
View File
@@ -0,0 +1,403 @@
package events
// 本文件钉住渠道事件**实时面**的两条出口(design/26 §4.5;切片 E7):
// 采样循环(`Watcher`)与 Store 装饰器(`PublishingStore`)。
//
// 两条口径是这里反复断言的:
// - **只在落库成功之后推帧**:落库失败 ⇒ 一帧都不发(面板不会出现查询面查不到的
// 幽灵行);
// - **推不出去绝不影响写入**:bus 缺席、Publish 报错都只记 WARN,事件本身照常落库。
import (
"context"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
// fakePublisher 记录被推上 bus 的主题与载荷。
type fakePublisher struct {
mu sync.Mutex
subjects []string
payloads []any
err error
}
func (p *fakePublisher) Publish(_ context.Context, subject string, payload any) error {
p.mu.Lock()
defer p.mu.Unlock()
p.subjects = append(p.subjects, subject)
p.payloads = append(p.payloads, payload)
return p.err
}
func (p *fakePublisher) snapshot() ([]string, []any) {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.subjects...), append([]any(nil), p.payloads...)
}
// staticSource 是固定的状态快照来源(每次调用返回同一份)。
type staticSource struct {
samples []StatusSample
calls atomic.Int64
}
func (s *staticSource) AccountStatusSamples() []StatusSample {
s.calls.Add(1)
return s.samples
}
// forwardingStoreStub 记录每个方法被调用了几次,并回一个哨兵值 —— 用来断言装饰器
// 把它们**逐个转发**到内层(少转发一个方法就编译不过,但转发到错的地方仍要测)。
type forwardingStoreStub struct {
mu sync.Mutex
recordCalls int
countCalls int
listCalls int
counterCalls int
flushCalls int
sweepCalls int
recordErr error
}
func (s *forwardingStoreStub) Record(context.Context, Event) error {
s.mu.Lock()
defer s.mu.Unlock()
s.recordCalls++
return s.recordErr
}
func (s *forwardingStoreStub) Count(context.Context, Filter) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.countCalls++
return 42, nil
}
func (s *forwardingStoreStub) List(context.Context, Filter) ([]Event, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.listCalls++
return []Event{{ID: 7}}, nil
}
func (s *forwardingStoreStub) Counters(context.Context, string) ([]Counter, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.counterCalls++
return []Counter{{Platform: PlatformQQ}}, nil
}
func (s *forwardingStoreStub) Flush(context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.flushCalls++
return nil
}
func (s *forwardingStoreStub) Sweep(context.Context) (SweepStats, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.sweepCalls++
return SweepStats{Deleted: 3}, nil
}
// TestNewPublishingStore_未接线返回真nil:桥接层用 `eventStore == nil` 判定「未接线 ⇒
// 降级为内存计数」(§4.3)。把一个 nil 包成非 nil 接口会让那条路径失效、直接解引用崩掉。
func TestNewPublishingStore_未接线返回真nil(t *testing.T) {
t.Parallel()
if got := NewPublishingStore(nil, &fakePublisher{}, nil); got != nil {
t.Fatalf("NewPublishingStore(nil, …) = %#v, want 真 nil", got)
}
}
// TestPublishingStore_落库成功才推帧:成功推一帧、失败一帧不推(且错误原样上抛)。
func TestPublishingStore_落库成功才推帧(t *testing.T) {
t.Parallel()
pub := &fakePublisher{}
inner := &forwardingStoreStub{}
st := NewPublishingStore(inner, pub, nil)
ev := sampleT2()
if err := st.Record(context.Background(), ev); err != nil {
t.Fatalf("Record: %v", err)
}
subjects, payloads := pub.snapshot()
if len(subjects) != 1 || subjects[0] != SubjectChannelEvent {
t.Fatalf("subjects = %v, want [%s]", subjects, SubjectChannelEvent)
}
frame, ok := payloads[0].(ChannelEventFrame)
if !ok {
t.Fatalf("payload 类型 = %T, want ChannelEventFrame", payloads[0])
}
if frame.Event.Platform != ev.Platform || frame.Event.Category != string(ev.Category) {
t.Errorf("frame.Event = %+v, want 与事件行一致", frame.Event)
}
// 载荷必须是可 JSON 化的线上形状(面板按 `data.event` 解)。
raw, err := json.Marshal(frame)
if err != nil {
t.Fatalf("marshal frame: %v", err)
}
var decoded struct {
Event ChannelEventJSON `json:"event"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode frame: %v (raw=%s)", err, raw)
}
if decoded.Event.Summary != ev.Summary || decoded.Event.PlatformType != ev.PlatformType {
t.Errorf("解出的 event = %+v, want 与事件行一致", decoded.Event)
}
// 失败:错误上抛、**一帧都不推**。
boom := errors.New("落库失败")
inner.recordErr = boom
if err := st.Record(context.Background(), ev); !errors.Is(err, boom) {
t.Fatalf("err = %v, want %v", err, boom)
}
if subjects, _ := pub.snapshot(); len(subjects) != 1 {
t.Errorf("推送次数 = %d, want 1(失败的那次不许推)", len(subjects))
}
}
// TestPublishingStore_无Publisher时透明转发:bus 缺席(测试构造 / 未接 bus)不是错误,
// 事件照常落库,且**不 panic**。
func TestPublishingStore_无Publisher时透明转发(t *testing.T) {
t.Parallel()
inner := &forwardingStoreStub{}
st := NewPublishingStore(inner, nil, nil)
if st == nil {
t.Fatal("NewPublishingStore(inner, nil, nil) = nil, want 装饰器(内层非 nil)")
}
if err := st.Record(context.Background(), sampleT2()); err != nil {
t.Fatalf("Record: %v", err)
}
if inner.recordCalls != 1 {
t.Errorf("内层 Record 调用 = %d, want 1", inner.recordCalls)
}
}
// TestPublishingStore_推送失败不影响写入:bus 报错只记 WARN,Record 照常回 nil ——
// 帧是 best-effort,落库那一行才是权威(§4.5 的轮询兜底正是为此存在)。
func TestPublishingStore_推送失败不影响写入(t *testing.T) {
t.Parallel()
pub := &fakePublisher{err: errors.New("bus 满了")}
st := NewPublishingStore(&forwardingStoreStub{}, pub, nil)
if err := st.Record(context.Background(), sampleT2()); err != nil {
t.Fatalf("Record = %v, want nil(推送失败不该冒泡)", err)
}
if subjects, _ := pub.snapshot(); len(subjects) != 1 {
t.Errorf("推送尝试 = %d, want 1(尝试过即记 WARN)", len(subjects))
}
}
// TestPublishingStore_逐个转发其余方法:装饰器必须仍是**同一个 Store** ——
// 查询/计数/flush/清扫一律原样转发(哨兵值原样回来即证明到底了)。
func TestPublishingStore_逐个转发其余方法(t *testing.T) {
t.Parallel()
inner := &forwardingStoreStub{}
st := NewPublishingStore(inner, nil, nil)
ctx := context.Background()
if n, err := st.Count(ctx, Filter{}); err != nil || n != 42 {
t.Errorf("Count = %d/%v, want 42/nil", n, err)
}
if rows, err := st.List(ctx, Filter{}); err != nil || len(rows) != 1 || rows[0].ID != 7 {
t.Errorf("List = %+v/%v, want [{ID:7}]/nil", rows, err)
}
if cs, err := st.Counters(ctx, PlatformQQ); err != nil || len(cs) != 1 || cs[0].Platform != PlatformQQ {
t.Errorf("Counters = %+v/%v, want 一行 qq-obv11", cs, err)
}
if err := st.Flush(ctx); err != nil {
t.Errorf("Flush = %v, want nil", err)
}
if stats, err := st.Sweep(ctx); err != nil || stats.Deleted != 3 {
t.Errorf("Sweep = %+v/%v, want {Deleted:3}", stats, err)
}
if inner.countCalls != 1 || inner.listCalls != 1 || inner.counterCalls != 1 ||
inner.flushCalls != 1 || inner.sweepCalls != 1 {
t.Errorf("内层调用次数 = %+v, want 每个方法各 1 次", inner)
}
}
// TestNewWatcher_未接线明确失败:没有留存面就没有「可查的历史」这件事可做,
// 宁可明确失败,也不给一个只推帧不落库的替身(与 NewStore 的 ErrNilDB 同一口径)。
func TestNewWatcher_未接线明确失败(t *testing.T) {
t.Parallel()
w, err := NewWatcher(WatcherConfig{Source: &staticSource{}})
if !errors.Is(err, ErrNilDB) {
t.Fatalf("err = %v, want wrap of ErrNilDB", err)
}
if w != nil {
t.Fatalf("NewWatcher = %#v, want nil", w)
}
}
// TestWatcher_推帧只推已落库的_载荷形状:两个账号各一次迁移 ⇒ 两帧,
// 主题与载荷形状逐项核对;随后再采一轮(状态没变)⇒ 一帧都不多。
func TestWatcher_推帧只推已落库的_载荷形状(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
pub := &fakePublisher{}
src := &staticSource{samples: []StatusSample{
sample("bot-1", true, true),
sample("bot-2", false, true),
{Platform: PlatformQQ, AccountID: "bot-3", Known: false, Source: StatusSourceProbe},
}}
w, err := NewWatcher(WatcherConfig{
Store: store, Source: src, Publisher: pub, Interval: time.Hour, Now: testNow,
})
if err != nil {
t.Fatalf("NewWatcher: %v", err)
}
ctx := context.Background()
w.ObserveOnce(ctx)
subjects, payloads := pub.snapshot()
if len(subjects) != 3 {
t.Fatalf("帧数 = %d, want 3(三个账号各一次首次观测)", len(subjects))
}
for i, s := range subjects {
if s != SubjectChannelEvent {
t.Errorf("第 %d 帧主题 = %q, want %q", i, s, SubjectChannelEvent)
}
if _, ok := payloads[i].(ChannelEventFrame); !ok {
t.Errorf("第 %d 帧载荷类型 = %T, want ChannelEventFrame", i, payloads[i])
}
}
// 「未知」也是一个迁移、也要落行、也要推帧(§1.1 的绝不静默)。
frame, _ := payloads[2].(ChannelEventFrame)
if frame.Event.Platform != PlatformQQ || frame.Event.AccountID != "bot-3" {
t.Errorf("第三帧 = %+v, want bot-3", frame.Event)
}
if frame.Event.Summary == "" {
t.Error("未知态的 summary 为空,want 一句话常量")
}
// 「未知」的 ID 尚未回读 ⇒ id 键整个缺席,绝不写 0(0 会让所有实时帧看起来是同一行)。
raw, err := json.Marshal(frame)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var keys map[string]any
if err := json.Unmarshal(raw, &keys); err != nil {
t.Fatalf("unmarshal: %v", err)
}
evKeys, ok := keys["event"].(map[string]any)
if !ok {
t.Fatalf("event 载荷 = %v, want 一个对象", keys["event"])
}
if _, present := evKeys["id"]; present {
t.Errorf("实时帧里出现了 id 键: %s(写侧不知道刚插入的行的 id,如实缺席)", raw)
}
// 第二轮:状态未变 ⇒ 不落行、不推帧。
w.ObserveOnce(ctx)
if subjects, _ := pub.snapshot(); len(subjects) != 3 {
t.Errorf("第二轮后又推了帧(共 %d),want 仍是 3", len(subjects))
}
rows, err := store.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 3 {
t.Errorf("库里有 %d 行, want 3", len(rows))
}
}
// TestWatcher_落库失败既不推帧也不崩:逐条留存的失败只影响它自己那个账号,
// 其余账号照常走完这一轮(绝不整轮丢弃)。
func TestWatcher_落库失败既不推帧也不崩(t *testing.T) {
t.Parallel()
pub := &fakePublisher{}
stub := &accountStatusStoreStub{err: errors.New("写不进去")}
src := &staticSource{samples: []StatusSample{sample("bot-1", true, true)}}
w, err := NewWatcher(WatcherConfig{Store: stub, Source: src, Publisher: pub, Interval: time.Hour})
if err != nil {
t.Fatalf("NewWatcher: %v", err)
}
w.ObserveOnce(context.Background())
if subjects, _ := pub.snapshot(); len(subjects) != 0 {
t.Errorf("推送次数 = %d, want 0(落不下去就不声称)", len(subjects))
}
if stub.calls != 1 {
t.Errorf("落库调用 = %d, want 1(判定照常发生,只是没成功)", stub.calls)
}
}
// TestWatcher_未接Publisher只落库:未接 bus(真机未接 / 单元测试构造)时
// 事件照常留痕,只是没有实时帧。
func TestWatcher_未接Publisher只落库(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
src := &staticSource{samples: []StatusSample{sample("bot-1", true, true)}}
w, err := NewWatcher(WatcherConfig{Store: store, Source: src, Interval: time.Hour})
if err != nil {
t.Fatalf("NewWatcher: %v", err)
}
w.ObserveOnce(context.Background())
rows, err := store.List(context.Background(), Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 {
t.Fatalf("库里有 %d 行, want 1(只落库不推帧)", len(rows))
}
}
// TestWatcher_源缺席不崩:Source 为 nil 时每拍都是空快照(等于不观测),
// 绝不因为未接线而 panic 或空转刷日志。
func TestWatcher_源缺席不崩(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
w, err := NewWatcher(WatcherConfig{Store: store, Interval: time.Hour})
if err != nil {
t.Fatalf("NewWatcher: %v", err)
}
w.ObserveOnce(context.Background())
if rows, _ := store.List(context.Background(), Filter{}); len(rows) != 0 {
t.Fatalf("库里有 %d 行, want 0", len(rows))
}
}
// TestWatcher_立即观测且可干净停止:Start 后**不等一个间隔**就先采一次(进程刚起来
// 时那次观测不该被推迟),ctx 取消后循环退出、Done 关闭。
func TestWatcher_立即观测且可干净停止(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, nil)
src := &staticSource{samples: []StatusSample{sample("bot-1", true, true)}}
// 节拍取得很短:这条用例要的是「循环真的在跑、真的会停」,而不是等 60s。
w, err := NewWatcher(WatcherConfig{Store: store, Source: src, Interval: 5 * time.Millisecond})
if err != nil {
t.Fatalf("NewWatcher: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
w.Start(ctx)
// 重复 Start 必须幂等:否则两个循环同时 `close(w.done)` 会 panic。
w.Start(ctx)
if !waitFor(func() bool { return src.calls.Load() >= 3 }, 2*time.Second) {
t.Fatalf("采样调用 = %d, want ≥3(立即一次 + 至少两次节拍)", src.calls.Load())
}
cancel()
select {
case <-w.Done():
case <-time.After(2 * time.Second):
t.Fatal("ctx 取消后 Done 未关闭(采样循环没干净退出)")
}
settled := src.calls.Load()
time.Sleep(30 * time.Millisecond)
if got := src.calls.Load(); got != settled {
t.Errorf("循环退出后仍在采样:%d → %d", settled, got)
}
}
// waitFor 轮询直到 cond 为真或超时(避免给循环用例引入固定 sleep 的脆性)。
func waitFor(cond func() bool, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(time.Millisecond)
}
return cond()
}