transfers/ 下面还套着 multer 的 uploads/ 子目录;原来只清理一层,目录本身会被当成过期文件去 unlink(失败但刷日志), 而 uploads/ 里因体积超限被丢弃的残片又永远不会被清。现在只删普通文件,并两处都清。
142 lines
5.8 KiB
JavaScript
142 lines
5.8 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)
|
|
/* 只删普通文件:目录里还套着 multer 的 uploads/,别把目录本身当过期文件删 */
|
|
if (!stat.isFile()) continue
|
|
if (Date.now() - stat.mtimeMs > maxAgeMs) { fs.unlinkSync(file); removed++ }
|
|
} catch { /* 单个文件失败不影响其它清理 */ }
|
|
}
|
|
} catch { /* 目录还不存在 */ }
|
|
return removed
|
|
}
|
|
|
|
module.exports = {
|
|
listCatalog,
|
|
exportToStream,
|
|
exportFileName,
|
|
previewImport,
|
|
applyImport,
|
|
cleanupUploads
|
|
}
|