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