import { Router } from 'express'; import { query } from 'express-validator'; import { spawn } from 'child_process'; import path from 'path'; import { fileURLToPath } from 'url'; import { authenticate } from '../middleware/auth.js'; import { asyncHandler } from '../middleware/error.js'; import { validateRequest } from '../middleware/validate.js'; import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js'; import { logger } from '../utils/logger.js'; /** * Work day statistics(原 Python 统计接口的 Node 版本) * 目的:避免前端强依赖本地 Python 服务(:5005) */ const router = Router(); const workDayStatisticsService = new WorkDayStatisticsService(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // 所有路由需要认证 router.use(authenticate); function runPythonExportXlsx(payload: unknown): Promise { const pythonBin = process.env.PYTHON_BIN || 'python'; const scriptPath = path.resolve(__dirname, '../../python/export_work_day_overview_xlsx.py'); return new Promise((resolve, reject) => { const child = spawn(pythonBin, [scriptPath], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; child.stdout.on('data', (d) => stdoutChunks.push(Buffer.from(d))); child.stderr.on('data', (d) => stderrChunks.push(Buffer.from(d))); child.on('error', (err) => { reject(err); }); child.on('close', (code) => { if (code === 0) { resolve(Buffer.concat(stdoutChunks)); return; } const stderr = Buffer.concat(stderrChunks).toString('utf-8').slice(0, 4000); reject(new Error(`Python export failed (code=${code}). ${stderr}`)); }); // stdin 写入 JSON(注意:不要写入额外换行/日志到 stdout) try { child.stdin.write(JSON.stringify(payload ?? {}), 'utf-8'); child.stdin.end(); } catch (e) { child.kill(); reject(e); } }); } function runPythonExportPlatformXlsx(payload: unknown): Promise { const pythonBin = process.env.PYTHON_BIN || 'python'; const scriptPath = path.resolve(__dirname, '../../python/export_platform_statistics_xlsx.py'); return new Promise((resolve, reject) => { const child = spawn(pythonBin, [scriptPath], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; child.stdout.on('data', (d) => stdoutChunks.push(Buffer.from(d))); child.stderr.on('data', (d) => stderrChunks.push(Buffer.from(d))); child.on('error', (err) => { reject(err); }); child.on('close', (code) => { if (code === 0) { resolve(Buffer.concat(stdoutChunks)); return; } const stderr = Buffer.concat(stderrChunks).toString('utf-8').slice(0, 4000); reject(new Error(`Python export failed (code=${code}). ${stderr}`)); }); try { child.stdin.write(JSON.stringify(payload ?? {}), 'utf-8'); child.stdin.end(); } catch (e) { child.kill(); reject(e); } }); } /** * GET /api/work-day-statistics/overview * 获取数据总览(账号列表和汇总统计) * * 兼容前端可能传入的 user_id,但服务端始终以 JWT 用户为准。 */ router.get( '/overview', [ // 前端历史遗留参数:不强制,但如果传了也应为数字 query('user_id').optional().isInt().withMessage('user_id 必须是整数'), validateRequest, ], asyncHandler(async (req, res) => { const data = await workDayStatisticsService.getOverview(req.user!.userId); res.json({ success: true, data }); }) ); /** * GET /api/work-day-statistics/platforms/export * 导出“平台数据”xlsx(Node 调用 Python 生成) * * 查询参数: * - startDate: 开始日期(必填) * - endDate: 结束日期(必填) */ router.get( '/platforms/export', [ query('startDate').notEmpty().withMessage('startDate 不能为空'), query('endDate').notEmpty().withMessage('endDate 不能为空'), validateRequest, ], asyncHandler(async (req, res) => { const { startDate, endDate } = req.query; const data = await workDayStatisticsService.getStatisticsByPlatform(req.user!.userId, { startDate: startDate as string, endDate: endDate as string, }); const exportPayload = { platforms: data.map((p) => ({ platform: p.platform || '', viewsCount: p.viewsCount, commentsCount: p.commentsCount, likesCount: p.likesCount, fansIncrease: p.fansIncrease, // 使用 endDate 作为“更新时间”展示(仅日期) updateTime: p.updateTime || '', })), }; const xlsxBuffer = await runPythonExportPlatformXlsx(exportPayload); const now = new Date(); const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, '0'); const dd = String(now.getDate()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0'); const mi = String(now.getMinutes()).padStart(2, '0'); const ss = String(now.getSeconds()).padStart(2, '0'); const filename = `platform_statistics_${yyyy}${mm}${dd}_${hh}${mi}${ss}.xlsx`; res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(xlsxBuffer); }) ); /** * GET /api/work-day-statistics/overview/export * 导出“数据总览”xlsx(Node 调用 Python 生成) * * 可选筛选参数: * - groupId: 分组ID * - platform: 平台(douyin/baijiahao/weixin_video/xiaohongshu) * - keyword: 账号关键字(nickname/username) */ router.get( '/overview/export', [ query('groupId').optional().isInt().withMessage('groupId 必须是整数'), query('platform').optional().isString().withMessage('platform 必须是字符串'), query('keyword').optional().isString().withMessage('keyword 必须是字符串'), validateRequest, ], asyncHandler(async (req, res) => { const data = await workDayStatisticsService.getOverview(req.user!.userId); const groupId = req.query.groupId ? Number(req.query.groupId) : undefined; const platform = (req.query.platform as string | undefined) || undefined; const keyword = ((req.query.keyword as string | undefined) || '').trim().toLowerCase() || undefined; let accounts = data.accounts || []; if (groupId) accounts = accounts.filter(a => a.groupId === groupId); if (platform) accounts = accounts.filter(a => a.platform === platform); if (keyword) { accounts = accounts.filter(a => (a.nickname || '').toLowerCase().includes(keyword) || (a.username || '').toLowerCase().includes(keyword) ); } // 组装给 Python 的导出数据(列顺序按截图) const exportPayload = { accounts: accounts.map(a => ({ account: a.nickname || a.username || '', platform: a.platform || '', totalViews: a.totalViews, yesterdayViews: a.yesterdayViews, fansCount: a.fansCount, yesterdayComments: a.yesterdayComments ?? 0, yesterdayLikes: a.yesterdayLikes ?? 0, yesterdayFansIncrease: a.yesterdayFansIncrease ?? 0, // 交给 Python 解析为 datetime,避免 Excel 显示 ####### updateTime: a.updateTime || '', })), }; const xlsxBuffer = await runPythonExportXlsx(exportPayload); const now = new Date(); const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, '0'); const dd = String(now.getDate()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0'); const mi = String(now.getMinutes()).padStart(2, '0'); const ss = String(now.getSeconds()).padStart(2, '0'); const filename = `work_day_overview_${yyyy}${mm}${dd}_${hh}${mi}${ss}.xlsx`; res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(xlsxBuffer); }) ); /** * GET /api/work-day-statistics/platform-detail * 获取平台详情数据(汇总统计、每日汇总、账号列表) * * 查询参数: * - platform: 平台类型(必填) * - startDate: 开始日期(必填) * - endDate: 结束日期(必填) */ router.get( '/platform-detail', [ query('platform').notEmpty().withMessage('platform 不能为空'), query('startDate').notEmpty().withMessage('startDate 不能为空'), query('endDate').notEmpty().withMessage('endDate 不能为空'), validateRequest, ], asyncHandler(async (req, res) => { const { platform, startDate, endDate } = req.query; const userId = req.user!.userId; const data = await workDayStatisticsService.getPlatformDetail(userId, platform as string, { startDate: startDate as string, endDate: endDate as string, }); // 调试日志:确认接口是否有每日汇总数据返回 try { logger.info('[WorkDayStatistics] /platform-detail response preview', { userId, platform, startDate, endDate, dailyCount: data?.dailyData?.length ?? 0, firstDailyItems: (data?.dailyData || []).slice(0, 5), summary: data?.summary, }); } catch { // 日志失败不影响正常返回 } res.json({ success: true, data }); }) ); export default router;