297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
|
|
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<any[]>(`
|
|||
|
|
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<any[]>(`
|
|||
|
|
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<any[]>(`
|
|||
|
|
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<any[]>(`
|
|||
|
|
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<number, any>();
|
|||
|
|
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<number>(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
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,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|