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