/** * 个人财务预算系统 - 后端服务入口 * * 技术栈: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); } } });