AnCao/internal/handlers/exam_handler.go
yanlongqi 62281b5047 重构试卷分享系统:修复类型不匹配问题
本次提交重构了试卷分享功能,从"副本模式"改为"关联表模式",并修复了关键的类型不匹配问题。

## 主要更新

### 后端架构重构
- 新增 ExamShare 关联表模型,替代原有的试卷副本方式
- 修复 User.ID (int64) 与 ExamShare 外键的类型不匹配问题
- 更新所有相关 API 以使用新的关联表架构
- 添加 IsAccessibleBy 和 GetAccessibleExams 权限检查方法

### 类型系统修复
- ExamShare.SharedByID/SharedToID: uint → int64
- IsAccessibleBy/GetAccessibleExams 参数: uint → int64
- 修复所有涉及用户ID类型转换的代码

### 新增工具
- cmd/migrate_exam_shares.go: 数据迁移脚本(旧数据迁移)
- cmd/cleanup/main.go: 数据库表清理工具

### API 更新
- ShareExam: 创建分享关联记录而非复制试卷
- GetExamList: 返回分享人信息和参与人数统计
- GetExamRecord: 支持查看共享试卷的其他用户记录
- GetExamRecordList: 按试卷ID查询所有用户的考试记录

### 前端更新
- 更新 TypeScript 类型定义以匹配新的 API 响应
- 添加分享人标签显示("来自 XXX")
- 考试记录列表显示所有参与者信息

## 技术细节
- 使用 GORM 外键关联和 Preload 优化查询
- 添加唯一索引防止重复分享
- 事务保护数据一致性
- 软删除支持数据恢复

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 22:32:54 +08:00

1014 lines
29 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
}
// 使用固定的题型配置总分100分
questionTypes := []models.QuestionTypeConfig{
{Type: "fill-in-blank", Count: 20, Score: 2.0}, // 40分
{Type: "true-false", Count: 10, Score: 1.0}, // 10分
{Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分
{Type: "multiple-selection", Count: 10, Score: 2.0}, // 20分
{Type: "short-answer", Count: 1, Score: 10.0}, // 10分
{Type: "ordinary-essay", Count: 1, Score: 10.0}, // 10分普通涉密人员论述题
{Type: "management-essay", Count: 1, Score: 10.0}, // 10分保密管理人员论述题
}
// 按题型配置随机抽取题目
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: uint(userID.(int64)),
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()
// 获取用户可访问的试卷(拥有的 + 被分享的)
exams, err := models.GetAccessibleExams(userID.(int64), db)
if 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
ParticipantCount int `json:"participant_count"` // 共享试卷的参与人数(所有用户)
IsShared bool `json:"is_shared"` // 是否为分享的试卷
SharedBy *struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
} `json:"shared_by,omitempty"`
}
result := make([]ExamWithStats, 0, len(exams))
for _, exam := range exams {
var questionIDs []uint
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
continue
}
stats := ExamWithStats{
Exam: exam,
QuestionCount: len(questionIDs),
}
// 检查是否为分享试卷
if exam.UserID != uint(userID.(int64)) {
stats.IsShared = true
// 查询分享人信息
var share models.ExamShare
if err := db.Where("exam_id = ? AND shared_to_id = ?", exam.ID, userID.(int64)).
Preload("SharedBy").First(&share).Error; err == nil && share.SharedBy != nil {
stats.SharedBy = &struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
}{
ID: share.SharedBy.ID,
Username: share.SharedBy.Username,
Nickname: share.SharedBy.Nickname,
}
}
}
// 当前用户的统计
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
}
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
}
// 参与人数统计(简化:直接统计该试卷的不同用户)
var participantCount int64
db.Model(&models.ExamRecord{}).
Where("exam_id = ? AND status = ?", exam.ID, "graded").
Distinct("user_id").
Count(&participantCount)
stats.ParticipantCount = int(participantCount)
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
}
// 检查是否需要显示答案
showAnswer := c.Query("show_answer") == "true"
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
}
// 检查是否包含论述题,如果没有则添加两种论述题
hasOrdinaryEssay := false
hasManagementEssay := false
for _, q := range questions {
if q.Type == "ordinary-essay" {
hasOrdinaryEssay = true
}
if q.Type == "management-essay" {
hasManagementEssay = true
}
}
// 如果缺少论述题,则补充
var additionalQuestions []models.PracticeQuestion
if !hasOrdinaryEssay {
var ordinaryEssay models.PracticeQuestion
if err := db.Where("type = ?", "ordinary-essay").First(&ordinaryEssay).Error; err == nil {
additionalQuestions = append(additionalQuestions, ordinaryEssay)
}
}
if !hasManagementEssay {
var managementEssay models.PracticeQuestion
if err := db.Where("type = ?", "management-essay").First(&managementEssay).Error; err == nil {
additionalQuestions = append(additionalQuestions, managementEssay)
}
}
// 将补充的题目添加到题目映射中
for _, q := range additionalQuestions {
questionMap[q.ID] = q
}
orderedDTOs := make([]models.PracticeQuestionDTO, 0, len(questionIDs))
for _, id := range questionIDs {
if q, ok := questionMap[id]; ok {
dto := convertToDTO(q)
// 根据showAnswer参数决定是否显示答案
if !showAnswer {
dto.Answer = nil // 不显示答案
}
orderedDTOs = append(orderedDTOs, dto)
}
}
// 添加补充的论述题到结果中
for _, q := range additionalQuestions {
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,
},
})
}
// 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: uint(userID.(int64)),
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, uint(userID.(int64)))
}
// 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 = ?", recordID).
Preload("Exam").
First(&record).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
return
}
// 检查权限:只有以下情况可以查看
// 1. 记录属于当前用户
// 2. 当前用户有权限访问该试卷
if record.UserID != uint(userID.(int64)) {
// 不是自己的记录,检查是否有权限访问该试卷
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
}
if !exam.IsAccessibleBy(userID.(int64), db) {
c.JSON(http.StatusForbidden, 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()
// 如果指定了试卷ID
if examIDStr != "" {
examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"})
return
}
// 查询试卷并检查权限
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
}
// 检查用户是否有权限访问该试卷
if !exam.IsAccessibleBy(userID.(int64), db) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "无权限访问"})
return
}
// 查询该试卷的所有已完成考试记录(包含用户信息)
var records []models.ExamRecord
if err := db.Where("exam_id = ? AND status = ?", examID, "graded").
Preload("Exam").
Preload("User", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "username", "nickname", "avatar")
}).
Order("score DESC, 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,
})
return
}
// 没有指定试卷ID返回当前用户的所有记录
var records []models.ExamRecord
if err := db.Where("user_id = ?", userID).
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,
})
}
// GetShareableUsers 获取可分享的用户列表(排除当前用户)
func GetShareableUsers(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 users []models.User
if err := db.Where("id != ?", userID).Select("id", "username", "nickname", "avatar").Find(&users).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": users,
})
}
// ShareExamRequest 分享试卷请求
type ShareExamRequest struct {
UserIDs []uint `json:"user_ids" binding:"required,min=1"` // 分享给哪些用户
}
// ShareExam 分享试卷
func ShareExam(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 ShareExamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据: " + err.Error()})
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
}
// 开始事务
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
sharedByID := userID.(int64)
sharedCount := 0
now := time.Now()
// 为每个用户创建分享记录
for _, targetUserID := range req.UserIDs {
// 检查是否已分享
var existingShare models.ExamShare
err := tx.Where("exam_id = ? AND shared_to_id = ?", uint(examID), int64(targetUserID)).
First(&existingShare).Error
if err == nil {
continue // 已存在,跳过
} else if err != gorm.ErrRecordNotFound {
tx.Rollback()
log.Printf("检查分享记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"})
return
}
// 创建分享记录
share := models.ExamShare{
ExamID: uint(examID),
SharedByID: sharedByID,
SharedToID: int64(targetUserID),
SharedAt: now,
}
if err := tx.Create(&share).Error; err != nil {
tx.Rollback()
log.Printf("创建分享记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"})
return
}
sharedCount++
}
// 提交事务
if err := tx.Commit().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": fmt.Sprintf("成功分享给 %d 个用户", sharedCount),
"data": gin.H{
"shared_count": sharedCount,
},
})
}