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()