/** * 数据迁移接口(业务数据导入 / 导出)。 * 挂载点:/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