fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。

改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。

线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。

凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。

验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
This commit is contained in:
AskaEth committed 2026-10-13 19:38:56 +08:00
1 parent d81635672e
commit b4d55f412e
23 files changed
+1440 -27

No files matched your search

@@ -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}
</span>
)}
<Tag tone={account.connected === true ? "ok" : account.connected === false ? "err" : "neutral"}>{state}</Tag>
<Tag tone={state.tone} title={state.hint}>{state.label}</Tag>
{/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事: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 };
}
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
@@ -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, ">连接中</span>"), "重试中的账号画「连接中」");
ok(!has(retrying, ">未连接</span>"), "重试中不许画成「未连接」(它是进行中,不是结论)");
ok(has(retrying, REASON), "重试中的原因随标签悬停一并给出(说不出为什么就等于没说)");
const ready = renderRow({ id: "9616b27a", config: {}, connected: false, ready: true });
ok(has(ready, ">等待接入</span>"), "监听就绪的账号画「等待接入」");
ok(!has(ready, ">未连接</span>"), "就绪等接入不许画成「未连接」");
// 回归:后端没这条状态面的账号逐字不变。
const plain = renderRow({ id: "9616b27a", config: {}, connected: false });
ok(has(plain, ">未连接</span>"), "没有状态面的账号仍是「未连接」");
}
console.log(`channel account row render test: ${checks} checks passed`);
@@ -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<string, string>();
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);
@@ -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<T>(
@@ -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 ? "已连接" : "未连接",
@@ -3,6 +3,8 @@ import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
const dot: Record<ChannelStatusKind, string> = {
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<ChannelStatusKind, string> = {
};
/** 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 (
<span className={`inline-flex flex-wrap items-center gap-2 ${className}`}>
<Tag tone={status.tone}>
<Tag tone={status.tone} title={status.hint ?? undefined}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} />
{status.label}
</Tag>
@@ -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) => {