diff --git a/frontend/web/src/admin/modules/channels/AccountRow.tsx b/frontend/web/src/admin/modules/channels/AccountRow.tsx
index ad03f73..39c417e 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, accountOnlineStatus, shortAccountID, type ChannelStatus } from "./channelStatus";
+import { accountActionGate, accountOnlineStatus, channelConnectStateOf, connectStateView, shortAccountID, type ChannelStatus, type ChannelStatusTone } from "./channelStatus";
/** 一条渠道账号在活动适配器列表里的行。
*
@@ -46,7 +46,7 @@ export function AccountRow({
const raw = account.config?.[authKey];
const authSet = typeof raw === "string" && raw.trim() !== "";
const gate = accountActionGate(status, account.connected);
- const state = connectionTag(account.connected);
+ const state = connectionTag(account);
const online = accountOnlineStatus(account.accountOnline);
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
@@ -67,7 +67,7 @@ export function AccountRow({
id {short}
)}
- {state}
+ {state.label}
{/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
@@ -127,11 +127,23 @@ export function AccountRow({
);
}
-/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */
-function connectionTag(connected: boolean | undefined): string {
- if (connected === true) return "已连接";
- if (connected === false) return "未连接";
- return "状态未知";
+/** 账号级连接状态的呈现:已连接 / 连接中 / 等待接入 / 未连接 / 状态未知
+ * (后端没报告该账号的活适配器)。
+ *
+ * 「连接中」与「等待接入」来自后端自报的连接态三态(见 ChannelConnectState)。
+ * 没有它们时,一个正在后台重试的 ws+client 账号会被画成红色的「未连接」,主人就会
+ * 以为它坏了 —— 那正是 2026-10-07 事故的另一半(一半是弹「已连接」,一半是卡片
+ * 永远显示「未连接」)。两态都用非错误腔:它们不是结论,是进行中。 */
+function connectionTag(account: AdminChannelAccount): {
+ label: string;
+ tone: ChannelStatusTone;
+ hint: string | undefined;
+} {
+ if (account.connected === true) return { label: "已连接", tone: "ok", hint: undefined };
+ const view = connectStateView(channelConnectStateOf(account), account.reason);
+ if (view !== null) return { label: view.label, tone: view.tone, hint: view.hint };
+ if (account.connected === false) return { label: "未连接", tone: "err", hint: undefined };
+ return { label: "状态未知", tone: "neutral", hint: undefined };
}
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
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 f36feb4..e0422dd 100644
--- a/frontend/web/src/admin/modules/channels/accountRow.render.test.ts
+++ b/frontend/web/src/admin/modules/channels/accountRow.render.test.ts
@@ -154,4 +154,24 @@ function buttonHTML(html: string, label: string): string {
ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态");
}
+// ── ⑤ 重试中 / 就绪等接入:不许画成红色的「未连接」 ────────────────────────────
+// 2026-10-07 事故的另一半:点了「连接」之后卡片一直显示「未连接」。ws+client 的首连
+// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),所以那不是故障,是「正在连」;
+// ws+server 则是「就绪等对端接入」。两者都必须与静态的「未连接」分开画。
+{
+ const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
+ const retrying = renderRow({ id: "9616b27a", config: {}, connected: false, pending: true, reason: REASON });
+ ok(has(retrying, ">连接中"), "重试中的账号画「连接中」");
+ ok(!has(retrying, ">未连接"), "重试中不许画成「未连接」(它是进行中,不是结论)");
+ ok(has(retrying, REASON), "重试中的原因随标签悬停一并给出(说不出为什么就等于没说)");
+
+ const ready = renderRow({ id: "9616b27a", config: {}, connected: false, ready: true });
+ ok(has(ready, ">等待接入"), "监听就绪的账号画「等待接入」");
+ ok(!has(ready, ">未连接"), "就绪等接入不许画成「未连接」");
+
+ // 回归:后端没这条状态面的账号逐字不变。
+ const plain = renderRow({ id: "9616b27a", config: {}, connected: false });
+ ok(has(plain, ">未连接"), "没有状态面的账号仍是「未连接」");
+}
+
console.log(`channel account row render test: ${checks} checks passed`);
diff --git a/frontend/web/src/admin/modules/channels/channelConnectNotice.test.ts b/frontend/web/src/admin/modules/channels/channelConnectNotice.test.ts
new file mode 100644
index 0000000..405b92f
--- /dev/null
+++ b/frontend/web/src/admin/modules/channels/channelConnectNotice.test.ts
@@ -0,0 +1,213 @@
+// 「点了连接之后,面板到底说了什么」的真实断言(2026-10-07 UI 撒谎事故的回归测试)。
+//
+// 为什么必须存在:旧代码只按「请求成功」判断 —— `adminChannelOpen` 一 resolve 就弹
+// 「已连接」,而 ws+client 的首连失败**刻意非致命**(OneBot 实现可能比 Cyrene 后启动),
+// 于是「后端受理了」被说成了「渠道连上了」。这里不看组件、不靠记忆:既直接驱动
+// `createChannelActions`(stub 掉 globalThis.fetch,读它真正写进 notice 的那句话),
+// 也直接驱动卡片用的纯函数 `channelStatus`。
+//
+// 跑法(与 channelActions.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require):
+//
+// cd frontend/web \
+// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelConnectNotice.test.ts \
+// --bundle --platform=node --format=cjs \
+// --outfile=/tmp/cyrene-channel-connect-notice.test.cjs \
+// && node /tmp/cyrene-channel-connect-notice.test.cjs
+//
+// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内
+// 导入、不 import node:*。
+
+import type { AdminChannelRecord } from "../../../lib/types";
+import { channelConnectStateOf, channelStatus } from "./channelStatus";
+import { connectDisconnectNotice, createChannelActions } from "./useChannelActions";
+
+let checks = 0;
+
+function ok(cond: boolean, what: string): void {
+ checks += 1;
+ if (!cond) throw new Error(`FAILED: ${what}`);
+}
+
+function notOk(cond: boolean, what: string): void {
+ ok(!cond, what);
+}
+
+function eq(got: unknown, want: unknown, what: string): void {
+ checks += 1;
+ if (got !== want) throw new Error(`FAILED: ${what}(got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`);
+}
+
+const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
+
+// ── ① 纯函数:提示文案只看**响应**,不看请求是否成功 ─────────────────────────────
+{
+ // 本次事故的正题:请求 200 + connected:false + pending:true ⇒ 绝不是「已连接」。
+ const pending = connectDisconnectNotice(true, "qq-obv11", {
+ name: "qq-obv11",
+ connected: false,
+ pending: true,
+ reason: REASON,
+ });
+ notOk(pending.includes("已连接"), `重试中不许说「已连接」,got ${JSON.stringify(pending)}`);
+ ok(pending.includes("正在连接"), `重试中必须说「正在连接」,got ${JSON.stringify(pending)}`);
+ ok(pending.includes(REASON), `重试中必须带上原因,got ${JSON.stringify(pending)}`);
+
+ // 连上了才是「已连接」。
+ eq(
+ connectDisconnectNotice(true, "qq-obv11:9616b27a", { name: "qq-obv11", connected: true }),
+ "已连接 qq-obv11:9616b27a",
+ "真连上才说「已连接」(并回显实际驱动的适配器)",
+ );
+
+ // server 已就绪、等待对端接入:既不是「已连接」,也不是一句静态的「未连接」。
+ const ready = connectDisconnectNotice(true, "qq-obv11", { name: "qq-obv11", connected: false, ready: true });
+ notOk(ready.includes("已连接"), `就绪等接入不许说「已连接」,got ${JSON.stringify(ready)}`);
+ ok(ready.includes("已就绪"), `就绪等接入必须说清楚,got ${JSON.stringify(ready)}`);
+
+ // 后端没有这条状态面(两个键都缺):如实说「尚未连上」,绝不乐观。
+ const bare = connectDisconnectNotice(true, "kook", { name: "kook", connected: false });
+ notOk(bare.includes("已连接"), `连不上不许说「已连接」,got ${JSON.stringify(bare)}`);
+ ok(bare.includes("尚未连上"), `必须如实说尚未连上,got ${JSON.stringify(bare)}`);
+
+ // 断开同样不乐观:后端报连接仍在,就不能说「已断开」。
+ eq(
+ connectDisconnectNotice(false, "kook", { name: "kook", connected: false }),
+ "已断开 kook",
+ "真断开才说「已断开」",
+ );
+ const stuck = connectDisconnectNotice(false, "kook", { name: "kook", connected: true });
+ notOk(stuck.includes("已断开"), `仍未断开不许说「已断开」,got ${JSON.stringify(stuck)}`);
+}
+
+// ── ② 卡片状态:三态在**真实渲染逻辑**里与「未连接」分开 ────────────────────────
+{
+ const pendingRecord: AdminChannelRecord = {
+ name: "qq-obv11",
+ connected: false,
+ pending: true,
+ reason: REASON,
+ };
+ const pending = channelStatus([pendingRecord], null, pendingRecord);
+ eq(pending.kind, "connecting", "重试中的卡片是 connecting,不是 disconnected");
+ eq(pending.label, "连接中", "重试中的卡片写「连接中」");
+ eq(pending.tone, "warn", "重试中用警示色而不是错误色(它不是故障)");
+ ok(pending.hint !== null && pending.hint.includes(REASON), "重试中的卡片带上原因");
+ notOk(pending.label.includes("未连接"), "重试中不许画成「未连接」");
+
+ const readyRecord: AdminChannelRecord = { name: "qq-obv11", connected: false, ready: true };
+ const ready = channelStatus([readyRecord], null, readyRecord);
+ eq(ready.kind, "ready", "就绪等接入的卡片是 ready");
+ eq(ready.label, "等待接入", "就绪等接入的卡片写「等待接入」");
+ eq(ready.tone, "neutral", "就绪等接入不用错误色");
+
+ // 回归:后端没这条状态面的平台逐字不变。
+ const plain: AdminChannelRecord = { name: "kook", connected: false };
+ const disconnected = channelStatus([plain], null, plain);
+ eq(disconnected.kind, "disconnected", "没有状态面的平台仍是 disconnected");
+ eq(disconnected.label, "未连接", "没有状态面的平台仍写「未连接」");
+ eq(disconnected.tone, "err", "没有状态面的平台仍用错误色");
+
+ // 真连上优先:pending 与 connected 同时给出时不许被降级成「连接中」。
+ const both: AdminChannelRecord = { name: "qq-obv11", connected: true, pending: true };
+ eq(channelStatus([both], null, both).kind, "connected", "connected=true 优先于 pending");
+
+ eq(channelConnectStateOf({}), null, "两个键都缺省 = 没有这条状态面");
+ eq(channelConnectStateOf({ pending: true }), "pending", "pending 优先");
+ eq(channelConnectStateOf({ ready: true }), "ready", "ready 单独给出也认");
+}
+
+// ── ③ 真实请求:createChannelActions 写进 notice 的那句话 ───────────────────────
+// 与前两段不同,这里驱动的是面板按钮真正调用的那一层,并用 stub 的 fetch 回一个
+// 「请求成功但没连上」的响应 —— 旧代码在这条路径上会说「已连接」。
+
+interface Captured {
+ url: string;
+ method: string;
+}
+
+const captured: Captured[] = [];
+// 每次请求各取一个响应:三个动作在同一个 tick 里发出,若共用一个可变变量,
+// 先发的两次会读到第三次的响应(那样断言的就是错的东西)。
+const payloadQueue: unknown[] = [];
+
+function installStubs(): void {
+ const store = new Map();
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => void store.set(k, v),
+ removeItem: (k: string) => void store.delete(k),
+ },
+ });
+ Object.defineProperty(globalThis, "fetch", {
+ configurable: true,
+ value: (url: string, init?: RequestInit) => {
+ captured.push({ url: String(url), method: String(init?.method ?? "GET") });
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ data: payloadQueue.shift() ?? {} }),
+ } as unknown as Response);
+ },
+ });
+}
+
+installStubs();
+
+const notices: string[] = [];
+const errors: string[] = [];
+let reloads = 0;
+const actions = createChannelActions({
+ log: () => {},
+ setBusy: () => {},
+ setError: (e) => {
+ if (e != null) errors.push(e);
+ },
+ setNotice: (n) => notices.push(n ?? ""),
+ reload: () => {
+ reloads += 1;
+ },
+});
+
+// 场景 A:后端回「请求成功但仍在重试」。
+payloadQueue.push({
+ name: "qq-obv11",
+ resolved: "qq-obv11:9616b27a",
+ accountId: "9616b27a",
+ connected: false,
+ pending: true,
+ reason: REASON,
+});
+actions.toggle("qq-obv11", true, "9616b27a");
+
+// 场景 B:后端回「真连上」。
+payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true });
+actions.toggle("qq-obv11", true, "9616b27a");
+
+// 场景 C:断开成功。
+payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: false });
+actions.toggle("qq-obv11", false, "9616b27a");
+
+setTimeout(() => {
+ const pendingNotice = notices.find((n) => n.includes("正在连接"));
+ ok(pendingNotice !== undefined, `重试中必须提示「正在连接」,got ${JSON.stringify(notices)}`);
+ ok(
+ !notices[0]?.includes("已连接"),
+ `第一个动作(仍在重试)绝不能说「已连接」,got ${JSON.stringify(notices[0])}`,
+ );
+ ok(notices[0]?.includes(REASON) === true, `重试提示必须带上原因,got ${JSON.stringify(notices[0])}`);
+ ok(
+ notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")),
+ `真连上时必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`,
+ );
+ ok(
+ notices.some((n) => n.includes("已断开 qq-obv11:9616b27a")),
+ `断开成功要说「已断开」,got ${JSON.stringify(notices)}`,
+ );
+ eq(errors.length, 0, "这些路径都不该产生错误提示");
+ eq(reloads, 3, "每次动作后都刷新列表(卡片状态要靠它才不再撒谎)");
+ eq(captured[0].url, "/api/v1/admin/channels/open", "连接仍打到既有路径");
+ eq(captured[2].url, "/api/v1/admin/channels/close", "断开仍打到既有路径");
+ console.log(`channel connect notice test: ${checks} checks passed`);
+}, 20);
diff --git a/frontend/web/src/admin/modules/channels/channelStatus.ts b/frontend/web/src/admin/modules/channels/channelStatus.ts
index b10ea64..0b64512 100644
--- a/frontend/web/src/admin/modules/channels/channelStatus.ts
+++ b/frontend/web/src/admin/modules/channels/channelStatus.ts
@@ -7,6 +7,8 @@ import type { AdminChannelRecord } from "../../../lib/types";
* as plain node — see channelStatus.test.ts. */
export type ChannelStatusKind =
| "connected"
+ | "connecting"
+ | "ready"
| "disconnected"
| "unconfigured"
| "unknown"
@@ -29,6 +31,55 @@ export interface ChannelStatus {
/** How much of the channel list we actually know right now. */
export type ChannelListState = "ready" | "pending" | "unknown";
+/** 适配器自报的**连接态三态**(后端可选键 `pending` / `ready`,契约 v0.68;见
+ * internal/services/gateway/admin_channel_connect_outcome.go)。
+ *
+ * 为什么需要它:`connected:false` 至少有两种互不相同的现状 —— 「已在后台重试」
+ * (ws+client 的首连失败刻意非致命:OneBot 实现可能比 Cyrene 后启动)与「已就绪、
+ * 等待对端接入」(server 模式监听已绑定)。用一句静态的「未连接」覆盖两者,主人就会
+ * 把「正在连接」读成故障,或者(更糟)反方向把「没连上」读成「已连接」——
+ * 2026-10-07 的 UI 撒谎事故正是这么来的。
+ *
+ * `null` = 适配器没有这条状态面(后端两个键都缺省)⇒ 调用方退回只看 `connected`
+ * 布尔,**绝不**凭空编一个状态。 */
+export type ChannelConnectState = "pending" | "ready" | null;
+
+/** 把后端可选的 `pending` / `ready` 两键映成上面的三态。两者都缺省 ⇒ null。 */
+export function channelConnectStateOf(record: {
+ pending?: boolean;
+ ready?: boolean;
+}): ChannelConnectState {
+ if (record.pending === true) return "pending";
+ if (record.ready === true) return "ready";
+ return null;
+}
+
+/** 未连上时那句如实的话:`pending` 说「正在重试」并把原因带上(原因缺失时也不假装
+ * 知道为什么),`ready` 说「等待接入」。两者都不是失败腔。`null` 表示后端没有这条
+ * 状态面 ⇒ 调用方退回自己那套说法。 */
+export function connectStateView(
+ state: ChannelConnectState,
+ reason?: string,
+): { kind: ChannelStatusKind; label: string; tone: ChannelStatusTone; hint: string } | null {
+ if (state === "pending") {
+ return {
+ kind: "connecting",
+ label: "连接中",
+ tone: "warn",
+ hint: reason ? `正在后台重试连接:${reason}` : "已在后台重试连接(尚未连上)",
+ };
+ }
+ if (state === "ready") {
+ return {
+ kind: "ready",
+ label: "等待接入",
+ tone: "neutral",
+ hint: "监听已就绪,等待对端接入(尚未连上)",
+ };
+ }
+ return null;
+}
+
/** List-level tri-state: a list we never read plus a recorded error is *unknown*,
* never "empty". `data == null` with no error is the first load still running. */
export function channelListState(
@@ -91,6 +142,24 @@ export function channelStatus(
}
const connected = record.connected === true;
+ if (!connected) {
+ // 「没连上」有三种互不相同的现状,只有后端自报时才敢细说(见 ChannelConnectState):
+ // pending 是「正在重试」、ready 是「就绪等接入」。两者都不是「未连接」这样一个
+ // 静态结论,因此不给错误腔,也不把「断开」摆出来(会话本来就没建立)。
+ const state = channelConnectStateOf(record);
+ const view = connectStateView(state, record.reason);
+ if (view !== null) {
+ return {
+ kind: view.kind,
+ label: view.label,
+ tone: view.tone,
+ hint: error != null ? `${view.hint};本次刷新失败,数据可能过期:${error}` : view.hint,
+ stale: error != null,
+ canConnect: true,
+ canDisconnect: false,
+ };
+ }
+ }
return {
kind: connected ? "connected" : "disconnected",
label: connected ? "已连接" : "未连接",
diff --git a/frontend/web/src/admin/modules/channels/channelStatusView.tsx b/frontend/web/src/admin/modules/channels/channelStatusView.tsx
index de3108c..fc4eb68 100644
--- a/frontend/web/src/admin/modules/channels/channelStatusView.tsx
+++ b/frontend/web/src/admin/modules/channels/channelStatusView.tsx
@@ -3,6 +3,8 @@ import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
const dot: Record = {
connected: "bg-[var(--mu-ok)]",
+ connecting: "bg-[var(--mu-warn)] mu-blink",
+ ready: "bg-[var(--mu-text-lo)]",
disconnected: "bg-[var(--mu-err)]",
unconfigured: "bg-[var(--mu-text-lo)]",
unknown: "bg-[var(--mu-text-lo)]",
@@ -10,9 +12,10 @@ const dot: Record = {
};
/** The one honest place an adapter's connection state is rendered, so list page,
- * detail page and overview can never drift apart: 已连接 / 未连接 / 状态未知.
- * `showHint` off renders the compact form for dense surfaces (table row, card) —
- * the surrounding banner already carries the failure reason there. */
+ * detail page and overview can never drift apart: 已连接 / 连接中 / 等待接入 /
+ * 未连接 / 状态未知. `showHint` off renders the compact form for dense surfaces
+ * (table row, card) — there the hint (e.g. the retry reason behind「连接中」) rides
+ * along as a hover title instead of taking a line of its own. */
export function ChannelStatusTag({
status,
showHint = true,
@@ -24,7 +27,7 @@ export function ChannelStatusTag({
}): React.ReactNode {
return (
-
+
{status.label}
diff --git a/frontend/web/src/admin/modules/channels/useChannelActions.ts b/frontend/web/src/admin/modules/channels/useChannelActions.ts
index e6663a3..d05372d 100644
--- a/frontend/web/src/admin/modules/channels/useChannelActions.ts
+++ b/frontend/web/src/admin/modules/channels/useChannelActions.ts
@@ -4,6 +4,7 @@ import {
adminChannelOpen,
adminDeleteChannelAccount,
adminDeleteEmptyChannelAccounts,
+ type ChannelActionResult,
} from "../../../lib/api";
import { useAuditStore } from "../../audit";
@@ -19,6 +20,35 @@ export interface ChannelActionDeps {
reload: () => void;
}
+/** 连接/断开返回后要说给主人的那句话。
+ *
+ * **只看响应,不看请求是否成功**:请求 200 只说明后端受理了这次调用,不说明渠道真的
+ * 连上了 —— ws+client 的首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时
+ * 后端回 `connected:false` + `pending:true` + `reason`。把这句写死成「已连接」,
+ * 正是 2026-10-07 那次 UI 撒谎的根因。断开同理:报后端回的真实连接状态,而不是假定
+ * 「调用成功就一定断了」。
+ *
+ * 纯函数、与 React 解耦,因此「响应 connected:false + pending:true ⇒ 提示里没有
+ * 『已连接』」可以在 node 侧被直接断言(见 channelActions.test.ts)。 */
+export function connectDisconnectNotice(
+ connect: boolean,
+ who: string,
+ res?: ChannelActionResult,
+): string {
+ if (!connect) {
+ return res?.connected === true ? `${who} 仍未断开(后端报告连接仍存在)` : `已断开 ${who}`;
+ }
+ if (res?.connected === true) return `已连接 ${who}`;
+ if (res?.pending === true) {
+ return res.reason
+ ? `${who} 正在连接(${res.reason})`
+ : `${who} 正在连接(尚未连上,已在后台重试)`;
+ }
+ if (res?.ready === true) return `${who} 已就绪,等待对端接入`;
+ // 既没连上、也没有可解释的状态面:如实说「尚未连上」——请求被受理不等于连上了。
+ return `${who} 尚未连上(连接请求已受理,可稍后在列表中查看状态)`;
+}
+
/** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */
export interface ChannelActions {
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
@@ -42,7 +72,7 @@ export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
// 后端把解析结果放在 resolved 里,这里如实说出来。
const who = res?.resolved ?? name;
- setNotice(`${connect ? "已连接" : "已断开"} ${who}`);
+ setNotice(connectDisconnectNotice(connect, who, res));
reload();
})
.catch((e: unknown) => {
diff --git a/frontend/web/src/lib/api/adminChannels.ts b/frontend/web/src/lib/api/adminChannels.ts
index e7ab5a1..d491cd7 100644
--- a/frontend/web/src/lib/api/adminChannels.ts
+++ b/frontend/web/src/lib/api/adminChannels.ts
@@ -23,12 +23,26 @@ export interface ChannelConfigSaveResult {
/** Result of POST /admin/channels/open|close. `resolved` is the adapter that was
* actually driven (`qq-obv11:9616b27a`), which is what the panel must show —
* the request's `name` alone never tells a multi-account platform which account
- * was touched. */
+ * was touched.
+ *
+ * `connected` 说的是**现在通不通**,不是「这次请求成没成功」:ws+client 的首连失败
+ * 刻意非致命(OneBot 实现可能比 Cyrene 后启动),因此 `Connect` 返回 nil 并不等于连上了。
+ * `pending`/`ready`(后端可选,契约 v0.68)进一步说明「没连上是哪种没连上」:
+ *
+ * - `pending: true` → 已在后台重试、当前未连上 ⇒ 提示须是「正在连接(reason)」,
+ * **绝不是**「已连接」,也不是一句静态的「未连接」;
+ * - `ready: true` → 已就绪、等待对端接入(server 模式)⇒ 同样不是失败;
+ * - `reason` → 未连上的原因(已脱敏)。
+ *
+ * 三者缺省 = 该适配器没有这条状态面 ⇒ 只看 `connected`。 */
export interface ChannelActionResult {
name: string;
resolved?: string;
accountId?: string;
connected: boolean;
+ pending?: boolean;
+ ready?: boolean;
+ reason?: string;
}
/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端
diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts
index 0945846..be4908f 100644
--- a/frontend/web/src/lib/types/admin.ts
+++ b/frontend/web/src/lib/types/admin.ts
@@ -40,6 +40,19 @@ export interface InstalledPlugin {
export interface AdminChannelRecord {
name: string;
connected: boolean;
+ /** 适配器自报的**连接态三态**(后端可选键,契约 v0.68;见 internal/services/
+ * gateway/admin_channel_connect_outcome.go)。`connected:false` 至少有两种互不相同
+ * 的现状,缺了它就只能被画成一句静态的「未连接」:
+ *
+ * - `pending` → 已在后台重试、当前未连上 ⇒ 面板读作「连接中」(`reason` 说明原因);
+ * - `ready` → 已就绪、等待对端接入(server 模式监听已绑定)⇒ 不是失败。
+ *
+ * 两者都**缺省** = 该适配器没有这条状态面(绝大多数平台)⇒ 面板退回只看 `connected`,
+ * 绝不凭空编一个状态。 */
+ pending?: boolean;
+ ready?: boolean;
+ /** 未连上时的原因(已脱敏,绝不含凭据)。只有 `pending` 时才会出现。 */
+ reason?: string;
/** Configured accounts for this channel's platform (token masked). */
accounts?: AdminChannelAccount[];
}
@@ -80,6 +93,12 @@ export interface AdminChannelAccount {
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
accountOnline?: boolean;
+ /** 该账号适配器自报的连接态三态(可选,见 `AdminChannelRecord.pending`)。三者缺省 =
+ * 该适配器没有这条状态面 ⇒ 退回只看 `connected`。 */
+ pending?: boolean;
+ ready?: boolean;
+ /** 未连上时的原因(已脱敏)。只有 `pending` 时才会出现。 */
+ reason?: string;
}
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
diff --git a/internal/channel/adapter/connect_outcome.go b/internal/channel/adapter/connect_outcome.go
new file mode 100644
index 0000000..2be88fc
--- /dev/null
+++ b/internal/channel/adapter/connect_outcome.go
@@ -0,0 +1,82 @@
+package adapter
+
+// 连接**就绪三态**的取值与判定:一次 Connect 返回之后,适配器到底处于哪一种状态。
+//
+// 为什么必须三态而不是一个 bool:`Connect` 返回 nil 只说明「这次调用没有致命失败」,
+// 它至少覆盖三件互不相同的事——
+//
+// ConnectStateConnected —— 真连上了(client 拨号成功 / HTTP 传输已装配就绪);
+// ConnectStatePending —— 已进入后台重试、当前还没连上(OneBot 实现比 Cyrene 后
+// 启动是常态,首连失败刻意非致命);
+// ConnectStateReady —— 已绑定监听、就绪等待对端接入(ws+server 模式)。
+//
+// pending 与 ready 的 IsConnected() 都是 false,但它们**不是**「未连接」这样一个静态
+// 结论:前者是「正在连接」,后者是「就绪等接入」。用一个布尔去覆盖三态,管理面板就只能
+// 编一个说法——编出来的正是 2026-10-07 那个「点了连接就弹已连接,而卡片一直显示未连接」
+// 的谎。口径属于适配器(它才知道自己的 transport × mode 意味着什么),网关只做映射
+// (internal/services/gateway 的 connectOutcomeAware,经类型断言探测,核心契约不扩展)。
+//
+// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
+// transport × mode);立场与 design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致 ——
+// 一个布尔说不了两件事,更说不了三件。
+//
+// 与 SessionState* / AccountStatus 同一口径:核心 PlatformAdapter 契约**刻意不扩展**,
+// 需要这条状态面的适配器自行实现该方法,网关用类型断言探测;没实现的适配器行为逐字不变。
+
+const (
+ // ConnectStateConnected 表示真连上:会话已建立(client 拨号成功),或传输已装配
+ // 就绪而无可观察的长连接(HTTP)。此时 IsConnected() 应为 true。
+ ConnectStateConnected = "connected"
+ // ConnectStatePending 表示已进入后台重试、当前尚未连上。它既不是配置错误,也不是
+ // 「未连接」,而是「连接中」,Reason 应说明原因(脱敏,绝不含凭据)。
+ ConnectStatePending = "pending"
+ // ConnectStateReady 表示已就绪、等待对端接入:server 模式已绑定监听,但还没有
+ // 对端连上来。IsConnected() 此时为 false,却**不是失败**,因此没有 Reason。
+ ConnectStateReady = "ready"
+)
+
+// ConnectSignals 是判定三态所需的**全部**输入。用结构体而不是几个相邻的布尔参数,
+// 一是让调用点自解释,二是避免两个 bool 被接反(那会静默地骗过所有测试)。
+type ConnectSignals struct {
+ // Started 报告 Connect 是否已成功启动该适配器;false 表示尚未启动,无从判断。
+ Started bool
+ // Connected 报告当前是否有一条活的会话。
+ Connected bool
+ // Retrying 报告未连上时后台是否有重试循环在跑(ws+client 有一条;HTTP 没有会话
+ // 可重连,不算)。
+ Retrying bool
+ // Listening 报告适配器是否已绑定监听、在等对端接入(server 模式)。
+ Listening bool
+ // Reason 是最近一次失败的原因(**已脱敏**);没有失败或已连上时为空串。
+ Reason string
+}
+
+// ConnectStateOf 是**纯函数**:由信号判定三态与原因。它不读时钟、不触网、不加锁,
+// 因此三态可以逐条单测(见 connect_outcome_test.go);适配器侧只负责在同一把锁下把
+// 信号读齐。
+//
+// 判定顺序(自上而下,先匹配者胜):
+//
+// !Started → ("", "") —— 未启动的适配器无从判断:回「无意见」而不是
+// 随便认一个状态,调用方据此让可选键整键缺席。
+// Connected → ConnectStateConnected —— 有活会话就是真连上,先于其它分支。
+// Retrying → ConnectStatePending —— 尚未连上但重试在跑,这才是「连接中」。
+// Listening → ConnectStateReady —— 监听已就绪、等对端接入,不是失败。
+// 否则 → ("", "") —— 已启动、未连上、也没有可解释的就绪面:**不编**
+// 状态,让调用方退回只看 IsConnected()。
+//
+// 只有 pending 会带 Reason:connected 没有任何坏消息要说,ready 本来就不是坏消息。
+func ConnectStateOf(sig ConnectSignals) (state, reason string) {
+ switch {
+ case !sig.Started:
+ return "", ""
+ case sig.Connected:
+ return ConnectStateConnected, ""
+ case sig.Retrying:
+ return ConnectStatePending, sig.Reason
+ case sig.Listening:
+ return ConnectStateReady, ""
+ default:
+ return "", ""
+ }
+}
diff --git a/internal/channel/adapter/connect_outcome_test.go b/internal/channel/adapter/connect_outcome_test.go
new file mode 100644
index 0000000..c1c1416
--- /dev/null
+++ b/internal/channel/adapter/connect_outcome_test.go
@@ -0,0 +1,75 @@
+// 本文件只测连接态三态的**纯判定**(ConnectStateOf):它不读时钟、不触网、不加锁,
+// 因此三态可以逐条单测。适配器侧的落点(qq_obv11 的 ConnectOutcome 与脱敏)见
+// plugins/channels/qq_obv11/connect_outcome_test.go,网关侧的映射见
+// internal/services/gateway/admin_channel_connect_outcome_test.go。
+package adapter
+
+import "testing"
+
+// TestConnectStateOfNotStartedHasNoOpinion 断言未启动的适配器**不编状态**:回空串,
+// 调用方据此退回只看 IsConnected(),而不是随便认一个。
+func TestConnectStateOfNotStartedHasNoOpinion(t *testing.T) {
+ t.Parallel()
+ for _, sig := range []ConnectSignals{
+ {},
+ {Connected: true},
+ {Retrying: true, Reason: "boom"},
+ {Listening: true},
+ } {
+ if state, reason := ConnectStateOf(sig); state != "" || reason != "" {
+ t.Errorf("ConnectStateOf(%+v) = (%q,%q), want 无意见", sig, state, reason)
+ }
+ }
+}
+
+// TestConnectStateOfConnectedWins 断言真连上优先于其它分支(监听与重试信号同时为真
+// 也不该改变结论),且**不**带原因 —— 「已连接」旁边不该挂一条过期的失败原因。
+func TestConnectStateOfConnectedWins(t *testing.T) {
+ t.Parallel()
+ state, reason := ConnectStateOf(ConnectSignals{
+ Started: true, Connected: true, Retrying: true, Listening: true, Reason: "stale failure",
+ })
+ if state != ConnectStateConnected || reason != "" {
+ t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateConnected)
+ }
+}
+
+// TestConnectStateOfRetryingIsPending 断言「未连上但重试在跑」是 pending 且带上原因。
+// 这正是 2026-10-07 事故里被写成「已连接」(或静态「未连接」)的那一格。
+func TestConnectStateOfRetryingIsPending(t *testing.T) {
+ t.Parallel()
+ state, reason := ConnectStateOf(ConnectSignals{
+ Started: true, Retrying: true, Reason: "dial tcp 127.0.0.1:10301: connection refused",
+ })
+ if state != ConnectStatePending {
+ t.Fatalf("state = %q, want %q", state, ConnectStatePending)
+ }
+ if reason != "dial tcp 127.0.0.1:10301: connection refused" {
+ t.Fatalf("reason = %q, want the failure reason verbatim", reason)
+ }
+ // 三个取值互不相同:把 pending 写成 connected 是这次要根除的谎。
+ if ConnectStatePending == ConnectStateConnected || ConnectStatePending == ConnectStateReady {
+ t.Fatal("pending 的取值必须与 connected / ready 都不同")
+ }
+}
+
+// TestConnectStateOfListeningIsReady 断言「已绑定监听、等对端接入」是 ready 而不是
+// pending:它不是失败,因此**没有**原因可说(哪怕结构体里带着一条陈旧的 reason)。
+func TestConnectStateOfListeningIsReady(t *testing.T) {
+ t.Parallel()
+ state, reason := ConnectStateOf(ConnectSignals{
+ Started: true, Listening: true, Reason: "should not be surfaced",
+ })
+ if state != ConnectStateReady || reason != "" {
+ t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateReady)
+ }
+}
+
+// TestConnectStateOfUnexplainedStaysSilent 断言「已启动、未连上、也没有可解释的就绪面」
+// 时**不编**状态:既不说「正在连接」(不知道有没有在连),也不说「就绪」。
+func TestConnectStateOfUnexplainedStaysSilent(t *testing.T) {
+ t.Parallel()
+ if state, reason := ConnectStateOf(ConnectSignals{Started: true, Reason: "orphan"}); state != "" || reason != "" {
+ t.Fatalf("ConnectStateOf = (%q,%q), want 无意见", state, reason)
+ }
+}
diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go
index 4a09a7f..a13fbe8 100644
--- a/internal/services/gateway/admin.go
+++ b/internal/services/gateway/admin.go
@@ -23,11 +23,19 @@ type PluginSummary struct {
// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权
// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器
// 确实要求时才出现,否则一律省略。
+//
+// Pending/Ready/Reason(v0.68)报告 `connected=false` 时**到底是哪种没连上**
+// (见 admin_channel_connect_outcome.go 中的 connectOutcomeAware):pending =
+// 已在后台重试、当前未连上(面板读作「连接中」),ready = 已就绪、等待对端接入。
+// 三者同样只在适配器确实自报时才出现 —— 没有这条状态面的平台逐字节不受影响。
type ChannelSummary struct {
Name string `json:"name"`
Connected bool `json:"connected"`
NeedsRescan bool `json:"needsRescan,omitempty"`
LastError string `json:"lastError,omitempty"`
+ Pending bool `json:"pending,omitempty"`
+ Ready bool `json:"ready,omitempty"`
+ Reason string `json:"reason,omitempty"`
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
}
diff --git a/internal/services/gateway/admin_channel_connect_outcome.go b/internal/services/gateway/admin_channel_connect_outcome.go
new file mode 100644
index 0000000..3bd8f4c
--- /dev/null
+++ b/internal/services/gateway/admin_channel_connect_outcome.go
@@ -0,0 +1,98 @@
+package gateway
+
+// 本文件承载管理面读取**适配器连接态三态**的那一层:把适配器的 ConnectOutcome
+// 映射成面板与线上契约能用的形状,使「点了连接之后到底怎么样了」不再只有一个布尔。
+//
+// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」。ws+client 的首连
+// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时 IsConnected() 为 false ——
+// 它与「监听已就绪等对端接入」(server 模式,同样 false)在布尔上无从区分,更与
+// 「配置错、起不来」分不开。网关**绝不**按适配器名或 transport 去猜:口径属于适配器,
+// 它才知道自己的 transport × mode 意味着什么;网关只做映射。
+//
+// 与 accountStatusAware / rescanAware / sessionStatusAware 同一契约风格(见
+// admin_channel_status.go 与 admin_channels_rescan.go):核心 SDK Channel 契约被刻意
+// 不扩展,因此没有这条状态面的平台不受任何改动,也不会凭空多出一个字段。
+
+import (
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
+ "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
+)
+
+// connectOutcomeAware 是渠道适配器暴露「Connect 之后处于哪种状态」这一可选能力的接口。
+// 当前由 *qq_obv11.Adapter 实现(ConnectOutcome):它的 ws+client 首连失败非致命、
+// ws+server 只需监听就绪,两种「没连上」都不是同一件事。
+//
+// 返回的 state 是**闭集**取值(internal/channel/adapter 的 ConnectState* 常量):
+//
+// "connected" —— 真连上;
+// "pending" —— 已进入后台重试、当前未连上,reason 说明原因;
+// "ready" —— 已绑定监听、就绪等对端接入,不是失败;
+// "" —— 无意见(尚未启动),调用方退回只看 IsConnected()。
+//
+// 闭集而不是布尔,是因为「没连上」至少还有两种互不相同的现状,而它们对主人的意义
+// 完全不同:一种是「还在连」,一种是「就等对面来连」。
+type connectOutcomeAware interface {
+ // ConnectOutcome 报告连接态;state 取值见上,reason 在未连上时说明原因
+ // (如实的中文/技术短句,**绝不含凭据**)。
+ ConnectOutcome() (state string, reason string)
+}
+
+// asConnectOutcomeAware 报告已注册的渠道值是否暴露连接态三态。与 asRescanAware /
+// asAccountStatusAware 同一套路:注册时会把适配器包进 cmd/cyrene 的 namedAdapter ——
+// 一个内嵌 adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的方法,因此具体
+// 适配器多出的能力只能通过拆开被内嵌字段才能抵达。直接的适配器值(测试)按原样接受。
+func asConnectOutcomeAware(v any) (connectOutcomeAware, bool) {
+ if co, ok := v.(connectOutcomeAware); ok {
+ return co, true
+ }
+ inner, ok := unwrapNamedAdapter(v)
+ if !ok {
+ return nil, false
+ }
+ co, ok := inner.(connectOutcomeAware)
+ return co, ok
+}
+
+// channelConnectOutcome 是连接态三态在**线上**的投影:三态里只有两种「没连上」需要
+// 额外说明(真连上已经由 connected 布尔说完),reason 只在 pending 时非空。
+//
+// 刻意用可选键而不是把三态塞进 connected:connected 的语义已被老客户端当作「连上了
+// 没有」在读,改写它等于改契约;新增可选键则谁都不吃亏 —— 零值的三态投影让
+// pending / ready / reason **整键缺席**,与 NeedsRescan / LastError 同一口径。
+type channelConnectOutcome struct {
+ // Pending 表示「已在后台重试、当前未连上」——面板须把它读作「连接中」,
+ // 绝不是「未连接」,也绝不是「已连接」。
+ Pending bool
+ // Ready 表示「已就绪、等待对端接入」——同样不是失败。
+ Ready bool
+ // Reason 是未连上时的原因(已脱敏),仅在 Pending 时可能非空。
+ Reason string
+}
+
+// channelConnectStateOf 报告某渠道适配器当前的连接态:connected 布尔 + 需要额外说明的
+// 三态投影。
+//
+// 适配器实现了 connectOutcomeAware ⇒ **以它为准**:闭集三态优先于 IsConnected() 的
+// 单个布尔(pending 与 ready 的 IsConnected() 都是 false,但两者都不是「未连接」这样
+// 一个静态结论)。没实现,或返回空串「无意见」⇒ 逐字退回今天的行为(只看
+// IsConnected(),三态投影为零值、可选键缺席)。
+func channelConnectStateOf(ch sdk.Channel) (bool, channelConnectOutcome) {
+ if ch == nil {
+ return false, channelConnectOutcome{}
+ }
+ aware, ok := asConnectOutcomeAware(ch)
+ if !ok {
+ return ch.IsConnected(), channelConnectOutcome{}
+ }
+ state, reason := aware.ConnectOutcome()
+ switch state {
+ case adapter.ConnectStateConnected:
+ return true, channelConnectOutcome{}
+ case adapter.ConnectStatePending:
+ return false, channelConnectOutcome{Pending: true, Reason: reason}
+ case adapter.ConnectStateReady:
+ return false, channelConnectOutcome{Ready: true}
+ default:
+ return ch.IsConnected(), channelConnectOutcome{}
+ }
+}
diff --git a/internal/services/gateway/admin_channel_connect_outcome_test.go b/internal/services/gateway/admin_channel_connect_outcome_test.go
new file mode 100644
index 0000000..6585334
--- /dev/null
+++ b/internal/services/gateway/admin_channel_connect_outcome_test.go
@@ -0,0 +1,251 @@
+package gateway
+
+// 本文件锁定**连接态三态**在管理面上的如实转述(2026-10-07 的 UI 撒谎事故):
+//
+// - `POST /admin/channels/open` 之后 **不许**无条件报 connected=true;
+// 适配器自报「重试中」时必须是 connected=false + pending + reason;
+// - 适配器自报「就绪等接入」(server 模式)时是 connected=false + ready,
+// 既不是「已连接」,也不是一句静态的「未连接」;
+// - 没实现这条状态面的平台(绝大多数)响应逐字节不变 —— 可选键整键缺席。
+//
+// 断言打在**线上响应**上(不是内部函数),因为「面板读到什么」才是这件事的全部。
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "sync/atomic"
+ "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/config"
+)
+
+// outcomeChannel 是一个会自报连接态三态的假适配器:Connect 不致命(照常返回
+// nil),真实状态与原因完全由测试给定,因此「网关有没有照实转述」是可断言的事实。
+type outcomeChannel struct {
+ name string
+ state string
+ reason string
+ connected atomic.Bool
+ connects atomic.Int32
+}
+
+func (c *outcomeChannel) Name() string { return c.name }
+func (c *outcomeChannel) IsConnected() bool { return c.connected.Load() }
+func (c *outcomeChannel) HealthCheck(context.Context) error { return nil }
+func (c *outcomeChannel) ConnectOutcome() (string, string) { return c.state, c.reason }
+
+func (c *outcomeChannel) Connect(context.Context) error {
+ c.connects.Add(1)
+ // 「真连上」才置位;pending / ready 都必须保持未连上 —— 这正是三态要区分的。
+ c.connected.Store(c.state == "connected")
+ return nil
+}
+
+func (c *outcomeChannel) Disconnect(context.Context) error {
+ c.connected.Store(false)
+ return nil
+}
+
+// openChannelData 发起一次 open 并解出 data 对象(原始 map,好断言「键在不在」)。
+func openChannelData(t *testing.T, ch *outcomeChannel) map[string]any {
+ t.Helper()
+ caps := capreg.New()
+ if err := caps.RegisterChannel(ch); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
+ tok := signAny(t, jwt, "acct", auth.RoleOwner)
+ resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
+ map[string]any{"name": ch.name}, tok)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body)
+ }
+ var out map[string]any
+ if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
+ t.Fatalf("decode open data: %v (body=%s)", err, body)
+ }
+ return out
+}
+
+// TestChannelOpenReportsConnectedTruthfully 场景①:适配器自报真连上 ⇒ connected=true,
+// 且三个可选键一律缺席(没有坏消息要额外说,老客户端形状不变)。
+func TestChannelOpenReportsConnectedTruthfully(t *testing.T) {
+ t.Parallel()
+ out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "connected"})
+ if out["connected"] != true {
+ t.Fatalf("connected = %v, want true", out["connected"])
+ }
+ for _, key := range []string{"pending", "ready", "reason"} {
+ if _, present := out[key]; present {
+ t.Fatalf("%s 不该出现在「真连上」的响应里: %v", key, out)
+ }
+ }
+}
+
+// TestChannelOpenReportsPendingWithReason 场景②(本次事故的正题):适配器自报
+// 「已在后台重试、当前未连上」时,响应**绝不能**是 connected=true —— 那正是面板弹出
+// 「已连接」而卡片一直显示「未连接」的根源。必须如实给出 connected=false + pending
+// + reason,且 resolved/name 等既有键原样保留。
+func TestChannelOpenReportsPendingWithReason(t *testing.T) {
+ t.Parallel()
+ const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
+ out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "pending", reason: reason})
+
+ if out["connected"] != false {
+ t.Fatalf("connected = %v, want false(重试中不许写成已连接)", out["connected"])
+ }
+ if out["pending"] != true {
+ t.Fatalf("pending = %v, want true(面板据此显示「正在连接」而不是「未连接」)", out["pending"])
+ }
+ if out["reason"] != reason {
+ t.Fatalf("reason = %v, want %q(必须把「为什么没连上」如实交出去)", out["reason"], reason)
+ }
+ if _, present := out["ready"]; present {
+ t.Fatalf("ready 不该与 pending 同时出现: %v", out)
+ }
+ if out["name"] != "qq-obv11" || out["resolved"] != "qq-obv11" {
+ t.Fatalf("既有键被破坏: %v", out)
+ }
+}
+
+// TestChannelOpenReportsReady 场景③:ws+server 已绑定监听、还没有对端接入。
+// IsConnected() 同样是 false,但它**不是失败**,因此报 ready 而不是 pending,
+// 也不带 reason(没有坏消息可说)。
+func TestChannelOpenReportsReady(t *testing.T) {
+ t.Parallel()
+ out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "ready"})
+ if out["connected"] != false {
+ t.Fatalf("connected = %v, want false(监听就绪 ≠ 已连上)", out["connected"])
+ }
+ if out["ready"] != true {
+ t.Fatalf("ready = %v, want true(就绪等接入不是失败)", out["ready"])
+ }
+ for _, key := range []string{"pending", "reason"} {
+ if _, present := out[key]; present {
+ t.Fatalf("%s 不该出现在「就绪等接入」的响应里: %v", key, out)
+ }
+ }
+}
+
+// TestChannelOpenWithoutOutcomeKeepsLegacyShape 守住「不动别人」:没实现连接态三态
+// 的适配器(绝大多数平台)响应与今天逐字节一致 —— 三个可选键整键缺席,只有既有四键。
+func TestChannelOpenWithoutOutcomeKeepsLegacyShape(t *testing.T) {
+ t.Parallel()
+ caps := capreg.New()
+ only := &spyChannel{name: "kook:3fc8"}
+ if err := caps.RegisterChannel(only); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
+ tok := signAny(t, jwt, "acct", auth.RoleOwner)
+ resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
+ map[string]any{"name": "kook"}, tok)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
+ }
+ var out map[string]any
+ if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
+ t.Fatalf("decode: %v (body=%s)", err, body)
+ }
+ if len(out) != 3 || out["connected"] != true {
+ t.Fatalf("data = %v, want exactly {name,resolved,connected:true}", out)
+ }
+ for _, key := range []string{"pending", "ready", "reason"} {
+ if _, present := out[key]; present {
+ t.Fatalf("%s 不该出现(适配器没有这条状态面): %v", key, out)
+ }
+ }
+}
+
+// TestChannelCloseIsNotOptimistic 守住断开路径同样不乐观:报**真实的** IsConnected()。
+// 一个「Disconnect 返回 nil 但其实没断开」的适配器必须被照实报成 connected=true,
+// 而不是假定调用成功就等于断开了。
+func TestChannelCloseIsNotOptimistic(t *testing.T) {
+ t.Parallel()
+ stuck := &outcomeChannel{name: "qq-obv11", state: "pending", reason: "still retrying"}
+ stuck.connected.Store(true) // 假装会话还活着,Disconnect 也没能拆掉它
+ stuckDisconnect := &stubbornChannel{outcomeChannel: stuck}
+ caps := capreg.New()
+ if err := caps.RegisterChannel(stuckDisconnect); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
+ tok := signAny(t, jwt, "acct", auth.RoleOwner)
+ resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/close",
+ map[string]any{"name": "qq-obv11"}, tok)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body)
+ }
+ var out map[string]any
+ if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
+ t.Fatalf("decode: %v (body=%s)", err, body)
+ }
+ if out["connected"] != true {
+ t.Fatalf("connected = %v, want true(实测仍未断开就必须照实报)", out["connected"])
+ }
+ if _, present := out["pending"]; present {
+ t.Fatalf("关闭路径不该投影三态: %v", out)
+ }
+}
+
+// stubbornChannel 是一个 Disconnect 不真正断开连接的假适配器:用来证明关闭路径读的
+// 是真实 IsConnected(),而不是「调用成功 ⇒ 一定断开了」。
+type stubbornChannel struct {
+ *outcomeChannel
+}
+
+func (c *stubbornChannel) Disconnect(context.Context) error { return nil }
+
+// TestChannelListExposesConnectOutcome 守住列表侧:面板卡片的状态来自
+// GET /admin/channels,因此三态必须在那里也如实出现(平台行与账号行都要),
+// 否则点完连接刷新一次列表,「连接中」又会变回一句静态的「未连接」。
+func TestChannelListExposesConnectOutcome(t *testing.T) {
+ t.Parallel()
+ const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
+ ch := &outcomeChannel{name: "qq-obv11:9616b27a", state: "pending", reason: reason}
+ store := channelsStoreOf(t, "qq-obv11",
+ config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
+ caps := capreg.New()
+ if err := caps.RegisterChannel(ch); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ _, 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)
+ }
+ var rows []struct {
+ Name string `json:"name"`
+ Connected bool `json:"connected"`
+ Pending bool `json:"pending"`
+ Reason string `json:"reason"`
+ Accounts []struct {
+ ID string `json:"id"`
+ Connected *bool `json:"connected"`
+ Pending bool `json:"pending"`
+ Reason string `json:"reason"`
+ } `json:"accounts"`
+ }
+ if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
+ t.Fatalf("decode list: %v (body=%s)", err, body)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("rows = %d, want 1 (body=%s)", len(rows), body)
+ }
+ row := rows[0]
+ if row.Connected || !row.Pending || row.Reason != reason {
+ t.Fatalf("平台行 = %+v, want connected=false pending=true reason=%q", row, reason)
+ }
+ if len(row.Accounts) != 1 {
+ t.Fatalf("accounts = %d, want 1", len(row.Accounts))
+ }
+ acct := row.Accounts[0]
+ if acct.ID != "9616b27a" || acct.Connected == nil || *acct.Connected || !acct.Pending || acct.Reason != reason {
+ t.Fatalf("账号行 = %+v, want id=9616b27a connected=false pending=true reason=%q", acct, reason)
+ }
+}
diff --git a/internal/services/gateway/admin_channel_status.go b/internal/services/gateway/admin_channel_status.go
index 318c560..7d2c56e 100644
--- a/internal/services/gateway/admin_channel_status.go
+++ b/internal/services/gateway/admin_channel_status.go
@@ -48,6 +48,58 @@ func enrichAccountsWithAccountOnline(accts []AdminChannelAccount, by map[string]
return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v })
}
+// liveAccountConnectOutcomes 收集每个平台各账号的**连接态三态**(适配器自报的
+// pending / ready / reason),键与 liveAccountConnections 相同(裸适配器名 ⇒ 空账号 id)。
+//
+// 只有确实自报了三态的账号才进 map:三态投影为零值(适配器没这条状态面,或它此刻自认
+// 「真连上 / 无意见」)就缺席 —— 缺席 = 面板退回只看 connected 布尔,绝不凭空编一个
+// 状态。补一个 false 顶上去,与本次要根除的「编一个状态」同属一类。
+func (s *Server) liveAccountConnectOutcomes() map[string]map[string]channelConnectOutcome {
+ out := make(map[string]map[string]channelConnectOutcome)
+ if s.caps == nil {
+ return out
+ }
+ for _, cap := range s.caps.List(capreg.KindChannel) {
+ ch, ok := cap.Value.(sdk.Channel)
+ if !ok {
+ continue
+ }
+ _, outcome := channelConnectStateOf(ch)
+ if outcome == (channelConnectOutcome{}) {
+ continue
+ }
+ platform, acct := splitAdapterName(ch.Name())
+ if out[platform] == nil {
+ out[platform] = make(map[string]channelConnectOutcome)
+ }
+ out[platform][acct] = outcome
+ }
+ return out
+}
+
+// enrichAccountsWithConnectOutcome 把**连接态三态**合并进平台中匹配的账号(按账号 id
+// 配对)。不在 by 里的账号保持 Pending/Ready/Reason 全空 ⇒ 整键缺席 ⇒ 面板退回只看
+// connected。入参切片不被修改。
+func enrichAccountsWithConnectOutcome(
+ accts []AdminChannelAccount,
+ by map[string]channelConnectOutcome,
+) []AdminChannelAccount {
+ if len(by) == 0 {
+ return accts
+ }
+ out := make([]AdminChannelAccount, 0, len(accts))
+ for _, a := range accts {
+ item := a
+ if o, ok := by[item.ID]; ok {
+ item.Pending = o.Pending
+ item.Ready = o.Ready
+ item.Reason = o.Reason
+ }
+ out = append(out, item)
+ }
+ return out
+}
+
// enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的
// 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 ——
// 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。
diff --git a/internal/services/gateway/admin_channel_target.go b/internal/services/gateway/admin_channel_target.go
index 62fd9da..d140aef 100644
--- a/internal/services/gateway/admin_channel_target.go
+++ b/internal/services/gateway/admin_channel_target.go
@@ -135,11 +135,38 @@ func (s *Server) firstConfiguredChannel(name string) (sdk.Channel, bool) {
// channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端
// 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的
// 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。
-func channelActionResult(target channelTarget, resolved string, connected bool) map[string]any {
+//
+// 除既有四个键外,另按适配器自报的**连接态三态**(见 admin_channel_connect_outcome.go)
+// 补三个**可选**键,且取不到就整键缺席(同 NeedsRescan / LastError 口径):
+//
+// pending: true —— 已在后台重试、当前未连上 ⇒ 此时 connected=false。面板须读作
+// 「连接中(reason)」,**不是**「未连接」,更不是「已连接」;
+// ready: true —— 已就绪、等待对端接入(server 模式)⇒ 同样 connected=false,
+// 但它不是失败;
+// reason: "<已脱敏>" —— 未连上时的原因,仅在 pending 时可能出现。
+//
+// 真连上时这三个键一律缺席:connected=true 已经把话说完了,再补一个「没有坏消息」的
+// 空串只会让老客户端多出一个要处理的键。适配器没实现这条状态面时同理 —— 响应与今天
+// 逐字节一致。
+func channelActionResult(
+ target channelTarget,
+ resolved string,
+ connected bool,
+ outcome channelConnectOutcome,
+) map[string]any {
out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected}
if target.AccountID != "" {
out["accountId"] = target.AccountID
}
+ if outcome.Pending {
+ out["pending"] = true
+ }
+ if outcome.Ready {
+ out["ready"] = true
+ }
+ if outcome.Reason != "" {
+ out["reason"] = outcome.Reason
+ }
return out
}
diff --git a/internal/services/gateway/admin_query.go b/internal/services/gateway/admin_query.go
index 9910c03..fd71c78 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) {
botByPlatform := s.liveBotIdentity()
connectedByPlatform := s.liveAccountConnections()
onlineByPlatform := s.liveAccountOnline()
+ outcomeByPlatform := s.liveAccountConnectOutcomes()
out := make([]ChannelSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) {
@@ -87,7 +88,17 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
if s.hasChannelSummary(out, platform) {
continue
}
- sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()}
+ // 连接态三态(v0.68):`connected=false` 可能只是「正在后台重试」或
+ // 「监听已就绪、等对端接入」,两者都不是「未连接」这样一个静态结论。
+ // 只有适配器确实自报了才写这三个键,否则一律省略(同 NeedsRescan 口径)。
+ connected, outcome := channelConnectStateOf(ch)
+ sum := ChannelSummary{
+ Name: platform,
+ Connected: connected,
+ Pending: outcome.Pending,
+ Ready: outcome.Ready,
+ Reason: outcome.Reason,
+ }
// 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器
// 确实要求重新扫码时才报告,因此健康的适配器保持字段省略,
// 而不是送出一个编造的值。
@@ -98,9 +109,12 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
}
}
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
- sum.Accounts = enrichAccountsWithAccountOnline(
- enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
- onlineByPlatform[platform],
+ sum.Accounts = enrichAccountsWithConnectOutcome(
+ enrichAccountsWithAccountOnline(
+ enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
+ onlineByPlatform[platform],
+ ),
+ outcomeByPlatform[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 029359a..e5eef1a 100644
--- a/internal/services/gateway/admin_write_channel_config.go
+++ b/internal/services/gateway/admin_write_channel_config.go
@@ -70,6 +70,14 @@ type AdminChannelAccount struct {
// 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。
// 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。
AccountOnline *bool `json:"accountOnline,omitempty"`
+ // Pending/Ready/Reason 是该账号适配器自报的**连接态三态**(可选能力,见
+ // internal/services/gateway/admin_channel_connect_outcome.go):Connected=false 时
+ // 面板据此把「已在后台重试(连接中)」与「已就绪、等待对端接入」从「未连接」里
+ // 分出来。三者缺省 = 该适配器没有这条状态面 ⇒ 面板退回只看 Connected,
+ // 绝不凭空编一个状态。
+ Pending bool `json:"pending,omitempty"`
+ Ready bool `json:"ready,omitempty"`
+ Reason string `json:"reason,omitempty"`
// Capabilities 列出该平台适配器暴露的账号级管理操作
// (friends/channels/user-chats/message-test),使面板
// 渲染正确的每账号卡片。没有专用适配器表面的平台
diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go
index 10ddfa9..17bff49 100644
--- a/internal/services/gateway/admin_write_llm_channels.go
+++ b/internal/services/gateway/admin_write_llm_channels.go
@@ -303,7 +303,12 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
return
}
s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID)
- writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), true))
+ // `Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连失败刻意
+ // 非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪。所以这里
+ // **绝不**硬编码 connected=true:如实取适配器自报的连接态三态,并把「重试中(含
+ // 原因)」与「就绪等接入」作为可选键一并交出(见 admin_channel_connect_outcome.go)。
+ connected, outcome := channelConnectStateOf(ch)
+ writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), connected, outcome))
}
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId
@@ -324,7 +329,11 @@ func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
return
}
s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID)
- writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), false))
+ // 断开不乐观:报**真实的** IsConnected(),而不是假定「调用成功 ⇒ 一定断开了」。
+ // 关闭路径刻意不投影三态:pending / ready 说的是「连的过程」,而这里要回答的是
+ // 「会话到底还在不在」——那正是 IsConnected() 的语义。
+ writeJSON(w, http.StatusOK,
+ channelActionResult(target, ch.Name(), ch.IsConnected(), channelConnectOutcome{}))
}
// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道
diff --git a/plugins/channels/qq_obv11/adapter.go b/plugins/channels/qq_obv11/adapter.go
index 109bb29..7a28b90 100644
--- a/plugins/channels/qq_obv11/adapter.go
+++ b/plugins/channels/qq_obv11/adapter.go
@@ -106,9 +106,13 @@ type Adapter struct {
httpAPI *obHTTPClient
started bool
connected bool
- cancel context.CancelFunc
- lifeCtx context.Context
- newConn chan struct{}
+ // lastConnectErr 是最近一次连接失败的**脱敏**原因,供 ConnectOutcome 如实回答
+ // 「为什么没连上」(见 connect_outcome.go)。连上或断开即清空:一条已经过期的
+ // 原因不该继续挂在当前状态旁边。由 mu 保护。
+ lastConnectErr string
+ cancel context.CancelFunc
+ lifeCtx context.Context
+ newConn chan struct{}
handlerMu sync.RWMutex
handler adapter.MessageHandler
@@ -297,6 +301,8 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
a.ln = nil
a.srv = nil
a.httpAPI = nil
+ // 断开即清空上一次失败原因:会话已被拆掉,「为什么没连上」不再是当前事实。
+ a.lastConnectErr = ""
// 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用
// 上一轮的陈旧结论(AccountStatus 的诚实口径)。
a.account = accountStatus{}
diff --git a/plugins/channels/qq_obv11/connect_outcome.go b/plugins/channels/qq_obv11/connect_outcome.go
new file mode 100644
index 0000000..f44272b
--- /dev/null
+++ b/plugins/channels/qq_obv11/connect_outcome.go
@@ -0,0 +1,121 @@
+package qq_obv11
+
+// 本文件承载 qq-obv11 的**连接态三态**:一次 Connect 返回之后,该适配器到底处于
+// 「真连上 / 正在重试 / 就绪等接入」中的哪一种,以及没连上时的**脱敏原因**。
+//
+// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连
+// 失败是**故意非致命**的(见 ws_client.go 与 reconnect.go:NapCat 等 OneBot 实现可能
+// 比 Cyrene 后启动)。于是管理面板拿到的 `connected=false` 既可能是「正在后台重试」,
+// 也可能是「监听已就绪等对端接入」,而它与「配置错、起不来」在布尔上无从区分;面板只能
+// 编一个说法,编出来的正是 2026-10-07 那个「点了连接就弹已连接」的谎。三态把这三件事
+// 分开,并且**由适配器自己回答**——只有它知道自己的 transport × mode 意味着什么。
+//
+// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
+// transport × mode);三态取值与判定顺序定义在 internal/channel/adapter 的
+// ConnectStateOf(跨平台共用一份口径,别的适配器接上时不会各自走样)。立场与
+// design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致:一个布尔说不了三件事。
+
+import (
+ "strings"
+
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
+)
+
+// maskedCredential 是脱敏后的占位符(与全链路可视化的脱敏标记同形,便于一眼认出
+// 「这里原本是凭据」而不是「这里本来就长这样」)。
+const maskedCredential = "[redacted]"
+
+// ConnectOutcome 报告一次 Connect 之后该适配器处于哪种就绪状态。
+//
+// state 是闭集取值(adapter.ConnectStateConnected / ConnectStatePending /
+// ConnectStateReady),空串表示「无意见」(尚未启动,无从判断);reason 是未连上时的
+// **脱敏**原因,绝不含 access_token。
+//
+// 它是可选的适配器能力:网关经类型断言读取(internal/services/gateway 的
+// connectOutcomeAware),核心 PlatformAdapter 契约刻意不扩展。
+func (a *Adapter) ConnectOutcome() (state, reason string) {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return adapter.ConnectStateOf(adapter.ConnectSignals{
+ Started: a.started,
+ Connected: a.connected,
+ // 只有 ws+client 有一条后台重试循环(readLoop 复用 connectClient),因此
+ // 「未连上 ⇒ 连接中」这条推论只在它上面成立;HTTP 没有会话可重连。
+ Retrying: a.cfg.Mode == modeClient && a.cfg.Transport != transportHTTP,
+ // 监听已绑定 ⇒ 就绪等对端接入(ws+server;http+server 也有监听器,但它由
+ // Connect 直接置 connected=true,走不到这里)。
+ Listening: a.ln != nil,
+ Reason: a.lastConnectErr,
+ })
+}
+
+// connectFailureReason 把一次连接失败整理成适配器可以如实回答的脱敏原因。
+func (a *Adapter) connectFailureReason(err error) string {
+ if err == nil {
+ return ""
+ }
+ return sanitizeConnectReason(err.Error(), a.cfg.AccessToken)
+}
+
+// sanitizeConnectReason 把一次连接失败的原因整理成可以对外说的短句。三层防线缺一不可:
+//
+// 1. trace.RedactText 做形状级兜底与截断(与全链路可视化同一道红线):一个不设上限的
+// 错误串不该被原样端给面板,而凭据形状(api_key/bearer/JWT/≥6 字符的 token 赋值)
+// 在这一层就被抹掉 —— 包括 token 已被换掉、我们并不知道明文的那种情况。
+// 2. 配置里的 access_token 明文整体替换 —— ws_client.go 的 appendQueryToken 会把 token
+// 拼进拨号 URL,任何把该 URL 写进错误串的实现都会把它带出来;而**短 token** 逃得过
+// 第 1 层的形状匹配,只有「已知明文」这一层能兜住。
+// 3. 剥掉 URL 里 access_token 查询参数的值(含内嵌在引号/反括号里的那些)。
+//
+// 顺序是刻意的:先形状级、再逐字剥。反过来(先剥参数再交给形状级)会让第 1 层的贪婪
+// 匹配把我们已经写好的 `access_token=` 参数名一起吃掉,留下更难读懂的一句话。
+//
+// 空原因返回空串 —— 「没有原因」与「原因被脱敏成空」是两件事,绝不互相冒充。
+func sanitizeConnectReason(raw, token string) string {
+ if raw == "" {
+ return ""
+ }
+ raw = trace.RedactText(raw)
+ if token != "" {
+ raw = strings.ReplaceAll(raw, token, maskedCredential)
+ }
+ return maskAccessTokenQuery(raw)
+}
+
+// maskAccessTokenQuery 把 access_token 查询参数的值替换成占位符。大小写不敏感,循环
+// 处理多次出现(同一错误串里可能带多条 URL)。
+//
+// 每轮把已处理的前缀搬进 builder、只对**剩余部分**再查:参数名会被原样保留,因此在同一
+// 段文本上原地重查会永远命中同一个位置(占位符不改变它的存在),那是死循环而不是脱敏。
+func maskAccessTokenQuery(raw string) string {
+ const key = "access_token="
+ var b strings.Builder
+ rest := raw
+ for {
+ i := strings.Index(strings.ToLower(rest), key)
+ if i < 0 {
+ b.WriteString(rest)
+ return b.String()
+ }
+ start := i + len(key)
+ end := start
+ for end < len(rest) && !isQueryValueDelimiter(rest[end]) {
+ end++
+ }
+ b.WriteString(rest[:start])
+ b.WriteString(maskedCredential)
+ rest = rest[end:]
+ }
+}
+
+// isQueryValueDelimiter 报告该字节是否终止一个查询参数值:`&`/`#` 是 URL 分隔符,
+// 空白与引号、反括号则是内嵌在错误串里的 URL 的常见收尾。
+func isQueryValueDelimiter(c byte) bool {
+ switch c {
+ case '&', '#', ' ', '\t', '\n', '\r', '"', '\'', ')', '}', ']':
+ return true
+ default:
+ return false
+ }
+}
diff --git a/plugins/channels/qq_obv11/connect_outcome_test.go b/plugins/channels/qq_obv11/connect_outcome_test.go
new file mode 100644
index 0000000..c8ebe53
--- /dev/null
+++ b/plugins/channels/qq_obv11/connect_outcome_test.go
@@ -0,0 +1,273 @@
+package qq_obv11
+
+// 本文件测连接态三态的**适配器落点**:ConnectOutcome 如实回答「Connect 之后处于哪种
+// 状态」,以及没连上时那条原因确实被脱敏(access_token 绝不出现)。
+//
+// 为什么必须有它:2026-10-07 管理面板「点连接就弹已连接」的根因,就是适配器只对外给
+// 一个 IsConnected() 布尔 —— ws+client 的首连失败刻意非致命(NapCat 等可能比 Cyrene
+// 后启动),于是「正在重试」「监听就绪」「起不来」三件事在布尔上长得一模一样。
+// 纯判定部分(adapter.ConnectStateOf)见 internal/channel/adapter 的测试。
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "math"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
+)
+
+// tokenLeakDialer 是一个**故意把 access_token 拼进错误串**的拨号替身:真实世界里任何
+// 把拨号 URL 写进错误串的实现都会这样(ws_client.go 的 appendQueryToken 会把 token 拼
+// 进 URL)。它使「脱敏真的接在了存储与日志前面」成为可断言的事实,而不是一句注释。
+type tokenLeakDialer struct{ rawURL string }
+
+func (d tokenLeakDialer) dial(context.Context, string, string) (net.Conn, error) {
+ return nil, fmt.Errorf("test: dial %s failed", d.rawURL)
+}
+
+// TestSanitizeConnectReasonStripsAccessToken 断言原因串里的 access_token 绝不出现,
+// 且留下可读的占位符(面板要能看出「这里原本是凭据」,而不是一段乱码)。
+func TestSanitizeConnectReasonStripsAccessToken(t *testing.T) {
+ t.Parallel()
+ const token = "s3cr3t-token-value"
+ raw := "websocket: dial ws://hook.example/onebot?access_token=" + token + "&self_id=1: connection refused"
+ got := sanitizeConnectReason(raw, token)
+
+ if strings.Contains(got, token) {
+ t.Fatalf("脱敏后仍含 access_token 明文: %q", got)
+ }
+ if !strings.Contains(got, maskedCredential) {
+ t.Errorf("脱敏后没有留下占位符: %q", got)
+ }
+}
+
+// TestSanitizeConnectReasonStripsShortToken 断言**短、且紧邻其它参数**的 token 也剥得掉:
+// 形状级匹配要求赋值后至少 6 个非空白字符,`access_token=x&y` 这种逃得过它,只有
+// 「已知明文 + 剥查询参数」这两层能兜住 —— 这正是它们存在的理由。
+//
+// (参数名有时会被形状级兜底连同值一起吞掉:少一点诊断信息总好过泄露凭据,
+// 所以这里只断言**凭据绝不出现**,不绑定具体措辞。)
+func TestSanitizeConnectReasonStripsShortToken(t *testing.T) {
+ t.Parallel()
+ const token = "ab12"
+ for _, raw := range []string{
+ "dial ws://h/p?access_token=ab12&self_id=1 failed",
+ "websocket: dial ws://h/p?access_token=ab12: connection refused",
+ } {
+ got := sanitizeConnectReason(raw, token)
+ if strings.Contains(got, token) {
+ t.Errorf("短 token 泄露: %q → %q", raw, got)
+ }
+ if !strings.Contains(got, maskedCredential) {
+ t.Errorf("脱敏后没有留下占位符: %q → %q", raw, got)
+ }
+ }
+}
+
+// TestSanitizeConnectReasonStripsUnknownShortQueryToken 钉住第 3 层(剥 `access_token=`
+// 查询参数)**独有**的那一小段窗口:值**短于 6 个字符、且紧邻空白/引号等终止符**时,
+// 形状级那条规则(要求赋值后至少 6 个非空白字符)匹配不上,而「已知明文替换」又因为
+// 这把值**不是**我们配置的 token 而无从下手 —— 两层都落空,只剩这一层能兜住。
+//
+// 边界是实测出来的,不是想象的:`access_token=ab12&self_id=1` 这种写法会被形状级
+// **顺带**吃掉(它的值字符类一直吃到空格为止,于是 ≥6 字符成立),所以那类串钉不住
+// 第 3 层;本用例特意让短值后面紧跟一个空白,正好落在第 1、2 层都够不着的那一格。
+// 去掉第 3 层实现时本条必须变红 —— 它是那一层唯一的钉子。
+func TestSanitizeConnectReasonStripsUnknownShortQueryToken(t *testing.T) {
+ t.Parallel()
+ const leaked = "ab12"
+ raw := "websocket: dial ws://hook.example/onebot?access_token=" + leaked + " rejected during handshake"
+ got := sanitizeConnectReason(raw, "")
+ if strings.Contains(got, leaked) {
+ t.Fatalf("未配置 token 时仍泄露了他处的 access_token 明文: %q", got)
+ }
+ if !strings.Contains(got, maskedCredential) {
+ t.Errorf("脱敏后没有留下占位符: %q", got)
+ }
+}
+
+// TestSanitizeConnectReasonEmptyStaysEmpty 断言「没有原因」与「被脱敏成空」不互相冒充。
+func TestSanitizeConnectReasonEmptyStaysEmpty(t *testing.T) {
+ t.Parallel()
+ if got := sanitizeConnectReason("", "tok"); got != "" {
+ t.Fatalf("空原因 = %q, want 空串", got)
+ }
+}
+
+// TestConnectOutcomeClientPendingCarriesSanitizedReason 场景②(事故正题):ws+client
+// 首连失败 ⇒ Connect 照旧返回 nil(非致命),但 ConnectOutcome 必须如实报 pending
+// 并带上**脱敏后**的原因;日志里同样不许出现 token。
+func TestConnectOutcomeClientPendingCarriesSanitizedReason(t *testing.T) {
+ t.Parallel()
+
+ const token = "super-secret-token"
+ const rawURL = "ws://127.0.0.1:10301/onebot"
+ store := &testLogStore{}
+ a := NewAdapter(Config{
+ Mode: modeClient,
+ RemoteURL: rawURL,
+ AccessToken: token,
+ DialContext: tokenLeakDialer{rawURL: appendQueryToken(rawURL, token)}.dial,
+ Logger: slog.New(&testLogHandler{store: store}),
+ })
+ a.policy = testReconnectPolicy()
+
+ if err := a.Connect(context.Background()); err != nil {
+ t.Fatalf("Connect() error = %v, want nil(首连失败必须非致命)", err)
+ }
+ t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
+
+ state, reason := a.ConnectOutcome()
+ if state != adapter.ConnectStatePending {
+ t.Fatalf("ConnectOutcome state = %q, want %q(未连上且重试在跑)", state, adapter.ConnectStatePending)
+ }
+ if state == adapter.ConnectStateConnected {
+ t.Fatal("首连失败被报成已连接 —— 这正是面板撒谎的根因")
+ }
+ if reason == "" {
+ t.Fatal("pending 必须带上原因:面板要能把「为什么没连上」如实说出来")
+ }
+ if strings.Contains(reason, token) {
+ t.Fatalf("原因串泄露 access_token: %q", reason)
+ }
+ assertNoTokenLeak(t, store, token)
+}
+
+// TestConnectOutcomeClientConnected 场景①:对面在时拨号成功 ⇒ connected,且没有原因
+// 可挂(「已连接」旁边不该留一条过期的失败消息)。
+func TestConnectOutcomeClientConnected(t *testing.T) {
+ t.Parallel()
+
+ srv, _ := newFakeOneBotServer(t)
+ defer srv.Close()
+ a := NewAdapter(Config{
+ Mode: modeClient,
+ RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
+ DialContext: (&net.Dialer{}).DialContext,
+ })
+ a.policy = testReconnectPolicy()
+
+ if err := a.Connect(context.Background()); err != nil {
+ t.Fatalf("Connect() error = %v", err)
+ }
+ t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
+
+ state, reason := a.ConnectOutcome()
+ if state != adapter.ConnectStateConnected || reason != "" {
+ t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateConnected)
+ }
+ if !a.IsConnected() {
+ t.Fatal("报 connected 却 IsConnected()=false:两个事实面必须一致")
+ }
+}
+
+// TestConnectOutcomeServerReadyThenConnected 场景③:ws+server 绑定监听但没有对端接入时
+// 是 ready(IsConnected() 同为 false,却**不是失败**);对端反连上来之后变 connected。
+func TestConnectOutcomeServerReadyThenConnected(t *testing.T) {
+ t.Parallel()
+
+ a := NewAdapter(Config{Mode: modeServer, ListenAddr: "127.0.0.1:0"})
+ if err := a.Connect(context.Background()); err != nil {
+ t.Fatalf("Connect() error = %v", err)
+ }
+ t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
+
+ if a.IsConnected() {
+ t.Fatal("还没有对端接入就报 IsConnected()=true")
+ }
+ if state, reason := a.ConnectOutcome(); state != adapter.ConnectStateReady || reason != "" {
+ t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateReady)
+ }
+
+ client, err := dialWS(context.Background(), nil, "ws://"+a.Addr()+"/", "")
+ if err != nil {
+ t.Fatalf("dial server error: %v", err)
+ }
+ defer client.Close()
+ if !waitUntil(3*time.Second, a.IsConnected) {
+ t.Fatal("对端接入后仍未报已连接")
+ }
+ if state, _ := a.ConnectOutcome(); state != adapter.ConnectStateConnected {
+ t.Fatalf("对端接入后 ConnectOutcome state = %q, want %q", state, adapter.ConnectStateConnected)
+ }
+}
+
+// TestConnectOutcomeClearsReasonOnSuccess 断言连上时清空失败原因:一条已经过期的原因
+// 不该继续挂在「已连接」旁边(面板会把过期的与当前的混为一谈)。
+func TestConnectOutcomeClearsReasonOnSuccess(t *testing.T) {
+ t.Parallel()
+
+ srv, _ := newFakeOneBotServer(t)
+ defer srv.Close()
+ gate := make(chan struct{})
+ dial := &countingDialer{failTimes: 1, gate: gate, delegate: (&net.Dialer{}).DialContext}
+ a := NewAdapter(Config{
+ Mode: modeClient,
+ RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
+ DialContext: dial.dial,
+ })
+ a.policy = testReconnectPolicy()
+
+ if err := a.Connect(context.Background()); err != nil {
+ t.Fatalf("Connect() error = %v", err)
+ }
+ t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
+ if state, reason := a.ConnectOutcome(); state != adapter.ConnectStatePending || reason == "" {
+ t.Fatalf("首连失败后 = (%q,%q), want pending 且带原因", state, reason)
+ }
+
+ close(gate) // 对面起来了
+ if !waitUntil(3*time.Second, a.IsConnected) {
+ t.Fatal("第 2 次拨号成功却没有自动连上")
+ }
+ state, reason := a.ConnectOutcome()
+ if state != adapter.ConnectStateConnected || reason != "" {
+ t.Fatalf("连上后 = (%q,%q), want connected 且原因为空(过期的原因必须清掉)", state, reason)
+ }
+}
+
+// TestConnectOutcomeAfterDisconnectHasNoOpinion 断言断开之后适配器「无意见」:会话已经
+// 拆掉,「连的过程」不再是当前事实,网关据此退回只看 IsConnected()。
+func TestConnectOutcomeAfterDisconnectHasNoOpinion(t *testing.T) {
+ t.Parallel()
+
+ a := NewAdapter(Config{
+ Mode: modeClient,
+ RemoteURL: "ws://127.0.0.1:1/",
+ DialContext: (&countingDialer{failTimes: math.MaxInt}).dial,
+ })
+ a.policy = testReconnectPolicy()
+ if err := a.Connect(context.Background()); err != nil {
+ t.Fatalf("Connect() error = %v", err)
+ }
+ if err := a.Disconnect(context.Background()); err != nil {
+ t.Fatalf("Disconnect() error = %v", err)
+ }
+ if state, reason := a.ConnectOutcome(); state != "" || reason != "" {
+ t.Fatalf("Disconnect 后 = (%q,%q), want 无意见", state, reason)
+ }
+}
+
+// TestConnectOutcomeConfigErrorStaysFatalNotPending 守住「配置错仍然是硬失败」:外层的 Connect 报错路径没被动过,
+// 因此配置错永远不会走到 ConnectOutcome 这里被说成 pending。
+func TestConnectOutcomeConfigErrorStaysFatalNotPending(t *testing.T) {
+ t.Parallel()
+
+ a := NewAdapter(Config{Mode: modeClient})
+ err := a.Connect(context.Background())
+ if err == nil {
+ t.Fatal("缺 remote_url 的 Connect() = nil, want error")
+ }
+ if !errors.Is(err, errClientConfig) {
+ t.Fatalf("Connect() error = %v, want errClientConfig", err)
+ }
+ if state, _ := a.ConnectOutcome(); state != "" {
+ t.Fatalf("未启动的适配器 ConnectOutcome = %q, want 无意见", state)
+ }
+}
diff --git a/plugins/channels/qq_obv11/reconnect.go b/plugins/channels/qq_obv11/reconnect.go
index b5dfe08..c7aeeda 100644
--- a/plugins/channels/qq_obv11/reconnect.go
+++ b/plugins/channels/qq_obv11/reconnect.go
@@ -122,9 +122,15 @@ func (s *reconnectState) reset() {
s.failures = 0
}
-// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,并写一条带来源的
-// WARN(含 err 与下次重试间隔)。access_token 绝不入日志。
+// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,把**脱敏后的**原因留在
+// 适配器上(供 ConnectOutcome 如实回答「为什么没连上」,见 connect_outcome.go),并写一条
+// 带来源的 WARN(含原因与下次重试间隔)。原因与日志都走同一道脱敏,因此 access_token
+// 既不落日志、也不进对外文案。
func (a *Adapter) noteConnectFailure(err error) {
delay := a.reconnect.fail(a.policy)
- a.log.Warn("onebot connect failed; retrying", "err", err, "retryIn", delay)
+ reason := a.connectFailureReason(err)
+ a.mu.Lock()
+ a.lastConnectErr = reason
+ a.mu.Unlock()
+ a.log.Warn("onebot connect failed; retrying", "err", reason, "retryIn", delay)
}
diff --git a/plugins/channels/qq_obv11/session.go b/plugins/channels/qq_obv11/session.go
index 3f9b447..e68c546 100644
--- a/plugins/channels/qq_obv11/session.go
+++ b/plugins/channels/qq_obv11/session.go
@@ -330,6 +330,9 @@ func (a *Adapter) setConn(conn *wsConn) {
old := a.conn
a.conn = conn
a.connected = true
+ // 会话真的活了 ⇒ 上一次失败的原因不再是当前事实,立刻清掉,免得面板把一条已经
+ // 过期的原因挂在「已连接」旁边。
+ a.lastConnectErr = ""
a.mu.Unlock()
if old != nil && old != conn {
_ = old.Close()