import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; @Injectable() export class AnalyticsService { constructor(private prisma: PrismaService) {} /** * 检查活动是否对租户可见 */ private isContestVisibleToTenant(contest: any, tenantId: number): boolean { if (contest.contestState !== 'published') return false; if (!contest.contestTenants) return true; try { const ids = Array.isArray(contest.contestTenants) ? contest.contestTenants : JSON.parse(contest.contestTenants as string); return ids.includes(tenantId); } catch { return false; } } /** * 运营概览 */ async getOverview(tenantId: number, params: { timeRange?: string; contestId?: number }) { const { contestId } = params; // 获取该租户可见的活动 const allContests = await this.prisma.contest.findMany({ where: { contestState: 'published' }, select: { id: true, contestTenants: true, contestState: true, contestName: true }, }); let visibleContestIds = allContests .filter(c => this.isContestVisibleToTenant(c, tenantId)) .map(c => c.id); if (contestId) { visibleContestIds = visibleContestIds.filter(id => id === contestId); } const regWhere: any = { tenantId, contestId: { in: visibleContestIds } }; const workWhere: any = { tenantId, contestId: { in: visibleContestIds }, validState: 1, isLatest: true }; // 核心指标 const [totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks] = await Promise.all([ this.prisma.contestRegistration.count({ where: regWhere }), this.prisma.contestRegistration.count({ where: { ...regWhere, registrationState: 'passed' } }), this.prisma.contestWork.count({ where: workWhere }), this.prisma.contestWork.count({ where: { ...workWhere, status: { in: ['accepted', 'awarded'] } } }), this.prisma.contestWork.count({ where: { ...workWhere, awardName: { not: null } } }), ]); // 漏斗数据 const funnel = { registered: totalRegistrations, passed: passedRegistrations, submitted: totalWorks, reviewed: reviewedWorks, awarded: awardedWorks, }; // 月度趋势(最近6个月) const sixMonthsAgo = new Date(); sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5); sixMonthsAgo.setDate(1); sixMonthsAgo.setHours(0, 0, 0, 0); const registrationsByMonth = await this.prisma.$queryRawUnsafe(` SELECT DATE_FORMAT(registration_time, '%Y-%m') as month, COUNT(*) as count FROM t_contest_registration WHERE tenant_id = ? AND contest_id IN (${visibleContestIds.join(',') || '0'}) AND registration_time >= ? GROUP BY month ORDER BY month `, tenantId, sixMonthsAgo); const worksByMonth = await this.prisma.$queryRawUnsafe(` SELECT DATE_FORMAT(submit_time, '%Y-%m') as month, COUNT(*) as count FROM t_contest_work WHERE tenant_id = ? AND contest_id IN (${visibleContestIds.join(',') || '0'}) AND valid_state = 1 AND is_latest = 1 AND submit_time >= ? GROUP BY month ORDER BY month `, tenantId, sixMonthsAgo); // 构建连续6个月数据 const monthlyTrend: { month: string; registrations: number; works: number }[] = []; for (let i = 0; i < 6; i++) { const d = new Date(); d.setMonth(d.getMonth() - 5 + i); const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; const regRow = registrationsByMonth.find((r: any) => r.month === m); const workRow = worksByMonth.find((r: any) => r.month === m); monthlyTrend.push({ month: m, registrations: Number(regRow?.count || 0), works: Number(workRow?.count || 0), }); } // 活动对比 const contestComparison: any[] = []; for (const cid of visibleContestIds) { const contest = allContests.find(c => c.id === cid); if (!contest) continue; const [regTotal, regPassed, worksTotal, worksReviewed, worksAwarded] = await Promise.all([ this.prisma.contestRegistration.count({ where: { tenantId, contestId: cid } }), this.prisma.contestRegistration.count({ where: { tenantId, contestId: cid, registrationState: 'passed' } }), this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true } }), this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, status: { in: ['accepted', 'awarded'] } } }), this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, awardName: { not: null } } }), ]); const avgScore = await this.prisma.contestWork.aggregate({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, finalScore: { not: null } }, _avg: { finalScore: true }, }); contestComparison.push({ contestId: cid, contestName: contest.contestName, registrations: regTotal, passRate: regTotal > 0 ? Math.round(regPassed / regTotal * 100) : 0, submitRate: regPassed > 0 ? Math.round(worksTotal / regPassed * 100) : 0, reviewRate: worksTotal > 0 ? Math.round(worksReviewed / worksTotal * 100) : 0, awardRate: worksTotal > 0 ? Math.round(worksAwarded / worksTotal * 100) : 0, avgScore: avgScore._avg.finalScore ? Number(Number(avgScore._avg.finalScore).toFixed(2)) : null, }); } return { summary: { totalContests: visibleContestIds.length, totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks, }, funnel, monthlyTrend, contestComparison, }; } /** * 评审分析 */ async getReviewAnalysis(tenantId: number, params: { contestId?: number }) { const { contestId } = params; // 获取可见活动 const allContests = await this.prisma.contest.findMany({ where: { contestState: 'published' }, select: { id: true, contestTenants: true, contestState: true }, }); let visibleContestIds = allContests .filter(c => this.isContestVisibleToTenant(c, tenantId)) .map(c => c.id); if (contestId) { visibleContestIds = visibleContestIds.filter(id => id === contestId); } if (visibleContestIds.length === 0) { return { efficiency: { avgReviewDays: 0, dailyReviewCount: 0, pendingAssignments: 0, avgScoreStddev: 0 }, judgeWorkload: [], awardDistribution: [], }; } const contestIdList = visibleContestIds.join(','); // 评审效率 const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const [pendingAssignments, recentScoreCount] = await Promise.all([ this.prisma.contestWorkJudgeAssignment.count({ where: { contestId: { in: visibleContestIds }, status: 'assigned' }, }), this.prisma.contestWorkScore.count({ where: { contestId: { in: visibleContestIds }, scoreTime: { gte: thirtyDaysAgo } }, }), ]); // 平均评审周期:从作品提交到第一次评分的天数 let avgReviewDays = 0; try { const reviewDaysResult = await this.prisma.$queryRawUnsafe(` SELECT AVG(DATEDIFF(s.score_time, w.submit_time)) as avg_days FROM t_contest_work_score s JOIN t_contest_work w ON s.work_id = w.id WHERE s.contest_id IN (${contestIdList}) AND w.valid_state = 1 `); avgReviewDays = reviewDaysResult[0]?.avg_days ? Number(Number(reviewDaysResult[0].avg_days).toFixed(1)) : 0; } catch { /* */ } // 评分标准差(评委间一致性) let avgScoreStddev = 0; try { const stddevResult = await this.prisma.$queryRawUnsafe(` SELECT AVG(stddev_score) as avg_stddev FROM ( SELECT work_id, STDDEV(total_score) as stddev_score FROM t_contest_work_score WHERE contest_id IN (${contestIdList}) AND valid_state = 1 GROUP BY work_id HAVING COUNT(*) > 1 ) sub `); avgScoreStddev = stddevResult[0]?.avg_stddev ? Number(Number(stddevResult[0].avg_stddev).toFixed(1)) : 0; } catch { /* */ } // 评委工作量 const judges = await this.prisma.contestJudge.findMany({ where: { contestId: { in: visibleContestIds }, validState: 1 }, include: { judge: { select: { id: true, nickname: true, username: true } }, }, }); // 按评委去重 const judgeMap = new Map(); for (const j of judges) { if (!judgeMap.has(j.judgeId)) { judgeMap.set(j.judgeId, { judgeId: j.judgeId, judgeName: j.judge?.nickname || j.judge?.username || '-', contestIds: new Set(), }); } judgeMap.get(j.judgeId).contestIds.add(j.contestId); } const judgeWorkload: any[] = []; for (const [judgeId, info] of judgeMap) { const [assignedCount, scoredCount, scores] = await Promise.all([ this.prisma.contestWorkJudgeAssignment.count({ where: { judgeId, contestId: { in: visibleContestIds } }, }), this.prisma.contestWorkScore.count({ where: { judgeId, contestId: { in: visibleContestIds }, validState: 1 }, }), this.prisma.contestWorkScore.findMany({ where: { judgeId, contestId: { in: visibleContestIds }, validState: 1 }, select: { totalScore: true }, }), ]); const scoreValues = scores.map(s => Number(s.totalScore)); const avg = scoreValues.length > 0 ? scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length : 0; const variance = scoreValues.length > 1 ? scoreValues.reduce((sum, v) => sum + Math.pow(v - avg, 2), 0) / (scoreValues.length - 1) : 0; judgeWorkload.push({ judgeId, judgeName: info.judgeName, contestCount: info.contestIds.size, assignedCount, scoredCount, completionRate: assignedCount > 0 ? Math.round(scoredCount / assignedCount * 100) : 0, avgScore: scoreValues.length > 0 ? Number(avg.toFixed(2)) : null, scoreStddev: scoreValues.length > 1 ? Number(Math.sqrt(variance).toFixed(2)) : 0, }); } // 奖项分布 const awardGroups = await this.prisma.contestWork.groupBy({ by: ['awardName'], where: { tenantId, contestId: { in: visibleContestIds }, validState: 1, isLatest: true, awardName: { not: null } }, _count: { id: true }, }); const totalAwarded = awardGroups.reduce((sum, g) => sum + g._count.id, 0); const awardDistribution = awardGroups.map(g => ({ awardName: g.awardName, count: g._count.id, percentage: totalAwarded > 0 ? Math.round(g._count.id / totalAwarded * 100) : 0, })); return { efficiency: { avgReviewDays, dailyReviewCount: Number((recentScoreCount / 30).toFixed(1)), pendingAssignments, avgScoreStddev, }, judgeWorkload, awardDistribution, }; } }