feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
This commit is contained in:
Aska committed 2026-09-07 13:29:41 +08:00
1 parent 2d7151f979
commit aedae2190b
18 files changed
+1743 -126

No files matched your search

+240 -4
View File
@@ -1,24 +1,54 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListChannels, adminChannelOpen, adminChannelClose, adminChannelTest } from "../../lib/api";
import type { AdminChannelRecord, ChannelTestResult } from "../../lib/types";
import {
adminListChannels,
adminChannelOpen,
adminChannelClose,
adminChannelTest,
adminSaveChannelConfig,
adminDeleteChannelAccount,
} from "../../lib/api";
import type { AdminChannelRecord, AdminChannelAccount, ChannelTestResult } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
function platformOf(name: string): string {
return name.split(":")[0];
}
export function ChannelsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [ownerId, setOwnerId] = useState("");
const [platform, setPlatform] = useState("");
const [group, setGroup] = useState(false);
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
// Config form state (per selected platform).
const [cfgPlatform, setCfgPlatform] = useState("");
const [cfgId, setCfgId] = useState("");
const [token, setToken] = useState("");
const [baseURL, setBaseURL] = useState("");
const [remoteURL, setRemoteURL] = useState("");
const [listenAddr, setListenAddr] = useState("");
const [mode, setMode] = useState("");
const [allowGroups, setAllowGroups] = useState("");
const [denyGroups, setDenyGroups] = useState("");
const [allowUsers, setAllowUsers] = useState("");
const [denyUsers, setDenyUsers] = useState("");
const [groupMsgPerMin, setGroupMsgPerMin] = useState("");
const [msgPerMin, setMsgPerMin] = useState("");
const [maxMessages, setMaxMessages] = useState("");
const [extraConfig, setExtraConfig] = useState("");
const toggle = (name: string, connect: boolean) => {
setBusy(name);
setErr(null);
@@ -54,6 +84,101 @@ export function ChannelsModule(): React.ReactNode {
.finally(() => setBusy(null));
};
const openConfig = (name: string) => {
setErr(null);
setNote(null);
setCfgPlatform(platformOf(name));
setCfgId("");
setToken("");
setBaseURL("");
setRemoteURL("");
setListenAddr("");
setMode("");
setAllowGroups("");
setDenyGroups("");
setAllowUsers("");
setDenyUsers("");
setGroupMsgPerMin("");
setMsgPerMin("");
setMaxMessages("");
setExtraConfig("");
};
const save = () => {
if (!cfgPlatform) return;
setBusy("__save__");
setErr(null);
setNote(null);
const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
let configMap: Record<string, unknown> | undefined;
if (extraConfig.trim()) {
try {
configMap = JSON.parse(extraConfig);
} catch {
setErr("额外配置 JSON 格式有误");
setBusy(null);
return;
}
}
adminSaveChannelConfig(cfgPlatform, {
id: cfgId.trim() || undefined,
token: token.trim() || undefined,
baseURL: baseURL.trim() || undefined,
remoteURL: remoteURL.trim() || undefined,
listenAddr: listenAddr.trim() || undefined,
mode: mode.trim() || undefined,
config: configMap,
allowGroups: toList(allowGroups),
denyGroups: toList(denyGroups),
allowUsers: toList(allowUsers),
denyUsers: toList(denyUsers),
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
maxMessages: maxMessages ? Number(maxMessages) : undefined,
})
.then((r) => {
log("channel.save", "ok");
setNote(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
setCfgId("");
setToken("");
setBaseURL("");
setRemoteURL("");
setListenAddr("");
setMode("");
setAllowGroups("");
setDenyGroups("");
setAllowUsers("");
setDenyUsers("");
setGroupMsgPerMin("");
setMsgPerMin("");
setMaxMessages("");
setExtraConfig("");
reload();
})
.catch((e: unknown) => {
log("channel.save", "error");
setErr(e instanceof Error ? e.message : "保存渠道配置失败");
})
.finally(() => setBusy(null));
};
const del = (platform: string, id: string) => {
setBusy("__del__" + id);
setErr(null);
setNote(null);
adminDeleteChannelAccount(platform, id)
.then(() => {
log("channel.delete", "ok");
setNote(`已删除账号 ${id} 并热更新 ${platform}`);
reload();
})
.catch((e: unknown) => {
log("channel.delete", "error");
setErr(e instanceof Error ? e.message : "删除渠道账号失败");
})
.finally(() => setBusy(null));
};
const columns: AdminColumn<AdminChannelRecord>[] = [
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
{
@@ -66,11 +191,35 @@ export function ChannelsModule(): React.ReactNode {
</Tag>
),
},
{
key: "accounts",
title: "已配账号",
render: (c) =>
c.accounts && c.accounts.length ? (
<span className="flex flex-wrap items-center gap-1">
{c.accounts.map((a) => (
<span key={a.id || "default"} className="flex items-center gap-1">
<Tag tone="neutral">{a.id || "default"}</Tag>
{a.id ? (
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
删除
</Button>
) : null}
</span>
))}
</span>
) : (
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
),
},
{
key: "actions",
title: "操作",
render: (c) => (
<span className="flex items-center gap-2">
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(c.name)}>
配置
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null || c.connected} onClick={() => toggle(c.name, true)}>
连接
</Button>
@@ -86,7 +235,7 @@ export function ChannelsModule(): React.ReactNode {
<div className="space-y-5">
<PageHeader
title="渠道管理"
subtitle="GET/POST /api/v1/admin/channels · 连接/断开(真实生命周期) + 身份识别基于 actor 元数据 + 白名单"
subtitle="GET/PUT/DELETE /api/v1/admin/channels · 配置(落盘 channels.json + 热更新) + 连接/断开(真实生命周期) + 身份识别"
actions={
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
@@ -95,6 +244,7 @@ export function ChannelsModule(): React.ReactNode {
/>
{err ? <Notice tone="err">{err}</Notice> : null}
{note ? <Notice tone="ok">{note}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
@@ -107,6 +257,89 @@ export function ChannelsModule(): React.ReactNode {
/>
</Surface>
{cfgPlatform ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID(留空自动生成)</span>
<input value={cfgId} onChange={(e) => setCfgId(e.target.value)} placeholder="conf0" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">Token(bot_token)</span>
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="机器人 token" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL(地址)</span>
<input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="https://…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">mode(qq-obv11:client/server)</span>
<input value={mode} onChange={(e) => setMode(e.target.value)} placeholder="server" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">remoteURL(qq client)</span>
<input value={remoteURL} onChange={(e) => setRemoteURL(e.target.value)} placeholder="ws://…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">listenAddr(qq server)</span>
<input value={listenAddr} onChange={(e) => setListenAddr(e.target.value)} placeholder="0.0.0.0:6700" className={inputCls} />
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">allowGroups(逗号分隔白名单)</span>
<input value={allowGroups} onChange={(e) => setAllowGroups(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">denyGroups(黑名单)</span>
<input value={denyGroups} onChange={(e) => setDenyGroups(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">allowUsers(白名单)</span>
<input value={allowUsers} onChange={(e) => setAllowUsers(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">denyUsers(黑名单)</span>
<input value={denyUsers} onChange={(e) => setDenyUsers(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">groupMsgPerMin(群聊限流,≤0 关闭)</span>
<input value={groupMsgPerMin} onChange={(e) => setGroupMsgPerMin(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">msgPerMin</span>
<input value={msgPerMin} onChange={(e) => setMsgPerMin(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">maxMessages</span>
<input value={maxMessages} onChange={(e) => setMaxMessages(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
</div>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">额外 config(可选 JSON,覆盖平台特有字段如 webhook_url/endpointPath/secret)</span>
<textarea
value={extraConfig}
onChange={(e) => setExtraConfig(e.target.value)}
rows={2}
placeholder='{"webhook_url":"…"}'
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
</label>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy !== null} onClick={save}>
保存配置
</Button>
<Button variant="ghost" disabled={busy !== null} onClick={() => setCfgPlatform("")}>
取消
</Button>
</div>
</Surface>
) : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
@@ -141,7 +374,10 @@ export function ChannelsModule(): React.ReactNode {
) : null}
</Surface>
<Notice>账号配置(扫码/token)、消息流预览、推送测试由后端 M10+ 提供;此页当前为注册渠道 + 连接健康 + 结构性身份识别。</Notice>
<Notice>账号配置落盘 channels.json(本地、已 gitignore)并热更新运行中 adapter;token/密钥不回显、不打印,.env 为兜底。消息流预览、推送测试由后端 M10+ 提供。</Notice>
</div>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";