diff --git a/backend/lib/dataTransfer/bundle.js b/backend/lib/dataTransfer/bundle.js index dd4a62b..d778e49 100644 --- a/backend/lib/dataTransfer/bundle.js +++ b/backend/lib/dataTransfer/bundle.js @@ -217,6 +217,22 @@ function openMaybeGzip(filePath) { return fs.createReadStream(filePath).pipe(isGzip ? zlib.createGunzip() : new (require('stream').PassThrough)()) } +/** + * 逐行吐出,并在**任何**退出路径上关掉底层流。 + * + * 校验失败时 readBundle 会中途抛错,如果不在这里兜住,文件句柄就跟着异常一起漏掉 —— + * 服务器上反复上传坏包会把 fd 耗光(排查起来极难,因为错误信息只有「数据包损坏」)。 + * 包在外层 for-await 里的生成器,在 break / throw / 正常结束三种情况下都会走到 finally。 + */ +async function* linesOf(reader, input) { + try { + for await (const line of reader) yield line + } finally { + try { reader.close() } catch { /* 已经关了 */ } + try { input.destroy() } catch { /* 已经释放 */ } + } +} + /** * 读取并校验数据包。 * @@ -241,7 +257,7 @@ async function readBundle(filePath) { const counts = new Map() let lineNo = 0 - for await (const raw of reader) { + for await (const raw of linesOf(reader, input)) { const line = raw.trim() if (!line) continue lineNo++ diff --git a/backend/scripts/data-transfer.js b/backend/scripts/data-transfer.js new file mode 100644 index 0000000..013141d --- /dev/null +++ b/backend/scripts/data-transfer.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * 命令行版数据迁移 —— 网页「数据迁移」的备用通道。 + * + * 什么时候用它: + * · 服务器的 nginx / CDN 限制了上传体积,1 MB 以上的包传不上去; + * · 想用 scp 直接把包丢到服务器再导,不经过浏览器; + * · 定期把业务数据导出来做异地备份(配合 cron)。 + * + * 与网页版共用 backend/lib/dataTransfer 的同一套代码:同样的判重、归属改写、补壳与备份。 + * + * 用法(在 backend/ 目录下执行,Node ≥ 22): + * node scripts/data-transfer.js export --out ./data.hdata.gz [--categories catalog,orders] + * node scripts/data-transfer.js import ./data.hdata.gz --preview [--categories catalog] + * node scripts/data-transfer.js import ./data.hdata.gz --apply [--user admin] [--categories catalog] + * node scripts/data-transfer.js tables + * + * 环境变量 DATA_DIR 与网页版一致(默认 backend/data)。 + */ +const fs = require('fs') +const os = require('os') +const path = require('path') + +const args = process.argv.slice(2) +const command = args[0] + +function flag(name) { + const index = args.indexOf(`--${name}`) + if (index < 0) return undefined + const value = args[index + 1] + return !value || value.startsWith('--') ? true : value +} + +function positional() { + return args.slice(1).filter((item, index, list) => !item.startsWith('--') && !(list[index - 1] || '').startsWith('--')) +} + +function usage(message) { + if (message) console.error(`✗ ${message}\n`) + console.error(`用法: + node scripts/data-transfer.js tables + node scripts/data-transfer.js export --out <文件.hdata.gz> [--categories catalog,customers,orders,todos,prospects] + node scripts/data-transfer.js import <文件.hdata.gz> --preview [--categories …] + node scripts/data-transfer.js import <文件.hdata.gz> --apply [--user admin] [--categories …]`) + process.exit(message ? 1 : 0) +} + +if (!command || ['-h', '--help', 'help'].includes(command)) usage() + +const db = require('../db/db') +const transfer = require('../lib/dataTransfer') +const { readBundle } = require('../lib/dataTransfer/bundle') +const { CATEGORY_LIST } = require('../lib/dataTransfer/catalog') + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data') +const TRANSFER_DIR = path.join(DATA_DIR, 'transfers') +const BACKUP_DIR = path.join(DATA_DIR, 'backups') + +function defaultUser() { + const username = flag('user') + const row = username + ? db.prepare('SELECT * FROM users WHERE username = ?').get(String(username)) + : db.prepare("SELECT * FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get() + if (!row) { + console.error(username ? `✗ 找不到用户 ${username}` : '✗ 数据库里没有 admin 账号') + process.exit(1) + } + return row +} + +function categories() { + const value = flag('categories') + return value && value !== true ? String(value).split(',').map(item => item.trim()).filter(Boolean) : [] +} + +function printPlan(plan) { + console.log('') + console.log('表名 包内 新增 跳过 失败') + for (const item of plan.tables) { + console.log(`${item.label.padEnd(26)}${String(item.rows).padStart(6)}${String(item.insert).padStart(7)}${String(item.skip).padStart(7)}${String(item.failed).padStart(7)}`) + } + console.log('') + console.log(`合计:包内 ${plan.totals.rows} → 新增 ${plan.totals.insert} / 跳过 ${plan.totals.skip} / 失败 ${plan.totals.failed}`) + if (plan.stubbed.length) { + console.log(`自动补建:${plan.stubbed.map(item => `${item.label} ${item.count} 条(只有名称,请补全)`).join('、')}`) + } + const failures = plan.tables.filter(item => item.failed) + if (failures.length) { + console.log('失败明细:') + for (const item of failures) for (const warning of item.warnings) console.log(` ${item.label}: ${warning}`) + } + if (plan.missing.length) console.log(`目标库缺少表:${plan.missing.join('、')}`) +} + +;(async () => { + if (command === 'tables') { + for (const category of transfer.listCatalog(db)) { + const rows = category.tables.reduce((sum, item) => sum + item.rows, 0) + console.log(`${category.key.padEnd(10)}${category.label}(${rows} 行)`) + for (const item of category.tables) console.log(` ${item.table.padEnd(24)}${item.label.padEnd(18)}${item.rows}`) + } + return + } + + if (command === 'export') { + const out = flag('out') + if (!out || out === true) usage('导出需要 --out <文件路径>') + const target = path.resolve(String(out)) + fs.mkdirSync(path.dirname(target), { recursive: true }) + const started = Date.now() + await transfer.exportToStream(db, fs.createWriteStream(target), { + categories: categories(), + exporter: defaultUser() + }) + const size = fs.statSync(target).size + console.log(`✓ 已导出 ${target}(${(size / 1024 / 1024).toFixed(2)} MB,${Date.now() - started}ms)`) + console.log(' 包内不含用户、权限组、组织、设置(含 AI 密钥)、登录会话与审计日志。') + return + } + + if (command === 'import') { + const file = positional()[0] || flag('file') + if (!file || file === true) usage('导入需要数据包路径') + const source = path.resolve(String(file)) + if (!fs.existsSync(source)) usage(`找不到数据包:${source}`) + const user = defaultUser() + + if (flag('preview')) { + /* 预览在库副本上真跑一遍,与网页版同源;线上库不受影响 */ + const preview = await transfer.previewImport(db, source, { + categories: categories(), + user, + workDir: TRANSFER_DIR, + fileName: path.basename(source) + }) + console.log(`数据包:${preview.file.name} · ${(preview.file.size / 1024 / 1024).toFixed(2)} MB · 导出于 ${preview.file.exportedAt}`) + printPlan(preview.plan) + console.log('\n这只是预览,线上库没有变化。确认无误后加 --apply 执行导入。') + return + } + + if (flag('apply')) { + const result = await transfer.applyImport(db, source, { + categories: categories(), + user, + backupDir: BACKUP_DIR, + fileName: path.basename(source) + }) + console.log(`数据包:${result.file.name} · 归属给 ${user.username}`) + printPlan(result.plan) + console.log(`\n✓ 导入完成。导入前备份:${result.backup}`) + return + } + + usage('导入需要 --preview(只看报告)或 --apply(真正写入)') + } + + usage(`未知命令:${command}`) +})().catch(error => { + console.error(`✗ ${error.message}`) + process.exitCode = 1 +}) diff --git a/docs/data-transfer.md b/docs/data-transfer.md index 2f8f1f2..539e3db 100644 --- a/docs/data-transfer.md +++ b/docs/data-transfer.md @@ -94,6 +94,30 @@ sleep 3 && curl -s http://127.0.0.1:4300/api/health 反向也成立:云端 → 本地同样走这一页,包是同一个格式。 +## 命令行(备用通道) + +浏览器上传被 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` 可以只搬某几类。 + ## 接口 | 方法 | 路径 | 说明 |