package gateway import ( "encoding/json" "io" "log/slog" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" 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/config" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11" ) // 本文件锁定管理列表的**账号在线状态**(`AdminChannelAccount.AccountOnline`, // 契约 v0.67 §2.4(b))。它守的是 2026-10-05 事故:OneBot 的进程与正向 WS 一直连着, // 而背后的 QQ 账号已经掉线 —— 面板只报「我们连着」,于是「收不到也发不出」被 // 一片绿盖住。三种情形各一条断言: // ① 适配器有独立状态面且 known=true ⇒ 在线/离线**如实**浮现; // ② known=false(刚启动 / 一直探不到)⇒ 键**缺席**(未知),绝不写成 false; // ③ 适配器没有这条状态面(telegram 之类)⇒ 键**缺席**,同样不是 false。 // onlineStubAdapter 在 rescanStubAdapter 之上多一条**独立状态面**(AccountStatus), // 正如 *qq_obv11.Adapter 在 get_status 探活之后那样。known 可关,用来复现 // 「刚启动 / 一直探不到」——此时线上必须**没有** accountOnline 键。 type onlineStubAdapter struct { *rescanStubAdapter online bool known bool } func (o *onlineStubAdapter) AccountStatus() (bool, bool) { return o.online, o.known } var _ adapter.PlatformAdapter = (*onlineStubAdapter)(nil) // namedAccountAdapter 仿照 cmd/cyrene 的 namedAdapter:一个内嵌 // adapter.PlatformAdapter 的未导出包装器,只提升接口的方法。生产路径就是经它注册的, // 因此网关必须拆开内嵌字段才能抵达账号状态面(与 admin_channels_rescan_test.go 的 // namedRescanAdapter 同形)。 type namedAccountAdapter struct{ adapter.PlatformAdapter } // *qq_obv11.Adapter 是当前唯一提供该状态面的适配器。编译期锁一下:谁改了这个方法名/ // 签名,管理面就会静默退回「永远未知」(面板一片灰),这里让它**编译不过**。 var _ accountStatusAware = (*qq_obv11.Adapter)(nil) // TestAdminListChannelsReportsAccountOnline 断言三态都落到线上:确知在线 / 确知离线 // 如实浮现,而「没探到」与「没有这条状态面」都以**键缺席**表示(缺失 ≠ 离线)。 func TestAdminListChannelsReportsAccountOnline(t *testing.T) { t.Parallel() caps := capreg.New() // 三条 qq-obv11 账号:在线、账号掉线(而我们仍连着)、尚未探到。 // 每条都经 namedAccountAdapter 注册,与组合根一致。 for _, tc := range []struct { acct string online bool known bool }{ {acct: "aa11online", online: true, known: true}, {acct: "bb22down", online: false, known: true}, {acct: "cc33unknown", known: false}, } { stub := &onlineStubAdapter{ rescanStubAdapter: &rescanStubAdapter{name: "qq-obv11:" + tc.acct, connected: true}, online: tc.online, known: tc.known, } if err := caps.Register(capreg.Capability{ Name: stub.name, Kind: capreg.KindChannel, Value: namedAccountAdapter{PlatformAdapter: stub}, }); err != nil { t.Fatalf("register %s: %v", stub.name, err) } } // telegram 没有独立账号状态面:它的账号**不带** accountOnline 键。 plain := &rescanStubAdapter{name: "telegram:dd44noapi", connected: true} if err := caps.Register(capreg.Capability{ Name: plain.name, Kind: capreg.KindChannel, Value: namedAccountAdapter{PlatformAdapter: plain}, }); err != nil { t.Fatalf("register %s: %v", plain.name, err) } store := &stubChannelConfigStore{ path: t.TempDir() + "/channels.json", accounts: map[string][]config.ChannelAccount{ "qq-obv11": { {ID: "aa11online", Config: map[string]any{"access_token": "tk-a"}}, {ID: "bb22down", Config: map[string]any{"access_token": "tk-b"}}, {ID: "cc33unknown", Config: map[string]any{"access_token": "tk-c"}}, }, "telegram": {{ID: "dd44noapi", Config: map[string]any{"bot_token": "tk-d"}}}, }, } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) } got := rawAccounts(t, body) // ① 确知:在线 true / 账号掉线 false(且 connected 仍为 true —— 两件事并存, // 这正是事故现场:「我们连着」为真而消息不通)。 online := got["aa11online"] if !online.present || online.value == nil || !*online.value { t.Errorf("aa11online accountOnline = %s, want true(确知在线)", online.raw) } down := got["bb22down"] if !down.present || down.value == nil || *down.value { t.Errorf("bb22down accountOnline = %s, want false(确知离线)", down.raw) } if down.connected == nil || !*down.connected { t.Errorf("bb22down connected = %v, want true —— 连接与账号在线是两件事", down.connected) } if online.connected == nil || !*online.connected { t.Errorf("aa11online connected = %v, want true", online.connected) } // ② known=false ⇒ 键**缺席**。写成 false 就是把「没探到」谎报成「账号离线」, // 与本次事故(界面与事实无关)同属一类。 if unknow := got["cc33unknown"]; unknow.present { t.Errorf("cc33unknown accountOnline = %s, want the key omitted(未知 ≠ 离线)", unknow.raw) } else if unknow.connected == nil || !*unknow.connected { t.Errorf("cc33unknown connected = %v, want true(连接状态不受账号状态面影响)", unknow.connected) } // ③ 适配器不实现该接口 ⇒ 键同样缺席,而不是 false。 if na := got["dd44noapi"]; na.present { t.Errorf("dd44noapi accountOnline = %s, want the key omitted(该平台没有独立状态面)", na.raw) } // 整个响应里 accountOnline 只该出现在那两条确知的账号上。 if n := strings.Count(string(body), `"accountOnline"`); n != 2 { t.Errorf(`accountOnline appears %d times in the list response, want exactly 2: %s`, n, body) } // 凭据绝不进列表响应。 if strings.Contains(string(body), "tk-a") || strings.Contains(string(body), "tk-d") { t.Errorf("list response leaked a token: %s", body) } } // TestAdminListChannelsOmitsAccountOnlineBeforeProbe 跨层闭环:把**真**的 // *qq_obv11.Adapter(从未 Connect、探活从未成功 ⇒ known=false)按组合根注册它的方式 // 注册后,管理列表**不能**出现 accountOnline 键 ——「尚未探到」在面板上必须还是 // 「未知」,不许退化成「账号离线」。 func TestAdminListChannelsOmitsAccountOnlineBeforeProbe(t *testing.T) { t.Parallel() // 探活的兜底 logger 会记一条 WARN:测试里换成丢弃器,免得刷屏。 ad := qq_obv11.NewAdapter( qq_obv11.Config{SelfID: "10001"}, qq_obv11.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))), ) if _, known := ad.AccountStatus(); known { t.Fatal("a freshly built adapter reports known=true, want known=false before any probe") } caps := capreg.New() if err := caps.Register(capreg.Capability{ Name: ad.Name(), Kind: capreg.KindChannel, Value: namedAccountAdapter{PlatformAdapter: ad}, }); err != nil { t.Fatalf("register %s: %v", ad.Name(), err) } store := &stubChannelConfigStore{ path: t.TempDir() + "/channels.json", // 裸适配器名 ⇒ 账号 id 为空(与组合根的单账号路径一致)。 accounts: map[string][]config.ChannelAccount{ ad.Name(): {{Config: map[string]any{"access_token": "tk-real"}}}, }, } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) items, body := adminChannelsList(t, ts, tok) if len(items) != 1 || items[0].Name != ad.Name() { t.Fatalf("items = %+v, want a single %s row", items, ad.Name()) } if len(items[0].Accounts) != 1 || items[0].Accounts[0].AccountOnline != nil { t.Errorf("accounts = %+v, want one account without accountOnline", items[0].Accounts) } if strings.Contains(string(body), `"accountOnline"`) { t.Errorf("accountOnline appears before any probe succeeded: %s", body) } } // rawAccountState 是一条账号的原始线上形状:presence 靠 RawMessage 判(键缺席 // 与 `false` 在类型化解码后长得一样,而这两者的语义恰恰相反)。 type rawAccountState struct { connected *bool raw string present bool value *bool } // rawAccounts 把响应里的账号按 id 铺平成 id → 原始状态,用于区分 // 「键缺席」与「值是 false」。 func rawAccounts(t *testing.T, body []byte) map[string]rawAccountState { t.Helper() var rows []struct { Name string `json:"name"` Accounts []struct { ID string `json:"id"` Connected *bool `json:"connected"` AccountOnline json.RawMessage `json:"accountOnline"` } `json:"accounts"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil { t.Fatalf("decode list: %v (body=%s)", err, body) } out := make(map[string]rawAccountState) for _, row := range rows { for _, a := range row.Accounts { st := rawAccountState{connected: a.Connected, raw: string(a.AccountOnline)} if len(a.AccountOnline) > 0 { st.present = true var v bool if err := json.Unmarshal(a.AccountOnline, &v); err != nil { t.Fatalf("decode accountOnline of %s: %v", a.ID, err) } st.value = &v } out[a.ID] = st } } if len(out) != 4 { t.Fatalf("accounts = %+v, want all four configured accounts listed", out) } return out }