AnCao/internal/handlers/exam_handler.go
yanlongqi e651910e74 refactor: 数据库模型优化和答题逻辑重构
主要变更:
- 数据库ID字段统一从 uint 改为 int64,提升数据容量和兼容性
- 重构答题检查逻辑,采用策略模式替代 switch-case
- 新增 PracticeProgress 模型,支持练习进度持久化
- 优化错题本系统,自动记录答题进度和错误历史
- 添加 lib/pq PostgreSQL 驱动依赖
- 移除错题标签管理 API(待后续迁移)
- 前端类型定义同步更新,适配后端模型变更

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 03:28:39 +08:00

770 lines
22 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"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// CreateExamRequest 创建试卷请求
type CreateExamRequest struct {
Title string `json:"title" binding:"required"`
}
// CreateExam 创建试卷
func CreateExam(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
var req CreateExamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据: " + err.Error()})
return
}
db := database.GetDB()
// 查询用户信息,获取用户类型
var user models.User
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
log.Printf("查询用户失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询用户信息失败"})
return
}
// 根据用户类型决定论述题类型
essayType := "ordinary-essay" // 默认普通涉密人员论述题
if user.UserType == "management-person" {
essayType = "management-essay" // 保密管理人员论述题
}
// 使用固定的题型配置总分100分
questionTypes := []models.QuestionTypeConfig{
{Type: "fill-in-blank", Count: 10, Score: 2.0}, // 20分
{Type: "true-false", Count: 10, Score: 2.0}, // 20分
{Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分
{Type: "multiple-selection", Count: 10, Score: 2.5}, // 25分
{Type: "short-answer", Count: 2, Score: 10.0}, // 20分
{Type: essayType, Count: 1, Score: 5.0}, // 5分根据用户类型选择论述题
}
// 按题型配置随机抽取题目
var allQuestionIDs []int64
totalScore := 0.0
for _, qtConfig := range questionTypes {
var questions []models.PracticeQuestion
query := db.Where("type = ?", qtConfig.Type)
if err := query.Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
return
}
// 检查题目数量是否足够
if len(questions) < qtConfig.Count {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": fmt.Sprintf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", qtConfig.Type, qtConfig.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[:qtConfig.Count]
// 收集题目ID
for _, q := range selectedQuestions {
allQuestionIDs = append(allQuestionIDs, q.ID)
}
// 计算总分
totalScore += float64(qtConfig.Count) * qtConfig.Score
}
// 随机打乱题目ID顺序
rand.Seed(time.Now().UnixNano())
for i := len(allQuestionIDs) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
allQuestionIDs[i], allQuestionIDs[j] = allQuestionIDs[j], allQuestionIDs[i]
}
// 序列化题目ID
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),
Title: req.Title,
TotalScore: int(totalScore), // 总分100分
Duration: 60, // 固定60分钟
PassScore: 80, // 固定80分及格
QuestionIDs: questionIDsJSON,
Status: "active",
}
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{
"id": exam.ID,
"title": exam.Title,
"total_score": exam.TotalScore,
"duration": exam.Duration,
"pass_score": exam.PassScore,
"question_count": len(allQuestionIDs),
"created_at": exam.CreatedAt,
},
})
}
// GetExamList 获取试卷列表
func GetExamList(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
db := database.GetDB()
// 查询用户创建的试卷
var exams []models.Exam
if err := db.Where("user_id = ? AND status = ?", userID, "active").
Order("created_at DESC").
Find(&exams).Error; err != nil {
log.Printf("查询试卷列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询试卷列表失败"})
return
}
// 为每个试卷计算题目数量和获取考试记录统计
type ExamWithStats struct {
models.Exam
QuestionCount int `json:"question_count"`
AttemptCount int `json:"attempt_count"` // 考试次数
BestScore float64 `json:"best_score"` // 最高分
HasInProgressExam bool `json:"has_in_progress_exam"` // 是否有进行中的考试
InProgressRecordID uint `json:"in_progress_record_id,omitempty"` // 进行中的考试记录ID
}
result := make([]ExamWithStats, 0, len(exams))
for _, exam := range exams {
var questionIDs []uint
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err == nil {
stats := ExamWithStats{
Exam: exam,
QuestionCount: len(questionIDs),
}
// 查询该试卷的考试记录统计
var count int64
db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count)
stats.AttemptCount = int(count)
// 查询最高分
var record models.ExamRecord
if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID).
Order("score DESC").
First(&record).Error; err == nil {
stats.BestScore = record.Score
}
// 查询是否有进行中的考试status为in_progress
var inProgressRecord models.ExamRecord
if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress").
Order("created_at DESC").
First(&inProgressRecord).Error; err == nil {
stats.HasInProgressExam = true
stats.InProgressRecordID = inProgressRecord.ID
}
result = append(result, stats)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
// GetExamDetail 获取试卷详情
func GetExamDetail(c *gin.Context) {
_, 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 = ?", examID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
return
}
// 解析题目ID列表
var questionIDs []int64
if err := json.Unmarshal(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
}
// 按原始顺序排序题目并转换为DTO不显示答案
questionMap := make(map[int64]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)
dto.Answer = nil // 不显示答案
orderedDTOs = append(orderedDTOs, dto)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"exam": exam,
"questions": orderedDTOs,
},
})
}
// StartExam 开始考试(创建考试记录)
func StartExam(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 = ?", examID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
return
}
// 创建考试记录
now := time.Now()
record := models.ExamRecord{
ExamID: uint(examID),
UserID: userID.(uint),
StartTime: &now,
TotalScore: exam.TotalScore,
Status: "in_progress",
}
if err := db.Create(&record).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{
"record_id": record.ID,
"start_time": record.StartTime,
"duration": exam.Duration,
},
})
}
// SubmitExam 提交试卷答案
func SubmitExam(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
recordIDStr := c.Param("record_id")
recordID, err := strconv.ParseUint(recordIDStr, 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 (可选)
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"})
return
}
db := database.GetDB()
// 查询考试记录
var record models.ExamRecord
if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
return
}
// 检查是否已提交
if record.Status == "submitted" || record.Status == "graded" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已提交"})
return
}
// 查询试卷
var exam models.Exam
if err := db.Where("id = ?", record.ExamID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
return
}
// 更新考试记录状态为已提交
now := time.Now()
record.Status = "submitted"
record.SubmitTime = &now
// 计算用时(秒)
if record.StartTime != nil {
duration := now.Sub(*record.StartTime)
record.TimeSpent = int(duration.Seconds())
// 确保用时不为负数(容错处理)
if record.TimeSpent < 0 {
log.Printf("警告: 计算出负的用时,开始时间=%v, 结束时间=%v", *record.StartTime, now)
record.TimeSpent = 0
}
}
if err := db.Save(&record).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,
"message": "提交成功,正在阅卷中...",
"data": gin.H{
"record_id": record.ID,
"status": "submitted",
"time_spent": record.TimeSpent,
"total_score": exam.TotalScore,
},
})
// 异步执行阅卷(从 exam_user_answers 表读取答案)
go gradeExam(uint(recordID), exam.ID, userID.(uint))
}
// GetExamRecord 获取考试记录详情
func GetExamRecord(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
recordIDStr := c.Param("record_id")
recordID, err := strconv.ParseUint(recordIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"})
return
}
db := database.GetDB()
// 查询考试记录
var record models.ExamRecord
if err := db.Where("id = ? AND user_id = ?", recordID, userID).
Preload("Exam").
First(&record).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
return
}
// 从 exam_user_answers 表读取所有答案
var userAnswers []models.ExamUserAnswer
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
log.Printf("查询用户答案失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"})
return
}
// 查询所有题目以获取正确答案
var questionIDs []uint
if err := json.Unmarshal(record.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[int64]models.PracticeQuestion)
for _, q := range questions {
questionMap[q.ID] = q
}
// 构建 ExamAnswer 列表
examAnswers := make([]models.ExamAnswer, 0, len(userAnswers))
for _, ua := range userAnswers {
// 解析用户答案
var userAnswer interface{}
if err := json.Unmarshal(ua.Answer, &userAnswer); err != nil {
log.Printf("解析用户答案失败: %v", err)
continue
}
// 获取题目并解析正确答案
question, ok := questionMap[ua.QuestionID]
if !ok {
continue
}
var correctAnswerRaw interface{}
switch question.Type {
case "fill-in-blank", "multiple-selection", "multiple-choice":
// 数组类型:需要 JSON 解析单选题也是数组格式A
var arr []string
if err := json.Unmarshal([]byte(question.AnswerData), &arr); err != nil {
// 尝试解析为单个字符串(兼容旧数据格式)
var singleStr string
if err2 := json.Unmarshal([]byte(question.AnswerData), &singleStr); err2 == nil {
// 成功解析为字符串,单选题直接使用,其他类型转为数组
if question.Type == "multiple-choice" {
correctAnswerRaw = singleStr
} else {
correctAnswerRaw = []string{singleStr}
}
} else {
correctAnswerRaw = "解析失败"
}
} else {
// 单选题只取第一个元素
if question.Type == "multiple-choice" && len(arr) > 0 {
correctAnswerRaw = arr[0]
} else {
correctAnswerRaw = arr
}
}
default:
// 字符串类型:直接使用 AnswerDatatrue-false, short-answer, essay
correctAnswerRaw = question.AnswerData
}
// 构建答案对象
examAnswer := models.ExamAnswer{
QuestionID: ua.QuestionID,
Answer: userAnswer,
CorrectAnswer: correctAnswerRaw,
IsCorrect: ua.IsCorrect != nil && *ua.IsCorrect,
Score: ua.Score,
}
// 添加 AI 评分信息
if len(ua.AIGradingData) > 0 {
var aiGrading models.AIGrading
if err := json.Unmarshal(ua.AIGradingData, &aiGrading); err == nil {
examAnswer.AIGrading = &aiGrading
}
}
examAnswers = append(examAnswers, examAnswer)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"record": record,
"answers": examAnswers,
},
})
}
// GetExamRecordList 获取考试记录列表
func GetExamRecordList(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
examIDStr := c.Query("exam_id")
db := database.GetDB()
query := db.Where("user_id = ?", userID)
// 如果指定了试卷ID,只查询该试卷的记录
if examIDStr != "" {
examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"})
return
}
query = query.Where("exam_id = ?", examID)
}
var records []models.ExamRecord
if err := query.Preload("Exam").
Order("created_at DESC").
Find(&records).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": records,
})
}
// DeleteExam 删除试卷
func DeleteExam(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
}
// 软删除
if err := db.Delete(&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,
"message": "删除成功",
})
}
// SaveExamProgressRequest 保存考试进度请求
type SaveExamProgressRequest struct {
QuestionID int64 `json:"question_id"` // 题目ID
Answer interface{} `json:"answer"` // 答案数据
}
// SaveExamProgress 保存单题答案
func SaveExamProgress(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
recordIDStr := c.Param("record_id")
recordID, err := strconv.ParseInt(recordIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"})
return
}
var req SaveExamProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"})
return
}
db := database.GetDB()
// 查询考试记录
var record models.ExamRecord
if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
return
}
// 检查考试状态
if record.Status != "in_progress" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已结束,无法保存答案"})
return
}
// 序列化答案数据
answerJSON, err := json.Marshal(req.Answer)
if err != nil {
log.Printf("序列化答案失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存失败"})
return
}
now := time.Now()
// 查找是否已存在该题的答案
var userAnswer models.ExamUserAnswer
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, req.QuestionID).First(&userAnswer)
if result.Error == gorm.ErrRecordNotFound {
// 不存在,创建新记录
userAnswer = models.ExamUserAnswer{
ExamRecordID: recordID,
QuestionID: req.QuestionID,
UserID: userID.(int64),
Answer: answerJSON,
AnsweredAt: &now,
LastModifiedAt: now,
}
if err := db.Create(&userAnswer).Error; err != nil {
log.Printf("创建答案记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存答案失败"})
return
}
} else if result.Error != nil {
log.Printf("查询答案记录失败: %v", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"})
return
} else {
// 已存在,更新答案
updates := map[string]interface{}{
"answer": answerJSON,
"last_modified_at": now,
}
if err := db.Model(&userAnswer).Updates(updates).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,
"message": "保存成功",
})
}
// GetExamUserAnswers 获取用户在考试中的所有答案
func GetExamUserAnswers(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
recordIDStr := c.Param("record_id")
recordID, err := strconv.ParseUint(recordIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"})
return
}
db := database.GetDB()
// 查询考试记录,确保用户有权限
var record models.ExamRecord
if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
return
}
// 查询所有已保存的答案
var userAnswers []models.ExamUserAnswer
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
log.Printf("查询用户答案失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"})
return
}
// 转换为前端需要的格式: { question_id: answer }
answers := make(map[string]interface{})
for _, ua := range userAnswers {
var answer interface{}
if err := json.Unmarshal(ua.Answer, &answer); err != nil {
log.Printf("解析答案失败: %v", err)
continue
}
// 使用 q_<question_id> 格式作为key与前端表单字段名保持一致
fieldName := fmt.Sprintf("q_%d", ua.QuestionID)
answers[fieldName] = answer
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": answers,
})
}