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