Files
Drsakura 5acfbcea7d chore(release): 1.2.0 —— 数据迁移功能发版
- 版本号 1.1.1 → 1.2.0(root / backend / frontend 与两个 lockfile):
  部署后 /api/health 与「系统与更新」能直接分辨线上跑的是哪份代码,否则换库前后都显示 1.1.1。
- API 测试补齐:只导订单的包内容、/api/data/catalog、换类别预览(replan)、取消上传后不可再导。
- 文档补排查表:413、版本确认、权限位缺失、SQLITE_BUSY、包过期、整体回滚。
- 已验证 data.transfer 只补给超级管理员组(admin),董事长/总经理/销售经理都拿不到。
2026-10-23 13:51:13 +08:00

166 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 业务数据导入导出(数据迁移)
> 对应 `backend/lib/dataTransfer/`、`backend/routes/dataTransfer.js`、`frontend/src/pages/DataTransfer.jsx`。
> 界面入口:侧栏 · 系统管理 · **数据迁移**(权限点 `data.transfer`,默认只给超级管理员组)。
## 为什么不再「整个 trade.db 换过去」
把本地 `trade.db` 直接覆盖到服务器,会把**整套系统状态**一起带过去:`users`(密码哈希)、
`sessions`(登录态)、`settings`(里面存着大模型 API Key 与合同认列规则)、`permission_groups`、
`audit_log`。这等于让「传数据」变成「换一套后台」,而且线上一旦有自己的账号和配置就会被抹掉。
所以数据分成两类,边界写死在 `backend/lib/dataTransfer/catalog.js`:
| 业务数据(可导出 / 可导入) | 系统数据(永不进包、永不覆盖) |
| --- | --- |
| 供应商、产品组、合同认列记录、产品、供应商报价、阶梯价、价格历史、产品属性、产品图片(含 BLOB)、合同原件(含 BLOB)、包装规格、清洗任务与明细 | 用户与密码 |
| 客户、联系人、商机、客户报价单与明细、询盘与明细、客户时间线、跟进记录 | 登录会话 |
| 订单、订单明细、订单杂费 | 组织信息 |
| 待办与日程 | 权限组、角色指派 |
| 新客开发任务与线索 | 系统设置(AI 密钥、合同规则、权限台账) |
| | 审计日志 |
`cart_items`(本机小推车)也不在迁移范围 —— 它只是临时挑选状态,不属于要交接的数据。
## 数据包格式
文件名形如 `happy-export-20261023-1512.hdata.gz`,内容是 **gzip 压缩的 JSONL**:
```
{"type":"header","format":"happy-data","version":1,"exportedAt":"…","categories":[…],"tableNames":[…],"exporter":{…}}
{"t":"suppliers","r":{"name":"宁波腾达","created_at":"…"}}
{"t":"products","r":{"sku":"TD-100","price":88,"__refs":{"supplier_id":"宁波腾达"}}}
…
{"type":"footer","tables":{"products":{"rows":1576,"sha256":"…"}},"rows":3854}
```
- **不用 zip、不直接拷 .db**:gzip + JSONL 只用 Node 内置能力,逐行流式读写,
未来合同原件变成几十 MB 也不会整包读进内存;
- **每张表一个 sha256 + 行数**:导入前先校验,整包任一表对不上就整体拒绝 ——
半个包导进去比导不进去更难收拾;
- `__refs` 是父记录的**自然键快照**(如订单里的客户公司名)。只导订单时,
目标库靠它按公司名找到客户,或补一个只有公司名的客户壳。
当前这套真实数据(1576 个产品、823 条合同、272 个供应商…)导出后约 **1.22 MB**、3854 行。
## 导入规则
1. **归属一律给导入人。** `owner_id` / `created_by` / `assigned_to` / `reviewed_by` 全部改写成
执行导入的账号,`org_id` 改写成该账号所属组织。同事之间快速同步、或职责交接时,
导完由管理员在客户页重新分配即可。
2. **冲突跳过,绝不覆盖。** 线上已有同一条记录就跳过,并写进报告:
产品按 `sku`、供应商按 `name`、订单按 `order_no`、客户按 `company`、报价单按 `quote_no`…
(完整对照见 `catalog.js` 的 `key`)。
3. **流水表按整行内容比对。** 价格历史这类没有自然业务键的表,用「所有列都相同」
判断重复 —— 曾经用 `(sku, changed_at)` 当键,实测把 643 行里的 186 行当成重复静默丢掉了
(一次批量改价会在同一毫秒写多条)。同一个包里逐列完全相同的两行也会都保留。
4. **父记录可补壳。** 只导订单而没导客户时,按 `__refs` 里的公司名补一个客户,
报告里单列「自动补建」,导完提示补全联系人、地址等信息。
5. **一个事务 + 导入前备份。** 写入在一个事务里完成,异常整体回滚;
导入前用 `VACUUM INTO` 把线上库备份到 `DATA_DIR/backups/pre-import-<时间>-<随机>.db`。
6. **预览等于真跑一遍。** 预览把库 `VACUUM INTO` 成副本,在副本上执行同一套导入代码,
跑完删掉副本 —— 所以预览报告里的「新增 / 跳过 / 失败」与真导结果同源,
不存在「预览说能导、真导却失败」的两套逻辑。
## 操作步骤:本地 → 云端
1. **服务器先升级到含本功能的版本**(见下节),重启后管理员登录。
2. **本地**(有真实数据的那台):侧栏 · 系统管理 · 数据迁移 → 勾选类别
(全选 = 全部业务数据,也可以只勾产品 → 只导 SKU)→ 导出,得到一个 `.hdata.gz`。
3. **云端**:同一页面上传该文件 → 等预览报告 → 检查「将新增 / 跳过」→ 确认导入。
4. 核对:工作台统计里的产品 / 供应商 / 客户 / 订单数量应与本地一致;
报告里的「自动补建」条目需要人工补全。
本次迁移的预期结果(本地 2026-10-23 的库):
```
包内 3854 行 → 新增 3854 / 跳过 0 / 失败 0
products 1576 · contracts 823 · price_history 643 · product_groups 516 · suppliers 272
```
## 服务器部署
```bash
cd /opt/1panel/www/sites/erp.foohere.de/index
git pull
cd backend && npm install --omit=dev --ignore-scripts
cd ../frontend && npm install && npm run build
systemctl restart erp
sleep 3 && curl -s http://127.0.0.1:4300/api/health # 应返回 "version":"1.2.0"
```
版本号从 `1.1.1` 升到 `1.2.0` 就是为了这一步:/api/health 与「设置 → 系统与更新」都能一眼看出线上跑的是哪份代码。
部署后**现有账号与设置保持不变**(这正是与整库替换的区别);新增的权限点 `data.transfer`
会由 `db.js` 的 `syncNewPermissions()` 自动补进超级管理员组 —— 已实测:只补给「超级管理员组 / admin」,
董事长、总经理、销售经理都拿不到。
反向也成立:云端 → 本地同样走这一页,包是同一个格式。
## 命令行(备用通道)
浏览器上传被 nginx / CDN 的体积限制挡住(默认 `client_max_body_size 1m`,而真实包 1.22 MB 就会 413),
或者想把包 `scp` 上去直接在服务器导,用同仓库的 CLI:
```bash
cd /opt/1panel/www/sites/erp.foohere.de/index/backend
# 各类别、各表当前行数
/usr/bin/node scripts/data-transfer.js tables
# 先预览:在库副本上真跑一遍,线上库不动
/usr/bin/node scripts/data-transfer.js import /root/bundle.hdata.gz --preview
# 确认后导入:自动备份到 data/backups/,归属给 --user 指定的账号
/usr/bin/node scripts/data-transfer.js import /root/bundle.hdata.gz --apply --user admin
# 定期导出做异地备份(配合 cron)
/usr/bin/node scripts/data-transfer.js export --out /root/backup-$(date +%F).hdata.gz
```
与网页版**共用同一套代码**(判重、归属改写、补壳、备份完全一致),
`--categories catalog,orders` 可以只搬某几类。
## 接口
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/data/catalog` | 类别与各表行数(界面用) |
| GET | `/api/data/export?categories=catalog,orders` | 导出,`all` 或省略 = 全部 |
| POST | `/api/data/import/preview` | multipart `file`,上传并出预览报告,不写库 |
| POST | `/api/data/import/replan` | `{ id, categories }`,换类别重算预览,不重传 |
| POST | `/api/data/import/apply` | `{ id, categories }`,真导(带备份) |
| DELETE | `/api/data/import/:id` | 取消,丢弃临时包 |
全部要登录 + `data.transfer` 权限;导出与导入都会写 `audit_log`。
## 排查
| 现象 | 原因与处理 |
| --- | --- |
| 上传后 413 / 上传被截断 | nginx(或 CDN)的 `client_max_body_size` 小于包体积。加 `client_max_body_size 100m;` 后 reload;急的话改走命令行通道 |
| 接口 401、页面打不开 | 新代码没部署:`curl -s http://127.0.0.1:4300/api/health` 应返回 `1.2.0` |
| 侧栏没有「数据迁移」 | 权限点只给超管组。用 admin 登录;或在权限组管理里给对应组勾上 `data.transfer` |
| 导入报 `SQLITE_BUSY` | 有别的长事务(例如大合同解析)占着写锁。等几秒重试,或先 `systemctl stop erp` 再用命令行导入 |
| 「数据包已过期」 | 上传的包只保留 24 小时,重新上传即可 |
| 想整体回滚 | `systemctl stop erp` 后用 `backend/data/backups/pre-import-*.db` 覆盖 `trade.db`,并删掉 `trade.db-wal` / `trade.db-shm`,再启动 |
## 限制
- 单包上限 **100 MB / 200 万行**,超过请按类别分开导;
- 上传的包保存在 `DATA_DIR/transfers/`,**24 小时后过期**(每次上传会顺带清理旧包);
- 导入备份 `DATA_DIR/backups/` **不会自动清理**,请自行定期删除;
- 导入是同步执行的(几千行级别毫秒到秒级),包特别大时会占住事件循环。
## 维护:新增业务表时
在 `backend/lib/dataTransfer/catalog.js` 的 `TABLES` 里**按依赖顺序**加一条:
```js
{ table: 'my_table', label: '中文名', category: 'catalog', key: ['业务唯一键'] }
```
- `key`:判重列;没有自然键的流水表改用 `contentKey: true`;
- 父记录允许补壳的表加 `stub: true`(目前只有 customers / suppliers / product_groups);
- 依赖顺序错了会撞外键。`backend/test/data-transfer.test.js` 会校验顺序与系统表是否被误导出。