feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」** (面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。 ⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
This commit is contained in:
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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user