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

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