Files
happy-export/backend/routes/dataTransfer.js
T
Drsakura 1a759ef37d 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
2026-10-23 13:46:26 +08:00

223 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 数据迁移接口(业务数据导入 / 导出)。
* 挂载点:/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