- 在GetExam API中使用convertToDTO函数转换题目数据 - 确保选项数据(options)正确解析并返回给前端 - 修复前端ExamOnline渲染选择题时的undefined错误 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
421 lines
12 KiB
Go
421 lines
12 KiB
Go
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
|
||
}
|
||
|
||
// 是否显示答案
|
||
showAnswer := c.Query("show_answer") == "true"
|
||
|
||
// 查询题目详情
|
||
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
|
||
}
|
||
|
||
// 按原始顺序排序题目并转换为DTO
|
||
questionMap := make(map[uint]models.PracticeQuestion)
|
||
for _, q := range questions {
|
||
questionMap[q.ID] = q
|
||
}
|
||
orderedDTOs := make([]models.PracticeQuestionDTO, 0, len(questionIDs))
|
||
for _, id := range questionIDs {
|
||
if q, ok := questionMap[id]; ok {
|
||
dto := convertToDTO(q)
|
||
// 是否显示答案
|
||
if !showAnswer {
|
||
dto.Answer = nil
|
||
}
|
||
orderedDTOs = append(orderedDTOs, dto)
|
||
}
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": gin.H{
|
||
"exam": exam,
|
||
"questions": orderedDTOs,
|
||
},
|
||
})
|
||
}
|
||
|
||
// 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,
|
||
},
|
||
})
|
||
}
|