| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- 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<Buffer> {
- 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<Buffer> {
- 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;
|