analytics.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { Router } from 'express';
  2. import { query } from 'express-validator';
  3. import { AnalyticsService } from '../services/AnalyticsService.js';
  4. import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js';
  5. import { authenticate } from '../middleware/auth.js';
  6. import { asyncHandler } from '../middleware/error.js';
  7. import { validateRequest } from '../middleware/validate.js';
  8. const router = Router();
  9. const analyticsService = new AnalyticsService();
  10. const workDayStatisticsService = new WorkDayStatisticsService();
  11. router.use(authenticate);
  12. // 在 Node 中声明全局 fetch,避免 TypeScript 编译错误(运行时使用 Node 18+ 自带的 fetch)
  13. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  14. declare const fetch: any;
  15. /**
  16. * 调用本地 Python 统计服务的工具函数
  17. * 默认地址: http://localhost:5005
  18. */
  19. async function callPythonAnalyticsApi(pathname: string, params: Record<string, string | number | undefined>) {
  20. const base = process.env.PYTHON_API_URL || 'http://localhost:5005';
  21. const url = new URL(base);
  22. url.pathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
  23. const search = new URLSearchParams();
  24. Object.entries(params).forEach(([key, value]) => {
  25. if (value !== undefined && value !== null) {
  26. search.append(key, String(value));
  27. }
  28. });
  29. url.search = search.toString();
  30. try {
  31. const resp = await fetch(url.toString(), { method: 'GET' });
  32. // 检查响应状态
  33. if (!resp.ok) {
  34. // 尝试解析 JSON 错误响应
  35. let errorData: any;
  36. try {
  37. errorData = await resp.json();
  38. } catch {
  39. // 如果不是 JSON,读取文本内容
  40. const text = await resp.text();
  41. errorData = {
  42. success: false,
  43. error: `Python API 返回错误 (${resp.status}): ${text.substring(0, 500)}`,
  44. };
  45. }
  46. throw new Error(errorData.error || `Python API 返回错误: ${resp.status} ${resp.statusText}`);
  47. }
  48. // 检查 Content-Type
  49. const contentType = resp.headers.get('content-type') || '';
  50. if (!contentType.includes('application/json')) {
  51. const text = await resp.text();
  52. throw new Error(`Python API 返回非 JSON 响应: ${text.substring(0, 500)}`);
  53. }
  54. const json = await resp.json();
  55. return json;
  56. } catch (error: any) {
  57. // 如果是网络错误(连接失败、超时等)
  58. if (error.name === 'TypeError' && error.message.includes('fetch')) {
  59. throw new Error(`无法连接 Python API (${base}): ${error.message}`);
  60. }
  61. // 其他错误直接抛出
  62. throw error;
  63. }
  64. }
  65. // 获取汇总统计(直接走 Node 本地统计)
  66. router.get(
  67. '/summary',
  68. [
  69. query('startDate').notEmpty().withMessage('开始日期不能为空'),
  70. query('endDate').notEmpty().withMessage('结束日期不能为空'),
  71. validateRequest,
  72. ],
  73. asyncHandler(async (req, res) => {
  74. const { startDate, endDate, accountId, platform } = req.query;
  75. const summary = await analyticsService.getSummary(req.user!.userId, {
  76. startDate: startDate as string,
  77. endDate: endDate as string,
  78. accountId: accountId ? Number(accountId) : undefined,
  79. platform: platform as string,
  80. });
  81. res.json({ success: true, data: summary });
  82. })
  83. );
  84. /**
  85. * GET /api/analytics/trend-from-python
  86. * 通过 Node 转发到 Python 的 /work_day_statistics/trend
  87. * 前端只需要传 startDate / endDate,userId 由 JWT 决定
  88. */
  89. router.get(
  90. '/trend-from-python',
  91. [
  92. query('startDate').notEmpty().withMessage('开始日期不能为空'),
  93. query('endDate').notEmpty().withMessage('结束日期不能为空'),
  94. validateRequest,
  95. ],
  96. asyncHandler(async (req, res) => {
  97. const { startDate, endDate } = req.query;
  98. try {
  99. const pythonResult = await callPythonAnalyticsApi('/work_day_statistics/trend', {
  100. user_id: req.user!.userId,
  101. start_date: String(startDate),
  102. end_date: String(endDate),
  103. });
  104. if (!pythonResult || pythonResult.success === false) {
  105. return res.status(500).json({
  106. success: false,
  107. error: pythonResult?.error || '获取数据趋势失败',
  108. message: pythonResult?.error || '获取数据趋势失败',
  109. });
  110. }
  111. return res.json({
  112. success: true,
  113. data: pythonResult.data,
  114. });
  115. } catch (error: any) {
  116. console.error('[trend-from-python] 调用 Python API 失败:', error);
  117. return res.status(500).json({
  118. success: false,
  119. error: error.message || '调用 Python API 失败',
  120. message: error.message || '调用 Python API 失败',
  121. });
  122. }
  123. })
  124. );
  125. // 获取趋势数据(直接走 Node 本地统计)
  126. router.get(
  127. '/trend',
  128. [
  129. query('startDate').notEmpty().withMessage('开始日期不能为空'),
  130. query('endDate').notEmpty().withMessage('结束日期不能为空'),
  131. validateRequest,
  132. ],
  133. asyncHandler(async (req, res) => {
  134. const { startDate, endDate, accountId, platform } = req.query;
  135. const trend = await analyticsService.getTrend(req.user!.userId, {
  136. startDate: startDate as string,
  137. endDate: endDate as string,
  138. accountId: accountId ? Number(accountId) : undefined,
  139. platform: platform as string,
  140. });
  141. res.json({ success: true, data: trend });
  142. })
  143. );
  144. /**
  145. * GET /api/analytics/platforms-from-python
  146. * 通过 Node 转发到 Python 的 /work_day_statistics/platforms
  147. * 前端只需要传 startDate / endDate,userId 由 JWT 决定
  148. */
  149. router.get(
  150. '/platforms-from-python',
  151. [
  152. query('startDate').notEmpty().withMessage('开始日期不能为空'),
  153. query('endDate').notEmpty().withMessage('结束日期不能为空'),
  154. validateRequest,
  155. ],
  156. asyncHandler(async (req, res) => {
  157. const { startDate, endDate } = req.query;
  158. try {
  159. // 口径切换:平台数据不再转发 Python,直接使用 Node 本地的 user_day_statistics 统计
  160. const data = await workDayStatisticsService.getStatisticsByPlatform(req.user!.userId, {
  161. startDate: String(startDate),
  162. endDate: String(endDate),
  163. });
  164. return res.json({ success: true, data });
  165. } catch (error: any) {
  166. // 捕获并返回详细的错误信息
  167. console.error('[platforms-from-python] 调用 Python API 失败:', error);
  168. return res.status(500).json({
  169. success: false,
  170. error: error.message || '调用 Python API 失败',
  171. message: error.message || '调用 Python API 失败',
  172. });
  173. }
  174. })
  175. );
  176. // 获取平台对比(直接走 Node 本地统计)
  177. router.get(
  178. '/platforms',
  179. [
  180. query('startDate').notEmpty().withMessage('开始日期不能为空'),
  181. query('endDate').notEmpty().withMessage('结束日期不能为空'),
  182. validateRequest,
  183. ],
  184. asyncHandler(async (req, res) => {
  185. const { startDate, endDate } = req.query;
  186. const comparison = await analyticsService.getPlatformComparison(req.user!.userId, {
  187. startDate: startDate as string,
  188. endDate: endDate as string,
  189. });
  190. res.json({ success: true, data: comparison });
  191. })
  192. );
  193. // 导出报表
  194. router.get(
  195. '/export',
  196. [
  197. query('startDate').notEmpty().withMessage('开始日期不能为空'),
  198. query('endDate').notEmpty().withMessage('结束日期不能为空'),
  199. validateRequest,
  200. ],
  201. asyncHandler(async (req, res) => {
  202. const { startDate, endDate, accountId, format = 'csv' } = req.query;
  203. const data = await analyticsService.exportData(req.user!.userId, {
  204. startDate: startDate as string,
  205. endDate: endDate as string,
  206. accountId: accountId ? Number(accountId) : undefined,
  207. format: format as string,
  208. });
  209. res.setHeader('Content-Type', format === 'csv' ? 'text/csv' : 'application/json');
  210. res.setHeader('Content-Disposition', `attachment; filename=analytics_${startDate}_${endDate}.${format}`);
  211. res.send(data);
  212. })
  213. );
  214. export default router;