feat: ship loose-offline dbsync (validate, agent push, LWW audit)

Add UUID/FK channel checks, agent whitelist/push APIs, bindings, super-admin LWW audit with rollback, reconcile rate limits, and sync docs. Default customers stay opt-in; company conflict UI is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 17:54:14 +08:00
parent 632057c857
commit 76cdcd760e
39 changed files with 3302 additions and 199 deletions

View File

@@ -22,13 +22,10 @@ import {
import {
Session,
SyncChannel,
SyncConflict,
createSyncChannel,
deleteSyncChannel,
listSyncChannels,
listSyncConflicts,
reconcileSyncChannel,
resolveSyncConflict,
startSyncChannel,
stopSyncChannel,
testSyncEndpoints,
@@ -97,7 +94,6 @@ export function SyncPage(props: {
const { session, busy, setBusy, setError, setInfo } = props;
const { message } = AntApp.useApp();
const [items, setItems] = useState<SyncChannel[]>([]);
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<SyncChannel | null>(null);
@@ -106,12 +102,8 @@ export function SyncPage(props: {
async function refresh() {
setLoading(true);
try {
const [ch, cf] = await Promise.all([
listSyncChannels(session),
listSyncConflicts(session, true),
]);
const ch = await listSyncChannels(session);
setItems(ch.items || []);
setConflicts(cf.items || []);
} catch (e: any) {
const msg = e.message || String(e);
setError(msg);
@@ -130,8 +122,8 @@ export function SyncPage(props: {
setEditing(null);
form.setFieldsValue({
name: "本地 B ↔ 线上 A",
direction: "bidirectional",
conflict_policy: "queue",
direction: "local_to_remote",
conflict_policy: "lww_source",
poll_interval_ms: 500,
local: { driver: "sqlite", dsn: "file:./data/local.db", tables: "article" },
remote: {
@@ -212,9 +204,10 @@ export function SyncPage(props: {
</Typography.Title>
<Typography.Paragraph type="secondary">
<strong></strong>
A 线 B C B A
+
<strong></strong>
<strong></strong> <Typography.Text code>local_dbsync</Typography.Text> 线
线
/--
</Typography.Paragraph>
<Space style={{ marginBottom: 12 }}>
@@ -262,7 +255,7 @@ export function SyncPage(props: {
{
title: "统计",
render: (_: unknown, r: SyncChannel) =>
`${r.stats?.pushed_ok || 0}${r.stats?.pulled_ok || 0} 冲突${r.stats?.conflicts || 0}`,
`${r.stats?.pushed_ok || 0}${r.stats?.pulled_ok || 0}`,
},
{
title: "操作",
@@ -310,17 +303,22 @@ export function SyncPage(props: {
(x) =>
`${x.table}: 补推${x.patched_push} 补拉${x.patched_pull}(仅本地${(x.only_local || []).length} 仅线上${(x.only_remote || []).length}`
);
message.success(parts.length ? parts.join("") : "对账完成,无差异");
setInfo("对账完成");
message.success(parts.length ? parts.join("") : "同步修复完成,无差异");
setInfo("同步修复完成");
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
const msg = e.message || String(e);
if (/过于频繁|429|Too Many/i.test(msg)) {
message.warning(msg || "对账过于频繁,请稍后再试");
} else {
message.error(msg);
}
} finally {
setBusy(false);
}
}}
>
</Button>
<Button
size="small"
@@ -338,46 +336,9 @@ export function SyncPage(props: {
]}
/>
<Typography.Title level={5} style={{ marginTop: 24 }}>
</Typography.Title>
<Table
rowKey="id"
dataSource={conflicts}
pagination={false}
columns={[
{ title: "通道", dataIndex: "channel_id", ellipsis: true },
{ title: "表", dataIndex: "table" },
{ title: "主键", dataIndex: "row_pk" },
{ title: "来源", dataIndex: "source" },
{ title: "说明", dataIndex: "message", ellipsis: true },
{
title: "操作",
render: (_: unknown, r: SyncConflict) => (
<Space>
<Button
size="small"
onClick={async () => {
await resolveSyncConflict(session, r.id, "keep_target");
await refresh();
}}
>
</Button>
<Button
size="small"
onClick={async () => {
await resolveSyncConflict(session, r.id, "discard");
await refresh();
}}
>
</Button>
</Space>
),
},
]}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
LWW 5
</Typography.Paragraph>
<Modal
title={editing ? "编辑同步通道" : "新建同步通道"}
@@ -409,12 +370,16 @@ export function SyncPage(props: {
]}
/>
</Form.Item>
<Form.Item name="conflict_policy" label="冲突策略">
<Form.Item
name="conflict_policy"
label="冲突策略"
extra="自动 LWW覆盖日志仅平台超级管理员可查公司侧无冲突台"
>
<Select
options={[
{ value: "queue", label: "入冲突队列(推荐" },
{ value: "lww_source", label: "源端覆盖" },
{ value: "lww_source", label: "源端覆盖推荐B→A" },
{ value: "lww_target", label: "保留目标" },
{ value: "queue", label: "入队(调试用,租户不可见)" },
]}
/>
</Form.Item>
@@ -434,7 +399,12 @@ export function SyncPage(props: {
>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name={["local", "tables"]} label="表(逗号分隔)" rules={[{ required: true }]}>
<Form.Item
name={["local", "tables"]}
label="表(逗号分隔)"
rules={[{ required: true }]}
extra="同步表白名单表须为 UUID TEXT 主键(不可自增);外键闭包表须一并列入"
>
<Input placeholder="article,order" />
</Form.Item>
@@ -450,7 +420,12 @@ export function SyncPage(props: {
>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name={["remote", "tables"]} label="表(逗号分隔)" rules={[{ required: true }]}>
<Form.Item
name={["remote", "tables"]}
label="表(逗号分隔)"
rules={[{ required: true }]}
extra="与本地表白名单一致;须满足 UUID TEXT PK + FK 闭包"
>
<Input />
</Form.Item>
<Form.Item