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

@@ -42,31 +42,17 @@ const channelEventsLogSource = "gateway/admin_channel_events"
// 「与 `llm/usage` 同口径」,两处各对着同一条设计口径写死,谁也别想只改一处。
const channelEventsMaxWindow = 90 * 24 * time.Hour
// channelEventDTO 是一条 `channel_events` 行的线上投影(键名与同族的
// `counters[]` 一致,取 camelCase)。
// channelEventDTO 是一条 `channel_events` 行的线上投影。它现在是
// `events.ChannelEventJSON` 的**类型别名**(切片 E7):同一条事件有两条线上出口 ——
// 这里的 REST 列表与实时 `channel_event` 帧的 `data.event`。两个出口共用一份结构,
// 是「不许两处各写一份形状」这条纪律的落地(否则迟早出现「帧里有这个键、列表里
// 没有」这类无从察觉的差异)。
//
// 列与 `events.Event` **一一对应且不多不少**:它没有、也不会有承载平台原文、
// 列与 `events.Event` 一一对应且不多不少:它没有、也不会有承载平台原文、
// 媒体 URL、凭据或 OneBot `flag` 的字段(§4.2 红线)。`summary` 是适配器生成的
// 固定文案(如「群成员加入」),**已净化**,查不出原始文本。
type channelEventDTO struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
AccountID string `json:"accountId"`
Tier string `json:"tier"`
Category string `json:"category"`
PlatformType string `json:"platformType"`
ChannelID string `json:"channelId"`
ChannelType string `json:"channelType"`
GroupID string `json:"groupId"`
ActorID string `json:"actorId"`
ActorRole string `json:"actorRole"`
TargetID string `json:"targetId"`
SelfRelated bool `json:"selfRelated"`
MessageID string `json:"messageId"`
Summary string `json:"summary"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// 固定文案(如「群成员加入」)或本包的固定迁移文案,**已净化**,查不出原始文本。
// `id` 可缺席(实时帧里那行的 id 尚未回读),列表路径上它恒有值。
type channelEventDTO = events.ChannelEventJSON
// channelEventListResponse 是列表端点的 data 本体(§4.5:`{events, total}`)。
// `events` 恒为非 nil 切片(无数据时是 `[]` 而非 `null`,客户端不必判空)。
@@ -323,27 +309,9 @@ func channelEventsListResponseOf(rows []events.Event, total int64) channelEventL
return out
}
// channelEventDTOOf 是单行投影(一处换算,避免 List 与将来可能的详情端点各写一遍)。
// channelEventDTOOf 是单行投影(一处换算,避免 List 与实时帧各写一遍)。
func channelEventDTOOf(ev events.Event) channelEventDTO {
return channelEventDTO{
ID: ev.ID,
Platform: ev.Platform,
AccountID: ev.AccountID,
Tier: string(ev.Tier),
Category: string(ev.Category),
PlatformType: ev.PlatformType,
ChannelID: ev.ChannelID,
ChannelType: ev.ChannelType,
GroupID: ev.GroupID,
ActorID: ev.ActorID,
ActorRole: ev.ActorRole,
TargetID: ev.TargetID,
SelfRelated: ev.SelfRelated,
MessageID: ev.MessageID,
Summary: ev.Summary,
CreatedAt: ev.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: ev.UpdatedAt.UTC().Format(time.RFC3339),
}
return ev.JSON()
}
// channelEventCountersResponseOf 把计数表结果翻成线上结构。`approx` 恒为 true:
+9
View File
@@ -4,6 +4,7 @@ import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
@@ -51,6 +52,11 @@ var eventBridge = map[string]string{
obsLogSubject: wsObservabilityLog,
obsTraceSubject: wsObservabilityTrace,
obsMetricsSubject: wsObservabilityMetrics,
// 渠道事件实时帧(design/26 §2.6 / §4.5;切片 E7):账号状态迁移与(经
// PublishingStore 装饰的)其余渠道事件都发在同一个领域主题上,在这里统一落成
// 帧类型。载荷即 `events.ChannelEventFrame`(`{"event":{…}}`),与 REST 列表的
// `events[]` 同形 —— 面板不需要第二套解码。
events.SubjectChannelEvent: wsChannelEvent,
}
// bridgePatterns 是 hub 在 ws.> 之外额外订阅的 bus 模式,用于
@@ -67,6 +73,9 @@ var bridgePatterns = []string{
// 后台子代理批次结果:它既不匹配 ws.>,也不是 cyrene.agent 的 4 段广播形态,
// 所以必须在这里登记订阅,否则后台结果永远上不了前台(只能看到进度帧)。
orchestration.SubagentResultSubject,
// 渠道事件实时面(切片 E7):精确主题,不用 channel.> —— 不替将来可能的
// 其他 channel.* 生产者顺手开口子(多订阅一个主题就多一次解码尝试)。
events.SubjectChannelEvent,
}
// normalizeEvent 把 bus 事件映射为下行 wsEvent。ws.* 主题保持
@@ -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)
}
@@ -0,0 +1,382 @@
package gateway
// 本文件钉住渠道事件**实时面**的网关侧(design/26 §2.6 / §4.5 / §6.5;切片 E7):
//
// - 账号状态**快照**的三态口径:确知在线 / 确知离线 / **尚未探到**都要进快照
// (`liveAccountOnline` 会把未知整类丢掉,那是面板口径;留存面必须收),
// 而没有那条状态面的平台不进快照(「没有这条信号」≠「我们探不到」);
// - 写入面按**能力断言**取:未接线的只读替身 ⇒ 实时面不启动(不伪造替身);
// - **真走法**端到端:能力注册表里的账号状态迁移 → 落一行 T2 → 上进程内 bus →
// hub 的 relay → `channel_event` 帧;owner/admin 收得到,**viewer 收不到**
// (frameReachesAll 的 fail-closed 默认档,design/11 §7.1)。
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// watchStubAdapter 是带**可变、并发安全**账号状态面的渠道替身:它模拟
// `*qq_obv11.Adapter`(`AccountStatus()` + 探活首帧之前 known=false)。
// 加锁是必要的 —— 测试从另一条 goroutine 改状态,采样循环在自己的 goroutine 上读。
type watchStubAdapter struct {
*rescanStubAdapter
mu sync.Mutex
online bool
known bool
}
func (a *watchStubAdapter) AccountStatus() (bool, bool) {
a.mu.Lock()
defer a.mu.Unlock()
return a.online, a.known
}
// set 改一次底层状态(模拟探活返回了新的 online/known)。
func (a *watchStubAdapter) set(online, known bool) {
a.mu.Lock()
a.online, a.known = online, known
a.mu.Unlock()
}
var _ adapter.PlatformAdapter = (*watchStubAdapter)(nil)
var _ accountStatusAware = (*watchStubAdapter)(nil)
// newWatchStub 构造一个已注册形态的渠道替身(与组合根一致:包在 namedAdapter 里,
// 因此网关必须拆开内嵌字段才能抵达状态面)。
func newWatchStub(name string, online, known bool) *watchStubAdapter {
return &watchStubAdapter{
rescanStubAdapter: &rescanStubAdapter{name: name, connected: true},
online: online,
known: known,
}
}
// registerChannelStub 把一个渠道替身按组合根的形态注册进注册表。
func registerChannelStub(t *testing.T, caps *capreg.Registry, stub adapter.PlatformAdapter, name string) {
t.Helper()
if err := caps.Register(capreg.Capability{
Name: name,
Kind: capreg.KindChannel,
Value: namedAccountAdapter{PlatformAdapter: stub},
}); err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
// TestChannelAccountStatusSamples_三态齐全且不丢未知 是这条能力面的口径测试:
// 三个 qq-obv11 账号(在线 / 离线 / 尚未探到)都必须进快照,且未知的样本
// **必须带着 Known=false** 出来(它会被归一化成 `unknown` 并落行);
// 没有状态面的平台(telegram)不进快照。
func TestChannelAccountStatusSamples_三态齐全且不丢未知(t *testing.T) {
t.Parallel()
caps := capreg.New()
registerChannelStub(t, caps, newWatchStub("qq-obv11:aa", true, true), "qq-obv11:aa")
registerChannelStub(t, caps, newWatchStub("qq-obv11:bb", false, true), "qq-obv11:bb")
registerChannelStub(t, caps, newWatchStub("qq-obv11:cc", false, false), "qq-obv11:cc")
// telegram 没有独立账号状态面:它不进快照(不伪造一个 unknown)。
registerChannelStub(t, caps, &rescanStubAdapter{name: "telegram:dd", connected: true}, "telegram:dd")
srv := buildServerOnly(t, cfgDefault(), Deps{Auth: mustJWT(t), Registry: caps})
got := srv.channelAccountStatusSamples()
if len(got) != 3 {
t.Fatalf("快照 %d 条, want 3(未知也要收,没有状态面的平台不收): %+v", len(got), got)
}
// 顺序确定(按平台 + 账号排序),因此可以按下标断言。
want := []struct {
account string
online bool
known bool
}{{"aa", true, true}, {"bb", false, true}, {"cc", false, false}}
for i, w := range want {
if got[i].Platform != events.PlatformQQ || got[i].AccountID != w.account {
t.Fatalf("快照[%d] = %s/%s, want qq-obv11/%s", i, got[i].Platform, got[i].AccountID, w.account)
}
if got[i].Online != w.online || got[i].Known != w.known {
t.Errorf("快照[%d] = online:%v known:%v, want online:%v known:%v",
i, got[i].Online, got[i].Known, w.online, w.known)
}
if got[i].Source != events.StatusSourceProbe {
t.Errorf("快照[%d].Source = %q, want %q", i, got[i].Source, events.StatusSourceProbe)
}
}
}
// TestChannelAccountStatusSamples_注册表缺席时为空:未接注册表(只可能出现在
// 精简的测试构造里)⇒ 空快照,而不是 panic 或一条编出来的样本。
func TestChannelAccountStatusSamples_注册表缺席时为空(t *testing.T) {
t.Parallel()
srv := buildServerOnly(t, cfgDefault(), Deps{Auth: mustJWT(t)})
if got := srv.channelAccountStatusSamples(); len(got) != 0 {
t.Fatalf("快照 = %+v, want 空", got)
}
}
// TestChannelEventWriter_只读替身不给写入面:`Deps.EventStore` 声明的是只读查询面,
// 组合根实际注入的是完整存储 ⇒ 能力断言才拿得到写入面。拿不到时返回**真 nil**,
// 实时面据此不启动(绝不包一个写不进去的替身)。
func TestChannelEventWriter_只读替身不给写入面(t *testing.T) {
t.Parallel()
// ① 只读替身(E6 的查询面测试就是用它)⇒ 没有写入面。
readOnly := buildServerOnly(t, cfgDefault(), Deps{Auth: mustJWT(t), EventStore: &channelEventStoreStub{}})
if got := readOnly.channelEventWriter(); got != nil {
t.Fatalf("channelEventWriter(只读替身) = %#v, want nil", got)
}
if readOnly.hub.channelWatch != nil {
t.Error("只读替身下实时面仍被启动(应当整个不启动)")
}
// ② 未接线(真 nil 接口)⇒ 同样 nil。
unwired := buildServerOnly(t, cfgDefault(), Deps{Auth: mustJWT(t)})
if got := unwired.channelEventWriter(); got != nil {
t.Fatalf("channelEventWriter(未接线) = %#v, want nil", got)
}
// ③ 真存储 ⇒ 写入面可用,且实时面真的起来了。
_, store := channelEventTestStore(t, t.TempDir())
wired := buildServerOnly(t, cfgDefault(), Deps{Auth: mustJWT(t), EventStore: store})
if wired.channelEventWriter() == nil {
t.Fatal("channelEventWriter(真存储) = nil, want 写入面")
}
if wired.hub.channelWatch == nil {
t.Fatal("真存储下实时面未启动(账号状态迁移将不会留痕)")
}
wired.hub.close()
}
// TestChannelEventFrame_真实主题只投给owner与admin:在真实的生产主题
// `events.SubjectChannelEvent` 上发布一帧,经 bus → relay → 帧门 → 连接。
// viewer 只收到哨兵(状态机信号),绝不收到渠道事件帧 —— 这是 design/11 §7.1
// 的 fail-closed 默认档,`channel_event` 不在白名单里(也不该在)。
func TestChannelEventFrame_真实主题只投给owner与admin(t *testing.T) {
t.Parallel()
b := bus.New()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Bus: b})
ownerConn := dialWS(t, ts.URL, signAny(t, jwt, "owner", auth.RoleOwner))
defer ownerConn.Close()
adminConn := dialWS(t, ts.URL, signAny(t, jwt, "admin", auth.RoleAdmin))
defer adminConn.Close()
viewerConn := dialWS(t, ts.URL, signAny(t, jwt, "viewer", auth.RoleViewer))
defer viewerConn.Close()
// 连接注册发生在服务端的升级处理里,dial 返回不等于已注册;不等它,发布就会
// 打在「零连接」上(帧投递按当时的连接集合快照进行)。
waitConnCount(t, srv, 3)
payload := events.ChannelEventFrame{Event: events.Event{
Platform: events.PlatformQQ, AccountID: "bot-1", Tier: events.TierT2,
Category: events.CategoryLifecycle, PlatformType: events.StatusSourceProbe,
ActorRole: events.RoleSystem, SelfRelated: true, Summary: "账号离线(在线→离线)",
}.JSON()}
if err := b.Publish(context.Background(), events.SubjectChannelEvent, payload); err != nil {
t.Fatalf("publish: %v", err)
}
// 哨兵:bus 的每个订阅按序处理,因此看到它即代表它之前的帧已投递完(把
// 「viewer 没收到别的」从猜测的空闲窗口变成精确判定)。
publishSentinel(t, b)
// owner/admin 各收 2 帧(渠道事件 + 哨兵),viewer 只收哨兵 —— 条数本身就是断言。
owner := readTypedFrames(t, ownerConn, 2)
admin := readTypedFrames(t, adminConn, 2)
viewer := readTypedFrames(t, viewerConn, 1)
for _, tc := range []struct {
role string
typed []typedFrame
}{{"owner", owner}, {"admin", admin}} {
frame, ok := findTypedFrame(tc.typed, wsChannelEvent)
if !ok {
t.Fatalf("%s 未收到 %s 帧(收到 %v)", tc.role, wsChannelEvent, typedFrameTypes(tc.typed))
}
var data struct {
Event events.ChannelEventJSON `json:"event"`
}
if err := json.Unmarshal(frame.Data, &data); err != nil {
t.Fatalf("%s 的 %s 载荷解不出: %v (raw=%s)", tc.role, wsChannelEvent, err, frame.Data)
}
if data.Event.Summary != "账号离线(在线→离线)" || data.Event.Platform != events.PlatformQQ {
t.Errorf("%s 帧载荷 = %+v, want 与发布的事件一致", tc.role, data.Event)
}
}
if _, ok := findTypedFrame(viewer, wsChannelEvent); ok {
t.Fatalf("viewer 收到了 %s 帧(收到 %v):它含渠道元数据,属 fail-closed 默认档",
wsChannelEvent, typedFrameTypes(viewer))
}
if _, ok := findTypedFrame(viewer, EventSessionIdle); !ok {
t.Fatalf("viewer 没收到哨兵 %s(收到 %v)", EventSessionIdle, typedFrameTypes(viewer))
}
}
// TestChannelEventWatch_迁移落行且只推frames给owner与admin 是这条腿的**真走法**:
// 组合根形态的注册表 + 真 SQLite 事件存储 + 真 bus + 真 WS 连接。
//
// 它一次钉住三件事:
// - 服务构造时实时面**常驻启动**,第一拍(首次观测)就把账号状态落了行
// —— 「没人看面板时也留痕」这条要求正是靠它成立;
// - 状态**迁移**跟着落第二行,并把那一行推成帧(owner 收得到,viewer 收不到);
// - 每拍都落行会把库写胖(§4.3),所以第三拍(状态没变)**不多落行、不多推帧**。
func TestChannelEventWatch_迁移落行且只推frames给owner与admin(t *testing.T) {
t.Parallel()
_, store := channelEventTestStore(t, t.TempDir())
caps := capreg.New()
stub := newWatchStub("qq-obv11:bot-1", true, false) // 尚未探到 ⇒ 首拍落「未知」
registerChannelStub(t, caps, stub, "qq-obv11:bot-1")
b := bus.New()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, EventStore: store, Bus: b})
if srv.hub.channelWatch == nil {
t.Fatal("实时面未启动(真存储 + 注册表都在)")
}
ctx := context.Background()
// 首拍(构造期已立即采过一次):unknown 也是一次迁移、也要留痕。
waitRowCount(t, store, 1)
adminConn := dialWS(t, ts.URL, signAny(t, jwt, "admin", auth.RoleAdmin))
defer adminConn.Close()
viewerConn := dialWS(t, ts.URL, signAny(t, jwt, "viewer", auth.RoleViewer))
defer viewerConn.Close()
waitConnCount(t, srv, 2)
// 探活第一次成功、账号在线 ⇒ 一次真实迁移(未知 → 在线)。
stub.set(true, true)
srv.hub.channelWatch.ObserveOnce(ctx)
waitRowCount(t, store, 2)
rows, err := store.List(ctx, events.Filter{Platform: events.PlatformQQ})
if err != nil {
t.Fatalf("List: %v", err)
}
latest := rows[0]
if latest.AccountID != "bot-1" || latest.Tier != events.TierT2 ||
latest.Category != events.CategoryLifecycle || latest.PlatformType != events.StatusSourceProbe {
t.Fatalf("最新一行 = %+v, want bot-1 / t2 / lifecycle / get_status", latest)
}
if latest.Summary != "账号上线(未知→在线)" {
t.Errorf("summary = %q, want %q", latest.Summary, "账号上线(未知→在线)")
}
if !latest.SelfRelated || latest.ActorRole != events.RoleSystem {
t.Errorf("行 = %+v, want self_related + actor_role=system", latest)
}
publishSentinel(t, b)
admin := readTypedFrames(t, adminConn, 2)
viewer := readTypedFrames(t, viewerConn, 1)
frame, ok := findTypedFrame(admin, wsChannelEvent)
if !ok {
t.Fatalf("admin 未收到 %s 帧(收到 %v)", wsChannelEvent, typedFrameTypes(admin))
}
var data struct {
Event events.ChannelEventJSON `json:"event"`
}
if err := json.Unmarshal(frame.Data, &data); err != nil {
t.Fatalf("解载荷: %v (raw=%s)", err, frame.Data)
}
if data.Event.Summary != latest.Summary || data.Event.AccountID != "bot-1" {
t.Errorf("帧载荷 = %+v, want 与刚落的那一行一致(summary=%q)", data.Event, latest.Summary)
}
if data.Event.ID != 0 {
t.Errorf("实时帧带上了 id=%d,want 0/缺席(写侧还没回读那一行的 id)", data.Event.ID)
}
if _, ok := findTypedFrame(viewer, wsChannelEvent); ok {
t.Fatalf("viewer 收到了渠道事件帧(收到 %v)", typedFrameTypes(viewer))
}
// 第三拍:状态没变 ⇒ 不落行、不推帧(§4.3 的「绝不逐帧写库」)。
srv.hub.channelWatch.ObserveOnce(ctx)
time.Sleep(30 * time.Millisecond)
rows2, err := store.List(ctx, events.Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows2) != 2 {
t.Errorf("库里 %d 行, want 2(状态未变不该多落)", len(rows2))
}
srv.hub.close()
}
// typedFrame 是一帧的原始类型与载荷(读连接用)。
type typedFrame struct {
Type string
Data json.RawMessage
}
// readTypedFrames 读满 want 帧(每帧各自带读超时)。
//
// 为什么按**条数**读、而不按「读到哨兵为止」:哨兵发在 `ws.>` 主题上,而被测
// 帧发在它自己的领域主题上 —— 两个订阅各有独立的 worker,bus 只保证**同一订阅内**
// 的顺序。按哨兵截断会与另一条订阅赛跑(实测:哨兵先到,帧被截在窗口外)。
// 条数则是确定的:本用例在每条连接上发布的帧数可以精确数出来。
func readTypedFrames(t *testing.T, conn *websocket.Conn, want int) []typedFrame {
t.Helper()
out := make([]typedFrame, 0, want)
for len(out) < want {
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("读第 %d/%d 帧失败(已收 %v): %v", len(out)+1, want, typedFrameTypes(out), err)
}
var raw struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(msg, &raw); err != nil {
t.Fatalf("解帧失败 %s: %v", msg, err)
}
out = append(out, typedFrame{Type: raw.Type, Data: raw.Data})
}
return out
}
// publishSentinel 发布批次哨兵(`session_idle` 是白名单状态信号,人人都收)。
func publishSentinel(t *testing.T, b bus.Bus) {
t.Helper()
if err := b.Publish(context.Background(), wsPrefix+EventSessionIdle, map[string]any{"sessionId": "web"}); err != nil {
t.Fatalf("publish sentinel: %v", err)
}
}
// findFrame 在收到的帧里找指定类型。
func findTypedFrame(frames []typedFrame, typ string) (typedFrame, bool) {
for _, f := range frames {
if f.Type == typ {
return f, true
}
}
return typedFrame{}, false
}
// frameTypesOf 把帧类型拼成可读串(失败信息用)。
func typedFrameTypes(frames []typedFrame) string {
types := make([]string, 0, len(frames))
for _, f := range frames {
types = append(types, f.Type)
}
return "[" + strings.Join(types, " ") + "]"
}
// waitRowCount 轮询直到库里的事件行数达到 want(首拍的落行发生在另一条 goroutine
// 上,因此不能假设它已经完成 —— 但也绝不靠固定 sleep 猜)。
func waitRowCount(t *testing.T, store events.Store, want int) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
var last int
for time.Now().Before(deadline) {
n, err := store.Count(context.Background(), events.Filter{})
if err != nil {
t.Fatalf("Count: %v", err)
}
last = int(n)
if last >= want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("事件行数 = %d, want ≥%d", last, want)
}
@@ -119,6 +119,12 @@ func frameScopeCases() []frameScopeCase {
relayed("mood", false),
relayed("device_update", false),
relayed("reminder", false),
// 渠道事件实时帧(design/26 §2.6 / §4.5;切片 E7):载荷是 `channel_events`
// 的一行(平台 / 账号 / 类别 / 摘要…),属**管理面元数据** —— 不进白名单,
// 按 fail-closed 默认档只发 owner + admin。它真实的生产主题是
// `events.SubjectChannelEvent`("channel.event",带点),端到端路径由
// ws_channel_events_test.go 单独驱动;这一行钉的是**帧名**的分类。
relayed(wsChannelEvent, false),
}
}
+32 -14
View File
@@ -12,6 +12,7 @@ import (
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// WebSocket 调参常量。ping/pong 节奏用于防止中间代理把空闲连接断开。
@@ -66,12 +67,22 @@ type hub struct {
// 解析器同源,使 WS 帧与 REST 的文档校验口径一致。为 nil 时带附件的回合
// 被明确拒绝(见 resolveDocuments),绝不静默丢弃。
documents documentResolver
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
// eventStore 是渠道事件的**写入面**(design/26 §2.6;切片 E7)。为 nil 表示
// 这份构建没有留存面 ⇒ 渠道事件实时面整个不启动(见 startChannelEventWatch)。
eventStore events.Store
// accountStatus 采集各账号的在线状态快照(含未知)。它是纯函数式的取数口,
// 由 server 提供(见 channelAccountStatusSamples),hub 只负责按节拍调用。
accountStatus func() []events.StatusSample
// channelWatch 是渠道事件采样循环的句柄(未接线时为 nil)。持有它有两个理由:
// close() 之外还想让「循环到底起没起」有一个可断言的对象;测试可经
// ObserveOnce 在**确定的一拍**上驱动迁移,而不必等 60s。
channelWatch *events.Watcher
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
}
// newHub 构建一个绑定到 server 依赖的 hub。
@@ -88,9 +99,13 @@ func newHub(s *Server) *hub {
history: s.history,
envResolver: s.envResolver,
documents: s.resolveChatDocuments,
log: s.log,
origins: origins,
allowAll: allowAll,
// 渠道事件实时面的两个来源都在构造期取定:写入面按能力断言取(未接线时为
// 真 nil ⇒ 循环不启动),状态快照直接引用 server 的方法。
eventStore: s.channelEventWriter(),
accountStatus: s.channelAccountStatusSamples,
log: s.log,
origins: origins,
allowAll: allowAll,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
@@ -101,13 +116,16 @@ func newHub(s *Server) *hub {
// start 让 hub 订阅 bus 事件流。发往 ws.> 的事件到达此处后广播给客户端;
// 生产者桥接模式(iot/task/automation/reminder/subagent-progress)也一并订阅,
// 并归一为 ws.* 事件类型。未接入 bus 时什么都不做。
// 并归一为 ws.* 事件类型。它同时启动**渠道事件实时面**(切片 E7):那一条腿
// 只依赖写入面与状态源,没有 bus 也照常采(落行不推帧),因此不能挂在 bus 的
// 判定之后。ctx 与订阅共用:close() 取消它,两条腿一起干净退出。
func (h *hub) start() {
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
h.startChannelEventWatch(ctx)
if h.bus == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
patterns := append([]string{wsEventsPattern}, bridgePatterns...)
for _, p := range patterns {
if _, err := h.bus.Subscribe(ctx, p, h.relay); err != nil {
@@ -116,8 +134,8 @@ func (h *hub) start() {
}
}
// close 取消 bus 订阅、中断所有 orchestrator 会话(使后台回合不会比 hub 活得
// 更久),并关闭每条在线连接。
// close 取消 bus 订阅与渠道事件采样循环、中断所有 orchestrator 会话(使后台回合
// 不会比 hub 活得更久),并关闭每条在线连接。
func (h *hub) close() {
if h.cancel != nil {
h.cancel()