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

@@ -0,0 +1,159 @@
package gateway
// 本文件是渠道事件的**实时面**(design/26 §2.6 / §4.5;切片 E7)。
//
// 与同族的 `admin_channel_events.go` 分工明确、互不重叠:
//
// - 那边是**只读查询面**(REST,面板轮询的权威):`/admin/channels/events` 与
// `/counters`;
// - 这边是**增量面**:周期采样账号状态 → 判定迁移 → 落一行 T2(`events.StatusTracker`)
// → 推上进程内 bus(`channel.event`)→ hub 的 relay 规范成 `channel_event` 帧 →
// 角色门 → 连接。**不新开监听端口、不做 HTTP 互调**(AGENTS §9 红线)。
//
// 为什么采样要**常驻**(而不是像可观测推送那样「有订阅才跑」):§0.2 的事故正是
// 「没人看面板的那几分钟里,账号掉线没有任何痕迹」。留存面必须**不看面板也在跑**,
// 否则「可查的历史」只在有人盯着时才存在。推帧只是顺带的产物。
//
// 采样节拍 60s 与 `qq_obv11` 的 `get_status` 探活同拍:采得比探活快只会反复读到
// 同一个值(纯浪费),慢于探活只会推迟发现掉线。它**不是可配项** —— 调优类旋钮
// 一律走 `$DATA_DIR/config/**`(AGENTS §10 的 `.env` 冻结红线),而这里没有任何
// 值得调的东西。
import (
"context"
"sort"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// wsChannelEvent 是渠道事件实时帧的类型名。它**不在** `ws_frame_scope.go` 的
// `frameReachesAll` 白名单里,因此按 fail-closed 默认档只发 `owner` / `admin`
// 连接;`viewer` 继续走既有的 REST 只读档(design/11 §7.1)。帧壳:
//
// {"type":"channel_event","data":{"event":{…ChannelEventJSON…}}}
//
// 帧里**没有**任何平台原文 / 媒体 URL / 凭据:它承载的就是 `channel_events` 的一行,
// 那行的列在迁移 0021 里就没有那些字段(§4.2)。
const wsChannelEvent = "channel_event"
// channelAccountStatusSamples 采集注册表里**每一个可观测账号**的在线状态快照。
//
// 与 `liveAccountOnline`(管理列表用)的三点差别,每一点都是刻意的:
//
// 1. **未知也收**:那边「只收确知的结果」,known=false 的账号整类不进 map(面板上
// 表现为字段缺席);这边 **known=false 正是要留痕的状态之一**(§2.6 的「尚未探到」
// ≠ 离线),归一化成 `unknown` 照常落行。这正是本片要补的洞:未知不是「不报」,
// 而是「如实报未知」。
// 2. **带上账号 id**:管理列表按平台聚合,留存行按 `account_id` 落列(§4.1)。
// 3. **排序确定**:注册表的 List 走 map,顺序随机;这里按(平台, 账号)排序,
// 使日志与测试顺序可复现(同一拍里多个账号的先后不该是随机的)。
//
// 没有那条状态面的适配器(telegram / webhook / weixin-ilink…)**不进快照**:本能力
// 观测的是「问得到的账号状态」,凭空为它们造一个 `unknown` 等于把「这个平台根本没有
// 这条信号」写成「我们探不到」—— 那是两件事(与 §3.4 对微信「不提供非消息事件」的
// 如实标注同一个方向:没有就说没有,不伪造)。
func (s *Server) channelAccountStatusSamples() []events.StatusSample {
if s == nil || s.caps == nil {
return nil
}
out := make([]events.StatusSample, 0, 4)
for _, cap := range s.caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
as, ok := asAccountStatusAware(cap.Value)
if !ok {
continue
}
online, known := as.AccountStatus()
platform, account := splitAdapterName(ch.Name())
out = append(out, events.StatusSample{
Platform: platform, AccountID: account,
Online: online, Known: known,
// 这条状态面当前**唯一**的写入点是 `qq_obv11/probe.go` 的
// recordAccountStatus(get_status 主动探活)。将来若把 meta_event 心跳
// 也接进同一状态面,通道码应改由适配器一并报告(那时这个常量就要让位)。
Source: events.StatusSourceProbe,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Platform != out[j].Platform {
return out[i].Platform < out[j].Platform
}
return out[i].AccountID < out[j].AccountID
})
return out
}
// channelEventWriter 返回渠道事件的**写入面**。
//
// `Deps.EventStore` 声明的类型是只读查询面(E6 的两个端点只需要 List/Count/Counters),
// 而组合根实际注入的是实现了完整 `events.Store` 的具体存储。这里按**能力断言**取
// 写入面(与 `asAccountStatusAware` / `asRescanAware` 同一套路):
//
// - 拿到 ⇒ 实时面可用(落行 + 推帧);
// - 拿不到(真 nil 接口,或测试里的只读替身)⇒ 返回 nil,实时面**整个不启动**,
// 绝不伪造一个写不进去的替身、也绝不留一条「看着在跑其实什么都没记」的循环。
//
// ⚠️ 这条断言是**零改动的接线**(不动 deps.go / server.go / contracts.go 三个热点文件)。
// 若将来把写入面正式纳入 `Deps`(另一个字段),这里的断言应换成那个字段。
func (s *Server) channelEventWriter() events.Store {
if s == nil || s.channelEvents == nil {
return nil
}
store, ok := s.channelEvents.(events.Store)
if !ok {
return nil
}
return store
}
// statusSourceFunc 把「返回快照的函数」适配成 events.StatusSource(网关不实现实体类型,
// 只交出这个函数,避免为一个方法再引入一个结构体)。
type statusSourceFunc func() []events.StatusSample
// AccountStatusSamples 实现 events.StatusSource;函数为 nil 时返回空快照。
func (f statusSourceFunc) AccountStatusSamples() []events.StatusSample {
if f == nil {
return nil
}
return f()
}
// startChannelEventWatch 启动渠道事件的实时面(常驻,直到 ctx 取消)。
//
// 启停**都留一行日志**(对齐 `qq_obv11/probe.go:logProbeStart` 的同一条纪律):
// 「实时面根本没跑」与「跑了但一直没迁移」在日志里否则长得一模一样(都是没有),
// 而排障时要能一眼分开这两件事。落点是**启动**(带生效间隔、有无 bus、有无状态源)
// 与**未启动**(带原因)两种;停止不单独记(进程退出本身有日志,再记一行只是噪音)。
//
// 未接线(没有写入面)时记一行 INFO 说明原因后安静返回:这既不是错误也不是降级,
// 只是「这份构建没有留存面」。真出错(构造失败)记 WARN —— 绝不静默。
func (h *hub) startChannelEventWatch(ctx context.Context) {
if h == nil {
return
}
if h.eventStore == nil {
h.log.Info("channel event watch disabled", "reason", "no writable event store wired")
return
}
w, err := events.NewWatcher(events.WatcherConfig{
Store: h.eventStore,
Source: statusSourceFunc(h.accountStatus),
Publisher: h.bus,
Log: h.log,
})
if err != nil {
h.log.Warn("channel event watch disabled", "err", err)
return
}
h.channelWatch = w
w.Start(ctx)
h.log.Info("channel event watch started",
"interval", w.Interval().String(),
"publisher", h.bus != nil,
"source", h.accountStatus != nil)
}