feat: harden loose-offline sync for user JWT, schema, and console ops

Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-05 09:47:35 +08:00
parent 76cdcd760e
commit b04b180d30
59 changed files with 4762 additions and 308 deletions

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import {
App as AntApp,
Button,
Checkbox,
Form,
Input,
InputNumber,
@@ -18,13 +19,26 @@ import {
PlayCircleOutlined,
PauseCircleOutlined,
ApiOutlined,
TableOutlined,
EyeOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import {
AgentAccount,
Session,
SyncBinding,
SyncChannel,
SyncInspectResult,
SyncPreviewResult,
createSyncChannel,
deleteSyncChannel,
dropSyncTable,
inspectSyncChannel,
listAgents,
listApps,
listSyncBindings,
listSyncChannels,
previewSyncTable,
reconcileSyncChannel,
startSyncChannel,
stopSyncChannel,
@@ -43,6 +57,8 @@ type FormValues = {
direction: string;
conflict_policy: string;
poll_interval_ms: number;
agent_id?: number | null;
app_slug?: string;
local: EndpointForm;
remote: EndpointForm;
pk_columns: string; // table:pk,table:pk
@@ -57,6 +73,56 @@ function parsePKs(s: string): Record<string, string> {
return out;
}
/** DSN 脱敏file 库尽量只露文件名 */
function formatDsnHint(dsn: string): string {
const masked = (dsn || "").replace(/:[^:@/]+@/, ":***@");
const fileMatch = masked.match(/(?:^|[\\/])([^\\/?#]+\.db)(?:\?|$)/i);
if (fileMatch) return fileMatch[1];
if (masked.length > 56) return `${masked.slice(0, 56)}`;
return masked;
}
function bindingLocalLabel(b: SyncBinding): string {
return (b.database_name || "").trim() || b.local_database_id;
}
function bindingOnlineLabel(b: SyncBinding): string {
return (b.display_name || "").trim() || b.online_db_id;
}
/** 联调烟雾 Binding占位 id易被误认为「库名对不上」 */
function isSmokeBinding(b: SyncBinding): boolean {
const note = (b.note || "").toLowerCase();
if (note.includes("smoke") || note.includes("user-jwt")) return true;
const local = (b.local_database_id || "").toLowerCase();
const online = (b.online_db_id || "").toLowerCase();
return local.startsWith("local_") || online.startsWith("online_");
}
/** 产品:与本地一致的库名优先,勿用落库文件名当主标题 */
function channelOnlineTitle(ch: SyncChannel, bindings: SyncBinding[]): string {
const linked = bindings.filter((b) => b.channel_id === ch.id);
const localNames = linked.map((b) => (b.database_name || "").trim()).filter(Boolean);
if (localNames.length) return localNames[0];
const onlineNames = linked.map((b) => (b.display_name || "").trim()).filter(Boolean);
if (onlineNames.length) return onlineNames[0];
if ((ch.name || "").trim()) return ch.name.trim();
return ch.remote?.driver || "线上库";
}
function channelOnlineIds(ch: SyncChannel, bindings: SyncBinding[]): string {
const linked = bindings.filter((b) => b.channel_id === ch.id);
const ids = linked.map((b) => b.online_db_id).filter(Boolean);
const uniq = [...new Set(ids)];
if (!uniq.length) return "";
// 有可读本地名时副文案带上 local↔online id便于对账
const localName = linked.map((b) => (b.database_name || "").trim()).find(Boolean);
if (localName && linked[0]?.local_database_id) {
return `${linked[0].local_database_id}${uniq.join(" · ")}`;
}
return uniq.join(" · ");
}
function toChannelBody(v: FormValues, id?: string): Partial<SyncChannel> {
return {
id,
@@ -64,6 +130,8 @@ function toChannelBody(v: FormValues, id?: string): Partial<SyncChannel> {
direction: v.direction,
conflict_policy: v.conflict_policy,
poll_interval_ms: v.poll_interval_ms || 500,
agent_id: v.agent_id || undefined,
app_slug: (v.app_slug || "").trim() || undefined,
local: {
driver: v.local.driver,
dsn: v.local.dsn,
@@ -94,16 +162,48 @@ export function SyncPage(props: {
const { session, busy, setBusy, setError, setInfo } = props;
const { message } = AntApp.useApp();
const [items, setItems] = useState<SyncChannel[]>([]);
const [bindings, setBindings] = useState<SyncBinding[]>([]);
const [agents, setAgents] = useState<AgentAccount[]>([]);
const [appOptions, setAppOptions] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<SyncChannel | null>(null);
const [showSmokeBindings, setShowSmokeBindings] = useState(false);
const [filterAgentId, setFilterAgentId] = useState<number | undefined>(undefined);
const [inspectOpen, setInspectOpen] = useState(false);
const [inspectCh, setInspectCh] = useState<SyncChannel | null>(null);
const [inspectSide, setInspectSide] = useState<"remote" | "local">("remote");
const [inspectData, setInspectData] = useState<SyncInspectResult | null>(null);
const [inspectLoading, setInspectLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewData, setPreviewData] = useState<SyncPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [form] = Form.useForm<FormValues>();
const visibleBindings = showSmokeBindings
? bindings
: bindings.filter((b) => !isSmokeBinding(b));
const smokeHidden = bindings.length - visibleBindings.length;
const visibleChannels = filterAgentId
? items.filter((c) => c.agent_id === filterAgentId || agents.find((a) => a.agent_id === filterAgentId)?.channel_id === c.id)
: items;
async function refresh() {
setLoading(true);
try {
const ch = await listSyncChannels(session);
const [ch, bind, ag, apps] = await Promise.all([
listSyncChannels(session),
listSyncBindings(session).catch(() => ({ items: [] as SyncBinding[] })),
listAgents(session).catch(() => ({ items: [] as AgentAccount[] })),
listApps(session).catch(() => ({ items: [] as { slug: string; name: string; status: string }[] })),
]);
setItems(ch.items || []);
setBindings(bind.items || []);
setAgents(ag.items || []);
setAppOptions(
(apps.items || [])
.filter((a) => a.status === "published" || !a.status)
.map((a) => ({ value: a.slug, label: `${a.name || a.slug} (${a.slug})` }))
);
} catch (e: any) {
const msg = e.message || String(e);
setError(msg);
@@ -125,10 +225,12 @@ export function SyncPage(props: {
direction: "local_to_remote",
conflict_policy: "lww_source",
poll_interval_ms: 500,
agent_id: undefined,
app_slug: undefined,
local: { driver: "sqlite", dsn: "file:./data/local.db", tables: "article" },
remote: {
driver: "mysql",
dsn: "user:pass@tcp(127.0.0.1:3306)/app?parseTime=true&charset=utf8mb4",
driver: "postgres",
dsn: "postgres://user:pass@127.0.0.1:5432/app?sslmode=disable",
tables: "article",
},
pk_columns: "article:id",
@@ -143,6 +245,8 @@ export function SyncPage(props: {
direction: ch.direction,
conflict_policy: ch.conflict_policy,
poll_interval_ms: ch.poll_interval_ms,
agent_id: ch.agent_id || undefined,
app_slug: ch.app_slug || undefined,
local: {
driver: ch.local.driver,
dsn: ch.local.dsn,
@@ -160,6 +264,78 @@ export function SyncPage(props: {
setOpen(true);
}
async function openInspect(ch: SyncChannel, side: "remote" | "local" = "remote") {
setInspectCh(ch);
setInspectSide(side);
setInspectOpen(true);
setInspectData(null);
setInspectLoading(true);
try {
const data = await inspectSyncChannel(session, ch.id, { side, include_sync_meta: true });
setInspectData(data);
} catch (e: any) {
message.error(e.message || String(e));
setInspectOpen(false);
} finally {
setInspectLoading(false);
}
}
async function openPreview(table: string) {
if (!inspectCh) return;
setPreviewLoading(true);
setPreviewOpen(true);
setPreviewData(null);
try {
const data = await previewSyncTable(session, inspectCh.id, {
table,
side: inspectSide,
limit: 50,
});
setPreviewData(data);
} catch (e: any) {
message.error(e.message || String(e));
setPreviewOpen(false);
} finally {
setPreviewLoading(false);
}
}
function confirmDropTable(table: string) {
if (!inspectCh) return;
const sideLabel = inspectSide === "remote" ? "线上库" : "本机端(通道 local";
Modal.confirm({
title: `删除表「${table}」?`,
content: (
<div>
<p>
<strong>{sideLabel}</strong>
</p>
<p style={{ marginBottom: 0 }}>
线<strong></strong> push/ensure 线
</p>
</div>
),
okText: "确认删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
const res = await dropSyncTable(session, inspectCh.id, {
table,
side: inspectSide,
});
message.success(res.message || (res.dropped ? "已删除" : "表不存在"));
if (previewData?.table === table) setPreviewOpen(false);
await openInspect(inspectCh, inspectSide);
} catch (e: any) {
message.error(e.message || String(e));
throw e;
}
},
});
}
async function onSave() {
const v = await form.validateFields();
setBusy(true);
@@ -204,25 +380,39 @@ export function SyncPage(props: {
</Typography.Title>
<Typography.Paragraph type="secondary">
<strong></strong>
<strong></strong> <Typography.Text code>local_dbsync</Typography.Text> 线
线
/--
<strong></strong> DSN / 线
<Typography.Text code>Binding</Typography.Text> + JWT
<strong></strong><strong></strong>
<strong> + </strong>/线/
TokenZ8f
线/ pull·bootstrap 线<strong></strong>线
线 A <Typography.Text code>postgres</Typography.Text>
</Typography.Paragraph>
<Space style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 12 }} wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void refresh()}>
</Button>
<Select
allowClear
placeholder="按智能体筛选通道"
style={{ minWidth: 220 }}
value={filterAgentId}
onChange={(v) => setFilterAgentId(v)}
options={agents.map((a) => ({
value: a.agent_id,
label: `${a.name}${a.channel_id ? " · 已绑通道" : ""}`,
}))}
/>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={items}
dataSource={visibleChannels}
pagination={false}
columns={[
{ title: "名称", dataIndex: "name" },
@@ -237,15 +427,37 @@ export function SyncPage(props: {
},
{
title: "线上",
render: (_: unknown, r: SyncChannel) => (
<span>
{r.remote.driver}
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{(r.remote.dsn || "").replace(/:[^:@/]+@/, ":***@")}
</Typography.Text>
</span>
),
render: (_: unknown, r: SyncChannel) => {
const title = channelOnlineTitle(r, bindings);
const ids = channelOnlineIds(r, bindings);
const path = formatDsnHint(r.remote?.dsn || "");
return (
<span>
<Typography.Text strong>{title}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{r.remote?.driver}
{ids ? ` · ${ids}` : ""}
{path ? ` · ${path}` : ""}
</Typography.Text>
</span>
);
},
},
{
title: "智能体/模块",
render: (_: unknown, r: SyncChannel) => {
const ag = agents.find((a) => a.agent_id === r.agent_id);
return (
<span>
{ag ? ag.name : r.agent_id ? `#${r.agent_id}` : "—"}
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{r.app_slug || "未绑模块"}
</Typography.Text>
</span>
);
},
},
{
title: "状态",
@@ -253,14 +465,25 @@ export function SyncPage(props: {
r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: "统计",
render: (_: unknown, r: SyncChannel) =>
`${r.stats?.pushed_ok || 0}${r.stats?.pulled_ok || 0}`,
title: "推送次数",
render: (_: unknown, r: SyncChannel) => (
<span>
{r.stats?.pushed_ok || 0}
{r.stats?.pushed_skipped ? `(跳过 ${r.stats.pushed_skipped}` : ""}
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
push
</Typography.Text>
</span>
),
},
{
title: "操作",
render: (_: unknown, r: SyncChannel) => (
<Space wrap>
<Button size="small" icon={<TableOutlined />} onClick={() => void openInspect(r, "remote")}>
线
</Button>
<Button size="small" onClick={() => openEdit(r)}>
</Button>
@@ -336,10 +559,234 @@ export function SyncPage(props: {
]}
/>
<Typography.Title level={5} style={{ marginTop: 28 }}>
线
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
<Typography.Text code>local_database_id</Typography.Text> {" "}
<Typography.Text code>online_db_id</Typography.Text>{" "}
<strong></strong>
ensure <Typography.Text code>database_name</Typography.Text> /{" "}
<Typography.Text code>display_name</Typography.Text>
线 Binding
<Typography.Text code>local_*</Typography.Text> / note=smoke
</Typography.Paragraph>
<Space style={{ marginBottom: 8 }}>
<Checkbox
checked={showSmokeBindings}
onChange={(e) => setShowSmokeBindings(e.target.checked)}
>
Binding
{smokeHidden > 0 ? `(已隐藏 ${smokeHidden} 条)` : ""}
</Checkbox>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={visibleBindings}
pagination={false}
locale={{ emptyText: "暂无 Binding客户端选「同步」并登记后出现" }}
columns={[
{
title: "本地库名",
render: (_: unknown, b: SyncBinding) => (
<span>
<Typography.Text strong>{bindingLocalLabel(b)}</Typography.Text>
{b.database_name ? (
<>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{b.local_database_id}
</Typography.Text>
</>
) : null}
</span>
),
},
{
title: "线上库名",
render: (_: unknown, b: SyncBinding) => (
<span>
<Typography.Text strong>{bindingOnlineLabel(b)}</Typography.Text>
{b.display_name ? (
<>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{b.online_db_id}
</Typography.Text>
</>
) : null}
</span>
),
},
{
title: "映射",
render: (_: unknown, b: SyncBinding) => (
<Typography.Text style={{ fontSize: 13 }}>
{bindingLocalLabel(b)} {bindingOnlineLabel(b)}
</Typography.Text>
),
},
{
title: "通道",
dataIndex: "channel_id",
render: (id: string) => {
if (!id) return "—";
const ch = items.find((c) => c.id === id);
if (!ch) {
return (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
· {id.slice(0, 8)}
</Typography.Text>
);
}
const tables = (ch.remote?.tables || []).join(", ") || "(按 Binding 任意表)";
return (
<span>
{ch.name || id}
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{tables}
</Typography.Text>
</span>
);
},
},
{ title: "备注", dataIndex: "note", ellipsis: true },
]}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
LWW 5
= agent push
线 push Binding
</Typography.Paragraph>
<Modal
title={
inspectCh
? `线上库表 · ${channelOnlineTitle(inspectCh, bindings)}${inspectSide === "local" ? "(本机端)" : ""}`
: "库表"
}
open={inspectOpen}
onCancel={() => setInspectOpen(false)}
width={820}
footer={
<Space>
<Button
loading={inspectLoading}
onClick={() => inspectCh && void openInspect(inspectCh, inspectSide === "remote" ? "local" : "remote")}
>
{inspectSide === "remote" ? "本机端" : "线上"}
</Button>
<Button
loading={inspectLoading}
onClick={() => inspectCh && void openInspect(inspectCh, inspectSide)}
>
</Button>
<Button type="primary" onClick={() => setInspectOpen(false)}>
</Button>
</Space>
}
>
{inspectData?.dsn_hint ? (
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
{inspectData.driver} · {inspectData.dsn_hint}
{inspectData.message ? ` · ${inspectData.message}` : ""}
</Typography.Paragraph>
) : null}
<Table
rowKey="name"
loading={inspectLoading}
dataSource={(inspectData?.tables || []).filter((t) => !String(t.name || "").startsWith("_ajz_"))}
pagination={false}
size="small"
locale={{ emptyText: "暂无业务表(尚未 push 或连不上库)" }}
columns={[
{ title: "表名", dataIndex: "name" },
{
title: "行数",
dataIndex: "row_count",
width: 90,
render: (n: number) => <Typography.Text strong>{n}</Typography.Text>,
},
{
title: "字段数",
dataIndex: "column_count",
width: 90,
},
{
title: "字段",
dataIndex: "columns",
ellipsis: true,
render: (cols: string[]) => (cols || []).join(", "),
},
{
title: "操作",
width: 168,
render: (_: unknown, t: { name: string; row_count: number }) => (
<Space size={4}>
<Button size="small" icon={<EyeOutlined />} onClick={() => void openPreview(t.name)}>
</Button>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => confirmDropTable(t.name)}
>
</Button>
</Space>
),
},
]}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
{(inspectData?.tables || []).some((t) => String(t.name || "").startsWith("_ajz_")) ? (
<>
<Typography.Text code>_ajz_*</Typography.Text>
/outbox
</>
) : null}
= push/ensure
</Typography.Paragraph>
</Modal>
<Modal
title={previewData ? `预览 · ${previewData.table}(共 ${previewData.total} 行,显示前 ${previewData.limit}` : "预览"}
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
width={960}
footer={
<Button type="primary" onClick={() => setPreviewOpen(false)}>
</Button>
}
>
<Table
loading={previewLoading}
dataSource={(previewData?.rows || []).map((row, i) => ({ ...row, __k: i }))}
rowKey="__k"
size="small"
scroll={{ x: true }}
pagination={false}
columns={(previewData?.columns || []).map((c) => ({
title: c,
dataIndex: c,
ellipsis: true,
render: (v: unknown) =>
v === null || v === undefined ? (
<Typography.Text type="secondary">null</Typography.Text>
) : (
String(v)
),
}))}
/>
</Modal>
<Modal
title={editing ? "编辑同步通道" : "新建同步通道"}
open={open}
@@ -361,6 +808,27 @@ export function SyncPage(props: {
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="agent_id"
label="所属智能体"
extra="哪个智能体的库就选哪个;模块数据应落在该智能体对应线上库"
>
<Select
allowClear
placeholder="可选"
options={agents.map((a) => ({
value: a.agent_id,
label: `${a.name} (#${a.agent_id})`,
}))}
/>
</Form.Item>
<Form.Item
name="app_slug"
label="关联模块"
extra="已发布模块;本地上传/智能体管理的模块数据与此对照"
>
<Select allowClear showSearch placeholder="可选" options={appOptions} />
</Form.Item>
<Form.Item name="direction" label="方向" rules={[{ required: true }]}>
<Select
options={[
@@ -403,20 +871,31 @@ export function SyncPage(props: {
name={["local", "tables"]}
label="表(逗号分隔)"
rules={[{ required: true }]}
extra="同步表白名单表须为 UUID TEXT 主键(不可自增);外键闭包表须一并列入"
extra="通道表名单仅作提示;用户 JWT + Binding 下任意表可 push"
>
<Input placeholder="article,order" />
</Form.Item>
<Typography.Text strong>线</Typography.Text>
<Form.Item name={["remote", "driver"]} label="驱动" rules={[{ required: true }]}>
<Select options={[{ value: "mysql" }, { value: "postgres" }, { value: "sqlite" }]} />
<Typography.Text strong>线 A Postgres</Typography.Text>
<Form.Item
name={["remote", "driver"]}
label="驱动"
rules={[{ required: true }]}
extra="生产推荐 postgresmysql 兼容存量sqlite 仅联调临时 A不宜多 writer"
>
<Select
options={[
{ value: "postgres", label: "postgres推荐" },
{ value: "mysql", label: "mysql" },
{ value: "sqlite", label: "sqlite仅联调" },
]}
/>
</Form.Item>
<Form.Item
name={["remote", "dsn"]}
label="线上 DSN"
rules={[{ required: true }]}
extra="MySQL 例user:pass@tcp(host:3306)/dbname?parseTime=true"
extra="Postgres 例postgres://user:pass@127.0.0.1:5432/dbname?sslmode=disable"
>
<Input.TextArea rows={2} />
</Form.Item>
@@ -424,7 +903,7 @@ export function SyncPage(props: {
name={["remote", "tables"]}
label="表(逗号分隔)"
rules={[{ required: true }]}
extra="与本地表白名单一致;须满足 UUID TEXT PK + FK 闭包"
extra="与本地提示表一致即可;验同步请用「查看线上表」"
>
<Input />
</Form.Item>