diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..76d554a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# 更新日志 + +## [1.1.0] - 2026-03-18 + +### 新增 +- 后端模块化重构,拆分单体 server.js +- 数据库迁移系统 +- 统一错误处理机制 +- 数据库索引优化 +- SWR 数据获取和缓存 +- 乐观更新支持 +- 错误边界组件 +- ESLint + Prettier 配置 +- Jest 测试框架 + +### 优化 +- API 错误响应格式统一 +- 前端数据获取性能优化 +- 代码质量和一致性检查 + +### 开发体验 +- 新增 `npm run dev` 同时启动前后端 +- 新增 `npm run migrate` 数据库迁移 +- 新增 `npm run lint` 代码检查 +- 新增 `npm test` 运行测试 diff --git a/SPEC.md b/SPEC.md index 858b046..9a59635 100644 --- a/SPEC.md +++ b/SPEC.md @@ -203,3 +203,43 @@ npm run dev ``` 访问 http://你的IP:3500 + +## 9. 项目结构 + +### 后端目录结构 +``` +server/ +├── index.js # 入口文件 +├── config/ +│ └── database.js # 数据库配置 +├── middleware/ +│ ├── auth.js # 认证中间件 +│ └── errorHandler.js # 错误处理 +├── routes/ +│ ├── auth.js # 认证路由 +│ ├── records.js # 账目路由 +│ ├── stats.js # 统计路由 +│ ├── recurring.js # 周期性账单 +│ ├── categories.js # 分类路由 +│ ├── export.js # 导入导出 +│ └── search.js # 搜索路由 +├── utils/ +│ └── validators.js # 验证函数 +└── db/ + ├── migrations/ # 数据库迁移 + └── migrate.js # 迁移脚本 +``` + +### 前端优化 +- 使用 SWR 进行数据获取和缓存 +- 乐观更新支持 +- 错误边界组件 +- 统一加载状态 + +### 新增功能 +- 数据库迁移系统 (`npm run migrate`) +- 结构化日志 +- 统一错误处理 +- API 请求验证 +- 数据库索引优化 + diff --git a/server.js.backup b/server.js.backup new file mode 100644 index 0000000..a9c360b --- /dev/null +++ b/server.js.backup @@ -0,0 +1,824 @@ +const express = require('express'); +const Database = require('better-sqlite3'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const cors = require('cors'); +const path = require('path'); + +const app = express(); +const PORT = 3501; +const JWT_SECRET = process.env.JWT_SECRET || 'accountbook_secret_key_2024'; + +// 中间件 +app.use(cors({ + origin: ['http://localhost:3500', 'http://127.0.0.1:3500'], + credentials: true +})); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static(path.join(__dirname, 'frontend/.next'))); +app.use(express.static(path.join(__dirname, 'frontend/.next/server/pages'))); + +// 数据库初始化 +const db = new Database('accountbook.db'); + +// 创建表 +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('income', 'expense')), + amount REAL NOT NULL, + category TEXT NOT NULL, + date DATETIME NOT NULL, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS recurring_bills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('income', 'expense')), + amount REAL NOT NULL, + category TEXT NOT NULL, + day INTEGER NOT NULL, + note TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('income', 'expense')), + name TEXT NOT NULL, + icon TEXT NOT NULL, + is_default INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); +`); + +const VALID_RECORD_TYPES = ['income', 'expense']; +const VALID_STATS_PERIODS = ['week', 'month', 'year']; +const VALID_STATS_TYPES = ['income', 'expense', 'all']; +const MONTH_PATTERN = /^\d{4}-\d{2}$/; +const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2})?$/; + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isValidMonth(value) { + return typeof value === 'string' && MONTH_PATTERN.test(value); +} + +function isValidDateTime(value) { + return typeof value === 'string' && DATE_TIME_PATTERN.test(value); +} + +function normalizeNote(value) { + return value === undefined || value === null ? '' : value; +} + +function validateRecordPayload(payload) { + if (!payload || typeof payload !== 'object') { + return '请求数据格式错误'; + } + + const { type, amount, category, date, note } = payload; + + if (!VALID_RECORD_TYPES.includes(type)) { + return '账目类型错误'; + } + + if (!Number.isFinite(amount) || amount <= 0) { + return '金额需大于0'; + } + + if (!isNonEmptyString(category)) { + return '请填写分类'; + } + + if (!isValidDateTime(date)) { + return '日期格式错误'; + } + + if (note !== undefined && note !== null && typeof note !== 'string') { + return '备注格式错误'; + } + + return null; +} + +function validateRecurringPayload(payload) { + if (!payload || typeof payload !== 'object') { + return '请求数据格式错误'; + } + + const { type, amount, category, day, note, is_active } = payload; + + if (!VALID_RECORD_TYPES.includes(type)) { + return '账单类型错误'; + } + + if (!Number.isFinite(amount) || amount <= 0) { + return '金额需大于0'; + } + + if (!isNonEmptyString(category)) { + return '请填写分类'; + } + + if (!Number.isInteger(day) || day < 1 || day > 28) { + return '日期需在1到28之间'; + } + + if (note !== undefined && note !== null && typeof note !== 'string') { + return '备注格式错误'; + } + + if (is_active !== undefined && ![0, 1, true, false].includes(is_active)) { + return '启用状态错误'; + } + + return null; +} + +function validateCategoryPayload(payload) { + if (!payload || typeof payload !== 'object') { + return '请求数据格式错误'; + } + + const { type, name, icon } = payload; + + if (!VALID_RECORD_TYPES.includes(type)) { + return '分类类型错误'; + } + + if (!isNonEmptyString(name)) { + return '请填写分类名称'; + } + + if (!isNonEmptyString(icon)) { + return '请选择分类图标'; + } + + return null; +} + +function validateImportPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return '导入数据格式错误'; + } + + const { records, recurring, customCategories } = payload; + + if (records !== undefined) { + if (!Array.isArray(records)) { + return 'records 必须是数组'; + } + + for (const record of records) { + const error = validateRecordPayload(record); + if (error) { + return `records 数据错误: ${error}`; + } + } + } + + if (recurring !== undefined) { + if (!Array.isArray(recurring)) { + return 'recurring 必须是数组'; + } + + for (const bill of recurring) { + const error = validateRecurringPayload(bill); + if (error) { + return `recurring 数据错误: ${error}`; + } + } + } + + if (customCategories !== undefined) { + if (!Array.isArray(customCategories)) { + return 'customCategories 必须是数组'; + } + + for (const category of customCategories) { + const error = validateCategoryPayload(category); + if (error) { + return `customCategories 数据错误: ${error}`; + } + } + } + + return null; +} + +// 认证中间件 +const authenticate = (req, res, next) => { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: '请先登录' }); + } + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.userId = decoded.userId; + next(); + } catch (err) { + return res.status(401).json({ error: '登录已过期' }); + } +}; + +// ============ 认证接口 ============ + +// 注册 +app.post('/api/auth/register', async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: '请填写用户名和密码' }); + } + + if (username.length < 3 || username.length > 20) { + return res.status(400).json({ error: '用户名需3-20个字符' }); + } + + if (password.length < 6 || password.length > 20) { + return res.status(400).json({ error: '密码需6-20个字符' }); + } + + try { + const hashedPassword = await bcrypt.hash(password, 10); + const stmt = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)'); + const result = stmt.run(username, hashedPassword); + + const token = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '7d' }); + res.json({ token, username }); + } catch (err) { + if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { + return res.status(400).json({ error: '用户名已存在' }); + } + res.status(500).json({ error: '服务器错误' }); + } +}); + +// 登录 +app.post('/api/auth/login', async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: '请填写用户名和密码' }); + } + + const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); + const user = stmt.get(username); + + if (!user) { + return res.status(400).json({ error: '用户名或密码错误' }); + } + + const validPassword = await bcrypt.compare(password, user.password); + if (!validPassword) { + return res.status(400).json({ error: '用户名或密码错误' }); + } + + const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); + res.json({ token, username: user.username }); +}); + +// ============ 账目接口 ============ + +// 获取账目列表 +app.get('/api/records', authenticate, (req, res) => { + const { month, type } = req.query; + + let query = 'SELECT * FROM records WHERE user_id = ?'; + const params = [req.userId]; + + if (month) { + query += ' AND date LIKE ?'; + params.push(`${month}%`); + } + + if (type && type !== 'all') { + query += ' AND type = ?'; + params.push(type); + } + + query += ' ORDER BY date DESC, id DESC'; + + const stmt = db.prepare(query); + const records = stmt.all(...params); + + res.json(records); +}); + +// 新增账目 +app.post('/api/records', authenticate, (req, res) => { + const { type, amount, category, date, note } = req.body; + + const validationError = validateRecordPayload(req.body); + if (validationError) { + return res.status(400).json({ error: validationError }); + } + + const stmt = db.prepare(` + INSERT INTO records (user_id, type, amount, category, date, note) + VALUES (?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run(req.userId, type, amount, category.trim(), date, normalizeNote(note)); + + res.json({ id: result.lastInsertRowid, message: '添加成功' }); +}); + +// 更新账目 +app.put('/api/records/:id', authenticate, (req, res) => { + const { id } = req.params; + const { type, amount, category, date, note } = req.body; + + const checkStmt = db.prepare('SELECT * FROM records WHERE id = ? AND user_id = ?'); + const record = checkStmt.get(id, req.userId); + + if (!record) { + return res.status(404).json({ error: '记录不存在' }); + } + + const validationError = validateRecordPayload(req.body); + if (validationError) { + return res.status(400).json({ error: validationError }); + } + + const stmt = db.prepare(` + UPDATE records SET type = ?, amount = ?, category = ?, date = ?, note = ? + WHERE id = ? AND user_id = ? + `); + + stmt.run(type, amount, category.trim(), date, normalizeNote(note), id, req.userId); + + res.json({ message: '更新成功' }); +}); + +// 删除账目 +app.delete('/api/records/:id', authenticate, (req, res) => { + const { id } = req.params; + + const checkStmt = db.prepare('SELECT * FROM records WHERE id = ? AND user_id = ?'); + const record = checkStmt.get(id, req.userId); + + if (!record) { + return res.status(404).json({ error: '记录不存在' }); + } + + const stmt = db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?'); + stmt.run(id, req.userId); + + res.json({ message: '删除成功' }); +}); + +// 批量删除指定分类的记录 +app.delete('/api/records/by-category/:category', authenticate, (req, res) => { + const { category } = req.params; + + if (!category || typeof category !== 'string' || !category.trim()) { + return res.status(400).json({ error: '分类不能为空' }); + } + + const stmt = db.prepare('DELETE FROM records WHERE category = ? AND user_id = ?'); + const result = stmt.run(category.trim(), req.userId); + + res.json({ message: `已删除 ${result.changes} 条记录` }); +}); + +// 获取月度统计 +app.get('/api/stats', authenticate, (req, res) => { + const { month } = req.query; + + if (!month) { + return res.status(400).json({ error: '请指定月份' }); + } + + const stmt = db.prepare(` + SELECT + SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as totalIncome, + SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as totalExpense + FROM records + WHERE user_id = ? AND date LIKE ? + `); + + const result = stmt.get(req.userId, `${month}%`); + + res.json({ + income: result.totalIncome || 0, + expense: result.totalExpense || 0, + balance: (result.totalIncome || 0) - (result.totalExpense || 0) + }); +}); + +// ============ 搜索接口 ============ + +// 搜索账目 +app.get('/api/records/search', authenticate, (req, res) => { + const { q } = req.query; + + if (!q) { + return res.status(400).json({ error: '请输入搜索关键词' }); + } + + const stmt = db.prepare(` + SELECT * FROM records + WHERE user_id = ? AND (category LIKE ? OR note LIKE ?) + ORDER BY date DESC, id DESC + LIMIT 50 + `); + + const records = stmt.all(req.userId, `%${q}%`, `%${q}%`); + res.json(records); +}); + +// ============ 统计接口 ============ + +// 收支趋势统计 +app.get('/api/stats/trend', authenticate, (req, res) => { + const { period = 'month', month } = req.query; + + if (!VALID_STATS_PERIODS.includes(period)) { + return res.status(400).json({ error: '统计周期错误' }); + } + + if (month && !isValidMonth(month)) { + return res.status(400).json({ error: '月份格式错误' }); + } + + let dateGroup; + if (period === 'year') { + dateGroup = "strftime('%Y', date)"; + } else if (period === 'week') { + dateGroup = "strftime('%Y-%W', date)"; + } else { + dateGroup = "strftime('%Y-%m', date)"; + } + + let query = ` + SELECT + ${dateGroup} as period, + SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income, + SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as expense + FROM records + WHERE user_id = ? + `; + const params = [req.userId]; + + if (month) { + query += ' AND date LIKE ?'; + params.push(`${month}%`); + } + + query += ` + GROUP BY ${dateGroup} + ORDER BY period DESC + LIMIT 12 + `; + + const stmt = db.prepare(query); + const result = stmt.all(...params); + res.json(result.reverse()); +}); + +// 分类统计 +app.get('/api/stats/category', authenticate, (req, res) => { + const { month, type = 'all' } = req.query; + + if (!VALID_STATS_TYPES.includes(type)) { + return res.status(400).json({ error: '统计类型错误' }); + } + + if (month && !isValidMonth(month)) { + return res.status(400).json({ error: '月份格式错误' }); + } + + let query = ` + SELECT + category, + SUM(amount) as total, + type + FROM records + WHERE user_id = ? + `; + const params = [req.userId]; + + if (month) { + query += ' AND date LIKE ?'; + params.push(`${month}%`); + } + + if (type !== 'all') { + query += ' AND type = ?'; + params.push(type); + } + + query += ` + GROUP BY category, type + ORDER BY total DESC + `; + + const stmt = db.prepare(query); + const result = stmt.all(...params); + res.json(result); +}); + +// ============ 周期性账单接口 ============ + +// 获取周期性账单 +app.get('/api/recurring', authenticate, (req, res) => { + const stmt = db.prepare('SELECT * FROM recurring_bills WHERE user_id = ? ORDER BY day ASC'); + const bills = stmt.all(req.userId); + res.json(bills); +}); + +// 新增周期性账单 +app.post('/api/recurring', authenticate, (req, res) => { + const { type, amount, category, day, note } = req.body; + + const validationError = validateRecurringPayload(req.body); + if (validationError) { + return res.status(400).json({ error: validationError }); + } + + const stmt = db.prepare(` + INSERT INTO recurring_bills (user_id, type, amount, category, day, note) + VALUES (?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run(req.userId, type, amount, category.trim(), day, normalizeNote(note)); + res.json({ id: result.lastInsertRowid, message: '添加成功' }); +}); + +// 更新周期性账单 +app.put('/api/recurring/:id', authenticate, (req, res) => { + const { id } = req.params; + const { type, amount, category, day, note, is_active } = req.body; + + const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ?'); + const bill = checkStmt.get(id, req.userId); + + if (!bill) { + return res.status(404).json({ error: '账单不存在' }); + } + + const validationError = validateRecurringPayload(req.body); + if (validationError) { + return res.status(400).json({ error: validationError }); + } + + const stmt = db.prepare(` + UPDATE recurring_bills SET type = ?, amount = ?, category = ?, day = ?, note = ?, is_active = ? + WHERE id = ? AND user_id = ? + `); + + stmt.run(type, amount, category.trim(), day, normalizeNote(note), is_active !== undefined ? is_active : 1, id, req.userId); + res.json({ message: '更新成功' }); +}); + +// 删除周期性账单 +app.delete('/api/recurring/:id', authenticate, (req, res) => { + const { id } = req.params; + + const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ?'); + const bill = checkStmt.get(id, req.userId); + + if (!bill) { + return res.status(404).json({ error: '账单不存在' }); + } + + const stmt = db.prepare('DELETE FROM recurring_bills WHERE id = ? AND user_id = ?'); + stmt.run(id, req.userId); + res.json({ message: '删除成功' }); +}); + +// 应用周期性账单 +app.post('/api/recurring/:id/apply', authenticate, (req, res) => { + const { id } = req.params; + + const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ? AND is_active = 1'); + const bill = checkStmt.get(id, req.userId); + + if (!bill) { + return res.status(404).json({ error: '账单不存在或已禁用' }); + } + + // 创建当前日期的账目 + const today = new Date(); + const dateStr = today.toISOString().split('T')[0] + ' ' + today.toTimeString().slice(0, 5); + + const insertStmt = db.prepare(` + INSERT INTO records (user_id, type, amount, category, date, note) + VALUES (?, ?, ?, ?, ?, ?) + `); + + insertStmt.run(req.userId, bill.type, bill.amount, bill.category, dateStr, bill.note || ''); + res.json({ message: '应用成功' }); +}); + +// ============ 分类接口 ============ + +// 获取分类列表 +app.get('/api/categories', authenticate, (req, res) => { + const stmt = db.prepare('SELECT * FROM categories WHERE user_id = ? ORDER BY type, id'); + const customCategories = stmt.all(req.userId); + + const defaultCategories = { + expense: [ + { name: '餐饮', icon: '🍜' }, + { name: '买菜', icon: '🥬' }, + { name: '交通', icon: '🚗' }, + { name: '购物', icon: '🛒' }, + { name: '娱乐', icon: '🎮' }, + { name: '房租', icon: '🏠' }, + { name: '房贷', icon: '🏦' }, + { name: '水电', icon: '💧' }, + { name: '衣服', icon: '👔' }, + { name: '通讯', icon: '📱' }, + { name: '日用品', icon: '🧴' }, + { name: '美妆', icon: '💄' }, + { name: '医疗', icon: '🏥' }, + { name: '养娃', icon: '👶' }, + { name: '家电', icon: '📺' }, + { name: '家具', icon: '🛋️' }, + { name: '装修', icon: '🔨' }, + { name: '宠物', icon: '🐕' }, + { name: '其他', icon: '📝' } + ], + income: [ + { name: '工资', icon: '💰' }, + { name: '奖金', icon: '🎁' }, + { name: '理财', icon: '📈' }, + { name: '红包', icon: '🧧' }, + { name: '其他', icon: '📝' } + ] + }; + + // 合并默认分类和自定义分类 + const categories = { + expense: [ + ...defaultCategories.expense.map(c => ({ ...c, is_default: true })), + ...customCategories.filter(c => c.type === 'expense') + ], + income: [ + ...defaultCategories.income.map(c => ({ ...c, is_default: true })), + ...customCategories.filter(c => c.type === 'income') + ] + }; + + res.json(categories); +}); + +// 新增自定义分类 +app.post('/api/categories', authenticate, (req, res) => { + const { type, name, icon } = req.body; + + const validationError = validateCategoryPayload(req.body); + if (validationError) { + return res.status(400).json({ error: validationError }); + } + + const stmt = db.prepare(` + INSERT INTO categories (user_id, type, name, icon, is_default) + VALUES (?, ?, ?, ?, 0) + `); + + const result = stmt.run(req.userId, type, name.trim(), icon.trim()); + res.json({ id: result.lastInsertRowid, message: '添加成功' }); +}); + +// 删除自定义分类 +app.delete('/api/categories/:id', authenticate, (req, res) => { + const { id } = req.params; + + const checkStmt = db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND is_default = 0'); + const category = checkStmt.get(id, req.userId); + + if (!category) { + return res.status(404).json({ error: '分类不存在或无法删除' }); + } + + const stmt = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?'); + stmt.run(id, req.userId); + res.json({ message: '删除成功' }); +}); + +// ============ 数据导入导出接口 ============ + +// 导出数据 +app.get('/api/export', authenticate, (req, res) => { + const recordsStmt = db.prepare('SELECT * FROM records WHERE user_id = ? ORDER BY date DESC'); + const records = recordsStmt.all(req.userId); + + const recurringStmt = db.prepare('SELECT * FROM recurring_bills WHERE user_id = ?'); + const recurring = recurringStmt.all(req.userId); + + const categoriesStmt = db.prepare('SELECT * FROM categories WHERE user_id = ?'); + const categories = categoriesStmt.all(req.userId); + + res.json({ + version: '1.0', + exportDate: new Date().toISOString(), + records, + recurring, + customCategories: categories + }); +}); + +// 导入数据 +app.post('/api/import', authenticate, (req, res) => { + const { records: importRecords, recurring: importRecurring, customCategories: importCategories } = req.body; + + const validationError = validateImportPayload(req.body); + if (validationError) { + return res.status(400).json({ error: validationError }); + } + + try { + if (importRecords && Array.isArray(importRecords)) { + const insertRecord = db.prepare(` + INSERT INTO records (user_id, type, amount, category, date, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const insertMany = db.transaction((records) => { + for (const r of records) { + insertRecord.run(req.userId, r.type, r.amount, r.category.trim(), r.date, normalizeNote(r.note), r.created_at || new Date().toISOString()); + } + }); + insertMany(importRecords); + } + + if (importRecurring && Array.isArray(importRecurring)) { + const insertRecurring = db.prepare(` + INSERT INTO recurring_bills (user_id, type, amount, category, day, note, is_active, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const insertRecurringMany = db.transaction((bills) => { + for (const b of bills) { + insertRecurring.run(req.userId, b.type, b.amount, b.category.trim(), b.day, normalizeNote(b.note), b.is_active !== undefined ? b.is_active : 1, b.created_at || new Date().toISOString()); + } + }); + insertRecurringMany(importRecurring); + } + + if (importCategories && Array.isArray(importCategories)) { + const insertCategory = db.prepare(` + INSERT INTO categories (user_id, type, name, icon, is_default, created_at) + VALUES (?, ?, ?, ?, 0, ?) + `); + + const insertCategoryMany = db.transaction((cats) => { + for (const c of cats) { + insertCategory.run(req.userId, c.type, c.name.trim(), c.icon.trim(), c.created_at || new Date().toISOString()); + } + }); + insertCategoryMany(importCategories); + } + + res.json({ message: '导入成功' }); + } catch (err) { + res.status(500).json({ error: '导入失败: ' + err.message }); + } +}); + +// 前端路由 - 优先处理 Next.js 静态文件 +app.get('/_next/static/:path(*)', (req, res) => { + const filePath = path.join(__dirname, 'frontend/.next/static', req.params.path); + res.sendFile(filePath, (err) => { + if (err) res.status(404).send('Not found'); + }); +}); + +app.get('*', (req, res) => { + // 检查是否是 Next.js 预渲染的页面 + 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.listen(PORT, '0.0.0.0', () => { + console.log(`记账本服务已启动: http://0.0.0.0:${PORT}`); +});