fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
根因: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:
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) => {
|
||||
|
||||
@@ -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}`**(老后端/老客户端
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 "", ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),使面板
|
||||
// 渲染正确的每账号卡片。没有专用适配器表面的平台
|
||||
|
||||
@@ -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 的请求体:一个渠道
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user