library-picturebook-activity/backend/src/contests/analytics/analytics.service.ts
aid 9215465bd5 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

297 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}
}