feat: ship loose-offline dbsync (validate, agent push, LWW audit)

Add UUID/FK channel checks, agent whitelist/push APIs, bindings, super-admin LWW audit with rollback, reconcile rate limits, and sync docs. Default customers stay opt-in; company conflict UI is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 17:54:14 +08:00
parent 632057c857
commit 76cdcd760e
39 changed files with 3302 additions and 199 deletions

View File

@@ -1,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

View File

@@ -6,7 +6,11 @@
|------|------|
| [智能体-生成发布-能力说明.md](./智能体-生成发布-能力说明.md) | **需求 / 能力边界**(做什么、不做什么、工作流、验收) |
| [智能体-生成发布-API.md](./智能体-生成发布-API.md) | **接口契约**(路径、请求/响应、宿主回执字段) |
| [数据同步-中间件.md](./数据同步-中间件.md) | 跨库实时同步SQLite/MySQL/Postgres |
| [数据同步-中间件.md](./数据同步-中间件.md) | 跨库同步能力与 APILWW / agent push |
| [数据同步-开通说明.md](./数据同步-开通说明.md) | 管理员开通默认无感、opt-in |
| [数据同步-迁移手册.md](./数据同步-迁移手册.md) | O7旧客从双写迁入禁止静默 |
| [同步表约定.md](./同步表约定.md) | UUID 同步表模板与 agent 接口约定 |
| [发版说明-数据同步.md](./发版说明-数据同步.md) | 默认无感 / 增值开通发版摘录 |
> 业务用语称「**模块**」。HTTP 路径仍为 `/api/v1/apps/...`。智能体默认无需配置模块白名单即可自建发布。

View File

@@ -0,0 +1,35 @@
# 发版说明 · 数据同步(智建平台)
## 对默认客户
- **无感**:未显式开通时,不改变现有保存 / 自增表行为。
- `local_dbsync` 为**增值开通**(客户端显式配置);平台**不会**全员切默认。
## 本版本智建已交付
| 能力 | 说明 |
|------|------|
| 通道校验 | UUID TEXT PK + FK 闭包;拒绝自增整数作同步键 |
| Agent API | 拉白名单、push / push batch → 线上 A |
| Binding | `local_database_id ↔ online_db_id` 登记查询 |
| LWW | 默认源端覆盖;超管审计 + 单行回滚;公司 conflicts → 403 |
| 同步修复 | 对账限流(默认 300s |
## 管理员文档
- [数据同步-开通说明.md](./数据同步-开通说明.md)
- [数据同步-迁移手册.md](./数据同步-迁移手册.md)(旧客迁入,禁止静默)
- [同步表约定.md](./同步表约定.md)
- [数据同步-中间件.md](./数据同步-中间件.md)
## 配置platform
```yaml
DBSync:
Enabled: true
DataDir: ./data/dbsync
LwwAuditTTLDays: 90
ReconcileMinSec: 300
```
客户端 agent / 写网关由对接方自行落地;接口以本文档与 OpenAPI `/api/v1/meta/openapi.yaml` 为准。

76
docs/同步表约定.md Normal file
View File

@@ -0,0 +1,76 @@
# 同步表约定M0
> 依据:`松离线-dbsync方案-最终版.md`
> 默认建表模板**仍为自增**;仅「同步表」使用下列模板。
## 同步表模板UUID 主键)
SQLite 示例:
```sql
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY NOT NULL, -- UUID小写带连字符
-- ... 业务列 ...
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
Postgres 示例:
```sql
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY, -- 或 TEXT
-- ... 业务列 ...
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
## 入通道校验(平台已启用)
保存 / 启用同步通道时:
1. 白名单非空
2. 可达端(通常为 **remote 线上库**)上,各表主键列须为 **TEXT/VARCHAR/UUID** 类(拒绝 INTEGER/SERIAL
3. **外键闭包**:白名单内表若引用名单外表(或反之一侧在名单),保存失败
形态 B 下本机 SQLite 可能不可达:至少 **remote** 须能完成校验。
## 宇恒开通opt-in
```bash
YXD_SYNC_MODE=local_dbsync
YXD_SYNC_DBSYNC_TABLES=orders,order_items
# 或写入 cache/db_sync/whitelist.json: {"tables":["orders","order_items"]}
```
未设置 `local_dbsync` 时行为与现网一致。
## M2 本机 agentB→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

View File

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

View File

@@ -0,0 +1,36 @@
# 数据同步 · 开通说明(管理员)
> 依据:`松离线-dbsync方案-最终版.md`
> **默认客户无感**:未显式开通时,终端保存与自增表行为与现网一致。
## 三分模式(客户端配置,智建通道为表白名单源)
| 模式 | 含义 | 谁改 |
|------|------|------|
| `local_only` | 仅本地,无同步 | 默认之一 |
| `online_primary` | HTTP 双写 / 离线 pending旧路径 | 已配线上 API 且未写 MODE 时兼容升 |
| `local_dbsync` | 松离线 + 表白名单 + 本机 agent | **仅显式配置**,禁止自动升 |
智建控制台「数据同步」配的是**通道 + 表白名单 + 线上 DSN**;是否走 `local_dbsync` 由客户端环境变量决定,平台**不会**替全员切默认。
## 开通步骤(增值)
1. 公司管理员在「数据同步」建通道:`local`(本机 B 描述)+ `remote`(线上 A DSN表白名单须 **UUID TEXT PK** + FK 闭包。
2. 默认方向 **本地 → 线上**,冲突策略 **源端覆盖lww_source**
3. 客户端显式设 `YXD_SYNC_MODE=local_dbsync`,并配置通道 ID / token`同步表约定.md`)。
4. 装本机 sync agent 后变更才会上云;未装 agent**本地可保存**,文案须为「需 agent 才上云」。
5. Binding可选登记 `local_database_id → online_db_id`,见 API `/api/v1/admin/sync/bindings`
## 谁能看什么
| 角色 | 可见 |
|------|------|
| 公司管理员 | 通道配置、对账(同步修复)、统计;**无**冲突台 / LWW 覆盖明细 |
| 平台超级管理员 | LWW 覆盖审计(平台工作台) |
| 未开通终端用户 | **零同步文案**,无强制状态条 |
## 相关文档
- [同步表约定.md](./同步表约定.md)
- [数据同步-迁移手册.md](./数据同步-迁移手册.md)(旧客从 HTTP 双写迁入)
- [数据同步-中间件.md](./数据同步-中间件.md)

View File

@@ -0,0 +1,40 @@
# 数据同步 · 迁移手册O7
> 从旧路径 `online_primary`HTTP 双写)迁到 `local_dbsync`(松离线 + agent
> **禁止静默迁移、禁止全员一刀切关双写。**
## 硬规则
1. **旧客默认不变**:未评估、未签字前不得改默认模式。
2. **同表互斥**:某表不得「一边 HTTP 双写、一边进 dbsync 白名单」。
3. **顺序不可颠倒**:先停该表双写 → 再进白名单 / 切 `local_dbsync`
4. **agent 非安装强依赖**:迁移观察期允许只落本地 + 积压;须告知「需 agent 才上云」。
## 推荐流程(单表 / 单库)
| 步骤 | 动作 | 验收 |
|------|------|------|
| 1 | 智建通道准备目标表白名单、UUID PK 校验通过、remote DSN 可达 | 保存通道成功 |
| 2 | 客户端仍 `online_primary`:对该表**停止**双写(或从双写表白名单移除) | 该表仅写本地或仅走约定路径 |
| 3 | 观察 ≥1 个业务周期:无双写残留、无重复行 | 抽查线上/本地主键 |
| 4 | 显式设 `YXD_SYNC_MODE=local_dbsync`,写入表白名单缓存/通道拉名单 | `/sync/status` 显示 local_dbsync |
| 5 | 登记 Binding可选`local_database_id → online_db_id` | GET bindings 命中 |
| 6 | 装本机 agent观察 outbox 清空、同 UUID 上云 | agent push 成功;超管可查 LWW若有覆盖 |
| 7 | 确认稳定后再扩大白名单;**勿**对全员默认切模式 | 旧客未改默认 |
## 回滚
1. 客户端改回 `online_primary``local_only`
2. 智建侧可停通道 / 缩表白名单(勿删线上数据)。
3. 未推完的 outbox 由对方客户端自行处理;平台不强制清。
## 新客评估M4
仅对**新客**或**书面确认的迁移客**评估是否默认 `local_dbsync`
M4 前:**禁止**全员切默认。
## 相关
- [数据同步-开通说明.md](./数据同步-开通说明.md)
- [同步表约定.md](./同步表约定.md)
- 冻结方案:`松离线-dbsync方案-最终版.md` §8 / O7

View File

@@ -40,3 +40,5 @@ Storage:
DBSync:
Enabled: true
DataDir: /app/data/dbsync
LwwAuditTTLDays: 90
ReconcileMinSec: 300

View File

@@ -45,3 +45,5 @@ Storage:
DBSync:
Enabled: true
DataDir: ./data/dbsync
LwwAuditTTLDays: 90
ReconcileMinSec: 300

View File

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

View File

@@ -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写入模块,

View File

@@ -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"` // 手动对账最小间隔(秒)
}

View File

@@ -0,0 +1,143 @@
package dbsync
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
// Binding 本机库 ↔ 线上库映射P1不挡推送供登记/查询)。
type Binding struct {
ID string `json:"id"`
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id,omitempty"`
LocalDatabaseID string `json:"local_database_id"`
OnlineDBID string `json:"online_db_id"`
ChannelID string `json:"channel_id,omitempty"`
Note string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *FileStore) bindingPath() string {
return filepath.Join(s.dir, "bindings.json")
}
func (s *FileStore) EnsureBinding(b Binding) (Binding, error) {
s.mu.Lock()
defer s.mu.Unlock()
list, err := s.readBindingsUnlocked()
if err != nil {
return b, err
}
b.LocalDatabaseID = strings.TrimSpace(b.LocalDatabaseID)
b.OnlineDBID = strings.TrimSpace(b.OnlineDBID)
if b.TenantID <= 0 {
return b, fmt.Errorf("tenant_id required")
}
if b.LocalDatabaseID == "" || b.OnlineDBID == "" {
return b, fmt.Errorf("local_database_id and online_db_id required")
}
now := time.Now().UTC()
for i := range list {
if list[i].TenantID == b.TenantID && list[i].LocalDatabaseID == b.LocalDatabaseID {
if b.UserID > 0 && list[i].UserID > 0 && list[i].UserID != b.UserID {
continue
}
list[i].OnlineDBID = b.OnlineDBID
if b.ChannelID != "" {
list[i].ChannelID = b.ChannelID
}
if b.Note != "" {
list[i].Note = b.Note
}
if b.UserID > 0 {
list[i].UserID = b.UserID
}
list[i].UpdatedAt = now
if err := s.writeBindingsUnlocked(list); err != nil {
return b, err
}
return list[i], nil
}
}
if b.ID == "" {
b.ID = uuid.NewString()
}
b.CreatedAt = now
b.UpdatedAt = now
list = append(list, b)
if err := s.writeBindingsUnlocked(list); err != nil {
return b, err
}
return b, nil
}
func (s *FileStore) ListBindings(tenantID int64, localDatabaseID string) ([]Binding, error) {
s.mu.Lock()
defer s.mu.Unlock()
list, err := s.readBindingsUnlocked()
if err != nil {
return nil, err
}
out := make([]Binding, 0)
for _, b := range list {
if b.TenantID != tenantID {
continue
}
if localDatabaseID != "" && b.LocalDatabaseID != localDatabaseID {
continue
}
out = append(out, b)
}
return out, nil
}
func (s *FileStore) GetBinding(tenantID int64, localDatabaseID string) (*Binding, error) {
list, err := s.ListBindings(tenantID, localDatabaseID)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, fmt.Errorf("binding not found")
}
cp := list[0]
return &cp, nil
}
func (s *FileStore) readBindingsUnlocked() ([]Binding, error) {
path := s.bindingPath()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []Binding{}, nil
}
return nil, err
}
if len(b) == 0 {
return []Binding{}, nil
}
var list []Binding
if err := json.Unmarshal(b, &list); err != nil {
return nil, err
}
return list, nil
}
func (s *FileStore) writeBindingsUnlocked(list []Binding) error {
path := s.bindingPath()
raw, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@@ -0,0 +1,35 @@
package dbsync
import (
"path/filepath"
"testing"
)
func TestEnsureBindingUpsert(t *testing.T) {
dir := t.TempDir()
st, err := NewFileStore(filepath.Join(dir, "dbsync"))
if err != nil {
t.Fatal(err)
}
b, err := st.EnsureBinding(Binding{
TenantID: 1,
LocalDatabaseID: "local-a",
OnlineDBID: "online-1",
ChannelID: "ch1",
})
if err != nil || b.ID == "" {
t.Fatalf("ensure: %+v err=%v", b, err)
}
b2, err := st.EnsureBinding(Binding{
TenantID: 1,
LocalDatabaseID: "local-a",
OnlineDBID: "online-2",
})
if err != nil || b2.OnlineDBID != "online-2" || b2.ID != b.ID {
t.Fatalf("upsert: %+v err=%v", b2, err)
}
list, err := st.ListBindings(1, "local-a")
if err != nil || len(list) != 1 {
t.Fatalf("list=%d err=%v", len(list), err)
}
}

View File

@@ -0,0 +1,41 @@
package dbsync
import (
"context"
"database/sql"
"time"
)
const (
EntryDrain = "drain"
EntryAgentPush = "agent_push"
EntryRollback = "rollback"
OutcomeApplied = "applied_source"
OutcomeKept = "kept_target"
OutcomeRolled = "rolled_back"
)
// RecordLwwOverride 在 LWW 覆盖或保留目标时写入超管审计(失败忽略,不挡同步)。
func RecordLwwOverride(store *FileStore, o LwwOverride) {
if store == nil {
return
}
_ = store.AddLwwOverride(o)
}
// SnapshotTargetRow 取目标端当前行 JSON无行则空串
func SnapshotTargetRow(ctx context.Context, db *sql.DB, driver Driver, table, pkCol, rowPK string) string {
if db == nil || rowPK == "" {
return ""
}
js, _, err := FetchRowJSON(ctx, db, driver, table, pkCol, rowPK)
if err != nil {
return ""
}
return js
}
// DefaultLwwAuditTTL 默认 90 天。
func DefaultLwwAuditTTL() time.Duration {
return 90 * 24 * time.Hour
}

View File

@@ -0,0 +1,72 @@
package dbsync
import (
"path/filepath"
"testing"
"time"
)
func TestLwwOverrideStoreAndPurge(t *testing.T) {
dir := t.TempDir()
st, err := NewFileStore(filepath.Join(dir, "dbsync"))
if err != nil {
t.Fatal(err)
}
old := LwwOverride{
TenantID: 1,
ChannelID: "ch1",
Table: "orders",
RowPK: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
Entry: EntryAgentPush,
Policy: string(PolicyLWWSource),
Outcome: OutcomeApplied,
CreatedAt: time.Now().UTC().Add(-100 * 24 * time.Hour),
}
fresh := LwwOverride{
TenantID: 1,
ChannelID: "ch1",
Table: "orders",
RowPK: "bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee",
Entry: EntryDrain,
Policy: string(PolicyLWWSource),
Outcome: OutcomeApplied,
CreatedAt: time.Now().UTC(),
}
if err := st.AddLwwOverride(old); err != nil {
t.Fatal(err)
}
if err := st.AddLwwOverride(fresh); err != nil {
t.Fatal(err)
}
list, err := st.ListLwwOverrides(1, "ch1", 10)
if err != nil || len(list) != 2 {
t.Fatalf("list=%d err=%v", len(list), err)
}
n, err := st.PurgeLwwOverridesBefore(time.Now().UTC().Add(-90 * 24 * time.Hour))
if err != nil || n != 1 {
t.Fatalf("purge n=%d err=%v", n, err)
}
list, err = st.ListLwwOverrides(0, "", 10)
if err != nil || len(list) != 1 {
t.Fatalf("after purge list=%d err=%v", len(list), err)
}
}
func TestCanReconcile(t *testing.T) {
ok, _ := CanReconcile(nil, time.Minute)
if !ok {
t.Fatal("nil channel should allow")
}
past := time.Now().UTC().Add(-10 * time.Minute)
ch := &Channel{LastReconcileAt: &past}
ok, _ = CanReconcile(ch, 5*time.Minute)
if !ok {
t.Fatal("expected allow")
}
recent := time.Now().UTC()
ch.LastReconcileAt = &recent
ok, wait := CanReconcile(ch, 5*time.Minute)
if ok || wait <= 0 {
t.Fatalf("expected deny wait>0 ok=%v wait=%v", ok, wait)
}
}

View File

@@ -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 {

View File

@@ -0,0 +1,256 @@
package dbsync
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// PushItem 本机 agent → 线上 A 的一条变更(形态 B
type PushItem struct {
Table string `json:"table"`
Op string `json:"op"` // insert|update|update_by_id|delete|upsert
RowPK string `json:"row_pk"`
Row map[string]any `json:"row"` // 单行;与 Rows 二选一
Rows []map[string]any `json:"rows"` // 兼容批量 insert
Version int64 `json:"version"` // outbox 单调 version0 则用时间戳
ClientOutboxID string `json:"client_outbox_id"`
OnlineDBID string `json:"online_db_id"`
}
// PushResult 单条推送结果。
type PushResult struct {
OK bool `json:"ok"`
Applied bool `json:"applied"`
Skipped bool `json:"skipped"`
Conflict bool `json:"conflict"`
AppliedVersion int64 `json:"applied_version"`
Message string `json:"message"`
ClientOutboxID string `json:"client_outbox_id,omitempty"`
}
// PushToRemote 仅打开 remote按 LWW/幂等将变更落到线上 A。不连本机 SQLite。
func PushToRemote(ctx context.Context, ch *Channel, store *FileStore, item PushItem) (*PushResult, error) {
if ch == nil {
return nil, fmt.Errorf("channel is nil")
}
table := strings.TrimSpace(item.Table)
if table == "" {
return nil, fmt.Errorf("table required")
}
if !tableInChannel(ch, table) {
return nil, fmt.Errorf("table %s 不在通道白名单", table)
}
pkCol := "id"
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
pkCol = ch.PKColumns[table]
}
op, payload, rowPK, err := normalizePushPayload(item, pkCol)
if err != nil {
return nil, err
}
version := item.Version
if version <= 0 {
version = time.Now().UnixNano()
}
db, err := Open(ch.Remote.Driver, ch.Remote.DSN)
if err != nil {
return nil, fmt.Errorf("open remote: %w", err)
}
defer db.Close()
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
return nil, fmt.Errorf("ensure meta: %w", err)
}
res := &PushResult{
ClientOutboxID: item.ClientOutboxID,
AppliedVersion: version,
}
tgtVer, has, err := GetMetaVersion(ctx, db, ch.Remote.Driver, table, rowPK)
if err != nil {
return nil, err
}
// 幂等:同 version 已落地 → 跳过
if has && tgtVer == version {
res.OK = true
res.Skipped = true
res.Message = "already applied (same version)"
return res, nil
}
if has && tgtVer > version {
policy := ch.ConflictPolicy
if policy == "" {
policy = PolicyLWWSource // B→A 默认偏源
}
switch policy {
case PolicyLWWTarget:
loser := payload
winner := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, rowPK)
RecordLwwOverride(store, LwwOverride{
TenantID: ch.TenantID,
ChannelID: ch.ID,
Table: table,
RowPK: rowPK,
Op: op,
Entry: EntryAgentPush,
Policy: string(PolicyLWWTarget),
Outcome: OutcomeKept,
LoserPayload: loser,
WinnerPayload: winner,
TargetVer: tgtVer,
SourceVer: version,
})
res.OK = true
res.Skipped = true
res.Message = "target newer; kept (lww_target)"
return res, nil
case PolicyLWWSource:
loser := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, rowPK)
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, version); err != nil {
return nil, err
}
RecordLwwOverride(store, LwwOverride{
TenantID: ch.TenantID,
ChannelID: ch.ID,
Table: table,
RowPK: rowPK,
Op: op,
Entry: EntryAgentPush,
Policy: string(PolicyLWWSource),
Outcome: OutcomeApplied,
LoserPayload: loser,
WinnerPayload: payload,
TargetVer: tgtVer,
SourceVer: version,
})
if store != nil {
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.PushedOK++ })
}
res.OK = true
res.Applied = true
res.Message = "applied (lww_source override)"
return res, nil
default:
if store != nil {
_ = store.AddConflict(Conflict{
TenantID: ch.TenantID,
ChannelID: ch.ID,
Table: table,
RowPK: rowPK,
Op: op,
Source: "agent",
Payload: payload,
TargetVer: tgtVer,
SourceVer: version,
Message: "target version newer than agent push",
})
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.Conflicts++ })
}
res.OK = true
res.Skipped = true
res.Conflict = true
res.Message = "queued conflict; target newer"
return res, nil
}
}
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, version); err != nil {
return nil, err
}
if store != nil {
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.PushedOK++ })
}
res.OK = true
res.Applied = true
res.Message = "applied"
return res, nil
}
// PushBatchToRemote 保序批量;遇错即停,已成功条数在返回切片中。
func PushBatchToRemote(ctx context.Context, ch *Channel, store *FileStore, items []PushItem) ([]PushResult, error) {
out := make([]PushResult, 0, len(items))
for i, it := range items {
r, err := PushToRemote(ctx, ch, store, it)
if err != nil {
fail := PushResult{
OK: false,
Message: err.Error(),
ClientOutboxID: it.ClientOutboxID,
}
out = append(out, fail)
return out, fmt.Errorf("item[%d]: %w", i, err)
}
out = append(out, *r)
}
return out, nil
}
func tableInChannel(ch *Channel, table string) bool {
for _, t := range uniqueTables(ch.Local.Tables, ch.Remote.Tables) {
if strings.EqualFold(strings.TrimSpace(t), table) {
return true
}
}
return false
}
func normalizePushPayload(item PushItem, pkCol string) (op string, payload string, rowPK string, err error) {
rawOp := strings.ToLower(strings.TrimSpace(item.Op))
if rawOp == "" {
rawOp = "upsert"
}
switch rawOp {
case "delete":
op = "delete"
case "insert", "update", "update_by_id", "upsert":
op = "upsert"
default:
return "", "", "", fmt.Errorf("unsupported op: %s", item.Op)
}
row := item.Row
if row == nil && len(item.Rows) > 0 {
row = item.Rows[0]
}
rowPK = strings.TrimSpace(item.RowPK)
if row != nil {
if rowPK == "" {
rowPK = strings.TrimSpace(fmt.Sprint(row[pkCol]))
}
if op == "upsert" {
// 保证 payload 含主键
if _, ok := row[pkCol]; !ok && rowPK != "" {
row = copyMap(row)
row[pkCol] = rowPK
}
}
}
if rowPK == "" || rowPK == "<nil>" {
return "", "", "", fmt.Errorf("row_pk required")
}
if op == "delete" && row == nil {
row = map[string]any{pkCol: rowPK}
}
if row == nil {
return "", "", "", fmt.Errorf("row required for %s", op)
}
b, err := json.Marshal(row)
if err != nil {
return "", "", "", err
}
return op, string(b), rowPK, nil
}
func copyMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m)+1)
for k, v := range m {
out[k] = v
}
return out
}

View File

@@ -0,0 +1,46 @@
package dbsync
import "testing"
func TestNormalizePushPayloadInsert(t *testing.T) {
op, payload, pk, err := normalizePushPayload(PushItem{
Op: "insert",
RowPK: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
Row: map[string]any{
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"name": "x",
},
}, "id")
if err != nil {
t.Fatal(err)
}
if op != "upsert" || pk == "" || payload == "" {
t.Fatalf("got op=%s pk=%s payload=%s", op, pk, payload)
}
}
func TestNormalizePushPayloadDelete(t *testing.T) {
op, payload, pk, err := normalizePushPayload(PushItem{
Op: "delete",
RowPK: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
}, "id")
if err != nil {
t.Fatal(err)
}
if op != "delete" || pk == "" || payload == "" {
t.Fatalf("got op=%s pk=%s payload=%s", op, pk, payload)
}
}
func TestTableInChannel(t *testing.T) {
ch := &Channel{
Local: Endpoint{Tables: []string{"orders"}},
Remote: Endpoint{Tables: []string{"orders"}},
}
if !tableInChannel(ch, "orders") {
t.Fatal("expected in")
}
if tableInChannel(ch, "other") {
t.Fatal("expected out")
}
}

View File

@@ -0,0 +1,30 @@
package dbsync
import (
"fmt"
"time"
)
// DefaultManualReconcileInterval 公司管理员手动对账最小间隔。
const DefaultManualReconcileInterval = 5 * time.Minute
// CanReconcile 限流:距上次对账不足 minInterval 则拒绝。
func CanReconcile(ch *Channel, minInterval time.Duration) (bool, time.Duration) {
if ch == nil || ch.LastReconcileAt == nil || ch.LastReconcileAt.IsZero() {
return true, 0
}
if minInterval <= 0 {
minInterval = DefaultManualReconcileInterval
}
elapsed := time.Since(ch.LastReconcileAt.UTC())
if elapsed >= minInterval {
return true, 0
}
return false, minInterval - elapsed
}
// ReconcileTooSoonError 供 handler 返回 429。
func ReconcileTooSoonError(wait time.Duration) error {
sec := int(wait.Seconds()) + 1
return fmt.Errorf("对账过于频繁,请 %d 秒后再试", sec)
}

View File

@@ -0,0 +1,79 @@
package dbsync
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// RollbackLwwOverride 超管按落败快照回滚线上 A 单行。
// 仅 outcome=applied_source 可回滚:把 loser_payload 写回 remote无快照则按 pk 删除。
func RollbackLwwOverride(ctx context.Context, store *FileStore, overrideID string) (*LwwOverride, error) {
if store == nil {
return nil, fmt.Errorf("store is nil")
}
o, err := store.GetLwwOverride(overrideID)
if err != nil {
return nil, err
}
if o.Outcome != OutcomeApplied {
return nil, fmt.Errorf("仅「源端覆盖」记录可回滚(当前 outcome=%s", o.Outcome)
}
if strings.TrimSpace(o.ChannelID) == "" {
return nil, fmt.Errorf("override missing channel_id")
}
ch, err := store.GetChannel(o.ChannelID)
if err != nil {
return nil, fmt.Errorf("channel: %w", err)
}
table := strings.TrimSpace(o.Table)
pkCol := "id"
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
pkCol = ch.PKColumns[table]
}
db, err := Open(ch.Remote.Driver, ch.Remote.DSN)
if err != nil {
return nil, fmt.Errorf("open remote: %w", err)
}
defer db.Close()
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
return nil, err
}
before := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, o.RowPK)
ver := time.Now().UnixNano()
loser := strings.TrimSpace(o.LoserPayload)
op := "upsert"
payload := loser
if loser == "" || loser == "{}" {
op = "delete"
payload = fmt.Sprintf(`{%q:%q}`, pkCol, o.RowPK)
}
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, ver); err != nil {
return nil, fmt.Errorf("apply rollback: %w", err)
}
rec := LwwOverride{
ID: uuid.NewString(),
TenantID: o.TenantID,
ChannelID: o.ChannelID,
Table: o.Table,
RowPK: o.RowPK,
Op: op,
Entry: EntryRollback,
Policy: "manual_rollback",
Outcome: OutcomeRolled,
LoserPayload: before,
WinnerPayload: payload,
TargetVer: o.SourceVer,
SourceVer: ver,
CreatedAt: time.Now().UTC(),
}
if err := store.AddLwwOverride(rec); err != nil {
return nil, err
}
return &rec, nil
}

View File

@@ -0,0 +1,54 @@
package dbsync
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestRollbackRejectsNonApplied(t *testing.T) {
dir := t.TempDir()
st, err := NewFileStore(filepath.Join(dir, "dbsync"))
if err != nil {
t.Fatal(err)
}
o := LwwOverride{
ID: "ov1",
TenantID: 1,
ChannelID: "ch1",
Table: "orders",
RowPK: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
Outcome: OutcomeKept,
CreatedAt: time.Now().UTC(),
}
if err := st.AddLwwOverride(o); err != nil {
t.Fatal(err)
}
_, err = RollbackLwwOverride(context.Background(), st, "ov1")
if err == nil {
t.Fatal("expected reject kept_target")
}
}
func TestGetLwwOverride(t *testing.T) {
dir := t.TempDir()
st, err := NewFileStore(filepath.Join(dir, "dbsync"))
if err != nil {
t.Fatal(err)
}
o := LwwOverride{
ID: "ov2",
TenantID: 1,
Table: "t",
RowPK: "pk",
Outcome: OutcomeApplied,
}
if err := st.AddLwwOverride(o); err != nil {
t.Fatal(err)
}
got, err := st.GetLwwOverride("ov2")
if err != nil || got.Table != "t" {
t.Fatalf("got=%+v err=%v", got, err)
}
}

View File

@@ -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)
}

View File

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

View File

@@ -0,0 +1,264 @@
package dbsync
import (
"context"
"database/sql"
"fmt"
"strings"
)
// ValidateChannelConfig 静态校验不连库表名单、PK 列名约定。
func ValidateChannelConfig(ch *Channel) error {
if ch == nil {
return fmt.Errorf("channel is nil")
}
tables := uniqueTables(ch.Local.Tables, ch.Remote.Tables)
if len(tables) == 0 {
return fmt.Errorf("同步表白名单为空:请至少在 local 或 remote 填写表名")
}
for _, t := range tables {
t = strings.TrimSpace(t)
if t == "" {
return fmt.Errorf("表名不能为空")
}
if strings.HasPrefix(t, "_ajz_") {
return fmt.Errorf("禁止同步系统表: %s", t)
}
pk := pkColumn(ch, t)
if pk == "" {
return fmt.Errorf("表 %s 主键列名为空", t)
}
}
return nil
}
func pkColumn(ch *Channel, table string) string {
if ch.PKColumns != nil {
if v := strings.TrimSpace(ch.PKColumns[table]); v != "" {
return v
}
}
return "id"
}
// ValidateChannelAgainstDB 对可连接端做TEXT/UUID 主键类型 + FK 闭包。
// 某端连不上时跳过该端(形态 B 下 local 常不可达),但至少一端须校验成功,否则拒绝。
func ValidateChannelAgainstDB(ctx context.Context, ch *Channel) error {
if err := ValidateChannelConfig(ch); err != nil {
return err
}
tables := uniqueTables(ch.Local.Tables, ch.Remote.Tables)
checked := 0
var lastSkip error
for _, ep := range []Endpoint{ch.Remote, ch.Local} {
if strings.TrimSpace(ep.DSN) == "" || ep.Driver == "" {
continue
}
epTables := ep.Tables
if len(epTables) == 0 {
epTables = tables
}
db, err := Open(ep.Driver, ep.DSN)
if err != nil {
lastSkip = fmt.Errorf("%s 无法连接(跳过库内校验): %w", ep.Driver, err)
continue
}
if err := validateEndpointSchema(ctx, db, ep.Driver, ch, epTables, tables); err != nil {
_ = db.Close()
return fmt.Errorf("[%s] %w", ep.Driver, err)
}
_ = db.Close()
checked++
}
if checked == 0 {
if lastSkip != nil {
return fmt.Errorf("无法对任何端做主键/外键校验:%v请保证线上库remoteDSN 可达后再保存", 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, &notnull, &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()
}

View File

@@ -0,0 +1,65 @@
package dbsync
import (
"context"
"path/filepath"
"testing"
)
func TestIsTextLikePK(t *testing.T) {
good := []string{"TEXT", "VARCHAR(36)", "uuid", "CHARACTER VARYING", "NVARCHAR(64)", "char(36)"}
for _, g := range good {
if !isTextLikePK(g) {
t.Fatalf("expected text-like: %s", g)
}
}
bad := []string{"INTEGER", "INT", "BIGINT", "SERIAL", "BIGSERIAL", "int(11)", "NUMERIC", "DECIMAL(10,2)"}
for _, b := range bad {
if isTextLikePK(b) {
t.Fatalf("expected reject: %s", b)
}
}
}
func TestValidateChannelConfigEmpty(t *testing.T) {
ch := &Channel{}
if err := ValidateChannelConfig(ch); err == nil {
t.Fatal("expected error for empty tables")
}
ch.Local.Tables = []string{"orders"}
if err := ValidateChannelConfig(ch); err != nil {
t.Fatal(err)
}
}
func TestValidateChannelAgainstDBRejectsIntegerPK(t *testing.T) {
dir := t.TempDir()
dsn := "file:" + filepath.ToSlash(filepath.Join(dir, "t.db")) + "?_pragma=foreign_keys(1)"
db, err := Open(DriverSQLite, dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err = db.Exec(`CREATE TABLE orders (id INTEGER PRIMARY KEY, title TEXT)`); err != nil {
t.Fatal(err)
}
if _, err = db.Exec(`CREATE TABLE orders_uuid (id TEXT PRIMARY KEY, title TEXT)`); err != nil {
t.Fatal(err)
}
bad := &Channel{
Local: Endpoint{Driver: DriverSQLite, DSN: dsn, Tables: []string{"orders"}},
Remote: Endpoint{Driver: DriverSQLite, DSN: dsn, Tables: []string{"orders"}},
}
if err := ValidateChannelAgainstDB(context.Background(), bad); err == nil {
t.Fatal("expected reject INTEGER PK")
}
good := &Channel{
Local: Endpoint{Driver: DriverSQLite, DSN: dsn, Tables: []string{"orders_uuid"}},
Remote: Endpoint{Driver: DriverSQLite, DSN: dsn, Tables: []string{"orders_uuid"}},
}
if err := ValidateChannelAgainstDB(context.Background(), good); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,119 @@
package handler
import (
"encoding/json"
"net/http"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
"github.com/zeromicro/go-zero/rest/pathvar"
)
// agent 同步只读白名单 + 推远程 A形态 B需 JWT 含「数据同步」权限(人类管理员或智能体均可)。
func agentSyncWhitelistHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
tables := uniqueStringSlice(ch.Local.Tables, ch.Remote.Tables)
httpx.OkJson(w, map[string]any{
"channel_id": ch.ID,
"name": ch.Name,
"enabled": ch.Enabled,
"direction": ch.Direction,
"conflict_policy": ch.ConflictPolicy,
"tables": tables,
"pk_columns": ch.PKColumns,
"hint": "本机 agent 缓存此表白名单;仅白名单表走 local_dbsync",
})
}
}
func agentSyncPushHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
var item dbsync.PushItem
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
res, err := dbsync.PushToRemote(r.Context(), ch, svcCtx.DBSync.Store(), item)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"success": true, "result": res})
}
}
func agentSyncPushBatchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
var body struct {
Items []dbsync.PushItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if len(body.Items) == 0 {
authx.WriteError(w, http.StatusBadRequest, "items required")
return
}
if len(body.Items) > 100 {
authx.WriteError(w, http.StatusBadRequest, "items limit 100")
return
}
results, err := dbsync.PushBatchToRemote(r.Context(), ch, svcCtx.DBSync.Store(), body.Items)
if err != nil {
httpx.OkJson(w, map[string]any{
"success": false,
"error": err.Error(),
"results": results,
})
return
}
httpx.OkJson(w, map[string]any{"success": true, "results": results})
}
}
func uniqueStringSlice(a, b []string) []string {
seen := map[string]struct{}{}
var out []string
for _, xs := range [][]string{a, b} {
for _, s := range xs {
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
}
return out
}

View File

@@ -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:

View File

@@ -0,0 +1,68 @@
package handler
import (
"net/http"
"strconv"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
"github.com/zeromicro/go-zero/rest/pathvar"
)
// GET /api/v1/platform/dbsync/lww-overrides — 仅平台超级管理员。
func platformLwwOverridesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if svcCtx.DBSync == nil {
authx.WriteError(w, http.StatusServiceUnavailable, "dbsync not enabled")
return
}
q := r.URL.Query()
var tenantID int64
if s := q.Get("tenant_id"); s != "" {
tenantID, _ = strconv.ParseInt(s, 10, 64)
}
channelID := q.Get("channel_id")
limit := 200
if s := q.Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 {
limit = n
if limit > 1000 {
limit = 1000
}
}
}
list, err := svcCtx.DBSync.Store().ListLwwOverrides(tenantID, channelID, limit)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{
"items": list,
"hint": "LWW 自动覆盖审计;公司管理员不可见;默认 TTL 90 天;可对 applied_source 回滚单行",
})
}
}
// POST /api/v1/platform/dbsync/lww-overrides/:id/rollback — 按落败快照回滚线上单行。
func platformLwwRollbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if svcCtx.DBSync == nil {
authx.WriteError(w, http.StatusServiceUnavailable, "dbsync not enabled")
return
}
id := pathvar.Vars(r)["id"]
rec, err := dbsync.RollbackLwwOverride(r.Context(), svcCtx.DBSync.Store(), id)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{
"ok": true,
"record": rec,
"hint": "已按落败快照写回线上 A并追加一条 rollback 审计",
})
}
}

View File

@@ -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上传文件))},

View File

@@ -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})
// M3LWW/冲突追溯仅平台超级管理员;公司 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)
}
}

View File

@@ -0,0 +1,50 @@
package handler
import (
"encoding/json"
"net/http"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
func syncBindingsListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
localID := r.URL.Query().Get("local_database_id")
list, err := svcCtx.DBSync.Store().ListBindings(syncTenantID(r), localID)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"items": list})
}
}
func syncBindingsEnsureHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
var body dbsync.Binding
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
body.TenantID = syncTenantID(r)
if body.UserID == 0 {
body.UserID = authx.UserID(r.Context())
}
saved, err := svcCtx.DBSync.Store().EnsureBinding(body)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, saved)
}
}

View File

@@ -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() {

View File

@@ -33,6 +33,8 @@ import {
listPlatformPermModules,
listPlatformTenantAdmins,
listPlatformTenants,
listPlatformLwwOverrides,
rollbackPlatformLwwOverride,
setPlatformTenantPerms,
updatePlatformTenant,
updatePlatformTenantAdmin,
@@ -81,6 +83,10 @@ export function PlatformTenantsPage(props: {
phone?: string;
company: string;
} | null>(null);
const [lwwItems, setLwwItems] = useState<
Awaited<ReturnType<typeof listPlatformLwwOverrides>>["items"]
>([]);
const [lwwLoading, setLwwLoading] = useState(false);
const [form] = Form.useForm<{ name: string; slug: string; admin_phone?: string; with_invite?: boolean }>();
const [renameForm] = Form.useForm<{ name: string; slug: string }>();
const [adminForm] = Form.useForm<{
@@ -104,6 +110,18 @@ export function PlatformTenantsPage(props: {
}
}
async function refreshLww() {
setLwwLoading(true);
try {
const res = await listPlatformLwwOverrides(session, { limit: 100 });
setLwwItems(res.items || []);
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setLwwLoading(false);
}
}
async function openAdminCreds(row: TenantRow) {
setEditing(row);
setBusy(true);
@@ -157,6 +175,7 @@ export function PlatformTenantsPage(props: {
useEffect(() => {
void refresh();
void refreshLww();
listPlatformPermModules(session)
.then((r) => {
setModules(r.modules || []);
@@ -730,6 +749,72 @@ export function PlatformTenantsPage(props: {
})}
</Space>
</Modal>
<Typography.Title level={5} style={{ marginTop: 32 }}>
LWW
</Typography.Title>
<Typography.Paragraph type="secondary">
/ 90
线 remote
</Typography.Paragraph>
<Space style={{ marginBottom: 8 }}>
<Button icon={<ReloadOutlined />} loading={lwwLoading} onClick={() => void refreshLww()}>
</Button>
</Space>
<Table
rowKey="id"
size="small"
loading={lwwLoading}
dataSource={lwwItems}
pagination={{ pageSize: 10 }}
columns={[
{ title: "公司", dataIndex: "tenant_id", width: 80 },
{ title: "入口", dataIndex: "entry", width: 100 },
{ title: "表", dataIndex: "table", width: 120 },
{ title: "主键", dataIndex: "row_pk", ellipsis: true },
{ title: "策略", dataIndex: "policy", width: 110 },
{ title: "结果", dataIndex: "outcome", width: 120 },
{
title: "时间",
dataIndex: "created_at",
width: 180,
render: (v: string) => (v ? new Date(v).toLocaleString() : ""),
},
{
title: "操作",
width: 100,
render: (_: unknown, r: (typeof lwwItems)[number]) =>
r.outcome === "applied_source" ? (
<Button
size="small"
danger
loading={busy}
onClick={() => {
modal.confirm({
title: "按落败快照回滚线上单行?",
content: `${r.table} / ${r.row_pk}`,
onOk: async () => {
setBusy(true);
try {
await rollbackPlatformLwwOverride(session, r.id);
message.success("已回滚并记入审计");
await refreshLww();
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setBusy(false);
}
},
});
}}
>
</Button>
) : null,
},
]}
/>
</div>
);
}

View File

@@ -22,13 +22,10 @@ import {
import {
Session,
SyncChannel,
SyncConflict,
createSyncChannel,
deleteSyncChannel,
listSyncChannels,
listSyncConflicts,
reconcileSyncChannel,
resolveSyncConflict,
startSyncChannel,
stopSyncChannel,
testSyncEndpoints,
@@ -97,7 +94,6 @@ export function SyncPage(props: {
const { session, busy, setBusy, setError, setInfo } = props;
const { message } = AntApp.useApp();
const [items, setItems] = useState<SyncChannel[]>([]);
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<SyncChannel | null>(null);
@@ -106,12 +102,8 @@ export function SyncPage(props: {
async function refresh() {
setLoading(true);
try {
const [ch, cf] = await Promise.all([
listSyncChannels(session),
listSyncConflicts(session, true),
]);
const ch = await listSyncChannels(session);
setItems(ch.items || []);
setConflicts(cf.items || []);
} catch (e: any) {
const msg = e.message || String(e);
setError(msg);
@@ -130,8 +122,8 @@ export function SyncPage(props: {
setEditing(null);
form.setFieldsValue({
name: "本地 B ↔ 线上 A",
direction: "bidirectional",
conflict_policy: "queue",
direction: "local_to_remote",
conflict_policy: "lww_source",
poll_interval_ms: 500,
local: { driver: "sqlite", dsn: "file:./data/local.db", tables: "article" },
remote: {
@@ -212,9 +204,10 @@ export function SyncPage(props: {
</Typography.Title>
<Typography.Paragraph type="secondary">
<strong></strong>
A 线 B C B A
+
<strong></strong>
<strong></strong> <Typography.Text code>local_dbsync</Typography.Text> 线
线
/--
</Typography.Paragraph>
<Space style={{ marginBottom: 12 }}>
@@ -262,7 +255,7 @@ export function SyncPage(props: {
{
title: "统计",
render: (_: unknown, r: SyncChannel) =>
`${r.stats?.pushed_ok || 0}${r.stats?.pulled_ok || 0} 冲突${r.stats?.conflicts || 0}`,
`${r.stats?.pushed_ok || 0}${r.stats?.pulled_ok || 0}`,
},
{
title: "操作",
@@ -310,17 +303,22 @@ export function SyncPage(props: {
(x) =>
`${x.table}: 补推${x.patched_push} 补拉${x.patched_pull}(仅本地${(x.only_local || []).length} 仅线上${(x.only_remote || []).length}`
);
message.success(parts.length ? parts.join("") : "对账完成,无差异");
setInfo("对账完成");
message.success(parts.length ? parts.join("") : "同步修复完成,无差异");
setInfo("同步修复完成");
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
const msg = e.message || String(e);
if (/过于频繁|429|Too Many/i.test(msg)) {
message.warning(msg || "对账过于频繁,请稍后再试");
} else {
message.error(msg);
}
} finally {
setBusy(false);
}
}}
>
</Button>
<Button
size="small"
@@ -338,46 +336,9 @@ export function SyncPage(props: {
]}
/>
<Typography.Title level={5} style={{ marginTop: 24 }}>
</Typography.Title>
<Table
rowKey="id"
dataSource={conflicts}
pagination={false}
columns={[
{ title: "通道", dataIndex: "channel_id", ellipsis: true },
{ title: "表", dataIndex: "table" },
{ title: "主键", dataIndex: "row_pk" },
{ title: "来源", dataIndex: "source" },
{ title: "说明", dataIndex: "message", ellipsis: true },
{
title: "操作",
render: (_: unknown, r: SyncConflict) => (
<Space>
<Button
size="small"
onClick={async () => {
await resolveSyncConflict(session, r.id, "keep_target");
await refresh();
}}
>
</Button>
<Button
size="small"
onClick={async () => {
await resolveSyncConflict(session, r.id, "discard");
await refresh();
}}
>
</Button>
</Space>
),
},
]}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
LWW 5
</Typography.Paragraph>
<Modal
title={editing ? "编辑同步通道" : "新建同步通道"}
@@ -409,12 +370,16 @@ export function SyncPage(props: {
]}
/>
</Form.Item>
<Form.Item name="conflict_policy" label="冲突策略">
<Form.Item
name="conflict_policy"
label="冲突策略"
extra="自动 LWW覆盖日志仅平台超级管理员可查公司侧无冲突台"
>
<Select
options={[
{ value: "queue", label: "入冲突队列(推荐" },
{ value: "lww_source", label: "源端覆盖" },
{ value: "lww_source", label: "源端覆盖推荐B→A" },
{ value: "lww_target", label: "保留目标" },
{ value: "queue", label: "入队(调试用,租户不可见)" },
]}
/>
</Form.Item>
@@ -434,7 +399,12 @@ export function SyncPage(props: {
>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name={["local", "tables"]} label="表(逗号分隔)" rules={[{ required: true }]}>
<Form.Item
name={["local", "tables"]}
label="表(逗号分隔)"
rules={[{ required: true }]}
extra="同步表白名单表须为 UUID TEXT 主键(不可自增);外键闭包表须一并列入"
>
<Input placeholder="article,order" />
</Form.Item>
@@ -450,7 +420,12 @@ export function SyncPage(props: {
>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name={["remote", "tables"]} label="表(逗号分隔)" rules={[{ required: true }]}>
<Form.Item
name={["remote", "tables"]}
label="表(逗号分隔)"
rules={[{ required: true }]}
extra="与本地表白名单一致;须满足 UUID TEXT PK + FK 闭包"
>
<Input />
</Form.Item>
<Form.Item

View File

@@ -937,16 +937,18 @@ export async function stopSyncChannel(session: Session, id: string) {
return data;
}
/** @deprecated 公司侧已 403请用 listPlatformLwwOverrides */
export async function listSyncConflicts(session: Session, unresolved = true) {
const q = unresolved ? "?unresolved=1" : "?unresolved=0";
const res = await apiFetch(`/api/v1/admin/sync/conflicts${q}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const data = await readJson(res);
throwIfBad(res, data, "list conflicts failed");
throwIfBad(res, data, "list conflicts failed (公司侧已禁用,请用超管 LWW 审计)");
return data as { items: SyncConflict[] };
}
/** @deprecated 公司侧已 403 */
export async function resolveSyncConflict(session: Session, id: string, resolution: string) {
const res = await apiFetch(`/api/v1/admin/sync/conflicts/${encodeURIComponent(id)}/resolve`, {
method: "POST",
@@ -957,7 +959,7 @@ export async function resolveSyncConflict(session: Session, id: string, resoluti
body: JSON.stringify({ resolution }),
});
const data = await readJson(res);
throwIfBad(res, data, "resolve conflict failed");
throwIfBad(res, data, "resolve conflict failed (公司侧已禁用)");
return data;
}
@@ -1000,6 +1002,89 @@ export async function ingestSyncRows(
return data as { ok: boolean; ingested: number; hint?: string };
}
export async function ensureSyncBinding(
session: Session,
body: {
local_database_id: string;
online_db_id: string;
channel_id?: string;
note?: string;
user_id?: number;
}
) {
const res = await apiFetch(`/api/v1/admin/sync/bindings`, {
method: "POST",
headers: {
Authorization: `Bearer ${session.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await readJson(res);
throwIfBad(res, data, "ensure binding failed");
return data;
}
export async function listSyncBindings(session: Session, localDatabaseId?: string) {
const q = localDatabaseId
? `?local_database_id=${encodeURIComponent(localDatabaseId)}`
: "";
const res = await apiFetch(`/api/v1/admin/sync/bindings${q}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const data = await readJson(res);
throwIfBad(res, data, "list bindings failed");
return data as { items: Array<Record<string, unknown>> };
}
export async function listPlatformLwwOverrides(
session: Session,
opts?: { tenant_id?: number; channel_id?: string; limit?: number }
) {
const q = new URLSearchParams();
if (opts?.tenant_id) q.set("tenant_id", String(opts.tenant_id));
if (opts?.channel_id) q.set("channel_id", opts.channel_id);
if (opts?.limit) q.set("limit", String(opts.limit));
const qs = q.toString();
const res = await apiFetch(`/api/v1/platform/dbsync/lww-overrides${qs ? `?${qs}` : ""}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const data = await readJson(res);
throwIfBad(res, data, "list lww overrides failed");
return data as {
items: Array<{
id: string;
tenant_id: number;
channel_id: string;
table: string;
row_pk: string;
op: string;
entry: string;
policy: string;
outcome: string;
loser_payload?: string;
winner_payload?: string;
target_ver: number;
source_ver: number;
created_at: string;
}>;
hint?: string;
};
}
export async function rollbackPlatformLwwOverride(session: Session, id: string) {
const res = await apiFetch(
`/api/v1/platform/dbsync/lww-overrides/${encodeURIComponent(id)}/rollback`,
{
method: "POST",
headers: { Authorization: `Bearer ${session.accessToken}` },
}
);
const data = await readJson(res);
throwIfBad(res, data, "lww rollback failed");
return data as { ok: boolean; record?: Record<string, unknown>; hint?: string };
}
export async function listPlatformTenants(session: Session) {
const res = await apiFetch(`/api/v1/platform/tenants`, {
headers: { Authorization: `Bearer ${session.accessToken}` },

139
智建修改意见.md Normal file
View File

@@ -0,0 +1,139 @@
# 智建修改意见(松离线 + UUID 主键一致性)
> **最终方案(冻结)**[`松离线-dbsync方案-最终版.md`](./松离线-dbsync方案-最终版.md)
> 过程稿:[`松离线-dbsync方案.md`](./松离线-dbsync方案.md)
> 配套:`database_fastapi修改意见.md`;对照 `docs/数据同步-中间件.md`。
---
## 一、结论摘要
| 项 | 结论 |
|----|------|
| 对账引擎 | **不必从零重做**dbsync 已有按主键 upsert、ingest、reconcile、冲突队列 |
| 真正要改 | **表主键约定UUID** + **写入口语义(认客户端 id / upsert** + **与宇恒绑定对齐** |
| 推荐模型 | **松 + UUID**:离线本地先按最终主键落库;联网同主键幂等 upsert 到线上 |
| 禁止 | 同步键依赖两端各自 `INTEGER AUTOINCREMENT` |
宇恒侧(客户端)负责:补 UUID、松离线本地落库 + pending、回放幂等。
智建侧负责:线上按 UUID upsert、通道/对账/冲突、表结构与发号规则。
---
## 二、与宇恒文档的分工
| 端 | 文档 | 职责 |
|----|------|------|
| 宇恒客户端 | `database_fastapi修改意见.md` | `apply_write`、pending、binding、`online_primary` |
| 智建平台 | **本文** | 线上主库语义、dbsync、表约定、对账 API 对业务可用 |
接法二选一(长期建议 ①):
| 接法 | 含义 | 智建改动量 |
|------|------|------------|
| **① 走 dbsync** | 宇恒本地 = B松离线写 B带 UUID通道 B↔A | 表结构 + 配置为主,代码改少 |
| **② HTTP 双写** | 宇恒直打 `/database/permanent/...` | 线上 insert **必须**变 upsert且强制客户端 `id` |
---
## 三、表结构约定A / B 两端)
1. 参与同步的业务表主键为 **`id TEXT`UUID 字符串)**,不用自增整数当同步键。
2. 建议列:`updated_at` / `version`(与 dbsync 版本跳过、冲突策略对齐)。
3. 通道 `pk_columns`:非默认 `id` 时显式配置。
4. 存量自增表:迁移生成 UUID或暂不纳入同步白名单仅新表用 UUID。
5. dbsync 触发器已 `CAST(pk AS TEXT)`,字符串 UUID **原生兼容**;改的是建表习惯,不是中间件内核。
---
## 四、接法 ①:走 dbsync推荐
### 已有、可直接用
- 本地变更 → `_ajz_sync_outbox` → worker 推线上
- `POST .../ingest`:按主键 upsert示例已是 `"id": "c-001"`
- `POST .../reconcile`:主键集合补缺
- 冲突队列 + LWW / queue 策略
- 双向通道约每分钟自动对账
### 建议补强
| 优先级 | 项 | 说明 |
|--------|----|------|
| P0 | 文档/控制台规范 | 写死:同步表白名单必须 UUID 主键;松离线以客户端已带 `id` 为准 |
| P0 | 写入口拒无 id | ingest / 业务写:无 `id` → 400或服务端生成 UUID **并回写响应**(松离线仍优先客户端生成) |
| P1 | 库绑定对齐 | 宇恒 `online_db_id` 与智建租户库 / 通道 remote 可查询、可登记;避免仅本地 hash 对不上真实库 |
| P1 | 业务可调对账 | 现 sync API 偏管理员;提供租户内用户/agent 代理调用 ingest/reconcile或网关代调 |
| P2 | 同 PK 内容对账 | 当前 reconcile 偏主键集合;可选 version/hash 不一致 → 冲突队列 |
### 松离线数据流(①)
```text
离线:宇恒本地 B 按最终 UUID 落库(可标未同步)+ 可选 pending
联网:触发器/回放 → 同 PK upsert → A
兜底reconcile 补主键集合差;冲突进 conflicts
```
---
## 五、接法 ②HTTP 双写(短期兼容宇恒网关)
宇恒当前:`online_primary` 下同 body 先写线上再写本地,**不会**把线上自增 `inserted_id` 回填本地。
智建线上 permanent 写接口必须:
1. **`insert` = upsert by `id`**(有则更新,无则插入);请求**必须带 `id`**。
2. 响应带回 **`id` + `version`(可选)**,便于客户端校验。
3. `update` / `delete` 统一按同一 `id`;禁止「无 id 靠自增」。
4. 建表默认主键模板改为 UUID不再默认 `INTEGER AUTOINCREMENT`
5. (可选)按 `online_db_id` + 表暴露 reconcile或复用 dbsync reconcile。
未改以上语义时,即使客户端用 UUID线上若仍自增双轨主键仍会漂。
---
## 六、落地顺序(智建)
1. **规范 + 迁移**:同步表白名单,主键 UUID。
2. **选定接法**:优先 ①;若短期 ②,先改 permanent 写为 upsert。
3. **写入口强制带 `id`**(缺则 400 或服务端生成并返回)。
4. **绑定**:租户/用户 ↔ 线上库 ID 可查,与宇恒 `cache/db_bindings` 对齐。
5. **对账**:业务可读 API 或网关代调;冲突走现有 conflicts。
6. **文档**:离线写入 = 最终主键;联网只做同 PK 幂等,不重新发号。
---
## 七、验收标准
- 离线插入一行(客户端 UUID→ 联网后线上与本地 **同一 `id`**,无重复行。
- 同一 UUID 重复提交 / 回放 → **幂等**,不多行。
- 断网窗口结束回放后,主键集合对账无持续缺口(或缺口可一键 reconcile 清掉)。
- 并发改同 PK → 进冲突队列或按约定 LWW不静默改主键。
- 未纳入白名单的自增旧表:不同步或明确排除,避免误配通道。
---
## 八、勿做事项
- 不要用「两端自增 + 事后对账」充当主路径。
- 不要只靠触发器异步 outbox 充当客户端主写路径却仍让 HTTP insert 自增发号(两套语义打架)。
- 不要在回放失败时换新主键重试。
- 不要让管理员专用 sync API 成为唯一对账入口却要求终端用户自愈(需代理或网关)。
---
## 九、相关路径
| 项 | 路径 |
|----|------|
| 本文 | `E:\project\ai建站\智建修改意见.md` |
| 宇恒写网关意见 | `E:\project\ai建站\database_fastapi修改意见.md` |
| dbsync 说明 | `E:\project\ai建站\docs\数据同步-中间件.md` |
| dbsync 实现 | `platform/internal/dbsync/` |
| 宇恒网关实现 | `yuhengyihao_client/yxd/app_fastapi/db_write_gateway.py` 等 |
---
## 十、一句话
智建 dbsync **已经按主键 upsert / 对账**;「松 + UUID」要改的是 **业务表主键约定 + 写接口认客户端 id**,并与宇恒绑定、回放对齐——不是再造一套同步中间件。

View File

@@ -0,0 +1,294 @@
# 松离线 + dbsync 方案 · 最终版(冻结)
> **效力**:本文为双方协商后的**唯一开工依据**。
> 原方案稿 / 审评追加 / 决议草稿见同目录 `松离线-dbsync方案.md`(过程稿,不再改口径)。
> 冻结日期2026-07-31
---
## 0. 一句话
**技术**:松离线写本地 B最终 UUID+ 本机 agent 经 dbsync 幂等对齐线上 A冲突自动 LWW落败入**平台超级管理员**覆盖日志。
**产品**:宇恒**默认零影响**;仅显式 `local_dbsync` + **表白名单** opt-inM4 前不对全员切默认。
---
## 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
[开通 + 白名单表]
业务写 ──► BUUID──► outbox ──► 本机 agent ──► Aingest/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. 写 outboxagent 异步推 A。
4. agent 未装 / 停运:本地成功 + UI「待同步」**不失败保存**(定案 R1
5. deleteoutbox `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**(定案 O4M2 可用现网派生 `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. 书面确认R1R4 全部按上文硬约束执行(已并入本文 §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 + dbsyncLWW 自动、超管可追溯;默认不影响宇恒现网。按 M0→M4 开工,本文冻结。**
---
## 宇恒追加-实施钉死项(摘录已并入 §16
见过程稿全文;下列 §16 为智建复核后的**采纳与补钉**(不回退 H1H6
---
## 16. 实施钉死项 · 智建复核结论2026-07-31
> 宇恒 N1N6 **总体可采纳**,与最终版无冲突。下列为「写死值 + 防坑补钉」。
### 16.1 对 N1N6 的定案
| # | 宇恒建议 | 智建结论 | 写死值 |
|---|----------|----------|--------|
| 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 宇恒反馈有没有问题?
**没有方向性问题。** N1N6 都是在落实「不影响原有」,应进 M0/M1。
需注意的不是反对项,而是**执行时的灰区**(见下补钉)。
### 16.3 仍不完善 · 建议补钉P0/P1
| # | 缺口 | 建议 | 优先级 |
|---|------|------|--------|
| P0-a | 白名单缓存过期 | 定 TTL如 515 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/lwwyaml TTL/限流INTEGER PK 拒绝集成测;发版说明 |

611
松离线-dbsync方案.md Normal file
View File

@@ -0,0 +1,611 @@
# 方案稿:松离线 + 接法①dbsync
> 状态:**已冻结 → 请以 [`松离线-dbsync方案-最终版.md`](./松离线-dbsync方案-最终版.md) 为准开工**
> 本文保留为过程稿(正文草稿 + 双方审评 + 协商记录),**口径以最终版为准**。
> 日期2026-07-31
> 配套:`智建修改意见.md`、`docs/数据同步-中间件.md`、宇恒 `db_write_gateway.py`
---
## 1. 一句话
**离线在本地正式库按最终 UUID 落库;联网靠 dbsync 通道把本地 B 与线上 A 同主键幂等对齐。**
不做 HTTP 双写主路径;不做「两端自增再对账」。
---
## 2. 角色与库
| 端 | 角色 | 说明 |
|----|------|------|
| **A** | 智建线上主库 | Postgres租户/应用库) |
| **B** | 宇恒本机正式库 | SQLite permanent按用户绑定的 `local_database_id` |
| **绑定** | `(tenant_id, user_id, local_database_id) → online_db_id` | 宇恒本地登记;智建侧可查询/登记同一映射,通道 remote 指向对应 A |
通道(管理员配置,或安装时模板下发):
```text
local = Bsqlite该用户库文件
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 投递 Aupsert id=U1
→ A、B 同主键sync_status=synced若有
```
### 联网插入
```text
→ 写 B (id=U2) → outbox → 秒级 upsert A
```
### 冲突
```text
离线改 U1同时线上也改 U1
→ LWW 自动留一版,另一版写入「自动覆盖日志」(含两边 payload
→ 终端用户无感;仅平台超级管理员可查/导出(可选回滚)
→ 禁止换新主键重试
```
### 漏数兜底
```text
POST reconcile → 按主键集合补缺 → 不多发号
```
---
## 9. 验收标准
- [ ] 离线插入(客户端 UUID→ 本地立刻有行;联网后 A 与 B **同一 `id`**,无第二行。
- [ ] 同一 UUID 重复写 / 重放 outbox → **幂等**
- [ ] 断网窗口结束 + 对账后,白名单表主键集合无持续缺口。
- [ ] 同 PK 并发 → LWW 自动覆盖,**不改主键**;落败方在超级管理员覆盖日志中可查。
- [ ] 自增旧表未进白名单,误配会被拒绝或文档明确排除。
- [ ] 关闭 HTTP 双写主开关后,业务仍仅靠 B + dbsync 对齐。
---
## 10. 明确不做
- 紧离线(只 pending、正式表不落作为本方案主路径。
- 接法② HTTP 双写作为主同步。
- 两端自增 + 事后映射。
- 回放/投递失败时换新 UUID 重试。
---
## 11. 评审结论
| 项 | 结论 |
|----|------|
| 离线模型 | **松离线**(本地正式库落 UUID |
| 同步接法 | **① dbsync** |
| 通道形态 | **B 本机 sync agent** |
| 冲突默认 | **LWW自动裁决终端用户零操作**;建议按 `updated_at`/`version`,单客户端场景可偏本机(`lww_source` |
| 覆盖可追溯 | **LWW 仍自动覆盖,但必须写「自动覆盖日志」**(见下) |
| 谁可查日志 | **仅平台超级管理员**(非公司 top / 租户管理员) |
| 兼容开关 | **过渡期保留** `online_primary`HTTP 双写),灰度后再切默认 |
### 自动覆盖日志(相对现网 dbsync 的补强)
现网纯 LWW **不会**把落败方写入 `conflicts.json`。本方案要求:
| 项 | 约定 |
|----|------|
| 何时写 | 每次因版本较旧而跳过(`lww_target`)或因 LWW 覆盖目标旧行(`lww_source` |
| 记什么 | `tenant_id`、通道、表、`row_pk`、源/目标 version、**两边 payload 快照**、胜出策略、时间 |
| 存储 | 独立审计(如 `data/dbsync/lww_audit.json` 或平台审计表),**不要**当成用户待办冲突队列 |
| 终端用户 | 无入口、无提示、不阻断同步 |
| 公司 top | **不可见**(与现「数据同步」控制台租户管理员权限分离) |
| 平台超级管理员 | 可查、可导出可选「按快照回滚某一行」P2 |
权限口径:
```text
公司 top租户管理员→ 配自己公司的通道 / 对账(沿用现权限)
平台超级管理员 → 看全局 LWW 自动覆盖日志 / 应急回滚
终端用户 → 不参与冲突处理
```
---
## 12. 一句话验收口径
**松离线写 B最终 UUID+ 本机 agent 走 dbsync冲突自动 LWW落败方进超级管理员可查的覆盖日志主键不漂。**
---
<!-- =====================================================================
以下为独立评审意见(追加区),不改写上文方案正文。
标识:宇恒追加-审评意见 · 2026-07-31
===================================================================== -->
## 宇恒追加-审评意见
> **说明**:本节是宇恒侧对上文方案的外部审评意见,**不是**方案正文的修订稿。采纳与否单独决议;未并入 §1§12。
> **硬约束(宇恒侧)****这边的修改不得影响原有用户操作与现网写库逻辑**——默认行为保持现状;新能力仅 opt-in / 白名单生效。
### A. 总评
方案主线(松离线写 B + UUID + 本机 agent + dbsync + LWW与「离线可用且联网后主键不漂」目标匹配可立。
但在宇恒落地时必须遵守:
| 原则 | 含义 |
|------|------|
| **默认不变** | 未开新模式时,等同现网:`local_only` 或现有 `online_primary` 行为与契约不变 |
| **显式开启** | 仅 `YXD_SYNC_MODE=local_dbsync`(名可再定)+ 同步表白名单 时走松离线/dbsync |
| **白名单隔离** | 未进白名单的表:仍可自增、仍走原 insert**禁止**因全库切 UUID/强制 `id` 拖垮旧插件 |
| **双路径互斥** | 同一库/表不得同时 HTTP 双写与 local_dbsync避免双写放大 |
| **可回滚** | 关开关即回到原行为,无需迁数据才能用旧功能 |
**因此:正文目标可做,但宇恒改造必须是「旁路增量」,不是改默认写路径。**
### B. 同意保留的决策(在不影响原有的前提下)
| 项 | 意见 |
|----|------|
| 不做接法②作**新**主路径 | 同意;现网 `online_primary` 可作兼容保留,默认可不切走 |
| 形态 B 本机 agent | 同意;仅对已开通同步的用户/库启用 |
| 终端零冲突 UI | 同意;且**未开通同步的用户完全无感**(无新状态条、无强制 agent |
| 过渡期保留 HTTP 开关 | 同意;**默认勿切** `local_dbsync` |
### C. 建议补强(保证「不影响原有」)
1. **模式三分,默认旧行为**
- `local_only`:只写本地(现网兼容默认之一)
- `online_primary`:现网 HTTP 双写(已上线客户可继续)
- `local_dbsync`**新**松离线 + agent仅显式配置
未配置 / 未识别 → **不得**自动升到 `local_dbsync`
2. **强制 UUID / 必带 `id` 仅白名单表**
网关:白名单表缺 `id` 可补 UUID 或 400**非白名单表保持原样**(允许自增、允许不传 id
禁止「全局 create_table 改成 UUID」一刀切——可新增「同步表模板」旧模板不动。
3. **`apply_write` 分支,不改旧分支语义**
- `local_only` / `online_primary`:代码路径与响应字段兼容现网(可多字段,不可少成功语义)
- `local_dbsync`:才「始终写 B、不依赖离线 pending 空正式表」
旧调用方继续认 `success`;勿要求全员改认 `pending`
4. **agent 可选装**
未装 agent / 未开通同步:业务写库与现网一致;**禁止**写前强依赖 agent 心跳导致保存失败。
「仅本机 / 待同步 / 已同步」仅对白名单且已开通同步的会话展示。
5. **外键 / 闭包**
仅约束「要进同步白名单」的表集;未同步的旧关联表不强制改造。
6. **删改幂等、安全、对账**
仍建议做,但范围限 `local_dbsync` + 白名单;不影响未开通路径。
7. **建表模板**
双端「同步表模板」新增;**默认建表模板保持自增**,避免新用户无感被切 UUID。
8. **联网 reconcile**
仅同步用户;设置页入口可选;默认不弹、不阻断。
### D. 对现有用户操作逻辑的影响(按约束重述)
| 对象 | 要求结果 |
|------|----------|
| 未开 `local_dbsync` 的用户 | **零影响**:保存/查询/自增表/插件与现网一致 |
| 未进白名单的表 | **零影响**:不强制 UUID、不强制 agent |
| 已开同步的白名单表 | 预期内变更:离线立刻落 B、带 UUID、可显示待同步 |
| 聊天 / 选站 / 税务等非 permanent 同步写路径 | **零影响** |
若某改造会导致「未开同步也改变保存结果」→ **不予合入**,改回 opt-in。
### E. 建议验收追加项(含「不影响原有」)
- [ ] 默认配置下跑现网回归insert 不传 id 的自增表、offline pending 若仍开 `online_primary`)与改前一致。
- [ ] 仅打开 `local_dbsync` + 白名单后:松离线 UUID 验收§9通过。
- [ ] agent 停运:未开通用户保存仍成功;开通用户本地可写且待同步,恢复后主键不双行。
- [ ] 网页与客户端双入口仅同步表LWW 符合偏向;超管日志可查。
- [ ] 非白名单表加入通道:拒绝或警告。
- [ ] `local_dbsync``online_primary` 互斥;关 `local_dbsync` 后行为回到原模式。
### F. 不建议纳入本方案(避免范围膨胀与误伤)
- 用对账替换 UUID 主键策略。
- 终端用户冲突工作台。
- 平台直连用户 SQLite形态 A
- **默认全局改主键 / 默认切 `local_dbsync` / 强制全员装 agent。**
### G. 一句话评审口径
**方案可立,但宇恒必须旁路增量:默认不影响原有;仅白名单 + 显式 `local_dbsync` 走松离线。先灰度开通,再谈切默认。**
---
<!-- =====================================================================
标识:智建追加-审评意见 · 2026-07-31
角色:方案侧 / 平台侧复核(对照正文 §1§12 与上文「宇恒追加」)
===================================================================== -->
## 智建追加-审评意见
> **说明**:本节是智建/方案侧对正文及「宇恒追加-审评意见」的复核意见,**不是**方案正文修订稿。与宇恒追加并列,采纳单独决议。
### A. 总评
同意宇恒总评:主线可立,**必须白名单灰度**,不可默认切全库。
智建侧补充:现网 dbsyncoutbox / ingest / reconcile / LWW够用当引擎缺口在 **本机 agent 协议面**、**LWW 覆盖审计(超管)**、**绑定 API**,不是再造同步中间件。
与宇恒追加 **无方向冲突**;下文对 C 条逐项表态,便于开工排序。
### B. 同意保留 / 同意宇恒已同意项
| 项 | 意见 |
|----|------|
| 松离线 + ① + 形态 B + LWW + 超管覆盖日志 | **维持正文定案** |
| 不做接法②主路径 / 不做形态 A / 终端零冲突 UI | **同意**(与宇恒 B 节一致) |
| 过渡期保留 `online_primary` | **同意**;切默认前须互斥,禁止双路径同时写 |
| 白名单灰度 | **同意并升为 P0 开工前提** |
### C. 对「宇恒追加 · C 建议补强」的表态
| # | 宇恒建议 | 智建意见 | 建议处置 |
|---|----------|----------|----------|
| 1 | 外键 / 同步闭包 | **同意** | 写入规范:白名单按闭包进;跨名单外键配置期拒绝 |
| 2 | agent 未就绪与「立刻可见」 | **同意** | UI 三态(仅本机/待同步/已同步)+ 写前/写后可探测 agent 心跳;属宇恒 P0 |
| 3 | 双入口吞改 | **同意偏保守** | 双入口表:**先 B→A**稳定后再双向LWW 默认可偏本机,但双入口表在超管日志外可加「弱提示已自动合并」(仍不选边) |
| 4 | delete / 重放顺序 | **同意** | 正文补:墓碑或 outbox `delete`agent **按 outbox id 保序**;禁止失败换新 UUID |
| 5 | `YXD_SYNC_MODE=local_dbsync` | **同意** | 三分:`local_only` / `online_primary` / `local_dbsync`;文档与发版说明同步 |
| 6 | 双端建表模板 | **同意P0** | 智建 create + 宇恒 create 同时改;入通道校验非 TEXT UUID PK → 拒绝 |
| 7 | agent 凭证与 outbox 敏感列 | **同意** | 正文加安全节:最小权限、按 `online_db_id` 隔离、可吊销outbox 敏感列加密或脱敏 at rest |
| 8 | 联网自动 reconcile | **同意** | 限流自动对账 + 设置页「同步修复」;**不**暴露 conflicts 给终端;公司 top 仍可走现控制台 |
### D. 智建侧另补(宇恒未写或平台专属)
1. **LWW 审计权限**
维持:**仅平台超级管理员**;与公司 top「数据同步」菜单分离。实现上独立 API/页,勿挂在租户 sync 控制台下以免误授权。
2. **A 侧接收面**
agent 推送优先复用/扩展现有 ingest或等价 upsert 接口),保证 **认客户端 UUID、幂等**;避免再开一套与 dbsync 语义不一致的 HTTP 写。
3. **时钟与 version**
LWW 依赖 `updated_at`/`version`:约定客户端单调时钟或服务端授时规则,防止对时错误导致「旧盖新」。建议 version 用 outbox 单调序号为主、时间戳为辅。
4. **超管日志保留策略**
覆盖日志需 TTL/容量上限(如 90 天或按租户配额),避免无限涨;导出权限同超管。
### E. 对现有用户操作逻辑的影响(复核)
| 对象 | 影响程度 | 说明 |
|------|----------|------|
| 终端(白名单表) | **中高** | 同意宇恒:契约带 UUID + 可见性语义变化;用白名单限面 |
| 终端冲突 | **降低** | 同意;无选边 |
| 自增插件/脚本 | **高(仅误入白名单时)** | 配置期拒绝可把风险压到「零误配」 |
| 「保存后线上立刻读到」 | **中** | 同意改为异步需延迟可观测agent 状态) |
| 公司 top | **低~中** | 多「闭包校验 / 单向先」配置纪律;仍无 LWW 审计 |
| 平台超管 | **新增** | 覆盖日志查询/导出P2按快照回滚 |
**结论**:同意宇恒「冲击面用白名单隔离」;智建把住 **入通道校验 + 超管审计隔离 + ingest 幂等** 三条,终端无感冲突可成立。
### F. 建议验收追加项(相对 §9 与宇恒 E
- [ ] 采纳宇恒 E 全部四条agent 停运、双入口 LWW、非白名单拒绝、模式互斥
- [ ] 超管可按 `tenant_id` / 表 / `row_pk` 检索覆盖日志,且公司 top Token **403**
- [ ] 白名单闭包:只配父表不配子表时,保存通道失败并给出明确错误。
- [ ] `local_dbsync` 下业务写不调用 permanent HTTP 双写主路径(开关关闭时无双写流量)。
### G. 不建议纳入本方案
- 同意宇恒 F不对账替代 UUID、不做终端冲突台、不做形态 A。
- 另:**不做字段级自动合并CRDT** 作本期范围;需要时另立专项。
- **不把 LWW 审计对租户管理员开放**(即使只读),避免与「超管」口径混淆。
### H. 一句话评审口径
**同意宇恒「可立、先白名单再切默认」;正文方向不改。开工顺序建议:双端 UUID 模板与入通道校验 → `local_dbsync` 松离线写 B → agent 最小推送 + 超管 LWW 日志 → 再灰度关 HTTP 双写。**
---
<!-- =====================================================================
标识:协商决议版 · 2026-07-31
合成:正文 §1§12 + 宇恒追加 + 智建追加 → 可执行默认策略
===================================================================== -->
## 协商决议版(双方意见合成 · 以此为准开工)
> **效力**:本节覆盖正文中与「默认切全站 / 写前强依赖 agent」相冲突的表述技术主线松离线、①、形态 B、LWW、超管日志不变。
> **总原则(采纳宇恒硬约束)****未显式开通前,宇恒现网操作与写库逻辑零影响**;新能力仅 `local_dbsync` + 白名单 opt-in。
### 1. 定案一览
| 维度 | 决议 |
|------|------|
| 同步模型 | 松离线写 B最终 UUID+ 接法① dbsync |
| 通道形态 | **B 本机 sync agent**(平台不直连用户 SQLite |
| 冲突 | **自动 LWW**(用户零操作) |
| 落败方可追溯 | **自动覆盖日志****仅平台超级管理员**可查(公司 top 不可见) |
| 宇恒默认模式 | **保持现网**`local_only` 或已在用的 `online_primary`**不自动升** `local_dbsync` |
| 新模式 | 显式 `YXD_SYNC_MODE=local_dbsync` + **表级白名单** 才生效 |
| 双路径 | `local_dbsync``online_primary` **互斥**(同库不同时开) |
| 建表 | **默认模板仍自增**;另增「同步表模板」= UUID TEXT PK入通道校验非 UUID → 拒绝 |
| 同步方向(首期) | 白名单表 **先 B→A**;双入口稳定后再开 bidirectional |
| 范围外 | 不做形态 A、不做终端冲突台、不做 CRDT 字段合并、不对账替代 UUID |
### 2. 比原文「更好」的关键修正
原文偏「目标态替换默认写路径」;协商后改为 **旁路增量**,避免伤宇恒存量:
| 原文风险 | 决议后 |
|----------|--------|
| 全库强制 UUID / 改默认 create | 仅同步模板 + 白名单;旧表旧模板不动 |
| 松离线成为默认写语义 | 仅 `local_dbsync` 分支;旧分支语义冻结 |
| 写前强依赖 agent 心跳(智建初评 C2 | **作废**agent 停也不得导致保存失败;仅开通用户显示「待同步」 |
| 双向一上来 | 首期单向 B→A降低网页↔客户端互盖 |
| LWW 无痕 | 超管覆盖日志(两边 payload + 胜出策略 + TTL |
### 3. 模式与写路径(宇恒)
```text
local_only → 只写本地(现网)
online_primary → HTTP 双写 / 离线 pending现网已落地兼容保留
local_dbsync → 白名单表:始终写 B+ outbox非白名单仍走该模式下的「本地普通写」且不进同步
未配置 → 不得视为 local_dbsync
```
网关规则:
1. **非白名单**:不强制 `id`、允许自增;与现网一致。
2. **白名单 + local_dbsync**:无 `id` 则客户端补 UUID或 400写 B 成功即返回成功agent 仅异步。
3.`local_dbsync` → 行为回到原模式(可回滚)。
### 4. 智建平台职责(收窄可交付)
| P0 | 内容 |
|----|------|
| 入通道校验 | UUID TEXT PK + 白名单闭包(父子表一起或拒绝) |
| ingest/应用面 | 认客户端 UUID幂等 upsert供本机 agent 推送 |
| LWW 超管审计 | 独立存储与 API公司 top 403TTL建议 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 停:本地仍可保存 +「待同步」;恢复后追上。
- [ ] 双入口同 PKLWW 符合首期策略(偏本机或按 version落败在超管日志。
- [ ] 公司 top 查覆盖日志 → 403超管可检索/导出。
- [ ]`online_primary` 互斥,无双路径同时写。
### 8. 协商一句话
**技术上走松离线 + 本机 agent + dbsync + LWW超管可追溯产品上必须旁路 opt-in——默认不影响宇恒原有操作白名单灰度达标前不切默认。**
---
<!-- =====================================================================
标识:宇恒追加-继续协商(第二轮)· 2026-07-31
对照:协商决议版;回应智建追加中仍需对齐处
===================================================================== -->
## 宇恒追加-继续协商(第二轮)
> **说明**:在「协商决议版」之上的第二轮协商意见,**不改写** §1§12 与决议正文;请智建对「待拍板」表态后可并入决议修订。
> **立场**:决议版 **总原则 + §1/§2/§6/§7「不影响原有」** 已对齐宇恒硬约束 → **宇恒原则上接受决议版开工**。下列为收口与防回潮。
### 1. 宇恒对决议版的表态
| 决议要点 | 宇恒态度 |
|----------|----------|
| 默认不升 `local_dbsync`;旁路 opt-in | **接受(硬条件,不可回退)** |
| 默认建表仍自增;同步模板另增 | **接受** |
| 写前不因 agent 停而保存失败 | **接受(硬条件)**;请同步作废智建初评 C2「写前强依赖心跳」表述 |
| 首期 B→AM4 前不谈全员默认 | **接受** |
| 分期 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. 宇恒侧本期承诺(换智建确认 R1R4
1. M0/M1模式三分 + 白名单判断进 `apply_write`**旧分支零行为 diff**(附回归清单)。
2. 不把 agent 做成安装强依赖。
3. 同步 UI 与开关仅在开通后出现。
4. M2 起才对接 ingest协议以智建文档为准客户端不自造第二套写语义。
### 5. 建议智建本期承诺
1. 入通道 UUID + 闭包校验M0
2. ingest 幂等认客户端 UUIDM2 前就绪)。
3. 超管审计 API 与租户控制台分离M3
4. 发版说明写明:**默认客户无感**`local_dbsync` 为增值开通。
### 6. 第二轮协商一句话
**决议版方向已收束;宇恒同意开工。请智建确认 R1R4并拍板 O1O7尤其 O6 同表互斥 vs 同库互斥)后,即可冻结决议修订版进入 M0。**
---
## 最终冻结说明2026-07-31
R1R4、O1O7 已全部拍板并写入 **[`松离线-dbsync方案-最终版.md`](./松离线-dbsync方案-最终版.md)**。
此后改需求只改最终版并备注变更记录;本过程稿不再作为实施口径。