diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..41e8617 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,61 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const db = require('../config/database'); +const { generateToken } = require('../middleware/auth'); +const { asyncHandler, AppError } = require('../middleware/errorHandler'); + +const router = express.Router(); + +// 注册 +router.post('/register', asyncHandler(async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + throw new AppError('请填写用户名和密码', 400, 'MISSING_FIELDS'); + } + + if (username.length < 3 || username.length > 20) { + throw new AppError('用户名需3-20个字符', 400, 'INVALID_USERNAME'); + } + + if (password.length < 6 || password.length > 20) { + throw new AppError('密码需6-20个字符', 400, 'INVALID_PASSWORD'); + } + + const hashedPassword = await bcrypt.hash(password, 10); + const stmt = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)'); + const result = stmt.run(username, hashedPassword); + + const token = generateToken(result.lastInsertRowid); + console.log(`User registered: ${username}`); + + res.json({ token, username }); +})); + +// 登录 +router.post('/login', asyncHandler(async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + throw new AppError('请填写用户名和密码', 400, 'MISSING_FIELDS'); + } + + const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); + const user = stmt.get(username); + + if (!user) { + throw new AppError('用户名或密码错误', 400, 'INVALID_CREDENTIALS'); + } + + const validPassword = await bcrypt.compare(password, user.password); + if (!validPassword) { + throw new AppError('用户名或密码错误', 400, 'INVALID_CREDENTIALS'); + } + + const token = generateToken(user.id); + console.log(`User logged in: ${username}`); + + res.json({ token, username: user.username }); +})); + +module.exports = router; diff --git a/server/routes/categories.js b/server/routes/categories.js new file mode 100644 index 0000000..7de1b57 --- /dev/null +++ b/server/routes/categories.js @@ -0,0 +1,98 @@ +const express = require('express'); +const db = require('../config/database'); +const { authenticate } = require('../middleware/auth'); +const { asyncHandler, AppError } = require('../middleware/errorHandler'); +const { validateCategoryPayload } = require('../utils/validators'); + +const router = express.Router(); + +const defaultCategories = { + expense: [ + { name: '餐饮', icon: '🍜' }, + { name: '买菜', icon: '🥬' }, + { name: '交通', icon: '🚗' }, + { name: '购物', icon: '🛒' }, + { name: '娱乐', icon: '🎮' }, + { name: '房租', icon: '🏠' }, + { name: '房贷', icon: '🏦' }, + { name: '水电', icon: '💧' }, + { name: '衣服', icon: '👔' }, + { name: '通讯', icon: '📱' }, + { name: '日用品', icon: '🧴' }, + { name: '美妆', icon: '💄' }, + { name: '医疗', icon: '🏥' }, + { name: '养娃', icon: '👶' }, + { name: '家电', icon: '📺' }, + { name: '家具', icon: '🛋️' }, + { name: '装修', icon: '🔨' }, + { name: '宠物', icon: '🐕' }, + { name: '其他', icon: '📝' }, + ], + income: [ + { name: '工资', icon: '💰' }, + { name: '奖金', icon: '🎁' }, + { name: '理财', icon: '📈' }, + { name: '红包', icon: '🧧' }, + { name: '其他', icon: '📝' }, + ], +}; + +// 获取分类列表 +router.get('/', authenticate, asyncHandler(async (req, res) => { + const stmt = db.prepare('SELECT * FROM categories WHERE user_id = ? ORDER BY type, id'); + const customCategories = stmt.all(req.userId); + + // 合并默认分类和自定义分类 + const categories = { + expense: [ + ...defaultCategories.expense.map((c) => ({ ...c, is_default: true })), + ...customCategories.filter((c) => c.type === 'expense'), + ], + income: [ + ...defaultCategories.income.map((c) => ({ ...c, is_default: true })), + ...customCategories.filter((c) => c.type === 'income'), + ], + }; + + res.json(categories); +})); + +// 新增自定义分类 +router.post('/', authenticate, asyncHandler(async (req, res) => { + const { type, name, icon } = req.body; + + const validationError = validateCategoryPayload(req.body); + if (validationError) { + throw new AppError(validationError, 400, 'VALIDATION_ERROR'); + } + + const stmt = db.prepare(` + INSERT INTO categories (user_id, type, name, icon, is_default) + VALUES (?, ?, ?, ?, 0) + `); + + const result = stmt.run(req.userId, type, name.trim(), icon.trim()); + console.log(`Category created: user=${req.userId}, id=${result.lastInsertRowid}`); + + res.json({ id: result.lastInsertRowid, message: '添加成功' }); +})); + +// 删除自定义分类 +router.delete('/:id', authenticate, asyncHandler(async (req, res) => { + const { id } = req.params; + + const checkStmt = db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND is_default = 0'); + const category = checkStmt.get(id, req.userId); + + if (!category) { + throw new AppError('分类不存在或无法删除', 404, 'NOT_FOUND'); + } + + const stmt = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?'); + stmt.run(id, req.userId); + console.log(`Category deleted: user=${req.userId}, id=${id}`); + + res.json({ message: '删除成功' }); +})); + +module.exports = router; diff --git a/server/routes/export.js b/server/routes/export.js new file mode 100644 index 0000000..777863b --- /dev/null +++ b/server/routes/export.js @@ -0,0 +1,115 @@ +const express = require('express'); +const db = require('../config/database'); +const { authenticate } = require('../middleware/auth'); +const { asyncHandler, AppError } = require('../middleware/errorHandler'); +const { validateImportPayload, normalizeNote } = require('../utils/validators'); + +const router = express.Router(); + +// 导出数据 +router.get('/', authenticate, asyncHandler(async (req, res) => { + const recordsStmt = db.prepare('SELECT * FROM records WHERE user_id = ? ORDER BY date DESC'); + const records = recordsStmt.all(req.userId); + + const recurringStmt = db.prepare('SELECT * FROM recurring_bills WHERE user_id = ?'); + const recurring = recurringStmt.all(req.userId); + + const categoriesStmt = db.prepare('SELECT * FROM categories WHERE user_id = ?'); + const categories = categoriesStmt.all(req.userId); + + console.log(`Data exported: user=${req.userId}`); + + res.json({ + version: '1.0', + exportDate: new Date().toISOString(), + records, + recurring, + customCategories: categories, + }); +})); + +// 导入数据 +router.post('/', authenticate, asyncHandler(async (req, res) => { + const { records: importRecords, recurring: importRecurring, customCategories: importCategories } = req.body; + + const validationError = validateImportPayload(req.body); + if (validationError) { + throw new AppError(validationError, 400, 'VALIDATION_ERROR'); + } + + const importCount = { records: 0, recurring: 0, categories: 0 }; + + if (importRecords && Array.isArray(importRecords)) { + const insertRecord = db.prepare(` + INSERT INTO records (user_id, type, amount, category, date, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const insertMany = db.transaction((records) => { + for (const r of records) { + insertRecord.run( + req.userId, + r.type, + r.amount, + r.category.trim(), + r.date, + normalizeNote(r.note), + r.created_at || new Date().toISOString() + ); + } + }); + insertMany(importRecords); + importCount.records = importRecords.length; + } + + if (importRecurring && Array.isArray(importRecurring)) { + const insertRecurring = db.prepare(` + INSERT INTO recurring_bills (user_id, type, amount, category, day, note, is_active, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const insertRecurringMany = db.transaction((bills) => { + for (const b of bills) { + insertRecurring.run( + req.userId, + b.type, + b.amount, + b.category.trim(), + b.day, + normalizeNote(b.note), + b.is_active !== undefined ? b.is_active : 1, + b.created_at || new Date().toISOString() + ); + } + }); + insertRecurringMany(importRecurring); + importCount.recurring = importRecurring.length; + } + + if (importCategories && Array.isArray(importCategories)) { + const insertCategory = db.prepare(` + INSERT INTO categories (user_id, type, name, icon, is_default, created_at) + VALUES (?, ?, ?, ?, 0, ?) + `); + + const insertCategoryMany = db.transaction((cats) => { + for (const c of cats) { + insertCategory.run( + req.userId, + c.type, + c.name.trim(), + c.icon.trim(), + c.created_at || new Date().toISOString() + ); + } + }); + insertCategoryMany(importCategories); + importCount.categories = importCategories.length; + } + + console.log(`Data imported: user=${req.userId}, counts=${JSON.stringify(importCount)}`); + + res.json({ message: '导入成功', count: importCount }); +})); + +module.exports = router; diff --git a/server/routes/records.js b/server/routes/records.js new file mode 100644 index 0000000..dd35fb7 --- /dev/null +++ b/server/routes/records.js @@ -0,0 +1,116 @@ +const express = require('express'); +const db = require('../config/database'); +const { authenticate } = require('../middleware/auth'); +const { asyncHandler, AppError } = require('../middleware/errorHandler'); +const { validateRecordPayload, normalizeNote } = require('../utils/validators'); + +const router = express.Router(); + +// 获取账目列表 +router.get('/', authenticate, asyncHandler(async (req, res) => { + const { month, type } = req.query; + + let query = 'SELECT * FROM records WHERE user_id = ?'; + const params = [req.userId]; + + if (month) { + query += ' AND date LIKE ?'; + params.push(`${month}%`); + } + + if (type && type !== 'all') { + query += ' AND type = ?'; + params.push(type); + } + + query += ' ORDER BY date DESC, id DESC'; + + const stmt = db.prepare(query); + const records = stmt.all(...params); + + res.json(records); +})); + +// 新增账目 +router.post('/', authenticate, asyncHandler(async (req, res) => { + const { type, amount, category, date, note } = req.body; + + const validationError = validateRecordPayload(req.body); + if (validationError) { + throw new AppError(validationError, 400, 'VALIDATION_ERROR'); + } + + const stmt = db.prepare(` + INSERT INTO records (user_id, type, amount, category, date, note) + VALUES (?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run(req.userId, type, amount, category.trim(), date, normalizeNote(note)); + console.log(`Record created: user=${req.userId}, id=${result.lastInsertRowid}`); + + res.json({ id: result.lastInsertRowid, message: '添加成功' }); +})); + +// 更新账目 +router.put('/:id', authenticate, asyncHandler(async (req, res) => { + const { id } = req.params; + const { type, amount, category, date, note } = req.body; + + const checkStmt = db.prepare('SELECT * FROM records WHERE id = ? AND user_id = ?'); + const record = checkStmt.get(id, req.userId); + + if (!record) { + throw new AppError('记录不存在', 404, 'NOT_FOUND'); + } + + const validationError = validateRecordPayload(req.body); + if (validationError) { + throw new AppError(validationError, 400, 'VALIDATION_ERROR'); + } + + const stmt = db.prepare(` + UPDATE records SET type = ?, amount = ?, category = ?, date = ?, note = ? + WHERE id = ? AND user_id = ? + `); + + stmt.run(type, amount, category.trim(), date, normalizeNote(note), id, req.userId); + console.log(`Record updated: user=${req.userId}, id=${id}`); + + res.json({ message: '更新成功' }); +})); + +// 删除账目 +router.delete('/:id', authenticate, asyncHandler(async (req, res) => { + const { id } = req.params; + + const checkStmt = db.prepare('SELECT * FROM records WHERE id = ? AND user_id = ?'); + const record = checkStmt.get(id, req.userId); + + if (!record) { + throw new AppError('记录不存在', 404, 'NOT_FOUND'); + } + + const stmt = db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?'); + stmt.run(id, req.userId); + console.log(`Record deleted: user=${req.userId}, id=${id}`); + + res.json({ message: '删除成功' }); +})); + +// 批量删除指定分类的记录 +router.delete('/by-category/:category', authenticate, asyncHandler(async (req, res) => { + const { category } = req.params; + + if (!category || typeof category !== 'string' || !category.trim()) { + throw new AppError('分类不能为空', 400, 'VALIDATION_ERROR'); + } + + const stmt = db.prepare('DELETE FROM records WHERE category = ? AND user_id = ?'); + const result = stmt.run(category.trim(), req.userId); + + console.log(`Records deleted by category: user=${req.userId}, category=${category}, count=${result.changes}`); + + res.json({ message: `已删除 ${result.changes} 条记录` }); +})); + +module.exports = router; diff --git a/server/routes/recurring.js b/server/routes/recurring.js new file mode 100644 index 0000000..d71e8e4 --- /dev/null +++ b/server/routes/recurring.js @@ -0,0 +1,108 @@ +const express = require('express'); +const db = require('../config/database'); +const { authenticate } = require('../middleware/auth'); +const { asyncHandler, AppError } = require('../middleware/errorHandler'); +const { validateRecurringPayload, normalizeNote } = require('../utils/validators'); + +const router = express.Router(); + +// 获取周期性账单 +router.get('/', authenticate, asyncHandler(async (req, res) => { + const stmt = db.prepare('SELECT * FROM recurring_bills WHERE user_id = ? ORDER BY day ASC'); + const bills = stmt.all(req.userId); + res.json(bills); +})); + +// 新增周期性账单 +router.post('/', authenticate, asyncHandler(async (req, res) => { + const { type, amount, category, day, note } = req.body; + + const validationError = validateRecurringPayload(req.body); + if (validationError) { + throw new AppError(validationError, 400, 'VALIDATION_ERROR'); + } + + const stmt = db.prepare(` + INSERT INTO recurring_bills (user_id, type, amount, category, day, note) + VALUES (?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run(req.userId, type, amount, category.trim(), day, normalizeNote(note)); + console.log(`Recurring bill created: user=${req.userId}, id=${result.lastInsertRowid}`); + + res.json({ id: result.lastInsertRowid, message: '添加成功' }); +})); + +// 更新周期性账单 +router.put('/:id', authenticate, asyncHandler(async (req, res) => { + const { id } = req.params; + const { type, amount, category, day, note, is_active } = req.body; + + const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ?'); + const bill = checkStmt.get(id, req.userId); + + if (!bill) { + throw new AppError('账单不存在', 404, 'NOT_FOUND'); + } + + const validationError = validateRecurringPayload(req.body); + if (validationError) { + throw new AppError(validationError, 400, 'VALIDATION_ERROR'); + } + + const stmt = db.prepare(` + UPDATE recurring_bills SET type = ?, amount = ?, category = ?, day = ?, note = ?, is_active = ? + WHERE id = ? AND user_id = ? + `); + + stmt.run(type, amount, category.trim(), day, normalizeNote(note), is_active !== undefined ? is_active : 1, id, req.userId); + console.log(`Recurring bill updated: user=${req.userId}, id=${id}`); + + res.json({ message: '更新成功' }); +})); + +// 删除周期性账单 +router.delete('/:id', authenticate, asyncHandler(async (req, res) => { + const { id } = req.params; + + const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ?'); + const bill = checkStmt.get(id, req.userId); + + if (!bill) { + throw new AppError('账单不存在', 404, 'NOT_FOUND'); + } + + const stmt = db.prepare('DELETE FROM recurring_bills WHERE id = ? AND user_id = ?'); + stmt.run(id, req.userId); + console.log(`Recurring bill deleted: user=${req.userId}, id=${id}`); + + res.json({ message: '删除成功' }); +})); + +// 应用周期性账单 +router.post('/:id/apply', authenticate, asyncHandler(async (req, res) => { + const { id } = req.params; + + const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ? AND is_active = 1'); + const bill = checkStmt.get(id, req.userId); + + if (!bill) { + throw new AppError('账单不存在或已禁用', 404, 'NOT_FOUND'); + } + + // 创建当前日期的账目 + const today = new Date(); + const dateStr = today.toISOString().split('T')[0] + ' ' + today.toTimeString().slice(0, 5); + + const insertStmt = db.prepare(` + INSERT INTO records (user_id, type, amount, category, date, note) + VALUES (?, ?, ?, ?, ?, ?) + `); + + insertStmt.run(req.userId, bill.type, bill.amount, bill.category, dateStr, bill.note || ''); + console.log(`Recurring bill applied: user=${req.userId}, billId=${id}`); + + res.json({ message: '应用成功' }); +})); + +module.exports = router; diff --git a/server/routes/search.js b/server/routes/search.js new file mode 100644 index 0000000..55b0942 --- /dev/null +++ b/server/routes/search.js @@ -0,0 +1,27 @@ +const express = require('express'); +const db = require('../config/database'); +const { authenticate } = require('../middleware/auth'); +const { asyncHandler, AppError } = require('../middleware/errorHandler'); + +const router = express.Router(); + +// 搜索账目 +router.get('/', authenticate, asyncHandler(async (req, res) => { + const { q } = req.query; + + if (!q) { + throw new AppError('请输入搜索关键词', 400, 'MISSING_QUERY'); + } + + const stmt = db.prepare(` + SELECT * FROM records + WHERE user_id = ? AND (category LIKE ? OR note LIKE ?) + ORDER BY date DESC, id DESC + LIMIT 50 + `); + + const records = stmt.all(req.userId, `%${q}%`, `%${q}%`); + res.json(records); +})); + +module.exports = router; diff --git a/server/routes/stats.js b/server/routes/stats.js new file mode 100644 index 0000000..aee5867 --- /dev/null +++ b/server/routes/stats.js @@ -0,0 +1,123 @@ +const express = require('express'); +const db = require('../config/database'); +const { authenticate } = require('../middleware/auth'); +const { asyncHandler, AppError } = require('../middleware/errorHandler'); +const { isValidMonth, VALID_STATS_PERIODS, VALID_STATS_TYPES } = require('../utils/validators'); + +const router = express.Router(); + +// 获取月度统计 +router.get('/', authenticate, asyncHandler(async (req, res) => { + const { month } = req.query; + + if (!month) { + throw new AppError('请指定月份', 400, 'MISSING_MONTH'); + } + + const stmt = db.prepare(` + SELECT + SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as totalIncome, + SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as totalExpense + FROM records + WHERE user_id = ? AND date LIKE ? + `); + + const result = stmt.get(req.userId, `${month}%`); + + res.json({ + income: result.totalIncome || 0, + expense: result.totalExpense || 0, + balance: (result.totalIncome || 0) - (result.totalExpense || 0), + }); +})); + +// 收支趋势统计 +router.get('/trend', authenticate, asyncHandler(async (req, res) => { + const { period = 'month', month } = req.query; + + if (!VALID_STATS_PERIODS.includes(period)) { + throw new AppError('统计周期错误', 400, 'INVALID_PERIOD'); + } + + if (month && !isValidMonth(month)) { + throw new AppError('月份格式错误', 400, 'INVALID_MONTH'); + } + + let dateGroup; + if (period === 'year') { + dateGroup = "strftime('%Y', date)"; + } else if (period === 'week') { + dateGroup = "strftime('%Y-%W', date)"; + } else { + dateGroup = "strftime('%Y-%m', date)"; + } + + let query = ` + SELECT + ${dateGroup} as period, + SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income, + SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as expense + FROM records + WHERE user_id = ? + `; + const params = [req.userId]; + + if (month) { + query += ' AND date LIKE ?'; + params.push(`${month}%`); + } + + query += ` + GROUP BY ${dateGroup} + ORDER BY period DESC + LIMIT 12 + `; + + const stmt = db.prepare(query); + const result = stmt.all(...params); + res.json(result.reverse()); +})); + +// 分类统计 +router.get('/category', authenticate, asyncHandler(async (req, res) => { + const { month, type = 'all' } = req.query; + + if (!VALID_STATS_TYPES.includes(type)) { + throw new AppError('统计类型错误', 400, 'INVALID_TYPE'); + } + + if (month && !isValidMonth(month)) { + throw new AppError('月份格式错误', 400, 'INVALID_MONTH'); + } + + let query = ` + SELECT + category, + SUM(amount) as total, + type + FROM records + WHERE user_id = ? + `; + const params = [req.userId]; + + if (month) { + query += ' AND date LIKE ?'; + params.push(`${month}%`); + } + + if (type !== 'all') { + query += ' AND type = ?'; + params.push(type); + } + + query += ` + GROUP BY category, type + ORDER BY total DESC + `; + + const stmt = db.prepare(query); + const result = stmt.all(...params); + res.json(result); +})); + +module.exports = router;