From 9c3281e6acbcec1ac192fcf022837c3a18a3c5da Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 18 Mar 2026 16:03:38 +0800 Subject: [PATCH] feat: add utils, error handler and auth middleware --- server/middleware/auth.js | 29 +++++ server/middleware/errorHandler.js | 62 +++++++++++ server/utils/validators.js | 170 ++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 server/middleware/auth.js create mode 100644 server/middleware/errorHandler.js create mode 100644 server/utils/validators.js diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 0000000..35afba5 --- /dev/null +++ b/server/middleware/auth.js @@ -0,0 +1,29 @@ +const jwt = require('jsonwebtoken'); +const { AppError } = require('./errorHandler'); + +const JWT_SECRET = process.env.JWT_SECRET || 'accountbook_secret_key_2024'; + +function authenticate(req, res, next) { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + throw new AppError('请先登录', 401, 'UNAUTHORIZED'); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.userId = decoded.userId; + next(); + } catch (err) { + throw new AppError('登录已过期', 401, 'TOKEN_EXPIRED'); + } +} + +function generateToken(userId) { + return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' }); +} + +module.exports = { + authenticate, + generateToken, + JWT_SECRET, +}; diff --git a/server/middleware/errorHandler.js b/server/middleware/errorHandler.js new file mode 100644 index 0000000..99821e9 --- /dev/null +++ b/server/middleware/errorHandler.js @@ -0,0 +1,62 @@ +class AppError extends Error { + constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') { + super(message); + this.statusCode = statusCode; + this.code = code; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } +} + +function errorHandler(err, req, res, next) { + console.error({ + message: err.message, + stack: err.stack, + url: req.url, + method: req.method, + userId: req.userId, + }); + + if (err instanceof AppError && err.isOperational) { + return res.status(err.statusCode).json({ + error: err.message, + code: err.code, + }); + } + + // 数据库唯一性冲突 + if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { + return res.status(400).json({ + error: '数据已存在', + code: 'DUPLICATE_ENTRY', + }); + } + + // 其他数据库错误 + if (err.code && err.code.startsWith('SQLITE_')) { + return res.status(500).json({ + error: '数据库错误', + code: 'DATABASE_ERROR', + }); + } + + // 默认错误响应 + res.status(500).json({ + error: process.env.NODE_ENV === 'production' + ? '服务器内部错误' + : err.message, + code: 'INTERNAL_ERROR', + }); +} + +function asyncHandler(fn) { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +module.exports = { + AppError, + errorHandler, + asyncHandler, +}; diff --git a/server/utils/validators.js b/server/utils/validators.js new file mode 100644 index 0000000..37c318e --- /dev/null +++ b/server/utils/validators.js @@ -0,0 +1,170 @@ +const VALID_RECORD_TYPES = ['income', 'expense']; +const VALID_STATS_PERIODS = ['week', 'month', 'year']; +const VALID_STATS_TYPES = ['income', 'expense', 'all']; +const MONTH_PATTERN = /^\d{4}-\d{2}$/; +const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2})?$/; + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isValidMonth(value) { + return typeof value === 'string' && MONTH_PATTERN.test(value); +} + +function isValidDateTime(value) { + return typeof value === 'string' && DATE_TIME_PATTERN.test(value); +} + +function normalizeNote(value) { + return value === undefined || value === null ? '' : value; +} + +function validateRecordPayload(payload) { + if (!payload || typeof payload !== 'object') { + return '请求数据格式错误'; + } + + const { type, amount, category, date, note } = payload; + + if (!VALID_RECORD_TYPES.includes(type)) { + return '账目类型错误'; + } + + if (!Number.isFinite(amount) || amount <= 0) { + return '金额需大于0'; + } + + if (!isNonEmptyString(category)) { + return '请填写分类'; + } + + if (!isValidDateTime(date)) { + return '日期格式错误'; + } + + if (note !== undefined && note !== null && typeof note !== 'string') { + return '备注格式错误'; + } + + return null; +} + +function validateRecurringPayload(payload) { + if (!payload || typeof payload !== 'object') { + return '请求数据格式错误'; + } + + const { type, amount, category, day, note, is_active } = payload; + + if (!VALID_RECORD_TYPES.includes(type)) { + return '账单类型错误'; + } + + if (!Number.isFinite(amount) || amount <= 0) { + return '金额需大于0'; + } + + if (!isNonEmptyString(category)) { + return '请填写分类'; + } + + if (!Number.isInteger(day) || day < 1 || day > 28) { + return '日期需在1到28之间'; + } + + if (note !== undefined && note !== null && typeof note !== 'string') { + return '备注格式错误'; + } + + if (is_active !== undefined && ![0, 1, true, false].includes(is_active)) { + return '启用状态错误'; + } + + return null; +} + +function validateCategoryPayload(payload) { + if (!payload || typeof payload !== 'object') { + return '请求数据格式错误'; + } + + const { type, name, icon } = payload; + + if (!VALID_RECORD_TYPES.includes(type)) { + return '分类类型错误'; + } + + if (!isNonEmptyString(name)) { + return '请填写分类名称'; + } + + if (!isNonEmptyString(icon)) { + return '请选择分类图标'; + } + + return null; +} + +function validateImportPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return '导入数据格式错误'; + } + + const { records, recurring, customCategories } = payload; + + if (records !== undefined) { + if (!Array.isArray(records)) { + return 'records 必须是数组'; + } + + for (const record of records) { + const error = validateRecordPayload(record); + if (error) { + return `records 数据错误: ${error}`; + } + } + } + + if (recurring !== undefined) { + if (!Array.isArray(recurring)) { + return 'recurring 必须是数组'; + } + + for (const bill of recurring) { + const error = validateRecurringPayload(bill); + if (error) { + return `recurring 数据错误: ${error}`; + } + } + } + + if (customCategories !== undefined) { + if (!Array.isArray(customCategories)) { + return 'customCategories 必须是数组'; + } + + for (const category of customCategories) { + const error = validateCategoryPayload(category); + if (error) { + return `customCategories 数据错误: ${error}`; + } + } + } + + return null; +} + +module.exports = { + isNonEmptyString, + isValidMonth, + isValidDateTime, + normalizeNote, + validateRecordPayload, + validateRecurringPayload, + validateCategoryPayload, + validateImportPayload, + VALID_RECORD_TYPES, + VALID_STATS_PERIODS, + VALID_STATS_TYPES, +};