feat: harden loose-offline sync for user JWT, schema, and console ops

Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-05 09:47:35 +08:00
parent 76cdcd760e
commit b04b180d30
59 changed files with 4762 additions and 308 deletions

View File

@@ -0,0 +1,430 @@
# 宇恒 × 智建 · 松离线数据同步使用文档
> 面向:**宇恒一号客户端**对接同学
> 平台侧仓库智建ai建站
> 依据:`松离线-dbsync方案-最终版.md`(冻结)
> 本地测试基址示例:`http://127.0.0.1:8180`;生产示例:`https://aisite.yuxindazhineng.com`
---
## 1. 你要做什么(一句话)
未开通用户**零改动**。仅当显式 `YXD_SYNC_MODE=local_dbsync` 且表在白名单时:业务写进本机正式库 BUUID→ 写 outbox → **本机 agent** 调智建 push API → 落到线上库 A。agent 停了也**不能挡保存**。
```text
业务保存 ──► 本机 B最终 UUID立刻可见
outbox积压可接受
▼ 本机 agent可选装
智建 POST .../push ──► 线上 A
```
---
## 2. 硬约束(违反即不合入)
| # | 要求 |
|---|------|
| H1 | 未开通:保存 / 自增表 / 插件与现网 **行为 diff = 0** |
| H2 | **禁止**自动升为 `local_dbsync`(仅显式配置) |
| H3 | agent 停运 / 推送失败 → **保存仍成功**;心跳只驱动 UI |
| H4 | 默认建表仍自增;仅「同步表」用 UUID TEXT PK |
| H5 | 旧 `online_primary` 客户禁止静默关双写 |
| H6 | 未开通用户:**零同步文案**(无强制状态条、无「已自动合并」) |
开通判定(写路径):
```text
mode == local_dbsync AND 表白名单缓存命中该表
→ 走松离线旁路
否则 → 原写路径
```
同表互斥:某表不可同时 HTTP 双写 + dbsync 白名单。
---
## 3. 模式三分(环境变量)
| `YXD_SYNC_MODE` | 含义 | 说明 |
|-----------------|------|------|
| `local_only` | 仅本地 | 默认之一 |
| `online_primary` | HTTP 双写 / 离线 pending | **旧客保留**;仅配了 `YXD_ONLINE_API_BASE` 且未写 MODE 时可兼容升为此模式 |
| `local_dbsync` | 松离线 + 白名单 + agent | **必须显式写出**,永不因「配了线上地址」自动升 |
建议本地测试:
```bash
# 默认无感:不要设 local_dbsync或显式
YXD_SYNC_MODE=local_only
```
开通增值:
```bash
YXD_SYNC_MODE=local_dbsync
YXD_SYNC_AGENT=1
YXD_ONLINE_API_BASE=http://127.0.0.1:8180
YXD_SYNC_CHANNEL_ID=<智建控制台通道 ID>
YXD_SYNC_ACCESS_TOKEN=<Bearer管理员/智能体「数据同步」,或登录用户 JWT自助须先 Binding>
# 可选
YXD_SYNC_WHITELIST_TTL_SEC=600
YXD_SYNC_AGENT_INTERVAL_SEC=5
YXD_SYNC_WHITELIST_CACHE=cache/db_sync/whitelist.json
```
调试兜底表白名单(通道拉取失败前):
```bash
YXD_SYNC_WHITELIST=orders,order_items
# 或
YXD_SYNC_DBSYNC_TABLES=orders,order_items
```
---
## 4. 智建侧前置(公司管理员)
1. 登录智建控制台 → **数据同步** → 新建通道。
2. **生产**`remote.driver=postgres`DSN 例:`postgres://user:pass@host:5432/db?sslmode=disable`(须可达)。
3. 表白名单须 **UUID TEXT/UUID PK** + **外键闭包**
4. 方向推荐 **本地 → 线上**;冲突策略推荐 **源端覆盖lww_source**
5. 保存通过校验后记下 **通道 ID**
6. **二选一鉴权**
- 管理路径:发带「数据同步」的智能体 Token / 管理员 JWT
- **用户自助**:终端用登录用户 JWT先登记 Bindingpush 带本人 `online_db_id`
**单服务器**:一条通道 + 一个 `YXD_SYNC_CHANNEL_ID` 即可。
**多服务器**:每台线上 Postgres **各建一条通道**;按本机库选择对应 `channel_id`(建议登记 Binding
Binding多库 / 用户自助时建议登记;登录 JWT 即可,不必管理员权):
```http
POST /api/v1/admin/sync/bindings
Authorization: Bearer <token>
Content-Type: application/json
{
"local_database_id": " ID",
"online_db_id": "线 ID",
"channel_id": "<ID>",
"database_name": "",
"display_name": "线",
"note": ""
}
```
不传可读名时控制台仍显示 id宇恒有命名库名时建议一并写入便于 SyncPage「本地库名 ↔ 线上库名」。
```http
GET /api/v1/admin/sync/bindings?local_database_id=...
```
---
## 5. 宇恒侧实现清单
### 5.1 写网关旁路M1可无 agent
`insert / update / delete / 写 SQL`
1. 读模式与白名单。
2. **未命中**:原路径,响应形状与现网一致(可多字段,不可少成功语义)。
3. **命中**
- insert缺 id 或非法 id → **自动补**小写带连字符 UUID
- 写入本机正式库 B
- append outbox失败只打日志**不挡保存**
- `sync_status``pending` / `local_only_table` 等仅开通用户可见。
文案:`需 agent 才上云`(不得暗示已上云)。
### 5.2 Outbox
建议路径(等价即可):
```text
cache/db_sync/outbox/<local_database_id>/pending.jsonl
```
每条建议字段:
| 字段 | 说明 |
|------|------|
| `id` | outbox 记录 UUID |
| `seq` | 单调版本(如 `time.time_ns()`),作 push 的 `version` |
| `op` | `insert` / `update` / `update_by_id` / `delete` |
| `table_name` | 表名 |
| `row_pk` | 行主键 UUID |
| `payload` | 含 `data` / `update_data` / `where_*` |
| `status` | `pending` |
规则:按 `seq` **保序**消费;失败标记 error 并**停该库后续**,恢复后重试;**禁止**失败后换新 UUID。
### 5.3 本机 AgentM2
启用:`YXD_SYNC_AGENT=1``mode=local_dbsync`。默认可不启。
循环建议:
1. TTL拉白名单 → 写本地缓存。
2. `list_pending` → 逐条 push → 成功 `mark_done` / 失败 `mark_error` 并 break。
3. 写心跳文件(供 `/sync/status` 展示 `agent.running` / `last_beat`)。
**拉白名单**
```http
GET /api/v1/agent/sync/channels/{channel_id}/whitelist
Authorization: Bearer <token>
```
响应要点:`tables``pk_columns``conflict_policy`
**推单条**
```http
POST /api/v1/agent/sync/channels/{channel_id}/push
Authorization: Bearer <token>
Content-Type: application/json
{
"table": "orders",
"op": "insert",
"row_pk": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"row": {
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"title": "demo"
},
"version": 1710000000000000000,
"client_outbox_id": "outbox-record-uuid",
"online_db_id": " Binding/"
}
```
| `op` | 平台行为 |
|------|----------|
| `insert` / `update` / `update_by_id` | upsert 到 remote A |
| `delete` | 按 pk 删 A |
幂等:同 `version` 已落地 → 跳过成功(`skipped=true`)。
LWW目标更新 → 按通道策略;源端覆盖会记**超管**审计(公司管理员不可见)。
重复 push双 drain正确性由 version 保证;客户端可用 drain 锁减流量,非平台必做。
remote 暂不可达:`503` + `retryable:true`
**下行(仅线上 / 灌库 / 补齐)**
```http
POST /api/v1/agent/sync/channels/{channel_id}/bootstrap
Authorization: Bearer <token>
Content-Type: application/json
{
"table": "orders",
"after_pk": "",
"limit": 200,
"online_db_id": ""
}
```
`POST .../pull``mode` 取:
| mode | 作用 |
|------|------|
| `bootstrap` | 分页全量(循环至 `has_more=false` |
| `pks` | 只取 A 主键,本机 diff |
| `rows` | 按 `row_pks` 取行 |
客户端把 `result.items` upsert 进本机 B 时务必 **WithApplying**(或等价),避免回声进 outbox。
空表:`result.columns` 仍会返回;本机应用其 `CREATE TABLE IF NOT EXISTS`(见下「表结构同步」)。
成功响应示例:
```json
{
"success": true,
"result": {
"ok": true,
"applied": false,
"skipped": true,
"conflict": false,
"applied_version": 1710000000000000000,
"message": "already applied (same version)",
"client_outbox_id": "outbox-record-uuid"
}
}
```
批量:`POST .../push/batch`body `{ "items": [ ... ] }`,最多 100保序遇错即停。
也可设完整 URL`YXD_SYNC_PUSH_URL=...`(覆盖默认拼装)。
**表结构同步(空表也要建)**
仅靠 outbox 行 push 时,**空表不会出现在线上**(无变更事件)。同步周期应额外:
1. **本机 → 线上**:对本机每张业务表(含 0 行)调用 ensure
```http
POST /api/v1/agent/sync/channels/{channel_id}/schema/ensure
Authorization: Bearer <token>
Content-Type: application/json
{
"online_db_id": "",
"tables": [
{
"table": "accounts",
"pk_column": "id",
"columns": ["id", "username", "password", "email", "nickname", "status", "created_at", "updated_at"]
}
]
}
```
平台对每张表 `CREATE TABLE IF NOT EXISTS`(列一律 TEXT + 指定 PK已存在则跳过。
2. **线上 → 本机**:先拉结构,本机缺表则建空表,再 bootstrap 行:
```http
POST /api/v1/agent/sync/channels/{channel_id}/schema
Authorization: Bearer <token>
Content-Type: application/json
{ "online_db_id": "" }
```
响应 `result.tables[].columns` + `row_count`;对本地缺失表执行 `CREATE TABLE IF NOT EXISTS`(建议 TEXT + PK=`pk_column`)。
`pull`/`bootstrap``result.columns` 同样可用于单表建空表。
### 5.4 状态接口(建议)
`GET /database/sync/status`(或你们现有等价路由)对开通用户返回:
- `mode`
- `local_dbsync.whitelist_tables` / `outbox_pending`
- `agent.enabled_flag` / `running` / `last_error`
- `note`:未开通勿强塞同步 UI
---
## 6. UUID 约定
- 标准形式:**小写 + 连字符** `8-4-4-4-12`
- 入库前 `normalize`;无连字符 32 hex 可规范化
- 默认建表模板**不要**改成 UUID只给同步表白名单表用
SQLite 同步表示例:
```sql
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY NOT NULL,
title TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
## 7. 从旧双写迁入O7摘要
1. 该表先停 `online_primary` 双写。
2. 观察无残留。
3. 再进白名单并切 `local_dbsync`
4. 装 agent看 outbox 清空。
5. **禁止**静默全员迁移;旧客默认不变。
完整步骤见智建仓:`docs/数据同步-迁移手册.md`
---
## 8. 联调检查表
### 未开通回归(必过)
- [ ] 不设 `local_dbsync`:插入自增表与现网一致
- [ ] 无同步强制文案、无强制装 agent
- [ ] 保存不因同步模块报错失败
### 开通 + 无 agent
- [ ] 白名单表本地立刻可见UUID
- [ ] outbox 增长;响应提示需 agent 才上云
- [ ] 非白名单表仍走原路径
### 开通 + agent
- [ ] 拉白名单成功并缓存
- [ ] push 后线上 A 出现**同一 UUID**(不双行)
- [ ] 重复 push 同 version 幂等
- [ ] agent 停:仍可本地保存;恢复后按序追上
- [ ] delete 保序,不换新 UUID
### 冲突
- [ ] 终端**无**冲突处理台
- [ ] 公司管理员打 conflicts API → 403
- [ ] 超管可在平台工作台看 LWW 日志 / 回滚
---
## 9. 智建 API 速查
基址:`{YXD_ONLINE_API_BASE}`,鉴权:`Authorization: Bearer ...`
- 管理路径:需「数据同步」
- **用户自助**:登录用户 JWT + 本人 Bindingpush 须带 `online_db_id`
- LWW 仅超管
| 方法 | 路径 | 谁用 |
|------|------|------|
| GET | `/api/v1/agent/sync/channels/{id}/whitelist` | agent / 用户 JWT |
| POST | `/api/v1/agent/sync/channels/{id}/push` | agent / 用户 JWT |
| POST | `/api/v1/agent/sync/channels/{id}/push/batch` | agent / 用户 JWT |
| POST | `/api/v1/agent/sync/channels/{id}/pull` | agent / 用户 JWT |
| POST | `/api/v1/agent/sync/channels/{id}/bootstrap` | agent / 用户 JWT |
| POST | `/api/v1/agent/sync/channels/{id}/schema` | agent / 用户 JWT拉线上表结构含空表 |
| POST | `/api/v1/agent/sync/channels/{id}/schema/ensure` | agent / 用户 JWT本机空表建到线上 |
| POST | `/api/v1/agent/sync/channels/{id}/pull` | agent / 用户 JWT下行 |
| POST | `/api/v1/agent/sync/channels/{id}/bootstrap` | 全量灌库别名 |
| GET/POST | `/api/v1/admin/sync/bindings` | 管理员或用户自助 |
| GET | `/api/v1/platform/dbsync/lww-overrides` | 仅超管 |
| POST | `/api/v1/platform/dbsync/lww-overrides/{id}/rollback` | 仅超管 |
OpenAPI`GET /api/v1/meta/openapi.yaml`
---
## 10. 本地联调最小步骤
1. 智建本机起栈(`.env` 已是本地:`AIJZ_PUBLIC_BASE_URL=http://127.0.0.1:8180`)。
2. 控制台建通道:`remote` 优先本机 **Postgres**(与平台同实例或独立库均可);拷贝通道 ID。
3. 拿 token管理员登录或智能体 client_credentials
4. 宇恒设:
```bash
YXD_SYNC_MODE=local_dbsync
YXD_SYNC_AGENT=1
YXD_ONLINE_API_BASE=http://127.0.0.1:8180
YXD_SYNC_CHANNEL_ID=...
YXD_SYNC_ACCESS_TOKEN=...
```
5. 对白名单表插一行 → 看本机 B + outbox → agent 推上 A → 比对 UUID。
6. 多服务器演练:再建第二条通道指向另一 Postgres`CHANNEL_ID` 验证不串库。
---
## 11. 相关文档(智建仓)
| 文档 | 内容 |
|------|------|
| `松离线-dbsync方案-最终版.md` | 双方冻结方案 |
| `docs/同步表约定.md` | UUID / 接口约定 |
| `docs/数据同步-开通说明.md` | 管理员开通 |
| `docs/数据同步-迁移手册.md` | 旧客迁移 |
| `docs/数据同步-中间件.md` | 平台能力与 API |
| `docs/发版说明-数据同步.md` | 默认无感发版说明 |
---
**分工提醒**智建只提供平台通道、校验、push/whitelist/Binding/LWW**写网关、outbox、agent、未开通回归**由宇恒在己方仓库实现,勿改智建仓业务代码。