Files
happy-export/docs/data-transfer.md
T
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

9.3 KiB
Raw Permalink Blame History

业务数据导入导出(数据迁移)

对应 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

服务器部署

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:

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 里按依赖顺序加一条:

{ table: 'my_table', label: '中文名', category: 'catalog', key: ['业务唯一键'] }
  • key:判重列;没有自然键的流水表改用 contentKey: true;
  • 父记录允许补壳的表加 stub: true(目前只有 customers / suppliers / product_groups);
  • 依赖顺序错了会撞外键。backend/test/data-transfer.test.js 会校验顺序与系统表是否被误导出。