feat(gateway): 账号在线三态(后端 AccountStatus 可选接口 + 面板第二枚标签 + 契约账目更正)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- known=false ⇒ accountOnline 键**缺席**(未知 ≠ 离线),与既有 connected 语义并列不合并
- 面板:账号在线/账号离线/账号状态未知 三态 + 中性灰 tone + 「这不等于离线」悬停说明
- 测试:gateway 跨层锁「键缺席」(用真 qq_obv11.Adapter)+ 面板 61/40 断言
- 变异:known 当离线 ⇒ 两条测试同时红(已复跑确认)
- Docs/api-client.md:§7.2 那行由未实现改已实现(留表,注明下版移入 §7.1)
This commit is contained in:
AskaEth committed 2026-10-05 13:29:53 +08:00
1 parent 7ae4298871
commit 52dac4575f
10 files changed
+456 -16

No files matched your search

@@ -0,0 +1,235 @@
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
}
@@ -38,16 +38,100 @@ func (s *Server) liveAccountConnections() map[string]map[string]bool {
// 配对,与适配器注册名一致)。没有实时适配器的账号保持 Connected == nil(未知),
// 因此面板能在「没连上」与「读不到」之间如实区分。入参切片不被修改。
func enrichAccountsWithConnection(accts []AdminChannelAccount, by map[string]bool) []AdminChannelAccount {
return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.Connected = &v })
}
// enrichAccountsWithAccountOnline 把**确知的**账号在线状态合并进平台中匹配的账号
// (按账号 id 配对)。不在 by 里的账号保持 AccountOnline == nil ⇒ 字段缺席 ⇒ 未知,
// 因此面板能在「确知离线」与「没探到」之间如实区分。入参切片不被修改。
func enrichAccountsWithAccountOnline(accts []AdminChannelAccount, by map[string]bool) []AdminChannelAccount {
return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v })
}
// enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的
// 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 ——
// 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。
func enrichAccountBool(
accts []AdminChannelAccount,
by map[string]bool,
set func(a *AdminChannelAccount, v bool),
) []AdminChannelAccount {
if len(by) == 0 {
return accts
}
out := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
if connected, ok := by[a.ID]; ok {
value := connected
a.Connected = &value
item := a
if v, ok := by[item.ID]; ok {
set(&item, v)
}
out = append(out, a)
out = append(out, item)
}
return out
}
// accountStatusAware 是渠道适配器暴露「账号在平台上是否在线」这一**独立状态面**的
// 可选能力。*qq_obv11.Adapter 实现了它(AccountStatus):OneBot 实现(NapCat 等)的
// 进程与正向 WS 可以一直连着,而它背后的 QQ 账号已经掉线 —— 此时「我们连着」
// (connected)为真,消息却既收不到也发不出(2026-10-05 事故)。
//
// 该能力返回**两个**布尔而不是一个,是因为 `known` 必须能传到线上:调用方要把
// 「尚未探到」处理成**字段缺席**(未知),绝不能用 false 冒充「确知离线」。
//
// 核心的 SDK Channel 契约被刻意不扩展,因此没有这条状态面的平台(telegram、webhook……)
// 不受任何改动,也不会凭空多出一个字段。
type accountStatusAware interface {
// AccountStatus 报告账号在线状态;known=false 表示尚未探到结果
// (探活未接线 / 刚启动 / 一直失败)。
AccountStatus() (online bool, known bool)
}
// asAccountStatusAware 报告已注册的渠道值是否暴露账号在线状态能力。与 asRescanAware
// 同一套路(见 admin_channels_rescan.go):注册时会把适配器包进 cmd/cyrene 的
// namedAdapter —— 一个内嵌 adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的
// 方法,因此具体适配器多出的能力只能通过拆开被内嵌字段才能抵达。
func asAccountStatusAware(v any) (accountStatusAware, bool) {
if as, ok := v.(accountStatusAware); ok {
return as, true
}
inner, ok := unwrapNamedAdapter(v)
if !ok {
return nil, false
}
as, ok := inner.(accountStatusAware)
return as, ok
}
// liveAccountOnline 收集每个平台各账号**在平台侧**的在线状态,键与
// liveAccountConnections 相同(裸平台名 ⇒ 空账号 id)。
//
// 与 liveAccountConnections 的关键差别:**只收「确知」的结果**。适配器没有这条独立
// 状态面,或实现了但 known=false(刚启动 / 还没探到 / 一直失败)时该账号**不进 map** ——
// 缺席 = 状态未知。补一个 false 顶上去,等于把「没探到」谎报成「账号离线」,与本次
// 事故(面板一片绿 ↔ 账号已掉线)同属一类:画出来的与事实无关。
func (s *Server) liveAccountOnline() map[string]map[string]bool {
out := make(map[string]map[string]bool)
if s.caps == nil {
return out
}
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()
if !known {
continue
}
platform, acct := splitAdapterName(ch.Name())
if out[platform] == nil {
out[platform] = make(map[string]bool)
}
out[platform][acct] = online
}
return out
}
+5 -1
View File
@@ -63,6 +63,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
accountsByPlatform := s.maskedChannelAccounts()
botByPlatform := s.liveBotIdentity()
connectedByPlatform := s.liveAccountConnections()
onlineByPlatform := s.liveAccountOnline()
out := make([]ChannelSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) {
@@ -98,7 +99,10 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
}
}
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
sum.Accounts = enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform])
sum.Accounts = enrichAccountsWithAccountOnline(
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
onlineByPlatform[platform],
)
}
out = append(out, sum)
}
@@ -55,6 +55,14 @@ type AdminChannelAccount struct {
// nil(字段缺省)表示「当前没有该账号的已注册适配器」—— 状态**未知**,面板不得
// 画成「未连接」,也不得允许断开一条它根本没连上的账号。
Connected *bool `json:"connected,omitempty"`
// AccountOnline 是该账号**在平台侧**是否在线(独立状态面,当前仅 qq-obv11 经
// OneBot `get_status` 探活提供),与 Connected(本项目与平台的会话是否连着)
// **是两件事**:OneBot 实现的进程与正向 WS 可以一直连着,而背后的 QQ 账号已掉线 ——
// 此时 Connected=true 而 AccountOnline=false,消息既收不到也发不出(2026-10-05
// 事故:面板一片绿)。nil(字段缺省)= **状态未知**(该适配器没有独立状态面,
// 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。
// 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。
AccountOnline *bool `json:"accountOnline,omitempty"`
// Capabilities 列出该平台适配器暴露的账号级管理操作
// (friends/channels/user-chats/message-test),使面板
// 渲染正确的每账号卡片。没有专用适配器表面的平台