diff --git a/Docs/api-client.md b/Docs/api-client.md index 8b08754..86e0f1c 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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=""`;`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` 为准**。 diff --git a/frontend/web/src/admin/modules/channels/AccountRow.tsx b/frontend/web/src/admin/modules/channels/AccountRow.tsx index 453b089..ad03f73 100644 --- a/frontend/web/src/admin/modules/channels/AccountRow.tsx +++ b/frontend/web/src/admin/modules/channels/AccountRow.tsx @@ -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({ )} {state} + {/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的 + 进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。 + 两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。 + 后端没给 accountOnline 时画「账号状态未知」,绝不画成「账号离线」。 */} + + {online.label} + {authSet ? `${authLabel} 已配置` : `${authLabel} 未填`} diff --git a/frontend/web/src/admin/modules/channels/accountRow.render.test.ts b/frontend/web/src/admin/modules/channels/accountRow.render.test.ts index 1cea79d..f36feb4 100644 --- a/frontend/web/src/admin/modules/channels/accountRow.render.test.ts +++ b/frontend/web/src/admin/modules/channels/accountRow.render.test.ts @@ -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, ">状态未知"), "连接标签还在(两枚标签各说一件事,不合并)"); + ok(has(omitted, "账号状态未知"), "accountOnline 缺省 ⇒ 渲染「账号状态未知」"); + ok(!has(omitted, "账号离线"), "缺省不得渲染成「账号离线」(未知 ≠ 离线)"); + ok(has(omitted, "这不等于离线"), "缺省带悬停说明:未知不等于离线"); + + const up = renderRow({ id: "9616b27a", config: {}, connected: true, accountOnline: true }); + ok(has(up, ">已连接") && has(up, "账号在线"), "已连接 + 账号在线:两枚标签各报各的"); + ok(!has(up, "账号离线") && !has(up, "账号状态未知"), "确知在线不会同屏出现别的在线态"); + + const down = renderRow({ id: "9616b27a", config: {}, connected: true, accountOnline: false }); + ok(has(down, ">已连接"), "事故现场:我们的连接仍是「已连接」"); + ok(has(down, "账号离线"), "……而账号那一枚照实说「账号离线」(消息收不到也发不出)"); + ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态"); +} + console.log(`channel account row render test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/channels/channelStatus.test.ts b/frontend/web/src/admin/modules/channels/channelStatus.test.ts index 259bd50..069feed 100644 --- a/frontend/web/src/admin/modules/channels/channelStatus.test.ts +++ b/frontend/web/src/admin/modules/channels/channelStatus.test.ts @@ -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`); diff --git a/frontend/web/src/admin/modules/channels/channelStatus.ts b/frontend/web/src/admin/modules/channels/channelStatus.ts index 5743eb1..b10ea64 100644 --- a/frontend/web/src/admin/modules/channels/channelStatus.ts +++ b/frontend/web/src/admin/modules/channels/channelStatus.ts @@ -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 = { + online: "账号在线", + offline: "账号离线", + unknown: "账号状态未知", +}; + +const ACCOUNT_ONLINE_HINT: Record = { + 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`): diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts index 96c7458..5f57d3c 100644 --- a/frontend/web/src/lib/types/admin.ts +++ b/frontend/web/src/lib/types/admin.ts @@ -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). diff --git a/internal/services/gateway/admin_channel_account_online_test.go b/internal/services/gateway/admin_channel_account_online_test.go new file mode 100644 index 0000000..ced1c3c --- /dev/null +++ b/internal/services/gateway/admin_channel_account_online_test.go @@ -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 +} diff --git a/internal/services/gateway/admin_channel_status.go b/internal/services/gateway/admin_channel_status.go index 865b082..3e0fc63 100644 --- a/internal/services/gateway/admin_channel_status.go +++ b/internal/services/gateway/admin_channel_status.go @@ -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 } diff --git a/internal/services/gateway/admin_query.go b/internal/services/gateway/admin_query.go index e0b37f2..3cc5b9e 100644 --- a/internal/services/gateway/admin_query.go +++ b/internal/services/gateway/admin_query.go @@ -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) } diff --git a/internal/services/gateway/admin_write_channel_config.go b/internal/services/gateway/admin_write_channel_config.go index b1613ea..84df6f6 100644 --- a/internal/services/gateway/admin_write_channel_config.go +++ b/internal/services/gateway/admin_write_channel_config.go @@ -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),使面板 // 渲染正确的每账号卡片。没有专用适配器表面的平台