269 lines
9.4 KiB
TypeScript
269 lines
9.4 KiB
TypeScript
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
|