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

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

160 lines
7.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 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)
}