Files
gamer/server/routes/io.js
T

213 lines
8.0 KiB
JavaScript
Raw Normal View History

2026-03-12 11:23:10 +08:00
const express = require('express');
const db = require('../db');
2026-07-01 16:13:47 +08:00
const config = require('../config');
const logger = require('../utils/logger');
2026-07-01 16:13:47 +08:00
const {
parseCSVLine,
AppError,
validateNumber,
validateInList,
validateIsoDate,
optionalString
} = require('../utils/helpers');
2026-03-12 11:23:10 +08:00
const router = express.Router();
2026-07-01 16:13:47 +08:00
function emptyToNull(value) {
return value === undefined || value === null || value === '' ? null : value;
}
function nullableDbValue(value) {
return value === undefined || value === null || value === '' ? null : value;
}
2026-03-12 11:23:10 +08:00
// 导出数据
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);
2026-03-12 11:23:10 +08:00
}
});
// 导入数据
router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, next) => {
2026-03-12 11:23:10 +08:00
try {
const raw = req.body;
if (!raw || typeof raw !== 'string') {
throw new AppError('未收到文件内容', 400);
2026-03-12 11:23:10 +08:00
}
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);
2026-03-12 11:23:10 +08:00
}
records = data.records;
} else {
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
if (lines.length < 2) {
throw new AppError('CSV文件为空或格式不正确', 400);
2026-03-12 11:23:10 +08:00
}
if (lines[0] !== allFields.join(',')) {
throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400);
2026-03-12 11:23:10 +08:00
}
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 = {};
2026-07-01 16:13:47 +08:00
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);
2026-03-12 11:23:10 +08:00
records.push(record);
}
}
2026-07-01 16:13:47 +08:00
if (records.length > config.import.maxRecords) {
throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
2026-03-12 11:23:10 +08:00
}
2026-07-01 16:13:47 +08:00
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 = ?
`);
2026-03-12 11:23:10 +08:00
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) {
2026-07-01 16:13:47 +08:00
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)) {
2026-03-12 11:23:10 +08:00
skipped++; continue;
}
insertStmt.run(req.user.id, r.type, r.category, r.amount,
2026-07-01 16:13:47 +08:00
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);
2026-03-12 11:23:10 +08:00
imported++;
}
});
2026-07-01 16:13:47 +08:00
insertMany(normalizedRecords);
2026-03-12 11:23:10 +08:00
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`);
2026-07-01 16:13:47 +08:00
res.json({ imported, skipped, total: normalizedRecords.length });
} catch (err) {
if (err instanceof SyntaxError) {
next(new AppError('文件解析失败: ' + err.message, 400));
} else {
next(err);
}
2026-03-12 11:23:10 +08:00
}
});
module.exports = router;