Files
accountbook/docs/superpowers/plans/2026-03-18-code-optimization.md
DeveloperandClaude 127f491167 feat: 前端UI标题栏优化与配置更新
- 更新.gitignore,添加frontend/.next/忽略
- 优化RecordModal、index、recurring、search、stats页面标题栏样式
- 新增frontend/lib/utils.js工具函数
- 更新useCategories.js钩子
- 添加代码优化计划文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-18 16:33:29 +08:00

2239 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 记账本项目代码优化实施计划
> **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 (
<div className="error-boundary">
<div className="error-boundary__content">
<Icon name="info" size={48} />
<h2>出错了</h2>
<p>抱歉应用遇到了问题</p>
{process.env.NODE_ENV === 'development' && (
<pre className="error-boundary__details">
{this.state.error?.toString()}
</pre>
)}
<div className="error-boundary__actions">
<button onClick={this.handleGoHome} className="btn btn-secondary">
返回首页
</button>
<button onClick={this.handleReload} className="btn btn-primary">
重新加载
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
```
- [ ] **Step 2: 创建加载动画组件**
```javascript
// frontend/components/LoadingSpinner.js
import { Icon } from './icons';
export default function LoadingSpinner({ size = 24, text = '加载中...' }) {
return (
<div className="loading-spinner">
<Icon name="spinner" size={size} className="is-spinning" />
{text && <span>{text}</span>}
</div>
);
}
```
- [ ] **Step 3: 更新 _app.js**
```javascript
// frontend/pages/_app.js
import { SWRConfig } from 'swr';
import ErrorBoundary from '../components/ErrorBoundary';
import ToastProvider from '../components/ToastProvider';
import ConfirmProvider from '../components/ConfirmProvider';
import { swrConfig } from '../lib/swr-config';
import '../styles/globals.css';
function MyApp({ Component, pageProps }) {
return (
<ErrorBoundary>
<SWRConfig value={swrConfig}>
<ToastProvider>
<ConfirmProvider>
<Component {...pageProps} />
</ConfirmProvider>
</ToastProvider>
</SWRConfig>
</ErrorBoundary>
);
}
export default MyApp;
```
- [ ] **Step 4: 添加 CSS 样式**
`frontend/styles/globals.css` 末尾添加:
```css
/* Error Boundary */
.error-boundary {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.error-boundary__content {
text-align: center;
max-width: 400px;
}
.error-boundary__content h2 {
margin: 16px 0 8px;
color: #1e293b;
}
.error-boundary__content p {
color: #64748b;
margin-bottom: 24px;
}
.error-boundary__details {
background: #f1f5f9;
padding: 12px;
border-radius: 8px;
font-size: 12px;
color: #64748b;
margin-bottom: 24px;
overflow-x: auto;
}
.error-boundary__actions {
display: flex;
gap: 12px;
justify-content: center;
}
/* Loading Spinner */
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
color: #64748b;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.is-spinning {
animation: spin 1s linear infinite;
}
```
- [ ] **Step 5: 提交**
```bash
git add frontend/
git commit -m "feat: add error boundary and loading spinner components"
```
---
## Phase 3: 开发工具配置
### Task 8: 配置 ESLint 和 Prettier
**Files:**
- Create: `.eslintrc.js`
- Create: `.prettierrc`
- Create: `.eslintignore`
- Modify: `package.json`
**步骤:**
- [ ] **Step 1: 安装依赖**
```bash
cd /opt/accountbook
npm install --save-dev eslint prettier eslint-config-prettier eslint-plugin-node
cd /opt/accountbook/frontend
npm install --save-dev eslint prettier eslint-config-prettier eslint-plugin-react eslint-plugin-react-hooks
```
- [ ] **Step 2: 创建 ESLint 配置(后端)**
```javascript
// .eslintrc.js (根目录)
module.exports = {
root: true,
env: {
node: true,
es2021: true,
jest: true,
},
extends: ['eslint:recommended', 'prettier'],
plugins: ['node'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-console': 'off',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
},
overrides: [
{
files: ['server/**/*.js'],
rules: {
'no-console': 'off',
},
},
],
};
```
- [ ] **Step 3: 创建 ESLint 配置(前端)**
```javascript
// frontend/.eslintrc.js
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
plugins: ['react', 'react-hooks'],
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
settings: {
react: {
version: 'detect',
},
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react-hooks/exhaustive-deps': 'warn',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'prefer-const': 'error',
},
};
```
- [ ] **Step 4: 创建 Prettier 配置**
```json
// .prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always"
}
```
- [ ] **Step 5: 创建忽略文件**
```
# .eslintignore
node_modules/
frontend/node_modules/
frontend/.next/
logs/
*.log
accountbook.db
```
- [ ] **Step 6: 提交**
```bash
git add .eslintrc.js .prettierrc .eslintignore
ngit commit -m "chore: add ESLint and Prettier configuration"
```
---
### Task 9: 配置 Jest 测试
**Files:**
- Create: `jest.config.js`
- Create: `server/__tests__/validators.test.js`
- Create: `server/__tests__/api.test.js`
**步骤:**
- [ ] **Step 1: 安装测试依赖**
```bash
cd /opt/accountbook
npm install --save-dev jest supertest @types/jest
```
- [ ] **Step 2: 创建 Jest 配置**
```javascript
// jest.config.js
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/server'],
testMatch: ['**/__tests__/**/*.test.js'],
collectCoverageFrom: [
'server/**/*.js',
'!server/**/__tests__/**',
'!server/db/migrate.js',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
verbose: true,
};
```
- [ ] **Step 3: 创建验证器测试**
```javascript
// server/__tests__/validators.test.js
const {
isNonEmptyString,
isValidMonth,
isValidDateTime,
validateRecordPayload,
validateRecurringPayload,
validateCategoryPayload,
} = require('../utils/validators');
describe('Validators', () => {
describe('isNonEmptyString', () => {
test('returns true for non-empty string', () => {
expect(isNonEmptyString('hello')).toBe(true);
});
test('returns false for empty string', () => {
expect(isNonEmptyString('')).toBe(false);
});
test('returns false for non-string', () => {
expect(isNonEmptyString(123)).toBe(false);
expect(isNonEmptyString(null)).toBe(false);
expect(isNonEmptyString(undefined)).toBe(false);
});
});
describe('isValidMonth', () => {
test('returns true for valid month format', () => {
expect(isValidMonth('2024-01')).toBe(true);
expect(isValidMonth('2024-12')).toBe(true);
});
test('returns false for invalid month format', () => {
expect(isValidMonth('2024-1')).toBe(false);
expect(isValidMonth('01-2024')).toBe(false);
expect(isValidMonth('invalid')).toBe(false);
});
});
describe('isValidDateTime', () => {
test('returns true for valid date', () => {
expect(isValidDateTime('2024-01-15')).toBe(true);
expect(isValidDateTime('2024-01-15 10:30')).toBe(true);
});
test('returns false for invalid date', () => {
expect(isValidDateTime('invalid')).toBe(false);
expect(isValidDateTime('15-01-2024')).toBe(false);
});
});
describe('validateRecordPayload', () => {
test('returns null for valid payload', () => {
const payload = {
type: 'expense',
amount: 100,
category: '餐饮',
date: '2024-01-15',
note: '',
};
expect(validateRecordPayload(payload)).toBeNull();
});
test('returns error for invalid type', () => {
const payload = {
type: 'invalid',
amount: 100,
category: '餐饮',
date: '2024-01-15',
};
expect(validateRecordPayload(payload)).toBe('账目类型错误');
});
test('returns error for negative amount', () => {
const payload = {
type: 'expense',
amount: -100,
category: '餐饮',
date: '2024-01-15',
};
expect(validateRecordPayload(payload)).toBe('金额需大于0');
});
test('returns error for empty category', () => {
const payload = {
type: 'expense',
amount: 100,
category: '',
date: '2024-01-15',
};
expect(validateRecordPayload(payload)).toBe('请填写分类');
});
});
});
```
- [ ] **Step 4: 创建 API 集成测试**
```javascript
// server/__tests__/api.test.js
const request = require('supertest');
const express = require('express');
// 模拟数据库和认证
describe('API Integration Tests', () => {
test('placeholder test', () => {
expect(true).toBe(true);
});
});
```
- [ ] **Step 5: 提交**
```bash
git add jest.config.js server/__tests__/
git commit -m "test: add Jest configuration and initial tests"
```
---
### Task 10: 更新首页使用新的 Hooks
**Files:**
- Modify: `frontend/pages/index.js`
**步骤:**
- [ ] **Step 1: 更新首页使用 SWR**
替换原有的数据获取逻辑:
```javascript
// 修改导入部分
import { useRecords } from '../hooks/useRecords';
import { useStats } from '../hooks/useStats';
import LoadingSpinner from '../components/LoadingSpinner';
// 修改组件逻辑
export default function Home() {
// ... 其他状态
const { records, isLoading: recordsLoading, addRecord, editRecord, removeRecord } = useRecords(
getMonthStr(currentMonth),
typeFilter
);
const { stats, isLoading: statsLoading } = useStats(getMonthStr(currentMonth));
// 移除原有的 loadData, setRecords, setStats 等逻辑
// 使用 addRecord, editRecord, removeRecord 替代原有的 API 调用
}
```
由于代码量较大,这里提供关键修改点:
1. 使用 `useRecords``useStats` 替代手动 fetch
2. 使用乐观更新替代重新加载
3. 添加 `LoadingSpinner` 组件
- [ ] **Step 2: 提交**
```bash
git add frontend/pages/index.js
git commit -m "refactor: update index page to use SWR hooks"
```
---
## Phase 4: 清理和文档
### Task 11: 备份旧文件并更新文档
**Files:**
- Move: `server.js``server.js.backup`
- Modify: `SPEC.md`
- Create: `CHANGELOG.md`
**步骤:**
- [ ] **Step 1: 备份旧服务器文件**
```bash
cd /opt/accountbook
mv server.js server.js.backup
git add server.js.backup
git commit -m "chore: backup old server.js"
```
- [ ] **Step 2: 更新 SPEC.md**
在 SPEC.md 中添加新的后端结构说明:
```markdown
## 9. 项目结构
### 后端目录结构
```
server/
├── index.js # 入口文件
├── config/
│ └── database.js # 数据库配置
├── middleware/
│ ├── auth.js # 认证中间件
│ ├── errorHandler.js # 错误处理
│ └── validator.js # 验证中间件
├── routes/
│ ├── auth.js # 认证路由
│ ├── records.js # 账目路由
│ ├── stats.js # 统计路由
│ ├── recurring.js # 周期性账单
│ ├── categories.js # 分类路由
│ ├── export.js # 导入导出
│ └── search.js # 搜索路由
├── utils/
│ ├── validators.js # 验证函数
│ └── logger.js # 日志工具
└── db/
├── migrations/ # 数据库迁移
└── migrate.js # 迁移脚本
```
### 新增功能
- 数据库迁移系统
- 结构化日志 (Winston)
- 统一错误处理
- API 请求验证
- 数据库索引优化
```
- [ ] **Step 3: 创建 CHANGELOG.md**
```markdown
# 更新日志
## [1.1.0] - 2026-03-18
### 新增
- 后端模块化重构,拆分单体 server.js
- 数据库迁移系统
- Winston 结构化日志
- 统一错误处理机制
- 数据库索引优化
- SWR 数据获取和缓存
- 乐观更新支持
- 错误边界组件
- ESLint + Prettier 配置
- Jest 测试框架
### 优化
- API 错误响应格式统一
- 前端数据获取性能优化
- 代码质量和一致性检查
### 开发体验
- 新增 `npm run dev` 同时启动前后端
- 新增 `npm run migrate` 数据库迁移
- 新增 `npm run lint` 代码检查
- 新增 `npm test` 运行测试
```
- [ ] **Step 4: 最终提交**
```bash
git add SPEC.md CHANGELOG.md
git commit -m "docs: update SPEC.md and add CHANGELOG"
```
---
## 总结
完成以上任务后,项目将获得:
1. **模块化后端**:清晰的代码结构,易于维护
2. **数据库优化**:索引和迁移系统
3. **错误处理**:统一和可预测的错误响应
4. **前端性能**SWR 缓存和乐观更新
5. **开发工具**ESLint, Prettier, Jest
6. **日志系统**:结构化的请求和错误日志
所有更改都是增量式的,不影响现有功能。