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:
Drsakura
2026-10-23 13:46:26 +08:00
parent 1ffcc1ea07
commit 1a759ef37d
17 changed files with 2516 additions and 19 deletions
+222
View File
@@ -0,0 +1,222 @@
/**
* 数据迁移接口(业务数据导入 / 导出)。
* 挂载点:/api/data
*
* 与「系统更新」的区别:这里动的是**业务数据**,不碰用户、权限、组织、设置与日志。
* 权限点 data.transfer 只授予超级管理员组 —— 一次导入能把几千条商品写进库里,
* 不该出现在业务员的菜单里。
*/
const express = require('express')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const multer = require('multer')
const db = require('../db/db')
const auth = require('../lib/auth')
const transfer = require('../lib/dataTransfer')
const { MAX_BUNDLE_BYTES } = require('../lib/dataTransfer/bundle')
const { resolveCategories } = require('../lib/dataTransfer/catalog')
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data')
const TRANSFER_DIR = path.join(DATA_DIR, 'transfers')
const UPLOAD_DIR = path.join(TRANSFER_DIR, 'uploads')
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
const router = express.Router()
const guard = auth.requirePermission('data.transfer')
const upload = multer({
dest: UPLOAD_DIR,
limits: { fileSize: MAX_BUNDLE_BYTES, files: 1 }
})
function safeUnlink(file) {
try { fs.unlinkSync(file) } catch { /* 已不存在 */ }
}
/** 请求里的类别既可能是 "a,b",也可能是 ["a","b"](表单或 JSON 都兼容) */
function parseCategories(input) {
if (Array.isArray(input)) return input
if (typeof input === 'string' && input.trim()) {
try {
const parsed = JSON.parse(input)
if (Array.isArray(parsed)) return parsed
} catch { /* 不是 JSON 就按逗号分隔 */ }
return input.split(',').map(item => item.trim()).filter(Boolean)
}
return []
}
/** multipart 里的中文文件名是裸字节,按 UTF-8 重解一次(与 server.js 同一套判断) */
function decodeOriginalName(name) {
const raw = String(name || '')
if (!raw || !/[\u0080-\u00ff]/.test(raw)) return raw
const restored = Buffer.from(raw, 'latin1').toString('utf8')
return restored.includes('\ufffd') ? raw : restored
}
/* ------------------------------------------------------------------ 目录 */
/** GET /api/data/catalog —— 各类别与各表在本机的行数,界面用 */
router.get('/catalog', guard, (req, res) => {
try {
res.setHeader('Cache-Control', 'no-store')
res.json({
categories: transfer.listCatalog(db),
limits: { maxBytes: MAX_BUNDLE_BYTES }
})
} catch (error) {
res.status(500).json({ error: error.message })
}
})
/* ------------------------------------------------------------------ 导出 */
/** GET /api/data/export?categories=catalog,orders(省略或 all = 全部业务数据) */
router.get('/export', guard, async (req, res) => {
let categories
try {
categories = resolveCategories(req.query.categories)
} catch (error) {
return res.status(error.statusCode || 400).json({ error: error.message })
}
const fileName = transfer.exportFileName()
res.setHeader('Content-Type', 'application/gzip')
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`)
res.setHeader('Cache-Control', 'no-store')
try {
await transfer.exportToStream(db, res, { categories, exporter: req.user })
auth.audit({
username: req.user.username,
action: 'data_export',
detail: `导出业务数据:${categories.join('、')}(${fileName})`,
req
})
} catch (error) {
/* 已经开始下发就只能断开 —— 让浏览器看到传输失败,而不是拿到一个半截包当成功 */
if (!res.headersSent) res.status(500).json({ error: error.message })
else res.destroy()
}
})
/* ------------------------------------------------------------------ 导入 */
/**
* POST /api/data/import/preview —— 上传数据包并出预览报告(不写线上库)
* 表单:file(数据包)、categories(可选,逗号分隔或 JSON 数组)
*/
router.post('/import/preview', guard, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: '请选择要导入的数据包(.hdata.gz)' })
const id = crypto.randomBytes(12).toString('hex')
const target = path.join(TRANSFER_DIR, `${id}.hdata`)
const fileName = decodeOriginalName(req.file.originalname)
try {
fs.renameSync(req.file.path, target)
/* 顺手清掉 24 小时前的旧包,避免上传目录无限增长(本次刚移入的 mtime 是现在,不会被删) */
transfer.cleanupUploads(TRANSFER_DIR)
const categories = parseCategories(req.body?.categories)
const preview = await transfer.previewImport(db, target, {
categories,
user: req.user,
workDir: TRANSFER_DIR,
fileName
})
res.json({ id, fileName, ...preview })
} catch (error) {
safeUnlink(target)
safeUnlink(req.file.path)
res.status(error.statusCode || 400).json({ error: error.message })
}
})
/**
* POST /api/data/import/replan —— 用已上传的包按新类别重算预览(不重新上传)
* 请求体:{ id, categories, fileName? }
*/
router.post('/import/replan', guard, async (req, res) => {
const id = String(req.body?.id || '')
if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '导入任务标识无效,请重新上传数据包' })
const file = path.join(TRANSFER_DIR, `${id}.hdata`)
if (!fs.existsSync(file)) {
return res.status(404).json({ error: '数据包已过期(超过 24 小时),请重新上传后再预览一次' })
}
try {
const preview = await transfer.previewImport(db, file, {
categories: parseCategories(req.body?.categories),
user: req.user,
workDir: TRANSFER_DIR,
fileName: req.body?.fileName ? decodeOriginalName(req.body.fileName) : undefined
})
res.json({ id, fileName: req.body?.fileName, ...preview })
} catch (error) {
res.status(error.statusCode || 400).json({ error: error.message })
}
})
/**
* POST /api/data/import/apply —— 按预览过的数据包真导
* 请求体:{ id, categories? }
*/
router.post('/import/apply', guard, async (req, res) => {
const id = String(req.body?.id || '')
if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '导入任务标识无效,请重新上传数据包' })
const file = path.join(TRANSFER_DIR, `${id}.hdata`)
if (!fs.existsSync(file)) {
return res.status(404).json({ error: '数据包已过期(超过 24 小时),请重新上传后再预览一次' })
}
try {
const categories = parseCategories(req.body?.categories)
const result = await transfer.applyImport(db, file, {
categories,
user: req.user,
backupDir: BACKUP_DIR
})
const summary = result.plan.tables
.filter(item => item.insert || item.skip || item.failed)
.map(item => `${item.label} 新增 ${item.insert} / 跳过 ${item.skip}${item.failed ? ` / 失败 ${item.failed}` : ''}`)
.join(',')
auth.audit({
username: req.user.username,
action: 'data_import',
detail: `导入业务数据:${result.plan.categories.map(item => item.label).join('、')}(${summary});备份 ${path.basename(result.backup)}`,
req
})
safeUnlink(file)
res.json({ fileName: result.file.name, ...result })
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message })
}
})
/** DELETE /api/data/import/:id —— 用户取消时丢掉临时包 */
router.delete('/import/:id', guard, (req, res) => {
const id = String(req.params.id || '')
if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '标识无效' })
safeUnlink(path.join(TRANSFER_DIR, `${id}.hdata`))
res.json({ ok: true })
})
/* multer 的体积/文件数错误要变成可读提示,不能落成 500 */
router.use((error, req, res, next) => {
if (!error) return next()
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: `数据包超过 ${MAX_BUNDLE_BYTES / 1024 / 1024} MB 上限,请按类别分开导出后导入` })
}
return res.status(error.statusCode || 400).json({ error: error.message })
})
module.exports = router