Files
Cyrene_v2/frontend/web/src/admin/modules/channels/useChannelActions.ts
T
AskaEth b4d55f412e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00

141 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}