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

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

114 lines
5.3 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 §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)