analytics.ts 7.8 KB

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