state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」** (面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。 ⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
160 lines
7.3 KiB
Go
160 lines
7.3 KiB
Go
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)
|
||
}
|