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
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 数据迁移的门面:把「读包、预览、真导、备份」串起来,路由只调这一层。
|
||||
*
|
||||
* 预览为什么要开一个库副本:
|
||||
* 预览必须回答「照这样导,线上会变成什么样」。如果只是把行数点一遍,就答不了
|
||||
* 「哪一行会因为外键或非空约束失败」。所以预览在做一次 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
|
||||
}
|
||||
Reference in New Issue
Block a user