feat(data): 命令行导入导出通道 + 修上传解析的句柄泄漏
- scripts/data-transfer.js:tables / export / import --preview|--apply,与网页版共用同一套内核。 用于 nginx/CDN 挡住 1.22MB 上传时的备用通道,也可挂 cron 做异地备份。 - readBundle:校验失败中途抛错时关闭 readStream 与 readline,避免反复上传坏包耗尽文件句柄。 - docs:补命令行用法与体积限制说明。
This commit is contained in:
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user