library-picturebook-activity/backend/src/contests/analytics/analytics.service.ts

297 lines
11 KiB
TypeScript
Raw Normal View History

Day5: 租户端全面优化 + 数据统计看板 + 成果发布完善 租户端基础设施: - 新增工作台首页(欢迎信息/统计/待办/快捷操作/新手引导) - 新增机构信息管理页(自助查看编辑机构信息) - 修复403报错(fetchTenants加超管守卫) - 修复权限(log:read/notice:update/notice:delete/contest:work:read) - 修复评审规则组件映射 活动管理全模块优化(机构端视角): - 活动列表:加统计概览+精简列+筛选自动查询+发布弹窗修复+操作逻辑优化 - 创建/编辑活动:重构布局(去card嵌套+栅格响应式+分区卡片) - 评委管理:统一主色调+冻结确认+导入导出disabled - 报名管理:去Tab+统计+审核状态列+批量审核接口 - 报名记录:统计概览+去机构列+撤销审核+返回按钮+去参与方式列 - 作品管理:去Tab+统计+递交进度彩色+筛选修复(assignStatus/submitTime) - 评审进度:去Tab+统计+实际完成率状态+筛选修复 - 评审规则:表格加评委数/计算方式+描述列修复+删除保护 - 成果发布:去Tab+统计+操作文案优化 - 通知公告:统一主色调+发布确认+操作逻辑+状态筛选+时间范围 成果发布详情功能补全: - 计算得分/排名/设置奖项三步操作流程 - 排名列(金银铜徽章)+奖项列+奖项筛选 - 自定义奖项(动态添加行替代硬编码一二三等奖) - 后端AutoSetAwardsDto改为awards数组格式 数据统计看板(新模块): - 后端analytics module(overview+review两个接口) - 运营概览:6指标卡片+报名转化漏斗+ECharts月度趋势+活动对比表 - 评审分析:4效率卡片+评委工作量表+ECharts奖项分布饼图 - 菜单注册:数据统计→运营概览+评审分析 Bug修复: - 超管重置其他租户用户密码报"用户不存在" - gdlib登录快捷标签密码不一致 - 分配评委去掉评审时间限制 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 20:02:24 +08:00
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,
};
}
}