Files
gerenjizhang/backend/src/index.js
T

1216 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 个人财务预算系统 - 后端服务入口
*
* 技术栈: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<String>} 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 默认 0color 有默认色值
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);
}
}
});