# 记账本项目代码优化实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. **Goal:** 重构和优化记账本项目的代码结构、数据库、错误处理、前端性能和开发体验 **Architecture:** 将单体 server.js 拆分为模块化结构,添加数据库索引优化,统一错误处理,引入前端数据缓存,完善开发工具链 **Tech Stack:** Node.js, Express, SQLite, Next.js 14, React, SWR, Winston --- ## 文件结构变更 ### 后端重构 (server/) ``` server/ ├── index.js # 入口文件(原 server.js 简化版) ├── config/ │ └── database.js # 数据库配置和连接 ├── middleware/ │ ├── errorHandler.js # 统一错误处理 │ └── validator.js # 请求验证中间件 ├── routes/ │ ├── auth.js # 认证路由 │ ├── records.js # 账目路由 │ ├── stats.js # 统计路由 │ ├── recurring.js # 周期性账单路由 │ ├── categories.js # 分类路由 │ └── export.js # 导入导出路由 ├── utils/ │ ├── validators.js # 验证函数 │ └── logger.js # 日志工具 └── db/ ├── migrations/ │ ├── 001_initial.sql │ └── 002_add_indexes.sql └── migrate.js # 迁移脚本 ``` ### 前端优化 - `frontend/lib/swr-config.js` - SWR 全局配置 - `frontend/hooks/useRecords.js` - 数据获取 Hook - `frontend/hooks/useStats.js` - 统计数据 Hook - `frontend/components/ErrorBoundary.js` - 错误边界 ### 开发工具 - `.eslintrc.js` - ESLint 配置 - `.prettierrc` - Prettier 配置 - `jest.config.js` - 测试配置 - `package.json` 脚本更新 --- ## Phase 1: 后端模块化重构 ### Task 1: 创建数据库配置模块 **Files:** - Create: `server/config/database.js` - Create: `server/db/migrations/001_initial.sql` - Create: `server/db/migrations/002_add_indexes.sql` - Create: `server/db/migrate.js` **步骤:** - [ ] **Step 1: 创建数据库配置模块** ```javascript // server/config/database.js const Database = require('better-sqlite3'); const path = require('path'); const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../accountbook.db'); const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); module.exports = db; ``` - [ ] **Step 2: 创建初始迁移文件** ```sql -- server/db/migrations/001_initial.sql 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 TABLE IF NOT EXISTS migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, executed_at DATETIME DEFAULT CURRENT_TIMESTAMP ); ``` - [ ] **Step 3: 创建索引迁移文件** ```sql -- server/db/migrations/002_add_indexes.sql -- 优化查询性能 CREATE INDEX IF NOT EXISTS idx_records_user_date 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_records_user_type ON records(user_id, type); CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_bills(user_id); CREATE INDEX IF NOT EXISTS idx_categories_user_type ON categories(user_id, type); ``` - [ ] **Step 4: 创建迁移脚本** ```javascript // server/db/migrate.js const fs = require('fs'); const path = require('path'); const db = require('../config/database'); const migrationsDir = path.join(__dirname, 'migrations'); function getExecutedMigrations() { return db.prepare('SELECT name FROM migrations').all().map(m => m.name); } function runMigration(filename) { const filepath = path.join(migrationsDir, filename); const sql = fs.readFileSync(filepath, 'utf-8'); console.log(`Running migration: ${filename}`); db.exec(sql); db.prepare('INSERT INTO migrations (name) VALUES (?)').run(filename); console.log(`Completed: ${filename}`); } function migrate() { const executed = getExecutedMigrations(); const files = fs.readdirSync(migrationsDir) .filter(f => f.endsWith('.sql')) .filter(f => !executed.includes(f)) .sort(); for (const file of files) { runMigration(file); } console.log('All migrations completed.'); } migrate(); ``` - [ ] **Step 5: 提交** ```bash git add server/ git commit -m "feat: add database config and migration system" ``` --- ### Task 2: 创建工具函数模块 **Files:** - Create: `server/utils/validators.js` - Create: `server/utils/logger.js` - Create: `server/middleware/errorHandler.js` **步骤:** - [ ] **Step 1: 创建验证函数模块** ```javascript // server/utils/validators.js 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, }; ``` - [ ] **Step 2: 创建日志工具** ```javascript // server/utils/logger.js const winston = require('winston'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), winston.format.json() ), defaultMeta: { service: 'accountbook' }, transports: [ new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), new winston.transports.File({ filename: 'logs/combined.log' }), ], }); if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ) })); } module.exports = logger; ``` - [ ] **Step 3: 创建统一错误处理中间件** ```javascript // server/middleware/errorHandler.js const logger = require('../utils/logger'); 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) { logger.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, }; ``` - [ ] **Step 4: 创建认证中间件** ```javascript // server/middleware/auth.js 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, }; ``` - [ ] **Step 5: 提交** ```bash git add server/ git commit -m "feat: add utils, logger and error handling middleware" ``` --- ### Task 3: 创建路由模块 **Files:** - Create: `server/routes/auth.js` - Create: `server/routes/records.js` - Create: `server/routes/stats.js` **步骤:** - [ ] **Step 1: 创建认证路由** ```javascript // server/routes/auth.js 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 logger = require('../utils/logger'); 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); logger.info(`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); logger.info(`User logged in: ${username}`); res.json({ token, username: user.username }); })); module.exports = router; ``` - [ ] **Step 2: 创建账目路由** ```javascript // server/routes/records.js 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 logger = require('../utils/logger'); 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)); logger.info(`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); logger.info(`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); logger.info(`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); logger.info(`Records deleted by category: user=${req.userId}, category=${category}, count=${result.changes}`); res.json({ message: `已删除 ${result.changes} 条记录` }); })); module.exports = router; ``` - [ ] **Step 3: 创建统计路由** ```javascript // server/routes/stats.js 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; ``` - [ ] **Step 4: 提交** ```bash git add server/routes/ git commit -m "feat: add auth, records, and stats routes" ``` --- ### Task 4: 创建剩余路由模块 **Files:** - Create: `server/routes/recurring.js` - Create: `server/routes/categories.js` - Create: `server/routes/export.js` - Create: `server/routes/search.js` **步骤:** - [ ] **Step 1: 创建周期性账单路由** ```javascript // server/routes/recurring.js 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 logger = require('../utils/logger'); 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)); logger.info(`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); logger.info(`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); logger.info(`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 || ''); logger.info(`Recurring bill applied: user=${req.userId}, billId=${id}`); res.json({ message: '应用成功' }); })); module.exports = router; ``` - [ ] **Step 2: 创建分类路由** ```javascript // server/routes/categories.js 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 logger = require('../utils/logger'); 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()); logger.info(`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); logger.info(`Category deleted: user=${req.userId}, id=${id}`); res.json({ message: '删除成功' }); })); module.exports = router; ``` - [ ] **Step 3: 创建导入导出和搜索路由** ```javascript // server/routes/export.js 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 logger = require('../utils/logger'); 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); logger.info(`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; } logger.info(`Data imported: user=${req.userId}, counts=${JSON.stringify(importCount)}`); res.json({ message: '导入成功', count: importCount }); })); module.exports = router; ``` ```javascript // server/routes/search.js 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; ``` - [ ] **Step 4: 提交** ```bash git add server/routes/ git commit -m "feat: add recurring, categories, export and search routes" ``` --- ### Task 5: 创建新的入口文件 **Files:** - Create: `server/index.js` - Modify: `package.json` (scripts) **步骤:** - [ ] **Step 1: 创建新的入口文件** ```javascript // server/index.js const express = require('express'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); // 确保日志目录存在 const logsDir = path.join(__dirname, '../logs'); if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } // 加载环境变量 require('dotenv').config(); const { errorHandler } = require('./middleware/errorHandler'); const logger = require('./utils/logger'); // 运行数据库迁移 require('./db/migrate'); const app = express(); const PORT = process.env.PORT || 3501; // 中间件 app.use(cors({ origin: process.env.NODE_ENV === 'production' ? process.env.ALLOWED_ORIGINS?.split(',') || [] : ['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'))); app.use(express.static(path.join(__dirname, '../frontend/.next/server/pages'))); // 请求日志 app.use((req, res, next) => { logger.info(`${req.method} ${req.url}`); next(); }); // 路由 app.use('/api/auth', require('./routes/auth')); app.use('/api/records', require('./routes/records')); app.use('/api/stats', require('./routes/stats')); app.use('/api/recurring', require('./routes/recurring')); app.use('/api/categories', require('./routes/categories')); app.use('/api/export', require('./routes/export')); app.use('/api/search', require('./routes/search')); // 健康检查 app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // 前端路由 - 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) => { let 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.use(errorHandler); // 启动服务器 app.listen(PORT, '0.0.0.0', () => { logger.info(`记账本服务已启动: http://0.0.0.0:${PORT}`); }); ``` - [ ] **Step 2: 更新 package.json** ```json { "name": "accountbook", "version": "1.0.0", "description": "简易记账本", "main": "server/index.js", "scripts": { "start": "node server/index.js", "dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"", "server:dev": "node server/index.js", "client:dev": "cd frontend && npm run dev", "build": "cd frontend && npm run build", "migrate": "node server/db/migrate.js", "test": "jest", "lint": "eslint server/ frontend/", "lint:fix": "eslint server/ frontend/ --fix" }, "dependencies": { "express": "^4.18.2", "better-sqlite3": "^9.2.2", "jsonwebtoken": "^9.0.2", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "winston": "^3.11.0", "dotenv": "^16.3.1" }, "devDependencies": { "concurrently": "^8.2.2", "eslint": "^8.55.0", "jest": "^29.7.0", "nodemon": "^3.0.2" } } ``` - [ ] **Step 3: 创建 .env.example** ``` # 服务器配置 PORT=3501 NODE_ENV=development # JWT 密钥(生产环境必须修改) JWT_SECRET=your-secret-key-here # 数据库路径 DB_PATH=./accountbook.db # 日志级别 LOG_LEVEL=info # 生产环境允许的域名(逗号分隔) ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com ``` - [ ] **Step 4: 提交** ```bash git add server/index.js package.json .env.example git commit -m "feat: add new server entry point and update package.json" ``` --- ## Phase 2: 前端性能优化 ### Task 6: 添加 SWR 数据获取 **Files:** - Create: `frontend/lib/swr-config.js` - Create: `frontend/hooks/useRecords.js` - Create: `frontend/hooks/useStats.js` - Modify: `frontend/package.json` **步骤:** - [ ] **Step 1: 安装依赖** ```bash cd /opt/accountbook/frontend npm install swr ``` - [ ] **Step 2: 创建 SWR 配置** ```javascript // frontend/lib/swr-config.js import { getRecords, getStats, getCategories, getRecurringBills } from './api'; // 全局 SWR 配置 export const swrConfig = { refreshInterval: 0, // 默认不自动刷新 revalidateOnFocus: true, revalidateOnReconnect: true, dedupingInterval: 2000, errorRetryCount: 3, onError: (error) => { console.error('SWR Error:', error); }, }; // 数据获取器映射 export const fetchers = { records: (month, type) => getRecords(month, type), stats: (month) => getStats(month), categories: () => getCategories(), recurring: () => getRecurringBills(), }; ``` - [ ] **Step 3: 创建 useRecords Hook** ```javascript // frontend/hooks/useRecords.js import useSWR from 'swr'; import { getRecords, createRecord, updateRecord, deleteRecord } from '../lib/api'; export function useRecords(month, type = 'all') { const key = month ? ['records', month, type] : null; const { data, error, isLoading, mutate } = useSWR( key, () => getRecords(month, type), { revalidateOnFocus: false, dedupingInterval: 5000, } ); // 乐观更新:添加记录 const addRecord = async (recordData) => { const newRecord = await createRecord(recordData); mutate( (current) => (current ? [...current, newRecord] : [newRecord]), { revalidate: false } ); return newRecord; }; // 乐观更新:更新记录 const editRecord = async (id, recordData) => { await updateRecord(id, recordData); mutate( (current) => current?.map((r) => (r.id === id ? { ...r, ...recordData } : r)), { revalidate: false } ); }; // 乐观更新:删除记录 const removeRecord = async (id) => { await deleteRecord(id); mutate( (current) => current?.filter((r) => r.id !== id), { revalidate: false } ); }; return { records: data || [], isLoading, error, mutate, addRecord, editRecord, removeRecord, }; } ``` - [ ] **Step 4: 创建 useStats Hook** ```javascript // frontend/hooks/useStats.js import useSWR from 'swr'; import { getStats, getTrendStats, getCategoryStats } from '../lib/api'; export function useStats(month) { const key = month ? ['stats', month] : null; const { data, error, isLoading, mutate } = useSWR( key, () => getStats(month), { revalidateOnFocus: false, } ); return { stats: data || { income: 0, expense: 0, balance: 0 }, isLoading, error, mutate, }; } export function useTrendStats(period = 'month', month) { const key = ['trend', period, month]; const { data, error, isLoading } = useSWR( key, () => getTrendStats(period, month), { revalidateOnFocus: false, } ); return { trend: data || [], isLoading, error, }; } export function useCategoryStats(month, type = 'all') { const key = ['categoryStats', month, type]; const { data, error, isLoading } = useSWR( key, () => getCategoryStats(month, type), { revalidateOnFocus: false, } ); return { categories: data || [], isLoading, error, }; } ``` - [ ] **Step 5: 提交** ```bash git add frontend/ git commit -m "feat: add SWR for data fetching with optimistic updates" ``` --- ### Task 7: 添加错误边界和加载状态 **Files:** - Create: `frontend/components/ErrorBoundary.js` - Create: `frontend/components/LoadingSpinner.js` - Modify: `frontend/pages/_app.js` **步骤:** - [ ] **Step 1: 创建错误边界组件** ```javascript // frontend/components/ErrorBoundary.js import React from 'react'; import { Icon } from './icons'; class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { console.error('ErrorBoundary caught error:', error, errorInfo); } handleReload = () => { window.location.reload(); }; handleGoHome = () => { window.location.href = '/'; }; render() { if (this.state.hasError) { return (
抱歉,应用遇到了问题。
{process.env.NODE_ENV === 'development' && (
{this.state.error?.toString()}
)}