state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」** (面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。 ⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
261 lines
13 KiB
Go
261 lines
13 KiB
Go
package events
|
||
|
||
// 本文件是「**她自己账号的在线 / 离线**」这条高价值事件的**迁移检测与归一化**
|
||
// (design/26 §2.6;切片 E7)。
|
||
//
|
||
// 它补的是 §2.6 里那条**债**:`channelbridge/events.go` 的 `isFrameTelemetry` 明写
|
||
// 「心跳 / 生命周期逐帧写库是纯浪费(T1 + 只计数)」,而 §2.6 要求的是**状态迁移**
|
||
// (在线→离线 / 离线→在线)落一行 T2 —— 当时没有迁移信号可接(`adapter.SystemNotice`
|
||
// 没投影 `status.online`),`qq_obv11/probe.go` 的主动探活也只有内存状态 + 一条日志。
|
||
// 本文件把那句话变成可实现的东西:**给它一个状态样本,它回答「这是不是一次迁移」**,
|
||
// 迁移就落一条 T2 行(逐条、参与查询面与 GC),没迁移就什么都不做。
|
||
//
|
||
// 三条口径写在这里(都有测试钉住,不靠注释自觉):
|
||
//
|
||
// 1. **只在迁移时落行**:每 60s 采一次样,只有状态**变了**才落 —— 这既满足 §2.6
|
||
// (迁移是事实),又不违背 §4.3(绝不逐帧写库)。
|
||
// 2. **未知 / 异常一律归一化留痕,不整类丢弃**:样本的另一半取值是 `known=false`
|
||
// (尚未探到 / 一直失败),它归一化成 `AccountStateUnknown` 并**照常落一行**;
|
||
// 检测通道码为空时归一成固定码,**绝不**因为「字段看着怪」把整类事件丢掉
|
||
// (§1.1「绝不静默」)。这与 `gateway` 的 `liveAccountOnline`(未知即缺席)是
|
||
// **两件不同的事**:那里是「面板要如实显示未知」,这里是「未知也是要留痕的事实」。
|
||
// 3. **身份不在这里判定**:本文件不读白名单、不看正文、不决定权限。行的
|
||
// `actor_role` 恒为 `system`(状态变更由平台侧上报,不是人做出的动作 —— 与
|
||
// `channelbridge` 的 `eventActorRole` 对「无当事人事件」的口径逐字一致),
|
||
// `self_related` 恒为 true(§2.2 的 self 命中正是这次升档的依据)。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// AccountState 是账号在线状态的**归一化三态**(§2.6 的「known 区分确知离线与尚未探到」)。
|
||
type AccountState string
|
||
|
||
// 三态取值。它们同时也是写进 `summary`(固定文案)的那个词,绝不落平台原文。
|
||
const (
|
||
// AccountStateUnknown 是**尚未探到**:探活刚启动 / 一直失败 / 未接线。它既不是
|
||
// 「在线」也不是「离线」——把未知写成离线,正是 2026-10-05 事故(面板一片绿)
|
||
// 的镜像错误,因此它有自己的取值、自己的文案,且**照常落行**。
|
||
AccountStateUnknown AccountState = "unknown"
|
||
// AccountStateOnline 是平台侧回复「账号在线」(OneBot `status.online=true`)。
|
||
AccountStateOnline AccountState = "online"
|
||
// AccountStateOffline 是**确知离线**(探活成功且 `online=false`)。它与
|
||
// 「我们连不上 OneBot」是两件事(§2.6 第 4 条:连接 ≠ 账号在线)。
|
||
AccountStateOffline AccountState = "offline"
|
||
)
|
||
|
||
// 检测通道码(写进 `platform_type` 列,§4.1「平台原生类型码原样保留,排障时两者并用」)。
|
||
//
|
||
// 两条通道并列(§2.6):主动探活(本片接线)与 `meta_event` 心跳 / 生命周期
|
||
// (取决于对端是否配置推送;其**逐帧**留存仍由 E2 的 T1 计数承担,迁移事件可复用
|
||
// 本文件 —— 把样本的 Source 换成下面两个常量即可,不需要第二个实现)。
|
||
const (
|
||
// StatusSourceProbe 是 OneBot 标准 action `get_status` 的主动探活(§2.6 的兜底通道)。
|
||
StatusSourceProbe = "get_status"
|
||
// StatusSourceHeartbeat 是上游主动推送的 `meta_event.heartbeat`(§2.6 的一手通道)。
|
||
StatusSourceHeartbeat = "meta_event.heartbeat"
|
||
// StatusSourceLifecycle 是上游主动推送的 `meta_event.lifecycle`(connect/enable/disable)。
|
||
StatusSourceLifecycle = "meta_event.lifecycle"
|
||
// statusSourceFallback 是**通道码缺失**时的归一化取值。它是我们自己的码(不是任何
|
||
// 平台枚举值),因此不会被误读成「上游报了这种类型」;有它,「字段没给」这件事
|
||
// 不会演变成「整条事件被丢掉」。
|
||
statusSourceFallback = "account_status"
|
||
)
|
||
|
||
// 账号状态迁移事件的固定文案(§4.2:一句话常量,**不含任何平台原文**)。
|
||
//
|
||
// 九种 (前态, 后态) 组合**逐条给出**,没有 default 兜底 —— 兜底会让「漏了一种组合」
|
||
// 悄悄退化成一句含糊的话;而这里的每一种组合都对应一个真实可查的事实。前态为空表示
|
||
// 「本进程首次观测到该账号」(也不是「未知」:未知有自己的取值)。
|
||
//
|
||
// 表按**调用返回**而不是包级变量(AGENTS §10 禁包级状态;与 `admin_channel_events.go`
|
||
// 的 `channelEventPlatforms()` 同一手法)。它每次迁移才构造一次,迁移本就是低频事实,
|
||
// 这点分配可以忽略。
|
||
func statusSummaries() map[string]string {
|
||
return map[string]string{
|
||
statusSummaryKey("", AccountStateOnline): "账号在线(首次确知)",
|
||
statusSummaryKey("", AccountStateOffline): "账号离线(首次确知)",
|
||
statusSummaryKey("", AccountStateUnknown): "账号状态未知(尚未探到)",
|
||
statusSummaryKey(AccountStateOnline, AccountStateOffline): "账号离线(在线→离线)",
|
||
statusSummaryKey(AccountStateOnline, AccountStateUnknown): "账号状态未知(在线→未知)",
|
||
statusSummaryKey(AccountStateOffline, AccountStateOnline): "账号上线(离线→在线)",
|
||
statusSummaryKey(AccountStateOffline, AccountStateUnknown): "账号状态未知(离线→未知)",
|
||
statusSummaryKey(AccountStateUnknown, AccountStateOnline): "账号上线(未知→在线)",
|
||
statusSummaryKey(AccountStateUnknown, AccountStateOffline): "账号离线(未知→离线)",
|
||
}
|
||
}
|
||
|
||
// statusSummaryKey 拼出一个迁移在文案表里的键(前后态之间用一个固定分隔符)。
|
||
func statusSummaryKey(prev, next AccountState) string {
|
||
return string(prev) + "→" + string(next)
|
||
}
|
||
|
||
// StatusSample 是一次**账号状态观测**:谁(平台 + 账号)在哪个通道上被看到处于什么状态。
|
||
//
|
||
// 它是本文件唯一的输入形状,刻意与适配器的既有能力面同构
|
||
// (`AccountStatus() (online, known bool)`,见 `admin_channel_status.go` 的
|
||
// `accountStatusAware`),因此采样侧(网关)不需要发明第二套语义。
|
||
type StatusSample struct {
|
||
// Platform 是来源平台(platform 常量之一)。**必填**,否则 Observe 返回 ErrNoPlatform。
|
||
Platform string
|
||
// AccountID 是多账号标识;空 = 单账号 / 未知(§4.1 的列口径,绝不编一个 id 出来)。
|
||
AccountID string
|
||
// Online 是平台侧报告的在线位。**只有 Known 为真时才有意义**(OneBot 的
|
||
// `status.online`):Known 为假时它被忽略,绝不拿来当「离线」。
|
||
Online bool
|
||
// Known 区分「确知」与「尚未探到」——它是三态而不是二态的**唯一**来源。
|
||
Known bool
|
||
// Source 是检测通道码(StatusSource* 常量)。空时归一成 statusSourceFallback,
|
||
// **不报错**:一个字段缺失不该让整类事件消失(§1.1)。非白名单取值原样保留:
|
||
// 它是排障用的平台原生码(§4.1),在这里做封闭校验只会让将来的新通道被拒。
|
||
Source string
|
||
}
|
||
|
||
// state 把一次观测归一化成三态(归一化规则见 StatusSample 的字段注释)。
|
||
// 唯一会失败的情形是**没有平台**:缺了它这一行既查不到也归不了类(与 Store.Record
|
||
// 的 ErrNoPlatform 同一判据,本函数只是更早地把错误交给调用方)。
|
||
func (s StatusSample) state() (AccountState, error) {
|
||
if strings.TrimSpace(s.Platform) == "" {
|
||
return "", fmt.Errorf("events: 账号状态样本缺少来源平台: %w", ErrNoPlatform)
|
||
}
|
||
switch {
|
||
case !s.Known:
|
||
return AccountStateUnknown, nil
|
||
case s.Online:
|
||
return AccountStateOnline, nil
|
||
default:
|
||
return AccountStateOffline, nil
|
||
}
|
||
}
|
||
|
||
// statusKey 是迁移表的键:平台 + 账号(与计数表的唯一键前两列同形)。
|
||
type statusKey struct {
|
||
platform string
|
||
accountID string
|
||
}
|
||
|
||
// StatusResult 是 Observe 的结果。
|
||
type StatusResult struct {
|
||
// State 是本次观测归一化后的状态(**无论是否迁移都给出**)。
|
||
State AccountState
|
||
// Previous 是上一次观测到的状态;空串表示「本进程首次见到该账号」。
|
||
Previous AccountState
|
||
// Changed 报告这**是不是一次迁移**(首次观测也算:它是「第一份事实」)。
|
||
Changed bool
|
||
// Event 是归一化后的事件行,仅在 Changed 时有意义 —— 它是落进 `channel_events`
|
||
// 的那一行,也是上 bus 推成 WS 帧的载荷(见 ChannelEventFrame)。
|
||
Event Event
|
||
// Recorded 报告这一行是否**真的落库成功**。Store 未接线或落库失败时它为 false,
|
||
// 此时事件**不得**被当成「已留存的事实」去推给面板(落不下去就不声称)。
|
||
Recorded bool
|
||
}
|
||
|
||
// StatusTracker 是账号状态迁移的判定与落盘。它**只做状态机**:不采样、不定时、
|
||
// 不推送(那是 `Watcher` 与网关的事),因此可以用一张表把九种迁移逐个钉住。
|
||
//
|
||
// 它可安全并发使用;`seen` 只在临界区内读写,落库调用**在锁外**执行(绝不在持锁时
|
||
// 做 I/O —— 一次慢写不该让别的账号的观测排队)。
|
||
type StatusTracker struct {
|
||
store Store
|
||
now func() time.Time
|
||
mu sync.Mutex
|
||
seen map[statusKey]AccountState
|
||
}
|
||
|
||
// NewStatusTracker 构造迁移判定器。store 为 nil 时**照常判定**,只是不落库
|
||
// (§4.3 的 fail-visible 降级由调用方决定怎么呈现;这里绝不 panic、也绝不假装写过)。
|
||
// now 为 nil 时用 time.Now(测试注入固定时钟)。
|
||
func NewStatusTracker(store Store, now func() time.Time) *StatusTracker {
|
||
if now == nil {
|
||
now = time.Now
|
||
}
|
||
return &StatusTracker{store: store, now: now, seen: make(map[statusKey]AccountState)}
|
||
}
|
||
|
||
// Observe 收一次观测:归一化 → 判迁移 → 迁移则落一行 T2(并在结果里带回那行)。
|
||
//
|
||
// 返回错误的两种情形都是**调用方该看见的**:样本缺平台(ErrNoPlatform 的包装)、
|
||
// 或者迁移行**落库失败**(此时 Recorded 为 false,调用方据此不推帧、只记 WARN)。
|
||
// 非迁移观测返回零错误 —— 「状态没变」是正常结果,不是异常。
|
||
func (t *StatusTracker) Observe(ctx context.Context, s StatusSample) (StatusResult, error) {
|
||
state, err := s.state()
|
||
if err != nil {
|
||
return StatusResult{}, err
|
||
}
|
||
key := statusKey{platform: strings.TrimSpace(s.Platform), accountID: s.AccountID}
|
||
t.mu.Lock()
|
||
prev, seen := t.seen[key]
|
||
if prev != state {
|
||
t.seen[key] = state
|
||
}
|
||
t.mu.Unlock()
|
||
|
||
res := StatusResult{State: state, Previous: prev, Changed: !seen || prev != state}
|
||
if !res.Changed {
|
||
return res, nil
|
||
}
|
||
res.Event = statusEvent(key, s, state, prev, t.now())
|
||
if t.store == nil {
|
||
return res, nil
|
||
}
|
||
if err := t.store.Record(ctx, res.Event); err != nil {
|
||
return res, fmt.Errorf("events: 落账号状态迁移行: %w", err)
|
||
}
|
||
res.Recorded = true
|
||
return res, nil
|
||
}
|
||
|
||
// States 返回当前已观测到的账号状态快照(键为「平台 + 账号」)。它给测试与排障用:
|
||
// 迁移判定的内部状态可见,但**不会被任何生产路径依赖**。
|
||
func (t *StatusTracker) States() map[string]AccountState {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
out := make(map[string]AccountState, len(t.seen))
|
||
for k, v := range t.seen {
|
||
out[statusKeyString(k)] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// statusEvent 把一次迁移投影成一条待留存事件(design/26 §4.1 的列,逐列对应)。
|
||
//
|
||
// 取值口径(每一条都是「如实」而非「猜」):
|
||
// - `tier = t2`:§2.6 明令「她自己账号的在线 / 离线」是 T2 高价值(留存 + 上行);
|
||
// 也正因它 T2,存储层才**逐条落行**(persistsAsRow),这一行才查得到;
|
||
// - `category = lifecycle`:它由生命周期 / 探活派生(§3.1 的 meta_event 行注明了这条
|
||
// 派生关系),不另造新类别;
|
||
// - `platform_type` = 检测通道码(get_status / meta_event.*),排障时据此分辨
|
||
// 「这条是探活探到的」还是「上游心跳推来的」;
|
||
// - `actor_id` 空 + `actor_role = system`:状态变更来自平台侧,没有人的当事人 ——
|
||
// 与 channelbridge 的 eventActorRole 对「无当事人事件」的口径逐字一致;
|
||
// - `self_related = true`:命中的正是她自己的账号(§2.2 升档的那条判据);
|
||
// - `channel_id` / `group_id` / `message_id` 留空:账号状态不属任何会话或消息。
|
||
func statusEvent(key statusKey, s StatusSample, next, prev AccountState, now time.Time) Event {
|
||
source := strings.TrimSpace(s.Source)
|
||
if source == "" {
|
||
source = statusSourceFallback
|
||
}
|
||
summary := statusSummaries()[statusSummaryKey(prev, next)]
|
||
return Event{
|
||
Platform: key.platform,
|
||
AccountID: key.accountID,
|
||
Tier: TierT2,
|
||
Category: CategoryLifecycle,
|
||
PlatformType: source,
|
||
ActorRole: RoleSystem,
|
||
SelfRelated: true,
|
||
Summary: summary,
|
||
CreatedAt: now,
|
||
}
|
||
}
|
||
|
||
// statusKeyString 把内部键拼成 `"<platform>/<accountID>"`(日志与 States 的可读形态,
|
||
// 与 channelbridge 的内存降级计数字典同形)。
|
||
func statusKeyString(k statusKey) string {
|
||
return k.platform + "/" + k.accountID
|
||
}
|