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

@@ -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>
);
}

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

View File

@@ -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}` },