Files
happy-export/backend/routes/dataTransfer.js
T
Drsakura ee7550c890 fix(data): 清理临时目录时跳过子目录,并回收 multer 残片
transfers/ 下面还套着 multer 的 uploads/ 子目录;原来只清理一层,目录本身会被当成过期文件去 unlink(失败但刷日志),
而 uploads/ 里因体积超限被丢弃的残片又永远不会被清。现在只删普通文件,并两处都清。
2026-10-23 13:52:14 +08:00

225 lines
8.2 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)
/* multer 落盘后因体积/字段错误被丢弃的残片留在 uploads/,一并清 */
transfer.cleanupUploads(UPLOAD_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