import { Router } from 'express'; import { query } from 'express-validator'; import { AnalyticsService } from '../services/AnalyticsService.js'; import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js'; import { authenticate } from '../middleware/auth.js'; import { asyncHandler } from '../middleware/error.js'; import { validateRequest } from '../middleware/validate.js'; const router = Router(); const analyticsService = new AnalyticsService(); const workDayStatisticsService = new WorkDayStatisticsService(); router.use(authenticate); // 在 Node 中声明全局 fetch,避免 TypeScript 编译错误(运行时使用 Node 18+ 自带的 fetch) // eslint-disable-next-line @typescript-eslint/no-explicit-any declare const fetch: any; /** * 调用本地 Python 统计服务的工具函数 * 默认地址: http://localhost:5005 */ async function callPythonAnalyticsApi(pathname: string, params: Record) { const base = process.env.PYTHON_API_URL || 'http://localhost:5005'; const url = new URL(base); url.pathname = pathname.startsWith('/') ? pathname : `/${pathname}`; const search = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { search.append(key, String(value)); } }); url.search = search.toString(); try { const resp = await fetch(url.toString(), { method: 'GET' }); // 检查响应状态 if (!resp.ok) { // 尝试解析 JSON 错误响应 let errorData: any; try { errorData = await resp.json(); } catch { // 如果不是 JSON,读取文本内容 const text = await resp.text(); errorData = { success: false, error: `Python API 返回错误 (${resp.status}): ${text.substring(0, 500)}`, }; } throw new Error(errorData.error || `Python API 返回错误: ${resp.status} ${resp.statusText}`); } // 检查 Content-Type const contentType = resp.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { const text = await resp.text(); throw new Error(`Python API 返回非 JSON 响应: ${text.substring(0, 500)}`); } const json = await resp.json(); return json; } catch (error: any) { // 如果是网络错误(连接失败、超时等) if (error.name === 'TypeError' && error.message.includes('fetch')) { throw new Error(`无法连接 Python API (${base}): ${error.message}`); } // 其他错误直接抛出 throw error; } } // 获取汇总统计(直接走 Node 本地统计) router.get( '/summary', [ query('startDate').notEmpty().withMessage('开始日期不能为空'), query('endDate').notEmpty().withMessage('结束日期不能为空'), validateRequest, ], asyncHandler(async (req, res) => { const { startDate, endDate, accountId, platform } = req.query; const summary = await analyticsService.getSummary(req.user!.userId, { startDate: startDate as string, endDate: endDate as string, accountId: accountId ? Number(accountId) : undefined, platform: platform as string, }); res.json({ success: true, data: summary }); }) ); /** * GET /api/analytics/trend-from-python * 通过 Node 转发到 Python 的 /work_day_statistics/trend * 前端只需要传 startDate / endDate,userId 由 JWT 决定 */ router.get( '/trend-from-python', [ query('startDate').notEmpty().withMessage('开始日期不能为空'), query('endDate').notEmpty().withMessage('结束日期不能为空'), validateRequest, ], asyncHandler(async (req, res) => { const { startDate, endDate } = req.query; try { const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/trend', { user_id: req.user!.userId, start_date: String(startDate), end_date: String(endDate), }); if (!pythonResult || pythonResult.success === false) { return res.status(500).json({ success: false, error: pythonResult?.error || '获取数据趋势失败', message: pythonResult?.error || '获取数据趋势失败', }); } return res.json({ success: true, data: pythonResult.data, }); } catch (error: any) { console.error('[trend-from-python] 调用 Python API 失败:', error); return res.status(500).json({ success: false, error: error.message || '调用 Python API 失败', message: error.message || '调用 Python API 失败', }); } }) ); // 获取趋势数据(直接走 Node 本地统计) router.get( '/trend', [ query('startDate').notEmpty().withMessage('开始日期不能为空'), query('endDate').notEmpty().withMessage('结束日期不能为空'), validateRequest, ], asyncHandler(async (req, res) => { const { startDate, endDate, accountId, platform } = req.query; const trend = await analyticsService.getTrend(req.user!.userId, { startDate: startDate as string, endDate: endDate as string, accountId: accountId ? Number(accountId) : undefined, platform: platform as string, }); res.json({ success: true, data: trend }); }) ); /** * GET /api/analytics/platforms-from-python * 通过 Node 转发到 Python 的 /work_day_statistics/platforms * 前端只需要传 startDate / endDate,userId 由 JWT 决定 */ router.get( '/platforms-from-python', [ query('startDate').notEmpty().withMessage('开始日期不能为空'), query('endDate').notEmpty().withMessage('结束日期不能为空'), validateRequest, ], asyncHandler(async (req, res) => { const { startDate, endDate } = req.query; try { // 口径切换:平台数据不再转发 Python,直接使用 Node 本地的 user_day_statistics 统计 const data = await workDayStatisticsService.getStatisticsByPlatform(req.user!.userId, { startDate: String(startDate), endDate: String(endDate), }); return res.json({ success: true, data }); } catch (error: any) { // 捕获并返回详细的错误信息 console.error('[platforms-from-python] 调用 Python API 失败:', error); return res.status(500).json({ success: false, error: error.message || '调用 Python API 失败', message: error.message || '调用 Python API 失败', }); } }) ); // 获取平台对比(直接走 Node 本地统计) router.get( '/platforms', [ query('startDate').notEmpty().withMessage('开始日期不能为空'), query('endDate').notEmpty().withMessage('结束日期不能为空'), validateRequest, ], asyncHandler(async (req, res) => { const { startDate, endDate } = req.query; const comparison = await analyticsService.getPlatformComparison(req.user!.userId, { startDate: startDate as string, endDate: endDate as string, }); res.json({ success: true, data: comparison }); }) ); // 导出报表 router.get( '/export', [ query('startDate').notEmpty().withMessage('开始日期不能为空'), query('endDate').notEmpty().withMessage('结束日期不能为空'), validateRequest, ], asyncHandler(async (req, res) => { const { startDate, endDate, accountId, format = 'csv' } = req.query; const data = await analyticsService.exportData(req.user!.userId, { startDate: startDate as string, endDate: endDate as string, accountId: accountId ? Number(accountId) : undefined, format: format as string, }); res.setHeader('Content-Type', format === 'csv' ? 'text/csv' : 'application/json'); res.setHeader('Content-Disposition', `attachment; filename=analytics_${startDate}_${endDate}.${format}`); res.send(data); }) ); export default router;