const express = require('express'); const Database = require('better-sqlite3'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const cors = require('cors'); const path = require('path'); const app = express(); const PORT = 3501; const JWT_SECRET = process.env.JWT_SECRET || 'accountbook_secret_key_2024'; // 中间件 app.use(cors({ origin: ['http://localhost:3500', 'http://127.0.0.1:3500'], credentials: true })); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'frontend/.next/static'))); // 数据库初始化 const db = new Database('accountbook.db'); // 创建表 db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL CHECK(type IN ('income', 'expense')), amount REAL NOT NULL, category TEXT NOT NULL, date DATETIME NOT NULL, note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS recurring_bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL CHECK(type IN ('income', 'expense')), amount REAL NOT NULL, category TEXT NOT NULL, day INTEGER NOT NULL, note TEXT, is_active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL CHECK(type IN ('income', 'expense')), name TEXT NOT NULL, icon TEXT NOT NULL, is_default INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE INDEX IF NOT EXISTS idx_records_user_month ON records(user_id, date); CREATE INDEX IF NOT EXISTS idx_records_user_category ON records(user_id, category); CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_bills(user_id); CREATE INDEX IF NOT EXISTS idx_categories_user ON categories(user_id); `); 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; } // 认证中间件 const authenticate = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) { return res.status(401).json({ error: '请先登录' }); } try { const decoded = jwt.verify(token, JWT_SECRET); req.userId = decoded.userId; next(); } catch (err) { return res.status(401).json({ error: '登录已过期' }); } }; // ============ 认证接口 ============ // 注册 app.post('/api/auth/register', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: '请填写用户名和密码' }); } if (username.length < 3 || username.length > 20) { return res.status(400).json({ error: '用户名需3-20个字符' }); } if (password.length < 6 || password.length > 20) { return res.status(400).json({ error: '密码需6-20个字符' }); } try { 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 = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '7d' }); res.json({ token, username }); } catch (err) { if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { return res.status(400).json({ error: '用户名已存在' }); } res.status(500).json({ error: '服务器错误' }); } }); // 登录 app.post('/api/auth/login', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: '请填写用户名和密码' }); } const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); const user = stmt.get(username); if (!user) { return res.status(400).json({ error: '用户名或密码错误' }); } const validPassword = await bcrypt.compare(password, user.password); if (!validPassword) { return res.status(400).json({ error: '用户名或密码错误' }); } const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); res.json({ token, username: user.username }); }); // ============ 账目接口 ============ // 获取账目列表 app.get('/api/records', authenticate, (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') { if (!VALID_RECORD_TYPES.includes(type)) { return res.status(400).json({ error: '类型参数错误' }); } 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); }); // 新增账目 app.post('/api/records', authenticate, (req, res) => { const { type, amount, category, date, note } = req.body; const validationError = validateRecordPayload(req.body); if (validationError) { return res.status(400).json({ error: validationError }); } 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)); res.json({ id: result.lastInsertRowid, message: '添加成功' }); }); // 更新账目 app.put('/api/records/:id', authenticate, (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) { return res.status(404).json({ error: '记录不存在' }); } const validationError = validateRecordPayload(req.body); if (validationError) { return res.status(400).json({ error: validationError }); } 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); res.json({ message: '更新成功' }); }); // 删除账目 app.delete('/api/records/:id', authenticate, (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) { return res.status(404).json({ error: '记录不存在' }); } const stmt = db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?'); stmt.run(id, req.userId); res.json({ message: '删除成功' }); }); // 批量删除指定分类的记录 app.delete('/api/records/by-category/:category', authenticate, (req, res) => { const { category } = req.params; if (!category || typeof category !== 'string' || !category.trim()) { return res.status(400).json({ error: '分类不能为空' }); } const stmt = db.prepare('DELETE FROM records WHERE category = ? AND user_id = ?'); const result = stmt.run(category.trim(), req.userId); res.json({ message: `已删除 ${result.changes} 条记录` }); }); // 获取月度统计 app.get('/api/stats', authenticate, (req, res) => { const { month } = req.query; if (!month) { return res.status(400).json({ error: '请指定月份' }); } 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) }); }); // ============ 搜索接口 ============ // 搜索账目 app.get('/api/records/search', authenticate, (req, res) => { const { q } = req.query; if (!q) { return res.status(400).json({ error: '请输入搜索关键词' }); } 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); }); // ============ 统计接口 ============ // 收支趋势统计 app.get('/api/stats/trend', authenticate, (req, res) => { const { period = 'month', month } = req.query; if (!VALID_STATS_PERIODS.includes(period)) { return res.status(400).json({ error: '统计周期错误' }); } if (month && !isValidMonth(month)) { return res.status(400).json({ error: '月份格式错误' }); } 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()); }); // 分类统计 app.get('/api/stats/category', authenticate, (req, res) => { const { month, type = 'all' } = req.query; if (!VALID_STATS_TYPES.includes(type)) { return res.status(400).json({ error: '统计类型错误' }); } if (month && !isValidMonth(month)) { return res.status(400).json({ error: '月份格式错误' }); } 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); }); // ============ 周期性账单接口 ============ // 获取周期性账单 app.get('/api/recurring', authenticate, (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); }); // 新增周期性账单 app.post('/api/recurring', authenticate, (req, res) => { const { type, amount, category, day, note } = req.body; const validationError = validateRecurringPayload(req.body); if (validationError) { return res.status(400).json({ error: validationError }); } 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)); res.json({ id: result.lastInsertRowid, message: '添加成功' }); }); // 更新周期性账单 app.put('/api/recurring/:id', authenticate, (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) { return res.status(404).json({ error: '账单不存在' }); } const validationError = validateRecurringPayload(req.body); if (validationError) { return res.status(400).json({ error: validationError }); } 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); res.json({ message: '更新成功' }); }); // 删除周期性账单 app.delete('/api/recurring/:id', authenticate, (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) { return res.status(404).json({ error: '账单不存在' }); } const stmt = db.prepare('DELETE FROM recurring_bills WHERE id = ? AND user_id = ?'); stmt.run(id, req.userId); res.json({ message: '删除成功' }); }); // 应用周期性账单 app.post('/api/recurring/:id/apply', authenticate, (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) { return res.status(404).json({ error: '账单不存在或已禁用' }); } // 创建当前日期的账目 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 || ''); res.json({ message: '应用成功' }); }); // ============ 分类接口 ============ // 获取分类列表 app.get('/api/categories', authenticate, (req, res) => { const stmt = db.prepare('SELECT * FROM categories WHERE user_id = ? ORDER BY type, id'); const customCategories = stmt.all(req.userId); 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: '📝' } ] }; // 合并默认分类和自定义分类 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); }); // 新增自定义分类 app.post('/api/categories', authenticate, (req, res) => { const { type, name, icon } = req.body; const validationError = validateCategoryPayload(req.body); if (validationError) { return res.status(400).json({ error: validationError }); } 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()); res.json({ id: result.lastInsertRowid, message: '添加成功' }); }); // 删除自定义分类 app.delete('/api/categories/:id', authenticate, (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) { return res.status(404).json({ error: '分类不存在或无法删除' }); } const stmt = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?'); stmt.run(id, req.userId); res.json({ message: '删除成功' }); }); // ============ 数据导入导出接口 ============ // 导出数据 app.get('/api/export', authenticate, (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); res.json({ version: '1.0', exportDate: new Date().toISOString(), records, recurring, customCategories: categories }); }); // 导入数据 app.post('/api/import', authenticate, (req, res) => { const { records: importRecords, recurring: importRecurring, customCategories: importCategories } = req.body; const validationError = validateImportPayload(req.body); if (validationError) { return res.status(400).json({ error: validationError }); } try { 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); } 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); } 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); } res.json({ message: '导入成功' }); } catch (err) { res.status(500).json({ error: '导入失败: ' + err.message }); } }); // 前端路由 - 优先处理 Next.js 静态文件 app.get('/_next/static/:path(*)', (req, res) => { const filePath = path.join(__dirname, 'frontend/.next/static', req.params.path); res.sendFile(filePath, (err) => { if (err) res.status(404).send('Not found'); }); }); app.get('*', (req, res) => { // 检查是否是 Next.js 预渲染的页面 const pagePath = req.path === '/' ? '/index.html' : req.path + '.html'; const nextPagePath = path.join(__dirname, 'frontend/.next/server/pages', pagePath); res.sendFile(nextPagePath, (err) => { if (err) { // 如果找不到,返回首页 res.sendFile(path.join(__dirname, 'frontend/.next/server/pages/index.html')); } }); }); app.listen(PORT, '0.0.0.0', () => { console.log(`记账本服务已启动: http://0.0.0.0:${PORT}`); });