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

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

261 lines
13 KiB
Go
Raw 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 §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
}