workDayStatistics.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import { Router } from 'express';
  2. import { query } from 'express-validator';
  3. import { spawn } from 'child_process';
  4. import path from 'path';
  5. import { fileURLToPath } from 'url';
  6. import { authenticate } from '../middleware/auth.js';
  7. import { asyncHandler } from '../middleware/error.js';
  8. import { validateRequest } from '../middleware/validate.js';
  9. import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js';
  10. import { logger } from '../utils/logger.js';
  11. /**
  12. * Work day statistics(原 Python 统计接口的 Node 版本)
  13. * 目的:避免前端强依赖本地 Python 服务(:5005)
  14. */
  15. const router = Router();
  16. const workDayStatisticsService = new WorkDayStatisticsService();
  17. const __filename = fileURLToPath(import.meta.url);
  18. const __dirname = path.dirname(__filename);
  19. // 所有路由需要认证
  20. router.use(authenticate);
  21. function runPythonExportXlsx(payload: unknown): Promise<Buffer> {
  22. const pythonBin = process.env.PYTHON_BIN || 'python';
  23. const scriptPath = path.resolve(__dirname, '../../python/export_work_day_overview_xlsx.py');
  24. return new Promise((resolve, reject) => {
  25. const child = spawn(pythonBin, [scriptPath], {
  26. stdio: ['pipe', 'pipe', 'pipe'],
  27. windowsHide: true,
  28. });
  29. const stdoutChunks: Buffer[] = [];
  30. const stderrChunks: Buffer[] = [];
  31. child.stdout.on('data', (d) => stdoutChunks.push(Buffer.from(d)));
  32. child.stderr.on('data', (d) => stderrChunks.push(Buffer.from(d)));
  33. child.on('error', (err) => {
  34. reject(err);
  35. });
  36. child.on('close', (code) => {
  37. if (code === 0) {
  38. resolve(Buffer.concat(stdoutChunks));
  39. return;
  40. }
  41. const stderr = Buffer.concat(stderrChunks).toString('utf-8').slice(0, 4000);
  42. reject(new Error(`Python export failed (code=${code}). ${stderr}`));
  43. });
  44. // stdin 写入 JSON(注意:不要写入额外换行/日志到 stdout)
  45. try {
  46. child.stdin.write(JSON.stringify(payload ?? {}), 'utf-8');
  47. child.stdin.end();
  48. } catch (e) {
  49. child.kill();
  50. reject(e);
  51. }
  52. });
  53. }
  54. function runPythonExportPlatformXlsx(payload: unknown): Promise<Buffer> {
  55. const pythonBin = process.env.PYTHON_BIN || 'python';
  56. const scriptPath = path.resolve(__dirname, '../../python/export_platform_statistics_xlsx.py');
  57. return new Promise((resolve, reject) => {
  58. const child = spawn(pythonBin, [scriptPath], {
  59. stdio: ['pipe', 'pipe', 'pipe'],
  60. windowsHide: true,
  61. });
  62. const stdoutChunks: Buffer[] = [];
  63. const stderrChunks: Buffer[] = [];
  64. child.stdout.on('data', (d) => stdoutChunks.push(Buffer.from(d)));
  65. child.stderr.on('data', (d) => stderrChunks.push(Buffer.from(d)));
  66. child.on('error', (err) => {
  67. reject(err);
  68. });
  69. child.on('close', (code) => {
  70. if (code === 0) {
  71. resolve(Buffer.concat(stdoutChunks));
  72. return;
  73. }
  74. const stderr = Buffer.concat(stderrChunks).toString('utf-8').slice(0, 4000);
  75. reject(new Error(`Python export failed (code=${code}). ${stderr}`));
  76. });
  77. try {
  78. child.stdin.write(JSON.stringify(payload ?? {}), 'utf-8');
  79. child.stdin.end();
  80. } catch (e) {
  81. child.kill();
  82. reject(e);
  83. }
  84. });
  85. }
  86. /**
  87. * GET /api/work-day-statistics/overview
  88. * 获取数据总览(账号列表和汇总统计)
  89. *
  90. * 兼容前端可能传入的 user_id,但服务端始终以 JWT 用户为准。
  91. */
  92. router.get(
  93. '/overview',
  94. [
  95. // 前端历史遗留参数:不强制,但如果传了也应为数字
  96. query('user_id').optional().isInt().withMessage('user_id 必须是整数'),
  97. validateRequest,
  98. ],
  99. asyncHandler(async (req, res) => {
  100. const data = await workDayStatisticsService.getOverview(req.user!.userId);
  101. res.json({ success: true, data });
  102. })
  103. );
  104. /**
  105. * GET /api/work-day-statistics/platforms/export
  106. * 导出“平台数据”xlsx(Node 调用 Python 生成)
  107. *
  108. * 查询参数:
  109. * - startDate: 开始日期(必填)
  110. * - endDate: 结束日期(必填)
  111. */
  112. router.get(
  113. '/platforms/export',
  114. [
  115. query('startDate').notEmpty().withMessage('startDate 不能为空'),
  116. query('endDate').notEmpty().withMessage('endDate 不能为空'),
  117. validateRequest,
  118. ],
  119. asyncHandler(async (req, res) => {
  120. const { startDate, endDate } = req.query;
  121. const data = await workDayStatisticsService.getStatisticsByPlatform(req.user!.userId, {
  122. startDate: startDate as string,
  123. endDate: endDate as string,
  124. });
  125. const exportPayload = {
  126. platforms: data.map((p) => ({
  127. platform: p.platform || '',
  128. viewsCount: p.viewsCount,
  129. commentsCount: p.commentsCount,
  130. likesCount: p.likesCount,
  131. fansIncrease: p.fansIncrease,
  132. // 使用 endDate 作为“更新时间”展示(仅日期)
  133. updateTime: p.updateTime || '',
  134. })),
  135. };
  136. const xlsxBuffer = await runPythonExportPlatformXlsx(exportPayload);
  137. const now = new Date();
  138. const yyyy = now.getFullYear();
  139. const mm = String(now.getMonth() + 1).padStart(2, '0');
  140. const dd = String(now.getDate()).padStart(2, '0');
  141. const hh = String(now.getHours()).padStart(2, '0');
  142. const mi = String(now.getMinutes()).padStart(2, '0');
  143. const ss = String(now.getSeconds()).padStart(2, '0');
  144. const filename = `platform_statistics_${yyyy}${mm}${dd}_${hh}${mi}${ss}.xlsx`;
  145. res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
  146. res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
  147. res.send(xlsxBuffer);
  148. })
  149. );
  150. /**
  151. * GET /api/work-day-statistics/overview/export
  152. * 导出“数据总览”xlsx(Node 调用 Python 生成)
  153. *
  154. * 可选筛选参数:
  155. * - groupId: 分组ID
  156. * - platform: 平台(douyin/baijiahao/weixin_video/xiaohongshu)
  157. * - keyword: 账号关键字(nickname/username)
  158. */
  159. router.get(
  160. '/overview/export',
  161. [
  162. query('groupId').optional().isInt().withMessage('groupId 必须是整数'),
  163. query('platform').optional().isString().withMessage('platform 必须是字符串'),
  164. query('keyword').optional().isString().withMessage('keyword 必须是字符串'),
  165. validateRequest,
  166. ],
  167. asyncHandler(async (req, res) => {
  168. const data = await workDayStatisticsService.getOverview(req.user!.userId);
  169. const groupId = req.query.groupId ? Number(req.query.groupId) : undefined;
  170. const platform = (req.query.platform as string | undefined) || undefined;
  171. const keyword = ((req.query.keyword as string | undefined) || '').trim().toLowerCase() || undefined;
  172. let accounts = data.accounts || [];
  173. if (groupId) accounts = accounts.filter(a => a.groupId === groupId);
  174. if (platform) accounts = accounts.filter(a => a.platform === platform);
  175. if (keyword) {
  176. accounts = accounts.filter(a =>
  177. (a.nickname || '').toLowerCase().includes(keyword) ||
  178. (a.username || '').toLowerCase().includes(keyword)
  179. );
  180. }
  181. // 组装给 Python 的导出数据(列顺序按截图)
  182. const exportPayload = {
  183. accounts: accounts.map(a => ({
  184. account: a.nickname || a.username || '',
  185. platform: a.platform || '',
  186. totalViews: a.totalViews,
  187. yesterdayViews: a.yesterdayViews,
  188. fansCount: a.fansCount,
  189. yesterdayComments: a.yesterdayComments ?? 0,
  190. yesterdayLikes: a.yesterdayLikes ?? 0,
  191. yesterdayFansIncrease: a.yesterdayFansIncrease ?? 0,
  192. // 交给 Python 解析为 datetime,避免 Excel 显示 #######
  193. updateTime: a.updateTime || '',
  194. })),
  195. };
  196. const xlsxBuffer = await runPythonExportXlsx(exportPayload);
  197. const now = new Date();
  198. const yyyy = now.getFullYear();
  199. const mm = String(now.getMonth() + 1).padStart(2, '0');
  200. const dd = String(now.getDate()).padStart(2, '0');
  201. const hh = String(now.getHours()).padStart(2, '0');
  202. const mi = String(now.getMinutes()).padStart(2, '0');
  203. const ss = String(now.getSeconds()).padStart(2, '0');
  204. const filename = `work_day_overview_${yyyy}${mm}${dd}_${hh}${mi}${ss}.xlsx`;
  205. res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
  206. res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
  207. res.send(xlsxBuffer);
  208. })
  209. );
  210. /**
  211. * GET /api/work-day-statistics/platform-detail
  212. * 获取平台详情数据(汇总统计、每日汇总、账号列表)
  213. *
  214. * 查询参数:
  215. * - platform: 平台类型(必填)
  216. * - startDate: 开始日期(必填)
  217. * - endDate: 结束日期(必填)
  218. */
  219. router.get(
  220. '/platform-detail',
  221. [
  222. query('platform').notEmpty().withMessage('platform 不能为空'),
  223. query('startDate').notEmpty().withMessage('startDate 不能为空'),
  224. query('endDate').notEmpty().withMessage('endDate 不能为空'),
  225. validateRequest,
  226. ],
  227. asyncHandler(async (req, res) => {
  228. const { platform, startDate, endDate } = req.query;
  229. const userId = req.user!.userId;
  230. const data = await workDayStatisticsService.getPlatformDetail(userId, platform as string, {
  231. startDate: startDate as string,
  232. endDate: endDate as string,
  233. });
  234. // 调试日志:确认接口是否有每日汇总数据返回
  235. try {
  236. logger.info('[WorkDayStatistics] /platform-detail response preview', {
  237. userId,
  238. platform,
  239. startDate,
  240. endDate,
  241. dailyCount: data?.dailyData?.length ?? 0,
  242. firstDailyItems: (data?.dailyData || []).slice(0, 5),
  243. summary: data?.summary,
  244. });
  245. } catch {
  246. // 日志失败不影响正常返回
  247. }
  248. res.json({ success: true, data });
  249. })
  250. );
  251. export default router;