数据迁移不再整体替换 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
140 lines
5.7 KiB
JavaScript
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
|
|
}
|