AnCao/internal/handlers/exam_handler.go
yanlongqi 52fff11f07 feat: 实现模拟考试功能
## 后端实现
- 添加考试数据模型 (Exam)
- 实现考试生成API (/api/exam/generate)
- 实现获取考试详情API (/api/exam/:id)
- 实现提交考试API (/api/exam/:id/submit)
- 支持按题型随机抽取题目
- AI智能评分(简答题和论述题)
- 自动计算总分和详细评分

## 前端实现
- 首页添加"模拟考试"入口
- 考试准备页:显示考试说明,选择在线/打印模式
- 在线答题页:按题型分组显示,支持论述题二选一
- 试卷打印页:A4排版,支持打印试卷/答案
- 成绩报告页:显示总分、详细评分、错题分析

## 核心特性
- 随机组卷:填空10题、判断10题、单选10题、多选10题、简答2题、论述题2选1
- 智能评分:使用AI评分论述题,给出分数、评语和建议
- 答题进度保存:使用localStorage防止刷新丢失
- 打印优化:A4纸张、黑白打印、合理排版
- 响应式设计:适配移动端、平板和PC端

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 20:45:30 +08:00

422 lines
12 KiB
Go
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.

package handlers
import (
"ankao/internal/database"
"ankao/internal/models"
"ankao/internal/services"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// GenerateExam 生成考试
func GenerateExam(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
db := database.GetDB()
// 使用默认配置
config := models.DefaultExamConfig
// 按题型随机抽取题目
questionsByType := make(map[string][]models.PracticeQuestion)
questionTypes := []struct {
Type string
Count int
}{
{"fill-in-blank", config.FillInBlank},
{"true-false", config.TrueFalse},
{"multiple-choice", config.MultipleChoice},
{"multiple-selection", config.MultipleSelection},
{"short-answer", config.ShortAnswer},
{"ordinary-essay", config.OrdinaryEssay},
{"management-essay", config.ManagementEssay},
}
var allQuestionIDs []uint
for _, qt := range questionTypes {
var questions []models.PracticeQuestion
if err := db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
return
}
// 检查题目数量是否足够
if len(questions) < qt.Count {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": fmt.Sprintf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", qt.Type, qt.Count, len(questions)),
})
return
}
// 随机抽取 (Fisher-Yates 洗牌算法)
rand.Seed(time.Now().UnixNano())
for i := len(questions) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
questions[i], questions[j] = questions[j], questions[i]
}
selectedQuestions := questions[:qt.Count]
questionsByType[qt.Type] = selectedQuestions
// 收集题目ID
for _, q := range selectedQuestions {
allQuestionIDs = append(allQuestionIDs, q.ID)
}
}
// 将题目ID列表转为JSON
questionIDsJSON, err := json.Marshal(allQuestionIDs)
if err != nil {
log.Printf("序列化题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成考试失败"})
return
}
// 创建考试记录
exam := models.Exam{
UserID: userID.(uint),
QuestionIDs: string(questionIDsJSON),
Status: "draft",
}
if err := db.Create(&exam).Error; err != nil {
log.Printf("创建考试记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建考试失败"})
return
}
// 返回考试信息
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"exam_id": exam.ID,
"question_ids": allQuestionIDs,
"created_at": exam.CreatedAt,
},
})
}
// GetExam 获取考试详情
func GetExam(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
examIDStr := c.Param("id")
examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"})
return
}
db := database.GetDB()
// 查询考试记录
var exam models.Exam
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"})
return
}
// 解析题目ID列表
var questionIDs []uint
if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil {
log.Printf("解析题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"})
return
}
// 查询题目详情
var questions []models.PracticeQuestion
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
return
}
// 按原始顺序排序题目
questionMap := make(map[uint]models.PracticeQuestion)
for _, q := range questions {
questionMap[q.ID] = q
}
orderedQuestions := make([]models.PracticeQuestion, 0, len(questionIDs))
for _, id := range questionIDs {
if q, ok := questionMap[id]; ok {
orderedQuestions = append(orderedQuestions, q)
}
}
// 是否显示答案
showAnswer := c.Query("show_answer") == "true"
if !showAnswer {
// 不显示答案时,隐藏答案字段
for i := range orderedQuestions {
orderedQuestions[i].AnswerData = ""
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"exam": exam,
"questions": orderedQuestions,
},
})
}
// SubmitExam 提交考试
func SubmitExam(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
examIDStr := c.Param("id")
examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"})
return
}
// 解析请求体
var req struct {
Answers map[string]interface{} `json:"answers"` // question_id -> answer
EssayChoice string `json:"essay_choice"` // 论述题选择: ordinary 或 management
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"})
return
}
db := database.GetDB()
// 查询考试记录
var exam models.Exam
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"})
return
}
// 检查是否已提交
if exam.Status == "submitted" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已提交"})
return
}
// 解析题目ID列表
var questionIDs []uint
if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil {
log.Printf("解析题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"})
return
}
// 查询题目详情
var questions []models.PracticeQuestion
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
return
}
// 评分
totalScore := 0.0
scoreConfig := models.DefaultScoreConfig
aiService := services.NewAIGradingService()
detailedResults := make(map[string]interface{})
for _, question := range questions {
questionIDStr := fmt.Sprintf("%d", question.ID)
userAnswerRaw, answered := req.Answers[questionIDStr]
if !answered {
detailedResults[questionIDStr] = gin.H{
"correct": false,
"score": 0,
"message": "未作答",
}
continue
}
// 论述题特殊处理:根据用户选择判断是否计分
if question.Type == "ordinary-essay" && req.EssayChoice != "ordinary" {
detailedResults[questionIDStr] = gin.H{
"correct": false,
"score": 0,
"message": "未选择此题",
}
continue
}
if question.Type == "management-essay" && req.EssayChoice != "management" {
detailedResults[questionIDStr] = gin.H{
"correct": false,
"score": 0,
"message": "未选择此题",
}
continue
}
// 根据题型判断答案
var isCorrect bool
var score float64
var aiGrading *models.AIGrading
switch question.Type {
case "fill-in-blank":
// 填空题:比较数组
userAnswerArr, ok := userAnswerRaw.([]interface{})
if !ok {
detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"}
continue
}
var correctAnswers []string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
log.Printf("解析填空题答案失败: %v", err)
continue
}
isCorrect = true
for i, ua := range userAnswerArr {
if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] {
isCorrect = false
break
}
}
if isCorrect {
score = scoreConfig.FillInBlank
}
case "true-false":
// 判断题
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析判断题答案失败: %v", err)
continue
}
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
if isCorrect {
score = scoreConfig.TrueFalse
}
case "multiple-choice":
// 单选题
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析单选题答案失败: %v", err)
continue
}
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
if isCorrect {
score = scoreConfig.MultipleChoice
}
case "multiple-selection":
// 多选题:比较数组(顺序无关)
userAnswerArr, ok := userAnswerRaw.([]interface{})
if !ok {
detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"}
continue
}
var correctAnswers []string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
log.Printf("解析多选题答案失败: %v", err)
continue
}
userAnswerSet := make(map[string]bool)
for _, ua := range userAnswerArr {
userAnswerSet[fmt.Sprintf("%v", ua)] = true
}
isCorrect = len(userAnswerSet) == len(correctAnswers)
if isCorrect {
for _, ca := range correctAnswers {
if !userAnswerSet[ca] {
isCorrect = false
break
}
}
}
if isCorrect {
score = scoreConfig.MultipleSelection
}
case "short-answer", "ordinary-essay", "management-essay":
// 简答题和论述题使用AI评分
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析简答题答案失败: %v", err)
continue
}
userAnswerStr := fmt.Sprintf("%v", userAnswerRaw)
aiResult, err := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr)
if err != nil {
log.Printf("AI评分失败: %v", err)
// AI评分失败时给一个保守的分数
isCorrect = false
score = 0
} else {
isCorrect = aiResult.IsCorrect
if question.Type == "short-answer" {
// 简答题不计分,仅供参考
score = 0
} else {
// 论述题按AI评分比例计算
score = scoreConfig.Essay * (aiResult.Score / 100.0)
}
aiGrading = &models.AIGrading{
Score: aiResult.Score,
Feedback: aiResult.Feedback,
Suggestion: aiResult.Suggestion,
}
}
}
totalScore += score
detailedResults[questionIDStr] = gin.H{
"correct": isCorrect,
"score": score,
"ai_grading": aiGrading,
}
}
// 保存答案和分数
answersJSON, err := json.Marshal(req.Answers)
if err != nil {
log.Printf("序列化答案失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存答案失败"})
return
}
now := time.Now()
exam.Answers = string(answersJSON)
exam.Score = totalScore
exam.Status = "submitted"
exam.SubmittedAt = &now
if err := db.Save(&exam).Error; err != nil {
log.Printf("保存考试记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存考试失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"score": totalScore,
"detailed_results": detailedResults,
},
})
}