refactor: 系统优化 - 错误处理、输入验证、无限滚动

主要改进:
- 新增配置管理 (server/config.js)
- 新增日志工具 (server/utils/logger.js)
- 添加全局错误处理中间件,统一错误响应格式
- 添加输入验证和 AppError 错误类
- 重构 db.js 迁移逻辑,代码更清晰
- 前端列表改为无限滚动加载,每页 20 条
- 封装 AppState 状态管理模块

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lizhilun
2026-03-26 10:39:58 +08:00
co-authored by Claude Opus 4.6
parent 19e5a48f07
commit d551841035
12 changed files with 789 additions and 350 deletions
+64 -53
View File
@@ -1,61 +1,68 @@
const express = require('express');
const db = require('../db');
const { parseCSVLine } = require('../utils/helpers');
const logger = require('../utils/logger');
const { parseCSVLine, AppError } = require('../utils/helpers');
const router = express.Router();
// 导出数据
router.get('/export', (req, res) => {
const format = req.query.format;
if (!format || !['json', 'csv'].includes(format)) {
return res.status(400).json({ error: '请指定格式: json 或 csv' });
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);
}
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
};
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(',');
});
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'));
});
// 导入数据
router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) => {
router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, next) => {
try {
const raw = req.body;
if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: '未收到文件内容' });
throw new AppError('未收到文件内容', 400);
}
let records = [];
@@ -66,16 +73,16 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
if (trimmed.startsWith('{')) {
const data = JSON.parse(trimmed);
if (!data.exportVersion || !Array.isArray(data.records)) {
return res.status(400).json({ error: '无效的JSON导出文件格式' });
throw new AppError('无效的JSON导出文件格式', 400);
}
records = data.records;
} else {
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
if (lines.length < 2) {
return res.status(400).json({ error: 'CSV文件为空或格式不正确' });
throw new AppError('CSV文件为空或格式不正确', 400);
}
if (lines[0] !== allFields.join(',')) {
return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' });
throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400);
}
const csvHeaders = lines[0].split(',');
for (let i = 1; i < lines.length; i++) {
@@ -94,11 +101,11 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
for (const r of records) {
for (const f of requiredFields) {
if (!r[f] && r[f] !== 0) {
return res.status(400).json({ error: `记录缺少必填字段: ${f}` });
throw new AppError(`记录缺少必填字段: ${f}`, 400);
}
}
if (!['income', 'expense'].includes(r.type)) {
return res.status(400).json({ error: `无效的类型: ${r.type}` });
throw new AppError(`无效的类型: ${r.type}`, 400);
}
}
@@ -126,10 +133,14 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
});
insertMany(records);
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped}`);
res.json({ imported, skipped, total: records.length });
} catch (e) {
console.error('Import error:', e);
res.status(400).json({ error: '文件解析失败: ' + e.message });
} catch (err) {
if (err instanceof SyntaxError) {
next(new AppError('文件解析失败: ' + err.message, 400));
} else {
next(err);
}
}
});