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

+10 -9
View File
@@ -134,7 +134,7 @@
| A2 | GET | `/api/v1/admin/sessions` | 会话清单 |
| A3 | GET | `/api/v1/admin/plugins` | 能力/插件清单 |
| A4 | GET | `/api/v1/admin/plugins/installed` | 已安装插件清单 |
| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**v0.67 登记(尚未实现)**:各元素另**可能**带可选 `accountOnline:bool`(**账号在平台上是否在线**,与 `connected` 是**两件事**;**键缺席 = 状态未知 ≠ 离线**)—— 见 §2.4(b)。**不含任何凭据** |
| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**v0.67 已实现**(本次落地;落地前基线 `f91a83f`):各元素另**可能**带可选 `accountOnline:bool`(**账号在平台上是否在线**,与 `connected` 是**两件事**;**键缺席 = 状态未知 ≠ 离线**)—— 见 §2.4(b)。**不含任何凭据** |
| A6 | GET | `/api/v1/admin/iot` | IoT 设备清单 |
| A7 | GET | `/api/v1/admin/automations` | 自动化与提醒清单 |
| A8 | GET | `/api/v1/admin/rag` | 知识库文档清单(名字/描述 + 切分/嵌入状态);`?query=` 搜索亦只读 |
@@ -239,11 +239,12 @@
#### (b) `GET /api/v1/admin/channels` 的账号项:`accountOnline`
- `accounts[]` 元素**可能**带可选 `accountOnline:bool`(**v0.67 登记、尚未实现**):该账号**在平台侧是否在线**。
- `accounts[]` 元素**可能**带可选 `accountOnline:bool`(**v0.67 登记,已实现** —— 管理面已落地,见 §7.2):该账号**在平台侧是否在线**。
- **与 `connected` 是两件事**(这是本案的全部理由):`connected` = **本项目的适配器是否连着**(WS 会话连通);`accountOnline` = **账号本身在平台上是否在线**。OneBot 实现(NapCat 等)的进程与正向 WS 可以一直连着,而背后的 QQ 账号已经掉线 —— 此时 `connected:true` 而 `accountOnline:false`,消息**既收不到也发不出**。
- **三态**(`known` 口径):`accountOnline:true` = **确知在线**;`accountOnline:false` = **确知离线**;**键缺席 = 状态未知**(尚未探到:探活未接线 / 刚启动 / 一直失败 / 该平台没有独立账号状态面)。**绝不可把「未知」画成「离线」**,也不可画成「在线」—— 那正是 2026-10-05 事故里「面板仍显示已连接、日志一片空白」的同一类坑。
- 状态源:`AccountStatus()`(`plugins/channels/qq_obv11/probe.go`),其 `known` 区分「确知离线」与「尚未探到」;本字段的**缺席**即承载 `known=false`。当前**只有 `qq-obv11`** 有这条独立状态面:周期约 **60s** 主动探活 OneBot `get_status`(读 `{online, good}`),与上游可配的 `meta_event` 心跳**并列**(心跳优先、探活兜底),**只在状态变化时**记一条带 `source` 的日志。
- ⚠️ **字段名(`accountOnline`)是本契约所拟**:设计只规定「独立状态面 + `AccountStatus()` + `known`」,**未给管理面字段名**(见 §7.4-13)。实现若改名,须回改本行与 §6 的 v0.67 条目。
- **已实现口径**(本次落地):管理列表按**可选接口断言**读它 —— 适配器实现了 `AccountStatus()` 才读,否则**键缺席**;`known=false` 时同样**键缺席**(绝不用 `false` 冒充离线)。**不另发** `known` 同级键:缺席本身即承载 `known=false`。当前**只有 `qq-obv11`** 会带该键(telegram / webhook 等没有独立状态面)。
- ⚠️ **字段名(`accountOnline`)是本契约所拟**:设计只规定「独立状态面 + `AccountStatus()` + `known`」,**未给管理面字段名**(见 §7.4-13)。实现沿用了本契约所拟的名字(未改名)。
---
@@ -723,9 +724,9 @@
### v0.67(当前契约版本)
**三批已在设计文档定稿、但尚未实现的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态已落地**(见下)。
> ⚠️ **本版是「契约先行」登记**:三批**都还没实现**(逐条见 §7.2),形状**取自设计文档而非代码核对**。客户端**先按形状对齐、不要按「已可用」编程**;实现落地后本版会补记 commit 并把 §7.2 相应行改为上线。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态已落地**(本次,见 §2.4(b) / §7.2),**语音消息与非消息事件查询面尚未实现**;后两批的形状**取自设计文档而非代码核对**。客户端**先按形状对齐、不要按「已可用」编程**;实现落地后本版会补记 commit 并把 §7.2 相应行改为上线。
- **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
@@ -734,7 +735,7 @@
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。
⑥ 新 admin 端点 **`GET /api/v1/admin/channels/events/counters`**(逐类计数汇总 `{data:{counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,并带 **`approx:true`** 如实标注「计数为聚合近似值」)—— §2.4(a);未接线存储 ⇒ **`500 channel_events_unavailable`**(新增错误码,**绝不回全 0 假汇总**)。
⑦ `GET /api/v1/admin/channels` 的 `accounts[]` 增**可选** `accountOnline`(**账号在平台侧是否在线**,与 `connected`(适配器/WS 是否连着)**是两件事**;**键缺席 = 状态未知 ≠ 离线**)—— §2.4(b)。
⑦ `GET /api/v1/admin/channels` 的 `accounts[]` 增**可选** `accountOnline`(**账号在平台侧是否在线**,与 `connected`(适配器/WS 是否连着)**是两件事**;**键缺席 = 状态未知 ≠ 离线**)—— §2.4(b)。**已落地**(管理面按可选接口断言读 `AccountStatus()`;`known=false` 或适配器无该状态面 ⇒ 键缺席)。
⑧ 新 env 键 3 个:`CHANNEL_EVENT_RETENTION`(`720h`)/ `CHANNEL_EVENT_GC_INTERVAL`(`1h`)/ `CHANNEL_EVENT_COUNTER_FLUSH`(`30s`)—— 已在 §5 登记(本切片**不新增**语音相关 env 键;语音上限/保留期复用既有常量与 `DOCUMENT_*`)。
⑨ **WS 协议零变化**:`media` 是 REST 端点;无新帧、无新上行字段。
- **行为变更**:
@@ -824,7 +825,7 @@
### 7.1 在飞切片
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批(语音消息 / 非消息事件查询面 / 账号在线状态)尚未实现**,**不在本表** —— 逐条见 §7.2。
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)已落地**(本次,见 §7.2);**语音消息 / 非消息事件查询面尚未实现**,**不在本表** —— 逐条见 §7.2。
| 项 | 状态 | 冻结位置 |
|---|---|---|
@@ -843,7 +844,7 @@
|---|---|
| **🔴 v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **未实现**(**契约已先行登记**):端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5;权威设计 `design/24 §10`。⚠️ 同批的**行为变更**(QQ 语音 `content` 不再含 `[语音]`)与**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)也**随该切片一起生效**,现在**尚未生效** |
| **🔴 v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **未实现**(**契约已先行登记**):形状与错误码见 §2.4(a);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`;⚠️ 两个端点的 **RBAC 档位设计未定**(见 §7.4-12) |
| **🔴 v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **未实现**(**契约已先行登记**):形状见 §2.4(b)。**状态源已落地**(`AccountStatus()`,`plugins/channels/qq_obv11/probe.go`,`a79fe96`),但**管理面还没有任何字段读它**(全仓 grep 无 `accountOnline`)⇒ 该键**当前一定会缺席** |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** |
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 |
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
@@ -878,7 +879,7 @@
10. **(v0.67 登记)`events.total` 的语义**:是**筛选条件下的总条数**(不受 `limit`/`offset` 影响)还是**本次返回条数** —— 设计未说明。
11. **(v0.67 登记)渠道事件查询的参数默认值与错误码名**:`limit`/`offset` 的默认值、以及 `platform` 未知 / 跨度超 90 天时的**具体 `400` `code` 名**(设计只写「→ `400`」)—— 均未给出。
12. **(v0.67 登记)两个渠道事件端点的 RBAC 档位**:设计原文「owner/admin;`viewer` 只读亦可,按既有 RBAC 惯例」**自相含混**(按既有分档,渠道消息日志面 B2/B3 是 owner/admin 档)⇒ 落地前**不要**假设 viewer 能读。
13. **(v0.67 登记)`accounts[].accountOnline` 的字段名与是否另有 `known` 同级键**:字段名 `accountOnline` 是**本契约所拟**(设计只规定「独立状态面 / `AccountStatus()` / `known` 三态」,未给管理面字段名);哪些平台会带该键、以及实现是否另发一个 `known` 布尔 —— 未确证。
13. **(v0.67 登记 · 已确证)`accounts[].accountOnline` 的字段名与是否有 `known` 同级键**:字段名 `accountOnline` 为本契约所拟(设计只规定「独立状态面 / `AccountStatus()` / `known` 三态」,未给管理面字段名),**实现沿用了它、未改名**;**不发** `known` 同级键 —— 「未知」由**键缺席**承载。哪些平台会带该键**已确证**:只有适配器实现了 `AccountStatus()` 的平台(当前仅 `qq-obv11`)。
14. **(v0.67 登记)音频各容器在各客户端的可播放性**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了(design/24 §10.6 明确「未逐一实测」)⇒ 客户端**以实测为准**,播不了就照实提示。
15. **(v0.67 登记)`GET /admin/system/storage` 的 `documents` 段是否拆出语音副本**:设计**本期不拆**(不新增 `kind` 列 / 不按 ext 聚合)⇒ 该段**只增口径、不变形状**,客户端**不要**指望能分开统计。
16. **(v0.67 登记 · 前向提示)`voice.url` 指向的可能不是原件,而是转码后的副本**:`design/24 §10.14` 已把「源格式不可播 ⇒ 转码成可播副本(唯一必需的路 = `amr → wav`)」立为口径,转码能力规格见 `design/25-转码能力.md`(**该能力尚未接线**)⇒ 客户端**不要假定 `url` 一定是某一种音频格式**,一律**按响应的 `Content-Type` / `mime` 如实处理**,播不了就照实提示(**绝不给一个播不了的 `url` 假装能播**)。⚠️ 本条**不在 `design/24 §10.11` 的差异清单内**(§10.11 与 §10.13 均无「零转码」绑定表述),登记它是因为「`url` 的字节格式」会直接影响播放端;**最终口径以 `design/25` 为准**。
@@ -2,7 +2,7 @@ import type { AdminChannelAccount } from "../../../lib/types";
import { Button, Tag } from "../../../components/primitives";
import { authKeyOf } from "../channelFieldRules";
import { adapterNameOf } from "./channelUtils";
import { accountActionGate, shortAccountID, type ChannelStatus } from "./channelStatus";
import { accountActionGate, accountOnlineStatus, shortAccountID, type ChannelStatus } from "./channelStatus";
/** 一条渠道账号在活动适配器列表里的行。
*
@@ -47,6 +47,7 @@ export function AccountRow({
const authSet = typeof raw === "string" && raw.trim() !== "";
const gate = accountActionGate(status, account.connected);
const state = connectionTag(account.connected);
const online = accountOnlineStatus(account.accountOnline);
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
return (
@@ -67,6 +68,13 @@ export function AccountRow({
</span>
)}
<Tag tone={account.connected === true ? "ok" : account.connected === false ? "err" : "neutral"}>{state}</Tag>
{/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
后端没给 accountOnline 时画「账号状态未知」,绝不画成「账号离线」。 */}
<Tag tone={online.tone} title={online.hint}>
{online.label}
</Tag>
<Tag tone={authSet ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
{authSet ? `${authLabel} 已配置` : `${authLabel} 未填`}
</Tag>
@@ -134,4 +134,24 @@ function buttonHTML(html: string, label: string): string {
ok(has(none, "平台级连接会失败"), "没有账号时明说平台级连接会失败");
}
// ── ⑤ 账号在线标签:三态,且「未知」绝不被画成「离线」 ─────────────────────────
// 这是 2026-10-05 事故的正面修复:账号在平台上掉了,面板却只报「已连接」。修好之后
// 每行必须有**两枚**标签(连接 + 账号在线),且缺省那态要照实说「未知」。
{
const omitted = renderRow({ id: "9616b27a", config: {} });
ok(has(omitted, ">状态未知</span>"), "连接标签还在(两枚标签各说一件事,不合并)");
ok(has(omitted, "账号状态未知"), "accountOnline 缺省 ⇒ 渲染「账号状态未知」");
ok(!has(omitted, "账号离线"), "缺省不得渲染成「账号离线」(未知 ≠ 离线)");
ok(has(omitted, "这不等于离线"), "缺省带悬停说明:未知不等于离线");
const up = renderRow({ id: "9616b27a", config: {}, connected: true, accountOnline: true });
ok(has(up, ">已连接</span>") && has(up, "账号在线"), "已连接 + 账号在线:两枚标签各报各的");
ok(!has(up, "账号离线") && !has(up, "账号状态未知"), "确知在线不会同屏出现别的在线态");
const down = renderRow({ id: "9616b27a", config: {}, connected: true, accountOnline: false });
ok(has(down, ">已连接</span>"), "事故现场:我们的连接仍是「已连接」");
ok(has(down, "账号离线"), "……而账号那一枚照实说「账号离线」(消息收不到也发不出)");
ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态");
}
console.log(`channel account row render test: ${checks} checks passed`);
@@ -16,7 +16,7 @@
// imports nothing from node:*.
import type { AdminChannelRecord } from "../../../lib/types";
import { channelListState, channelStatus } from "./channelStatus";
import { accountOnlineStatus, channelListState, channelStatus } from "./channelStatus";
let checks = 0;
@@ -108,4 +108,32 @@ same(channelListState([weixin(true)], null), "ready", "a read list is ready");
same(channelListState(null, null), "pending", "a list never read and not failed is pending");
same(channelListState(null, "读取失败"), "unknown", "a list never read with an error is unknown");
// ── 账号在平台侧的在线状态(`accountOnline`,契约 v0.67 §2.4(b))───────────────
// 假绿的根源:面板只报「我们连着」,账号掉了看不出来。三态里最要命的是「未知」——
// 它既不是在线也不是离线,必须原样说出来,绝不能塌成离线那支红色。
const onlineUp = accountOnlineStatus(true);
same(onlineUp.kind, "online", "accountOnline=true ⇒ online");
same(onlineUp.label, "账号在线", "确知在线标「账号在线」");
same(onlineUp.tone, "ok", "确知在线是绿的");
const onlineDown = accountOnlineStatus(false);
same(onlineDown.kind, "offline", "accountOnline=false ⇒ offline");
same(onlineDown.label, "账号离线", "确知离线标「账号离线」");
same(onlineDown.tone, "err", "确知离线是红的");
const onlineUnknown = accountOnlineStatus(undefined);
same(onlineUnknown.kind, "unknown", "accountOnline 缺省 ⇒ unknown");
ok(onlineUnknown.kind !== "offline", "缺省绝不被读成「账号离线」");
same(onlineUnknown.label, "账号状态未知", "缺省标「账号状态未知」");
same(onlineUnknown.tone, "neutral", "缺省是中性灰 —— 不是 offline 那支红");
ok(onlineUnknown.tone !== "err", "缺省不得带「异常」腔");
ok(onlineUnknown.label.includes("未知"), "标签里明说「未知」,不留给读者猜");
ok(onlineUp.label.includes("账号"), "在线标签带「账号」二字,与连接标签区分得开");
ok(
new Set([onlineUp.label, onlineDown.label, onlineUnknown.label]).size === 3,
"三态三个不同标签(不许两态合并成一态)",
);
ok(onlineDown.hint.includes("账号在平台上"), "离线提示说清这是账号在平台上的状态,不是我们的连接");
ok(onlineUnknown.hint.includes("不等于离线"), "未知提示明说它不等于离线");
console.log(`channel status logic test: ${checks} checks passed`);
@@ -102,6 +102,52 @@ export function channelStatus(
};
}
/** 账号在**平台侧**是否在线(后端可选 `accountOnline`,契约 v0.67 §2.4(b))。
* 它与「连接状态」是**两件事**:OneBot 实现(NapCat 等)的进程与正向 WS 可以一直
* 连着,而背后的 QQ 账号已掉线 —— 此时「已连接」为真,消息却既收不到也发不出
* (2026-10-05 事故:账号掉了,面板一片绿)。所以这枚标签必须与连接标签**并列**
* 出现,且文案里带「账号」二字,让人一眼看出它说的是账号在平台上的状态。
*
* 三态与 channelStatus 同一口径:
* - `true` → 确知在线;
* - `false` → 确知离线;
* - 缺省 → **状态未知**(该适配器没有独立账号状态面,或尚未探到结果)——
* 既不是「离线」也不是「在线」,**绝不用红色异常腔渲染**。 */
export type AccountOnlineKind = "online" | "offline" | "unknown";
export interface AccountOnlineStatus {
kind: AccountOnlineKind;
label: string;
tone: ChannelStatusTone;
/** 悬停说明,把「这是账号在平台上的状态、不是我们的连接」讲明白。 */
hint: string;
}
const ACCOUNT_ONLINE_LABEL: Record<AccountOnlineKind, string> = {
online: "账号在线",
offline: "账号离线",
unknown: "账号状态未知",
};
const ACCOUNT_ONLINE_HINT: Record<AccountOnlineKind, string> = {
online: "账号在平台上在线(与「已连接」是两件事:那说的是我们与平台的连接)",
offline: "账号在平台上已离线:消息收不到也发不出(与「已连接」是两件事)",
unknown: "尚未探到账号在平台上的状态,或该渠道没有独立状态面 —— 这不等于离线",
};
/** 把后端可选的 `accountOnline` 映成三态标签:缺省一律「未知」,
* **绝不**退化成 false 那条「账号离线」分支。 */
export function accountOnlineStatus(accountOnline?: boolean): AccountOnlineStatus {
const kind: AccountOnlineKind =
accountOnline === true ? "online" : accountOnline === false ? "offline" : "unknown";
return {
kind,
label: ACCOUNT_ONLINE_LABEL[kind],
tone: kind === "online" ? "ok" : kind === "offline" ? "err" : "neutral",
hint: ACCOUNT_ONLINE_HINT[kind],
};
}
/** 每个账号一行连接/断开时的门控(多账号平台逐条操作用)。
*
* `connected` 是该账号**自己**的实时状态(`AdminChannelAccount.connected`):
+6
View File
@@ -70,6 +70,12 @@ export interface AdminChannelAccount {
* 适配器 ⇒ 状态**未知**,绝不是「未连接」:面板必须据此禁用「断开」
* (断一条状态未知的账号是盲操作),而「连接」仍可点。 */
connected?: boolean;
/** 该账号**在平台侧**是否在线(后端可选 `accountOnline`,契约 v0.67 §2.4(b))。
* 与 `connected` 是两件事:OneBot 的进程/WS 可以一直连着,而背后的 QQ 账号已掉线
* —— 此时 `connected:true` 而 `accountOnline:false`,消息收不到也发不出。
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
accountOnline?: boolean;
}
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
@@ -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),使面板
// 渲染正确的每账号卡片。没有专用适配器表面的平台