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:
50
.env.example
50
.env.example
@@ -1,39 +1,31 @@
|
||||
# copy to .env and fill keys
|
||||
# 供应商列表 / 默认模型见 ai-service/etc/llm.yaml(勿在代码里改)
|
||||
# =============================================================================
|
||||
# 生产环境模板(aisite.yuxindazhineng.com)
|
||||
# 用法:cp .env.example .env 后填入 API Key,再 ./restart.sh
|
||||
# 密钥勿提交 Git;供应商说明见 ai-service/etc/llm.yaml
|
||||
# =============================================================================
|
||||
|
||||
# ---- LLM ----
|
||||
LLM_PROVIDER=deepseek
|
||||
DEEPSEEK_API_KEY=
|
||||
MINIMAX_API_KEY=
|
||||
|
||||
# 视觉(可选;默认读 llm.yaml 的 vision 段,也可用环境变量覆盖)
|
||||
# 视觉 / 截图还原度:生产关闭
|
||||
VISION_PROVIDER=dashscope
|
||||
DASHSCOPE_API_KEY=
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
VISION_MODEL=qwen3.6-plus
|
||||
# AI_CONFIG_PATH=/app/etc/llm.yaml
|
||||
FIDELITY_LOOP=0
|
||||
|
||||
# 有截图时:视觉摘录 → 代码出蓝图 → 发布并截真页面 → 对比打分 → 再改,直到 ≥ FIDELITY_TARGET
|
||||
FIDELITY_LOOP=1
|
||||
FIDELITY_TARGET=95
|
||||
FIDELITY_MAX_ROUNDS=4
|
||||
FIDELITY_REAL_SCREEN=1
|
||||
PLATFORM_BASE=http://127.0.0.1:8180
|
||||
WEB_BASE=http://127.0.0.1:5173
|
||||
FIDELITY_USER=demo
|
||||
FIDELITY_PASSWORD=demo123
|
||||
# FIDELITY_SHOT_DIR=E:/project/ai建站/test/_out
|
||||
# ---- 端口(绑 127.0.0.1,由宿主机 Nginx 对外 80/443)----
|
||||
AIJZ_WEB_PUBLISH=127.0.0.1:5173
|
||||
AIJZ_GATEWAY_PUBLISH=127.0.0.1:8180
|
||||
AIJZ_PLATFORM_PUBLISH=127.0.0.1:8888
|
||||
AIJZ_AI_PUBLISH=127.0.0.1:8001
|
||||
AIJZ_PG_PUBLISH=127.0.0.1:5432
|
||||
|
||||
# ---- 同机 Docker 端口(默认绑 127.0.0.1,避开 yh_web 的 8088/9080/9081 与宿主机 80/443)----
|
||||
# AIJZ_WEB_PUBLISH=127.0.0.1:5173
|
||||
# AIJZ_GATEWAY_PUBLISH=127.0.0.1:8180
|
||||
# AIJZ_PLATFORM_PUBLISH=127.0.0.1:8888
|
||||
# AIJZ_AI_PUBLISH=127.0.0.1:8001
|
||||
# AIJZ_PG_PUBLISH=127.0.0.1:5432
|
||||
# 宿主机已有 Postgres 时改为:AIJZ_PG_PUBLISH=127.0.0.1:15432
|
||||
# 需要局域网直连时改为:AIJZ_WEB_PUBLISH=0.0.0.0:5173
|
||||
|
||||
# ---- 对外域名(可选;与同机 yh_web 并存时用不同域名)----
|
||||
# AIJZ_ENABLE_HOST_NGINX=1
|
||||
# AIJZ_DOMAIN=aijz.example.com
|
||||
# AIJZ_PUBLIC_BASE_URL=https://aijz.example.com
|
||||
# 证书放 nginx/<域名>.pem + nginx/<域名>.key,或 nginx/fullchain.pem + privkey.pem
|
||||
# 改配置后:自行 reload 对应服务 / 容器(本机运维脚本不入库)
|
||||
# ---- 对外域名 + 宿主机 Nginx 反代 ----
|
||||
AIJZ_ENABLE_HOST_NGINX=1
|
||||
AIJZ_DOMAIN=aisite.yuxindazhineng.com
|
||||
AIJZ_PUBLIC_BASE_URL=https://aisite.yuxindazhineng.com
|
||||
# 证书:nginx/aisite.yuxindazhineng.com.pem + .key
|
||||
# 或 nginx/fullchain.pem + nginx/privkey.pem
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|------|------|
|
||||
| [智能体-生成发布-能力说明.md](./智能体-生成发布-能力说明.md) | **需求 / 能力边界**(做什么、不做什么、工作流、验收) |
|
||||
| [智能体-生成发布-API.md](./智能体-生成发布-API.md) | **接口契约**(路径、请求/响应、宿主回执字段) |
|
||||
| [数据同步-中间件.md](./数据同步-中间件.md) | 跨库实时同步(SQLite/MySQL/Postgres) |
|
||||
| [数据同步-中间件.md](./数据同步-中间件.md) | 跨库同步能力与 API(LWW / agent push) |
|
||||
| [数据同步-开通说明.md](./数据同步-开通说明.md) | 管理员开通:默认无感、opt-in |
|
||||
| [数据同步-迁移手册.md](./数据同步-迁移手册.md) | O7:旧客从双写迁入(禁止静默) |
|
||||
| [同步表约定.md](./同步表约定.md) | UUID 同步表模板与 agent 接口约定 |
|
||||
| [发版说明-数据同步.md](./发版说明-数据同步.md) | 默认无感 / 增值开通发版摘录 |
|
||||
|
||||
> 业务用语称「**模块**」。HTTP 路径仍为 `/api/v1/apps/...`。智能体默认无需配置模块白名单即可自建发布。
|
||||
|
||||
|
||||
35
docs/发版说明-数据同步.md
Normal file
35
docs/发版说明-数据同步.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 发版说明 · 数据同步(智建平台)
|
||||
|
||||
## 对默认客户
|
||||
|
||||
- **无感**:未显式开通时,不改变现有保存 / 自增表行为。
|
||||
- `local_dbsync` 为**增值开通**(客户端显式配置);平台**不会**全员切默认。
|
||||
|
||||
## 本版本智建已交付
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 通道校验 | UUID TEXT PK + FK 闭包;拒绝自增整数作同步键 |
|
||||
| Agent API | 拉白名单、push / push batch → 线上 A |
|
||||
| Binding | `local_database_id ↔ online_db_id` 登记查询 |
|
||||
| LWW | 默认源端覆盖;超管审计 + 单行回滚;公司 conflicts → 403 |
|
||||
| 同步修复 | 对账限流(默认 300s) |
|
||||
|
||||
## 管理员文档
|
||||
|
||||
- [数据同步-开通说明.md](./数据同步-开通说明.md)
|
||||
- [数据同步-迁移手册.md](./数据同步-迁移手册.md)(旧客迁入,禁止静默)
|
||||
- [同步表约定.md](./同步表约定.md)
|
||||
- [数据同步-中间件.md](./数据同步-中间件.md)
|
||||
|
||||
## 配置(platform)
|
||||
|
||||
```yaml
|
||||
DBSync:
|
||||
Enabled: true
|
||||
DataDir: ./data/dbsync
|
||||
LwwAuditTTLDays: 90
|
||||
ReconcileMinSec: 300
|
||||
```
|
||||
|
||||
客户端 agent / 写网关由对接方自行落地;接口以本文档与 OpenAPI `/api/v1/meta/openapi.yaml` 为准。
|
||||
76
docs/同步表约定.md
Normal file
76
docs/同步表约定.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 同步表约定(M0)
|
||||
|
||||
> 依据:`松离线-dbsync方案-最终版.md`
|
||||
> 默认建表模板**仍为自增**;仅「同步表」使用下列模板。
|
||||
|
||||
## 同步表模板(UUID 主键)
|
||||
|
||||
SQLite 示例:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id TEXT PRIMARY KEY NOT NULL, -- UUID,小写带连字符
|
||||
-- ... 业务列 ...
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
Postgres 示例:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY, -- 或 TEXT
|
||||
-- ... 业务列 ...
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
## 入通道校验(平台已启用)
|
||||
|
||||
保存 / 启用同步通道时:
|
||||
|
||||
1. 白名单非空
|
||||
2. 可达端(通常为 **remote 线上库**)上,各表主键列须为 **TEXT/VARCHAR/UUID** 类(拒绝 INTEGER/SERIAL)
|
||||
3. **外键闭包**:白名单内表若引用名单外表(或反之一侧在名单),保存失败
|
||||
|
||||
形态 B 下本机 SQLite 可能不可达:至少 **remote** 须能完成校验。
|
||||
|
||||
## 宇恒开通(opt-in)
|
||||
|
||||
```bash
|
||||
YXD_SYNC_MODE=local_dbsync
|
||||
YXD_SYNC_DBSYNC_TABLES=orders,order_items
|
||||
# 或写入 cache/db_sync/whitelist.json: {"tables":["orders","order_items"]}
|
||||
```
|
||||
|
||||
未设置 `local_dbsync` 时行为与现网一致。
|
||||
|
||||
## M2 本机 agent(B→A)— 智建已提供的接口
|
||||
|
||||
宇恒侧自行实现 agent;智建只提供下列 API(需「数据同步」权限):
|
||||
|
||||
- `GET /api/v1/agent/sync/channels/:id/whitelist`
|
||||
- `POST /api/v1/agent/sync/channels/:id/push` — body: `{table,op,row_pk,row,version,client_outbox_id}`
|
||||
- `POST /api/v1/agent/sync/channels/:id/push/batch`
|
||||
|
||||
推送落到通道 **remote**(线上 A),幂等认客户端 version;平台不连用户本机 SQLite。
|
||||
|
||||
宇恒建议环境变量(由对方配置,不在智建仓改):
|
||||
|
||||
```bash
|
||||
YXD_SYNC_MODE=local_dbsync
|
||||
YXD_SYNC_AGENT=1
|
||||
YXD_ONLINE_API_BASE=https://aisite.example.com
|
||||
YXD_SYNC_CHANNEL_ID=<通道ID>
|
||||
YXD_SYNC_ACCESS_TOKEN=<含「数据同步」权限的 JWT 或 agent token>
|
||||
```
|
||||
|
||||
## M3 超管 LWW 审计(智建)
|
||||
|
||||
- 存储:`data/dbsync/lww_overrides.json`(与租户 conflicts 分离)
|
||||
- API:`GET /api/v1/platform/dbsync/lww-overrides`(**仅平台超级管理员**)
|
||||
- 公司侧:`/api/v1/admin/sync/conflicts*` → **403**
|
||||
- 双入口写入:worker `drain` + agent `push` 在 LWW 覆盖/保留时记审计
|
||||
- TTL:默认 90 天(`DBSync.LwwAuditTTLDays`)
|
||||
- 对账限流:手动默认 300 秒(`DBSync.ReconcileMinSec`)
|
||||
- 超管回滚:`POST /api/v1/platform/dbsync/lww-overrides/:id/rollback`(仅 `applied_source`;按 `loser_payload` 写回线上 A)
|
||||
122
docs/数据同步-中间件.md
122
docs/数据同步-中间件.md
@@ -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,触发器入 outbox,worker 再推到线上 A。
|
||||
## 相关
|
||||
|
||||
## 注意
|
||||
|
||||
- 两端业务表结构需兼容(同名列);主键默认 `id`,可用 `pk_columns` 覆盖。
|
||||
- MySQL 需账号有建触发器权限。
|
||||
- 密钥在 DSN 中;列表页会打码显示。
|
||||
- 「实时」为亚秒级轮询 + 触发器,非 MySQL binlog CDC;同机延迟通常 <1s。
|
||||
- 对账按**主键集合**补缺行,不做逐字段内容 diff;同 PK 内容冲突仍靠版本 / 冲突队列。
|
||||
- [同步表约定.md](./同步表约定.md)
|
||||
- [数据同步-开通说明.md](./数据同步-开通说明.md)
|
||||
- [数据同步-迁移手册.md](./数据同步-迁移手册.md)
|
||||
|
||||
36
docs/数据同步-开通说明.md
Normal file
36
docs/数据同步-开通说明.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 数据同步 · 开通说明(管理员)
|
||||
|
||||
> 依据:`松离线-dbsync方案-最终版.md`
|
||||
> **默认客户无感**:未显式开通时,终端保存与自增表行为与现网一致。
|
||||
|
||||
## 三分模式(客户端配置,智建通道为表白名单源)
|
||||
|
||||
| 模式 | 含义 | 谁改 |
|
||||
|------|------|------|
|
||||
| `local_only` | 仅本地,无同步 | 默认之一 |
|
||||
| `online_primary` | HTTP 双写 / 离线 pending(旧路径) | 已配线上 API 且未写 MODE 时兼容升 |
|
||||
| `local_dbsync` | 松离线 + 表白名单 + 本机 agent | **仅显式配置**,禁止自动升 |
|
||||
|
||||
智建控制台「数据同步」配的是**通道 + 表白名单 + 线上 DSN**;是否走 `local_dbsync` 由客户端环境变量决定,平台**不会**替全员切默认。
|
||||
|
||||
## 开通步骤(增值)
|
||||
|
||||
1. 公司管理员在「数据同步」建通道:`local`(本机 B 描述)+ `remote`(线上 A DSN),表白名单须 **UUID TEXT PK** + FK 闭包。
|
||||
2. 默认方向 **本地 → 线上**,冲突策略 **源端覆盖(lww_source)**。
|
||||
3. 客户端显式设 `YXD_SYNC_MODE=local_dbsync`,并配置通道 ID / token(见 `同步表约定.md`)。
|
||||
4. 装本机 sync agent 后变更才会上云;未装 agent:**本地可保存**,文案须为「需 agent 才上云」。
|
||||
5. Binding(可选):登记 `local_database_id → online_db_id`,见 API `/api/v1/admin/sync/bindings`。
|
||||
|
||||
## 谁能看什么
|
||||
|
||||
| 角色 | 可见 |
|
||||
|------|------|
|
||||
| 公司管理员 | 通道配置、对账(同步修复)、统计;**无**冲突台 / LWW 覆盖明细 |
|
||||
| 平台超级管理员 | LWW 覆盖审计(平台工作台) |
|
||||
| 未开通终端用户 | **零同步文案**,无强制状态条 |
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [同步表约定.md](./同步表约定.md)
|
||||
- [数据同步-迁移手册.md](./数据同步-迁移手册.md)(旧客从 HTTP 双写迁入)
|
||||
- [数据同步-中间件.md](./数据同步-中间件.md)
|
||||
40
docs/数据同步-迁移手册.md
Normal file
40
docs/数据同步-迁移手册.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 数据同步 · 迁移手册(O7)
|
||||
|
||||
> 从旧路径 `online_primary`(HTTP 双写)迁到 `local_dbsync`(松离线 + agent)。
|
||||
> **禁止静默迁移、禁止全员一刀切关双写。**
|
||||
|
||||
## 硬规则
|
||||
|
||||
1. **旧客默认不变**:未评估、未签字前不得改默认模式。
|
||||
2. **同表互斥**:某表不得「一边 HTTP 双写、一边进 dbsync 白名单」。
|
||||
3. **顺序不可颠倒**:先停该表双写 → 再进白名单 / 切 `local_dbsync`。
|
||||
4. **agent 非安装强依赖**:迁移观察期允许只落本地 + 积压;须告知「需 agent 才上云」。
|
||||
|
||||
## 推荐流程(单表 / 单库)
|
||||
|
||||
| 步骤 | 动作 | 验收 |
|
||||
|------|------|------|
|
||||
| 1 | 智建通道准备:目标表白名单、UUID PK 校验通过、remote DSN 可达 | 保存通道成功 |
|
||||
| 2 | 客户端仍 `online_primary`:对该表**停止**双写(或从双写表白名单移除) | 该表仅写本地或仅走约定路径 |
|
||||
| 3 | 观察 ≥1 个业务周期:无双写残留、无重复行 | 抽查线上/本地主键 |
|
||||
| 4 | 显式设 `YXD_SYNC_MODE=local_dbsync`,写入表白名单缓存/通道拉名单 | `/sync/status` 显示 local_dbsync |
|
||||
| 5 | 登记 Binding(可选)`local_database_id → online_db_id` | GET bindings 命中 |
|
||||
| 6 | 装本机 agent,观察 outbox 清空、同 UUID 上云 | agent push 成功;超管可查 LWW(若有覆盖) |
|
||||
| 7 | 确认稳定后再扩大白名单;**勿**对全员默认切模式 | 旧客未改默认 |
|
||||
|
||||
## 回滚
|
||||
|
||||
1. 客户端改回 `online_primary` 或 `local_only`。
|
||||
2. 智建侧可停通道 / 缩表白名单(勿删线上数据)。
|
||||
3. 未推完的 outbox 由对方客户端自行处理;平台不强制清。
|
||||
|
||||
## 新客评估(M4)
|
||||
|
||||
仅对**新客**或**书面确认的迁移客**评估是否默认 `local_dbsync`。
|
||||
M4 前:**禁止**全员切默认。
|
||||
|
||||
## 相关
|
||||
|
||||
- [数据同步-开通说明.md](./数据同步-开通说明.md)
|
||||
- [同步表约定.md](./同步表约定.md)
|
||||
- 冻结方案:`松离线-dbsync方案-最终版.md` §8 / O7
|
||||
@@ -40,3 +40,5 @@ Storage:
|
||||
DBSync:
|
||||
Enabled: true
|
||||
DataDir: /app/data/dbsync
|
||||
LwwAuditTTLDays: 90
|
||||
ReconcileMinSec: 300
|
||||
|
||||
@@ -45,3 +45,5 @@ Storage:
|
||||
DBSync:
|
||||
Enabled: true
|
||||
DataDir: ./data/dbsync
|
||||
LwwAuditTTLDays: 90
|
||||
ReconcileMinSec: 300
|
||||
|
||||
@@ -60,10 +60,20 @@ var Catalog = []Entry{
|
||||
{Method: "PUT", Path: "/api/v1/admin/sync/channels/{id}", OperationID: "updateSyncChannel", Summary: "更新同步通道", Group: "admin"},
|
||||
{Method: "DELETE", Path: "/api/v1/admin/sync/channels/{id}", OperationID: "deleteSyncChannel", Summary: "删除同步通道", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/test", OperationID: "testSyncEndpoints", Summary: "测试本地/线上库连接", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/prepare", OperationID: "prepareSyncChannel", Summary: "准备同步(建 outbox/触发器)", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/start", OperationID: "startSyncChannel", Summary: "启动近实时同步", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/stop", OperationID: "stopSyncChannel", Summary: "停止同步", Group: "admin"},
|
||||
{Method: "GET", Path: "/api/v1/admin/sync/conflicts", OperationID: "listSyncConflicts", Summary: "冲突队列", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/conflicts/{id}/resolve", OperationID: "resolveSyncConflict", Summary: "解决冲突", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/reconcile", OperationID: "reconcileSyncChannel", Summary: "主键对账(同步修复,有限流)", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/ingest", OperationID: "ingestSyncRows", Summary: "外部行写入通道 local", Group: "admin"},
|
||||
{Method: "GET", Path: "/api/v1/admin/sync/bindings", OperationID: "listSyncBindings", Summary: "列出本机库↔线上库绑定", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/bindings", OperationID: "ensureSyncBinding", Summary: "登记/更新绑定", Group: "admin"},
|
||||
{Method: "GET", Path: "/api/v1/admin/sync/conflicts", OperationID: "listSyncConflicts", Summary: "已废弃:公司侧 403,改用超管 LWW 审计", Group: "admin"},
|
||||
{Method: "POST", Path: "/api/v1/admin/sync/conflicts/{id}/resolve", OperationID: "resolveSyncConflict", Summary: "已废弃:公司侧 403", Group: "admin"},
|
||||
{Method: "GET", Path: "/api/v1/agent/sync/channels/{id}/whitelist", OperationID: "agentSyncWhitelist", Summary: "本机 agent 拉取表白名单", Group: "agent"},
|
||||
{Method: "POST", Path: "/api/v1/agent/sync/channels/{id}/push", OperationID: "agentSyncPush", Summary: "本机 agent 推变更到线上 A", Group: "agent"},
|
||||
{Method: "POST", Path: "/api/v1/agent/sync/channels/{id}/push/batch", OperationID: "agentSyncPushBatch", Summary: "本机 agent 批量推送", Group: "agent"},
|
||||
{Method: "GET", Path: "/api/v1/platform/dbsync/lww-overrides", OperationID: "platformLwwOverrides", Summary: "超管查看 LWW 覆盖审计", Group: "platform"},
|
||||
{Method: "POST", Path: "/api/v1/platform/dbsync/lww-overrides/{id}/rollback", OperationID: "platformLwwRollback", Summary: "超管按落败快照回滚线上单行", Group: "platform"},
|
||||
|
||||
{Method: "GET", Path: "/api/v1/apps", OperationID: "listApps", Summary: "列出模块:管理账号看本租户全部(含在建);智能体仅已授权", Group: "app"},
|
||||
{Method: "PUT", Path: "/api/v1/apps/{slug}/draft", OperationID: "saveDraft", Summary: "登记在建模块蓝图(不发布)", Group: "app"},
|
||||
|
||||
@@ -39,6 +39,7 @@ var permAlias = map[string]string{
|
||||
"tenant.invite": Perm邀请成员,
|
||||
"org.admin": Perm管理组织,
|
||||
"sync.admin": Perm数据同步,
|
||||
"sync.push": Perm数据同步,
|
||||
"tenant.admin": Perm管理租户,
|
||||
Perm读取模块: Perm读取模块,
|
||||
Perm写入模块: Perm写入模块,
|
||||
|
||||
@@ -69,5 +69,7 @@ type StorageConf struct {
|
||||
|
||||
type DBSyncConf struct {
|
||||
Enabled bool `json:",default=true"`
|
||||
DataDir string `json:",default=./data/dbsync"` // 通道/冲突队列 JSON
|
||||
DataDir string `json:",default=./data/dbsync"` // 通道/冲突/LWW 审计 JSON
|
||||
LwwAuditTTLDays int `json:",default=90"` // 超管 LWW 覆盖日志保留天数
|
||||
ReconcileMinSec int `json:",default=300"` // 手动对账最小间隔(秒)
|
||||
}
|
||||
|
||||
143
platform/internal/dbsync/binding.go
Normal file
143
platform/internal/dbsync/binding.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Binding 本机库 ↔ 线上库映射(P1;不挡推送,供登记/查询)。
|
||||
type Binding struct {
|
||||
ID string `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
LocalDatabaseID string `json:"local_database_id"`
|
||||
OnlineDBID string `json:"online_db_id"`
|
||||
ChannelID string `json:"channel_id,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *FileStore) bindingPath() string {
|
||||
return filepath.Join(s.dir, "bindings.json")
|
||||
}
|
||||
|
||||
func (s *FileStore) EnsureBinding(b Binding) (Binding, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list, err := s.readBindingsUnlocked()
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
b.LocalDatabaseID = strings.TrimSpace(b.LocalDatabaseID)
|
||||
b.OnlineDBID = strings.TrimSpace(b.OnlineDBID)
|
||||
if b.TenantID <= 0 {
|
||||
return b, fmt.Errorf("tenant_id required")
|
||||
}
|
||||
if b.LocalDatabaseID == "" || b.OnlineDBID == "" {
|
||||
return b, fmt.Errorf("local_database_id and online_db_id required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i := range list {
|
||||
if list[i].TenantID == b.TenantID && list[i].LocalDatabaseID == b.LocalDatabaseID {
|
||||
if b.UserID > 0 && list[i].UserID > 0 && list[i].UserID != b.UserID {
|
||||
continue
|
||||
}
|
||||
list[i].OnlineDBID = b.OnlineDBID
|
||||
if b.ChannelID != "" {
|
||||
list[i].ChannelID = b.ChannelID
|
||||
}
|
||||
if b.Note != "" {
|
||||
list[i].Note = b.Note
|
||||
}
|
||||
if b.UserID > 0 {
|
||||
list[i].UserID = b.UserID
|
||||
}
|
||||
list[i].UpdatedAt = now
|
||||
if err := s.writeBindingsUnlocked(list); err != nil {
|
||||
return b, err
|
||||
}
|
||||
return list[i], nil
|
||||
}
|
||||
}
|
||||
if b.ID == "" {
|
||||
b.ID = uuid.NewString()
|
||||
}
|
||||
b.CreatedAt = now
|
||||
b.UpdatedAt = now
|
||||
list = append(list, b)
|
||||
if err := s.writeBindingsUnlocked(list); err != nil {
|
||||
return b, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) ListBindings(tenantID int64, localDatabaseID string) ([]Binding, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list, err := s.readBindingsUnlocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Binding, 0)
|
||||
for _, b := range list {
|
||||
if b.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if localDatabaseID != "" && b.LocalDatabaseID != localDatabaseID {
|
||||
continue
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) GetBinding(tenantID int64, localDatabaseID string) (*Binding, error) {
|
||||
list, err := s.ListBindings(tenantID, localDatabaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, fmt.Errorf("binding not found")
|
||||
}
|
||||
cp := list[0]
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) readBindingsUnlocked() ([]Binding, error) {
|
||||
path := s.bindingPath()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Binding{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return []Binding{}, nil
|
||||
}
|
||||
var list []Binding
|
||||
if err := json.Unmarshal(b, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) writeBindingsUnlocked(list []Binding) error {
|
||||
path := s.bindingPath()
|
||||
raw, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
35
platform/internal/dbsync/binding_test.go
Normal file
35
platform/internal/dbsync/binding_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureBindingUpsert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := NewFileStore(filepath.Join(dir, "dbsync"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := st.EnsureBinding(Binding{
|
||||
TenantID: 1,
|
||||
LocalDatabaseID: "local-a",
|
||||
OnlineDBID: "online-1",
|
||||
ChannelID: "ch1",
|
||||
})
|
||||
if err != nil || b.ID == "" {
|
||||
t.Fatalf("ensure: %+v err=%v", b, err)
|
||||
}
|
||||
b2, err := st.EnsureBinding(Binding{
|
||||
TenantID: 1,
|
||||
LocalDatabaseID: "local-a",
|
||||
OnlineDBID: "online-2",
|
||||
})
|
||||
if err != nil || b2.OnlineDBID != "online-2" || b2.ID != b.ID {
|
||||
t.Fatalf("upsert: %+v err=%v", b2, err)
|
||||
}
|
||||
list, err := st.ListBindings(1, "local-a")
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list=%d err=%v", len(list), err)
|
||||
}
|
||||
}
|
||||
41
platform/internal/dbsync/lww_audit.go
Normal file
41
platform/internal/dbsync/lww_audit.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EntryDrain = "drain"
|
||||
EntryAgentPush = "agent_push"
|
||||
EntryRollback = "rollback"
|
||||
OutcomeApplied = "applied_source"
|
||||
OutcomeKept = "kept_target"
|
||||
OutcomeRolled = "rolled_back"
|
||||
)
|
||||
|
||||
// RecordLwwOverride 在 LWW 覆盖或保留目标时写入超管审计(失败忽略,不挡同步)。
|
||||
func RecordLwwOverride(store *FileStore, o LwwOverride) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
_ = store.AddLwwOverride(o)
|
||||
}
|
||||
|
||||
// SnapshotTargetRow 取目标端当前行 JSON(无行则空串)。
|
||||
func SnapshotTargetRow(ctx context.Context, db *sql.DB, driver Driver, table, pkCol, rowPK string) string {
|
||||
if db == nil || rowPK == "" {
|
||||
return ""
|
||||
}
|
||||
js, _, err := FetchRowJSON(ctx, db, driver, table, pkCol, rowPK)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return js
|
||||
}
|
||||
|
||||
// DefaultLwwAuditTTL 默认 90 天。
|
||||
func DefaultLwwAuditTTL() time.Duration {
|
||||
return 90 * 24 * time.Hour
|
||||
}
|
||||
72
platform/internal/dbsync/lww_audit_test.go
Normal file
72
platform/internal/dbsync/lww_audit_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLwwOverrideStoreAndPurge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := NewFileStore(filepath.Join(dir, "dbsync"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := LwwOverride{
|
||||
TenantID: 1,
|
||||
ChannelID: "ch1",
|
||||
Table: "orders",
|
||||
RowPK: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Entry: EntryAgentPush,
|
||||
Policy: string(PolicyLWWSource),
|
||||
Outcome: OutcomeApplied,
|
||||
CreatedAt: time.Now().UTC().Add(-100 * 24 * time.Hour),
|
||||
}
|
||||
fresh := LwwOverride{
|
||||
TenantID: 1,
|
||||
ChannelID: "ch1",
|
||||
Table: "orders",
|
||||
RowPK: "bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Entry: EntryDrain,
|
||||
Policy: string(PolicyLWWSource),
|
||||
Outcome: OutcomeApplied,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.AddLwwOverride(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AddLwwOverride(fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := st.ListLwwOverrides(1, "ch1", 10)
|
||||
if err != nil || len(list) != 2 {
|
||||
t.Fatalf("list=%d err=%v", len(list), err)
|
||||
}
|
||||
n, err := st.PurgeLwwOverridesBefore(time.Now().UTC().Add(-90 * 24 * time.Hour))
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("purge n=%d err=%v", n, err)
|
||||
}
|
||||
list, err = st.ListLwwOverrides(0, "", 10)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("after purge list=%d err=%v", len(list), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReconcile(t *testing.T) {
|
||||
ok, _ := CanReconcile(nil, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("nil channel should allow")
|
||||
}
|
||||
past := time.Now().UTC().Add(-10 * time.Minute)
|
||||
ch := &Channel{LastReconcileAt: &past}
|
||||
ok, _ = CanReconcile(ch, 5*time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("expected allow")
|
||||
}
|
||||
recent := time.Now().UTC()
|
||||
ch.LastReconcileAt = &recent
|
||||
ok, wait := CanReconcile(ch, 5*time.Minute)
|
||||
if ok || wait <= 0 {
|
||||
t.Fatalf("expected deny wait>0 ok=%v wait=%v", ok, wait)
|
||||
}
|
||||
}
|
||||
@@ -93,10 +93,24 @@ func (m *Manager) loop(ctx context.Context, id string) {
|
||||
log.Printf("dbsync channel %s: %v", id, err)
|
||||
}
|
||||
ticks++
|
||||
// 约每分钟主键对账一次,补漏(漏投递 / 触发器未装时的存量差)
|
||||
if ticks%120 == 0 && ch.Direction == DirBidirectional {
|
||||
// 双向通道自动对账:默认约每 15 分钟一次(限流)
|
||||
autoEvery := 1800 // poll 500ms → ~15min
|
||||
if ch.PollIntervalMS > 0 {
|
||||
autoEvery = int((15 * time.Minute) / (time.Duration(ch.PollIntervalMS) * time.Millisecond))
|
||||
if autoEvery < 60 {
|
||||
autoEvery = 60
|
||||
}
|
||||
}
|
||||
if ticks%autoEvery == 0 && ch.Direction == DirBidirectional {
|
||||
if allow, _ := CanReconcile(ch, 15*time.Minute); allow {
|
||||
if _, rerr := ReconcileChannel(ctx, ch); rerr != nil {
|
||||
log.Printf("dbsync reconcile %s: %v", id, rerr)
|
||||
} else {
|
||||
_ = m.store.PatchStats(id, func(c *Channel) {
|
||||
now := time.Now().UTC()
|
||||
c.LastReconcileAt = &now
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
@@ -214,12 +228,63 @@ func (m *Manager) drain(ctx context.Context, ch *Channel, sourceName string, src
|
||||
continue
|
||||
}
|
||||
if has && tgtVer > r.Version {
|
||||
switch ch.ConflictPolicy {
|
||||
policy := ch.ConflictPolicy
|
||||
if policy == "" {
|
||||
policy = PolicyLWWSource
|
||||
}
|
||||
switch policy {
|
||||
case PolicyLWWTarget:
|
||||
loser := payload
|
||||
winner := SnapshotTargetRow(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.RowPK)
|
||||
RecordLwwOverride(m.store, LwwOverride{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: r.TableName,
|
||||
RowPK: r.RowPK,
|
||||
Op: r.Op,
|
||||
Entry: EntryDrain,
|
||||
Policy: string(PolicyLWWTarget),
|
||||
Outcome: OutcomeKept,
|
||||
LoserPayload: loser,
|
||||
WinnerPayload: winner,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: r.Version,
|
||||
})
|
||||
done = append(done, r.ID)
|
||||
continue
|
||||
case PolicyLWWSource:
|
||||
// fallthrough apply
|
||||
loser := SnapshotTargetRow(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.RowPK)
|
||||
if err := ApplyChange(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.Op, payload, r.Version); err != nil {
|
||||
_ = m.store.PatchStats(ch.ID, func(c *Channel) {
|
||||
c.Stats.Retries++
|
||||
c.LastError = err.Error()
|
||||
})
|
||||
continue
|
||||
}
|
||||
RecordLwwOverride(m.store, LwwOverride{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: r.TableName,
|
||||
RowPK: r.RowPK,
|
||||
Op: r.Op,
|
||||
Entry: EntryDrain,
|
||||
Policy: string(PolicyLWWSource),
|
||||
Outcome: OutcomeApplied,
|
||||
LoserPayload: loser,
|
||||
WinnerPayload: payload,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: r.Version,
|
||||
})
|
||||
done = append(done, r.ID)
|
||||
okCount++
|
||||
_ = m.store.PatchStats(ch.ID, func(c *Channel) {
|
||||
if sourceName == "local" {
|
||||
c.Stats.PushedOK++
|
||||
} else {
|
||||
c.Stats.PulledOK++
|
||||
}
|
||||
})
|
||||
continue
|
||||
default:
|
||||
_ = m.store.AddConflict(Conflict{
|
||||
TenantID: ch.TenantID,
|
||||
@@ -264,6 +329,9 @@ func (m *Manager) drain(ctx context.Context, ch *Channel, sourceName string, src
|
||||
|
||||
// PrepareChannel 连接两端、建 outbox/触发器,供「测试/启用」调用。
|
||||
func PrepareChannel(ctx context.Context, ch *Channel) error {
|
||||
if err := ValidateChannelAgainstDB(ctx, ch); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ep := range []Endpoint{ch.Local, ch.Remote} {
|
||||
db, err := Open(ep.Driver, ep.DSN)
|
||||
if err != nil {
|
||||
|
||||
256
platform/internal/dbsync/push.go
Normal file
256
platform/internal/dbsync/push.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushItem 本机 agent → 线上 A 的一条变更(形态 B)。
|
||||
type PushItem struct {
|
||||
Table string `json:"table"`
|
||||
Op string `json:"op"` // insert|update|update_by_id|delete|upsert
|
||||
RowPK string `json:"row_pk"`
|
||||
Row map[string]any `json:"row"` // 单行;与 Rows 二选一
|
||||
Rows []map[string]any `json:"rows"` // 兼容批量 insert
|
||||
Version int64 `json:"version"` // outbox 单调 version;0 则用时间戳
|
||||
ClientOutboxID string `json:"client_outbox_id"`
|
||||
OnlineDBID string `json:"online_db_id"`
|
||||
}
|
||||
|
||||
// PushResult 单条推送结果。
|
||||
type PushResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Applied bool `json:"applied"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Conflict bool `json:"conflict"`
|
||||
AppliedVersion int64 `json:"applied_version"`
|
||||
Message string `json:"message"`
|
||||
ClientOutboxID string `json:"client_outbox_id,omitempty"`
|
||||
}
|
||||
|
||||
// PushToRemote 仅打开 remote,按 LWW/幂等将变更落到线上 A。不连本机 SQLite。
|
||||
func PushToRemote(ctx context.Context, ch *Channel, store *FileStore, item PushItem) (*PushResult, error) {
|
||||
if ch == nil {
|
||||
return nil, fmt.Errorf("channel is nil")
|
||||
}
|
||||
table := strings.TrimSpace(item.Table)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table required")
|
||||
}
|
||||
if !tableInChannel(ch, table) {
|
||||
return nil, fmt.Errorf("table %s 不在通道白名单", table)
|
||||
}
|
||||
pkCol := "id"
|
||||
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
|
||||
pkCol = ch.PKColumns[table]
|
||||
}
|
||||
|
||||
op, payload, rowPK, err := normalizePushPayload(item, pkCol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version := item.Version
|
||||
if version <= 0 {
|
||||
version = time.Now().UnixNano()
|
||||
}
|
||||
|
||||
db, err := Open(ch.Remote.Driver, ch.Remote.DSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open remote: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
|
||||
return nil, fmt.Errorf("ensure meta: %w", err)
|
||||
}
|
||||
|
||||
res := &PushResult{
|
||||
ClientOutboxID: item.ClientOutboxID,
|
||||
AppliedVersion: version,
|
||||
}
|
||||
|
||||
tgtVer, has, err := GetMetaVersion(ctx, db, ch.Remote.Driver, table, rowPK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 幂等:同 version 已落地 → 跳过
|
||||
if has && tgtVer == version {
|
||||
res.OK = true
|
||||
res.Skipped = true
|
||||
res.Message = "already applied (same version)"
|
||||
return res, nil
|
||||
}
|
||||
if has && tgtVer > version {
|
||||
policy := ch.ConflictPolicy
|
||||
if policy == "" {
|
||||
policy = PolicyLWWSource // B→A 默认偏源
|
||||
}
|
||||
switch policy {
|
||||
case PolicyLWWTarget:
|
||||
loser := payload
|
||||
winner := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, rowPK)
|
||||
RecordLwwOverride(store, LwwOverride{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: table,
|
||||
RowPK: rowPK,
|
||||
Op: op,
|
||||
Entry: EntryAgentPush,
|
||||
Policy: string(PolicyLWWTarget),
|
||||
Outcome: OutcomeKept,
|
||||
LoserPayload: loser,
|
||||
WinnerPayload: winner,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: version,
|
||||
})
|
||||
res.OK = true
|
||||
res.Skipped = true
|
||||
res.Message = "target newer; kept (lww_target)"
|
||||
return res, nil
|
||||
case PolicyLWWSource:
|
||||
loser := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, rowPK)
|
||||
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RecordLwwOverride(store, LwwOverride{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: table,
|
||||
RowPK: rowPK,
|
||||
Op: op,
|
||||
Entry: EntryAgentPush,
|
||||
Policy: string(PolicyLWWSource),
|
||||
Outcome: OutcomeApplied,
|
||||
LoserPayload: loser,
|
||||
WinnerPayload: payload,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: version,
|
||||
})
|
||||
if store != nil {
|
||||
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.PushedOK++ })
|
||||
}
|
||||
res.OK = true
|
||||
res.Applied = true
|
||||
res.Message = "applied (lww_source override)"
|
||||
return res, nil
|
||||
default:
|
||||
if store != nil {
|
||||
_ = store.AddConflict(Conflict{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: table,
|
||||
RowPK: rowPK,
|
||||
Op: op,
|
||||
Source: "agent",
|
||||
Payload: payload,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: version,
|
||||
Message: "target version newer than agent push",
|
||||
})
|
||||
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.Conflicts++ })
|
||||
}
|
||||
res.OK = true
|
||||
res.Skipped = true
|
||||
res.Conflict = true
|
||||
res.Message = "queued conflict; target newer"
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store != nil {
|
||||
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.PushedOK++ })
|
||||
}
|
||||
res.OK = true
|
||||
res.Applied = true
|
||||
res.Message = "applied"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// PushBatchToRemote 保序批量;遇错即停,已成功条数在返回切片中。
|
||||
func PushBatchToRemote(ctx context.Context, ch *Channel, store *FileStore, items []PushItem) ([]PushResult, error) {
|
||||
out := make([]PushResult, 0, len(items))
|
||||
for i, it := range items {
|
||||
r, err := PushToRemote(ctx, ch, store, it)
|
||||
if err != nil {
|
||||
fail := PushResult{
|
||||
OK: false,
|
||||
Message: err.Error(),
|
||||
ClientOutboxID: it.ClientOutboxID,
|
||||
}
|
||||
out = append(out, fail)
|
||||
return out, fmt.Errorf("item[%d]: %w", i, err)
|
||||
}
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tableInChannel(ch *Channel, table string) bool {
|
||||
for _, t := range uniqueTables(ch.Local.Tables, ch.Remote.Tables) {
|
||||
if strings.EqualFold(strings.TrimSpace(t), table) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePushPayload(item PushItem, pkCol string) (op string, payload string, rowPK string, err error) {
|
||||
rawOp := strings.ToLower(strings.TrimSpace(item.Op))
|
||||
if rawOp == "" {
|
||||
rawOp = "upsert"
|
||||
}
|
||||
switch rawOp {
|
||||
case "delete":
|
||||
op = "delete"
|
||||
case "insert", "update", "update_by_id", "upsert":
|
||||
op = "upsert"
|
||||
default:
|
||||
return "", "", "", fmt.Errorf("unsupported op: %s", item.Op)
|
||||
}
|
||||
|
||||
row := item.Row
|
||||
if row == nil && len(item.Rows) > 0 {
|
||||
row = item.Rows[0]
|
||||
}
|
||||
rowPK = strings.TrimSpace(item.RowPK)
|
||||
if row != nil {
|
||||
if rowPK == "" {
|
||||
rowPK = strings.TrimSpace(fmt.Sprint(row[pkCol]))
|
||||
}
|
||||
if op == "upsert" {
|
||||
// 保证 payload 含主键
|
||||
if _, ok := row[pkCol]; !ok && rowPK != "" {
|
||||
row = copyMap(row)
|
||||
row[pkCol] = rowPK
|
||||
}
|
||||
}
|
||||
}
|
||||
if rowPK == "" || rowPK == "<nil>" {
|
||||
return "", "", "", fmt.Errorf("row_pk required")
|
||||
}
|
||||
if op == "delete" && row == nil {
|
||||
row = map[string]any{pkCol: rowPK}
|
||||
}
|
||||
if row == nil {
|
||||
return "", "", "", fmt.Errorf("row required for %s", op)
|
||||
}
|
||||
b, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return op, string(b), rowPK, nil
|
||||
}
|
||||
|
||||
func copyMap(m map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(m)+1)
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
46
platform/internal/dbsync/push_test.go
Normal file
46
platform/internal/dbsync/push_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package dbsync
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizePushPayloadInsert(t *testing.T) {
|
||||
op, payload, pk, err := normalizePushPayload(PushItem{
|
||||
Op: "insert",
|
||||
RowPK: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Row: map[string]any{
|
||||
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"name": "x",
|
||||
},
|
||||
}, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if op != "upsert" || pk == "" || payload == "" {
|
||||
t.Fatalf("got op=%s pk=%s payload=%s", op, pk, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePushPayloadDelete(t *testing.T) {
|
||||
op, payload, pk, err := normalizePushPayload(PushItem{
|
||||
Op: "delete",
|
||||
RowPK: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
}, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if op != "delete" || pk == "" || payload == "" {
|
||||
t.Fatalf("got op=%s pk=%s payload=%s", op, pk, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableInChannel(t *testing.T) {
|
||||
ch := &Channel{
|
||||
Local: Endpoint{Tables: []string{"orders"}},
|
||||
Remote: Endpoint{Tables: []string{"orders"}},
|
||||
}
|
||||
if !tableInChannel(ch, "orders") {
|
||||
t.Fatal("expected in")
|
||||
}
|
||||
if tableInChannel(ch, "other") {
|
||||
t.Fatal("expected out")
|
||||
}
|
||||
}
|
||||
30
platform/internal/dbsync/reconcile_limit.go
Normal file
30
platform/internal/dbsync/reconcile_limit.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultManualReconcileInterval 公司管理员手动对账最小间隔。
|
||||
const DefaultManualReconcileInterval = 5 * time.Minute
|
||||
|
||||
// CanReconcile 限流:距上次对账不足 minInterval 则拒绝。
|
||||
func CanReconcile(ch *Channel, minInterval time.Duration) (bool, time.Duration) {
|
||||
if ch == nil || ch.LastReconcileAt == nil || ch.LastReconcileAt.IsZero() {
|
||||
return true, 0
|
||||
}
|
||||
if minInterval <= 0 {
|
||||
minInterval = DefaultManualReconcileInterval
|
||||
}
|
||||
elapsed := time.Since(ch.LastReconcileAt.UTC())
|
||||
if elapsed >= minInterval {
|
||||
return true, 0
|
||||
}
|
||||
return false, minInterval - elapsed
|
||||
}
|
||||
|
||||
// ReconcileTooSoonError 供 handler 返回 429。
|
||||
func ReconcileTooSoonError(wait time.Duration) error {
|
||||
sec := int(wait.Seconds()) + 1
|
||||
return fmt.Errorf("对账过于频繁,请 %d 秒后再试", sec)
|
||||
}
|
||||
79
platform/internal/dbsync/rollback.go
Normal file
79
platform/internal/dbsync/rollback.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RollbackLwwOverride 超管按落败快照回滚线上 A 单行。
|
||||
// 仅 outcome=applied_source 可回滚:把 loser_payload 写回 remote;无快照则按 pk 删除。
|
||||
func RollbackLwwOverride(ctx context.Context, store *FileStore, overrideID string) (*LwwOverride, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("store is nil")
|
||||
}
|
||||
o, err := store.GetLwwOverride(overrideID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if o.Outcome != OutcomeApplied {
|
||||
return nil, fmt.Errorf("仅「源端覆盖」记录可回滚(当前 outcome=%s)", o.Outcome)
|
||||
}
|
||||
if strings.TrimSpace(o.ChannelID) == "" {
|
||||
return nil, fmt.Errorf("override missing channel_id")
|
||||
}
|
||||
ch, err := store.GetChannel(o.ChannelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("channel: %w", err)
|
||||
}
|
||||
table := strings.TrimSpace(o.Table)
|
||||
pkCol := "id"
|
||||
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
|
||||
pkCol = ch.PKColumns[table]
|
||||
}
|
||||
db, err := Open(ch.Remote.Driver, ch.Remote.DSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open remote: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
before := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, o.RowPK)
|
||||
ver := time.Now().UnixNano()
|
||||
loser := strings.TrimSpace(o.LoserPayload)
|
||||
op := "upsert"
|
||||
payload := loser
|
||||
if loser == "" || loser == "{}" {
|
||||
op = "delete"
|
||||
payload = fmt.Sprintf(`{%q:%q}`, pkCol, o.RowPK)
|
||||
}
|
||||
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, ver); err != nil {
|
||||
return nil, fmt.Errorf("apply rollback: %w", err)
|
||||
}
|
||||
|
||||
rec := LwwOverride{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: o.TenantID,
|
||||
ChannelID: o.ChannelID,
|
||||
Table: o.Table,
|
||||
RowPK: o.RowPK,
|
||||
Op: op,
|
||||
Entry: EntryRollback,
|
||||
Policy: "manual_rollback",
|
||||
Outcome: OutcomeRolled,
|
||||
LoserPayload: before,
|
||||
WinnerPayload: payload,
|
||||
TargetVer: o.SourceVer,
|
||||
SourceVer: ver,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := store.AddLwwOverride(rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
54
platform/internal/dbsync/rollback_test.go
Normal file
54
platform/internal/dbsync/rollback_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRollbackRejectsNonApplied(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := NewFileStore(filepath.Join(dir, "dbsync"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := LwwOverride{
|
||||
ID: "ov1",
|
||||
TenantID: 1,
|
||||
ChannelID: "ch1",
|
||||
Table: "orders",
|
||||
RowPK: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Outcome: OutcomeKept,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.AddLwwOverride(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = RollbackLwwOverride(context.Background(), st, "ov1")
|
||||
if err == nil {
|
||||
t.Fatal("expected reject kept_target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLwwOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := NewFileStore(filepath.Join(dir, "dbsync"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := LwwOverride{
|
||||
ID: "ov2",
|
||||
TenantID: 1,
|
||||
Table: "t",
|
||||
RowPK: "pk",
|
||||
Outcome: OutcomeApplied,
|
||||
}
|
||||
if err := st.AddLwwOverride(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetLwwOverride("ov2")
|
||||
if err != nil || got.Table != "t" {
|
||||
t.Fatalf("got=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FileStore 持久化通道与冲突队列(JSON),不依赖业务库类型。
|
||||
// FileStore 持久化通道、冲突队列与超管 LWW 审计(JSON),不依赖业务库类型。
|
||||
type FileStore struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
chPath string
|
||||
cfPath string
|
||||
lwwPath string
|
||||
}
|
||||
|
||||
func NewFileStore(dir string) (*FileStore, error) {
|
||||
@@ -30,6 +31,7 @@ func NewFileStore(dir string) (*FileStore, error) {
|
||||
dir: dir,
|
||||
chPath: filepath.Join(dir, "channels.json"),
|
||||
cfPath: filepath.Join(dir, "conflicts.json"),
|
||||
lwwPath: filepath.Join(dir, "lww_overrides.json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -103,7 +105,7 @@ func (s *FileStore) SaveChannel(ch Channel) (Channel, error) {
|
||||
ch.Direction = DirLocalToRemote
|
||||
}
|
||||
if ch.ConflictPolicy == "" {
|
||||
ch.ConflictPolicy = PolicyQueue
|
||||
ch.ConflictPolicy = PolicyLWWSource
|
||||
}
|
||||
if ch.PKColumns == nil {
|
||||
ch.PKColumns = map[string]string{}
|
||||
@@ -120,6 +122,7 @@ func (s *FileStore) SaveChannel(ch Channel) (Channel, error) {
|
||||
}
|
||||
ch.CreatedAt = list[i].CreatedAt
|
||||
ch.Stats = list[i].Stats
|
||||
ch.LastReconcileAt = list[i].LastReconcileAt
|
||||
list[i] = ch
|
||||
found = true
|
||||
break
|
||||
@@ -325,3 +328,117 @@ func (s *FileStore) writeConflicts(list []Conflict) error {
|
||||
}
|
||||
return os.Rename(tmp, s.cfPath)
|
||||
}
|
||||
|
||||
func (s *FileStore) AddLwwOverride(o LwwOverride) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list, err := s.readLwwOverrides()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o.ID == "" {
|
||||
o.ID = uuid.NewString()
|
||||
}
|
||||
if o.CreatedAt.IsZero() {
|
||||
o.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
list = append(list, o)
|
||||
return s.writeLwwOverrides(list)
|
||||
}
|
||||
|
||||
func (s *FileStore) GetLwwOverride(id string) (*LwwOverride, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list, err := s.readLwwOverrides()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
cp := list[i]
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("lww override not found")
|
||||
}
|
||||
|
||||
// ListLwwOverrides 超管查询;tenantID/channelID 为 0/空 表示不过滤。
|
||||
func (s *FileStore) ListLwwOverrides(tenantID int64, channelID string, limit int) ([]LwwOverride, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list, err := s.readLwwOverrides()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]LwwOverride, 0, len(list))
|
||||
for i := len(list) - 1; i >= 0; i-- { // 新→旧
|
||||
o := list[i]
|
||||
if tenantID > 0 && o.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if channelID != "" && o.ChannelID != channelID {
|
||||
continue
|
||||
}
|
||||
out = append(out, o)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PurgeLwwOverridesBefore 删除 created_at 早于 cutoff 的记录,返回删除条数。
|
||||
func (s *FileStore) PurgeLwwOverridesBefore(cutoff time.Time) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list, err := s.readLwwOverrides()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
keep := make([]LwwOverride, 0, len(list))
|
||||
removed := 0
|
||||
for _, o := range list {
|
||||
if o.CreatedAt.Before(cutoff) {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
keep = append(keep, o)
|
||||
}
|
||||
if removed == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := s.writeLwwOverrides(keep); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) readLwwOverrides() ([]LwwOverride, error) {
|
||||
b, err := os.ReadFile(s.lwwPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []LwwOverride{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return []LwwOverride{}, nil
|
||||
}
|
||||
var list []LwwOverride
|
||||
if err := json.Unmarshal(b, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) writeLwwOverrides(list []LwwOverride) error {
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.lwwPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.lwwPath)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type Channel struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at,omitempty"`
|
||||
LastReconcileAt *time.Time `json:"last_reconcile_at,omitempty"`
|
||||
Stats ChannelStats `json:"stats"`
|
||||
}
|
||||
|
||||
@@ -80,6 +81,24 @@ type Conflict struct {
|
||||
Resolution string `json:"resolution,omitempty"` // apply_source | keep_target | discard
|
||||
}
|
||||
|
||||
// LwwOverride LWW 自动覆盖审计(仅平台超级管理员可见;与租户冲突队列分离)。
|
||||
type LwwOverride struct {
|
||||
ID string `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Table string `json:"table"`
|
||||
RowPK string `json:"row_pk"`
|
||||
Op string `json:"op"`
|
||||
Entry string `json:"entry"` // drain | agent_push
|
||||
Policy string `json:"policy"` // lww_source | lww_target
|
||||
Outcome string `json:"outcome"` // applied_source | kept_target
|
||||
LoserPayload string `json:"loser_payload"` // 落败侧
|
||||
WinnerPayload string `json:"winner_payload"` // 胜出侧
|
||||
TargetVer int64 `json:"target_ver"`
|
||||
SourceVer int64 `json:"source_ver"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type OutboxRow struct {
|
||||
ID int64
|
||||
TableName string
|
||||
|
||||
264
platform/internal/dbsync/validate.go
Normal file
264
platform/internal/dbsync/validate.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateChannelConfig 静态校验(不连库):表名单、PK 列名约定。
|
||||
func ValidateChannelConfig(ch *Channel) error {
|
||||
if ch == nil {
|
||||
return fmt.Errorf("channel is nil")
|
||||
}
|
||||
tables := uniqueTables(ch.Local.Tables, ch.Remote.Tables)
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("同步表白名单为空:请至少在 local 或 remote 填写表名")
|
||||
}
|
||||
for _, t := range tables {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return fmt.Errorf("表名不能为空")
|
||||
}
|
||||
if strings.HasPrefix(t, "_ajz_") {
|
||||
return fmt.Errorf("禁止同步系统表: %s", t)
|
||||
}
|
||||
pk := pkColumn(ch, t)
|
||||
if pk == "" {
|
||||
return fmt.Errorf("表 %s 主键列名为空", t)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pkColumn(ch *Channel, table string) string {
|
||||
if ch.PKColumns != nil {
|
||||
if v := strings.TrimSpace(ch.PKColumns[table]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "id"
|
||||
}
|
||||
|
||||
// ValidateChannelAgainstDB 对可连接端做:TEXT/UUID 主键类型 + FK 闭包。
|
||||
// 某端连不上时跳过该端(形态 B 下 local 常不可达),但至少一端须校验成功,否则拒绝。
|
||||
func ValidateChannelAgainstDB(ctx context.Context, ch *Channel) error {
|
||||
if err := ValidateChannelConfig(ch); err != nil {
|
||||
return err
|
||||
}
|
||||
tables := uniqueTables(ch.Local.Tables, ch.Remote.Tables)
|
||||
checked := 0
|
||||
var lastSkip error
|
||||
for _, ep := range []Endpoint{ch.Remote, ch.Local} {
|
||||
if strings.TrimSpace(ep.DSN) == "" || ep.Driver == "" {
|
||||
continue
|
||||
}
|
||||
epTables := ep.Tables
|
||||
if len(epTables) == 0 {
|
||||
epTables = tables
|
||||
}
|
||||
db, err := Open(ep.Driver, ep.DSN)
|
||||
if err != nil {
|
||||
lastSkip = fmt.Errorf("%s 无法连接(跳过库内校验): %w", ep.Driver, err)
|
||||
continue
|
||||
}
|
||||
if err := validateEndpointSchema(ctx, db, ep.Driver, ch, epTables, tables); err != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("[%s] %w", ep.Driver, err)
|
||||
}
|
||||
_ = db.Close()
|
||||
checked++
|
||||
}
|
||||
if checked == 0 {
|
||||
if lastSkip != nil {
|
||||
return fmt.Errorf("无法对任何端做主键/外键校验:%v;请保证线上库(remote)DSN 可达后再保存", lastSkip)
|
||||
}
|
||||
return fmt.Errorf("无法对任何端做主键/外键校验:请配置可达的 remote DSN")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEndpointSchema(ctx context.Context, db *sql.DB, driver Driver, ch *Channel, epTables, whitelist []string) error {
|
||||
wl := map[string]struct{}{}
|
||||
for _, t := range whitelist {
|
||||
wl[strings.TrimSpace(t)] = struct{}{}
|
||||
}
|
||||
for _, t := range epTables {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
pk := pkColumn(ch, t)
|
||||
typ, err := DescribeColumnType(ctx, db, driver, t, pk)
|
||||
if err != nil {
|
||||
return fmt.Errorf("表 %s 主键列 %s: %w", t, pk, err)
|
||||
}
|
||||
if !isTextLikePK(typ) {
|
||||
return fmt.Errorf("表 %s 主键 %s 类型为 %q,同步表须为 TEXT/VARCHAR/UUID 类(禁止自增整数作同步键)", t, pk, typ)
|
||||
}
|
||||
fks, err := ListForeignKeys(ctx, db, driver, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("表 %s 外键: %w", t, err)
|
||||
}
|
||||
for _, fk := range fks {
|
||||
_, childIn := wl[fk.ChildTable]
|
||||
_, parentIn := wl[fk.ParentTable]
|
||||
if childIn != parentIn {
|
||||
missing := fk.ParentTable
|
||||
if !childIn {
|
||||
missing = fk.ChildTable
|
||||
}
|
||||
return fmt.Errorf("外键闭包不完整:%s.%s → %s.%s,请将 %s 一并加入同步白名单",
|
||||
fk.ChildTable, fk.ChildColumn, fk.ParentTable, fk.ParentColumn, missing)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTextLikePK(typ string) bool {
|
||||
raw := strings.ToUpper(strings.TrimSpace(typ))
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
token := strings.Fields(raw)[0]
|
||||
token = strings.Split(token, "(")[0]
|
||||
switch token {
|
||||
case "TEXT", "VARCHAR", "CHAR", "CHARACTER", "UUID", "NVARCHAR", "NCHAR", "STRING", "CITEXT", "CLOB":
|
||||
return true
|
||||
case "INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "MEDIUMINT",
|
||||
"SERIAL", "BIGSERIAL", "SMALLSERIAL",
|
||||
"NUMERIC", "DECIMAL", "NUMBER", "FLOAT", "DOUBLE", "REAL", "BOOLEAN", "BOOL":
|
||||
return false
|
||||
default:
|
||||
if strings.Contains(raw, "CHAR") || strings.Contains(raw, "TEXT") || strings.Contains(raw, "UUID") || strings.Contains(raw, "CLOB") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ForeignKey 子表 → 父表
|
||||
type ForeignKey struct {
|
||||
ChildTable string
|
||||
ChildColumn string
|
||||
ParentTable string
|
||||
ParentColumn string
|
||||
}
|
||||
|
||||
// DescribeColumnType 返回列类型字符串(驱动相关原文)。
|
||||
func DescribeColumnType(ctx context.Context, db *sql.DB, driver Driver, table, column string) (string, error) {
|
||||
table = strings.TrimSpace(table)
|
||||
column = strings.TrimSpace(column)
|
||||
switch driver {
|
||||
case DriverSQLite:
|
||||
rows, err := db.QueryContext(ctx, fmt.Sprintf(`PRAGMA table_info(%s)`, quoteIdent(driver, table)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.EqualFold(name, column) {
|
||||
if typ == "" {
|
||||
typ = "TEXT" // sqlite 松类型兜底:无声明时按 TEXT 处理需调用方结合;此处空则拒
|
||||
return "", fmt.Errorf("列 %s 无类型声明(sqlite);请显式声明为 TEXT", column)
|
||||
}
|
||||
return typ, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("列不存在")
|
||||
case DriverMySQL:
|
||||
var typ string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT DATA_TYPE FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, table, column).Scan(&typ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return typ, nil
|
||||
case DriverPostgres:
|
||||
var typ string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT data_type FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`, table, column).Scan(&typ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return typ, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported driver")
|
||||
}
|
||||
}
|
||||
|
||||
// ListForeignKeys 列出以 table 为子表的外键。
|
||||
func ListForeignKeys(ctx context.Context, db *sql.DB, driver Driver, table string) ([]ForeignKey, error) {
|
||||
table = strings.TrimSpace(table)
|
||||
switch driver {
|
||||
case DriverSQLite:
|
||||
rows, err := db.QueryContext(ctx, fmt.Sprintf(`PRAGMA foreign_key_list(%s)`, quoteIdent(driver, table)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ForeignKey
|
||||
for rows.Next() {
|
||||
var id, seq int
|
||||
var parent, from, to, onUpdate, onDelete, match string
|
||||
if err := rows.Scan(&id, &seq, &parent, &from, &to, &onUpdate, &onDelete, &match); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ForeignKey{
|
||||
ChildTable: table, ChildColumn: from,
|
||||
ParentTable: parent, ParentColumn: to,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
case DriverMySQL:
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE table_schema = DATABASE() AND TABLE_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL`, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFKRows(rows)
|
||||
case DriverPostgres:
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT tc.table_name, kcu.column_name, ccu.table_name, ccu.column_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage AS ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public' AND tc.table_name = $1`, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFKRows(rows)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported driver")
|
||||
}
|
||||
}
|
||||
|
||||
func scanFKRows(rows *sql.Rows) ([]ForeignKey, error) {
|
||||
var out []ForeignKey
|
||||
for rows.Next() {
|
||||
var ctab, ccol, ptab, pcol string
|
||||
if err := rows.Scan(&ctab, &ccol, &ptab, &pcol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ForeignKey{ChildTable: ctab, ChildColumn: ccol, ParentTable: ptab, ParentColumn: pcol})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
65
platform/internal/dbsync/validate_test.go
Normal file
65
platform/internal/dbsync/validate_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsTextLikePK(t *testing.T) {
|
||||
good := []string{"TEXT", "VARCHAR(36)", "uuid", "CHARACTER VARYING", "NVARCHAR(64)", "char(36)"}
|
||||
for _, g := range good {
|
||||
if !isTextLikePK(g) {
|
||||
t.Fatalf("expected text-like: %s", g)
|
||||
}
|
||||
}
|
||||
bad := []string{"INTEGER", "INT", "BIGINT", "SERIAL", "BIGSERIAL", "int(11)", "NUMERIC", "DECIMAL(10,2)"}
|
||||
for _, b := range bad {
|
||||
if isTextLikePK(b) {
|
||||
t.Fatalf("expected reject: %s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChannelConfigEmpty(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
if err := ValidateChannelConfig(ch); err == nil {
|
||||
t.Fatal("expected error for empty tables")
|
||||
}
|
||||
ch.Local.Tables = []string{"orders"}
|
||||
if err := ValidateChannelConfig(ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChannelAgainstDBRejectsIntegerPK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dsn := "file:" + filepath.ToSlash(filepath.Join(dir, "t.db")) + "?_pragma=foreign_keys(1)"
|
||||
db, err := Open(DriverSQLite, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err = db.Exec(`CREATE TABLE orders (id INTEGER PRIMARY KEY, title TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(`CREATE TABLE orders_uuid (id TEXT PRIMARY KEY, title TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bad := &Channel{
|
||||
Local: Endpoint{Driver: DriverSQLite, DSN: dsn, Tables: []string{"orders"}},
|
||||
Remote: Endpoint{Driver: DriverSQLite, DSN: dsn, Tables: []string{"orders"}},
|
||||
}
|
||||
if err := ValidateChannelAgainstDB(context.Background(), bad); err == nil {
|
||||
t.Fatal("expected reject INTEGER PK")
|
||||
}
|
||||
|
||||
good := &Channel{
|
||||
Local: Endpoint{Driver: DriverSQLite, DSN: dsn, Tables: []string{"orders_uuid"}},
|
||||
Remote: Endpoint{Driver: DriverSQLite, DSN: dsn, Tables: []string{"orders_uuid"}},
|
||||
}
|
||||
if err := ValidateChannelAgainstDB(context.Background(), good); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
119
platform/internal/handler/agent_sync.go
Normal file
119
platform/internal/handler/agent_sync.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"aijianzhan/platform/internal/authx"
|
||||
"aijianzhan/platform/internal/dbsync"
|
||||
"aijianzhan/platform/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"github.com/zeromicro/go-zero/rest/pathvar"
|
||||
)
|
||||
|
||||
// agent 同步只读白名单 + 推远程 A(形态 B);需 JWT 含「数据同步」权限(人类管理员或智能体均可)。
|
||||
|
||||
func agentSyncWhitelistHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
tables := uniqueStringSlice(ch.Local.Tables, ch.Remote.Tables)
|
||||
httpx.OkJson(w, map[string]any{
|
||||
"channel_id": ch.ID,
|
||||
"name": ch.Name,
|
||||
"enabled": ch.Enabled,
|
||||
"direction": ch.Direction,
|
||||
"conflict_policy": ch.ConflictPolicy,
|
||||
"tables": tables,
|
||||
"pk_columns": ch.PKColumns,
|
||||
"hint": "本机 agent 缓存此表白名单;仅白名单表走 local_dbsync",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func agentSyncPushHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
var item dbsync.PushItem
|
||||
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := dbsync.PushToRemote(r.Context(), ch, svcCtx.DBSync.Store(), item)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{"success": true, "result": res})
|
||||
}
|
||||
}
|
||||
|
||||
func agentSyncPushBatchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Items []dbsync.PushItem `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(body.Items) == 0 {
|
||||
authx.WriteError(w, http.StatusBadRequest, "items required")
|
||||
return
|
||||
}
|
||||
if len(body.Items) > 100 {
|
||||
authx.WriteError(w, http.StatusBadRequest, "items limit 100")
|
||||
return
|
||||
}
|
||||
results, err := dbsync.PushBatchToRemote(r.Context(), ch, svcCtx.DBSync.Store(), body.Items)
|
||||
if err != nil {
|
||||
httpx.OkJson(w, map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
"results": results,
|
||||
})
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{"success": true, "results": results})
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueStringSlice(a, b []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, xs := range [][]string{a, b} {
|
||||
for _, s := range xs {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -281,6 +281,141 @@ paths:
|
||||
responses:
|
||||
"200": { description: OK }
|
||||
|
||||
/api/v1/admin/sync/channels:
|
||||
get:
|
||||
operationId: listSyncChannels
|
||||
summary: 列出同步通道
|
||||
parameters: [{ $ref: "#/components/parameters/Authorization" }]
|
||||
responses: { "200": { description: OK } }
|
||||
post:
|
||||
operationId: createSyncChannel
|
||||
summary: 创建同步通道(UUID PK + FK 闭包校验)
|
||||
parameters: [{ $ref: "#/components/parameters/Authorization" }]
|
||||
responses: { "200": { description: OK }, "400": { description: Bad Request } }
|
||||
/api/v1/admin/sync/channels/{id}:
|
||||
get:
|
||||
operationId: getSyncChannel
|
||||
summary: 获取同步通道
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
put:
|
||||
operationId: updateSyncChannel
|
||||
summary: 更新同步通道
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
delete:
|
||||
operationId: deleteSyncChannel
|
||||
summary: 删除同步通道
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/admin/sync/test:
|
||||
post:
|
||||
operationId: testSyncEndpoints
|
||||
summary: 测试本地/线上库连接
|
||||
parameters: [{ $ref: "#/components/parameters/Authorization" }]
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/admin/sync/channels/{id}/prepare:
|
||||
post:
|
||||
operationId: prepareSyncChannel
|
||||
summary: 准备同步(outbox/触发器)
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/admin/sync/channels/{id}/start:
|
||||
post:
|
||||
operationId: startSyncChannel
|
||||
summary: 启动同步
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/admin/sync/channels/{id}/stop:
|
||||
post:
|
||||
operationId: stopSyncChannel
|
||||
summary: 停止同步
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/admin/sync/channels/{id}/reconcile:
|
||||
post:
|
||||
operationId: reconcileSyncChannel
|
||||
summary: 同步修复(对账,有限流)
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK }, "429": { description: Too Many Requests } }
|
||||
/api/v1/admin/sync/channels/{id}/ingest:
|
||||
post:
|
||||
operationId: ingestSyncRows
|
||||
summary: 外部行写入通道 local
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/admin/sync/bindings:
|
||||
get:
|
||||
operationId: listSyncBindings
|
||||
summary: 列出本机库↔线上库绑定
|
||||
parameters: [{ $ref: "#/components/parameters/Authorization" }]
|
||||
responses: { "200": { description: OK } }
|
||||
post:
|
||||
operationId: ensureSyncBinding
|
||||
summary: 登记/更新绑定
|
||||
parameters: [{ $ref: "#/components/parameters/Authorization" }]
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/admin/sync/conflicts:
|
||||
get:
|
||||
operationId: listSyncConflicts
|
||||
summary: 已废弃(公司侧 403)
|
||||
parameters: [{ $ref: "#/components/parameters/Authorization" }]
|
||||
responses: { "403": { description: Forbidden } }
|
||||
/api/v1/agent/sync/channels/{id}/whitelist:
|
||||
get:
|
||||
operationId: agentSyncWhitelist
|
||||
summary: 本机 agent 拉取表白名单
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/agent/sync/channels/{id}/push:
|
||||
post:
|
||||
operationId: agentSyncPush
|
||||
summary: 本机 agent 推变更到线上 A
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/agent/sync/channels/{id}/push/batch:
|
||||
post:
|
||||
operationId: agentSyncPushBatch
|
||||
summary: 本机 agent 批量推送
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK } }
|
||||
/api/v1/platform/dbsync/lww-overrides:
|
||||
get:
|
||||
operationId: platformLwwOverrides
|
||||
summary: 超管 LWW 覆盖审计
|
||||
parameters: [{ $ref: "#/components/parameters/Authorization" }]
|
||||
responses: { "200": { description: OK }, "403": { description: Forbidden } }
|
||||
/api/v1/platform/dbsync/lww-overrides/{id}/rollback:
|
||||
post:
|
||||
operationId: platformLwwRollback
|
||||
summary: 超管按落败快照回滚线上单行
|
||||
parameters:
|
||||
- { $ref: "#/components/parameters/Authorization" }
|
||||
- { $ref: "#/components/parameters/Id" }
|
||||
responses: { "200": { description: OK }, "400": { description: Bad Request } }
|
||||
|
||||
components:
|
||||
parameters:
|
||||
Authorization:
|
||||
|
||||
68
platform/internal/handler/platform_dbsync.go
Normal file
68
platform/internal/handler/platform_dbsync.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"aijianzhan/platform/internal/authx"
|
||||
"aijianzhan/platform/internal/dbsync"
|
||||
"aijianzhan/platform/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"github.com/zeromicro/go-zero/rest/pathvar"
|
||||
)
|
||||
|
||||
// GET /api/v1/platform/dbsync/lww-overrides — 仅平台超级管理员。
|
||||
func platformLwwOverridesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if svcCtx.DBSync == nil {
|
||||
authx.WriteError(w, http.StatusServiceUnavailable, "dbsync not enabled")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
var tenantID int64
|
||||
if s := q.Get("tenant_id"); s != "" {
|
||||
tenantID, _ = strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
channelID := q.Get("channel_id")
|
||||
limit := 200
|
||||
if s := q.Get("limit"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
limit = n
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
list, err := svcCtx.DBSync.Store().ListLwwOverrides(tenantID, channelID, limit)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{
|
||||
"items": list,
|
||||
"hint": "LWW 自动覆盖审计;公司管理员不可见;默认 TTL 90 天;可对 applied_source 回滚单行",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/platform/dbsync/lww-overrides/:id/rollback — 按落败快照回滚线上单行。
|
||||
func platformLwwRollbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if svcCtx.DBSync == nil {
|
||||
authx.WriteError(w, http.StatusServiceUnavailable, "dbsync not enabled")
|
||||
return
|
||||
}
|
||||
id := pathvar.Vars(r)["id"]
|
||||
rec, err := dbsync.RollbackLwwOverride(r.Context(), svcCtx.DBSync.Store(), id)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{
|
||||
"ok": true,
|
||||
"record": rec,
|
||||
"hint": "已按落败快照写回线上 A,并追加一条 rollback 审计",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,8 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
|
||||
{Method: http.MethodGet, Path: "/api/v1/platform/perm-modules", Handler: chain(platformPermModulesHandler(svcCtx), rl, authMW, platformAdmin)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/platform/tenants/:id/permissions", Handler: chain(platformGetTenantPermsHandler(svcCtx), rl, authMW, platformAdmin)},
|
||||
{Method: http.MethodPut, Path: "/api/v1/platform/tenants/:id/permissions", Handler: chain(platformSetTenantPermsHandler(svcCtx), rl, authMW, platformAdmin)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/platform/dbsync/lww-overrides", Handler: chain(platformLwwOverridesHandler(svcCtx), rl, authMW, platformAdmin)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/platform/dbsync/lww-overrides/:id/rollback", Handler: chain(platformLwwRollbackHandler(svcCtx), rl, authMW, platformAdmin)},
|
||||
})
|
||||
|
||||
// —— 鉴权:需已加入租户 ——
|
||||
@@ -131,6 +133,13 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/conflicts/:id/resolve", Handler: chain(syncResolveConflictHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/reconcile", Handler: chain(syncReconcileHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/ingest", Handler: chain(syncIngestHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodGet, Path: "/api/v1/admin/sync/bindings", Handler: chain(syncBindingsListHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/bindings", Handler: chain(syncBindingsEnsureHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
|
||||
// —— 本机 sync agent(形态 B):白名单拉取 + 推线上 A;需「数据同步」权限 ——
|
||||
{Method: http.MethodGet, Path: "/api/v1/agent/sync/channels/:id/whitelist", Handler: chain(agentSyncWhitelistHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/push", Handler: chain(agentSyncPushHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/push/batch", Handler: chain(agentSyncPushBatchHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
|
||||
// 存储:POST 创建对象,GET 读取(无 /upload 动词路径;旧路径保留别名防断裂)
|
||||
{Method: http.MethodPost, Path: "/api/v1/storage", Handler: chain(uploadHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm上传文件))},
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"aijianzhan/platform/internal/authx"
|
||||
"aijianzhan/platform/internal/dbsync"
|
||||
@@ -20,6 +21,14 @@ func requireDBSync(svcCtx *svc.ServiceContext, w http.ResponseWriter) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func reconcileMinInterval(svcCtx *svc.ServiceContext) time.Duration {
|
||||
sec := 300
|
||||
if svcCtx != nil && svcCtx.Config.DBSync.ReconcileMinSec > 0 {
|
||||
sec = svcCtx.Config.DBSync.ReconcileMinSec
|
||||
}
|
||||
return time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
// sync 仅公司顶级权限(管理员 /「数据同步」);智能体与编辑不可配。
|
||||
func syncTenantID(r *http.Request) int64 {
|
||||
return authx.TenantID(r.Context())
|
||||
@@ -74,6 +83,15 @@ func syncSaveHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
// 强制归属当前公司,禁止客户端伪造 tenant_id
|
||||
ch.TenantID = syncTenantID(r)
|
||||
if err := dbsync.ValidateChannelConfig(&ch); err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// 对可达端做 UUID 主键 + 外键闭包校验(remote 通常为线上库)
|
||||
if err := dbsync.ValidateChannelAgainstDB(r.Context(), &ch); err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
saved, err := svcCtx.DBSync.Store().SaveChannel(ch)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
@@ -189,37 +207,14 @@ func syncStopHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
func syncConflictsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
only := r.URL.Query().Get("unresolved") != "0"
|
||||
list, err := svcCtx.DBSync.Store().ListConflictsByTenant(syncTenantID(r), only)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{"items": list})
|
||||
// M3:LWW/冲突追溯仅平台超级管理员;公司 top 403
|
||||
authx.WriteError(w, http.StatusForbidden, "冲突/LWW 覆盖日志仅平台超级管理员可查")
|
||||
}
|
||||
}
|
||||
|
||||
func syncResolveConflictHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Resolution string `json:"resolution"` // apply_source | keep_target | discard
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Resolution == "" {
|
||||
body.Resolution = "discard"
|
||||
}
|
||||
id := pathvar.Vars(r)["id"]
|
||||
if err := svcCtx.DBSync.Store().ResolveConflictForTenant(id, syncTenantID(r), body.Resolution); err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{"ok": true, "resolution": body.Resolution})
|
||||
authx.WriteError(w, http.StatusForbidden, "冲突/LWW 覆盖日志仅平台超级管理员可操作")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,11 +228,19 @@ func syncReconcileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
authx.WriteError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if ok, wait := dbsync.CanReconcile(ch, reconcileMinInterval(svcCtx)); !ok {
|
||||
authx.WriteError(w, http.StatusTooManyRequests, dbsync.ReconcileTooSoonError(wait).Error())
|
||||
return
|
||||
}
|
||||
res, err := dbsync.ReconcileChannel(r.Context(), ch)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_ = svcCtx.DBSync.Store().PatchStats(ch.ID, func(c *dbsync.Channel) {
|
||||
c.LastReconcileAt = &now
|
||||
})
|
||||
httpx.OkJson(w, res)
|
||||
}
|
||||
}
|
||||
|
||||
50
platform/internal/handler/sync_binding.go
Normal file
50
platform/internal/handler/sync_binding.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"aijianzhan/platform/internal/authx"
|
||||
"aijianzhan/platform/internal/dbsync"
|
||||
"aijianzhan/platform/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
func syncBindingsListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
localID := r.URL.Query().Get("local_database_id")
|
||||
list, err := svcCtx.DBSync.Store().ListBindings(syncTenantID(r), localID)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{"items": list})
|
||||
}
|
||||
}
|
||||
|
||||
func syncBindingsEnsureHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
var body dbsync.Binding
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
body.TenantID = syncTenantID(r)
|
||||
if body.UserID == 0 {
|
||||
body.UserID = authx.UserID(r.Context())
|
||||
}
|
||||
saved, err := svcCtx.DBSync.Store().EnsureBinding(body)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, saved)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"aijianzhan/platform/internal/config"
|
||||
"aijianzhan/platform/internal/handler"
|
||||
@@ -39,6 +40,34 @@ func main() {
|
||||
defer cancel()
|
||||
if ctx.DBSync != nil {
|
||||
ctx.DBSync.StartAll(runCtx)
|
||||
ttlDays := c.DBSync.LwwAuditTTLDays
|
||||
if ttlDays <= 0 {
|
||||
ttlDays = 90
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(6 * time.Hour)
|
||||
defer t.Stop()
|
||||
purge := func() {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(ttlDays) * 24 * time.Hour)
|
||||
n, err := ctx.DBSync.Store().PurgeLwwOverridesBefore(cutoff)
|
||||
if err != nil {
|
||||
fmt.Printf("dbsync lww purge: %v\n", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
fmt.Printf("dbsync lww purge: removed %d\n", n)
|
||||
}
|
||||
}
|
||||
purge()
|
||||
for {
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
purge()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
139
智建修改意见.md
Normal file
139
智建修改意见.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 智建修改意见(松离线 + UUID 主键一致性)
|
||||
|
||||
> **最终方案(冻结)**:[`松离线-dbsync方案-最终版.md`](./松离线-dbsync方案-最终版.md)
|
||||
> 过程稿:[`松离线-dbsync方案.md`](./松离线-dbsync方案.md)
|
||||
> 配套:`database_fastapi修改意见.md`;对照 `docs/数据同步-中间件.md`。
|
||||
|
||||
---
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
| 项 | 结论 |
|
||||
|----|------|
|
||||
| 对账引擎 | **不必从零重做**;dbsync 已有按主键 upsert、ingest、reconcile、冲突队列 |
|
||||
| 真正要改 | **表主键约定(UUID)** + **写入口语义(认客户端 id / upsert)** + **与宇恒绑定对齐** |
|
||||
| 推荐模型 | **松 + UUID**:离线本地先按最终主键落库;联网同主键幂等 upsert 到线上 |
|
||||
| 禁止 | 同步键依赖两端各自 `INTEGER AUTOINCREMENT` |
|
||||
|
||||
宇恒侧(客户端)负责:补 UUID、松离线本地落库 + pending、回放幂等。
|
||||
智建侧负责:线上按 UUID upsert、通道/对账/冲突、表结构与发号规则。
|
||||
|
||||
---
|
||||
|
||||
## 二、与宇恒文档的分工
|
||||
|
||||
| 端 | 文档 | 职责 |
|
||||
|----|------|------|
|
||||
| 宇恒客户端 | `database_fastapi修改意见.md` | `apply_write`、pending、binding、`online_primary` |
|
||||
| 智建平台 | **本文** | 线上主库语义、dbsync、表约定、对账 API 对业务可用 |
|
||||
|
||||
接法二选一(长期建议 ①):
|
||||
|
||||
| 接法 | 含义 | 智建改动量 |
|
||||
|------|------|------------|
|
||||
| **① 走 dbsync** | 宇恒本地 = B;松离线写 B(带 UUID);通道 B↔A | 表结构 + 配置为主,代码改少 |
|
||||
| **② HTTP 双写** | 宇恒直打 `/database/permanent/...` | 线上 insert **必须**变 upsert,且强制客户端 `id` |
|
||||
|
||||
---
|
||||
|
||||
## 三、表结构约定(A / B 两端)
|
||||
|
||||
1. 参与同步的业务表主键为 **`id TEXT`(UUID 字符串)**,不用自增整数当同步键。
|
||||
2. 建议列:`updated_at` / `version`(与 dbsync 版本跳过、冲突策略对齐)。
|
||||
3. 通道 `pk_columns`:非默认 `id` 时显式配置。
|
||||
4. 存量自增表:迁移生成 UUID,或暂不纳入同步白名单,仅新表用 UUID。
|
||||
5. dbsync 触发器已 `CAST(pk AS TEXT)`,字符串 UUID **原生兼容**;改的是建表习惯,不是中间件内核。
|
||||
|
||||
---
|
||||
|
||||
## 四、接法 ①:走 dbsync(推荐)
|
||||
|
||||
### 已有、可直接用
|
||||
|
||||
- 本地变更 → `_ajz_sync_outbox` → worker 推线上
|
||||
- `POST .../ingest`:按主键 upsert(示例已是 `"id": "c-001"`)
|
||||
- `POST .../reconcile`:主键集合补缺
|
||||
- 冲突队列 + LWW / queue 策略
|
||||
- 双向通道约每分钟自动对账
|
||||
|
||||
### 建议补强
|
||||
|
||||
| 优先级 | 项 | 说明 |
|
||||
|--------|----|------|
|
||||
| P0 | 文档/控制台规范 | 写死:同步表白名单必须 UUID 主键;松离线以客户端已带 `id` 为准 |
|
||||
| P0 | 写入口拒无 id | ingest / 业务写:无 `id` → 400,或服务端生成 UUID **并回写响应**(松离线仍优先客户端生成) |
|
||||
| P1 | 库绑定对齐 | 宇恒 `online_db_id` 与智建租户库 / 通道 remote 可查询、可登记;避免仅本地 hash 对不上真实库 |
|
||||
| P1 | 业务可调对账 | 现 sync API 偏管理员;提供租户内用户/agent 代理调用 ingest/reconcile,或网关代调 |
|
||||
| P2 | 同 PK 内容对账 | 当前 reconcile 偏主键集合;可选 version/hash 不一致 → 冲突队列 |
|
||||
|
||||
### 松离线数据流(①)
|
||||
|
||||
```text
|
||||
离线:宇恒本地 B 按最终 UUID 落库(可标未同步)+ 可选 pending
|
||||
联网:触发器/回放 → 同 PK upsert → A
|
||||
兜底:reconcile 补主键集合差;冲突进 conflicts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、接法 ②:HTTP 双写(短期兼容宇恒网关)
|
||||
|
||||
宇恒当前:`online_primary` 下同 body 先写线上再写本地,**不会**把线上自增 `inserted_id` 回填本地。
|
||||
|
||||
智建线上 permanent 写接口必须:
|
||||
|
||||
1. **`insert` = upsert by `id`**(有则更新,无则插入);请求**必须带 `id`**。
|
||||
2. 响应带回 **`id` + `version`(可选)**,便于客户端校验。
|
||||
3. `update` / `delete` 统一按同一 `id`;禁止「无 id 靠自增」。
|
||||
4. 建表默认主键模板改为 UUID,不再默认 `INTEGER AUTOINCREMENT`。
|
||||
5. (可选)按 `online_db_id` + 表暴露 reconcile,或复用 dbsync reconcile。
|
||||
|
||||
未改以上语义时,即使客户端用 UUID,线上若仍自增,双轨主键仍会漂。
|
||||
|
||||
---
|
||||
|
||||
## 六、落地顺序(智建)
|
||||
|
||||
1. **规范 + 迁移**:同步表白名单,主键 UUID。
|
||||
2. **选定接法**:优先 ①;若短期 ②,先改 permanent 写为 upsert。
|
||||
3. **写入口强制带 `id`**(缺则 400 或服务端生成并返回)。
|
||||
4. **绑定**:租户/用户 ↔ 线上库 ID 可查,与宇恒 `cache/db_bindings` 对齐。
|
||||
5. **对账**:业务可读 API 或网关代调;冲突走现有 conflicts。
|
||||
6. **文档**:离线写入 = 最终主键;联网只做同 PK 幂等,不重新发号。
|
||||
|
||||
---
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
- 离线插入一行(客户端 UUID)→ 联网后线上与本地 **同一 `id`**,无重复行。
|
||||
- 同一 UUID 重复提交 / 回放 → **幂等**,不多行。
|
||||
- 断网窗口结束回放后,主键集合对账无持续缺口(或缺口可一键 reconcile 清掉)。
|
||||
- 并发改同 PK → 进冲突队列或按约定 LWW,不静默改主键。
|
||||
- 未纳入白名单的自增旧表:不同步或明确排除,避免误配通道。
|
||||
|
||||
---
|
||||
|
||||
## 八、勿做事项
|
||||
|
||||
- 不要用「两端自增 + 事后对账」充当主路径。
|
||||
- 不要只靠触发器异步 outbox 充当客户端主写路径却仍让 HTTP insert 自增发号(两套语义打架)。
|
||||
- 不要在回放失败时换新主键重试。
|
||||
- 不要让管理员专用 sync API 成为唯一对账入口却要求终端用户自愈(需代理或网关)。
|
||||
|
||||
---
|
||||
|
||||
## 九、相关路径
|
||||
|
||||
| 项 | 路径 |
|
||||
|----|------|
|
||||
| 本文 | `E:\project\ai建站\智建修改意见.md` |
|
||||
| 宇恒写网关意见 | `E:\project\ai建站\database_fastapi修改意见.md` |
|
||||
| dbsync 说明 | `E:\project\ai建站\docs\数据同步-中间件.md` |
|
||||
| dbsync 实现 | `platform/internal/dbsync/` |
|
||||
| 宇恒网关实现 | `yuhengyihao_client/yxd/app_fastapi/db_write_gateway.py` 等 |
|
||||
|
||||
---
|
||||
|
||||
## 十、一句话
|
||||
|
||||
智建 dbsync **已经按主键 upsert / 对账**;「松 + UUID」要改的是 **业务表主键约定 + 写接口认客户端 id**,并与宇恒绑定、回放对齐——不是再造一套同步中间件。
|
||||
294
松离线-dbsync方案-最终版.md
Normal file
294
松离线-dbsync方案-最终版.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# 松离线 + dbsync 方案 · 最终版(冻结)
|
||||
|
||||
> **效力**:本文为双方协商后的**唯一开工依据**。
|
||||
> 原方案稿 / 审评追加 / 决议草稿见同目录 `松离线-dbsync方案.md`(过程稿,不再改口径)。
|
||||
> 冻结日期:2026-07-31
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
**技术**:松离线写本地 B(最终 UUID)+ 本机 agent 经 dbsync 幂等对齐线上 A;冲突自动 LWW,落败入**平台超级管理员**覆盖日志。
|
||||
**产品**:宇恒**默认零影响**;仅显式 `local_dbsync` + **表白名单** opt-in;M4 前不对全员切默认。
|
||||
|
||||
---
|
||||
|
||||
## 1. 硬约束(不可回退)
|
||||
|
||||
| # | 约束 |
|
||||
|---|------|
|
||||
| H1 | 未开通同步的用户:保存 / 自增表 / 插件行为与现网一致(行为 diff = 0) |
|
||||
| H2 | 默认**不得**自动升为 `local_dbsync` |
|
||||
| H3 | agent 停运**不得**导致 `apply_write` / 保存失败;心跳只驱动 UI |
|
||||
| H4 | 默认建表模板**保持自增**;仅「同步表模板」为 UUID |
|
||||
| H5 | 旧 `online_primary` 客户**禁止静默关双写**;仅新客或显式迁移客评估切换 |
|
||||
| H6 | 未开通用户**零同步文案**(无强制状态条、无「已自动合并」) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构定案
|
||||
|
||||
| 项 | 定案 |
|
||||
|----|------|
|
||||
| 离线 | **松离线**:开通且白名单表 → 本地正式库落最终 UUID,立刻可见 |
|
||||
| 同步 | **接法① dbsync**(非 HTTP 双写主路径) |
|
||||
| 通道形态 | **B 本机 sync agent**(平台不直连用户 SQLite) |
|
||||
| 首期方向 | **B→A**;双向另开,默认关 |
|
||||
| 冲突 | **自动 LWW**;首期投递以 **outbox 单调 version** 为主,偏源(本机推上)时覆盖目标并记审计 |
|
||||
| 落败追溯 | **LWW 自动覆盖日志**;**仅平台超级管理员**;公司 top **403**;建议 TTL 90 天 |
|
||||
| 终端冲突 UI | **不做** |
|
||||
| 形态 A / CRDT / 对账替代 UUID | **不做** |
|
||||
|
||||
```text
|
||||
[开通 + 白名单表]
|
||||
业务写 ──► B(UUID)──► outbox ──► 本机 agent ──► A(ingest/upsert)
|
||||
UI:仅本机 / 待同步 / 已同步(不影响保存成败)
|
||||
|
||||
[未开通]
|
||||
现网路径不变(local_only 或 online_primary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 模式三分(宇恒)
|
||||
|
||||
| 模式 | 含义 | 默认 |
|
||||
|------|------|------|
|
||||
| `local_only` | 只写本地 | 可作默认之一 |
|
||||
| `online_primary` | 现网 HTTP 双写 / 离线 pending | **已上线客户保留** |
|
||||
| `local_dbsync` | 松离线 + agent(新) | **仅显式配置** |
|
||||
|
||||
- 未配置 / 未识别 → **不得**当成 `local_dbsync`。
|
||||
- **互斥粒度(定案 O6)**:**同一张表**不可同时走 `online_primary` 双写与 `local_dbsync`;**同一库**允许部分表白名单走 `local_dbsync`,其余表保持该库原模式写本地(不强制整库切模式)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 白名单(定案 O1 / O2)
|
||||
|
||||
| 项 | 定案 |
|
||||
|----|------|
|
||||
| 源 of truth | **智建通道配置** |
|
||||
| 宇恒 | 本地缓存副本;拉取前 / 拉失败 → **视为未开通同步表**(走原写路径) |
|
||||
| 谁可改 | **公司 top**(现「数据同步」权限);终端只读 |
|
||||
| 闭包 | 父子外键须同进白名单,否则保存通道失败 |
|
||||
| 入通道 | 非 `id TEXT` UUID → **拒绝** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 写路径规则
|
||||
|
||||
### 5.1 未进白名单或未开 `local_dbsync`
|
||||
|
||||
与现网完全一致:可不传 `id`、允许自增;`online_primary` 语义不变。
|
||||
|
||||
### 5.2 已开 `local_dbsync` 且表在白名单
|
||||
|
||||
1. 无 `id` → **网关自动补 UUID**(规范化小写带连字符),响应带回 `id` / `inserted_id`;并记 auto_id 日志。
|
||||
2. **始终写 B 正式表**;成功即返回成功。
|
||||
3. 写 outbox;agent 异步推 A。
|
||||
4. agent 未装 / 停运:本地成功 + UI「待同步」;**不失败保存**(定案 R1)。
|
||||
5. delete:outbox `delete` 或墓碑;agent **按 outbox id 保序**;失败**禁止换新 UUID**。
|
||||
|
||||
**开通判定(写路径)**:`mode=local_dbsync` **且** 白名单缓存命中该表;否则走 §5.1。
|
||||
|
||||
### 5.3 M1 无 agent 时「开通」含义(定案 O3)
|
||||
|
||||
允许只落 B + 积压待同步;文案须写明 **「需 agent 才上云」**,不得暗示已上云。
|
||||
|
||||
---
|
||||
|
||||
## 6. 建表(定案 R2)
|
||||
|
||||
| 模板 | 主键 |
|
||||
|------|------|
|
||||
| 默认 create(双端) | **仍自增**(不动) |
|
||||
| 同步表模板(双端新增) | `id TEXT` UUID + 建议 `updated_at` / 配合 outbox version |
|
||||
|
||||
禁止「全局默认改成 UUID」。
|
||||
|
||||
---
|
||||
|
||||
## 7. version / LWW(定案 O5)
|
||||
|
||||
| 项 | 定案 |
|
||||
|----|------|
|
||||
| 主序 | **outbox 单调 id / version** |
|
||||
| 辅 | 客户端 `updated_at`(不对时**不挡保存**) |
|
||||
| 落败 | 写超管覆盖日志(两边 payload、胜出策略、tenant、表、pk、时间) |
|
||||
| 弱提示「已自动合并」 | 默认**关**;仅开通用户可配置打开(定案 R4) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 绑定与迁移
|
||||
|
||||
| 项 | 定案 |
|
||||
|----|------|
|
||||
| Binding API | **P1,不挡 M2**(定案 O4);M2 可用现网派生 `online_db_id` |
|
||||
| 存量 `online_primary` → `local_dbsync` | **另附迁移手册**(定案 O7):加白名单 → 装 agent → 观察 → 关 HTTP;**禁止静默迁移**(定案 R5/H5) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 本机 agent 最小集
|
||||
|
||||
- 可选装;未开通可不装。
|
||||
- 读 `_ajz_sync_outbox`(或等价),保序推 A(复用/扩展智建 ingest)。
|
||||
- 凭证:最小权限、按 `online_db_id` 隔离、可吊销;outbox 敏感列脱敏或加密。
|
||||
- 心跳 → UI only。
|
||||
|
||||
---
|
||||
|
||||
## 10. 智建平台交付
|
||||
|
||||
| 优先级 | 项 |
|
||||
|--------|-----|
|
||||
| **P0** | 入通道 UUID + 闭包校验;ingest 认客户端 UUID 幂等;模式/开通说明(默认客户无感) |
|
||||
| **P0** | (M3)超管 LWW 审计 API/存储,与租户「数据同步」菜单分离;TTL |
|
||||
| **P1** | Binding 登记/查询;限流 reconcile;设置页「同步修复」(可选、不弹窗强打断) |
|
||||
| **P1** | 表白名单 B→A / 双向开关(默认 B→A) |
|
||||
| **P2** | 超管按快照回滚单行 |
|
||||
|
||||
公司 top:配通道、白名单、对账(现权)。
|
||||
平台超管:覆盖日志。
|
||||
终端:无冲突台。
|
||||
|
||||
---
|
||||
|
||||
## 11. 分期与退出标准
|
||||
|
||||
| 阶段 | 目标 | 退出标准 |
|
||||
|------|------|----------|
|
||||
| **M0** | 同步表模板 + 入通道校验 + 模式三分文档 | 自增表无法误入通道 |
|
||||
| **M1** | 宇恒旁路:`local_dbsync` + 白名单松离线写 B(可无 agent) | **未开通用户自动化回归 = 现网**;开通用户离线可见;文案不承诺上云 |
|
||||
| **M2** | agent 最小 B→A + ingest 幂等 | 同 UUID 上云、不双行;agent 停仍可本地保存 |
|
||||
| **M3** | 超管 LWW 日志 + 限流 reconcile | top 403;双入口覆盖可查 |
|
||||
| **M4** | 扩白名单;**仅**评估新客或显式迁移客是否默认 `local_dbsync` | 旧客默认不变 |
|
||||
|
||||
**M4 前禁止全员切默认、禁止一刀切关 HTTP 双写。**
|
||||
|
||||
---
|
||||
|
||||
## 12. 验收清单(冻结)
|
||||
|
||||
### 不影响原有
|
||||
|
||||
- [ ] 未开 `local_dbsync`:现网回归通过(自增不传 id、原 `online_primary`)。
|
||||
- [ ] 非白名单:不强制 UUID;入通道拒绝。
|
||||
- [ ] 关 `local_dbsync` / 清白名单缓存:行为回原模式。
|
||||
- [ ] 未开通:无强制 agent、无同步文案、保存不因同步失败。
|
||||
- [ ] 同表未同时走双路径;关同步后无双写放大。
|
||||
|
||||
### 开通后(白名单)
|
||||
|
||||
- [ ] 离线写 B 可见;联网同 UUID 上 A;幂等不双行。
|
||||
- [ ] agent 停:可保存 + 待同步;恢复后追上。
|
||||
- [ ] delete/重放保序;不换新 UUID。
|
||||
- [ ] LWW 落败在超管日志;公司 top 不可见。
|
||||
- [ ] 闭包不完整无法保存通道。
|
||||
- [ ] M1 文案不暗示已上云。
|
||||
|
||||
---
|
||||
|
||||
## 13. 双方承诺(冻结)
|
||||
|
||||
### 宇恒
|
||||
|
||||
1. M0/M1:模式三分 + 白名单进网关;旧分支零行为 diff(附回归清单)。
|
||||
2. agent 非安装强依赖;同步 UI 仅开通后出现。
|
||||
3. M2 起对接 ingest,不自造第二套写语义。
|
||||
4. 合入门禁:未开通用户行为 diff ≠ 0 → 不予合入。
|
||||
|
||||
### 智建
|
||||
|
||||
1. M0:入通道 UUID + 闭包校验。
|
||||
2. M2 前:ingest 幂等认客户端 UUID。
|
||||
3. M3:超管审计与租户控制台分离。
|
||||
4. 发版说明写明:默认客户无感;`local_dbsync` 为增值开通。
|
||||
5. 书面确认:R1–R4 全部按上文硬约束执行(已并入本文 §1/§5/§6/§7/§8)。
|
||||
|
||||
---
|
||||
|
||||
## 14. 对第二轮「待拍板」的最终答复
|
||||
|
||||
| 编号 | 决议 |
|
||||
|------|------|
|
||||
| R1 | **确认**:心跳只驱动 UI,永不因 agent 拒绝保存 |
|
||||
| R2 | **确认**:只加同步模板,默认建表仍自增 |
|
||||
| R3 | **确认**:不一刀切关旧客 HTTP 双写 |
|
||||
| R4 | **确认**:弱提示默认关,仅开通用户可配 |
|
||||
| O1 | **通道为源**;宇恒缓存;无名单=未开通 |
|
||||
| O2 | **公司 top 改白名单**;终端只读 |
|
||||
| O3 | **M1 可无 agent**;文案「需 agent 才上云」 |
|
||||
| O4 | **Binding 不挡 M2** |
|
||||
| O5 | **outbox 单调 version 为主**;不对时不挡保存 |
|
||||
| O6 | **同表互斥**(非同库整库互斥) |
|
||||
| O7 | **迁移手册 + 禁止静默迁移** |
|
||||
|
||||
---
|
||||
|
||||
## 15. 最终口径
|
||||
|
||||
**旁路 opt-in 的松离线 + 本机 agent + dbsync;LWW 自动、超管可追溯;默认不影响宇恒现网。按 M0→M4 开工,本文冻结。**
|
||||
|
||||
---
|
||||
|
||||
## 宇恒追加-实施钉死项(摘录已并入 §16)
|
||||
|
||||
见过程稿全文;下列 §16 为智建复核后的**采纳与补钉**(不回退 H1–H6)。
|
||||
|
||||
---
|
||||
|
||||
## 16. 实施钉死项 · 智建复核结论(2026-07-31)
|
||||
|
||||
> 宇恒 N1–N6 **总体可采纳**,与最终版无冲突。下列为「写死值 + 防坑补钉」。
|
||||
|
||||
### 16.1 对 N1–N6 的定案
|
||||
|
||||
| # | 宇恒建议 | 智建结论 | 写死值 |
|
||||
|---|----------|----------|--------|
|
||||
| N1 | 无 id → 网关自动补 UUID | **采纳** | 自动生成并在响应带回 `id`(可用 `inserted_id` 作别名兼容);**同时打日志「auto_id」** 便于排障 |
|
||||
| N2 | 开通判定 | **采纳并收紧** | **硬条件**:`mode=local_dbsync` **且** 白名单缓存命中该表。租户「同步开通」标志若存在,作为**下发白名单的前提**(无标志则不下发名单),**不**再在写路径上增加第三道易漂移条件 |
|
||||
| N3 | 按表分支 + 单测 | **采纳** | M1 必测:同库表白名单 / 非白名单分叉 |
|
||||
| N4 | outbox;未开通不建表 | **采纳并优选** | **优先与 permanent 同库** `_ajz_sync_outbox`(触发器简单);未开通库**不创建**该表;首次开通时迁移创建 |
|
||||
| N5 | UUID 小写带连字符 | **采纳** | 双端入库前 `normalize`(小写 + 标准 8-4-4-4-12) |
|
||||
| N6 | 旧字段保留、新字段仅开通附加 | **采纳** | 未开通响应形状与现网一致(可多不可少成功语义) |
|
||||
|
||||
### 16.2 宇恒反馈有没有问题?
|
||||
|
||||
**没有方向性问题。** N1–N6 都是在落实「不影响原有」,应进 M0/M1。
|
||||
需注意的不是反对项,而是**执行时的灰区**(见下补钉)。
|
||||
|
||||
### 16.3 仍不完善 · 建议补钉(P0/P1)
|
||||
|
||||
| # | 缺口 | 建议 | 优先级 |
|
||||
|---|------|------|--------|
|
||||
| P0-a | 白名单缓存过期 | 定 TTL(如 5–15 min)+ 开通/改名单后**主动推送或下次启动强制拉**;过期失败 → 未开通(已定),避免长期用过期「仍在名单」误走同步 | P0 |
|
||||
| P0-b | 同表从 HTTP 双写迁入白名单 | 迁移手册写死:先停该表 `online_primary` 双写 → 再进白名单 / `local_dbsync`;禁止「边双写边进名单」 | P0(并入 O7 手册) |
|
||||
| P0-c | 未开通回归套件归属 | 宇恒维护基线用例;智建提供「通道拒绝非 UUID」用例;M1 退出双方签字 | P0 |
|
||||
| P1-a | M1 无 agent 积压 | outbox/待同步条数告警阈值(如 >N 提示装 agent);**仍不挡保存** | P1 |
|
||||
| P1-b | agent 凭证续期 | ingest Token 刷新与吊销流程写入 agent 最小集;过期只影响上云,不影响本地写 | P1 |
|
||||
| P1-c | 多 `local_database_id` | 回归:同一 user 多库绑定不串 `online_db_id`;名单按库缓存 | P1 |
|
||||
| P2-a | 自动补 UUID 掩盖调用方漏传 | 开通路径 metrics:`auto_id_count`;超管/日志可查,不挡业务 | P2 |
|
||||
|
||||
### 16.4 不采纳 / 不回潮
|
||||
|
||||
- 不因「自动补 UUID」改为默认 `local_dbsync`。
|
||||
- 不恢复「写前 agent 心跳失败则拒绝保存」。
|
||||
- 不把 LWW 审计开放给公司 top。
|
||||
|
||||
### 16.5 复核一句话
|
||||
|
||||
**宇恒钉死项应采纳;再补缓存 TTL、迁表白名单与双写互斥顺序、回归归属,M0/M1 即可干净开工。**
|
||||
|
||||
---
|
||||
|
||||
## 17. 变更记录(落地)
|
||||
|
||||
| 日期 | 范围 | 说明 |
|
||||
|------|------|------|
|
||||
| 2026-07-31 | M0 智建 | 通道保存 / `PrepareChannel`:UUID TEXT PK + FK 闭包校验(`platform/internal/dbsync/validate.go`);文档 `docs/同步表约定.md`;SyncPage 表白名单提示 |
|
||||
| 2026-07-31 | M2 智建 | `PushToRemote` + `/api/v1/agent/sync/.../push|whitelist`(agent JWT 需「数据同步」);**宇恒本机 agent / 写网关由宇恒侧自行落地,智建不改对方仓库** |
|
||||
| 2026-07-31 | 约定 | M1(宇恒 `local_dbsync` 旁路)与 agent 消费 outbox:以本文 + `docs/同步表约定.md` 为接口约定,不在智建仓改宇恒代码 |
|
||||
| 2026-07-31 | M3 智建 | LWW 覆盖审计 `lww_overrides.json`;`GET /api/v1/platform/dbsync/lww-overrides`(仅超管);公司 conflicts API 403;对账限流;默认 `lww_source`;SyncPage 去冲突台 |
|
||||
| 2026-07-31 | M4 智建 | Binding API;开通说明/O7 迁移手册;中间件文档 LWW 对齐;SyncPage「同步修复」+ opt-in 文案;apidef 补全。**全员默认 / 宇恒灰度由对方与运营决策,本仓不改宇恒** |
|
||||
| 2026-07-31 | P2 智建 | 超管按 LWW 落败快照回滚单行:`POST /api/v1/platform/dbsync/lww-overrides/:id/rollback` + 平台工作台按钮 |
|
||||
| 2026-07-31 | 收尾 智建 | OpenAPI 补 sync/agent/lww;yaml TTL/限流;INTEGER PK 拒绝集成测;发版说明 |
|
||||
611
松离线-dbsync方案.md
Normal file
611
松离线-dbsync方案.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# 方案稿:松离线 + 接法①(dbsync)
|
||||
|
||||
> 状态:**已冻结 → 请以 [`松离线-dbsync方案-最终版.md`](./松离线-dbsync方案-最终版.md) 为准开工**
|
||||
> 本文保留为过程稿(正文草稿 + 双方审评 + 协商记录),**口径以最终版为准**。
|
||||
> 日期:2026-07-31
|
||||
> 配套:`智建修改意见.md`、`docs/数据同步-中间件.md`、宇恒 `db_write_gateway.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话
|
||||
|
||||
**离线在本地正式库按最终 UUID 落库;联网靠 dbsync 通道把本地 B 与线上 A 同主键幂等对齐。**
|
||||
不做 HTTP 双写主路径;不做「两端自增再对账」。
|
||||
|
||||
---
|
||||
|
||||
## 2. 角色与库
|
||||
|
||||
| 端 | 角色 | 说明 |
|
||||
|----|------|------|
|
||||
| **A** | 智建线上主库 | Postgres(租户/应用库) |
|
||||
| **B** | 宇恒本机正式库 | SQLite permanent(按用户绑定的 `local_database_id`) |
|
||||
| **绑定** | `(tenant_id, user_id, local_database_id) → online_db_id` | 宇恒本地登记;智建侧可查询/登记同一映射,通道 remote 指向对应 A |
|
||||
|
||||
通道(管理员配置,或安装时模板下发):
|
||||
|
||||
```text
|
||||
local = B(sqlite,该用户库文件)
|
||||
remote = A(该 online_db_id / 租户库 DSN)
|
||||
方向 = bidirectional(或先 B→A,稳定后再双向)
|
||||
冲突 = LWW(自动)+ 超级管理员可查覆盖日志;非用户 queue
|
||||
表白名单 = 仅 UUID 主键业务表
|
||||
```
|
||||
|
||||
```text
|
||||
业务写 ──► B(本地正式库,带 UUID)
|
||||
│
|
||||
├─ 触发器 → _ajz_sync_outbox → worker → A(同 PK upsert)
|
||||
│
|
||||
└─ A 上变更 ←── outbox 拉回 ── B(双向时)
|
||||
对账兜底:reconcile(主键集合差)+ conflicts 队列
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 松离线语义(写路径)
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| **离线写** | 客户端生成 UUID → **直接写 B 正式表** → 成功即对用户可见;同时可标 `sync_status=pending`(可选列) |
|
||||
| **联网写** | 同样写 B(带 UUID);触发器进 outbox,近实时推 A |
|
||||
| **不做** | 离线只写 pending、正式表空着(那是紧离线,本方案不用) |
|
||||
| **不做** | 业务主路径先 HTTP 打智建 permanent 再 mirror(接法②,本方案不用) |
|
||||
|
||||
Insert 约定:
|
||||
|
||||
1. 请求/落库行必须带 **`id`(UUID)**,客户端生成。
|
||||
2. 禁止依赖 `INTEGER AUTOINCREMENT` 当同步键。
|
||||
3. 重复提交同一 `id` → upsert / 忽略,不多行。
|
||||
|
||||
---
|
||||
|
||||
## 4. 表结构约定(A/B 同步表白名单)
|
||||
|
||||
| 项 | 要求 |
|
||||
|----|------|
|
||||
| 主键 | `id TEXT`(UUID 字符串) |
|
||||
| 建议列 | `updated_at`、`version`(配合 LWW / 版本跳过) |
|
||||
| 可选列 | `sync_status`(`synced` / `pending` / `conflict`)仅 UI,同步仍靠 outbox |
|
||||
| 存量自增表 | **不进白名单**,或先迁移 UUID 再进 |
|
||||
| 通道 `pk_columns` | 默认 `id`;复合主键显式配置 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 两端职责
|
||||
|
||||
### 宇恒客户端
|
||||
|
||||
| 项 | 做什么 |
|
||||
|----|--------|
|
||||
| UUID | insert 前生成并写入;update/delete 带同一 `id` |
|
||||
| 写路径 | 改 `apply_write`:**始终先写本地 B**(松);去掉「离线只 pending 不落库」 |
|
||||
| 联网 | 不依赖 HTTP 双写;确保本机 B 上 dbsync 触发器/ agent 可用,或由智建侧连 B 的方式落地(见下「通道落地形态」) |
|
||||
| 绑定 | 保留 `BindingStore`;启动时向智建登记/校验 `online_db_id` |
|
||||
| UI | 可显示「待同步」;**不展示冲突处理**;以本地正式数据为离线真相 |
|
||||
|
||||
### 智建平台
|
||||
|
||||
| 项 | 做什么 |
|
||||
|----|--------|
|
||||
| dbsync | 通道、outbox、ingest、reconcile、conflicts — **复用现有** |
|
||||
| 表规范 | 控制台/文档写死:同步表必须 UUID 主键 |
|
||||
| 绑定 API | 提供「登记/查询 online_db_id ↔ 租户库」供宇恒对齐(P1) |
|
||||
| 对账入口 | 管理员 sync API 已有;可选网关代调给终端用户「一键对账」(P1) |
|
||||
| 线上业务写 | 控制台/网页改 A 时同样走带 UUID 的写;触发器推回 B |
|
||||
|
||||
---
|
||||
|
||||
## 6. 通道落地形态:**已选定 B(本机 sync agent)**
|
||||
|
||||
dbsync worker 默认跑在智建 platform 上时,往往**连不到**用户电脑上的 SQLite B。选定:
|
||||
|
||||
### 形态 B(已定)
|
||||
|
||||
```text
|
||||
用户电脑:业务写 B → 本机 sync agent(触发器 / outbox)→ 推到线上 A
|
||||
← 拉取 A 变更回 B(双向时)
|
||||
智建平台:提供线上 A、鉴权、对账/冲突 API、通道元数据;不直连用户 SQLite
|
||||
```
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| agent 位置 | 宇恒本机(独立进程或嵌入客户端) |
|
||||
| 推送目标 | 智建线上库 A(按 `online_db_id` / 租户库) |
|
||||
| 平台职责 | A 侧接收(兼容现有 ingest/应用逻辑)、conflicts、reconcile 入口 |
|
||||
| 为何不选 A | 用户电脑公网不可达,平台无法直连 B |
|
||||
| 为何不选 C | C 仅作过渡;主路径要近实时 outbox,用 B |
|
||||
|
||||
### 曾对比(归档)
|
||||
|
||||
| 形态 | 说明 | 结论 |
|
||||
|------|------|------|
|
||||
| A. 智建连远程 B | 平台 DSN 直连用户库 | 否(公网不可达) |
|
||||
| **B. 本机 sync agent** | 本机推/拉 A | **采用** |
|
||||
| C. 批量 ingest 过渡 | 联网后打包推 | 仅应急,非主路径 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 与当前实现的差异(改造清单)
|
||||
|
||||
| 现状 | 本方案目标 |
|
||||
|------|------------|
|
||||
| 宇恒 `online_primary`:离线 pending、不写正式库 | 离线 **写正式库(UUID)** |
|
||||
| 宇恒联网:HTTP 先写线上再 mirror | 联网 **写本地 B**,由 dbsync 推 A |
|
||||
| 智建 dbsync 偏管理员控制台 | 增加绑定对齐 +(可选)用户侧对账入口 |
|
||||
| 业务表可能自增主键 | 同步表白名单强制 UUID |
|
||||
|
||||
建议改造顺序:
|
||||
|
||||
1. **规范**:同步表 UUID + 建表模板(智建 + 宇恒)
|
||||
2. **宇恒写路径**:松离线落 B + 强制 `id`
|
||||
3. **本机 agent**:按形态 B 打通一条演示通道(本机 outbox → A)
|
||||
4. **绑定 API**:online_db_id 可查可登记
|
||||
5. **对账 + LWW 覆盖日志**:reconcile 沿用;LWW 落败快照仅超级管理员可查
|
||||
6. **下线接法②主路径**(HTTP 双写仅作运维/兼容开关)
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据流示例
|
||||
|
||||
### 离线插入
|
||||
|
||||
```text
|
||||
用户点保存
|
||||
→ 客户端 uuid = U1
|
||||
→ INSERT INTO B.orders (id=U1, ...) ✅ 本地立刻可见
|
||||
→ outbox 暂存本地(触发器);无法推 A
|
||||
联网后
|
||||
→ worker/agent 投递 A:upsert id=U1
|
||||
→ A、B 同主键;sync_status=synced(若有)
|
||||
```
|
||||
|
||||
### 联网插入
|
||||
|
||||
```text
|
||||
→ 写 B (id=U2) → outbox → 秒级 upsert A
|
||||
```
|
||||
|
||||
### 冲突
|
||||
|
||||
```text
|
||||
离线改 U1;同时线上也改 U1
|
||||
→ LWW 自动留一版,另一版写入「自动覆盖日志」(含两边 payload)
|
||||
→ 终端用户无感;仅平台超级管理员可查/导出(可选回滚)
|
||||
→ 禁止换新主键重试
|
||||
```
|
||||
|
||||
### 漏数兜底
|
||||
|
||||
```text
|
||||
POST reconcile → 按主键集合补缺 → 不多发号
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- [ ] 离线插入(客户端 UUID)→ 本地立刻有行;联网后 A 与 B **同一 `id`**,无第二行。
|
||||
- [ ] 同一 UUID 重复写 / 重放 outbox → **幂等**。
|
||||
- [ ] 断网窗口结束 + 对账后,白名单表主键集合无持续缺口。
|
||||
- [ ] 同 PK 并发 → LWW 自动覆盖,**不改主键**;落败方在超级管理员覆盖日志中可查。
|
||||
- [ ] 自增旧表未进白名单,误配会被拒绝或文档明确排除。
|
||||
- [ ] 关闭 HTTP 双写主开关后,业务仍仅靠 B + dbsync 对齐。
|
||||
|
||||
---
|
||||
|
||||
## 10. 明确不做
|
||||
|
||||
- 紧离线(只 pending、正式表不落)作为本方案主路径。
|
||||
- 接法② HTTP 双写作为主同步。
|
||||
- 两端自增 + 事后映射。
|
||||
- 回放/投递失败时换新 UUID 重试。
|
||||
|
||||
---
|
||||
|
||||
## 11. 评审结论
|
||||
|
||||
| 项 | 结论 |
|
||||
|----|------|
|
||||
| 离线模型 | **松离线**(本地正式库落 UUID) |
|
||||
| 同步接法 | **① dbsync** |
|
||||
| 通道形态 | **B 本机 sync agent** |
|
||||
| 冲突默认 | **LWW(自动裁决,终端用户零操作)**;建议按 `updated_at`/`version`,单客户端场景可偏本机(`lww_source`) |
|
||||
| 覆盖可追溯 | **LWW 仍自动覆盖,但必须写「自动覆盖日志」**(见下) |
|
||||
| 谁可查日志 | **仅平台超级管理员**(非公司 top / 租户管理员) |
|
||||
| 兼容开关 | **过渡期保留** `online_primary`(HTTP 双写),灰度后再切默认 |
|
||||
|
||||
### 自动覆盖日志(相对现网 dbsync 的补强)
|
||||
|
||||
现网纯 LWW **不会**把落败方写入 `conflicts.json`。本方案要求:
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 何时写 | 每次因版本较旧而跳过(`lww_target`)或因 LWW 覆盖目标旧行(`lww_source`) |
|
||||
| 记什么 | `tenant_id`、通道、表、`row_pk`、源/目标 version、**两边 payload 快照**、胜出策略、时间 |
|
||||
| 存储 | 独立审计(如 `data/dbsync/lww_audit.json` 或平台审计表),**不要**当成用户待办冲突队列 |
|
||||
| 终端用户 | 无入口、无提示、不阻断同步 |
|
||||
| 公司 top | **不可见**(与现「数据同步」控制台租户管理员权限分离) |
|
||||
| 平台超级管理员 | 可查、可导出;可选「按快照回滚某一行」(P2) |
|
||||
|
||||
权限口径:
|
||||
|
||||
```text
|
||||
公司 top(租户管理员)→ 配自己公司的通道 / 对账(沿用现权限)
|
||||
平台超级管理员 → 看全局 LWW 自动覆盖日志 / 应急回滚
|
||||
终端用户 → 不参与冲突处理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 一句话验收口径
|
||||
|
||||
**松离线写 B(最终 UUID)+ 本机 agent 走 dbsync;冲突自动 LWW;落败方进超级管理员可查的覆盖日志;主键不漂。**
|
||||
|
||||
---
|
||||
|
||||
<!-- =====================================================================
|
||||
以下为独立评审意见(追加区),不改写上文方案正文。
|
||||
标识:宇恒追加-审评意见 · 2026-07-31
|
||||
===================================================================== -->
|
||||
|
||||
## 宇恒追加-审评意见
|
||||
|
||||
> **说明**:本节是宇恒侧对上文方案的外部审评意见,**不是**方案正文的修订稿。采纳与否单独决议;未并入 §1–§12。
|
||||
> **硬约束(宇恒侧)**:**这边的修改不得影响原有用户操作与现网写库逻辑**——默认行为保持现状;新能力仅 opt-in / 白名单生效。
|
||||
|
||||
### A. 总评
|
||||
|
||||
方案主线(松离线写 B + UUID + 本机 agent + dbsync + LWW)与「离线可用且联网后主键不漂」目标匹配,可立。
|
||||
但在宇恒落地时必须遵守:
|
||||
|
||||
| 原则 | 含义 |
|
||||
|------|------|
|
||||
| **默认不变** | 未开新模式时,等同现网:`local_only` 或现有 `online_primary` 行为与契约不变 |
|
||||
| **显式开启** | 仅 `YXD_SYNC_MODE=local_dbsync`(名可再定)+ 同步表白名单 时走松离线/dbsync |
|
||||
| **白名单隔离** | 未进白名单的表:仍可自增、仍走原 insert;**禁止**因全库切 UUID/强制 `id` 拖垮旧插件 |
|
||||
| **双路径互斥** | 同一库/表不得同时 HTTP 双写与 local_dbsync,避免双写放大 |
|
||||
| **可回滚** | 关开关即回到原行为,无需迁数据才能用旧功能 |
|
||||
|
||||
**因此:正文目标可做,但宇恒改造必须是「旁路增量」,不是改默认写路径。**
|
||||
|
||||
### B. 同意保留的决策(在不影响原有的前提下)
|
||||
|
||||
| 项 | 意见 |
|
||||
|----|------|
|
||||
| 不做接法②作**新**主路径 | 同意;现网 `online_primary` 可作兼容保留,默认可不切走 |
|
||||
| 形态 B 本机 agent | 同意;仅对已开通同步的用户/库启用 |
|
||||
| 终端零冲突 UI | 同意;且**未开通同步的用户完全无感**(无新状态条、无强制 agent) |
|
||||
| 过渡期保留 HTTP 开关 | 同意;**默认勿切** `local_dbsync` |
|
||||
|
||||
### C. 建议补强(保证「不影响原有」)
|
||||
|
||||
1. **模式三分,默认旧行为**
|
||||
- `local_only`:只写本地(现网兼容默认之一)
|
||||
- `online_primary`:现网 HTTP 双写(已上线客户可继续)
|
||||
- `local_dbsync`:**新**松离线 + agent(仅显式配置)
|
||||
未配置 / 未识别 → **不得**自动升到 `local_dbsync`。
|
||||
|
||||
2. **强制 UUID / 必带 `id` 仅白名单表**
|
||||
网关:白名单表缺 `id` 可补 UUID 或 400;**非白名单表保持原样**(允许自增、允许不传 id)。
|
||||
禁止「全局 create_table 改成 UUID」一刀切——可新增「同步表模板」,旧模板不动。
|
||||
|
||||
3. **`apply_write` 分支,不改旧分支语义**
|
||||
- `local_only` / `online_primary`:代码路径与响应字段兼容现网(可多字段,不可少成功语义)
|
||||
- `local_dbsync`:才「始终写 B、不依赖离线 pending 空正式表」
|
||||
旧调用方继续认 `success`;勿要求全员改认 `pending`。
|
||||
|
||||
4. **agent 可选装**
|
||||
未装 agent / 未开通同步:业务写库与现网一致;**禁止**写前强依赖 agent 心跳导致保存失败。
|
||||
「仅本机 / 待同步 / 已同步」仅对白名单且已开通同步的会话展示。
|
||||
|
||||
5. **外键 / 闭包**
|
||||
仅约束「要进同步白名单」的表集;未同步的旧关联表不强制改造。
|
||||
|
||||
6. **删改幂等、安全、对账**
|
||||
仍建议做,但范围限 `local_dbsync` + 白名单;不影响未开通路径。
|
||||
|
||||
7. **建表模板**
|
||||
双端「同步表模板」新增;**默认建表模板保持自增**,避免新用户无感被切 UUID。
|
||||
|
||||
8. **联网 reconcile**
|
||||
仅同步用户;设置页入口可选;默认不弹、不阻断。
|
||||
|
||||
### D. 对现有用户操作逻辑的影响(按约束重述)
|
||||
|
||||
| 对象 | 要求结果 |
|
||||
|------|----------|
|
||||
| 未开 `local_dbsync` 的用户 | **零影响**:保存/查询/自增表/插件与现网一致 |
|
||||
| 未进白名单的表 | **零影响**:不强制 UUID、不强制 agent |
|
||||
| 已开同步的白名单表 | 预期内变更:离线立刻落 B、带 UUID、可显示待同步 |
|
||||
| 聊天 / 选站 / 税务等非 permanent 同步写路径 | **零影响** |
|
||||
|
||||
若某改造会导致「未开同步也改变保存结果」→ **不予合入**,改回 opt-in。
|
||||
|
||||
### E. 建议验收追加项(含「不影响原有」)
|
||||
|
||||
- [ ] 默认配置下:跑现网回归(insert 不传 id 的自增表、offline pending 若仍开 `online_primary`)与改前一致。
|
||||
- [ ] 仅打开 `local_dbsync` + 白名单后:松离线 UUID 验收(§9)通过。
|
||||
- [ ] agent 停运:未开通用户保存仍成功;开通用户本地可写且待同步,恢复后主键不双行。
|
||||
- [ ] 网页与客户端双入口:仅同步表;LWW 符合偏向;超管日志可查。
|
||||
- [ ] 非白名单表加入通道:拒绝或警告。
|
||||
- [ ] `local_dbsync` 与 `online_primary` 互斥;关 `local_dbsync` 后行为回到原模式。
|
||||
|
||||
### F. 不建议纳入本方案(避免范围膨胀与误伤)
|
||||
|
||||
- 用对账替换 UUID 主键策略。
|
||||
- 终端用户冲突工作台。
|
||||
- 平台直连用户 SQLite(形态 A)。
|
||||
- **默认全局改主键 / 默认切 `local_dbsync` / 强制全员装 agent。**
|
||||
|
||||
### G. 一句话评审口径
|
||||
|
||||
**方案可立,但宇恒必须旁路增量:默认不影响原有;仅白名单 + 显式 `local_dbsync` 走松离线。先灰度开通,再谈切默认。**
|
||||
|
||||
---
|
||||
|
||||
<!-- =====================================================================
|
||||
标识:智建追加-审评意见 · 2026-07-31
|
||||
角色:方案侧 / 平台侧复核(对照正文 §1–§12 与上文「宇恒追加」)
|
||||
===================================================================== -->
|
||||
|
||||
## 智建追加-审评意见
|
||||
|
||||
> **说明**:本节是智建/方案侧对正文及「宇恒追加-审评意见」的复核意见,**不是**方案正文修订稿。与宇恒追加并列,采纳单独决议。
|
||||
|
||||
### A. 总评
|
||||
|
||||
同意宇恒总评:主线可立,**必须白名单灰度**,不可默认切全库。
|
||||
智建侧补充:现网 dbsync(outbox / ingest / reconcile / LWW)够用当引擎;缺口在 **本机 agent 协议面**、**LWW 覆盖审计(超管)**、**绑定 API**,不是再造同步中间件。
|
||||
与宇恒追加 **无方向冲突**;下文对 C 条逐项表态,便于开工排序。
|
||||
|
||||
### B. 同意保留 / 同意宇恒已同意项
|
||||
|
||||
| 项 | 意见 |
|
||||
|----|------|
|
||||
| 松离线 + ① + 形态 B + LWW + 超管覆盖日志 | **维持正文定案** |
|
||||
| 不做接法②主路径 / 不做形态 A / 终端零冲突 UI | **同意**(与宇恒 B 节一致) |
|
||||
| 过渡期保留 `online_primary` | **同意**;切默认前须互斥,禁止双路径同时写 |
|
||||
| 白名单灰度 | **同意并升为 P0 开工前提** |
|
||||
|
||||
### C. 对「宇恒追加 · C 建议补强」的表态
|
||||
|
||||
| # | 宇恒建议 | 智建意见 | 建议处置 |
|
||||
|---|----------|----------|----------|
|
||||
| 1 | 外键 / 同步闭包 | **同意** | 写入规范:白名单按闭包进;跨名单外键配置期拒绝 |
|
||||
| 2 | agent 未就绪与「立刻可见」 | **同意** | UI 三态(仅本机/待同步/已同步)+ 写前/写后可探测 agent 心跳;属宇恒 P0 |
|
||||
| 3 | 双入口吞改 | **同意偏保守** | 双入口表:**先 B→A**,稳定后再双向;LWW 默认可偏本机,但双入口表在超管日志外可加「弱提示已自动合并」(仍不选边) |
|
||||
| 4 | delete / 重放顺序 | **同意** | 正文补:墓碑或 outbox `delete`;agent **按 outbox id 保序**;禁止失败换新 UUID |
|
||||
| 5 | `YXD_SYNC_MODE=local_dbsync` | **同意** | 三分:`local_only` / `online_primary` / `local_dbsync`;文档与发版说明同步 |
|
||||
| 6 | 双端建表模板 | **同意,P0** | 智建 create + 宇恒 create 同时改;入通道校验非 TEXT UUID PK → 拒绝 |
|
||||
| 7 | agent 凭证与 outbox 敏感列 | **同意** | 正文加安全节:最小权限、按 `online_db_id` 隔离、可吊销;outbox 敏感列加密或脱敏 at rest |
|
||||
| 8 | 联网自动 reconcile | **同意** | 限流自动对账 + 设置页「同步修复」;**不**暴露 conflicts 给终端;公司 top 仍可走现控制台 |
|
||||
|
||||
### D. 智建侧另补(宇恒未写或平台专属)
|
||||
|
||||
1. **LWW 审计权限**
|
||||
维持:**仅平台超级管理员**;与公司 top「数据同步」菜单分离。实现上独立 API/页,勿挂在租户 sync 控制台下以免误授权。
|
||||
|
||||
2. **A 侧接收面**
|
||||
agent 推送优先复用/扩展现有 ingest(或等价 upsert 接口),保证 **认客户端 UUID、幂等**;避免再开一套与 dbsync 语义不一致的 HTTP 写。
|
||||
|
||||
3. **时钟与 version**
|
||||
LWW 依赖 `updated_at`/`version`:约定客户端单调时钟或服务端授时规则,防止对时错误导致「旧盖新」。建议 version 用 outbox 单调序号为主、时间戳为辅。
|
||||
|
||||
4. **超管日志保留策略**
|
||||
覆盖日志需 TTL/容量上限(如 90 天或按租户配额),避免无限涨;导出权限同超管。
|
||||
|
||||
### E. 对现有用户操作逻辑的影响(复核)
|
||||
|
||||
| 对象 | 影响程度 | 说明 |
|
||||
|------|----------|------|
|
||||
| 终端(白名单表) | **中高** | 同意宇恒:契约带 UUID + 可见性语义变化;用白名单限面 |
|
||||
| 终端冲突 | **降低** | 同意;无选边 |
|
||||
| 自增插件/脚本 | **高(仅误入白名单时)** | 配置期拒绝可把风险压到「零误配」 |
|
||||
| 「保存后线上立刻读到」 | **中** | 同意改为异步;需延迟可观测(agent 状态) |
|
||||
| 公司 top | **低~中** | 多「闭包校验 / 单向先」配置纪律;仍无 LWW 审计 |
|
||||
| 平台超管 | **新增** | 覆盖日志查询/导出;(P2)按快照回滚 |
|
||||
|
||||
**结论**:同意宇恒「冲击面用白名单隔离」;智建把住 **入通道校验 + 超管审计隔离 + ingest 幂等** 三条,终端无感冲突可成立。
|
||||
|
||||
### F. 建议验收追加项(相对 §9 与宇恒 E)
|
||||
|
||||
- [ ] 采纳宇恒 E 全部四条(agent 停运、双入口 LWW、非白名单拒绝、模式互斥)。
|
||||
- [ ] 超管可按 `tenant_id` / 表 / `row_pk` 检索覆盖日志,且公司 top Token **403**。
|
||||
- [ ] 白名单闭包:只配父表不配子表时,保存通道失败并给出明确错误。
|
||||
- [ ] `local_dbsync` 下业务写不调用 permanent HTTP 双写主路径(开关关闭时无双写流量)。
|
||||
|
||||
### G. 不建议纳入本方案
|
||||
|
||||
- 同意宇恒 F:不对账替代 UUID、不做终端冲突台、不做形态 A。
|
||||
- 另:**不做字段级自动合并(CRDT)** 作本期范围;需要时另立专项。
|
||||
- **不把 LWW 审计对租户管理员开放**(即使只读),避免与「超管」口径混淆。
|
||||
|
||||
### H. 一句话评审口径
|
||||
|
||||
**同意宇恒「可立、先白名单再切默认」;正文方向不改。开工顺序建议:双端 UUID 模板与入通道校验 → `local_dbsync` 松离线写 B → agent 最小推送 + 超管 LWW 日志 → 再灰度关 HTTP 双写。**
|
||||
|
||||
---
|
||||
|
||||
<!-- =====================================================================
|
||||
标识:协商决议版 · 2026-07-31
|
||||
合成:正文 §1–§12 + 宇恒追加 + 智建追加 → 可执行默认策略
|
||||
===================================================================== -->
|
||||
|
||||
## 协商决议版(双方意见合成 · 以此为准开工)
|
||||
|
||||
> **效力**:本节覆盖正文中与「默认切全站 / 写前强依赖 agent」相冲突的表述;技术主线(松离线、①、形态 B、LWW、超管日志)不变。
|
||||
> **总原则(采纳宇恒硬约束)**:**未显式开通前,宇恒现网操作与写库逻辑零影响**;新能力仅 `local_dbsync` + 白名单 opt-in。
|
||||
|
||||
### 1. 定案一览
|
||||
|
||||
| 维度 | 决议 |
|
||||
|------|------|
|
||||
| 同步模型 | 松离线写 B(最终 UUID)+ 接法① dbsync |
|
||||
| 通道形态 | **B 本机 sync agent**(平台不直连用户 SQLite) |
|
||||
| 冲突 | **自动 LWW**(用户零操作) |
|
||||
| 落败方可追溯 | **自动覆盖日志**,**仅平台超级管理员**可查(公司 top 不可见) |
|
||||
| 宇恒默认模式 | **保持现网**:`local_only` 或已在用的 `online_primary`,**不自动升** `local_dbsync` |
|
||||
| 新模式 | 显式 `YXD_SYNC_MODE=local_dbsync` + **表级白名单** 才生效 |
|
||||
| 双路径 | `local_dbsync` 与 `online_primary` **互斥**(同库不同时开) |
|
||||
| 建表 | **默认模板仍自增**;另增「同步表模板」= UUID TEXT PK;入通道校验非 UUID → 拒绝 |
|
||||
| 同步方向(首期) | 白名单表 **先 B→A**;双入口稳定后再开 bidirectional |
|
||||
| 范围外 | 不做形态 A、不做终端冲突台、不做 CRDT 字段合并、不对账替代 UUID |
|
||||
|
||||
### 2. 比原文「更好」的关键修正
|
||||
|
||||
原文偏「目标态替换默认写路径」;协商后改为 **旁路增量**,避免伤宇恒存量:
|
||||
|
||||
| 原文风险 | 决议后 |
|
||||
|----------|--------|
|
||||
| 全库强制 UUID / 改默认 create | 仅同步模板 + 白名单;旧表旧模板不动 |
|
||||
| 松离线成为默认写语义 | 仅 `local_dbsync` 分支;旧分支语义冻结 |
|
||||
| 写前强依赖 agent 心跳(智建初评 C2) | **作废**:agent 停也不得导致保存失败;仅开通用户显示「待同步」 |
|
||||
| 双向一上来 | 首期单向 B→A,降低网页↔客户端互盖 |
|
||||
| LWW 无痕 | 超管覆盖日志(两边 payload + 胜出策略 + TTL) |
|
||||
|
||||
### 3. 模式与写路径(宇恒)
|
||||
|
||||
```text
|
||||
local_only → 只写本地(现网)
|
||||
online_primary → HTTP 双写 / 离线 pending(现网已落地,兼容保留)
|
||||
local_dbsync → 白名单表:始终写 B(松)+ outbox;非白名单:仍走该模式下的「本地普通写」且不进同步
|
||||
未配置 → 不得视为 local_dbsync
|
||||
```
|
||||
|
||||
网关规则:
|
||||
|
||||
1. **非白名单**:不强制 `id`、允许自增;与现网一致。
|
||||
2. **白名单 + local_dbsync**:无 `id` 则客户端补 UUID(或 400);写 B 成功即返回成功;agent 仅异步。
|
||||
3. 关 `local_dbsync` → 行为回到原模式(可回滚)。
|
||||
|
||||
### 4. 智建平台职责(收窄可交付)
|
||||
|
||||
| P0 | 内容 |
|
||||
|----|------|
|
||||
| 入通道校验 | UUID TEXT PK + 白名单闭包(父子表一起或拒绝) |
|
||||
| ingest/应用面 | 认客户端 UUID,幂等 upsert;供本机 agent 推送 |
|
||||
| LWW 超管审计 | 独立存储与 API;公司 top 403;TTL(建议 90 天) |
|
||||
| version | outbox 单调序号为主,时间戳为辅 |
|
||||
|
||||
| P1 | 内容 |
|
||||
|----|------|
|
||||
| 绑定 API | `online_db_id` 登记/查询,与宇恒 BindingStore 对齐 |
|
||||
| 联网限流 reconcile | 仅开通同步的库;设置页「同步修复」可选 |
|
||||
| 单向→双向 | 按表白名单开关,默认仍 B→A |
|
||||
|
||||
| P2 | 内容 |
|
||||
|----|------|
|
||||
| 超管按快照回滚单行 | |
|
||||
| 弱提示「已自动合并」 | 仅开通同步客户端,仍不选边 |
|
||||
|
||||
### 5. 本机 agent(形态 B)最小集
|
||||
|
||||
- 嵌入或独立进程;**未开通同步可不装**。
|
||||
- 读本地 `_ajz_sync_outbox`(或等价),**按 id 保序**推 A;支持 delete。
|
||||
- 凭证:最小权限、按 `online_db_id` 隔离、可吊销;outbox 敏感列脱敏/加密。
|
||||
- 心跳仅用于 UI 状态,**不阻断** `apply_write`。
|
||||
|
||||
### 6. 分期(建议排期)
|
||||
|
||||
| 阶段 | 目标 | 退出标准 |
|
||||
|------|------|----------|
|
||||
| **M0** | 规范:同步表模板、入通道校验、模式三分文档 | 误配自增表无法进通道 |
|
||||
| **M1** | 宇恒:`local_dbsync` 旁路 + 白名单松离线写 B(仍可无 agent,只落本地) | 未开通用户回归 = 现网;开通用户离线可见 |
|
||||
| **M2** | agent 最小推送 B→A + ingest 幂等 | 联网后同 UUID 上云、不双行 |
|
||||
| **M3** | 超管 LWW 覆盖日志 + 限流 reconcile | 双入口覆盖可查;公司 top 不可见日志 |
|
||||
| **M4** | 灰度扩大白名单;评估是否对**新客**默认 `local_dbsync` | 旧客默认仍不变,除非显式迁移 |
|
||||
|
||||
**明确:M4 之前不谈「全员切默认」。**
|
||||
|
||||
### 7. 统一验收(合并 §9 + 双方 E)
|
||||
|
||||
**不影响原有**
|
||||
|
||||
- [ ] 默认 / 未开 `local_dbsync`:现网回归通过(含自增不传 id、原 `online_primary` 行为)。
|
||||
- [ ] 非白名单表:不强制 UUID;加入通道被拒绝。
|
||||
- [ ] 关 `local_dbsync` 后行为回到原模式。
|
||||
- [ ] 未开通用户:无强制 agent、无新冲突 UI、保存不因同步失败。
|
||||
|
||||
**开通后(白名单)**
|
||||
|
||||
- [ ] 离线写 B 立刻可见;联网同 UUID 上 A,幂等不双行。
|
||||
- [ ] agent 停:本地仍可保存 +「待同步」;恢复后追上。
|
||||
- [ ] 双入口同 PK:LWW 符合首期策略(偏本机或按 version);落败在超管日志。
|
||||
- [ ] 公司 top 查覆盖日志 → 403;超管可检索/导出。
|
||||
- [ ] 与 `online_primary` 互斥,无双路径同时写。
|
||||
|
||||
### 8. 协商一句话
|
||||
|
||||
**技术上走松离线 + 本机 agent + dbsync + LWW(超管可追溯);产品上必须旁路 opt-in——默认不影响宇恒原有操作,白名单灰度达标前不切默认。**
|
||||
|
||||
---
|
||||
|
||||
<!-- =====================================================================
|
||||
标识:宇恒追加-继续协商(第二轮)· 2026-07-31
|
||||
对照:协商决议版;回应智建追加中仍需对齐处
|
||||
===================================================================== -->
|
||||
|
||||
## 宇恒追加-继续协商(第二轮)
|
||||
|
||||
> **说明**:在「协商决议版」之上的第二轮协商意见,**不改写** §1–§12 与决议正文;请智建对「待拍板」表态后可并入决议修订。
|
||||
> **立场**:决议版 **总原则 + §1/§2/§6/§7「不影响原有」** 已对齐宇恒硬约束 → **宇恒原则上接受决议版开工**。下列为收口与防回潮。
|
||||
|
||||
### 1. 宇恒对决议版的表态
|
||||
|
||||
| 决议要点 | 宇恒态度 |
|
||||
|----------|----------|
|
||||
| 默认不升 `local_dbsync`;旁路 opt-in | **接受(硬条件,不可回退)** |
|
||||
| 默认建表仍自增;同步模板另增 | **接受** |
|
||||
| 写前不因 agent 停而保存失败 | **接受(硬条件)**;请同步作废智建初评 C2「写前强依赖心跳」表述 |
|
||||
| 首期 B→A;M4 前不谈全员默认 | **接受** |
|
||||
| 分期 M0→M4 | **接受**;建议 M1 退出标准增加「未开通用户自动化回归套件」 |
|
||||
| 超管 LWW 日志 / 公司 top 403 | **接受**(宇恒侧无异议) |
|
||||
|
||||
**一句话**:可以按决议版排期;宇恒侧合入准则仍是——**未开通用户 diff 行为 = 0**。
|
||||
|
||||
### 2. 请智建确认(防与初评打架)
|
||||
|
||||
| # | 议题 | 宇恒主张 | 需要智建回复 |
|
||||
|---|------|----------|--------------|
|
||||
| R1 | agent 与保存 | 心跳**只驱动 UI**,永不 `apply_write` 失败 | 书面确认作废「写前探测失败则拒绝保存」 |
|
||||
| R2 | 双端建表 | 「同时改」= **只加同步模板**,默认模板不动 | 确认智建控制台默认建表仍自增 |
|
||||
| R3 | 关 HTTP 双写 | M4 只评估**新客**或**显式迁移客**;旧 `online_primary` 客户保留 | 确认不会「灰度达标后一刀切关双写」 |
|
||||
| R4 | 弱提示「已自动合并」 | 仅 `local_dbsync` 开通用户;默认关闭,配置打开 | 避免未开通用户弹任何同步文案 |
|
||||
|
||||
### 3. 仍待拍板(建议写进决议修订)
|
||||
|
||||
| # | 缺口 | 建议默认(宇恒提案) | 理由 |
|
||||
|---|------|----------------------|------|
|
||||
| O1 | **白名单存在哪** | 智建通道配置为源;宇恒本地缓存副本;以通道为准 | 防两端名单漂移;未拉到名单前当未开通 |
|
||||
| O2 | **谁有权改白名单** | 公司 top(现「数据同步」);终端用户只读 | 与现权限一致;避免用户误加自增表 |
|
||||
| O3 | **M1 无 agent 时「开通同步」含义** | 允许:仅本地松离线 + 待同步积压;产品文案写「需 agent 才上云」 | 方便分期,但不承诺已上云 |
|
||||
| O4 | **Binding API 是否挡 M2** | **不挡**:M2 可用现网派生 `online_db_id`;P1 再对齐登记 | 降低串行依赖 |
|
||||
| O5 | **version 授时** | 优先 **outbox 单调 id**;客户端 `updated_at` 仅辅助;不对时不挡保存 | 落实「不影响保存」 |
|
||||
| O6 | **同库表级互斥** | 决议写「同库互斥」偏严;提案改为:**同表**不可双路径;同库可「部分表 local_dbsync、其余保持原模式写本地」 | 更贴白名单灰度,少逼整库切模式 |
|
||||
| O7 | **存量 `online_primary` 迁 `local_dbsync`** | 另附迁移手册:先加白名单→装 agent→双跑观察→关 HTTP;禁止静默迁移 | 保护已上线双写客户 |
|
||||
|
||||
### 4. 宇恒侧本期承诺(换智建确认 R1–R4)
|
||||
|
||||
1. M0/M1:模式三分 + 白名单判断进 `apply_write`,**旧分支零行为 diff**(附回归清单)。
|
||||
2. 不把 agent 做成安装强依赖。
|
||||
3. 同步 UI 与开关仅在开通后出现。
|
||||
4. M2 起才对接 ingest;协议以智建文档为准,客户端不自造第二套写语义。
|
||||
|
||||
### 5. 建议智建本期承诺
|
||||
|
||||
1. 入通道 UUID + 闭包校验(M0)。
|
||||
2. ingest 幂等认客户端 UUID(M2 前就绪)。
|
||||
3. 超管审计 API 与租户控制台分离(M3)。
|
||||
4. 发版说明写明:**默认客户无感**;`local_dbsync` 为增值开通。
|
||||
|
||||
### 6. 第二轮协商一句话
|
||||
|
||||
**决议版方向已收束;宇恒同意开工。请智建确认 R1–R4,并拍板 O1–O7(尤其 O6 同表互斥 vs 同库互斥)后,即可冻结决议修订版进入 M0。**
|
||||
|
||||
---
|
||||
|
||||
## 最终冻结说明(2026-07-31)
|
||||
|
||||
R1–R4、O1–O7 已全部拍板并写入 **[`松离线-dbsync方案-最终版.md`](./松离线-dbsync方案-最终版.md)**。
|
||||
此后改需求只改最终版并备注变更记录;本过程稿不再作为实施口径。
|
||||
Reference in New Issue
Block a user