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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user