Files
happy-export/backend/lib/dataTransfer/index.js
T
Drsakura 1a759ef37d feat(data): 业务数据与系统数据分离的导入导出
数据迁移不再整体替换 trade.db —— 那条路会把账号、权限组、组织、设置(含大模型密钥)、
登录会话与审计日志一起搬到目标部署。改为按类别搬运业务数据,系统数据永远是目标部署自己那份。

- backend/lib/dataTransfer:表目录(30 张业务表,依赖顺序)、gzip JSONL 数据包(每表 sha256)、导入内核
- 归属统一给导入人;冲突跳过不覆盖;父记录按 __refs 自然键快照补壳
- 流水表(price_history)按整行内容判重:用 (sku, changed_at) 当键实测 643 行丢 186 行
- 预览在库副本上真跑一遍导入,报告与真导同源;导入前 VACUUM INTO 备份,单事务写入
- 新权限点 data.transfer(仅超管组),导出/导入均写审计
- 前端「数据迁移」页:按类别勾选导出、上传后预览逐表明细、确认导入;移除设置里的静态占位「数据交换」
- 测试:单测 7 项(含目录外键顺序、BLOB、重复导入、包被改坏)、API 端到端 1 项(两个真实实例互传)
- 用真实库副本彩排:3854 行 0 失败,29 张业务表行数全部一致,包 1.22 MB
2026-10-23 13:46:26 +08:00

140 lines
5.7 KiB
JavaScript

/**
* 数据迁移的门面:把「读包、预览、真导、备份」串起来,路由只调这一层。
*
* 预览为什么要开一个库副本:
* 预览必须回答「照这样导,线上会变成什么样」。如果只是把行数点一遍,就答不了
* 「哪一行会因为外键或非空约束失败」。所以预览在做一次 VACUUM INTO 出来的副本上
* **真跑一遍导入**,跑完把副本删掉 —— 报告里的数字与实际导入完全同源。
* 库只有几 MB,拷一份比维护两套判断逻辑便宜得多。
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3-multiple-ciphers')
const { CATEGORY_LIST, TABLES, resolveCategories } = require('./catalog')
const { writeBundle, readBundle, tableExists } = require('./bundle')
const { importInto } = require('./import')
const escapePath = file => String(file).replace(/'/g, "''")
/** 本机各类别各表当前行数(界面用来显示「这次会导出多少」) */
function listCatalog(db) {
return CATEGORY_LIST.map(category => ({
key: category.key,
label: category.label,
description: category.description,
tables: TABLES.filter(spec => spec.category === category.key).map(spec => {
let rows = 0
if (tableExists(db, spec.table)) {
try { rows = db.prepare(`SELECT COUNT(*) AS n FROM "${spec.table}"`).get().n } catch { rows = 0 }
}
return { table: spec.table, label: spec.label, rows }
})
}))
}
/** 导出到可写流(HTTP 响应直接接) */
async function exportToStream(db, output, options) {
await writeBundle(db, output, options)
}
/** 导出的文件名:带时间戳,便于在服务器上分辨先后 */
function exportFileName(date = new Date()) {
const pad = value => String(value).padStart(2, '0')
const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
return `happy-export-${stamp}.hdata.gz`
}
function readFileMeta(bundle, filePath, fileName) {
return {
name: fileName || path.basename(filePath),
size: bundle.size,
exportedAt: bundle.header.exportedAt,
exporter: bundle.header.exporter || null,
app: bundle.header.app,
version: bundle.header.version,
categories: bundle.categories
}
}
/**
* 预览:在库副本上真跑一遍导入,返回与实际导入同源的报告。
* 副本用完即删,线上库不受任何影响。
*/
async function previewImport(sourceDb, filePath, { categories, user, workDir, fileName }) {
fs.mkdirSync(workDir, { recursive: true })
const bundle = await readBundle(filePath)
const selected = resolveCategories(categories && categories.length ? categories : bundle.categories)
const available = selected.filter(key => bundle.categories.includes(key))
if (!available.length) {
const error = new Error('所选类别在数据包里没有内容,无法导入')
error.statusCode = 400
throw error
}
const copyPath = path.join(workDir, `preview-${crypto.randomBytes(6).toString('hex')}.db`)
if (fs.existsSync(copyPath)) fs.unlinkSync(copyPath)
sourceDb.exec(`VACUUM INTO '${escapePath(copyPath)}'`)
const copy = new Database(copyPath)
try {
copy.pragma('foreign_keys = ON')
copy.pragma('busy_timeout = 5000')
const plan = copy.transaction(() => importInto(copy, bundle, { categories: available, user }))()
return { file: readFileMeta(bundle, filePath, fileName), plan, availableCategories: available }
} finally {
try { copy.close() } catch { /* 副本关闭失败不影响结果 */ }
try { fs.unlinkSync(copyPath) } catch { /* 副本已被占用就留着,下次预览会覆盖 */ }
}
}
/**
* 真正导入:先给线上库做一份完整备份(留档可回滚),再在一个事务里写入。
* 任何未预期的异常都会整体回滚,备份文件保留在 backupDir。
*/
async function applyImport(db, filePath, { categories, user, backupDir, fileName }) {
fs.mkdirSync(backupDir, { recursive: true })
const bundle = await readBundle(filePath)
const selected = resolveCategories(categories && categories.length ? categories : bundle.categories)
const available = selected.filter(key => bundle.categories.includes(key))
if (!available.length) {
const error = new Error('所选类别在数据包里没有内容,无法导入')
error.statusCode = 400
throw error
}
/* 时间戳精确到秒,再拼一段随机后缀:同一秒内连续导两次也不会撞出
「output file already exists」,而文件名照样按时间可排序。 */
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)
const backupPath = path.join(backupDir, `pre-import-${stamp}-${crypto.randomBytes(3).toString('hex')}.db`)
db.exec(`VACUUM INTO '${escapePath(backupPath)}'`)
const plan = db.transaction(() => importInto(db, bundle, { categories: available, user }))()
return { file: readFileMeta(bundle, filePath, fileName), plan, backup: backupPath, availableCategories: available }
}
/** 清理过期的上传包与预览副本(默认 24 小时) */
function cleanupUploads(dir, { maxAgeMs = 24 * 60 * 60 * 1000 } = {}) {
let removed = 0
try {
for (const name of fs.readdirSync(dir)) {
const file = path.join(dir, name)
try {
const stat = fs.statSync(file)
if (Date.now() - stat.mtimeMs > maxAgeMs) { fs.unlinkSync(file); removed++ }
} catch { /* 单个文件失败不影响其它清理 */ }
}
} catch { /* 目录还不存在 */ }
return removed
}
module.exports = {
listCatalog,
exportToStream,
exportFileName,
previewImport,
applyImport,
cleanupUploads
}