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:
@@ -33,6 +33,8 @@ import {
|
||||
listPlatformPermModules,
|
||||
listPlatformTenantAdmins,
|
||||
listPlatformTenants,
|
||||
listPlatformLwwOverrides,
|
||||
rollbackPlatformLwwOverride,
|
||||
setPlatformTenantPerms,
|
||||
updatePlatformTenant,
|
||||
updatePlatformTenantAdmin,
|
||||
@@ -81,6 +83,10 @@ export function PlatformTenantsPage(props: {
|
||||
phone?: string;
|
||||
company: string;
|
||||
} | null>(null);
|
||||
const [lwwItems, setLwwItems] = useState<
|
||||
Awaited<ReturnType<typeof listPlatformLwwOverrides>>["items"]
|
||||
>([]);
|
||||
const [lwwLoading, setLwwLoading] = useState(false);
|
||||
const [form] = Form.useForm<{ name: string; slug: string; admin_phone?: string; with_invite?: boolean }>();
|
||||
const [renameForm] = Form.useForm<{ name: string; slug: string }>();
|
||||
const [adminForm] = Form.useForm<{
|
||||
@@ -104,6 +110,18 @@ export function PlatformTenantsPage(props: {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLww() {
|
||||
setLwwLoading(true);
|
||||
try {
|
||||
const res = await listPlatformLwwOverrides(session, { limit: 100 });
|
||||
setLwwItems(res.items || []);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || String(e));
|
||||
} finally {
|
||||
setLwwLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAdminCreds(row: TenantRow) {
|
||||
setEditing(row);
|
||||
setBusy(true);
|
||||
@@ -157,6 +175,7 @@ export function PlatformTenantsPage(props: {
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
void refreshLww();
|
||||
listPlatformPermModules(session)
|
||||
.then((r) => {
|
||||
setModules(r.modules || []);
|
||||
@@ -730,6 +749,72 @@ export function PlatformTenantsPage(props: {
|
||||
})}
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 32 }}>
|
||||
LWW 覆盖日志(仅超管)
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
同步自动覆盖时的落败/胜出记录;公司管理员不可见。默认保留 90 天。
|
||||
对「源端覆盖」记录可按落败快照回滚线上单行(需通道 remote 仍可达)。
|
||||
</Typography.Paragraph>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Button icon={<ReloadOutlined />} loading={lwwLoading} onClick={() => void refreshLww()}>
|
||||
刷新日志
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={lwwLoading}
|
||||
dataSource={lwwItems}
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: "公司", dataIndex: "tenant_id", width: 80 },
|
||||
{ title: "入口", dataIndex: "entry", width: 100 },
|
||||
{ title: "表", dataIndex: "table", width: 120 },
|
||||
{ title: "主键", dataIndex: "row_pk", ellipsis: true },
|
||||
{ title: "策略", dataIndex: "policy", width: 110 },
|
||||
{ title: "结果", dataIndex: "outcome", width: 120 },
|
||||
{
|
||||
title: "时间",
|
||||
dataIndex: "created_at",
|
||||
width: 180,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString() : ""),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 100,
|
||||
render: (_: unknown, r: (typeof lwwItems)[number]) =>
|
||||
r.outcome === "applied_source" ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={busy}
|
||||
onClick={() => {
|
||||
modal.confirm({
|
||||
title: "按落败快照回滚线上单行?",
|
||||
content: `${r.table} / ${r.row_pk}`,
|
||||
onOk: async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await rollbackPlatformLwwOverride(session, r.id);
|
||||
message.success("已回滚并记入审计");
|
||||
await refreshLww();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
回滚
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -937,16 +937,18 @@ export async function stopSyncChannel(session: Session, id: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** @deprecated 公司侧已 403;请用 listPlatformLwwOverrides */
|
||||
export async function listSyncConflicts(session: Session, unresolved = true) {
|
||||
const q = unresolved ? "?unresolved=1" : "?unresolved=0";
|
||||
const res = await apiFetch(`/api/v1/admin/sync/conflicts${q}`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "list conflicts failed");
|
||||
throwIfBad(res, data, "list conflicts failed (公司侧已禁用,请用超管 LWW 审计)");
|
||||
return data as { items: SyncConflict[] };
|
||||
}
|
||||
|
||||
/** @deprecated 公司侧已 403 */
|
||||
export async function resolveSyncConflict(session: Session, id: string, resolution: string) {
|
||||
const res = await apiFetch(`/api/v1/admin/sync/conflicts/${encodeURIComponent(id)}/resolve`, {
|
||||
method: "POST",
|
||||
@@ -957,7 +959,7 @@ export async function resolveSyncConflict(session: Session, id: string, resoluti
|
||||
body: JSON.stringify({ resolution }),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "resolve conflict failed");
|
||||
throwIfBad(res, data, "resolve conflict failed (公司侧已禁用)");
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1000,6 +1002,89 @@ export async function ingestSyncRows(
|
||||
return data as { ok: boolean; ingested: number; hint?: string };
|
||||
}
|
||||
|
||||
export async function ensureSyncBinding(
|
||||
session: Session,
|
||||
body: {
|
||||
local_database_id: string;
|
||||
online_db_id: string;
|
||||
channel_id?: string;
|
||||
note?: string;
|
||||
user_id?: number;
|
||||
}
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/sync/bindings`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "ensure binding failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listSyncBindings(session: Session, localDatabaseId?: string) {
|
||||
const q = localDatabaseId
|
||||
? `?local_database_id=${encodeURIComponent(localDatabaseId)}`
|
||||
: "";
|
||||
const res = await apiFetch(`/api/v1/admin/sync/bindings${q}`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "list bindings failed");
|
||||
return data as { items: Array<Record<string, unknown>> };
|
||||
}
|
||||
|
||||
export async function listPlatformLwwOverrides(
|
||||
session: Session,
|
||||
opts?: { tenant_id?: number; channel_id?: string; limit?: number }
|
||||
) {
|
||||
const q = new URLSearchParams();
|
||||
if (opts?.tenant_id) q.set("tenant_id", String(opts.tenant_id));
|
||||
if (opts?.channel_id) q.set("channel_id", opts.channel_id);
|
||||
if (opts?.limit) q.set("limit", String(opts.limit));
|
||||
const qs = q.toString();
|
||||
const res = await apiFetch(`/api/v1/platform/dbsync/lww-overrides${qs ? `?${qs}` : ""}`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "list lww overrides failed");
|
||||
return data as {
|
||||
items: Array<{
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
channel_id: string;
|
||||
table: string;
|
||||
row_pk: string;
|
||||
op: string;
|
||||
entry: string;
|
||||
policy: string;
|
||||
outcome: string;
|
||||
loser_payload?: string;
|
||||
winner_payload?: string;
|
||||
target_ver: number;
|
||||
source_ver: number;
|
||||
created_at: string;
|
||||
}>;
|
||||
hint?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function rollbackPlatformLwwOverride(session: Session, id: string) {
|
||||
const res = await apiFetch(
|
||||
`/api/v1/platform/dbsync/lww-overrides/${encodeURIComponent(id)}/rollback`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
}
|
||||
);
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "lww rollback failed");
|
||||
return data as { ok: boolean; record?: Record<string, unknown>; hint?: string };
|
||||
}
|
||||
|
||||
export async function listPlatformTenants(session: Session) {
|
||||
const res = await apiFetch(`/api/v1/platform/tenants`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
|
||||
Reference in New Issue
Block a user