Files
xiaocai/server/src/routes/stats.ts

269 lines
9.4 KiB
TypeScript
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.
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { getCurrentMonth, getMonthRange } from '../utils/date'
const router = Router()
const VALID_TYPES = ['expense', 'income']
/** 根据 group_id 参数构建 WHERE 条件(含成员验证) */
async function buildWhereClause(
userId: number | undefined,
groupId: string | undefined,
extraConditions: string[] = []
): Promise<{ where: string; params: any[] } | null> {
const params: any[] = []
if (groupId && groupId !== 'null') {
// 验证用户是群组成员
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, userId]
)
if ((memberCheck as any[]).length === 0) return null
// 群组视图:统计所有群组成员的个人账单
params.push(groupId)
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
params.push(userId)
let where = `WHERE t.user_id = ?`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
router.get('/overview', async (req: AuthRequest, res: Response) => {
try {
const { month, startDate: qStart, endDate: qEnd, group_id } = req.query
let startDate: string
let endDate: string
let elapsedDays = 1
if (qStart && qEnd) {
startDate = qStart as string
endDate = qEnd as string
// 计算日期范围天数
const start = new Date(startDate)
const end = new Date(endDate)
elapsedDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000) + 1)
} else {
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
startDate = range.startDate
endDate = range.endDate
const now = new Date()
const [year, mon] = m.split('-').map(Number)
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
}
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as income,
COUNT(*) as count,
SUM(CASE WHEN t.type = 'expense' THEN 1 ELSE 0 END) as expenseCount,
SUM(CASE WHEN t.type = 'income' THEN 1 ELSE 0 END) as incomeCount
FROM transactions t ${result.where}`,
[...result.params, startDate, endDate]
)
const row = (rows as any[])[0]
const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0
const dailyIncome = elapsedDays > 0 ? Math.round(row.income / elapsedDays) : 0
res.json({
code: 0,
data: {
expense: row.expense,
income: row.income,
count: row.count,
daily,
dailyIncome,
expenseCount: row.expenseCount,
incomeCount: row.incomeCount
}
})
} catch (err) {
console.error('[Stats] overview error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.get('/category', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense', group_id } = req.query
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.type = ?', 't.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${result.where}
GROUP BY c.id, c.name, c.icon, c.color
ORDER BY amount DESC`,
[...result.params, type, startDate, endDate]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Stats] category error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.get('/trend', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense', group_id } = req.query
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.type = ?', 't.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, SUM(t.amount) as amount
FROM transactions t
${result.where}
GROUP BY t.date
ORDER BY t.date`,
[...result.params, type, startDate, endDate]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Stats] trend error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 智能分类建议:根据历史记录的金额和备注推荐分类 */
router.get('/suggest-category', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId
const { amount, type = 'expense', note = '' } = req.query
if (!amount || !['expense', 'income'].includes(type as string)) {
return res.status(400).json({ code: 40001, message: '参数错误' })
}
const amt = parseInt(amount as string)
const noteStr = (note as string).trim()
// 1. 关键词匹配:从备注中提取关键词,查找历史记录中最常用的分类
let suggestedCategory: any = null
let confidence = 0
if (noteStr) {
// 提取关键词取前4个字符以上的词
const keywords = noteStr.replace(/[,。.!?\s]+/g, ' ').split(' ').filter(w => w.length >= 1)
if (keywords.length > 0) {
const likeClauses = keywords.map(() => 't.note LIKE ?').join(' OR ')
const likeParams = keywords.map(k => `%${k}%`)
const [rows] = await pool.query(
`SELECT t.category_id, c.name, c.color, COUNT(*) as cnt
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ? AND (${likeClauses})
GROUP BY t.category_id, c.name, c.color
ORDER BY cnt DESC
LIMIT 1`,
[userId, type, ...likeParams]
)
const match = (rows as any[])[0]
if (match) {
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
// 置信度基于匹配次数
confidence = Math.min(90, 40 + match.cnt * 15)
}
}
}
// 2. 金额相近匹配±30%
if (!suggestedCategory) {
const minAmt = Math.floor(amt * 0.7)
const maxAmt = Math.ceil(amt * 1.3)
const [rows] = await pool.query(
`SELECT t.category_id, c.name, c.color, COUNT(*) as cnt
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ? AND t.amount BETWEEN ? AND ?
GROUP BY t.category_id, c.name, c.color
ORDER BY cnt DESC
LIMIT 1`,
[userId, type, minAmt, maxAmt]
)
const match = (rows as any[])[0]
if (match) {
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
confidence = Math.min(70, 20 + match.cnt * 10)
}
}
// 3. 最近使用的分类(兜底)
if (!suggestedCategory) {
const [rows] = await pool.query(
`SELECT t.category_id, c.name, c.color
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ?
ORDER BY t.created_at DESC
LIMIT 1`,
[userId, type]
)
const match = (rows as any[])[0]
if (match) {
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
confidence = 20
}
}
res.json({
code: 0,
data: {
category: suggestedCategory,
confidence
}
})
} catch (err) {
console.error('[Stats] suggest-category error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router