From 4647e2c9b02ab30e1069aa32b798e230fc341bd4 Mon Sep 17 00:00:00 2001 From: snowgitea Date: Wed, 29 Apr 2026 10:53:18 +0800 Subject: [PATCH] chore: add backend/src/index.js --- backend/src/index.js | 1215 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1215 insertions(+) create mode 100644 backend/src/index.js diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..ddc6287 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,1215 @@ +/** + * 个人财务预算系统 - 后端服务入口 + * + * 技术栈:Express + Prisma (SQLite) + * 端口:默认 3001(可通过环境变量 PORT 覆盖) + * + * 核心模块: + * - 账户管理:多账户体系(支付宝/微信/银行卡等) + * - 交易记录:收支流水、分类聚合 + * - 预算管理:月度预算、使用率追踪 + * - 统计分析:月度统计/趋势/对比 + * - 仪表盘:汇总数据聚合 + * + * 安全说明: + * - 当前为 MVP 阶段,暂缺 JWT 鉴权,所有接口依赖 userId 参数做数据隔离 + * - 生产环境需接入 JWT 中间件,从 token 解析 userId,禁止客户端传入 + */ +import express from 'express' +import cors from 'cors' +import { PrismaClient } from '@prisma/client' + +const app = express() +const PORT = process.env.PORT || 3001 +const prisma = new PrismaClient() + +// 中间件配置 +// CORS:MVP 阶段允许所有来源,生产环境需限制为前端域名 +app.use(cors()) +// 请求体解析:限制 JSON 大小防 DoS(1MB 上限),生产环境建议根据实际需求调整 +app.use(express.json({ limit: '1mb' })) + +// ============================================================================ +// 工具函数:统一响应格式、安全校验 +// ============================================================================ + +/** + * 类型字段枚举校验 - 防止非法值入库 + * @param {String} value - 待校验的类型值 + * @param {Array} allowedValues - 允许的值列表 + * @returns {Boolean} - 是否合法 + * 说明:用于 type、accountType 等枚举字段校验,避免非法值污染数据库 + */ +const isValidEnum = (value, allowedValues) => allowedValues.includes(value) + +/** + * 字符串长度校验 - 防止过长数据导致性能问题或溢出 + * @param {String} str - 待校验的字符串 + * @param {Number} maxLength - 最大长度 + * @returns {Boolean} - 是否合法 + */ +const isValidLength = (str, maxLength) => typeof str === 'string' && str.length <= maxLength + +/** + * NaN 安全检查 - 确保类型转换后为有效数字 + * @param {Number} num - 类型转换后的数字 + * @returns {Boolean} - 是否为有效数字(非 NaN、非 Infinity) + */ +const isValidNumber = (num) => typeof num === 'number' && !isNaN(num) && isFinite(num) + +/** + * 成功响应格式化 - 统一成功响应结构 + * @param {Object} data - 业务数据 + * @param {String} message - 提示信息 + * 说明:所有成功接口统一返回 { success: true, data, message },前端可据此做统一拦截 + */ +const successResponse = (res, data, message = '操作成功') => { + res.json({ + success: true, + data, + message + }) +} + +/** + * 错误响应格式化 - 统一错误响应结构 + * @param {Response} res - Express 响应对象 + * @param {String} message - 错误信息 + * @param {Number} status - HTTP 状态码,默认 400 + * 说明:所有错误接口统一返回 { success: false, data: null, message }, + * 配合 HTTP 状态码便于前端区分客户端错误(4xx)与服务端错误(5xx) + */ +const errorResponse = (res, message, status = 400) => { + res.status(status).json({ + success: false, + data: null, + message + }) +} + +// ============================================================================ +// 健康检查与 API 入口 +// ============================================================================ + +// API: GET /health - 健康检查(负载均衡/容器探针使用,不依赖数据库) +app.get('/health', (req, res) => { + successResponse(res, { + timestamp: new Date().toISOString() + }, 'Personal Finance Backend is running') +}) + +// API: GET /api - API 根,返回版本信息(用于前端检测后端是否可达) +app.get('/api', (req, res) => { + successResponse(res, { + version: '1.0.0' + }, 'Personal Finance API') +}) + +/** + * 用户接口 - MVP 阶段临时接口,用于创建测试用户 + * 生产环境应替换为注册/登录流程,禁止直接暴露用户创建 + */ +// API: POST /api/users - 创建用户(临时接口) +app.post('/api/users', async (req, res) => { + try { + const { name, email } = req.body + if (!name || !email) { + return errorResponse(res, 'name和email为必填字段') + } + const user = await prisma.user.create({ + data: { name, email } + }) + successResponse(res, user, '用户创建成功') + } catch (error) { + // P2002: Prisma 唯一约束冲突(邮箱重复) + if (error.code === 'P2002') { + return errorResponse(res, '该邮箱已被注册') + } + errorResponse(res, '创建用户失败: ' + error.message, 500) + } +}) + +// API: GET /api/users - 获取所有用户(临时接口,生产环境应删除) +app.get('/api/users', async (req, res) => { + try { + const users = await prisma.user.findMany() + successResponse(res, users) + } catch (error) { + errorResponse(res, '获取用户列表失败', 500) + } +}) + +// ============================================================================ +// 账户模块 /api/accounts +// 职责:管理用户的资金账户(支付宝/微信/银行卡等),作为交易记录的归属载体 +// ============================================================================ + +// API: GET /api/accounts - 获取指定用户的账户列表 +// 入参:userId (query, 必填) - 数据隔离键,防止越权访问其他用户账户 +app.get('/api/accounts', async (req, res) => { + try { + const { userId } = req.query + if (!userId) { + return errorResponse(res, 'userId为必填参数') + } + const accounts = await prisma.account.findMany({ + where: { userId: parseInt(userId) } + }) + successResponse(res, accounts) + } catch (error) { + errorResponse(res, '获取账户列表失败', 500) + } +}) + +// API: GET /api/accounts/:id - 获取单个账户详情 +app.get('/api/accounts/:id', async (req, res) => { + try { + const { id } = req.params + const account = await prisma.account.findUnique({ + where: { id: parseInt(id) } + }) + if (!account) { + return errorResponse(res, '账户不存在', 404) + } + successResponse(res, account) + } catch (error) { + errorResponse(res, '获取账户详情失败', 500) + } +}) + +// API: POST /api/accounts - 创建新账户 +// 入参校验:userId/name/type 必填,balance 默认 0,color 有默认色值 +app.post('/api/accounts', async (req, res) => { + try { + const { userId, name, type, color, balance = 0 } = req.body + if (!userId || !name || !type) { + return errorResponse(res, 'userId、name、type为必填字段') + } + // 类型字段枚举校验:防止非法值入库 + if (!isValidEnum(type, ['payment', 'bank', 'cash'])) { + return errorResponse(res, 'type必须为payment/bank/cash之一') + } + // 字符串长度校验 + if (!isValidLength(name, 50)) { + return errorResponse(res, 'name长度不能超过50个字符') + } + const parsedUserId = parseInt(userId) + const parsedBalance = parseFloat(balance) + // NaN 安全检查 + if (!isValidNumber(parsedUserId) || !isValidNumber(parsedBalance)) { + return errorResponse(res, 'userId和balance必须为有效数字') + } + const account = await prisma.account.create({ + data: { + userId: parsedUserId, + name, + type, + color: color || '#1890FF', + balance: parsedBalance + } + }) + successResponse(res, account, '账户创建成功') + } catch (error) { + errorResponse(res, '创建账户失败: ' + error.message, 500) + } +}) + +// API: PUT /api/accounts/:id - 更新账户信息 +// 仅更新传入的字段(partial update),避免覆盖未传字段为 null +app.put('/api/accounts/:id', async (req, res) => { + try { + const { id } = req.params + const { name, type, color, balance } = req.body + const data = {} + if (name !== undefined) { + if (!isValidLength(name, 50)) { + return errorResponse(res, 'name长度不能超过50个字符') + } + data.name = name + } + if (type !== undefined) { + if (!isValidEnum(type, ['payment', 'bank', 'cash'])) { + return errorResponse(res, 'type必须为payment/bank/cash之一') + } + data.type = type + } + if (color !== undefined) data.color = color + if (balance !== undefined) { + const parsedBalance = parseFloat(balance) + if (!isValidNumber(parsedBalance)) { + return errorResponse(res, 'balance必须为有效数字') + } + data.balance = parsedBalance + } + + const account = await prisma.account.update({ + where: { id: parseInt(id) }, + data + }) + successResponse(res, account, '账户更新成功') + } catch (error) { + if (error.code === 'P2025') { + return errorResponse(res, '账户不存在', 404) + } + errorResponse(res, '更新账户失败', 500) + } +}) + +// API: DELETE /api/accounts/:id - 删除账户 +// 风险提醒:未做关联交易记录检查,直接删除可能导致孤儿记录,后续需加外键约束 +app.delete('/api/accounts/:id', async (req, res) => { + try { + const { id } = req.params + await prisma.account.delete({ + where: { id: parseInt(id) } + }) + successResponse(res, null, '账户删除成功') + } catch (error) { + if (error.code === 'P2025') { + return errorResponse(res, '账户不存在', 404) + } + errorResponse(res, '删除账户失败', 500) + } +}) + +// ============================================================================ +// 交易记录模块 /api/records +// 职责:收支流水的 CRUD,核心业务:创建/更新/删除时联动更新账户余额 +// 使用 Prisma 事务确保记录与余额的一致性(要么全成功,要么全回滚) +// ============================================================================ + +// API: GET /api/records - 获取交易记录列表(支持多维度筛选) +// 入参:userId(必填), accountId/type/category/startDate/endDate(可选) +// 注意:必须传入 userId 做数据隔离 +app.get('/api/records', async (req, res) => { + try { + const { userId, accountId, type, category, startDate, endDate } = req.query + const where = { userId: parseInt(userId) } + if (accountId) where.accountId = parseInt(accountId) + if (type) where.type = type + if (category) where.category = category + // 日期范围筛选:支持单独传 startDate 或 endDate,或同时传入 + if (startDate || endDate) { + where.date = {} + if (startDate) where.date.gte = new Date(startDate) + if (endDate) where.date.lte = new Date(endDate) + } + + const records = await prisma.record.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { account: true } // 关联查询账户信息,便于前端展示账户名 + }) + successResponse(res, records) + } catch (error) { + errorResponse(res, '获取交易记录失败', 500) + } +}) + +// API: GET /api/records/:id - 获取单个交易记录详情 +app.get('/api/records/:id', async (req, res) => { + try { + const { id } = req.params + const record = await prisma.record.findUnique({ + where: { id: parseInt(id) }, + include: { account: true } + }) + if (!record) { + return errorResponse(res, '交易记录不存在', 404) + } + successResponse(res, record) + } catch (error) { + errorResponse(res, '获取交易记录失败', 500) + } +}) + +/** + * 安全解析日期字符串 + * 问题背景:new Date("YYYY-MM-DD") 在 JS 中会被当作 UTC 00:00 解析, + * 在 UTC+8 时区下会变成前一天 08:00,导致日期偏移一天。 + * 解决方案:对纯日期格式按本地时区解析(用 Date(y, m, d) 构造器), + * 包含时间部分的字符串直接解析。 + */ +function parseDate(dateStr) { + if (!dateStr) return new Date(); + + // 包含时间部分(T),说明是完整时间戳,直接解析 + if (dateStr.includes('T')) { + return new Date(dateStr); + } + + // 纯日期格式 "YYYY-MM-DD",按本地时区解析 + // 避免 new Date("2026-04-26") 解析为 UTC 00:00 + const parts = dateStr.split('-'); + if (parts.length === 3) { + // 使用本地时区构造日期,月份从0开始 + return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); + } + + // 其他格式回退到默认解析 + return new Date(dateStr); +} + +// API: POST /api/records - 创建交易记录(联动更新账户余额) +// 核心逻辑:使用 Prisma 事务保证原子性 +// 1. 创建交易记录 +// 2. 查询当前账户余额 +// 3. 根据收支类型增减余额(income 加,expense 减) +// 4. 更新账户余额 +// 任何一步失败则整体回滚,防止数据不一致 +app.post('/api/records', async (req, res) => { + try { + const { userId, accountId, type, amount, category, description, date } = req.body + // 必填字段校验:核心业务字段缺一不可 + if (!userId || !accountId || !type || !amount || !category) { + return errorResponse(res, '必填字段缺失') + } + // 类型字段枚举校验:防止非法值入库 + if (!isValidEnum(type, ['income', 'expense'])) { + return errorResponse(res, 'type必须为income或expense') + } + // 字符串长度校验 + if (!isValidLength(category, 50)) { + return errorResponse(res, 'category长度不能超过50个字符') + } + if (description && !isValidLength(description, 200)) { + return errorResponse(res, 'description长度不能超过200个字符') + } + + // 金额合法性校验:防止 0 或负数入账 + const parsedAmount = parseFloat(amount) + const parsedUserId = parseInt(userId) + const parsedAccountId = parseInt(accountId) + // NaN 安全检查 + if (!isValidNumber(parsedAmount) || parsedAmount <= 0) { + return errorResponse(res, '金额必须大于0') + } + if (!isValidNumber(parsedUserId) || !isValidNumber(parsedAccountId)) { + return errorResponse(res, 'userId和accountId必须为有效数字') + } + + const recordData = { + userId: parsedUserId, + accountId: parsedAccountId, + type, + amount: parsedAmount, + category, + description, + date: parseDate(date) // 使用安全日期解析,避免时区偏移 + } + + // 事务执行:记录创建 + 余额更新为原子操作 + const record = await prisma.$transaction(async (tx) => { + // 1. 创建交易记录 + const rec = await tx.record.create({ data: recordData }) + + // 2. 查询当前账户余额(需要最新值,所以在事务内查询) + const account = await tx.account.findUnique({ + where: { id: parseInt(accountId) } + }) + + // 3. 根据类型计算新余额 + let newBalance = parseFloat(account.balance) + if (type === 'income') { + newBalance += parseFloat(amount) + } else { + newBalance -= parseFloat(amount) + } + + // 4. 更新账户余额 + await tx.account.update({ + where: { id: parseInt(accountId) }, + data: { balance: newBalance } + }) + + return rec + }) + + successResponse(res, record, '交易记录创建成功') + } catch (error) { + errorResponse(res, '创建交易记录失败: ' + error.message, 500) + } +}) + +// API: PUT /api/records/:id - 更新交易记录(联动重新计算账户余额) +// 核心逻辑:先撤销原记录对余额的影响,再应用新值 +// 1. 查询原记录 +// 2. 反向冲销原金额(income 减回去,expense 加回来) +// 3. 更新记录内容 +// 4. 应用新金额(按新 type 和 amount 计算) +// 说明:如果 type 或 amount 未变,冲销+应用后余额不变,但保证逻辑一致性 +app.put('/api/records/:id', async (req, res) => { + try { + const { id } = req.params + const { type, amount, category, description, date } = req.body + + // 类型字段枚举校验 + if (type !== undefined && !isValidEnum(type, ['income', 'expense'])) { + return errorResponse(res, 'type必须为income或expense') + } + // 字符串长度校验 + if (category !== undefined && !isValidLength(category, 50)) { + return errorResponse(res, 'category长度不能超过50个字符') + } + if (description !== undefined && !isValidLength(description, 200)) { + return errorResponse(res, 'description长度不能超过200个字符') + } + // NaN 安全检查 + if (amount !== undefined) { + const parsedAmount = parseFloat(amount) + if (!isValidNumber(parsedAmount) || parsedAmount <= 0) { + return errorResponse(res, 'amount必须为有效数字且大于0') + } + } + + const updatedRecord = await prisma.$transaction(async (tx) => { + // 1. 获取原记录,用于余额冲销 + const oldRecord = await tx.record.findUnique({ + where: { id: parseInt(id) } + }) + + if (!oldRecord) { + throw new Error('NOT_FOUND') + } + + // 2. 反向冲销原金额:撤销该记录对余额的影响 + const account = await tx.account.findUnique({ + where: { id: oldRecord.accountId } + }) + + let currentBalance = parseFloat(account.balance) + if (oldRecord.type === 'income') { + currentBalance -= parseFloat(oldRecord.amount) // 收入冲销:减去 + } else { + currentBalance += parseFloat(oldRecord.amount) // 支出冲销:加回 + } + + // 3. 更新记录(仅更新传入的字段) + const data = {} + if (type !== undefined) data.type = type + if (amount !== undefined) data.amount = parseFloat(amount) + if (category !== undefined) data.category = category + if (description !== undefined) data.description = description + if (date !== undefined) data.date = new Date(date) + + const rec = await tx.record.update({ + where: { id: parseInt(id) }, + data + }) + + // 4. 应用新金额:按新 type/amount 重新计算余额 + const finalType = type || oldRecord.type + const finalAmount = amount !== undefined ? parseFloat(amount) : parseFloat(oldRecord.amount) + + if (finalType === 'income') { + currentBalance += finalAmount + } else { + currentBalance -= finalAmount + } + + await tx.account.update({ + where: { id: oldRecord.accountId }, + data: { balance: currentBalance } + }) + + return rec + }) + + successResponse(res, updatedRecord, '交易记录更新成功') + } catch (error) { + if (error.message === 'NOT_FOUND') { + return errorResponse(res, '交易记录不存在', 404) + } + errorResponse(res, '更新交易记录失败', 500) + } +}) + +// API: DELETE /api/records/:id - 删除交易记录(恢复账户余额) +// 核心逻辑: +// 1. 查询原记录 +// 2. 反向冲销余额(与创建操作相反:income 减,expense 加) +// 3. 更新账户余额 +// 4. 删除记录 +// 事务保证:余额恢复和记录删除为原子操作,避免删除成功但余额未恢复 +app.delete('/api/records/:id', async (req, res) => { + try { + const { id } = req.params + + await prisma.$transaction(async (tx) => { + const record = await tx.record.findUnique({ + where: { id: parseInt(id) } + }) + + if (!record) { + throw new Error('NOT_FOUND') + } + + // 反向冲销余额:撤销该记录对余额的影响 + const account = await tx.account.findUnique({ + where: { id: record.accountId } + }) + + let currentBalance = parseFloat(account.balance) + if (record.type === 'income') { + currentBalance -= parseFloat(record.amount) // 收入撤销:减去 + } else { + currentBalance += parseFloat(record.amount) // 支出撤销:加回 + } + + await tx.account.update({ + where: { id: record.accountId }, + data: { balance: currentBalance } + }) + + // 删除记录 + await tx.record.delete({ where: { id: parseInt(id) } }) + }) + + successResponse(res, null, '交易记录删除成功') + } catch (error) { + if (error.message === 'NOT_FOUND') { + return errorResponse(res, '交易记录不存在', 404) + } + errorResponse(res, '删除交易记录失败', 500) + } +}) + +// ============================================================================ +// 预算模块 /api/budgets +// 职责:管理用户月度预算,按分类设置消费上限,用于仪表盘的使用率追踪 +// ============================================================================ + +// API: GET /api/budgets - 获取预算列表(支持按月份筛选) +// 入参:userId(必填), month(可选,格式 YYYY-MM) +app.get('/api/budgets', async (req, res) => { + try { + const { userId, month } = req.query + const where = { userId: parseInt(userId) } + if (month) where.month = month + + const budgets = await prisma.budget.findMany({ + where, + orderBy: { createdAt: 'desc' } + }) + successResponse(res, budgets) + } catch (error) { + errorResponse(res, '获取预算列表失败', 500) + } +}) + +// API: GET /api/budgets/:id - 获取单个预算详情 +app.get('/api/budgets/:id', async (req, res) => { + try { + const { id } = req.params + const budget = await prisma.budget.findUnique({ + where: { id: parseInt(id) } + }) + if (!budget) { + return errorResponse(res, '预算不存在', 404) + } + successResponse(res, budget) + } catch (error) { + errorResponse(res, '获取预算失败', 500) + } +}) + +// API: POST /api/budgets - 创建月度预算 +// 入参:userId/category/amount/month 必填,amount 需为正数 +app.post('/api/budgets', async (req, res) => { + try { + const { userId, category, amount, month } = req.body + if (!userId || !category || !amount || !month) { + return errorResponse(res, '必填字段缺失') + } + // 字符串长度校验 + if (!isValidLength(category, 50)) { + return errorResponse(res, 'category长度不能超过50个字符') + } + if (!isValidLength(month, 7)) { + return errorResponse(res, 'month格式错误,应为YYYY-MM') + } + const parsedUserId = parseInt(userId) + const parsedAmount = parseFloat(amount) + // NaN 安全检查 + if (!isValidNumber(parsedUserId) || !isValidNumber(parsedAmount) || parsedAmount <= 0) { + return errorResponse(res, 'userId和amount必须为有效数字且amount大于0') + } + const budget = await prisma.budget.create({ + data: { + userId: parsedUserId, + category, + amount: parsedAmount, + month + } + }) + successResponse(res, budget, '预算创建成功') + } catch (error) { + errorResponse(res, '创建预算失败', 500) + } +}) + +// API: PUT /api/budgets/:id - 更新预算(仅更新传入字段) +app.put('/api/budgets/:id', async (req, res) => { + try { + const { id } = req.params + const { category, amount, month } = req.body + const data = {} + if (category !== undefined) { + if (!isValidLength(category, 50)) { + return errorResponse(res, 'category长度不能超过50个字符') + } + data.category = category + } + if (amount !== undefined) { + const parsedAmount = parseFloat(amount) + if (!isValidNumber(parsedAmount) || parsedAmount <= 0) { + return errorResponse(res, 'amount必须为有效数字且大于0') + } + data.amount = parsedAmount + } + if (month !== undefined) { + if (!isValidLength(month, 7)) { + return errorResponse(res, 'month格式错误,应为YYYY-MM') + } + data.month = month + } + + const budget = await prisma.budget.update({ + where: { id: parseInt(id) }, + data + }) + successResponse(res, budget, '预算更新成功') + } catch (error) { + if (error.code === 'P2025') { + return errorResponse(res, '预算不存在', 404) + } + errorResponse(res, '更新预算失败', 500) + } +}) + +// API: DELETE /api/budgets/:id - 删除预算 +app.delete('/api/budgets/:id', async (req, res) => { + try { + const { id } = req.params + await prisma.budget.delete({ + where: { id: parseInt(id) } + }) + successResponse(res, null, '预算删除成功') + } catch (error) { + if (error.code === 'P2025') { + return errorResponse(res, '预算不存在', 404) + } + errorResponse(res, '删除预算失败', 500) + } +}) + +// ============================================================================ +// 统计分析模块 /api/statistics +// 职责:提供多维度数据聚合(月度统计/趋势/对比),用于前端图表渲染 +// ============================================================================ + +// API: GET /api/statistics/monthly - 月度收支统计 + 分类聚合 +// 入参:userId(必填), month(必填,格式 YYYY-MM) +// 返回:总收入/总支出/结余 + 各支出分类的汇总金额(用于饼图) +app.get('/api/statistics/monthly', async (req, res) => { + try { + const { userId, month } = req.query + if (!userId || !month) { + return errorResponse(res, 'userId和month为必填参数') + } + + // 计算月份的首尾日期:startDate=当月1号,endDate=当月最后一天 + const startDate = new Date(month + '-01') + const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 1, 0) + + const records = await prisma.record.findMany({ + where: { + userId: parseInt(userId), + date: { gte: startDate, lte: endDate } + } + }) + + // 聚合计算:按 type 汇总收入/支出,按 category 汇总支出分类 + let totalIncome = 0 + let totalExpense = 0 + const categoryStats = {} + + records.forEach(r => { + const amount = parseFloat(r.amount) + if (r.type === 'income') { + totalIncome += amount + } else { + totalExpense += amount + if (!categoryStats[r.category]) { + categoryStats[r.category] = 0 + } + categoryStats[r.category] += amount + } + }) + + successResponse(res, { + totalIncome, + totalExpense, + balance: totalIncome - totalExpense, + // 分类统计转为数组格式,便于前端遍历渲染 + categoryStats: Object.entries(categoryStats).map(([category, amount]) => ({ + category, + amount + })) + }) + } catch (error) { + errorResponse(res, '获取月度统计失败', 500) + } +}) + +// API: GET /api/statistics/trend - 趋势统计(按日期聚合的日级收支数据) +// 入参:userId(必填), startDate/endDate(可选,不传则返回所有数据) +// 返回:按日期分组的每日收入/支出数组(用于折线图/柱状图) +app.get('/api/statistics/trend', async (req, res) => { + try { + const { userId, startDate, endDate } = req.query + if (!userId) { + return errorResponse(res, 'userId为必填参数') + } + + const where = { userId: parseInt(userId) } + if (startDate || endDate) { + where.date = {} + if (startDate) { + // 按本地时区构造起始日期 + const parts = startDate.split('-') + where.date.gte = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2])) + } + if (endDate) { + // 结束日期包含当天的最后一秒,确保不会遗漏 + const parts = endDate.split('-') + where.date.lte = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2], 23, 59, 59)) + } + } + + const records = await prisma.record.findMany({ + where, + orderBy: { date: 'asc' } + }) + + // 按日期聚合:将同一天的多条记录合并为一条统计 + const dailyStats = {} + records.forEach(r => { + const dateObj = r.date instanceof Date ? r.date : new Date(r.date) + if (isNaN(dateObj.getTime())) { + // 跳过无效日期数据,防止脏数据污染聚合结果 + console.warn('Invalid date:', r.date) + return + } + const dateKey = dateObj.toISOString().split('T')[0] + if (!dailyStats[dateKey]) { + dailyStats[dateKey] = { date: dateKey, income: 0, expense: 0 } + } + const amount = parseFloat(r.amount) + if (r.type === 'income') { + dailyStats[dateKey].income += amount + } else { + dailyStats[dateKey].expense += amount + } + }) + + successResponse(res, Object.values(dailyStats)) + } catch (error) { + console.error('趋势统计错误:', error) + errorResponse(res, '获取趋势统计失败', 500) + } +}) + +// API: GET /api/statistics/compare - 月度对比(当前月与上月的收支对比) +// 入参:userId(必填), month(必填,格式 YYYY-MM) +// 返回:本月/上月的收入和支出,前端用于环比分析 +app.get('/api/statistics/compare', async (req, res) => { + try { + const { userId, month } = req.query + if (!userId || !month) { + return errorResponse(res, 'userId和month为必填参数') + } + + // 解析当前月份参数 + const currentParts = month.split('-') + const currentYear = parseInt(currentParts[0]) + const currentMonthNum = parseInt(currentParts[1]) + + // 计算上月年份和月份(处理跨年场景:1月的上月是去年12月) + const lastMonthYear = currentMonthNum === 1 ? currentYear - 1 : currentYear + const lastMonthNum = currentMonthNum === 1 ? 12 : currentMonthNum - 1 + + // 当前月日期范围(1号 00:00 至 最后一天 23:59:59) + const currentStart = new Date(currentYear, currentMonthNum - 1, 1) + const currentEnd = new Date(currentYear, currentMonthNum, 0, 23, 59, 59) + + // 上月日期范围 + const lastStart = new Date(lastMonthYear, lastMonthNum - 1, 1) + const lastEnd = new Date(lastMonthYear, lastMonthNum, 0, 23, 59, 59) + + // 并行查询本月和上月数据,减少数据库往返次数 + const [currentRecords, lastRecords] = await Promise.all([ + prisma.record.findMany({ + where: { userId: parseInt(userId), date: { gte: currentStart, lte: currentEnd } } + }), + prisma.record.findMany({ + where: { userId: parseInt(userId), date: { gte: lastStart, lte: lastEnd } } + }) + ]) + + // 聚合函数:计算指定记录集合的收入和支出总和 + const aggregate = (records) => { + let income = 0, expense = 0 + records.forEach(r => { + const amount = parseFloat(r.amount) + if (r.type === 'income') income += amount + else expense += amount + }) + return { income, expense } + } + + const current = aggregate(currentRecords) + const last = aggregate(lastRecords) + + successResponse(res, { + currentMonth: { + label: `${currentMonthNum}月`, + income: current.income, + expense: current.expense + }, + lastMonth: { + label: `${lastMonthNum}月`, + income: last.income, + expense: last.expense + } + }) + } catch (error) { + console.error('月度对比错误:', error) + errorResponse(res, '获取月度对比失败', 500) + } +}) + +// ============================================================================ +// 仪表盘模块 /api/dashboard/summary +// 职责:聚合多源数据(账户余额/本月收支/预算使用率),为首页仪表盘提供一次性数据 +// 性能优化:使用 Promise.all 并行查询,减少数据库往返次数 +// ============================================================================ + +// API: GET /api/dashboard/summary - 仪表盘汇总数据 +// 入参:userId(必填) +// 返回:总余额、本月收支、账户列表、预算使用情况 +app.get('/api/dashboard/summary', async (req, res) => { + try { + const { userId } = req.query + if (!userId) { + return errorResponse(res, 'userId为必填参数') + } + + // 计算当前月份的起止日期,用于本月收支统计 + const now = new Date() + const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` + const startDate = new Date(month + '-01') + const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0) + + // 并行查询三类数据:账户列表、本月交易记录、本月预算 + const [accounts, records, budgets] = await Promise.all([ + prisma.account.findMany({ where: { userId: parseInt(userId) } }), + prisma.record.findMany({ + where: { + userId: parseInt(userId), + date: { gte: startDate, lte: endDate } + } + }), + prisma.budget.findMany({ + where: { userId: parseInt(userId), month } + }) + ]) + + // 计算所有账户的总余额 + const totalBalance = accounts.reduce((sum, a) => sum + parseFloat(a.balance), 0) + + // 聚合本月收入和支出 + let monthIncome = 0 + let monthExpense = 0 + records.forEach(r => { + const amount = parseFloat(r.amount) + if (r.type === 'income') monthIncome += amount + else monthExpense += amount + }) + + // 计算预算使用率:按分类匹配本月支出,计算已用金额和百分比 + const budgetUsage = budgets.map(b => { + const spent = records + .filter(r => r.type === 'expense' && r.category === b.category) + .reduce((sum, r) => sum + parseFloat(r.amount), 0) + return { + ...b, + amount: parseFloat(b.amount), + spent, + // 使用率上限为 100%,避免超支后百分比溢出 + percentage: Math.min((spent / parseFloat(b.amount)) * 100, 100) + } + }) + + successResponse(res, { + totalBalance, + monthIncome, + monthExpense, + accounts, + budgetUsage + }) + } catch (error) { + errorResponse(res, '获取仪表盘数据失败', 500) + } +}) + +// ============================================================================ +// 工具接口:测试数据初始化(仅开发环境使用) +// 安全提醒:生产环境必须移除此接口,禁止外部触发数据初始化 +// ============================================================================ + +// API: GET /api/init-test-data - 手动初始化测试数据 +app.get('/api/init-test-data', async (req, res) => { + try { + console.log('🧪 初始化测试数据...'); + + // 幂等性检查:已有用户数据则跳过初始化 + const existingUsers = await prisma.user.findMany(); + if (existingUsers.length > 0) { + return successResponse(res, { userId: existingUsers[0].id }, '已有数据,无需初始化'); + } + + const today = new Date(); + + // 1. 创建测试用户 + const user = await prisma.user.create({ + data: { + name: '测试用户', + email: 'test@example.com', + }, + }); + + // 2. 并行创建三个账户(支付宝/微信钱包/招商银行) + const accounts = await Promise.all([ + prisma.account.create({ + data: { + userId: user.id, + name: '支付宝', + type: 'payment', + color: '#1890FF', + balance: 5000, + }, + }), + prisma.account.create({ + data: { + userId: user.id, + name: '微信钱包', + type: 'payment', + color: '#52C41A', + balance: 3000, + }, + }), + prisma.account.create({ + data: { + userId: user.id, + name: '招商银行', + type: 'bank', + color: '#FAAD14', + balance: 10000, + }, + }), + ]); + + // 3. 并行创建交易记录(2笔收入 + 4笔支出,覆盖多个分类) + const month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; + await Promise.all([ + prisma.record.create({ + data: { + userId: user.id, + accountId: accounts[0].id, + type: 'income', + amount: 8500, + category: '工资', + description: '2026年4月工资', + date: new Date(today.getFullYear(), today.getMonth(), 1), + }, + }), + prisma.record.create({ + data: { + userId: user.id, + accountId: accounts[0].id, + type: 'income', + amount: 500, + category: '奖金', + description: '绩效奖金', + date: new Date(today.getFullYear(), today.getMonth(), 5), + }, + }), + prisma.record.create({ + data: { + userId: user.id, + accountId: accounts[0].id, + type: 'expense', + amount: 68, + category: '餐饮', + description: '午饭', + date: today, + }, + }), + prisma.record.create({ + data: { + userId: user.id, + accountId: accounts[1].id, + type: 'expense', + amount: 25, + category: '交通', + description: '打车', + date: today, + }, + }), + prisma.record.create({ + data: { + userId: user.id, + accountId: accounts[0].id, + type: 'expense', + amount: 299, + category: '购物', + description: '买衣服', + date: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 2), + }, + }), + prisma.record.create({ + data: { + userId: user.id, + accountId: accounts[1].id, + type: 'expense', + amount: 128, + category: '娱乐', + description: '游戏充值', + date: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 3), + }, + }), + ]); + + // 4. 并行创建预算(餐饮/交通/购物/娱乐四个分类) + await Promise.all([ + prisma.budget.create({ + data: { + userId: user.id, + category: '餐饮', + amount: 1500, + month, + }, + }), + prisma.budget.create({ + data: { + userId: user.id, + category: '交通', + amount: 500, + month, + }, + }), + prisma.budget.create({ + data: { + userId: user.id, + category: '购物', + amount: 1000, + month, + }, + }), + prisma.budget.create({ + data: { + userId: user.id, + category: '娱乐', + amount: 500, + month, + }, + }), + ]); + + console.log('✅ 测试数据初始化完成!'); + + successResponse(res, { + userId: user.id, + message: '测试数据初始化成功' + }, '数据初始化成功'); + } catch (error) { + console.error('❌ 初始化失败:', error); + errorResponse(res, '初始化失败: ' + error.message, 500); + } +}); + +// ============================================================================ +// 服务启动 +// ============================================================================ + +// 启动 HTTP 服务,监听指定端口 +// 启动后自动检查是否需要初始化测试数据(冷启动场景) +app.listen(PORT, async () => { + console.log(`🚀 Server is running on http://localhost:${PORT}`); + + // 自动初始化:首次启动且数据库无数据时,创建默认测试数据 + console.log('🔍 检查是否需要初始化测试数据...'); + const users = await prisma.user.findMany(); + if (users.length === 0) { + console.log('📝 暂无数据,正在自动初始化测试数据...'); + + try { + const today = new Date(); + const month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; + + // 创建测试用户 + const user = await prisma.user.create({ + data: { name: '测试用户', email: 'test@example.com' }, + }); + + // 并行创建账户(与手动初始化接口逻辑一致) + const accounts = await Promise.all([ + prisma.account.create({ + data: { userId: user.id, name: '支付宝', type: 'payment', color: '#1890FF', balance: 5000 }, + }), + prisma.account.create({ + data: { userId: user.id, name: '微信钱包', type: 'payment', color: '#52C41A', balance: 3000 }, + }), + prisma.account.create({ + data: { userId: user.id, name: '招商银行', type: 'bank', color: '#FAAD14', balance: 10000 }, + }), + ]); + + // 并行创建交易记录 + await Promise.all([ + prisma.record.create({ + data: { userId: user.id, accountId: accounts[0].id, type: 'income', amount: 8500, category: '工资', description: '2026年4月工资', date: new Date(today.getFullYear(), today.getMonth(), 1) }, + }), + prisma.record.create({ + data: { userId: user.id, accountId: accounts[0].id, type: 'income', amount: 500, category: '奖金', description: '绩效奖金', date: new Date(today.getFullYear(), today.getMonth(), 5) }, + }), + prisma.record.create({ + data: { userId: user.id, accountId: accounts[0].id, type: 'expense', amount: 68, category: '餐饮', description: '午饭', date: today }, + }), + prisma.record.create({ + data: { userId: user.id, accountId: accounts[1].id, type: 'expense', amount: 25, category: '交通', description: '打车', date: today }, + }), + prisma.record.create({ + data: { userId: user.id, accountId: accounts[0].id, type: 'expense', amount: 299, category: '购物', description: '买衣服', date: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 2) }, + }), + prisma.record.create({ + data: { userId: user.id, accountId: accounts[1].id, type: 'expense', amount: 128, category: '娱乐', description: '游戏充值', date: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 3) }, + }), + ]); + + // 并行创建预算 + await Promise.all([ + prisma.budget.create({ data: { userId: user.id, category: '餐饮', amount: 1500, month } }), + prisma.budget.create({ data: { userId: user.id, category: '交通', amount: 500, month } }), + prisma.budget.create({ data: { userId: user.id, category: '购物', amount: 1000, month } }), + prisma.budget.create({ data: { userId: user.id, category: '娱乐', amount: 500, month } }), + ]); + + console.log('✅ 测试数据已自动创建!User ID:', user.id); + } catch (error) { + // 初始化失败不阻塞服务启动,仅记录日志 + console.error('❌ 自动初始化失败:', error); + } + } +});