feat(gateway): 账号在线三态(后端 AccountStatus 可选接口 + 面板第二枚标签 + 契约账目更正)
- 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:
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
|
||||
}
|
||||
@@ -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),使面板
|
||||
// 渲染正确的每账号卡片。没有专用适配器表面的平台
|
||||
|
||||
Reference in new issue
Block a user