const express = require('express'); const db = require('../db'); const config = require('../config'); const logger = require('../utils/logger'); const { parseCSVLine, AppError, validateNumber, validateInList, validateIsoDate, optionalString } = require('../utils/helpers'); const router = express.Router(); function emptyToNull(value) { return value === undefined || value === null || value === '' ? null : value; } function nullableDbValue(value) { return value === undefined || value === null || value === '' ? null : value; } // 导出数据 router.get('/export', (req, res, next) => { try { const format = req.query.format; if (!format || !['json', 'csv'].includes(format)) { throw new AppError('请指定格式: json 或 csv', 400); } const records = db.prepare( 'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC' ).all(req.user.id); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const filename = `gamer-export-${timestamp}`; if (format === 'json') { const exportData = { exportVersion: 1, exportedAt: new Date().toISOString(), username: req.user.username, recordCount: records.length, records }; logger.info(`用户 ${req.user.username} 导出 JSON 数据: ${records.length} 条`); res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`); return res.send(JSON.stringify(exportData, null, 2)); } // CSV with UTF-8 BOM const BOM = '\uFEFF'; const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']; const csvRows = records.map(r => { return cols.map(col => { const val = r[col]; if (val === null || val === undefined) return ''; const str = String(val); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; }).join(','); }); logger.info(`用户 ${req.user.username} 导出 CSV 数据: ${records.length} 条`); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`); res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n')); } catch (err) { next(err); } }); // 导入数据 router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, next) => { try { const raw = req.body; if (!raw || typeof raw !== 'string') { throw new AppError('未收到文件内容', 400); } let records = []; const requiredFields = ['type', 'category', 'amount', 'created_at']; const allFields = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']; const trimmed = raw.replace(/^\uFEFF/, '').trim(); if (trimmed.startsWith('{')) { const data = JSON.parse(trimmed); if (!data.exportVersion || !Array.isArray(data.records)) { throw new AppError('无效的JSON导出文件格式', 400); } records = data.records; } else { const lines = trimmed.split('\n').map(l => l.replace(/\r$/, '')); if (lines.length < 2) { throw new AppError('CSV文件为空或格式不正确', 400); } if (lines[0] !== allFields.join(',')) { throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400); } const csvHeaders = lines[0].split(','); for (let i = 1; i < lines.length; i++) { if (!lines[i].trim()) continue; const values = parseCSVLine(lines[i]); const record = {}; csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); }); if (record.amount !== null) record.amount = parseFloat(record.amount); if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10); if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price); if (record.rebate !== null) record.rebate = parseFloat(record.rebate); records.push(record); } } if (records.length > config.import.maxRecords) { throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400); } const allCategories = [...config.categories.income, ...config.categories.expense]; const normalizedRecords = records.map((r, index) => { for (const f of requiredFields) { if (!r[f] && r[f] !== 0) { throw new AppError(`第 ${index + 1} 条记录缺少必填字段: ${f}`, 400); } } validateInList(r.type, 'type', ['income', 'expense']); validateInList(r.category, 'category', allCategories); const amount = r.category === '派单' ? validateNumber(r.amount, '金额') : validateNumber(r.amount, '金额', 0); return { type: r.type, category: r.category, amount, quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0), unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0), rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'), boss: optionalString(r.boss, '老板', 100), partner: optionalString(r.partner, '陪玩', 100), source: optionalString(r.source, '来源', 100), destination: optionalString(r.destination, '去向', 100), note: optionalString(r.note, '备注', 500), created_at: validateIsoDate(r.created_at, '创建时间') }; }); const existingCheck = db.prepare(` SELECT id FROM records WHERE user_id = ? AND type = ? AND category = ? AND amount = ? AND COALESCE(quantity, '') = COALESCE(?, '') AND COALESCE(unit_price, '') = COALESCE(?, '') AND COALESCE(rebate, '') = COALESCE(?, '') AND COALESCE(boss, '') = COALESCE(?, '') AND COALESCE(partner, '') = COALESCE(?, '') AND COALESCE(source, '') = COALESCE(?, '') AND COALESCE(destination, '') = COALESCE(?, '') AND COALESCE(note, '') = COALESCE(?, '') AND created_at = ? `); const insertStmt = db.prepare(` INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); let imported = 0, skipped = 0; const insertMany = db.transaction((recs) => { for (const r of recs) { const values = [ req.user.id, r.type, r.category, r.amount, r.quantity, r.unit_price, r.rebate, r.boss, r.partner, r.source, r.destination, r.note, r.created_at ]; if (existingCheck.get(...values)) { skipped++; continue; } insertStmt.run(req.user.id, r.type, r.category, r.amount, nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate), nullableDbValue(r.boss), nullableDbValue(r.partner), nullableDbValue(r.source), nullableDbValue(r.destination), nullableDbValue(r.note), r.created_at); imported++; } }); insertMany(normalizedRecords); logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`); res.json({ imported, skipped, total: normalizedRecords.length }); } catch (err) { if (err instanceof SyntaxError) { next(new AppError('文件解析失败: ' + err.message, 400)); } else { next(err); } } }); module.exports = router;