Files
Cyrene_v2/internal/services/gateway/admin_channel_status.go
T
AskaEth 52dac4575f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 账号在线三态(后端 AccountStatus 可选接口 + 面板第二枚标签 + 契约账目更正)
- known=false ⇒ accountOnline 键**缺席**(未知 ≠ 离线),与既有 connected 语义并列不合并
- 面板:账号在线/账号离线/账号状态未知 三态 + 中性灰 tone + 「这不等于离线」悬停说明
- 测试:gateway 跨层锁「键缺席」(用真 qq_obv11.Adapter)+ 面板 61/40 断言
- 变异:known 当离线 ⇒ 两条测试同时红(已复跑确认)
- Docs/api-client.md:§7.2 那行由未实现改已实现(留表,注明下版移入 §7.1)
2026-10-05 13:29:53 +08:00

138 lines
6.0 KiB
Go

package gateway
// 管理渠道列表里的**每账号**连接状态(AdminChannelAccount.Connected)。
//
// 为什么要有它:平台级 `connected` 只反映该平台第一条已注册适配器(ChannelSummary
// 由聚合行上的那个适配器决定),多账号平台上它无法回答「我点连接的那条现在活着吗」。
// 面板因此只能盲目渲染每行的连接/断开按钮 —— 主人有两条 qq-obv11 时,「断开」到底
// 断的是哪一条、断完有没有生效,看板上都无从判断。这里按账号把它补上。
import (
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// liveAccountConnections 收集每个平台各账号的实时连接状态,键与 liveBotIdentity
// 相同(裸适配器名 ⇒ 空账号 id)。注册表里**没有**该账号的适配器时它直接缺席,
// 而不是报 false:缺席 = 状态未知(面板画「状态未知」),false = 确知未连接。
func (s *Server) liveAccountConnections() 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
}
platform, acct := splitAdapterName(ch.Name())
if out[platform] == nil {
out[platform] = make(map[string]bool)
}
out[platform][acct] = ch.IsConnected()
}
return out
}
// enrichAccountsWithConnection 把实时连接状态合并进平台中匹配的账号(按账号 id
// 配对,与适配器注册名一致)。没有实时适配器的账号保持 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 {
item := a
if v, ok := by[item.ID]; ok {
set(&item, v)
}
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
}