# 跨库数据同步中间件

支持 **SQLite ↔ MySQL ↔ Postgres**，不要求两端同一种数据库。变更经 **outbox 队列** 近实时投递；冲突进 **冲突队列**。

## 权限与隔离

| 项 | 说明 |
|----|------|
| 谁可配 | 仅公司**顶级权限（管理员）**，权限名「数据同步」 |
| 谁不可 | 编辑 / 只读、智能体账号（即使有「发布模块」） |
| 数据隔离 | 通道与冲突带 `tenant_id`；公司 A 看不到公司 B 的通道/DSN |
| 多服务器 | 同一公司可建多条通道，分别填 B、C 等库的 DSN |

## 典型场景：A / B / C

| 端 | 角色 |
|----|------|
| **A** | 线上库（用户增删改） |
| **B** | 本地库（本机业务 + 接收 C） |
| **C** | 额外数据源（Excel / API / 导入），只写入 **B** |

推荐配置：

1. 建一条通道：`local` = B，`remote` = A，**方向 `bidirectional`**，冲突策略 `queue`（或 LWW）。
2. C 的数据用 **ingest API**（或业务直接写 B）写入本地；触发器进 outbox，再推到 A。
3. A 上用户改的数据经 outbox 拉回 B。
4. 怀疑漏数时点 **对账**，或等双向通道约每分钟自动对账。

如何保证**不漏、不多**：

| 手段 | 防什么 |
|------|--------|
| 表触发器 → `_ajz_sync_outbox` | 漏（本地/线上变更必入队） |
| 应用远端时 `WithApplying`（触发器不写 outbox） | 多（A↔B 回声环） |
| 目标 meta 版本相等则跳过 | 多（重复投递） |
| 目标版本更新 → 冲突队列 / LWW | 并发改同一行 |
| 主键对账 reconcile | 漏（存量差、触发器未装前的行） |
| C→B upsert 同主键 | 多（重复灌入） |

```
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 |

配置与冲突持久化：`data/dbsync/channels.json`、`conflicts.json`（Docker：`.runtime/dbsync`）。

## 控制台用法

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

## API（需公司顶级权限「数据同步」/ 管理员）

| 方法 | 路径 |
|------|------|
| GET/POST | `/api/v1/admin/sync/channels` |
| GET/PUT/DELETE | `/api/v1/admin/sync/channels/{id}` |
| 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` |

### ingest 示例

```json
POST /api/v1/admin/sync/channels/{id}/ingest
{
  "table": "article",
  "source": "excel",
  "rows": [
    { "id": "c-001", "title": "来自 C" }
  ]
}
```

按主键 upsert 写入本地 B，触发器入 outbox，worker 再推到线上 A。

## 注意

- 两端业务表结构需兼容（同名列）；主键默认 `id`，可用 `pk_columns` 覆盖。
- MySQL 需账号有建触发器权限。
- 密钥在 DSN 中；列表页会打码显示。
- 「实时」为亚秒级轮询 + 触发器，非 MySQL binlog CDC；同机延迟通常 &lt;1s。
- 对账按**主键集合**补缺行，不做逐字段内容 diff；同 PK 内容冲突仍靠版本 / 冲突队列。
