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

@@ -1,69 +1,69 @@
# 跨库数据同步中间件
支持 **SQLite ↔ MySQL ↔ Postgres**,不要求两端同一种数据库。变更经 **outbox 队列** 近实时投递;冲突进 **冲突队列**
支持 **SQLite ↔ MySQL ↔ Postgres**。平台侧 worker 可轮询两端 outbox**形态 B本机 agent** 下由终端 agent 经平台 **push** 写线上 A平台**不直连用户本机 SQLite**
冲突策略默认 **自动 LWW源端覆盖**;落败写入**平台超级管理员**覆盖日志。公司管理员**无冲突台**。
## 权限与隔离
| 项 | 说明 |
|----|------|
| 谁可配 | 公司**顶级权限(管理员)**权限名「数据同步」 |
| 谁不可 | 编辑 / 只读、智能体账号(即使有「发布模块」 |
| 数据隔离 | 通道与冲突带 `tenant_id`;公司 A 看不到公司 B 的通道/DSN |
| 多服务器 | 同一公司可建多条通道,分别填 B、C 等库的 DSN |
| 谁可配通道 | 公司**顶级权限(管理员)**,「数据同步」 |
| Agent push / 拉白名单 | JWT 含「数据同步」(人类管理员或智能体凭证 |
| LWW 覆盖审计 | **仅平台超级管理员**;公司 top → 403 |
| 数据隔离 | 通道带 `tenant_id`;公司 A 看不到公司 B |
## 典型场景A / B / C
## 推荐场景(松离线 B→A
| 端 | 角色 |
|----|------|
| **A** | 线上库(用户增删改) |
| **B** | 本地库(本机业务 + 接收 C |
| **C** | 额外数据源Excel / API / 导入),只写入 **B** |
| **B** | 本机正式库UUID 主键);开通且白名单表本地可见 |
| **A** | 线上库agent 经平台 push 幂等写入 |
| **Agent** | 读本机 outbox → `POST /api/v1/agent/sync/channels/:id/push` |
推荐配置:
1. 建一条通道:`local` = B`remote` = A**方向 `bidirectional`**,冲突策略 `queue`(或 LWW
2. C 的数据用 **ingest API**(或业务直接写 B写入本地触发器进 outbox再推到 A。
3. A 上用户改的数据经 outbox 拉回 B。
4. 怀疑漏数时点 **对账**,或等双向通道约每分钟自动对账
1. 通道:`local` 描述本机表名单`remote` = A 的 DSN方向 **`local_to_remote`**;策略 **`lww_source`**。
2. 表白名单须 UUID TEXT PK + FK 闭包(保存时校验)。
3. 客户端显式 `local_dbsync`(见开通说明);未开通用户零感。
4. 怀疑漏数时点 **同步修复(对账**(有最小间隔限流);双向通道自动对账约 **15 分钟** 一次
如何保证**不漏、不多**
| 手段 | 防什么 |
|------|--------|
| 触发器 `_ajz_sync_outbox` | 漏(本地/线上变更必入队) |
| 应用远端时 `WithApplying`(触发器不写 outbox | 多(A↔B 回声 |
| 目标 meta 版本相等则跳过 | 多(重复投递) |
| 目标版本更新 → 冲突队列 / LWW | 并发改同一行 |
| 主键对账 reconcile | 漏(存量差、触发器未装前的行 |
| C→B upsert 同主键 | 多(重复灌入) |
| 触发器 / 本机 outbox → agent push | 漏 |
| `WithApplying` / 远端应用不回写 outbox | 多(回声) |
| 目标 meta 同 version 跳过 | 多(重复投递) |
| LWW + 超管覆盖日志 | 并发同 PK |
| 主键对账 reconcile | 漏(存量差) |
```
C ──ingest/写库──► B (local) ◄──bidirectional outbox──► A (remote)
```
## 能力
## 能力一览
| 项 | 说明 |
|----|------|
| 方言 | `sqlite` / `mysql` / `postgres` |
| 实时性 | 表触发器写 `_ajz_sync_outbox`worker 默认每 500ms 拉取 |
| 方向 | 本地→线上 / 线上→本地 / **双向**A↔B 场景用这个 |
| 冲突 | `queue`(入队)/ `lww_source` / `lww_target` |
| 对账 | `POST .../reconcile`;双向运行中约每分钟自动一次 |
| 外部源 | `POST .../ingest`C → B再同步到 A |
| 配置 | 控制台「数据同步」页;可改线上 DSN |
| 方向 | 本地→线上(推荐)/ 线上→本地 / 双向 |
| 冲突 | 默认 `lww_source``lww_target``queue` 仅调试(租户不可见 |
| Agent | `GET .../agent/sync/.../whitelist``POST .../push` |
| Binding | `GET/POST /api/v1/admin/sync/bindings` |
| 对账 | `POST .../reconcile`(默认最少间隔 300s |
| 审计 | `GET /api/v1/platform/dbsync/lww-overrides`(超管) |
配置与冲突持久化:`data/dbsync/channels.json``conflicts.json`Docker`.runtime/dbsync`)。
持久化:`data/dbsync/channels.json``lww_overrides.json`(及遗留 `conflicts.json`)。
## 控制台用法
1. 登录 → **数据同步****新建通道**
2. 本地 B`sqlite` + `file:./data/local.db`,表名逗号分隔
3. 线上 A`mysql` + `user:pass@tcp(host:3306)/db?parseTime=true`
4. 方向选 **双向****测试连接****保存****启动**
5. 需要补漏时点 **对账**C 数据走业务写 B 或调用 ingest API
1. **数据同步** → 新建通道UUID 表白名单)→ 测试 → 保存 → 启动
2. 默认策略选 **源端覆盖**;方向首期用 **本地 → 线上**
3. **同步修复** 做主键对账(勿连续狂点,有限流)
4. LWW 明细在 **平台工作台**(超管),不在公司同步页
## API需公司顶级权限「数据同步」/ 管理员)
开通与迁移:见 [数据同步-开通说明.md](./数据同步-开通说明.md)、[数据同步-迁移手册.md](./数据同步-迁移手册.md)。
## API
### 公司管理员(「数据同步」)
| 方法 | 路径 |
|------|------|
@@ -72,29 +72,41 @@ C ──ingest/写库──► B (local) ◄──bidirectional outbox──►
| POST | `/api/v1/admin/sync/test` |
| POST | `/api/v1/admin/sync/channels/{id}/prepare\|start\|stop` |
| POST | `/api/v1/admin/sync/channels/{id}/reconcile` |
| POST | `/api/v1/admin/sync/channels/{id}/ingest` |
| GET | `/api/v1/admin/sync/conflicts` |
| POST | `/api/v1/admin/sync/conflicts/{id}/resolve` |
| POST | `/api/v1/admin/sync/channels/{id}/ingest`(写通道 local偏形态 A |
| GET/POST | `/api/v1/admin/sync/bindings` |
| GET/POST | `/api/v1/admin/sync/conflicts*`**403**(已迁超管审计) |
### ingest 示例
### 本机 Agent
| 方法 | 路径 |
|------|------|
| GET | `/api/v1/agent/sync/channels/{id}/whitelist` |
| POST | `/api/v1/agent/sync/channels/{id}/push` |
| POST | `/api/v1/agent/sync/channels/{id}/push/batch` |
### 平台超级管理员
| 方法 | 路径 |
|------|------|
| GET | `/api/v1/platform/dbsync/lww-overrides` |
| POST | `/api/v1/platform/dbsync/lww-overrides/{id}/rollback` |
### push 示例
```json
POST /api/v1/admin/sync/channels/{id}/ingest
POST /api/v1/agent/sync/channels/{id}/push
{
"table": "article",
"source": "excel",
"rows": [
{ "id": "c-001", "title": "来自 C" }
]
"table": "orders",
"op": "insert",
"row_pk": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"row": { "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "title": "x" },
"version": 1710000000000000000,
"client_outbox_id": "..."
}
```
按主键 upsert 写入本地 B触发器入 outboxworker 再推到线上 A。
## 相关
## 注意
- 两端业务表结构需兼容(同名列);主键默认 `id`,可用 `pk_columns` 覆盖。
- MySQL 需账号有建触发器权限。
- 密钥在 DSN 中;列表页会打码显示。
- 「实时」为亚秒级轮询 + 触发器,非 MySQL binlog CDC同机延迟通常 &lt;1s。
- 对账按**主键集合**补缺行,不做逐字段内容 diff同 PK 内容冲突仍靠版本 / 冲突队列。
- [同步表约定.md](./同步表约定.md)
- [数据同步-开通说明.md](./数据同步-开通说明.md)
- [数据同步-迁移手册.md](./数据同步-迁移手册.md)