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,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
|
||||
Reference in New Issue
Block a user