根因: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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
141 lines
5.9 KiB
TypeScript
141 lines
5.9 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import {
|
||
adminChannelClose,
|
||
adminChannelOpen,
|
||
adminDeleteChannelAccount,
|
||
adminDeleteEmptyChannelAccounts,
|
||
type ChannelActionResult,
|
||
} from "../../../lib/api";
|
||
import { useAuditStore } from "../../audit";
|
||
|
||
/** 渠道动作执行器需要的最小依赖:状态写入口 + 审计 + 刷新。
|
||
* 抽成显式依赖是为了让「请求里到底带没带 accountId」能在 node 侧被断言
|
||
* (见 channelActions.test.ts)—— 把这段逻辑埋在 React hook 里就测不到,
|
||
* 而「accountId 被丢掉」正是这次要修的产品缺口本身。 */
|
||
export interface ChannelActionDeps {
|
||
log: (action: string, result: "ok" | "error") => void;
|
||
setBusy: (busy: string | null) => void;
|
||
setError: (error: string | null) => void;
|
||
setNotice: (notice: string | null) => void;
|
||
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` 给出时**只**作用于该账号(多账号平台逐条操作);
|
||
* 缺省时维持平台级旧行为(后端按自己的规则挑一条)。 */
|
||
toggle: (name: string, connect: boolean, accountId?: string) => void;
|
||
del: (platform: string, id: string) => void;
|
||
/** 删除该平台**无 id 的历史条目**(显式 `?empty=1`)。 */
|
||
delEmpty: (platform: string) => void;
|
||
}
|
||
|
||
export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
|
||
const { log, setBusy, setError, setNotice, reload } = deps;
|
||
|
||
const toggle = (name: string, connect: boolean, accountId?: string) => {
|
||
setBusy(name);
|
||
setError(null);
|
||
const call = connect ? adminChannelOpen(name, accountId) : adminChannelClose(name, accountId);
|
||
call
|
||
.then((res) => {
|
||
log(connect ? "channel.open" : "channel.close", "ok");
|
||
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
|
||
// 后端把解析结果放在 resolved 里,这里如实说出来。
|
||
const who = res?.resolved ?? name;
|
||
setNotice(connectDisconnectNotice(connect, who, res));
|
||
reload();
|
||
})
|
||
.catch((e: unknown) => {
|
||
log(connect ? "channel.open" : "channel.close", "error");
|
||
setError(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const del = (platform: string, id: string) => {
|
||
setBusy("__del__" + id);
|
||
setError(null);
|
||
setNotice(null);
|
||
adminDeleteChannelAccount(platform, id)
|
||
.then(() => {
|
||
log("channel.delete", "ok");
|
||
setNotice(`已删除账号 ${id} 并热更新 ${platform}`);
|
||
reload();
|
||
})
|
||
.catch((e: unknown) => {
|
||
log("channel.delete", "error");
|
||
setError(e instanceof Error ? e.message : "删除渠道账号失败");
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const delEmpty = (platform: string) => {
|
||
setBusy("__delEmpty__" + platform);
|
||
setError(null);
|
||
setNotice(null);
|
||
adminDeleteEmptyChannelAccounts(platform)
|
||
.then((res) => {
|
||
log("channel.delete", "ok");
|
||
// 删了几条必须说出来:无 id 条目彼此不可区分,「删掉无 id 条目」是批量动作,
|
||
// 条数对调用方是唯一能核对的东西。
|
||
const n = res?.removed ?? 0;
|
||
setNotice(`已删除 ${n} 条无 id 条目并热更新 ${platform}`);
|
||
reload();
|
||
})
|
||
.catch((e: unknown) => {
|
||
log("channel.delete", "error");
|
||
setError(e instanceof Error ? e.message : "删除无 id 条目失败");
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
return { toggle, del, delEmpty };
|
||
}
|
||
|
||
/** Shared connect/disconnect/delete actions + busy/error/notice state, reused by
|
||
* the list page and each adapter detail page. `reload` re-fetches the channel
|
||
* list after a mutation. */
|
||
export function useChannelActions(reload: () => void) {
|
||
const log = useAuditStore((s) => s.log);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
|
||
const actions = useMemo(
|
||
() => createChannelActions({ log, setBusy, setError, setNotice, reload }),
|
||
[log, reload],
|
||
);
|
||
|
||
return { busy, setBusy, error, setError, notice, setNotice, ...actions };
|
||
}
|