From 42279b7124a46a8a49d67111e89ea12b6f74b543 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 10:37:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=8C=89=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5/=E6=96=AD=E5=BC=80=20+=20=E5=8F=AF=E5=88=A0?= =?UTF-8?q?=E7=A9=BA=20id=20=E6=9D=A1=E7=9B=AE=EF=BC=88=E4=BF=AE=E4=B8=BB?= =?UTF-8?q?=E4=BA=BA=E7=9C=9F=E6=9C=BA=E8=B8=A9=E7=9A=84=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条 qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的 client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel 空 id = 新建),越改越多。 后端: - POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响); 按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409 - DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400; 无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id - 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty; GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知) - 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段 前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注 + 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。 测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、 另一条一次都没被碰」)。 变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId / 删空 id 退化成 ?id= / 空 id 行按普通行渲染)。 --- .../src/admin/modules/channels/AccountRow.tsx | 139 +++++++++ .../modules/channels/AdapterDetailPage.tsx | 31 +- .../src/admin/modules/channels/ListPage.tsx | 110 ++++--- .../channels/accountRow.render.test.ts | 137 +++++++++ .../modules/channels/channelActions.test.ts | 214 +++++++++++++ .../admin/modules/channels/channelStatus.ts | 28 ++ .../admin/modules/channels/channelUtils.ts | 19 ++ .../modules/channels/useChannelActions.ts | 132 +++++--- frontend/web/src/components/primitives.tsx | 5 +- frontend/web/src/lib/api.ts | 3 +- frontend/web/src/lib/api/adminChannels.ts | 46 ++- frontend/web/src/lib/types/admin.ts | 4 + .../admin_channel_account_status_test.go | 92 ++++++ .../gateway/admin_channel_action_test.go | 283 ++++++++++++++++++ .../services/gateway/admin_channel_status.go | 53 ++++ .../services/gateway/admin_channel_target.go | 206 +++++++++++++ .../admin_delete_channel_empty_test.go | 200 +++++++++++++ internal/services/gateway/admin_query.go | 3 +- .../gateway/admin_write_channel_config.go | 92 +++++- .../gateway/admin_write_llm_channels.go | 63 ++-- internal/services/gateway/audit.go | 23 +- 21 files changed, 1719 insertions(+), 164 deletions(-) create mode 100644 frontend/web/src/admin/modules/channels/AccountRow.tsx create mode 100644 frontend/web/src/admin/modules/channels/accountRow.render.test.ts create mode 100644 frontend/web/src/admin/modules/channels/channelActions.test.ts create mode 100644 internal/services/gateway/admin_channel_account_status_test.go create mode 100644 internal/services/gateway/admin_channel_action_test.go create mode 100644 internal/services/gateway/admin_channel_status.go create mode 100644 internal/services/gateway/admin_channel_target.go create mode 100644 internal/services/gateway/admin_delete_channel_empty_test.go diff --git a/frontend/web/src/admin/modules/channels/AccountRow.tsx b/frontend/web/src/admin/modules/channels/AccountRow.tsx new file mode 100644 index 0000000..453b089 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/AccountRow.tsx @@ -0,0 +1,139 @@ +import type { AdminChannelAccount } from "../../../lib/types"; +import { Button, Tag } from "../../../components/primitives"; +import { authKeyOf } from "../channelFieldRules"; +import { adapterNameOf } from "./channelUtils"; +import { accountActionGate, shortAccountID, type ChannelStatus } from "./channelStatus"; + +/** 一条渠道账号在活动适配器列表里的行。 + * + * 为什么每个账号必须自成一行:旧面板每平台只有一组「连接/断开」,点下去打的是 + * 后端**自己挑**的一条账号 —— 主人有两条 qq-obv11 时,他点「连接」看到「已连接」, + * 实际连的却是那条 mode=server、listen_addr 为空的历史条目(新账号永远轮不到)。 + * 逐账号按钮把「连的是哪一条」变成用户的选择,`accountId` 随请求发出; + * 按钮旁的小字进一步说明本次会作用在哪个账号上。 */ +export function AccountRow({ + platform, + account, + status, + busy, + onConnect, + onDisconnect, + onConfig, + onDelete, + onDeleteEmpty, + onLog, + onDetail, +}: { + platform: string; + account: AdminChannelAccount; + status: ChannelStatus; + busy: string | null; + onConnect: () => void; + onDisconnect: () => void; + onConfig: () => void; + onDelete: () => void; + onDeleteEmpty: () => void; + onLog: () => void; + onDetail: () => void; +}): React.ReactNode { + const id = account.id?.trim() ?? ""; + const noID = id === ""; + const adapter = adapterNameOf(platform, id); + const short = shortAccountID(id); + const label = account.botNickname || (account.botUsername ? `@${account.botUsername}` : short); + const authKey = authKeyOf(platform); + const authLabel = authKey === "bot_token" ? "token" : authKey; + const raw = account.config?.[authKey]; + const authSet = typeof raw === "string" && raw.trim() !== ""; + const gate = accountActionGate(status, account.connected); + const state = connectionTag(account.connected); + const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined; + + return ( + + + {noID ? null : ( + + id {short} + + )} + {state} + + {authSet ? `${authLabel} 已配置` : `${authLabel} 未填`} + + + + + {accountActionHint(platform, id, account.connected)} + + + {noID ? ( + <> + {/* 空 id 条目:面板既改不动(保存会被后端当成新账号另开一条)也删不掉 + (按 id 删除没有 id 可给),所以这里显著标注 + 给显式删除入口。 */} + + 无 id 的历史条目 + + + + ) : ( + <> + + + + )} + + ); +} + +/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */ +function connectionTag(connected: boolean | undefined): string { + if (connected === true) return "已连接"; + if (connected === false) return "未连接"; + return "状态未知"; +} + +/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案: + * 用户点之前就该看见自己点的是哪一条;空 id 条目没有 id 可念,就念它的后果 + * (它就是该平台的裸适配器)。 */ +export function accountActionHint(platform: string, id: string, connected?: boolean): string { + if (id === "") { + return connected === true + ? `断开无 id 条目(裸适配器 ${platform})` + : `将在无 id 条目(裸适配器 ${platform})上连接`; + } + return connected === true ? `断开账号 ${shortAccountID(id)}` : `将在账号 ${shortAccountID(id)} 上连接`; +} diff --git a/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx b/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx index 0f41f86..4d45f8d 100644 --- a/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx +++ b/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx @@ -12,7 +12,7 @@ import { KookPanel } from "./KookPanel"; import { WeixinLogin } from "./WeixinLogin"; import { GenericAdapterPanel } from "./GenericAdapterPanel"; import { platformOf } from "./channelUtils"; -import { channelStatus, type ChannelStatus } from "./channelStatus"; +import { channelStatus, shortAccountID, type ChannelStatus } from "./channelStatus"; import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView"; import { useChannelActions } from "./useChannelActions"; @@ -27,7 +27,7 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack const { data, error: loadErr, reload } = useAsyncData(adminListChannels, []); useAutoReload(reload, 10000); - const { busy, error, notice, toggle, del } = useChannelActions(reload); + const { busy, error, notice, toggle, del, delEmpty } = useChannelActions(reload); const record = (data ?? []).find((c) => platformOf(c.name) === platform); const status = channelStatus(data, loadErr, record); @@ -67,6 +67,7 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack busy={busy} toggle={toggle} del={del} + delEmpty={delEmpty} /> @@ -104,6 +105,7 @@ function AdapterStatusCard({ busy, toggle, del, + delEmpty, }: { label: string; platform: string; @@ -111,11 +113,24 @@ function AdapterStatusCard({ account?: AdminChannelAccount; status: ChannelStatus; busy: string | null; - toggle: (name: string, connect: boolean) => void; + toggle: (name: string, connect: boolean, accountId?: string) => void; del: (platform: string, id: string) => void; + delEmpty: (platform: string) => void; }): React.ReactNode { const unknown = status.kind === "unknown"; const unknownTitle = unknown ? "连接状态未知,已禁用;请先确认后端在线" : undefined; + // 本页是**某一条账号**的专属页,因此连接/断开必须指名它(旧代码只发平台名, + // 多账号平台上会打到后端自己挑的那条)。accountId 为空 = 该平台的裸适配器, + // 也就是那条无 id 条目本身。 + const target = accountId ?? ""; + const emptyIDEntry = target === "" && account !== undefined && (account.id ?? "") === ""; + // 「本次会作用在哪一条」在专属页上同样要写清:旧代码只发平台名,多账号平台上 + // 连接/断开打到的是后端自己挑的那条,与用户当前看的这条可能不是同一条。 + const scope = accountId + ? `账号 ${shortAccountID(accountId)}` + : emptyIDEntry + ? `无 id 条目(裸适配器 ${platform})` + : "该平台的默认账号"; return (
@@ -131,7 +146,7 @@ function AdapterStatusCard({ variant="soft" disabled={busy !== null || !status.canConnect} title={unknownTitle} - onClick={() => toggle(platform, true)} + onClick={() => toggle(platform, true, accountId)} > 连接 @@ -140,7 +155,7 @@ function AdapterStatusCard({ variant="ghost" disabled={busy !== null || !status.canDisconnect} title={unknownTitle} - onClick={() => toggle(platform, false)} + onClick={() => toggle(platform, false, accountId)} > 断开 @@ -148,8 +163,12 @@ function AdapterStatusCard({ + ) : emptyIDEntry ? ( + ) : null} - 连接/断开作用于 {platform} adapter,删除作用于账号 {accountId ?? "default"}。 + 连接/断开只作用于{scope};删除只作用于它自己。 {unknown ? ( 读取不到连接状态,连接/断开已禁用;请先确认后端在线再重试。 ) : null} diff --git a/frontend/web/src/admin/modules/channels/ListPage.tsx b/frontend/web/src/admin/modules/channels/ListPage.tsx index d7b0f6a..dc81dcf 100644 --- a/frontend/web/src/admin/modules/channels/ListPage.tsx +++ b/frontend/web/src/admin/modules/channels/ListPage.tsx @@ -4,13 +4,20 @@ import type { AdminChannelRecord } from "../../../lib/types"; import { useAsyncData } from "../../../lib/useAsyncData"; import { useAutoReload } from "../../hooks"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../../components"; -import { Button, Tag } from "../../../components/primitives"; +import { Button } from "../../../components/primitives"; import { PLATFORM_LABEL } from "../channelFields"; -import { authKeyOf } from "../channelFieldRules"; import { ChannelConfigForm } from "../channelConfigForm"; import { ChannelLogPanel } from "./channelLogPanel"; import { ChannelIdentityTest } from "./channelIdentityTest"; -import { adapterNameOf, buildCfgTarget, configuredPlatforms, platformOf, type CfgTarget } from "./channelUtils"; +import { AccountRow } from "./AccountRow"; +import { + adapterNameOf, + buildCfgTarget, + configuredPlatforms, + platformConnectHint, + platformOf, + type CfgTarget, +} from "./channelUtils"; import { channelStatus } from "./channelStatus"; import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView"; import { useChannelActions } from "./useChannelActions"; @@ -21,7 +28,7 @@ import type { AdapterNav } from "./useAdapterNav"; export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): React.ReactNode { const { data, loading, error: loadErr, reload } = useAsyncData(adminListChannels, []); useAutoReload(reload, 10000); - const { busy, setBusy, error, setError, notice, setNotice, toggle, del } = useChannelActions(reload); + const { busy, setBusy, error, setError, notice, setNotice, toggle, del, delEmpty } = useChannelActions(reload); const [cfg, setCfg] = useState(null); const [logFor, setLogFor] = useState<{ adapter: string | null } | null>(null); @@ -43,6 +50,7 @@ export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): R openConfig, toggle, del, + delEmpty, openDetail: (a) => navigate({ kind: "detail", adapter: a }), openLog: (a) => setLogFor({ adapter: a }), }; @@ -99,8 +107,10 @@ interface ListCtx { busy: string | null; /** `accountId` 指定要编辑的账号(多账号平台逐条编辑);缺省 = 该平台第一条。 */ openConfig: (name: string, accountId?: string) => void; - toggle: (name: string, connect: boolean) => void; + /** `accountId` 给出时只作用于该账号;缺省 = 平台级(后端按规则挑一条)。 */ + toggle: (name: string, connect: boolean, accountId?: string) => void; del: (platform: string, id: string) => void; + delEmpty: (platform: string) => void; openDetail: (adapter: string) => void; openLog: (adapter: string) => void; } @@ -109,72 +119,60 @@ function columnsOf(ctx: ListCtx): AdminColumn[] { return [ { key: "name", title: "平台", render: (c) => {PLATFORM_LABEL[platformOf(c.name)] ?? c.name} }, { key: "connected", title: "状态", render: (c) => }, - { key: "accounts", title: "配置账号(点击进入其专属页)", render: (c) => }, - { key: "actions", title: "操作", render: (c) => }, + { key: "accounts", title: "配置账号(逐条连接/断开/配置)", render: (c) => }, + { key: "actions", title: "平台级操作", render: (c) => }, ]; } -/** One configured account per entry: enter its detail page, log, delete. */ +/** One row per configured account: it carries its own connect/disconnect (with the + * accountId), so「连接」never silently lands on another account. */ function AccountsCell({ record, ctx }: { record: AdminChannelRecord; ctx: ListCtx }): React.ReactNode { if (!record.accounts || record.accounts.length === 0) { return —; } - // 「凭据是否已配」必须看该平台**真正读取**的键:qq-obv11 读 access_token、webhook 读 - // secret,用 bot_token 去判断会让配好的渠道永远显示「未填」。 - const authKey = authKeyOf(platformOf(record.name)); - const authLabel = authKey === "bot_token" ? "token" : authKey; + const platform = platformOf(record.name); + const status = channelStatus(ctx.data, ctx.error, record); return ( - - {record.accounts.map((a) => { - const adapter = adapterNameOf(platformOf(record.name), a.id); - const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default"); - const raw = a.config?.[authKey]; - const tokenMasked = typeof raw === "string" && raw.trim() !== ""; - return ( - - - - {tokenMasked ? `${authLabel} 已配置` : `${authLabel} 未填`} - - - - {a.id ? ( - - ) : null} - - ); - })} + + {record.accounts.map((a, idx) => ( + ctx.toggle(record.name, true, a.id)} + onDisconnect={() => ctx.toggle(record.name, false, a.id)} + onConfig={() => ctx.openConfig(record.name, a.id)} + onDelete={() => ctx.del(platform, a.id)} + onDeleteEmpty={() => ctx.delEmpty(platform)} + onLog={() => ctx.openLog(adapterNameOf(platform, a.id))} + onDetail={() => ctx.openDetail(adapterNameOf(platform, a.id))} + /> + ))} ); } -/** 连接 / 断开 / 配置. Gated by the row's tri-state so an unread state cannot be - * acted on as if it read "未连接". */ +/** 平台级 连接 / 断开 / 配置。这些按钮**不带** accountId,目标由后端规则决定 + * (精确同名适配器优先,否则该平台第一条账号),因此下方小字必须说清它会作用在 + * 哪一条 —— 否则主人会把「已连接」误读成「连上了我想连的那条」。 */ function ActionsCell({ record, ctx }: { record: AdminChannelRecord; ctx: ListCtx }): React.ReactNode { const st = channelStatus(ctx.data, ctx.error, record); return ( - - - - + + + + + + + {platformConnectHint(record)} ); } diff --git a/frontend/web/src/admin/modules/channels/accountRow.render.test.ts b/frontend/web/src/admin/modules/channels/accountRow.render.test.ts new file mode 100644 index 0000000..1cea79d --- /dev/null +++ b/frontend/web/src/admin/modules/channels/accountRow.render.test.ts @@ -0,0 +1,137 @@ +// 渠道列表「每账号一行」的**真实渲染**测试。 +// +// 为什么需要它:这次的产品缺口在渲染层的表现是「每平台只有一组连接/断开」—— +// 主人点了「连接」,界面回「已连接」,但连的是后端挑的另一条账号。修好之后, +// 每行必须自带连接/断开 + 「本次会作用在哪一条账号」的小字,空 id 的历史条目 +// 必须有显著标注和删除入口。这些是**画出来才算数**的事,纯逻辑测不了。 +// +// 跑法(同 channelConfigForm.render.test.ts): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/channels/accountRow.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-channel-account-row.test.cjs \ +// && node /tmp/cyrene-channel-account-row.test.cjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { AdminChannelAccount, AdminChannelRecord } from "../../../lib/types"; +import { AccountRow } from "./AccountRow"; +import { platformConnectHint } from "./channelUtils"; +import type { ChannelStatus } from "./channelStatus"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +/** 平台列表已读好的常态(未知/读取失败的门控在 channelActions.test.ts 里测)。 */ +const READY: ChannelStatus = { + kind: "disconnected", + label: "未连接", + tone: "err", + hint: null, + stale: false, + canConnect: true, + canDisconnect: false, +}; + +function renderRow(account: AdminChannelAccount, platform = "qq-obv11"): string { + const noop = (): void => {}; + return renderToStaticMarkup( + createElement(AccountRow, { + platform, + account, + status: READY, + busy: null, + onConnect: noop, + onDisconnect: noop, + onConfig: noop, + onDelete: noop, + onDeleteEmpty: noop, + onLog: noop, + onDetail: noop, + }), + ); +} + +/** 被禁用按钮的标记。注意 Button 的 className 里本来就有 `disabled:opacity-50` + * 这类 Tailwind 片段,所以只能用 React 实际渲染出的 `disabled=""` 属性来判定。 */ +const DISABLED = 'disabled=""'; + +/** 取出渲染出的某个按钮元素(用于断言它是否被禁用)。 */ +function buttonHTML(html: string, label: string): string { + const end = html.indexOf(`>${label}`); + if (end < 0) throw new Error(`FAILED: 按钮「${label}」没有渲染出来`); + return html.slice(html.lastIndexOf("${label}`), `账号行渲染「${label}」`); + } + ok(has(html, "将在账号 9616b27a… 上连接"), "「连接」旁的小字点名本次会连哪一条账号"); + ok(has(html, "状态未知"), "后端未报告该账号的活适配器 ⇒ 显示「状态未知」而不是「未连接」"); + ok(!has(html, "无 id 的历史条目"), "有 id 的普通行不做空 id 标注"); + ok(!has(html, "删除无 id 条目"), "有 id 的普通行不给「删无 id 条目」入口"); + // 未知状态:给「连接」,不给「断开」(断一条状态未知的账号是盲操作)。 + ok(!has(buttonHTML(html, "连接"), DISABLED), "状态未知仍可点「连接」"); + ok(has(buttonHTML(html, "断开"), DISABLED), "状态未知时「断开」被禁用"); + ok(has(html, "data-account-id=\"9616b27a5c1d\""), "行上带账号 id,便于排查"); +} + +// ── ② 已连接 / 未连接:按钮门控与提示随该账号自己的状态走 ────────────────────── +{ + const up = renderRow({ id: "9616b27a", config: {}, connected: true }); + ok(has(up, "已连接"), "connected=true 显示「已连接」"); + ok(has(up, "断开账号 9616b27a"), "已连接时小字说的是「断开账号 X」"); + ok(has(buttonHTML(up, "连接"), DISABLED), "已连接时「连接」被禁用"); + ok(!has(buttonHTML(up, "断开"), DISABLED), "已连接时「断开」可点"); + + const down = renderRow({ id: "9616b27a", config: {}, connected: false }); + ok(has(down, "未连接"), "connected=false 显示「未连接」"); + ok(has(down, "将在账号 9616b27a 上连接"), "未连接时小字说的是「将在账号 X 上连接」"); + ok(!has(buttonHTML(down, "连接"), DISABLED), "确知未连接时「连接」可点"); + ok(has(buttonHTML(down, "断开"), DISABLED), "确知未连接时「断开」被禁用"); +} + +// ── ③ 空 id 的历史条目:显著标注 + 显式删除入口(按 id 的操作一律不给) ──────── +{ + const html = renderRow({ id: "", config: { mode: "server", listen_addr: "" } }); + ok(has(html, "无 id 的历史条目"), "空 id 行显著标注"); + ok(has(html, "后端只按 id 定位账号"), "标注里给出悬停说明(为什么它是个问题)"); + ok(has(html, ">删除无 id 条目"), "空 id 行提供删除入口(走后端 ?empty=1)"); + ok(!has(html, ">配置"), "空 id 行不给「配置」(保存会另开一条,越改越多)"); + ok(!has(html, ">删除"), "空 id 行不给「按 id 删除」(没有 id 可给)"); + ok(has(html, "将在无 id 条目(裸适配器 qq-obv11)上连接"), "空 id 行的小字明说它是裸适配器,不渲染成空串"); + ok(has(html, "data-account-id=\"\""), "空 id 行的 data 属性为空,可被测试/排查定位"); +} + +// ── ④ 平台级按钮的小字:说清它会作用在哪一条账号 ────────────────────────────── +{ + const one = platformConnectHint({ name: "qq-obv11", connected: false, accounts: [{ id: "9616b27a", config: {} }] }); + ok(has(one, "平台级 = 账号 9616b27a"), "单账号平台点名平台级连接就是这一条"); + + const two = platformConnectHint({ + name: "qq-obv11", + connected: false, + accounts: [{ id: "aaaa1111", config: {} }, { id: "9616b27a", config: {} }], + } as AdminChannelRecord); + ok(has(two, "多账号(2 条)"), "多账号平台说明平台级按钮的目标不确定"); + ok(has(two, "请用每行「连接」点名账号"), "多账号平台把用户导向逐账号按钮"); + + const none = platformConnectHint({ name: "qq-obv11", connected: false, accounts: [] }); + ok(has(none, "平台级连接会失败"), "没有账号时明说平台级连接会失败"); +} + +console.log(`channel account row render test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/channels/channelActions.test.ts b/frontend/web/src/admin/modules/channels/channelActions.test.ts new file mode 100644 index 0000000..d1bcdf4 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelActions.test.ts @@ -0,0 +1,214 @@ +// 「accountId 到底有没有随请求发出去」的**真实请求**测试。 +// +// 为什么必须存在:这次的原始缺口就是「按平台连、且连的是第一条」——面板渲染得再好看, +// 只要请求体里没有 accountId,后端就只能自己挑一条账号。所以这里不看组件、不靠记忆, +// 而是 stub 掉 globalThis.fetch,直接读**真正发出的** URL 与请求体。 +// +// 同时覆盖 `createChannelActions`(面板按钮实际调用的那层):验证逐账号按钮确实把 +// accountId 传到了 API 层,以及显式 `?empty=1` 删除是唯一表达「删无 id 条目」的方式。 +// +// 跑法(与 channelConfigForm.render.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelActions.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-channel-actions.test.cjs \ +// && node /tmp/cyrene-channel-actions.test.cjs +// +// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内 +// 导入、不 import node:*。 + +import { + adminChannelClose, + adminChannelOpen, + adminDeleteChannelAccount, + adminDeleteEmptyChannelAccounts, + channelActionBody, +} from "../../../lib/api/adminChannels"; +import { createChannelActions } from "./useChannelActions"; +import { accountActionGate, shortAccountID, type ChannelStatus } from "./channelStatus"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${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)})`); +} + +// ── 请求捕获装置 ───────────────────────────────────────────────────────────── +// request() 会读 localStorage(token/apiKey)与 fetch,因此先把这两个全局装好; +// 模块在 import 时并不触碰它们(只在调用时读),所以静态 import 是安全的。 + +interface Captured { + url: string; + method: string; + body: string; +} + +const captured: Captured[] = []; + +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"), + body: typeof init?.body === "string" ? init.body : "", + }); + // 只回一个满足 request() 读取面的最小响应对象(不依赖 fetch 的 Response 实现)。 + const payload = { data: { name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true, removed: 2 } }; + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(payload), + } as unknown as Response); + }, + }); +} + +installStubs(); + +function lastCall(): Captured { + const call = captured[captured.length - 1]; + if (!call) throw new Error("FAILED: 没有任何请求被发出"); + return call; +} + +/** 从捕获到的请求体里取出 accountId(缺省时返回 undefined)。 */ +function accountIdOf(body: string): string | undefined { + const parsed = JSON.parse(body) as { accountId?: string }; + return parsed.accountId; +} + +// ── ① 纯构造:缺省时逐字维持旧契约 `{name}`,给出时才带 accountId ────────────── +{ + eq(JSON.stringify(channelActionBody("qq-obv11")), '{"name":"qq-obv11"}', "缺省 accountId 的请求体逐字不变"); + eq(JSON.stringify(channelActionBody("qq-obv11", "")), '{"name":"qq-obv11"}', "空 accountId 等同于缺省"); + eq(JSON.stringify(channelActionBody("qq-obv11", " ")), '{"name":"qq-obv11"}', "纯空白 accountId 等同于缺省"); + eq(accountIdOf(JSON.stringify(channelActionBody("qq-obv11", " 9616b27a "))), "9616b27a", "accountId 去除首尾空白后发出"); + eq(channelActionBody("qq-obv11", "default").accountId, "default", "default 是显式的默认账号定位器,必须原样发出"); +} + +// ── ② 真实请求:open/close 带上 accountId,且 URL 未变 ───────────────────────── +{ + captured.length = 0; + void adminChannelOpen("qq-obv11", "9616b27a").catch(() => {}); + const open = lastCall(); + eq(open.url, "/api/v1/admin/channels/open", "open 打到既有路径(契约未变)"); + eq(open.method, "POST", "open 是 POST"); + eq(accountIdOf(open.body), "9616b27a", "open 请求体带上 accountId(否则后端只能自己挑一条)"); + + void adminChannelClose("qq-obv11", "9616b27a").catch(() => {}); + const close = lastCall(); + eq(close.url, "/api/v1/admin/channels/close", "close 打到既有路径"); + eq(accountIdOf(close.body), "9616b27a", "close 请求体带上 accountId"); + + // 平台级(缺省)调用仍是旧请求体:老后端/老客户端零影响。 + void adminChannelOpen("qq-obv11").catch(() => {}); + eq(lastCall().body, '{"name":"qq-obv11"}', "平台级 open 的请求体不带 accountId"); +} + +// ── ③ 真实请求:删除只有两种显式写法 ────────────────────────────────────────── +{ + captured.length = 0; + void adminDeleteChannelAccount("qq-obv11", "9616b27a").catch(() => {}); + eq(lastCall().url, "/api/v1/admin/channels/qq-obv11/config?id=9616b27a", "按 id 删除走 ?id="); + + captured.length = 0; + void adminDeleteEmptyChannelAccounts("qq-obv11").catch(() => {}); + const delEmpty = lastCall(); + eq(delEmpty.url, "/api/v1/admin/channels/qq-obv11/config?empty=1", "删无 id 条目必须显式 ?empty=1"); + ok(!delEmpty.url.includes("id="), "删无 id 条目**不能**靠「id 为空」表达"); +} + +// ── ④ 面板按钮真正调用的那一层:createChannelActions 必须把 accountId 传下去 ── +{ + 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; + }, + }); + + captured.length = 0; + actions.toggle("qq-obv11", true, "9616b27a"); + eq(accountIdOf(lastCall().body), "9616b27a", "逐账号「连接」按钮把 accountId 传到了 API 层"); + + captured.length = 0; + actions.toggle("qq-obv11", false, "9616b27a"); + eq(accountIdOf(lastCall().body), "9616b27a", "逐账号「断开」按钮把 accountId 传到了 API 层"); + + captured.length = 0; + actions.toggle("qq-obv11", true); + eq(lastCall().body, '{"name":"qq-obv11"}', "平台级按钮不带 accountId(后端按自己的规则挑一条)"); + + captured.length = 0; + actions.delEmpty("qq-obv11"); + eq(lastCall().url, "/api/v1/admin/channels/qq-obv11/config?empty=1", "「删除无 id 条目」走显式 ?empty=1"); + + // 异步收尾(fetch 的回调在微任务里):等一拍后核对提示文案里回显了**实际被驱动的 + // 适配器**与删除条数 —— 多账号平台上「已连接」不写清是哪一个就等于没说。 + setTimeout(() => { + ok( + notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")), + `连接成功提示必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`, + ); + ok( + notices.some((n) => n.includes("已删除 2 条无 id 条目")), + `删除提示必须报出实际条数,got ${JSON.stringify(notices)}`, + ); + eq(errors.length, 0, "这些成功路径不该产生错误提示"); + eq(reloads, 4, "每次成功动作后都刷新列表"); + console.log(`channel actions test: ${checks} checks passed`); + }, 20); +} + +// ── ⑤ 每账号门控:状态未知只给「连接」,绝不给「断开」 ───────────────────────── +{ + const ready: ChannelStatus = { + kind: "disconnected", + label: "未连接", + tone: "err", + hint: null, + stale: false, + canConnect: true, + canDisconnect: false, + }; + const unknownList: ChannelStatus = { ...ready, kind: "unknown", canConnect: false, canDisconnect: false }; + + eq(accountActionGate(ready, true).canConnect, false, "已连接的账号不再给「连接」"); + eq(accountActionGate(ready, true).canDisconnect, true, "已连接的账号给「断开」"); + eq(accountActionGate(ready, false).canDisconnect, false, "确知未连接的账号不给「断开」"); + eq(accountActionGate(ready, undefined).canConnect, true, "状态未知的账号仍可「连接」(点它正是把它变活)"); + eq(accountActionGate(ready, undefined).canDisconnect, false, "状态未知的账号不给「断开」(断它是盲操作,后端也会拒)"); + eq(accountActionGate(unknownList, true).canDisconnect, false, "列表都读不到时两个动作都禁用"); + eq(accountActionGate(unknownList, undefined).canConnect, false, "列表都读不到时连「连接」也禁用"); + + eq(shortAccountID("9616b27a5c1d"), "9616b27a…", "长 id 截断显示"); + eq(shortAccountID("conf0"), "conf0", "短 id 原样显示"); + eq(shortAccountID(""), "(无 id)", "空 id 明说「无 id」而不是渲染成空串"); + eq(shortAccountID(undefined), "(无 id)", "缺省 id 同样明说"); +} diff --git a/frontend/web/src/admin/modules/channels/channelStatus.ts b/frontend/web/src/admin/modules/channels/channelStatus.ts index 906333e..5743eb1 100644 --- a/frontend/web/src/admin/modules/channels/channelStatus.ts +++ b/frontend/web/src/admin/modules/channels/channelStatus.ts @@ -101,3 +101,31 @@ export function channelStatus( canDisconnect: connected, }; } + +/** 每个账号一行连接/断开时的门控(多账号平台逐条操作用)。 + * + * `connected` 是该账号**自己**的实时状态(`AdminChannelAccount.connected`): + * - `true` → 已连接:只给「断开」; + * - `false` → 确知未连接:只给「连接」; + * - 缺省 → 后端没有该账号的活适配器 ⇒ 状态**未知**:只给「连接」(点它正是把它 + * 变成活的),**不给「断开」** —— 断一条状态未知的账号是盲操作, + * 后端也会以 404/409 拒绝,界面不该给一个注定失败的按钮。 + * + * 平台级状态(列表读取失败/读取中/记录不在)优先:读都读不到时两个动作都禁用, + * 与 channelStatus 的平台级门控同一口径。 */ +export function accountActionGate( + status: ChannelStatus, + connected?: boolean, +): { canConnect: boolean; canDisconnect: boolean } { + if (!status.canConnect && !status.canDisconnect) return { canConnect: false, canDisconnect: false }; + if (connected === true) return { canConnect: false, canDisconnect: true }; + return { canConnect: true, canDisconnect: false }; +} + +/** 账号 id 的短显示(面板里一行里塞得下):空 id 是「无 id 的历史条目」这一事实本身, + * 必须原样说出来,不能渲染成空串让主人以为「这行没问题」。 */ +export function shortAccountID(id: string | undefined, keep = 8): string { + const t = (id ?? "").trim(); + if (!t) return "(无 id)"; + return t.length <= keep ? t : `${t.slice(0, keep)}…`; +} diff --git a/frontend/web/src/admin/modules/channels/channelUtils.ts b/frontend/web/src/admin/modules/channels/channelUtils.ts index c094039..8f2ffd3 100644 --- a/frontend/web/src/admin/modules/channels/channelUtils.ts +++ b/frontend/web/src/admin/modules/channels/channelUtils.ts @@ -2,6 +2,7 @@ import type { AdminChannelRecord } from "../../../lib/types"; import { PLATFORM_FIELDS } from "../channelFields"; import { defaultSelection } from "../channelFieldRules"; import { channelPersonaOf } from "./channelPersona"; +import { shortAccountID } from "./channelStatus"; import type { ChannelConfigInitial } from "../channelConfigForm"; /** Platform part of an adapter name ("kook:conf0" → "kook"). */ @@ -140,6 +141,24 @@ export function limitRequestBody( return { ok: true, body }; } +/** 平台级「连接」按钮下方的小字:说清**它本次会作用在哪一条账号**。 + * + * 这不是装饰:平台级按钮不带 accountId,后端只能自己挑一条 —— 单账号时它等价于 + * 「就是这一条」,多账号时却是一个由后端规则决定的、用户无法预判的目标。把这些 + * 如实写出来,主人才不会以为平台级「连接」点的是他心里那条。 + * + * 0 条账号时更要明说:那次调用注定失败(没有可驱动的账号)。 */ +export function platformConnectHint(record: AdminChannelRecord): string { + const accts = record.accounts ?? []; + if (accts.length === 0) return "该平台还没有账号:平台级连接会失败,请先新建账号"; + if (accts.length === 1) { + // 后端对「无 accountId」的解析优先级是:精确同名适配器 → 该平台第一条账号。 + // 只要这条账号的适配器存在,两者就是同一条,因此可以直接点名。 + return `平台级 = 账号 ${shortAccountID(accts[0].id)}(无 accountId 时的唯一一条)`; + } + return `多账号(${accts.length} 条):平台级按钮由后端规则挑一条,不保证是你想的这条 → 请用每行「连接」点名账号`; +} + /** The set of platforms that have at least one configured account. */ export function configuredPlatforms(records: AdminChannelRecord[]): AdminChannelRecord[] { return records.filter((c) => (c.accounts?.length ?? 0) > 0); diff --git a/frontend/web/src/admin/modules/channels/useChannelActions.ts b/frontend/web/src/admin/modules/channels/useChannelActions.ts index 0940d01..e6663a3 100644 --- a/frontend/web/src/admin/modules/channels/useChannelActions.ts +++ b/frontend/web/src/admin/modules/channels/useChannelActions.ts @@ -1,11 +1,97 @@ -import { useCallback, useState } from "react"; +import { useMemo, useState } from "react"; import { - adminChannelOpen, adminChannelClose, + adminChannelOpen, adminDeleteChannelAccount, + adminDeleteEmptyChannelAccounts, } 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; +} + +/** 与 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(`${connect ? "已连接" : "已断开"} ${who}`); + 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. */ @@ -15,44 +101,10 @@ export function useChannelActions(reload: () => void) { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); - const toggle = useCallback( - (name: string, connect: boolean) => { - setBusy(name); - setError(null); - const call = connect ? adminChannelOpen(name) : adminChannelClose(name); - call - .then(() => { - log(connect ? "channel.open" : "channel.close", "ok"); - reload(); - }) - .catch((e: unknown) => { - log(connect ? "channel.open" : "channel.close", "error"); - setError(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`); - }) - .finally(() => setBusy(null)); - }, - [reload, log], + const actions = useMemo( + () => createChannelActions({ log, setBusy, setError, setNotice, reload }), + [log, reload], ); - const del = useCallback( - (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)); - }, - [reload, log], - ); - - return { busy, setBusy, error, setError, notice, setNotice, toggle, del }; + return { busy, setBusy, error, setError, notice, setNotice, ...actions }; } diff --git a/frontend/web/src/components/primitives.tsx b/frontend/web/src/components/primitives.tsx index 7121da5..d49e62c 100644 --- a/frontend/web/src/components/primitives.tsx +++ b/frontend/web/src/components/primitives.tsx @@ -44,6 +44,8 @@ export interface TagProps { tone?: TagTone; children: ReactNode; className?: string; + /** 原生 title:给标签补充「为什么」的悬停说明(如无 id 条目的后果)。 */ + title?: string; } const tagTone: Record = { @@ -54,9 +56,10 @@ const tagTone: Record = { neutral: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]", }; -export function Tag({ tone = "neutral", children, className = "" }: TagProps): React.ReactNode { +export function Tag({ tone = "neutral", children, className = "", title }: TagProps): React.ReactNode { return ( {children} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 56dfe7b..60bbdea 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -100,11 +100,12 @@ export { adminChannelPlatformView, adminChannelTest, adminDeleteChannelAccount, + adminDeleteEmptyChannelAccounts, adminSaveChannelConfig, adminWeixinLoginQR, adminWeixinLoginStatus, } from "./api/adminChannels"; -export type { ChannelConfigSaveResult } from "./api/adminChannels"; +export type { ChannelActionResult, ChannelConfigSaveResult } from "./api/adminChannels"; export { adminBackthink, diff --git a/frontend/web/src/lib/api/adminChannels.ts b/frontend/web/src/lib/api/adminChannels.ts index 42e8028..80666bb 100644 --- a/frontend/web/src/lib/api/adminChannels.ts +++ b/frontend/web/src/lib/api/adminChannels.ts @@ -15,20 +15,43 @@ export interface ChannelConfigSaveResult { path: string; platform: string; accountId: string; + /** 仅 `?empty=1` 的删除会带上:本次实际删掉的无 id 条目数。 */ + removed?: number; + empty?: boolean; } -/** POST /api/v1/admin/channels/open — connects a registered channel adapter. */ -export function adminChannelOpen(name: string): Promise<{ name: string; connected: boolean }> { - return request<{ name: string; connected: boolean }>("/admin/channels/open", { +/** 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. */ +export interface ChannelActionResult { + name: string; + resolved?: string; + accountId?: string; + connected: boolean; +} + +/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端 + * 仍互通);给出时才是多账号定位,后端据此**只**驱动该账号。 */ +export function channelActionBody(name: string, accountId?: string): { name: string; accountId?: string } { + const id = accountId?.trim(); + return id ? { name, accountId: id } : { name }; +} + +/** POST /api/v1/admin/channels/open — connects a registered channel adapter. + * Pass `accountId` on a multi-account platform so the panel connects the account + * the user actually clicked instead of whatever the backend picks first. */ +export function adminChannelOpen(name: string, accountId?: string): Promise { + return request("/admin/channels/open", { method: "POST", - body: JSON.stringify({ name }), + body: JSON.stringify(channelActionBody(name, accountId)), }); } /** POST /api/v1/admin/channels/close — disconnects a registered channel adapter. */ -export function adminChannelClose(name: string): Promise<{ name: string; connected: boolean }> { - return request<{ name: string; connected: boolean }>("/admin/channels/close", { +export function adminChannelClose(name: string, accountId?: string): Promise { + return request("/admin/channels/close", { method: "POST", - body: JSON.stringify({ name }), + body: JSON.stringify(channelActionBody(name, accountId)), }); } /** POST /api/v1/admin/channels/test — structural identity test (actor × whitelist). */ @@ -88,6 +111,15 @@ export function adminDeleteChannelAccount(platform: string, id: string): Promise method: "DELETE", }); } +/** DELETE /api/v1/admin/channels/{platform}/config?empty=1 — 删除该平台**无 id 的历史条目** + * (面板既改不动也删不掉它们:带空 id 保存会被后端当成新账号另开一条)。 + * 这个 `empty=1` 是**显式**的:绝不能靠「id 为空」来表达同一意图,那样一次误发的 + * 空请求就会静默批量删除。 */ +export function adminDeleteEmptyChannelAccounts(platform: string): Promise { + return request(`/admin/channels/${platform}/config?empty=1`, { + method: "DELETE", + }); +} /** GET /api/v1/admin/channels/{platform}/view — one platform account's aggregated * panel data (friends / servers / channels / members / private chats), owner only. */ export function adminChannelPlatformView(platform: string, accountId?: string): Promise { diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts index 791b7ad..96c7458 100644 --- a/frontend/web/src/lib/types/admin.ts +++ b/frontend/web/src/lib/types/admin.ts @@ -66,6 +66,10 @@ export interface AdminChannelAccount { /** Account-level admin operations the platform adapter exposes (e.g. kook's * ["friends","user_chats","channels","guilds","message_test"]). */ capabilities?: string[]; + /** 该账号**实时适配器**的连接状态。缺省(undefined)= 当前没有该账号的已注册 + * 适配器 ⇒ 状态**未知**,绝不是「未连接」:面板必须据此禁用「断开」 + * (断一条状态未知的账号是盲操作),而「连接」仍可点。 */ + connected?: boolean; } /** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr). diff --git a/internal/services/gateway/admin_channel_account_status_test.go b/internal/services/gateway/admin_channel_account_status_test.go new file mode 100644 index 0000000..f4c997b --- /dev/null +++ b/internal/services/gateway/admin_channel_account_status_test.go @@ -0,0 +1,92 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "strings" + "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" +) + +// 本文件锁定管理列表的**按账号**连接状态:多账号平台上,每行连接/断开的门控 +// 必须看该账号自己的状态;没有活适配器的账号状态**未知**(字段缺省), +// 而不是被读成「未连接」——把「读不到」画成「没连上」会让主人盲点「断开」。 + +// TestAdminListChannelsReportsPerAccountConnection 断言每个账号各报自己的状态, +// 且没有活适配器的账号**不带** connected 字段(未知 ≠ 未连接)。 +func TestAdminListChannelsReportsPerAccountConnection(t *testing.T) { + t.Parallel() + caps := capreg.New() + live := &spyChannel{name: "kook:3fc8"} + live.connected.Store(true) + down := &spyChannel{name: "kook:conf1"} + for _, ch := range []*spyChannel{live, down} { + if err := caps.RegisterChannel(ch); err != nil { + t.Fatalf("register %s: %v", ch.Name(), err) + } + } + store := &stubChannelConfigStore{ + path: t.TempDir() + "/channels.json", + accounts: map[string][]config.ChannelAccount{ + "kook": { + {ID: "3fc8", Config: map[string]any{"bot_token": "tk-a"}}, + {ID: "conf1", Config: map[string]any{"bot_token": "tk-b"}}, + {ID: "conf2", Config: map[string]any{"bot_token": "tk-c"}}, // 无活适配器 ⇒ 状态未知 + }, + }, + } + _, 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 items []ChannelSummary + if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { + t.Fatalf("decode list: %v (body=%s)", err, body) + } + if len(items) != 1 || items[0].Name != "kook" { + t.Fatalf("items = %+v, want a single kook row", items) + } + if len(items[0].Accounts) != 3 { + t.Fatalf("accounts = %+v, want all three configured accounts listed", items[0].Accounts) + } + state := accountConnections(items[0].Accounts) + if got := state["3fc8"]; got == nil || !*got { + t.Fatalf("account 3fc8 connected = %v, want true", got) + } + if got := state["conf1"]; got == nil || *got { + t.Fatalf("account conf1 connected = %v, want false", got) + } + // nil = 字段缺省:没有活适配器 ⇒ 状态未知,绝不能画成「未连接」。 + if got := state["conf2"]; got != nil { + t.Fatalf("account conf2 connected = %v, want the field omitted(未知 ≠ 未连接)", *got) + } + // 凭据绝不进列表响应。 + if bytes := string(body); containsToken(bytes) { + t.Fatalf("list response leaked a token: %s", bytes) + } +} + +// accountConnections 把账号列表映射成 id → 连接状态(字段缺省时值为 nil)。 +func accountConnections(accts []AdminChannelAccount) map[string]*bool { + out := make(map[string]*bool, len(accts)) + for _, a := range accts { + out[a.ID] = a.Connected + } + return out +} + +// containsToken 报告响应体里是否出现了测试用的原始 token 片段。 +func containsToken(body string) bool { + for _, tk := range []string{"tk-a", "tk-b", "tk-c"} { + if strings.Contains(body, tk) { + return true + } + } + return false +} diff --git a/internal/services/gateway/admin_channel_action_test.go b/internal/services/gateway/admin_channel_action_test.go new file mode 100644 index 0000000..e78a8a6 --- /dev/null +++ b/internal/services/gateway/admin_channel_action_test.go @@ -0,0 +1,283 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "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" +) + +// 本文件锁定「连接到底连了谁」这一根本困惑: +// +// - 带 accountId 的 open/close **只**作用于该账号; +// - 找不到该 id 时明确报错(404 account_not_found / 409 account_not_registered), +// **绝不静默回落到第一条**(静默回落正是主人点「连接」打到历史条目的根因); +// - 不带 accountId 时维持老契约,且在多账号平台上按**配置顺序**确定目标 +// (注册表是 map,旧代码的「第一条」其实每次可能不同,面板无法如实标注)。 + +// spyChannel 记录 Connect/Disconnect 是否真被调用,使「面板点的那一条被驱动了、 +// 另一条没被碰」成为可断言的证据。 +type spyChannel struct { + name string + connects atomic.Int32 + disconns atomic.Int32 + connected atomic.Bool + connectErr error +} + +func (s *spyChannel) Name() string { return s.name } +func (s *spyChannel) IsConnected() bool { return s.connected.Load() } +func (s *spyChannel) HealthCheck(context.Context) error { return nil } +func (s *spyChannel) Connect(context.Context) error { + s.connects.Add(1) + if s.connectErr != nil { + return s.connectErr + } + s.connected.Store(true) + return nil +} +func (s *spyChannel) Disconnect(context.Context) error { + s.disconns.Add(1) + s.connected.Store(false) + return nil +} + +// twoAccountFixture 建一个「两条 qq-obv11」的现场:一条无 id 的历史条目 +// (裸适配器名)与一条主人新建的 client 账号,并返回 spy 句柄。 +func twoAccountFixture(t *testing.T, store *stubChannelConfigStore) (*spyChannel, *spyChannel, string, *auth.JWTService) { + t.Helper() + legacy := &spyChannel{name: "qq-obv11"} + fresh := &spyChannel{name: "qq-obv11:9616b27a"} + caps := capreg.New() + if err := caps.RegisterChannel(legacy); err != nil { + t.Fatalf("register legacy: %v", err) + } + if err := caps.RegisterChannel(fresh); err != nil { + t.Fatalf("register fresh: %v", err) + } + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) + return legacy, fresh, ts.URL, jwt +} + +// worktreeTestStore 返回一个临时 channels.json 存储,含该平台的给定账号。 +func channelsStoreOf(t *testing.T, platform string, accts ...config.ChannelAccount) *stubChannelConfigStore { + t.Helper() + return &stubChannelConfigStore{ + path: t.TempDir() + "/channels.json", + accounts: map[string][]config.ChannelAccount{platform: accts}, + } +} + +// TestAdminChannelOpenTargetsRequestedAccount 断言 accountId 给出时连的是它, +// 而不是该平台注册表里的第一条。 +func TestAdminChannelOpenTargetsRequestedAccount(t *testing.T) { + t.Parallel() + legacy, fresh, base, jwt := twoAccountFixture(t, nil) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/open", + map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if got := fresh.connects.Load(); got != 1 { + t.Fatalf("fresh account Connect calls = %d, want 1", got) + } + if got := legacy.connects.Load(); got != 0 { + t.Fatalf("legacy account Connect calls = %d, want 0(不得打到别条账号)", got) + } + var res struct { + Name string `json:"name"` + Resolved string `json:"resolved"` + AccountID string `json:"accountId"` + Connected bool `json:"connected"` + } + if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { + t.Fatalf("decode open response: %v (body=%s)", err, body) + } + // resolved 必须如实报出**被驱动**的适配器:面板靠它显示「连的是哪一条」。 + if res.Resolved != "qq-obv11:9616b27a" || res.AccountID != "9616b27a" || !res.Connected { + t.Fatalf("open response = %+v, want resolved=qq-obv11:9616b27a accountId=9616b27a connected=true", res) + } +} + +// TestAdminChannelCloseTargetsRequestedAccount 同上,针对 close。 +func TestAdminChannelCloseTargetsRequestedAccount(t *testing.T) { + t.Parallel() + legacy, fresh, base, jwt := twoAccountFixture(t, nil) + fresh.connected.Store(true) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/close", + map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if fresh.disconns.Load() != 1 || legacy.disconns.Load() != 0 { + t.Fatalf("disconnect calls fresh=%d legacy=%d, want 1/0", fresh.disconns.Load(), legacy.disconns.Load()) + } + if fresh.IsConnected() { + t.Fatal("fresh account still connected after close") + } +} + +// TestAdminChannelOpenUnknownAccountIDIs404 断言「找不到该 id」是明确错误, +// 且**绝不**把请求打到该平台的第一条账号(静默回落是本 bug 的根因)。 +func TestAdminChannelOpenUnknownAccountIDIs404(t *testing.T) { + t.Parallel() + store := channelsStoreOf(t, "qq-obv11", + config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}}) + legacy, fresh, base, jwt := twoAccountFixture(t, store) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/open", + map[string]any{"name": "qq-obv11", "accountId": "does-not-exist"}, tok) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body) + } + errObj := decodeAdminError(t, body) + if errObj.Code != "account_not_found" { + t.Fatalf("error code = %q, want account_not_found", errObj.Code) + } + // 错误文案必须带上可用账号,方便面板/调用方自行纠正(账号 id 非敏感)。 + if !strings.Contains(errObj.Message, "9616b27a") { + t.Fatalf("error message = %q, want it to list the known account id", errObj.Message) + } + if legacy.connects.Load() != 0 || fresh.connects.Load() != 0 { + t.Fatalf("no account may be connected on a miss: legacy=%d fresh=%d", + legacy.connects.Load(), fresh.connects.Load()) + } +} + +// TestAdminChannelOpenConfiguredButUnregisteredIs409 区分两种 miss:账号确实在 +// channels.json 里、但当前没有它的活适配器(多半缺必要凭据),给 409 而不是 404, +// 面板据此提示「去修该账号配置」而不是「账号不存在」。 +func TestAdminChannelOpenConfiguredButUnregisteredIs409(t *testing.T) { + t.Parallel() + store := channelsStoreOf(t, "qq-obv11", + config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}}) + caps := capreg.New() + legacy := &spyChannel{name: "qq-obv11"} + if err := caps.RegisterChannel(legacy); 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.MethodPost, ts.URL+"/api/v1/admin/channels/open", + map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("status = %d, want 409 (body=%s)", resp.StatusCode, body) + } + if code := decodeAdminError(t, body).Code; code != "account_not_registered" { + t.Fatalf("error code = %q, want account_not_registered", code) + } + if legacy.connects.Load() != 0 { + t.Fatal("unregistered account must not fall back to another account") + } +} + +// TestAdminChannelOpenWithoutAccountIDStaysLegacy 断言缺省 accountId 时仍是老契约: +// 单账号平台照旧连上那一条,且响应不凭空造出 accountId。 +func TestAdminChannelOpenWithoutAccountIDStaysLegacy(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) + } + if only.connects.Load() != 1 { + t.Fatalf("connect calls = %d, want 1", only.connects.Load()) + } + 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 _, present := out["accountId"]; present { + t.Fatalf("platform-level open must not invent an accountId: %v", out) + } + if out["name"] != "kook" || out["resolved"] != "kook:3fc8" { + t.Fatalf("open data = %v, want name=kook resolved=kook:3fc8", out) + } +} + +// TestAdminChannelOpenPlatformLevelIsDeterministic 断言多账号平台上的**平台级** +// 连接目标按配置顺序确定,而不是随注册表 map 顺序抖动 —— 否则面板无法如实标注 +// 「这次会连哪一条」,主人也就永远不知道平台级按钮会打到谁。 +func TestAdminChannelOpenPlatformLevelIsDeterministic(t *testing.T) { + t.Parallel() + caps := capreg.New() + first := &spyChannel{name: "qq-obv11:aaaa1111"} + second := &spyChannel{name: "qq-obv11:bbbb2222"} + for _, ch := range []*spyChannel{second, first} { // 注册顺序与配置顺序故意相反 + if err := caps.RegisterChannel(ch); err != nil { + t.Fatalf("register: %v", err) + } + } + store := channelsStoreOf(t, "qq-obv11", + config.ChannelAccount{ID: "aaaa1111", Config: map[string]any{"mode": "client"}}, + config.ChannelAccount{ID: "bbbb2222", Config: map[string]any{"mode": "client"}}, + ) + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + for i := 0; i < 12; i++ { + resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", + map[string]any{"name": "qq-obv11"}, 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", err) + } + if out["resolved"] != "qq-obv11:aaaa1111" { + t.Fatalf("round %d resolved = %v, want the first configured account (aaaa1111)", i, out["resolved"]) + } + } + if first.connects.Load() != 12 || second.connects.Load() != 0 { + t.Fatalf("connects first=%d second=%d, want 12/0", first.connects.Load(), second.connects.Load()) + } +} + +// TestAdminChannelOpenDecodeFailureStill400 保证老客户的坏请求体照旧 400。 +func TestAdminChannelOpenDecodeFailureStill400(t *testing.T) { + t.Parallel() + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + resp, body := doRaw(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", "{", tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) + } +} + +// decodeAdminError 解出 {error:{code,message}} 信封。 +func decodeAdminError(t *testing.T, body []byte) struct { + Code string `json:"code"` + Message string `json:"message"` +} { + t.Helper() + var out struct { + Code string `json:"code"` + Message string `json:"message"` + } + raw := decodeBody(t, body)["error"] + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode error envelope: %v (body=%s)", err, body) + } + return out +} diff --git a/internal/services/gateway/admin_channel_status.go b/internal/services/gateway/admin_channel_status.go new file mode 100644 index 0000000..865b082 --- /dev/null +++ b/internal/services/gateway/admin_channel_status.go @@ -0,0 +1,53 @@ +package gateway + +// 管理渠道列表里的**每账号**连接状态(AdminChannelAccount.Connected)。 +// +// 为什么要有它:平台级 `connected` 只反映该平台第一条已注册适配器(ChannelSummary +// 由聚合行上的那个适配器决定),多账号平台上它无法回答「我点连接的那条现在活着吗」。 +// 面板因此只能盲目渲染每行的连接/断开按钮 —— 主人有两条 qq-obv11 时,「断开」到底 +// 断的是哪一条、断完有没有生效,看板上都无从判断。这里按账号把它补上。 + +import ( + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// liveAccountConnections 收集每个平台各账号的实时连接状态,键与 liveBotIdentity +// 相同(裸适配器名 ⇒ 空账号 id)。注册表里**没有**该账号的适配器时它直接缺席, +// 而不是报 false:缺席 = 状态未知(面板画「状态未知」),false = 确知未连接。 +func (s *Server) liveAccountConnections() map[string]map[string]bool { + out := make(map[string]map[string]bool) + if s.caps == nil { + return out + } + for _, cap := range s.caps.List(capreg.KindChannel) { + ch, ok := cap.Value.(sdk.Channel) + if !ok { + continue + } + platform, acct := splitAdapterName(ch.Name()) + if out[platform] == nil { + out[platform] = make(map[string]bool) + } + out[platform][acct] = ch.IsConnected() + } + return out +} + +// enrichAccountsWithConnection 把实时连接状态合并进平台中匹配的账号(按账号 id +// 配对,与适配器注册名一致)。没有实时适配器的账号保持 Connected == nil(未知), +// 因此面板能在「没连上」与「读不到」之间如实区分。入参切片不被修改。 +func enrichAccountsWithConnection(accts []AdminChannelAccount, by map[string]bool) []AdminChannelAccount { + if len(by) == 0 { + return accts + } + out := make([]AdminChannelAccount, 0, len(accts)) + for _, a := range accts { + if connected, ok := by[a.ID]; ok { + value := connected + a.Connected = &value + } + out = append(out, a) + } + return out +} diff --git a/internal/services/gateway/admin_channel_target.go b/internal/services/gateway/admin_channel_target.go new file mode 100644 index 0000000..62fd9da --- /dev/null +++ b/internal/services/gateway/admin_channel_target.go @@ -0,0 +1,206 @@ +package gateway + +// 渠道 open/close 的「操作目标」解析:请求体里的 name(平台名或精确适配器名) +// 加上**可选**的 accountId。 +// +// 这个文件的全部意义是把「连的是哪一条账号」变成不可含糊的事实:主人有两条 +// qq-obv11 时,旧契约(只有 name)让面板完全没有指定权 —— 后端只能自己挑,而 +// 挑中的可能正是他不想动的历史条目(如 mode=server 且 listen_addr 为空), +// 他新建的、配置正确的 client 账号永远轮不到;连「这次会挑哪一条」都无法预告。 +// 因此 accountId 给出时**只**作用于该账号,找不到就报错,绝不静默回落到第一条。 + +import ( + "encoding/json" + "net/http" + "strings" + + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// defaultChannelAccountID 是「该平台的(唯一)默认账号」这一账号 id 的线上写法。 +// 组合根 buildAccountAdapter 对**无 id** 的账号用裸平台名注册适配器,面板则把 +// 空 id / "default" 都映射到这个裸名(channelUtils.adapterNameOf),两侧口径在此对齐。 +const defaultChannelAccountID = "default" + +// channelActionRequest 是 POST /api/v1/admin/channels/open|close 的请求体。 +type channelActionRequest struct { + // Name 是渠道名:裸平台名("qq-obv11"),只有一条账号、或想沿用旧行为时用它。 + Name string `json:"name"` + // AccountID 是可选的多账号定位器(channels.json 里该账号的 id)。给出时只作用于 + // 该账号;缺省时走平台级旧语义(精确同名适配器 → 该平台配置顺序第一条), + // 因此老客户端零影响。 + AccountID string `json:"accountId"` +} + +// channelTarget 是一次 open/close 的已校验目标。 +type channelTarget struct { + Name string + AccountID string +} + +// channelAction 解码并校验 open/close 的请求体;name 缺失时写出 400 并返回 ok=false。 +func (s *Server) channelAction(r *http.Request, w http.ResponseWriter, op string) (channelTarget, bool) { + var req channelActionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) + return channelTarget{}, false + } + name := strings.TrimSpace(req.Name) + if name == "" { + s.auditClaims(r, op, "error") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required")) + return channelTarget{}, false + } + return channelTarget{Name: name, AccountID: strings.TrimSpace(req.AccountID)}, true +} + +// channelForAction 解析出要驱动的适配器;失败时写出错误响应并返回 ok=false。 +// +// - 无 accountId → 平台级旧语义(见 legacyChannelTarget:精确同名适配器 → +// 该平台配置顺序第一条 → 旧的裸平台名回落); +// - 有 accountId → **只**认该账号的适配器,找不到一律明确报错: +// 账号在 channels.json 里存在 ⇒ 409 account_not_registered(已配置但没有活 +// 适配器,多半是缺少必要凭据/构建被跳过);完全不存在 ⇒ 404 account_not_found。 +// +// 两条路径都绝不回落到「第一条」:静默回落正是「点连接连错账号」的根因, +// 面板会把「连上了别的账号」读成「连上了我点的那条」。 +func (s *Server) channelForAction(w http.ResponseWriter, r *http.Request, target channelTarget, op string) (sdk.Channel, bool) { + if target.AccountID == "" { + if ch, found := s.legacyChannelTarget(target.Name); found { + return ch, true + } + s.auditClaims(r, op, "error") + writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", target.Name)) + return nil, false + } + if ch, found := findChannelByAccount(s.caps, target.Name, target.AccountID); found { + return ch, true + } + s.auditClaimsAccount(r, op, "error", target.AccountID) + if s.accountConfigured(target.Name, target.AccountID) { + writeError(w, apperr.Wrap(apperr.ErrConflict, "account_not_registered", + "账号 %q 已配置但当前没有已注册的适配器(常见原因:缺少该账号必需凭据,或保存后尚未重载);请先修好该账号配置再连接", + target.AccountID)) + return nil, false + } + writeError(w, apperr.Wrap(apperr.ErrNotFound, "account_not_found", + "channel %q has no account %q%s", + channelBasePlatform(target.Name), target.AccountID, s.knownAccountHint(target.Name))) + return nil, false +} + +// legacyChannelTarget 解析**没有** accountId 的 open/close 目标(老客户端契约), +// 按三步优先级: +// +// 1. 精确同名适配器("kook" / "kook:conf0")—— 与旧 findChannel 的第一步一致; +// 2. 该平台**有效配置顺序**(channels.json,面板列表用的同一份)里第一条能命中的账号; +// 3. 旧 findChannel 的裸平台名回落(尽力而为)。 +// +// 第 2 步是必需的而不是锦上添花:注册表是 map,`List` 的顺序**不保证**,因此旧代码 +// 的「该平台第一条」在多账号平台上每次请求都可能落到不同账号 —— 面板根本没法如实 +// 写出「这次会连哪一条」。本函数把这一步变成确定性的(配置顺序第一条), +// 而第 1/3 步保持旧语义,所以单账号平台的行为逐字不变。 +func (s *Server) legacyChannelTarget(name string) (sdk.Channel, bool) { + if ch, ok := findChannelExact(s.caps, name); ok { + return ch, true + } + if ch, ok := s.firstConfiguredChannel(name); ok { + return ch, true + } + return findChannel(s.caps, name) +} + +// firstConfiguredChannel 把「该平台配置顺序里的第一条账号」解析成它已注册的适配器。 +// 账号没有 id 时对应的是裸平台适配器名(组合根 buildAccountAdapter 的注册口径)。 +// 配置存储未接线(测试)/该平台没有账号/这些账号都没有活适配器时返回 found=false。 +func (s *Server) firstConfiguredChannel(name string) (sdk.Channel, bool) { + if s.channels == nil { + return nil, false + } + base := channelBasePlatform(name) + for _, acct := range s.channels.Load()[base] { + id := strings.TrimSpace(acct.ID) + if id == "" { + id = defaultChannelAccountID + } + if ch, ok := findChannelByAccount(s.caps, base, id); ok { + return ch, true + } + } + return nil, false +} + +// channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端 +// 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的 +// 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。 +func channelActionResult(target channelTarget, resolved string, connected bool) map[string]any { + out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected} + if target.AccountID != "" { + out["accountId"] = target.AccountID + } + return out +} + +// findChannelByAccount 精确查找某账号的实时适配器(绝不回落)。账号 id 为 +// "default" 时对应的是该平台的裸适配器名(无 id 的账号正是这样注册的)。 +func findChannelByAccount(caps CapabilityStore, name, accountID string) (sdk.Channel, bool) { + base := channelBasePlatform(name) + if accountID == defaultChannelAccountID { + return findChannelExact(caps, base) + } + return findChannelExact(caps, base+":"+accountID) +} + +// findChannelExact 按注册名精确匹配渠道适配器。它与 findChannel 的区别是**没有** +// 裸平台名回落,因此可用作「就是这一条账号」的判据。 +func findChannelExact(caps CapabilityStore, name string) (sdk.Channel, bool) { + if caps == nil || name == "" { + return nil, false + } + for _, cap := range caps.List(capreg.KindChannel) { + ch, ok := cap.Value.(sdk.Channel) + if !ok { + continue + } + if ch.Name() == name { + return ch, true + } + } + return nil, false +} + +// accountConfigured 报告某平台的某账号是否在有效渠道配置里(channels.json → env)。 +// 它只用于把「账号不存在」与「账号存在但没有活适配器」区分开,好让面板给出正确 +// 指引;返回的账号 id 非敏感,不带任何凭据。 +func (s *Server) accountConfigured(name, accountID string) bool { + if s.channels == nil { + return false + } + base := channelBasePlatform(name) + for _, acct := range s.channels.Load()[base] { + if strings.TrimSpace(acct.ID) == accountID { + return true + } + } + return false +} + +// knownAccountHint 列出该平台已知的账号 id,附在「账号不存在」错误后,好让面板/ +// 调用方看到可用取值(id 非敏感)。没有任何已知账号时返回空串。 +func (s *Server) knownAccountHint(name string) string { + if s.channels == nil { + return "" + } + var ids []string + for _, acct := range s.channels.Load()[channelBasePlatform(name)] { + if id := strings.TrimSpace(acct.ID); id != "" { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return "" + } + return "(该平台已知账号:" + strings.Join(ids, "、") + ")" +} diff --git a/internal/services/gateway/admin_delete_channel_empty_test.go b/internal/services/gateway/admin_delete_channel_empty_test.go new file mode 100644 index 0000000..626fe56 --- /dev/null +++ b/internal/services/gateway/admin_delete_channel_empty_test.go @@ -0,0 +1,200 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 本文件锁定「空 id 条目删得掉」以及它**必须显式**: +// +// - `?id=` 仍要求非空(老契约); +// - `?empty=1` 才表示「删无 id 的历史条目」,且只删这些条目; +// - 绝不能把「id 缺失/为空」默认读成「删掉所有无 id 条目」—— +// 那是一次没有任何字面证据的批量删除。 + +// TestAdminDeleteChannelAccountEmptyExplicit 走 `?empty=1` 删除无 id 的历史条目: +// 文件里只剩带 id 的账号,且该平台被热重载。 +func TestAdminDeleteChannelAccountEmptyExplicit(t *testing.T) { + t.Parallel() + path := t.TempDir() + "/channels.json" + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "qq-obv11": { + {Config: map[string]any{"mode": "server", "listen_addr": ""}}, // 无 id 的历史条目 + {ID: "9616b27a", Config: map[string]any{"mode": "client", "remote_url": "ws://x/y"}}, + {ID: "conf1", Config: map[string]any{"mode": "client", "remote_url": "ws://x/z"}}, + }, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var res struct { + Persisted bool `json:"persisted"` + Platform string `json:"platform"` + Empty bool `json:"empty"` + Removed int `json:"removed"` + } + if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { + t.Fatalf("decode delete response: %v (body=%s)", err, body) + } + if !res.Persisted || res.Platform != "qq-obv11" || !res.Empty || res.Removed != 1 { + t.Fatalf("delete response = %+v, want persisted/qq-obv11/empty/removed=1", res) + } + if len(store.reloaded) != 1 || store.reloaded[0] != "qq-obv11" { + t.Fatalf("reloaded = %v, want [qq-obv11]", store.reloaded) + } + accounts, warn := runtimeconfig.LoadChannels(path, nil) + if warn != nil { + t.Fatalf("load channels warn = %v", warn) + } + kept := accounts["qq-obv11"] + if len(kept) != 2 { + t.Fatalf("kept accounts = %+v, want the two id'd accounts only", kept) + } + for _, acct := range kept { + if strings.TrimSpace(acct.ID) == "" { + t.Fatalf("an id-less account survived the explicit empty delete: %+v", kept) + } + } + if strings.Contains(string(body), "ws://x/y") || strings.Contains(string(body), "ws://x/z") { + t.Fatalf("delete response leaked config values: %s", body) + } +} + +// TestAdminDeleteChannelAccountEmptyRequiresExplicitFlag 是核心回归:没有任何 +// 字面证据时,空 id 请求一律 400,且盘上的无 id 条目**原封不动**(一次都不许写)。 +func TestAdminDeleteChannelAccountEmptyRequiresExplicitFlag(t *testing.T) { + t.Parallel() + for _, query := range []string{"", "?id=", "?id=%20", "?empty=", "?empty=0", "?empty=false"} { + query := query + t.Run("query="+query, func(t *testing.T) { + t.Parallel() + path := t.TempDir() + "/channels.json" + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "qq-obv11": {{Config: map[string]any{"mode": "server"}}}, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodDelete, + ts.URL+"/api/v1/admin/channels/qq-obv11/config"+query, nil, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) + } + if len(store.reloaded) != 0 { + t.Fatalf("reloaded = %v, want none", store.reloaded) + } + if store.accounts["qq-obv11"] == nil || len(store.accounts["qq-obv11"]) != 1 { + t.Fatalf("accounts = %+v, want the id-less entry untouched", store.accounts) + } + }) + } +} + +// TestAdminDeleteChannelAccountRejectsBothParams 断言 id 与 empty=1 同时给出时 +// 一律 400:一次请求只针对一种条目,不做「两个都删」这种猜测。 +func TestAdminDeleteChannelAccountRejectsBothParams(t *testing.T) { + t.Parallel() + path := t.TempDir() + "/channels.json" + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "qq-obv11": { + {Config: map[string]any{"mode": "server"}}, + {ID: "9616b27a", Config: map[string]any{"mode": "client"}}, + }, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodDelete, + ts.URL+"/api/v1/admin/channels/qq-obv11/config?id=9616b27a&empty=1", nil, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) + } + if len(store.reloaded) != 0 || len(store.accounts["qq-obv11"]) != 2 { + t.Fatalf("nothing may be deleted: reloaded=%v accounts=%+v", store.reloaded, store.accounts) + } +} + +// TestAdminDeleteChannelAccountByIdStillTargeted 老契约照旧:?id=<非空> 只删那一条, +// 无 id 的历史条目**不受影响**。 +func TestAdminDeleteChannelAccountByIdStillTargeted(t *testing.T) { + t.Parallel() + path := t.TempDir() + "/channels.json" + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "qq-obv11": { + {Config: map[string]any{"mode": "server"}}, + {ID: "9616b27a", Config: map[string]any{"mode": "client"}}, + }, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodDelete, + ts.URL+"/api/v1/admin/channels/qq-obv11/config?id=9616b27a", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + kept := store.accounts["qq-obv11"] + if len(kept) != 1 || strings.TrimSpace(kept[0].ID) != "" { + t.Fatalf("kept = %+v, want only the id-less historical entry", kept) + } +} + +// TestAdminDeleteChannelAccountsWithoutCandidatesIs404 断言显式请求了删除、但平台 +// 上没有无 id 条目时诚实回 404(不写盘、不重载、不假装删过)。 +func TestAdminDeleteChannelAccountsWithoutCandidatesIs404(t *testing.T) { + t.Parallel() + path := t.TempDir() + "/channels.json" + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "qq-obv11": {{ID: "9616b27a", Config: map[string]any{"mode": "client"}}}, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body) + } + if len(store.reloaded) != 0 { + t.Fatalf("reloaded = %v, want none", store.reloaded) + } + if len(store.accounts["qq-obv11"]) != 1 { + t.Fatalf("accounts = %+v, want untouched", store.accounts) + } +} + +// TestAdminDeleteChannelAccountEmptyDropsPlatformKey 断言只有无 id 条目时, +// 删除后整个平台键消失(与按 id 删除的收尾口径一致)。 +func TestAdminDeleteChannelAccountEmptyDropsPlatformKey(t *testing.T) { + t.Parallel() + path := t.TempDir() + "/channels.json" + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "qq-obv11": {{Config: map[string]any{"mode": "server"}}}, + "telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "tk"}}}, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if _, ok := store.accounts["qq-obv11"]; ok { + t.Fatalf("qq-obv11 key survived an empty delete: %+v", store.accounts) + } + if len(store.accounts["telegram"]) != 1 { + t.Fatalf("other platforms must be untouched: %+v", store.accounts) + } + if bytes := string(body); strings.Contains(bytes, "tk") { + t.Fatalf("delete response leaked a token: %s", bytes) + } +} diff --git a/internal/services/gateway/admin_query.go b/internal/services/gateway/admin_query.go index f6332c7..e0b37f2 100644 --- a/internal/services/gateway/admin_query.go +++ b/internal/services/gateway/admin_query.go @@ -62,6 +62,7 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) { func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { accountsByPlatform := s.maskedChannelAccounts() botByPlatform := s.liveBotIdentity() + connectedByPlatform := s.liveAccountConnections() out := make([]ChannelSummary, 0) if s.caps != nil { for _, cap := range s.caps.List(capreg.KindChannel) { @@ -97,7 +98,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { } } if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 { - sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[platform]) + sum.Accounts = enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[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 f051e14..b1613ea 100644 --- a/internal/services/gateway/admin_write_channel_config.go +++ b/internal/services/gateway/admin_write_channel_config.go @@ -51,6 +51,10 @@ type AdminChannelAccount struct { BotID string `json:"botId,omitempty"` BotUsername string `json:"botUsername,omitempty"` BotNickname string `json:"botNickname,omitempty"` + // Connected 是该账号**实时适配器**的连接状态(面板逐账号渲染连接/断开的依据)。 + // nil(字段缺省)表示「当前没有该账号的已注册适配器」—— 状态**未知**,面板不得 + // 画成「未连接」,也不得允许断开一条它根本没连上的账号。 + Connected *bool `json:"connected,omitempty"` // Capabilities 列出该平台适配器暴露的账号级管理操作 // (friends/channels/user-chats/message-test),使面板 // 渲染正确的每账号卡片。没有专用适配器表面的平台 @@ -181,19 +185,34 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) }) } -// adminDeleteChannelAccount 按 id 查询参数从某平台在 channels.json -// 中移除一个账号,并热重载该平台使适配器停止。 +// adminDeleteChannelAccount 从某平台移除账号并热重载该平台。两种寻址方式, +// **都必须显式**给出: +// +// - `?id=<账号 id>` → 删除该 id 的那一条(id 非空); +// - `?empty=1` → 删除该平台上**全部无 id 的历史条目**(面板根本改不动它们: +// 带空 id 保存会被 runtimeconfig.UpsertChannel 当成新账号另开一条,越改越多)。 +// +// 绝不允许把「id 缺失/为空」默认解释成「删掉无 id 条目」:那是一次没有字面证据的 +// 批量删除,误删无从追溯。两个参数同时给出也一律 400(一次请求只针对一种条目)。 func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) { platform := strings.TrimSpace(r.PathValue("platform")) - id := strings.TrimSpace(r.URL.Query().Get("id")) + query := r.URL.Query() + id := strings.TrimSpace(query.Get("id")) + empty := queryFlag(query.Get("empty")) if platform == "" { s.auditClaims(r, "channel.delete", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required")) return } - if id == "" { + if id != "" && empty { s.auditClaims(r, "channel.delete", "error") - writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required")) + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id 与 empty=1 不能同时给出:一次请求只删除一种条目")) + return + } + if id == "" && !empty { + s.auditClaims(r, "channel.delete", "error") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", + "id is required(要删除无 id 的历史条目请显式传 ?empty=1)")) return } if s.channels == nil { @@ -202,7 +221,22 @@ func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Reques return } accounts := s.channels.Load() - updated := runtimeconfig.DeleteChannel(accounts, platform, id) + updated := accounts + removed := 1 + if empty { + updated, removed = deleteEmptyIDAccounts(accounts, platform) + if removed == 0 { + // 没有可删的空 id 条目:不写文件、不重载,明确告诉调用方「这里没有它要找的东西」, + // 而不是回一个什么都没做的 200。 + s.auditClaims(r, "channel.delete", "error") + writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", + "platform %q has no id-less account to delete", platform)) + return + } + } else { + updated = runtimeconfig.DeleteChannel(accounts, platform, id) + removed = 1 + } if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil { s.auditClaims(r, "channel.delete", "error") s.log.Error("channel delete channels.json", "path", s.channels.Path(), "error", err) @@ -215,15 +249,59 @@ func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Reques writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload")) return } - s.auditClaims(r, "channel.delete", "ok") + s.auditClaimsAccount(r, "channel.delete", "ok", id) + s.log.Info("channel account deleted", "platform", platform, "accountId", id, "empty", empty, "removed", removed) writeJSON(w, http.StatusOK, map[string]any{ "persisted": true, "path": s.channels.Path(), "platform": platform, "accountId": id, + "empty": empty, + "removed": removed, }) } +// deleteEmptyIDAccounts 移除某平台上**全部无 id 条目**,返回剩余账号与删除条数。 +// 空 id 条目之间没有任何可寻址的区别(没有 id 可指名),因此「只删某一条」在这类 +// 条目上没有可实现的语义;面板把这动作标成「删除无 id 条目」并展示实际条数, +// 删除范围因此对调用方是完全可见的。平台再无账号时整个平台键被移除,与 +// runtimeconfig.DeleteChannel 的收尾口径一致。 +func deleteEmptyIDAccounts(accounts map[string][]config.ChannelAccount, platform string) (map[string][]config.ChannelAccount, int) { + if accounts == nil { + return map[string][]config.ChannelAccount{}, 0 + } + kept := make([]config.ChannelAccount, 0, len(accounts[platform])) + removed := 0 + for _, cur := range accounts[platform] { + if strings.TrimSpace(cur.ID) == "" { + removed++ + continue + } + kept = append(kept, cur) + } + if removed == 0 { + return accounts, 0 + } + if len(kept) == 0 { + delete(accounts, platform) + } else { + accounts[platform] = kept + } + return accounts, removed +} + +// queryFlag 解析显式的布尔查询参数:只有字面表示「是」的取值才算给出 +// (1/true/yes/on)。空串与其它一切取值都是「否」,因此 `?empty=` / `?empty=0` +// 绝不会被读成「删掉无 id 条目」。 +func queryFlag(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + // channelConfigFromRequest 把请求的便捷字段合并进平台特有 config // map,优先采用面板已显式设置的键。它绝不注入面板 // 未给出的键(空 token 保持缺位,这样用的是 diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go index b327d56..10ddfa9 100644 --- a/internal/services/gateway/admin_write_llm_channels.go +++ b/internal/services/gateway/admin_write_llm_channels.go @@ -269,25 +269,20 @@ func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens}) } -// channelActionRequest 是 POST /api/v1/admin/channels/open|close 的请求体。 -type channelActionRequest struct { - Name string `json:"name"` -} - -// adminChannelOpen 连接一个已注册渠道适配器(真实的生命周期调用)。 +// adminChannelOpen 连接一个已注册渠道适配器(真实的生命周期调用)。请求可选的 +// accountId 决定多账号平台上**连的是哪一条**(见 channelForAction);缺省时走平台级 +// 旧语义(精确同名适配器 → 该平台配置顺序第一条),因此老客户端/旧面板不受影响。 func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) { - name, ok := s.channelName(r, w, "channel.open") + target, ok := s.channelAction(r, w, "channel.open") if !ok { return } - ch, found := findChannel(s.caps, name) - if !found { - s.auditClaims(r, "channel.open", "error") - writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name)) + ch, ok := s.channelForAction(w, r, target, "channel.open") + if !ok { return } if err := ch.Connect(r.Context()); err != nil { - s.auditClaims(r, "channel.open", "error") + s.auditClaimsAccount(r, "channel.open", "error", target.AccountID) // 单纯重连修不好的状态不得读作通用失败: // weixin-iLink 机器人 token 被 iLink 拒绝(errcode=-14),因此 // 主人需要一次新的扫码登录。用该指引返回 409, @@ -303,34 +298,33 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接")) return } - s.log.Error("channel open", "name", name, "err", err) + s.log.Error("channel open", "name", ch.Name(), "accountId", target.AccountID, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error())) return } - s.auditClaims(r, "channel.open", "ok") - writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true}) + s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID) + writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), true)) } -// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。 +// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId +// 的语义与 open 完全一致(给出时只断该账号,缺省时同 open 的平台级解析)。 func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) { - name, ok := s.channelName(r, w, "channel.close") + target, ok := s.channelAction(r, w, "channel.close") if !ok { return } - ch, found := findChannel(s.caps, name) - if !found { - s.auditClaims(r, "channel.close", "error") - writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name)) + ch, ok := s.channelForAction(w, r, target, "channel.close") + if !ok { return } if err := ch.Disconnect(r.Context()); err != nil { - s.auditClaims(r, "channel.close", "error") - s.log.Error("channel close", "name", name, "err", err) + s.auditClaimsAccount(r, "channel.close", "error", target.AccountID) + s.log.Error("channel close", "name", ch.Name(), "accountId", target.AccountID, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error())) return } - s.auditClaims(r, "channel.close", "ok") - writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false}) + s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID) + writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), false)) } // channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道 @@ -379,27 +373,14 @@ func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) { }) } -// channelName 为 open/close 路由解码 {name} 请求体,失败时写出 400。 -// 它通过 ok 报告解码的唯一性。 -func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) { - var req channelActionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) - return "", false - } - if strings.TrimSpace(req.Name) == "" { - s.auditClaims(r, op, "error") - writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required")) - return "", false - } - return req.Name, true -} - // findChannel 返回名为 name 的已注册渠道适配器(若有)。精确的 // 适配器名("kook:conf0")胜出;非精确名的裸平台名("kook") // 仍会解析到该平台首个已注册账号,因此面板/CLI 的 // "connect kook" 能抵达携带 token 的账号,即使它名为 // "kook:conf0"(空 token 的裸占位符从不注册)。 +// +// 这就是 open/close 在**没有** accountId 时的旧语义;多账号平台上要指名某条 +// 账号请走 findChannelByAccount(见 admin_channel_target.go),那条路径没有回落。 func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) { if caps == nil { return nil, false diff --git a/internal/services/gateway/audit.go b/internal/services/gateway/audit.go index 2663b82..0a806ec 100644 --- a/internal/services/gateway/audit.go +++ b/internal/services/gateway/audit.go @@ -13,11 +13,22 @@ import ( // 谁/什么/何时/结果审计链;「主代理代审」这一面向用户的提示是 // 前端的职责,不是网关的。 func (s *Server) auditClaims(r *http.Request, what, result string) { + s.auditClaimsAccount(r, what, result, "") +} + +// auditClaimsAccount 在既有审计口径(what/result 不变,便于按动作聚合)之上补一个 +// 账号维度,使多账号平台上的「连/断/删的是哪一条」在审计里可追溯。accountID 为空 +// (平台级动作)时不带该字段。附加字段只放账号 id —— 凭据永不进审计/日志。 +func (s *Server) auditClaimsAccount(r *http.Request, what, result, accountID string) { who := "anonymous" if claims, ok := auth.ClaimsFromContext(r.Context()); ok { who = claims.Sub + ":" + string(claims.Role) } - s.audit(r.Context(), who, what, result) + extra := []any{} + if accountID != "" { + extra = append(extra, "accountId", accountID) + } + s.audit(r.Context(), who, what, result, extra...) } // AuditEvent 记录一次带显式调用方身份的外部(非 HTTP)审计事件, @@ -34,13 +45,17 @@ func (s *Server) AuditEvent(who, what, result string) { // 已配置的 AuditStore(未注入时回退到内存存储)。 // 持久化失败会记日志,但绝不阻塞管理写,因此 // 坏掉的审计存储无法拖垮审计链的首要职责。 -func (s *Server) audit(ctx context.Context, who, what, result string) { - s.log.Info("admin audit", +// extra 是追加的结构化字段(键值交替,如 "accountId", "abc"), +// 只用于日志行;持久化记录的口径保持 who/what/result 不变。 +func (s *Server) audit(ctx context.Context, who, what, result string, extra ...any) { + fields := []any{ "who", who, "what", what, "when", time.Now().UTC().Format(time.RFC3339), "result", result, - ) + } + fields = append(fields, extra...) + s.log.Info("admin audit", fields...) if s.obs != nil && s.obs.audit != nil { if err := s.obs.audit.Append(ctx, who, what, result, time.Now()); err != nil { s.log.Error("admin audit persist", "err", err, "what", what)