From 76cdcd760e9b199b8c1229651ff2cdbe83226016 Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Fri, 31 Jul 2026 17:54:14 +0800 Subject: [PATCH] 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 --- .env.example | 50 +- docs/README.md | 6 +- docs/发版说明-数据同步.md | 35 ++ docs/同步表约定.md | 76 +++ docs/数据同步-中间件.md | 122 ++-- docs/数据同步-开通说明.md | 36 ++ docs/数据同步-迁移手册.md | 40 ++ platform/etc/platform.docker.yaml | 2 + platform/etc/platform.yaml | 2 + platform/internal/apidef/catalog.go | 14 +- platform/internal/authx/perms.go | 1 + platform/internal/config/config.go | 6 +- platform/internal/dbsync/binding.go | 143 +++++ platform/internal/dbsync/binding_test.go | 35 ++ platform/internal/dbsync/lww_audit.go | 41 ++ platform/internal/dbsync/lww_audit_test.go | 72 +++ platform/internal/dbsync/manager.go | 80 ++- platform/internal/dbsync/push.go | 256 ++++++++ platform/internal/dbsync/push_test.go | 46 ++ platform/internal/dbsync/reconcile_limit.go | 30 + platform/internal/dbsync/rollback.go | 79 +++ platform/internal/dbsync/rollback_test.go | 54 ++ platform/internal/dbsync/store.go | 135 +++- platform/internal/dbsync/types.go | 25 +- platform/internal/dbsync/validate.go | 264 ++++++++ platform/internal/dbsync/validate_test.go | 65 ++ platform/internal/handler/agent_sync.go | 119 ++++ platform/internal/handler/openapi.yaml | 135 ++++ platform/internal/handler/platform_dbsync.go | 68 +++ platform/internal/handler/routes.go | 9 + platform/internal/handler/sync.go | 55 +- platform/internal/handler/sync_binding.go | 50 ++ platform/platform.go | 29 + web/src/PlatformTenantsPage.tsx | 85 +++ web/src/SyncPage.tsx | 103 ++-- web/src/api.ts | 89 ++- 智建修改意见.md | 139 +++++ 松离线-dbsync方案-最终版.md | 294 +++++++++ 松离线-dbsync方案.md | 611 +++++++++++++++++++ 39 files changed, 3302 insertions(+), 199 deletions(-) create mode 100644 docs/发版说明-数据同步.md create mode 100644 docs/同步表约定.md create mode 100644 docs/数据同步-开通说明.md create mode 100644 docs/数据同步-迁移手册.md create mode 100644 platform/internal/dbsync/binding.go create mode 100644 platform/internal/dbsync/binding_test.go create mode 100644 platform/internal/dbsync/lww_audit.go create mode 100644 platform/internal/dbsync/lww_audit_test.go create mode 100644 platform/internal/dbsync/push.go create mode 100644 platform/internal/dbsync/push_test.go create mode 100644 platform/internal/dbsync/reconcile_limit.go create mode 100644 platform/internal/dbsync/rollback.go create mode 100644 platform/internal/dbsync/rollback_test.go create mode 100644 platform/internal/dbsync/validate.go create mode 100644 platform/internal/dbsync/validate_test.go create mode 100644 platform/internal/handler/agent_sync.go create mode 100644 platform/internal/handler/platform_dbsync.go create mode 100644 platform/internal/handler/sync_binding.go create mode 100644 智建修改意见.md create mode 100644 松离线-dbsync方案-最终版.md create mode 100644 松离线-dbsync方案.md diff --git a/.env.example b/.env.example index 8887278..106beb5 100644 --- a/.env.example +++ b/.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 diff --git a/docs/README.md b/docs/README.md index f634638..b2b9dba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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/...`。智能体默认无需配置模块白名单即可自建发布。 diff --git a/docs/发版说明-数据同步.md b/docs/发版说明-数据同步.md new file mode 100644 index 0000000..b9e2730 --- /dev/null +++ b/docs/发版说明-数据同步.md @@ -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` 为准。 diff --git a/docs/同步表约定.md b/docs/同步表约定.md new file mode 100644 index 0000000..b8d8090 --- /dev/null +++ b/docs/同步表约定.md @@ -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) diff --git a/docs/数据同步-中间件.md b/docs/数据同步-中间件.md index 8ef77cd..0026d15 100644 --- a/docs/数据同步-中间件.md +++ b/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) diff --git a/docs/数据同步-开通说明.md b/docs/数据同步-开通说明.md new file mode 100644 index 0000000..9be867f --- /dev/null +++ b/docs/数据同步-开通说明.md @@ -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) diff --git a/docs/数据同步-迁移手册.md b/docs/数据同步-迁移手册.md new file mode 100644 index 0000000..cf48bf5 --- /dev/null +++ b/docs/数据同步-迁移手册.md @@ -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 diff --git a/platform/etc/platform.docker.yaml b/platform/etc/platform.docker.yaml index 5ba18f0..2c61d60 100644 --- a/platform/etc/platform.docker.yaml +++ b/platform/etc/platform.docker.yaml @@ -40,3 +40,5 @@ Storage: DBSync: Enabled: true DataDir: /app/data/dbsync + LwwAuditTTLDays: 90 + ReconcileMinSec: 300 diff --git a/platform/etc/platform.yaml b/platform/etc/platform.yaml index 57f73cb..704ecff 100644 --- a/platform/etc/platform.yaml +++ b/platform/etc/platform.yaml @@ -45,3 +45,5 @@ Storage: DBSync: Enabled: true DataDir: ./data/dbsync + LwwAuditTTLDays: 90 + ReconcileMinSec: 300 diff --git a/platform/internal/apidef/catalog.go b/platform/internal/apidef/catalog.go index 031d0bd..9611f88 100644 --- a/platform/internal/apidef/catalog.go +++ b/platform/internal/apidef/catalog.go @@ -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"}, diff --git a/platform/internal/authx/perms.go b/platform/internal/authx/perms.go index 7d8da02..2869a40 100644 --- a/platform/internal/authx/perms.go +++ b/platform/internal/authx/perms.go @@ -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写入模块, diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index 239a442..3acb6ee 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -68,6 +68,8 @@ type StorageConf struct { } type DBSyncConf struct { - Enabled bool `json:",default=true"` - DataDir string `json:",default=./data/dbsync"` // 通道/冲突队列 JSON + Enabled bool `json:",default=true"` + DataDir string `json:",default=./data/dbsync"` // 通道/冲突/LWW 审计 JSON + LwwAuditTTLDays int `json:",default=90"` // 超管 LWW 覆盖日志保留天数 + ReconcileMinSec int `json:",default=300"` // 手动对账最小间隔(秒) } diff --git a/platform/internal/dbsync/binding.go b/platform/internal/dbsync/binding.go new file mode 100644 index 0000000..3ad5583 --- /dev/null +++ b/platform/internal/dbsync/binding.go @@ -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) +} diff --git a/platform/internal/dbsync/binding_test.go b/platform/internal/dbsync/binding_test.go new file mode 100644 index 0000000..2168465 --- /dev/null +++ b/platform/internal/dbsync/binding_test.go @@ -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) + } +} diff --git a/platform/internal/dbsync/lww_audit.go b/platform/internal/dbsync/lww_audit.go new file mode 100644 index 0000000..0334aae --- /dev/null +++ b/platform/internal/dbsync/lww_audit.go @@ -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 +} diff --git a/platform/internal/dbsync/lww_audit_test.go b/platform/internal/dbsync/lww_audit_test.go new file mode 100644 index 0000000..8d2b8f3 --- /dev/null +++ b/platform/internal/dbsync/lww_audit_test.go @@ -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) + } +} diff --git a/platform/internal/dbsync/manager.go b/platform/internal/dbsync/manager.go index 2a6747f..f56632e 100644 --- a/platform/internal/dbsync/manager.go +++ b/platform/internal/dbsync/manager.go @@ -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 { - if _, rerr := ReconcileChannel(ctx, ch); rerr != nil { - log.Printf("dbsync reconcile %s: %v", id, rerr) + // 双向通道自动对账:默认约每 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 { diff --git a/platform/internal/dbsync/push.go b/platform/internal/dbsync/push.go new file mode 100644 index 0000000..02f12f0 --- /dev/null +++ b/platform/internal/dbsync/push.go @@ -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 == "" { + 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 +} diff --git a/platform/internal/dbsync/push_test.go b/platform/internal/dbsync/push_test.go new file mode 100644 index 0000000..205e613 --- /dev/null +++ b/platform/internal/dbsync/push_test.go @@ -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") + } +} diff --git a/platform/internal/dbsync/reconcile_limit.go b/platform/internal/dbsync/reconcile_limit.go new file mode 100644 index 0000000..e01ca98 --- /dev/null +++ b/platform/internal/dbsync/reconcile_limit.go @@ -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) +} diff --git a/platform/internal/dbsync/rollback.go b/platform/internal/dbsync/rollback.go new file mode 100644 index 0000000..79be290 --- /dev/null +++ b/platform/internal/dbsync/rollback.go @@ -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 +} diff --git a/platform/internal/dbsync/rollback_test.go b/platform/internal/dbsync/rollback_test.go new file mode 100644 index 0000000..b26dfb9 --- /dev/null +++ b/platform/internal/dbsync/rollback_test.go @@ -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) + } +} diff --git a/platform/internal/dbsync/store.go b/platform/internal/dbsync/store.go index 018ba54..36b550b 100644 --- a/platform/internal/dbsync/store.go +++ b/platform/internal/dbsync/store.go @@ -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 + mu sync.Mutex + dir string + chPath string + cfPath string + lwwPath string } func NewFileStore(dir string) (*FileStore, error) { @@ -27,9 +28,10 @@ func NewFileStore(dir string) (*FileStore, error) { return nil, err } return &FileStore{ - dir: dir, - chPath: filepath.Join(dir, "channels.json"), - cfPath: filepath.Join(dir, "conflicts.json"), + 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) +} diff --git a/platform/internal/dbsync/types.go b/platform/internal/dbsync/types.go index 6578b52..c10f45e 100644 --- a/platform/internal/dbsync/types.go +++ b/platform/internal/dbsync/types.go @@ -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 @@ -91,8 +110,8 @@ type OutboxRow struct { } type TestResult struct { - OK bool `json:"ok"` - Driver string `json:"driver"` - Message string `json:"message"` + OK bool `json:"ok"` + Driver string `json:"driver"` + Message string `json:"message"` Tables []string `json:"tables,omitempty"` } diff --git a/platform/internal/dbsync/validate.go b/platform/internal/dbsync/validate.go new file mode 100644 index 0000000..e70e09a --- /dev/null +++ b/platform/internal/dbsync/validate.go @@ -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() +} diff --git a/platform/internal/dbsync/validate_test.go b/platform/internal/dbsync/validate_test.go new file mode 100644 index 0000000..ccaf0d6 --- /dev/null +++ b/platform/internal/dbsync/validate_test.go @@ -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) + } +} diff --git a/platform/internal/handler/agent_sync.go b/platform/internal/handler/agent_sync.go new file mode 100644 index 0000000..b18e556 --- /dev/null +++ b/platform/internal/handler/agent_sync.go @@ -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 +} diff --git a/platform/internal/handler/openapi.yaml b/platform/internal/handler/openapi.yaml index a253b2b..e804553 100644 --- a/platform/internal/handler/openapi.yaml +++ b/platform/internal/handler/openapi.yaml @@ -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: diff --git a/platform/internal/handler/platform_dbsync.go b/platform/internal/handler/platform_dbsync.go new file mode 100644 index 0000000..4a71ac3 --- /dev/null +++ b/platform/internal/handler/platform_dbsync.go @@ -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 审计", + }) + } +} diff --git a/platform/internal/handler/routes.go b/platform/internal/handler/routes.go index 1a41f66..ba5b0d0 100644 --- a/platform/internal/handler/routes.go +++ b/platform/internal/handler/routes.go @@ -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上传文件))}, diff --git a/platform/internal/handler/sync.go b/platform/internal/handler/sync.go index b7ab08c..6688d6d 100644 --- a/platform/internal/handler/sync.go +++ b/platform/internal/handler/sync.go @@ -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) } } diff --git a/platform/internal/handler/sync_binding.go b/platform/internal/handler/sync_binding.go new file mode 100644 index 0000000..43aadf9 --- /dev/null +++ b/platform/internal/handler/sync_binding.go @@ -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) + } +} diff --git a/platform/platform.go b/platform/platform.go index 0515667..8b12163 100644 --- a/platform/platform.go +++ b/platform/platform.go @@ -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() { diff --git a/web/src/PlatformTenantsPage.tsx b/web/src/PlatformTenantsPage.tsx index 402511f..025122d 100644 --- a/web/src/PlatformTenantsPage.tsx +++ b/web/src/PlatformTenantsPage.tsx @@ -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>["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: { })} + + + LWW 覆盖日志(仅超管) + + + 同步自动覆盖时的落败/胜出记录;公司管理员不可见。默认保留 90 天。 + 对「源端覆盖」记录可按落败快照回滚线上单行(需通道 remote 仍可达)。 + + + + + (v ? new Date(v).toLocaleString() : ""), + }, + { + title: "操作", + width: 100, + render: (_: unknown, r: (typeof lwwItems)[number]) => + r.outcome === "applied_source" ? ( + + ) : null, + }, + ]} + /> ); } diff --git a/web/src/SyncPage.tsx b/web/src/SyncPage.tsx index 5b86ca7..9167a05 100644 --- a/web/src/SyncPage.tsx +++ b/web/src/SyncPage.tsx @@ -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([]); - const [conflicts, setConflicts] = useState([]); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [editing, setEditing] = useState(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: { 数据同步 - 仅本公司顶级权限(管理员)可配置;通道按公司隔离,看不到其他公司的服务器。 - 典型拓扑:A 线上 ↔ B 本地(双向),额外源 C 写入 B 再推到 A。 - 防回声 + 版本幂等避免「多」;触发器与对账避免「漏」。 + 仅本公司顶级权限(管理员)可配置;通道按公司隔离。 + 默认客户无感:终端须显式开通 local_dbsync 才走松离线。 + 首期推荐方向「本地 → 线上」、策略「源端覆盖」;覆盖审计仅平台超管可见。 + 开通/迁移说明见文档「数据同步-开通说明」「数据同步-迁移手册」。 @@ -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); } }} > - 对账 + 同步修复
( - - - - - ), - }, - ]} - /> + + 冲突已改为自动 LWW;覆盖审计仅平台超级管理员可查。「同步修复」有最小间隔限流(默认 5 分钟)。 + - + @@ -450,7 +420,12 @@ export function SyncPage(props: { > - + > }; +} + +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; hint?: string }; +} + export async function listPlatformTenants(session: Session) { const res = await apiFetch(`/api/v1/platform/tenants`, { headers: { Authorization: `Bearer ${session.accessToken}` }, diff --git a/智建修改意见.md b/智建修改意见.md new file mode 100644 index 0000000..79d0776 --- /dev/null +++ b/智建修改意见.md @@ -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**,并与宇恒绑定、回放对齐——不是再造一套同步中间件。 diff --git a/松离线-dbsync方案-最终版.md b/松离线-dbsync方案-最终版.md new file mode 100644 index 0000000..002370a --- /dev/null +++ b/松离线-dbsync方案-最终版.md @@ -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 拒绝集成测;发版说明 | diff --git a/松离线-dbsync方案.md b/松离线-dbsync方案.md new file mode 100644 index 0000000..7a9914f --- /dev/null +++ b/松离线-dbsync方案.md @@ -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;落败方进超级管理员可查的覆盖日志;主键不漂。** + +--- + + + +## 宇恒追加-审评意见 + +> **说明**:本节是宇恒侧对上文方案的外部审评意见,**不是**方案正文的修订稿。采纳与否单独决议;未并入 §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` 走松离线。先灰度开通,再谈切默认。** + +--- + + + +## 智建追加-审评意见 + +> **说明**:本节是智建/方案侧对正文及「宇恒追加-审评意见」的复核意见,**不是**方案正文修订稿。与宇恒追加并列,采纳单独决议。 + +### 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 双写。** + +--- + + + +## 协商决议版(双方意见合成 · 以此为准开工) + +> **效力**:本节覆盖正文中与「默认切全站 / 写前强依赖 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——默认不影响宇恒原有操作,白名单灰度达标前不切默认。** + +--- + + + +## 宇恒追加-继续协商(第二轮) + +> **说明**:在「协商决议版」之上的第二轮协商意见,**不改写** §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)**。 +此后改需求只改最终版并备注变更记录;本过程稿不再作为实施口径。