Files
accountbook/docs/superpowers/plans/2026-03-18-code-optimization.md
T
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

55 KiB
Raw Blame History

记账本项目代码优化实施计划

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: 创建数据库配置模块
// 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: 创建初始迁移文件
-- 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: 创建索引迁移文件
-- 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: 创建迁移脚本
// 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: 提交
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: 创建验证函数模块
// 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: 创建日志工具
// 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: 创建统一错误处理中间件
// 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: 创建认证中间件
// 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: 提交
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: 创建认证路由
// 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: 创建账目路由
// 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: 创建统计路由
// 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: 提交
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: 创建周期性账单路由
// 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: 创建分类路由
// 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: 创建导入导出和搜索路由
// 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;
// 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: 提交
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: 创建新的入口文件
// 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
{
  "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: 提交
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: 安装依赖
cd /opt/accountbook/frontend
npm install swr
  • Step 2: 创建 SWR 配置
// 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
// 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
// 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: 提交
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: 创建错误边界组件
// 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: 创建加载动画组件
// 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
// 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 末尾添加:

/* 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: 提交
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: 安装依赖
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 配置(后端)
// .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 配置(前端)
// 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 配置
// .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: 提交
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: 安装测试依赖
cd /opt/accountbook
npm install --save-dev jest supertest @types/jest
  • Step 2: 创建 Jest 配置
// 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: 创建验证器测试
// 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 集成测试
// 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: 提交
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

替换原有的数据获取逻辑:

// 修改导入部分
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. 使用 useRecordsuseStats 替代手动 fetch
  2. 使用乐观更新替代重新加载
  3. 添加 LoadingSpinner 组件
  • Step 2: 提交
git add frontend/pages/index.js
git commit -m "refactor: update index page to use SWR hooks"

Phase 4: 清理和文档

Task 11: 备份旧文件并更新文档

Files:

  • Move: server.jsserver.js.backup
  • Modify: SPEC.md
  • Create: CHANGELOG.md

步骤:

  • Step 1: 备份旧服务器文件
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 中添加新的后端结构说明:

## 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
# 更新日志

## [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: 最终提交
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. 日志系统:结构化的请求和错误日志

所有更改都是增量式的,不影响现有功能。