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
+10
-9
@@ -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`):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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