feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
486
backend/app/services/exam_report_service.py
Normal file
486
backend/app/services/exam_report_service.py
Normal file
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
考试报告和错题统计服务
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc, case, text
|
||||
from app.models.exam import Exam
|
||||
from app.models.exam_mistake import ExamMistake
|
||||
from app.models.course import Course, KnowledgePoint
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ExamReportService:
|
||||
"""考试报告服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_exam_report(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取成绩报告汇总数据
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
start_date: 开始日期(YYYY-MM-DD)
|
||||
end_date: 结束日期(YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
Dict: 包含overview、trends、subjects、recent_exams的完整报告数据
|
||||
"""
|
||||
logger.info(f"获取成绩报告 - user_id: {user_id}, start_date: {start_date}, end_date: {end_date}")
|
||||
|
||||
# 构建基础查询条件
|
||||
conditions = [Exam.user_id == user_id]
|
||||
|
||||
# 添加时间范围条件
|
||||
if start_date:
|
||||
conditions.append(Exam.start_time >= start_date)
|
||||
if end_date:
|
||||
# 结束日期包含当天全部
|
||||
end_datetime = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
conditions.append(Exam.start_time < end_datetime)
|
||||
|
||||
# 1. 获取概览数据
|
||||
overview = await ExamReportService._get_overview(db, conditions)
|
||||
|
||||
# 2. 获取趋势数据(最近30天)
|
||||
trends = await ExamReportService._get_trends(db, user_id, conditions)
|
||||
|
||||
# 3. 获取科目分析
|
||||
subjects = await ExamReportService._get_subjects(db, conditions)
|
||||
|
||||
# 4. 获取最近考试记录
|
||||
recent_exams = await ExamReportService._get_recent_exams(db, conditions)
|
||||
|
||||
return {
|
||||
"overview": overview,
|
||||
"trends": trends,
|
||||
"subjects": subjects,
|
||||
"recent_exams": recent_exams
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _get_overview(db: AsyncSession, conditions: List) -> Dict[str, Any]:
|
||||
"""获取概览数据"""
|
||||
# 查询统计数据(使用round1_score作为主要成绩)
|
||||
stmt = select(
|
||||
func.count(Exam.id).label("total_exams"),
|
||||
func.avg(Exam.round1_score).label("avg_score"),
|
||||
func.sum(Exam.question_count).label("total_questions"),
|
||||
func.count(case((Exam.is_passed == True, 1))).label("passed_exams")
|
||||
).where(
|
||||
and_(*conditions, Exam.round1_score.isnot(None))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
stats = result.one()
|
||||
|
||||
total_exams = stats.total_exams or 0
|
||||
passed_exams = stats.passed_exams or 0
|
||||
|
||||
return {
|
||||
"avg_score": round(float(stats.avg_score or 0), 1),
|
||||
"total_exams": total_exams,
|
||||
"pass_rate": round((passed_exams / total_exams * 100) if total_exams > 0 else 0, 1),
|
||||
"total_questions": stats.total_questions or 0
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _get_trends(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
base_conditions: List
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取成绩趋势(最近30天)"""
|
||||
# 计算30天前的日期
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
|
||||
# 查询最近30天的考试数据,按日期分组
|
||||
stmt = select(
|
||||
func.date(Exam.start_time).label("exam_date"),
|
||||
func.avg(Exam.round1_score).label("avg_score")
|
||||
).where(
|
||||
and_(
|
||||
Exam.user_id == user_id,
|
||||
Exam.start_time >= thirty_days_ago,
|
||||
Exam.round1_score.isnot(None)
|
||||
)
|
||||
).group_by(
|
||||
func.date(Exam.start_time)
|
||||
).order_by(
|
||||
func.date(Exam.start_time)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
trend_data = result.all()
|
||||
|
||||
# 转换为前端需要的格式
|
||||
trends = []
|
||||
for row in trend_data:
|
||||
trends.append({
|
||||
"date": row.exam_date.strftime("%Y-%m-%d") if row.exam_date else "",
|
||||
"avg_score": round(float(row.avg_score or 0), 1)
|
||||
})
|
||||
|
||||
return trends
|
||||
|
||||
@staticmethod
|
||||
async def _get_subjects(db: AsyncSession, conditions: List) -> List[Dict[str, Any]]:
|
||||
"""获取科目分析"""
|
||||
# 关联course表,按课程分组统计
|
||||
stmt = select(
|
||||
Exam.course_id,
|
||||
Course.name.label("course_name"),
|
||||
func.avg(Exam.round1_score).label("avg_score"),
|
||||
func.count(Exam.id).label("exam_count"),
|
||||
func.max(Exam.round1_score).label("max_score"),
|
||||
func.min(Exam.round1_score).label("min_score"),
|
||||
func.count(case((Exam.is_passed == True, 1))).label("passed_count")
|
||||
).join(
|
||||
Course, Exam.course_id == Course.id
|
||||
).where(
|
||||
and_(*conditions, Exam.round1_score.isnot(None))
|
||||
).group_by(
|
||||
Exam.course_id, Course.name
|
||||
).order_by(
|
||||
desc(func.count(Exam.id))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
subject_data = result.all()
|
||||
|
||||
subjects = []
|
||||
for row in subject_data:
|
||||
exam_count = row.exam_count or 0
|
||||
passed_count = row.passed_count or 0
|
||||
|
||||
subjects.append({
|
||||
"course_id": row.course_id,
|
||||
"course_name": row.course_name,
|
||||
"avg_score": round(float(row.avg_score or 0), 1),
|
||||
"exam_count": exam_count,
|
||||
"max_score": round(float(row.max_score or 0), 1),
|
||||
"min_score": round(float(row.min_score or 0), 1),
|
||||
"pass_rate": round((passed_count / exam_count * 100) if exam_count > 0 else 0, 1)
|
||||
})
|
||||
|
||||
return subjects
|
||||
|
||||
@staticmethod
|
||||
async def _get_recent_exams(db: AsyncSession, conditions: List) -> List[Dict[str, Any]]:
|
||||
"""获取最近10次考试记录"""
|
||||
# 查询最近10次考试,包含三轮得分
|
||||
stmt = select(
|
||||
Exam.id,
|
||||
Exam.course_id,
|
||||
Course.name.label("course_name"),
|
||||
Exam.score,
|
||||
Exam.total_score,
|
||||
Exam.is_passed,
|
||||
Exam.start_time,
|
||||
Exam.end_time,
|
||||
Exam.round1_score,
|
||||
Exam.round2_score,
|
||||
Exam.round3_score
|
||||
).join(
|
||||
Course, Exam.course_id == Course.id
|
||||
).where(
|
||||
and_(*conditions)
|
||||
).order_by(
|
||||
desc(Exam.created_at) # 改为按创建时间排序,避免start_time为NULL的问题
|
||||
).limit(10)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
exam_data = result.all()
|
||||
|
||||
recent_exams = []
|
||||
for row in exam_data:
|
||||
# 计算考试用时
|
||||
duration_seconds = None
|
||||
if row.start_time and row.end_time:
|
||||
duration_seconds = int((row.end_time - row.start_time).total_seconds())
|
||||
|
||||
recent_exams.append({
|
||||
"id": row.id,
|
||||
"course_id": row.course_id,
|
||||
"course_name": row.course_name,
|
||||
"score": round(float(row.score), 1) if row.score else None,
|
||||
"total_score": round(float(row.total_score or 100), 1),
|
||||
"is_passed": row.is_passed,
|
||||
"duration_seconds": duration_seconds,
|
||||
"start_time": row.start_time.isoformat() if row.start_time else None,
|
||||
"end_time": row.end_time.isoformat() if row.end_time else None,
|
||||
"round_scores": {
|
||||
"round1": round(float(row.round1_score), 1) if row.round1_score else None,
|
||||
"round2": round(float(row.round2_score), 1) if row.round2_score else None,
|
||||
"round3": round(float(row.round3_score), 1) if row.round3_score else None
|
||||
}
|
||||
})
|
||||
|
||||
return recent_exams
|
||||
|
||||
|
||||
class MistakeService:
|
||||
"""错题服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_mistakes_list(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
exam_id: Optional[int] = None,
|
||||
course_id: Optional[int] = None,
|
||||
question_type: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
page: int = 1,
|
||||
size: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取错题列表(支持多维度筛选)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
exam_id: 考试ID(可选)
|
||||
course_id: 课程ID(可选)
|
||||
question_type: 题型(可选)
|
||||
search: 关键词搜索(可选)
|
||||
start_date: 开始日期(可选)
|
||||
end_date: 结束日期(可选)
|
||||
page: 页码
|
||||
size: 每页数量
|
||||
|
||||
Returns:
|
||||
Dict: 包含items、total、page、size、pages的分页数据
|
||||
"""
|
||||
logger.info(f"获取错题列表 - user_id: {user_id}, exam_id: {exam_id}, course_id: {course_id}")
|
||||
|
||||
# 构建查询条件
|
||||
conditions = [ExamMistake.user_id == user_id]
|
||||
|
||||
if exam_id:
|
||||
conditions.append(ExamMistake.exam_id == exam_id)
|
||||
|
||||
if question_type:
|
||||
conditions.append(ExamMistake.question_type == question_type)
|
||||
|
||||
if search:
|
||||
conditions.append(ExamMistake.question_content.like(f"%{search}%"))
|
||||
|
||||
if start_date:
|
||||
conditions.append(ExamMistake.created_at >= start_date)
|
||||
|
||||
if end_date:
|
||||
end_datetime = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
conditions.append(ExamMistake.created_at < end_datetime)
|
||||
|
||||
# 如果指定了course_id,需要通过exam关联
|
||||
if course_id:
|
||||
conditions.append(Exam.course_id == course_id)
|
||||
|
||||
# 查询总数
|
||||
count_stmt = select(func.count(ExamMistake.id)).select_from(ExamMistake).join(
|
||||
Exam, ExamMistake.exam_id == Exam.id
|
||||
).where(and_(*conditions))
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 查询分页数据
|
||||
offset = (page - 1) * size
|
||||
|
||||
stmt = select(
|
||||
ExamMistake.id,
|
||||
ExamMistake.exam_id,
|
||||
Exam.course_id,
|
||||
Course.name.label("course_name"),
|
||||
ExamMistake.question_content,
|
||||
ExamMistake.correct_answer,
|
||||
ExamMistake.user_answer,
|
||||
ExamMistake.question_type,
|
||||
ExamMistake.knowledge_point_id,
|
||||
KnowledgePoint.name.label("knowledge_point_name"),
|
||||
ExamMistake.created_at
|
||||
).select_from(ExamMistake).join(
|
||||
Exam, ExamMistake.exam_id == Exam.id
|
||||
).join(
|
||||
Course, Exam.course_id == Course.id
|
||||
).outerjoin(
|
||||
KnowledgePoint, ExamMistake.knowledge_point_id == KnowledgePoint.id
|
||||
).where(
|
||||
and_(*conditions)
|
||||
).order_by(
|
||||
desc(ExamMistake.created_at)
|
||||
).offset(offset).limit(size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
mistakes = result.all()
|
||||
|
||||
# 构建返回数据
|
||||
items = []
|
||||
for row in mistakes:
|
||||
items.append({
|
||||
"id": row.id,
|
||||
"exam_id": row.exam_id,
|
||||
"course_id": row.course_id,
|
||||
"course_name": row.course_name,
|
||||
"question_content": row.question_content,
|
||||
"correct_answer": row.correct_answer,
|
||||
"user_answer": row.user_answer,
|
||||
"question_type": row.question_type,
|
||||
"knowledge_point_id": row.knowledge_point_id,
|
||||
"knowledge_point_name": row.knowledge_point_name,
|
||||
"created_at": row.created_at
|
||||
})
|
||||
|
||||
pages = (total + size - 1) // size
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"pages": pages
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_mistakes_statistics(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
course_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取错题统计数据
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
course_id: 课程ID(可选)
|
||||
|
||||
Returns:
|
||||
Dict: 包含total、by_course、by_type、by_time的统计数据
|
||||
"""
|
||||
logger.info(f"获取错题统计 - user_id: {user_id}, course_id: {course_id}")
|
||||
|
||||
# 基础条件
|
||||
base_conditions = [ExamMistake.user_id == user_id]
|
||||
if course_id:
|
||||
base_conditions.append(Exam.course_id == course_id)
|
||||
|
||||
# 1. 总数统计
|
||||
count_stmt = select(func.count(ExamMistake.id)).select_from(ExamMistake).join(
|
||||
Exam, ExamMistake.exam_id == Exam.id
|
||||
).where(and_(*base_conditions))
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 2. 按课程统计
|
||||
by_course_stmt = select(
|
||||
Exam.course_id,
|
||||
Course.name.label("course_name"),
|
||||
func.count(ExamMistake.id).label("count")
|
||||
).select_from(ExamMistake).join(
|
||||
Exam, ExamMistake.exam_id == Exam.id
|
||||
).join(
|
||||
Course, Exam.course_id == Course.id
|
||||
).where(
|
||||
ExamMistake.user_id == user_id
|
||||
).group_by(
|
||||
Exam.course_id, Course.name
|
||||
).order_by(
|
||||
desc(func.count(ExamMistake.id))
|
||||
)
|
||||
|
||||
by_course_result = await db.execute(by_course_stmt)
|
||||
by_course_data = by_course_result.all()
|
||||
|
||||
by_course = [
|
||||
{
|
||||
"course_id": row.course_id,
|
||||
"course_name": row.course_name,
|
||||
"count": row.count
|
||||
}
|
||||
for row in by_course_data
|
||||
]
|
||||
|
||||
# 3. 按题型统计
|
||||
by_type_stmt = select(
|
||||
ExamMistake.question_type,
|
||||
func.count(ExamMistake.id).label("count")
|
||||
).where(
|
||||
and_(ExamMistake.user_id == user_id, ExamMistake.question_type.isnot(None))
|
||||
).group_by(
|
||||
ExamMistake.question_type
|
||||
)
|
||||
|
||||
by_type_result = await db.execute(by_type_stmt)
|
||||
by_type_data = by_type_result.all()
|
||||
|
||||
# 题型名称映射
|
||||
type_names = {
|
||||
"single": "单选题",
|
||||
"multiple": "多选题",
|
||||
"judge": "判断题",
|
||||
"blank": "填空题",
|
||||
"essay": "问答题"
|
||||
}
|
||||
|
||||
by_type = [
|
||||
{
|
||||
"type": row.question_type,
|
||||
"type_name": type_names.get(row.question_type, "未知类型"),
|
||||
"count": row.count
|
||||
}
|
||||
for row in by_type_data
|
||||
]
|
||||
|
||||
# 4. 按时间统计
|
||||
now = datetime.now()
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
quarter_ago = now - timedelta(days=90)
|
||||
|
||||
# 最近一周
|
||||
week_stmt = select(func.count(ExamMistake.id)).where(
|
||||
and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= week_ago)
|
||||
)
|
||||
week_result = await db.execute(week_stmt)
|
||||
week_count = week_result.scalar() or 0
|
||||
|
||||
# 最近一月
|
||||
month_stmt = select(func.count(ExamMistake.id)).where(
|
||||
and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= month_ago)
|
||||
)
|
||||
month_result = await db.execute(month_stmt)
|
||||
month_count = month_result.scalar() or 0
|
||||
|
||||
# 最近三月
|
||||
quarter_stmt = select(func.count(ExamMistake.id)).where(
|
||||
and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= quarter_ago)
|
||||
)
|
||||
quarter_result = await db.execute(quarter_stmt)
|
||||
quarter_count = quarter_result.scalar() or 0
|
||||
|
||||
by_time = {
|
||||
"week": week_count,
|
||||
"month": month_count,
|
||||
"quarter": quarter_count
|
||||
}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_course": by_course,
|
||||
"by_type": by_type,
|
||||
"by_time": by_time
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user