diff --git a/go.mod b/go.mod index 8a480f0..2dc2dae 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 0f833c6..5114cef 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/database/database.go b/internal/database/database.go index 73de870..e61ca6d 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -21,7 +21,7 @@ func InitDB() error { var err error DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), // 开启SQL日志 - DisableForeignKeyConstraintWhenMigrating: true, // 迁移时禁用外键约束 + DisableForeignKeyConstraintWhenMigrating: true, // 迁移时禁用外键约束 }) if err != nil { return fmt.Errorf("failed to connect to database: %w", err) @@ -33,13 +33,13 @@ func InitDB() error { err = DB.AutoMigrate( &models.User{}, &models.PracticeQuestion{}, - &models.WrongQuestion{}, // 错题表 - &models.WrongQuestionHistory{}, // 错题历史表 - &models.WrongQuestionTag{}, // 错题标签表 - &models.UserAnswerRecord{}, // 用户答题记录表 - &models.Exam{}, // 考试表(试卷) - &models.ExamRecord{}, // 考试记录表 - &models.ExamUserAnswer{}, // 用户答案表 + &models.PracticeProgress{}, // 练习进度表 + &models.WrongQuestion{}, // 错题表 + &models.WrongQuestionHistory{}, // 错题历史表 + &models.UserAnswerRecord{}, // 用户答题记录表 + &models.Exam{}, // 考试表(试卷) + &models.ExamRecord{}, // 考试记录表 + &models.ExamUserAnswer{}, // 用户答案表 ) if err != nil { return fmt.Errorf("failed to migrate database: %w", err) diff --git a/internal/handlers/exam_grading.go b/internal/handlers/exam_grading.go index 236b33a..ed76c6f 100644 --- a/internal/handlers/exam_grading.go +++ b/internal/handlers/exam_grading.go @@ -42,7 +42,7 @@ func gradeExam(recordID uint, examID uint, userID uint) { } // 转换为 map 格式方便查找 - answersMap := make(map[uint]interface{}) + answersMap := make(map[int64]interface{}) for _, ua := range userAnswers { var answer interface{} if err := json.Unmarshal(ua.Answer, &answer); err != nil { diff --git a/internal/handlers/exam_handler.go b/internal/handlers/exam_handler.go index eed4ea9..27b9851 100644 --- a/internal/handlers/exam_handler.go +++ b/internal/handlers/exam_handler.go @@ -61,7 +61,7 @@ func CreateExam(c *gin.Context) { } // 按题型配置随机抽取题目 - var allQuestionIDs []uint + var allQuestionIDs []int64 totalScore := 0.0 for _, qtConfig := range questionTypes { @@ -120,8 +120,8 @@ func CreateExam(c *gin.Context) { UserID: userID.(uint), Title: req.Title, TotalScore: int(totalScore), // 总分100分 - Duration: 60, // 固定60分钟 - PassScore: 80, // 固定80分及格 + Duration: 60, // 固定60分钟 + PassScore: 80, // 固定80分及格 QuestionIDs: questionIDsJSON, Status: "active", } @@ -243,7 +243,7 @@ func GetExamDetail(c *gin.Context) { } // 解析题目ID列表 - var questionIDs []uint + 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": "解析试卷数据失败"}) @@ -259,7 +259,7 @@ func GetExamDetail(c *gin.Context) { } // 按原始顺序排序题目并转换为DTO(不显示答案) - questionMap := make(map[uint]models.PracticeQuestion) + questionMap := make(map[int64]models.PracticeQuestion) for _, q := range questions { questionMap[q.ID] = q } @@ -466,7 +466,7 @@ func GetExamRecord(c *gin.Context) { } // 构建题目映射 - questionMap := make(map[uint]models.PracticeQuestion) + questionMap := make(map[int64]models.PracticeQuestion) for _, q := range questions { questionMap[q.ID] = q } @@ -625,7 +625,7 @@ func DeleteExam(c *gin.Context) { // SaveExamProgressRequest 保存考试进度请求 type SaveExamProgressRequest struct { - QuestionID uint `json:"question_id"` // 题目ID + QuestionID int64 `json:"question_id"` // 题目ID Answer interface{} `json:"answer"` // 答案数据 } @@ -638,7 +638,7 @@ func SaveExamProgress(c *gin.Context) { } recordIDStr := c.Param("record_id") - recordID, err := strconv.ParseUint(recordIDStr, 10, 32) + recordID, err := strconv.ParseInt(recordIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"}) return @@ -682,9 +682,9 @@ func SaveExamProgress(c *gin.Context) { if result.Error == gorm.ErrRecordNotFound { // 不存在,创建新记录 userAnswer = models.ExamUserAnswer{ - ExamRecordID: uint(recordID), + ExamRecordID: recordID, QuestionID: req.QuestionID, - UserID: userID.(uint), + UserID: userID.(int64), Answer: answerJSON, AnsweredAt: &now, LastModifiedAt: now, diff --git a/internal/handlers/practice_handler.go b/internal/handlers/practice_handler.go index 6bd4315..c755200 100644 --- a/internal/handlers/practice_handler.go +++ b/internal/handlers/practice_handler.go @@ -5,6 +5,7 @@ import ( "ankao/internal/models" "ankao/internal/services" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -14,6 +15,7 @@ import ( "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // checkEssayPermission 检查用户是否有权限访问论述题 @@ -370,38 +372,63 @@ func SubmitPracticeAnswer(c *gin.Context) { } } - // 记录用户答题历史 - if uid, ok := userID.(uint); ok { - record := models.UserAnswerRecord{ - UserID: uid, - QuestionID: question.ID, - IsCorrect: correct, - AnsweredAt: time.Now(), - } - // 记录到数据库(忽略错误,不影响主流程) - if err := db.Create(&record).Error; err != nil { - log.Printf("记录答题历史失败: %v", err) - } + userAnswer, _ := json.Marshal(submit.Answer) + uid := userID.(int64) + record := models.UserAnswerRecord{ + UserID: uid, + QuestionID: question.ID, + IsCorrect: correct, + AnsweredAt: time.Now(), + UserAnswer: userAnswer, + } + // 记录到数据库(忽略错误,不影响主流程) + if err := db.Create(&record).Error; err != nil { + log.Printf("记录答题历史失败: %v", err) } // 记录到错题本(新版)- 使用 V2 API - if uid, ok := userID.(uint); ok { - timeSpent := 0 // TODO: 从前端获取答题用时 - if !correct { - // 答错,记录到错题本 - var wrongAnswer interface{} = submit.Answer - var stdAnswer interface{} = correctAnswer - if strings.HasSuffix(question.Type, "-essay") { - stdAnswer = "" // 论述题没有标准答案 - } - if err := services.RecordWrongAnswer(uid, question.ID, wrongAnswer, stdAnswer, timeSpent); err != nil { - log.Printf("记录错题失败: %v", err) - } - } else { - // 答对,如果这道题在错题本中,更新连续答对次数 - if err := services.RecordCorrectAnswer(uid, question.ID, submit.Answer, correctAnswer, timeSpent); err != nil { - log.Printf("更新错题记录失败: %v", err) - } + timeSpent := 0 + if !correct { + if err := services.RecordWrongAnswer(uid, question.ID, submit.Answer, correctAnswer, timeSpent); err != nil { + log.Printf("记录错题失败: %v", err) + } + } else { + // 答对,如果这道题在错题本中,更新连续答对次数 + if err := services.RecordCorrectAnswer(uid, question.ID, submit.Answer, correctAnswer, timeSpent); err != nil { + log.Printf("更新错题记录失败: %v", err) + } + } + + // 保存练习进度到数据库 + // 查找或创建进度记录 + var progress models.PracticeProgress + findResult := db.Where("user_id = ? and type = ?", uid, question.Type).First(&progress) + if errors.Is(findResult.Error, gorm.ErrRecordNotFound) { + // 创建新记录 + answerRecords := map[int64]int64{submit.QuestionID: record.ID} + answerRecordsJSON, _ := json.Marshal(answerRecords) + progress = models.PracticeProgress{ + UserID: uid, + CurrentQuestionID: submit.QuestionID, + Type: question.Type, + UserAnswerRecords: answerRecordsJSON, + } + if err := db.Create(&progress).Error; err != nil { + log.Printf("保存练习进度失败: %v", err) + } + } else if findResult.Error == nil { + // 解析现有的答题记录 + var answerRecords map[int64]int64 + if err := json.Unmarshal(progress.UserAnswerRecords, &answerRecords); err != nil { + answerRecords = make(map[int64]int64) + } + // 更新记录 + progress.CurrentQuestionID = submit.QuestionID + answerRecords[submit.QuestionID] = record.ID + answerRecordsJSON, _ := json.Marshal(answerRecords) + progress.UserAnswerRecords = answerRecordsJSON + if err := db.Save(&progress).Error; err != nil { + log.Printf("更新练习进度失败: %v", err) } } @@ -464,71 +491,116 @@ func GetPracticeQuestionTypes(c *gin.Context) { }) } -// checkPracticeAnswer 检查练习答案是否正确 -func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool { - switch questionType { - case "true-false": - // 判断题: boolean 比较 - userBool, ok1 := userAnswer.(bool) - correctBool, ok2 := correctAnswer.(bool) - return ok1 && ok2 && userBool == correctBool +type CheckAnswer interface { + check(userAnswer, correctAnswer interface{}) bool +} - case "multiple-choice": - // 单选题: 字符串比较 - userStr, ok1 := userAnswer.(string) - correctStr, ok2 := correctAnswer.(string) - return ok1 && ok2 && userStr == correctStr +type CheckTrueFalse struct { +} - case "multiple-selection": - // 多选题: 数组比较 - userArr, ok1 := toStringArray(userAnswer) - correctArr, ok2 := toStringArray(correctAnswer) - if !ok1 || !ok2 || len(userArr) != len(correctArr) { - return false - } +func (*CheckTrueFalse) check(userAnswer, correctAnswer interface{}) bool { + userBool, ok1 := userAnswer.(bool) + correctBool, ok2 := correctAnswer.(bool) + return ok1 && ok2 && userBool == correctBool +} - // 转换为map进行比较 - userMap := make(map[string]bool) - for _, v := range userArr { - userMap[v] = true - } - for _, v := range correctArr { - if !userMap[v] { - return false - } - } - return true +type MultipleChoice struct { +} - case "fill-in-blank": - // 填空题: 数组比较 - userArr, ok1 := toStringArray(userAnswer) - correctArr, ok2 := toStringArray(correctAnswer) - if !ok1 || !ok2 || len(userArr) != len(correctArr) { - log.Printf("填空题验证失败 - 数组转换或长度不匹配: ok1=%v, ok2=%v, userLen=%d, correctLen=%d", - ok1, ok2, len(userArr), len(correctArr)) - return false - } +func (*MultipleChoice) check(userAnswer, correctAnswer interface{}) bool { + userStr, ok1 := userAnswer.(string) + correctStr, ok2 := correctAnswer.(string) + return ok1 && ok2 && userStr == correctStr +} - // 逐个比较填空答案(去除前后空格) - for i := range correctArr { - userTrimmed := strings.TrimSpace(userArr[i]) - correctTrimmed := strings.TrimSpace(correctArr[i]) - if userTrimmed != correctTrimmed { - log.Printf("填空题验证失败 - 第%d个答案不匹配: user='%s', correct='%s'", - i+1, userTrimmed, correctTrimmed) - return false - } - } - return true +type MultipleSelection struct { +} - case "short-answer": - // 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断) - userStr, ok1 := userAnswer.(string) - correctStr, ok2 := correctAnswer.(string) - return ok1 && ok2 && userStr == correctStr +func (*MultipleSelection) check(userAnswer, correctAnswer interface{}) bool { + // 多选题: 数组比较 + userArr, ok1 := toStringArray(userAnswer) + correctArr, ok2 := toStringArray(correctAnswer) + if !ok1 || !ok2 || len(userArr) != len(correctArr) { + return false } - return false + // 转换为map进行比较 + userMap := make(map[string]bool) + for _, v := range userArr { + userMap[v] = true + } + for _, v := range correctArr { + if !userMap[v] { + return false + } + } + return true +} + +type FillInBlank struct { +} + +func (*FillInBlank) check(userAnswer, correctAnswer interface{}) bool { + // 填空题: 数组比较 + userArr, ok1 := toStringArray(userAnswer) + correctArr, ok2 := toStringArray(correctAnswer) + if !ok1 || !ok2 || len(userArr) != len(correctArr) { + log.Printf("填空题验证失败 - 数组转换或长度不匹配: ok1=%v, ok2=%v, userLen=%d, correctLen=%d", + ok1, ok2, len(userArr), len(correctArr)) + return false + } + + // 逐个比较填空答案(去除前后空格) + for i := range correctArr { + userTrimmed := strings.TrimSpace(userArr[i]) + correctTrimmed := strings.TrimSpace(correctArr[i]) + if userTrimmed != correctTrimmed { + log.Printf("填空题验证失败 - 第%d个答案不匹配: user='%s', correct='%s'", + i+1, userTrimmed, correctTrimmed) + return false + } + } + return true +} + +type ShortAnswer struct { +} + +func (*ShortAnswer) check(userAnswer, correctAnswer interface{}) bool { + // 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断) + userStr, ok1 := userAnswer.(string) + correctStr, ok2 := correctAnswer.(string) + return ok1 && ok2 && userStr == correctStr +} + +type CheckAnswers struct { + checkAnswers map[string]CheckAnswer +} + +func (c CheckAnswers) check(questionType string, userAnswer, correctAnswer interface{}) bool { + checkAnswer := c.checkAnswers[questionType] + return checkAnswer.check(userAnswer, correctAnswer) +} + +func NewCheckAnswers() *CheckAnswers { + return &CheckAnswers{ + checkAnswers: map[string]CheckAnswer{ + "true-false": &CheckTrueFalse{}, + "multiple-choice": &MultipleChoice{}, + "multiple-selection": &MultipleSelection{}, + "fill-in-blank": &FillInBlank{}, + "short-answer": &ShortAnswer{}, + }, + } +} + +// checkPracticeAnswer 检查练习答案是否正确 +func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool { + answers := NewCheckAnswers() + if answers == nil { + return false + } + return answers.check(questionType, userAnswer, correctAnswer) } // toStringArray 将interface{}转换为字符串数组 @@ -556,7 +628,7 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO { dto := models.PracticeQuestionDTO{ ID: question.ID, QuestionID: question.QuestionID, - Type: question.Type, // 直接使用数据库中的type,不做映射 + Type: question.Type, // 直接使用数据库中的type,不做映射 Content: question.Question, Category: question.TypeName, // 使用typeName作为分类显示 Options: []models.Option{}, @@ -697,11 +769,11 @@ func GetStatistics(c *gin.Context) { // CreatePracticeQuestion 创建新的练习题目 func CreatePracticeQuestion(c *gin.Context) { var req struct { - Type string `json:"type" binding:"required"` - TypeName string `json:"type_name"` - Question string `json:"question" binding:"required"` - Answer interface{} `json:"answer" binding:"required"` - Options map[string]string `json:"options"` + Type string `json:"type" binding:"required"` + TypeName string `json:"type_name"` + Question string `json:"question" binding:"required"` + Answer interface{} `json:"answer" binding:"required"` + Options map[string]string `json:"options"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -801,11 +873,11 @@ func UpdatePracticeQuestion(c *gin.Context) { } var req struct { - Type string `json:"type"` - TypeName string `json:"type_name"` - Question string `json:"question"` - Answer interface{} `json:"answer"` - Options map[string]string `json:"options"` + Type string `json:"type"` + TypeName string `json:"type_name"` + Question string `json:"question"` + Answer interface{} `json:"answer"` + Options map[string]string `json:"options"` } if err := c.ShouldBindJSON(&req); err != nil { diff --git a/internal/handlers/wrong_question_handler.go b/internal/handlers/wrong_question_handler.go index afdab69..2a3f915 100644 --- a/internal/handlers/wrong_question_handler.go +++ b/internal/handlers/wrong_question_handler.go @@ -254,210 +254,6 @@ func ClearWrongQuestions(c *gin.Context) { }) } -// UpdateWrongQuestionTags 更新错题标签 -// PUT /api/v2/wrong-questions/:id/tags -func UpdateWrongQuestionTags(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的错题ID"}) - return - } - - var req struct { - Tags []string `json:"tags"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"}) - return - } - - db := database.GetDB() - var wrongQuestion models.WrongQuestion - if err := db.Where("id = ? AND user_id = ?", id, userID).First(&wrongQuestion).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"}) - return - } - - wrongQuestion.Tags = req.Tags - if err := db.Save(&wrongQuestion).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "更新标签失败"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "标签更新成功", - }) -} - -// ==================== 标签管理 API ==================== - -// GetWrongQuestionTags 获取用户的所有标签 -// GET /api/v2/wrong-question-tags -func GetWrongQuestionTags(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) - return - } - - db := database.GetDB() - var tags []models.WrongQuestionTag - if err := db.Where("user_id = ?", userID).Order("created_at DESC").Find(&tags).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "获取标签失败"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": tags, - }) -} - -// CreateWrongQuestionTag 创建标签 -// POST /api/v2/wrong-question-tags -func CreateWrongQuestionTag(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) - return - } - - var req struct { - Name string `json:"name" binding:"required"` - Color string `json:"color"` - Description string `json:"description"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"}) - return - } - - db := database.GetDB() - - // 检查标签名是否已存在 - var existing models.WrongQuestionTag - if err := db.Where("user_id = ? AND name = ?", userID, req.Name).First(&existing).Error; err == nil { - c.JSON(http.StatusConflict, gin.H{"error": "标签名已存在"}) - return - } - - tag := models.WrongQuestionTag{ - UserID: userID.(uint), - Name: req.Name, - Color: req.Color, - Description: req.Description, - } - - if tag.Color == "" { - tag.Color = "#1890ff" // 默认颜色 - } - - if err := db.Create(&tag).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "创建标签失败"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": tag, - }) -} - -// UpdateWrongQuestionTag 更新标签 -// PUT /api/v2/wrong-question-tags/:id -func UpdateWrongQuestionTag(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的标签ID"}) - return - } - - var req struct { - Name string `json:"name"` - Color string `json:"color"` - Description string `json:"description"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"}) - return - } - - db := database.GetDB() - var tag models.WrongQuestionTag - if err := db.Where("id = ? AND user_id = ?", id, userID).First(&tag).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) - return - } - - if req.Name != "" { - tag.Name = req.Name - } - if req.Color != "" { - tag.Color = req.Color - } - if req.Description != "" { - tag.Description = req.Description - } - - if err := db.Save(&tag).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "更新标签失败"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": tag, - }) -} - -// DeleteWrongQuestionTag 删除标签 -// DELETE /api/v2/wrong-question-tags/:id -func DeleteWrongQuestionTag(c *gin.Context) { - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的标签ID"}) - return - } - - db := database.GetDB() - result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.WrongQuestionTag{}) - if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"}) - return - } - - if result.RowsAffected == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) - return - } - - // TODO: 从所有错题中移除该标签 - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "删除成功", - }) -} - // ==================== 辅助函数 ==================== // convertWrongQuestionToDTO 转换为 DTO V2(可选是否包含最近历史) @@ -471,7 +267,6 @@ func convertWrongQuestionToDTO(wq *models.WrongQuestion, includeHistory bool) mo MasteryLevel: wq.MasteryLevel, ConsecutiveCorrect: wq.ConsecutiveCorrect, IsMastered: wq.IsMastered, - Tags: wq.Tags, } // 转换题目信息 diff --git a/internal/models/answer_record.go b/internal/models/answer_record.go index e2e94fb..9950907 100644 --- a/internal/models/answer_record.go +++ b/internal/models/answer_record.go @@ -1,18 +1,18 @@ package models import ( + "gorm.io/datatypes" "time" - - "gorm.io/gorm" ) // UserAnswerRecord 用户答题记录 type UserAnswerRecord struct { - gorm.Model - UserID uint `gorm:"index;not null" json:"user_id"` // 用户ID - QuestionID uint `gorm:"index;not null" json:"question_id"` // 题目ID - IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对 - AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间 + ID int64 `gorm:"primarykey"` + UserID int64 `gorm:"index;not null" json:"user_id"` // 用户ID + QuestionID int64 `gorm:"index;not null" json:"question_id"` // 题目ID + IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对 + AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间 + UserAnswer datatypes.JSON `gorm:"json" json:"user_answer"` } // TableName 指定表名 diff --git a/internal/models/exam.go b/internal/models/exam.go index f1e625f..68530ce 100644 --- a/internal/models/exam.go +++ b/internal/models/exam.go @@ -13,12 +13,12 @@ type Exam struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID - Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题 - TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分 - Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟) - PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数 - QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组) + UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID + Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题 + TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分 + Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟) + PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数 + QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组) Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived } @@ -28,15 +28,15 @@ type ExamRecord struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - ExamID uint `gorm:"not null;index" json:"exam_id"` // 试卷ID - UserID uint `gorm:"not null;index" json:"user_id"` // 考生ID - StartTime *time.Time `json:"start_time"` // 开始时间 - SubmitTime *time.Time `json:"submit_time"` // 提交时间 - TimeSpent int `json:"time_spent"` // 实际用时(秒) - Score float64 `gorm:"type:decimal(5,2)" json:"score"` // 得分 - TotalScore int `json:"total_score"` // 总分 + ExamID uint `gorm:"not null;index" json:"exam_id"` // 试卷ID + UserID uint `gorm:"not null;index" json:"user_id"` // 考生ID + StartTime *time.Time `json:"start_time"` // 开始时间 + SubmitTime *time.Time `json:"submit_time"` // 提交时间 + TimeSpent int `json:"time_spent"` // 实际用时(秒) + Score float64 `gorm:"type:decimal(5,2)" json:"score"` // 得分 + TotalScore int `json:"total_score"` // 总分 Status string `gorm:"type:varchar(20);not null;default:'in_progress'" json:"status"` // 状态: in_progress, submitted, graded - IsPassed bool `json:"is_passed"` // 是否通过 + IsPassed bool `json:"is_passed"` // 是否通过 // 关联 Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"` @@ -45,13 +45,13 @@ type ExamRecord struct { // ExamUserAnswer 用户答案表(记录每道题的答案) type ExamUserAnswer struct { - ID uint `gorm:"primaryKey" json:"id"` + ID int64 `gorm:"primaryKey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - ExamRecordID uint `gorm:"not null;index:idx_record_question" json:"exam_record_id"` // 考试记录ID - QuestionID uint `gorm:"not null;index:idx_record_question" json:"question_id"` // 题目ID - UserID uint `gorm:"not null;index" json:"user_id"` // 用户ID + ExamRecordID int64 `gorm:"not null;index:idx_record_question" json:"exam_record_id"` // 考试记录ID + QuestionID int64 `gorm:"not null;index:idx_record_question" json:"question_id"` // 题目ID + UserID int64 `gorm:"not null;index" json:"user_id"` // 用户ID Answer datatypes.JSON `gorm:"type:json" json:"answer"` // 用户答案 (JSON格式,支持各种题型) IsCorrect *bool `json:"is_correct,omitempty"` // 是否正确(提交后评分) Score float64 `gorm:"type:decimal(5,2);default:0" json:"score"` // 得分 @@ -60,7 +60,7 @@ type ExamUserAnswer struct { LastModifiedAt time.Time `json:"last_modified_at"` // 最后修改时间 // 关联 - ExamRecord *ExamRecord `gorm:"foreignKey:ExamRecordID" json:"-"` + ExamRecord *ExamRecord `gorm:"foreignKey:ExamRecordID" json:"-"` Question *PracticeQuestion `gorm:"foreignKey:QuestionID" json:"-"` } @@ -81,9 +81,9 @@ type QuestionTypeConfig struct { // ExamAnswer 考试答案结构 type ExamAnswer struct { - QuestionID uint `json:"question_id"` - Answer interface{} `json:"answer"` // 用户答案 - CorrectAnswer interface{} `json:"correct_answer"` // 正确答案 + QuestionID int64 `json:"question_id"` + Answer interface{} `json:"answer"` // 用户答案 + CorrectAnswer interface{} `json:"correct_answer"` // 正确答案 IsCorrect bool `json:"is_correct"` Score float64 `json:"score"` AIGrading *AIGrading `json:"ai_grading,omitempty"` @@ -91,41 +91,40 @@ type ExamAnswer struct { // ExamQuestionConfig 考试题目配置 type ExamQuestionConfig struct { - FillInBlank int `json:"fill_in_blank"` // 填空题数量 - TrueFalse int `json:"true_false"` // 判断题数量 - MultipleChoice int `json:"multiple_choice"` // 单选题数量 - MultipleSelection int `json:"multiple_selection"` // 多选题数量 - ShortAnswer int `json:"short_answer"` // 简答题数量 - OrdinaryEssay int `json:"ordinary_essay"` // 普通涉密人员论述题数量 - ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量 + FillInBlank int `json:"fill_in_blank"` // 填空题数量 + TrueFalse int `json:"true_false"` // 判断题数量 + MultipleChoice int `json:"multiple_choice"` // 单选题数量 + MultipleSelection int `json:"multiple_selection"` // 多选题数量 + ShortAnswer int `json:"short_answer"` // 简答题数量 + OrdinaryEssay int `json:"ordinary_essay"` // 普通涉密人员论述题数量 + ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量 } - // DefaultExamConfig 默认考试配置 var DefaultExamConfig = ExamQuestionConfig{ - FillInBlank: 10, // 填空题10道 - TrueFalse: 10, // 判断题10道 - MultipleChoice: 10, // 单选题10道 - MultipleSelection: 10, // 多选题10道 - ShortAnswer: 2, // 简答题2道 - OrdinaryEssay: 1, // 普通论述题1道 - ManagementEssay: 1, // 管理论述题1道 + FillInBlank: 10, // 填空题10道 + TrueFalse: 10, // 判断题10道 + MultipleChoice: 10, // 单选题10道 + MultipleSelection: 10, // 多选题10道 + ShortAnswer: 2, // 简答题2道 + OrdinaryEssay: 1, // 普通论述题1道 + ManagementEssay: 1, // 管理论述题1道 } // ExamScoreConfig 考试分值配置 type ExamScoreConfig struct { - FillInBlank float64 `json:"fill_in_blank"` // 填空题分值 - TrueFalse float64 `json:"true_false"` // 判断题分值 - MultipleChoice float64 `json:"multiple_choice"` // 单选题分值 - MultipleSelection float64 `json:"multiple_selection"` // 多选题分值 - Essay float64 `json:"essay"` // 论述题分值 + FillInBlank float64 `json:"fill_in_blank"` // 填空题分值 + TrueFalse float64 `json:"true_false"` // 判断题分值 + MultipleChoice float64 `json:"multiple_choice"` // 单选题分值 + MultipleSelection float64 `json:"multiple_selection"` // 多选题分值 + Essay float64 `json:"essay"` // 论述题分值 } // DefaultScoreConfig 默认分值配置 var DefaultScoreConfig = ExamScoreConfig{ - FillInBlank: 2.0, // 填空题每题2分 (共20分) - TrueFalse: 2.0, // 判断题每题2分 (共20分) - MultipleChoice: 1.0, // 单选题每题1分 (共10分) - MultipleSelection: 2.5, // 多选题每题2.5分 (共25分) - Essay: 25.0, // 论述题25分 + FillInBlank: 2.0, // 填空题每题2分 (共20分) + TrueFalse: 2.0, // 判断题每题2分 (共20分) + MultipleChoice: 1.0, // 单选题每题1分 (共10分) + MultipleSelection: 2.5, // 多选题每题2.5分 (共25分) + Essay: 25.0, // 论述题25分 } diff --git a/internal/models/practice_progress.go b/internal/models/practice_progress.go new file mode 100644 index 0000000..aabb5db --- /dev/null +++ b/internal/models/practice_progress.go @@ -0,0 +1,19 @@ +package models + +import ( + "gorm.io/datatypes" +) + +// PracticeProgress 练习进度记录(每道题目一条记录) +type PracticeProgress struct { + ID int64 `gorm:"primarykey" json:"id"` + CurrentQuestionID int64 `gorm:"not null" json:"current_question_id"` + UserID int64 `gorm:"not null;uniqueIndex:idx_user_question" json:"user_id"` + Type string `gorm:"type:varchar(255);not null" json:"type"` + UserAnswerRecords datatypes.JSON `gorm:"type:jsonp" json:"answers"` +} + +// TableName 指定表名 +func (PracticeProgress) TableName() string { + return "practice_progress" +} diff --git a/internal/models/practice_question.go b/internal/models/practice_question.go index 18d6522..5f0efd0 100644 --- a/internal/models/practice_question.go +++ b/internal/models/practice_question.go @@ -1,16 +1,14 @@ package models -import "gorm.io/gorm" - // PracticeQuestion 练习题目模型 type PracticeQuestion struct { - gorm.Model - QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段) - Type string `gorm:"index;size:30;not null" json:"type"` // 题目类型 - TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文) - Question string `gorm:"type:text;not null" json:"question"` // 题目内容 - AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储) - OptionsData string `gorm:"type:text" json:"-"` // 选项数据(JSON格式存储,用于选择题) + ID int64 `gorm:"primarykey"` + QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段) + Type string `gorm:"index;size:30;not null" json:"type"` // 题目类型 + TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文) + Question string `gorm:"type:text;not null" json:"question"` // 题目内容 + AnswerData string `gorm:"type:jsonb" json:"-"` // 答案数据(JSON格式存储) + OptionsData string `gorm:"type:jsonb" json:"-"` // 选项数据(JSON格式存储,用于选择题) } // TableName 指定表名 @@ -20,7 +18,7 @@ func (PracticeQuestion) TableName() string { // PracticeQuestionDTO 用于前端返回的数据传输对象 type PracticeQuestionDTO struct { - ID uint `json:"id"` // 数据库自增ID + ID int64 `json:"id"` // 数据库自增ID QuestionID string `json:"question_id"` // 题目编号(原JSON中的id) Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill Content string `json:"content"` // 题目内容 @@ -31,7 +29,7 @@ type PracticeQuestionDTO struct { // PracticeAnswerSubmit 练习题答案提交 type PracticeAnswerSubmit struct { - QuestionID uint `json:"question_id" binding:"required"` // 数据库ID + QuestionID int64 `json:"question_id" binding:"required"` // 数据库ID Answer interface{} `json:"answer" binding:"required"` // 用户答案 } diff --git a/internal/models/user.go b/internal/models/user.go index e21e8c6..fa27f82 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -9,10 +9,10 @@ import ( // User 用户结构 type User struct { - ID uint `gorm:"primaryKey" json:"id"` + ID int64 `gorm:"primaryKey" json:"id"` Username string `gorm:"uniqueIndex;not null;size:50" json:"username"` Password string `gorm:"not null;size:255" json:"-"` // json:"-" 表示在JSON响应中不返回密码 - Token string `gorm:"size:255;index" json:"-"` // 用户登录token + Token string `gorm:"size:255;index" json:"-"` // 用户登录token Avatar string `gorm:"size:255" json:"avatar"` Nickname string `gorm:"size:50" json:"nickname"` UserType string `gorm:"size:50" json:"user_type"` // 用户类型: ordinary-person 或 management-person @@ -48,7 +48,7 @@ type LoginRequest struct { // LoginResponse 登录响应 type LoginResponse struct { - Token string `json:"token"` + Token string `json:"token"` User UserInfoResponse `json:"user"` } diff --git a/internal/models/wrong_question.go b/internal/models/wrong_question.go index 4c11500..cab107f 100644 --- a/internal/models/wrong_question.go +++ b/internal/models/wrong_question.go @@ -1,8 +1,6 @@ package models import ( - "database/sql/driver" - "encoding/json" "time" "gorm.io/gorm" @@ -10,82 +8,35 @@ import ( // WrongQuestion 错题记录 type WrongQuestion struct { - ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` - QuestionID uint `gorm:"index;not null" json:"question_id"` + ID int64 `gorm:"primarykey" json:"id"` + UserID int64 `gorm:"index;not null" json:"user_id"` + QuestionID int64 `gorm:"index;not null" json:"question_id"` FirstWrongTime time.Time `json:"first_wrong_time"` LastWrongTime time.Time `json:"last_wrong_time"` TotalWrongCount int `gorm:"default:1" json:"total_wrong_count"` MasteryLevel int `gorm:"default:0" json:"mastery_level"` // 0-100 ConsecutiveCorrect int `gorm:"default:0" json:"consecutive_correct"` IsMastered bool `gorm:"default:false" json:"is_mastered"` - Tags QuestionTags `gorm:"type:text" json:"tags"` // JSON 存储标签 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 关联 - PracticeQuestion *PracticeQuestion `gorm:"foreignKey:QuestionID;references:ID" json:"question,omitempty"` - History []WrongQuestionHistory `gorm:"foreignKey:WrongQuestionID" json:"history,omitempty"` + PracticeQuestion *PracticeQuestion `gorm:"foreignKey:QuestionID;references:ID" json:"question,omitempty"` + History []WrongQuestionHistory `gorm:"foreignKey:WrongQuestionID" json:"history,omitempty"` } // WrongQuestionHistory 错误历史记录 type WrongQuestionHistory struct { - ID uint `gorm:"primarykey" json:"id"` - WrongQuestionID uint `gorm:"index;not null" json:"wrong_question_id"` - UserAnswer string `gorm:"type:text;not null" json:"user_answer"` // JSON 存储 - CorrectAnswer string `gorm:"type:text;not null" json:"correct_answer"` // JSON 存储 + ID int64 `gorm:"primarykey" json:"id"` + WrongQuestionID int64 `gorm:"index;not null" json:"wrong_question_id"` + UserAnswer string `gorm:"type:text;not null" json:"user_answer"` // JSON 存储 + CorrectAnswer string `gorm:"type:text;not null" json:"correct_answer"` // JSON 存储 AnsweredAt time.Time `gorm:"index" json:"answered_at"` TimeSpent int `json:"time_spent"` // 答题用时(秒) IsCorrect bool `json:"is_correct"` } -// WrongQuestionTag 错题标签 -type WrongQuestionTag struct { - ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` - Name string `gorm:"size:50;not null" json:"name"` - Color string `gorm:"size:20;default:'#1890ff'" json:"color"` - Description string `gorm:"size:200" json:"description"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} - -// QuestionTags 标签列表(用于 JSON 存储) -type QuestionTags []string - -// Scan 从数据库读取 -func (t *QuestionTags) Scan(value interface{}) error { - if value == nil { - *t = []string{} - return nil - } - bytes, ok := value.([]byte) - if !ok { - str, ok := value.(string) - if !ok { - *t = []string{} - return nil - } - bytes = []byte(str) - } - if len(bytes) == 0 || string(bytes) == "null" { - *t = []string{} - return nil - } - return json.Unmarshal(bytes, t) -} - -// Value 写入数据库 -func (t QuestionTags) Value() (driver.Value, error) { - if len(t) == 0 { - return "[]", nil - } - bytes, err := json.Marshal(t) - return string(bytes), err -} - // TableName 指定表名 func (WrongQuestion) TableName() string { return "wrong_questions" @@ -96,29 +47,24 @@ func (WrongQuestionHistory) TableName() string { return "wrong_question_history" } -// TableName 指定表名 -func (WrongQuestionTag) TableName() string { - return "wrong_question_tags" -} - // WrongQuestionDTO 错题数据传输对象 type WrongQuestionDTO struct { - ID uint `json:"id"` - QuestionID uint `json:"question_id"` - Question *PracticeQuestionDTO `json:"question"` - FirstWrongTime time.Time `json:"first_wrong_time"` - LastWrongTime time.Time `json:"last_wrong_time"` - TotalWrongCount int `json:"total_wrong_count"` - MasteryLevel int `json:"mastery_level"` - ConsecutiveCorrect int `json:"consecutive_correct"` - IsMastered bool `json:"is_mastered"` - Tags []string `json:"tags"` - RecentHistory []WrongQuestionHistoryDTO `json:"recent_history,omitempty"` // 最近3次历史 + ID int64 `json:"id"` + QuestionID int64 `json:"question_id"` + Question *PracticeQuestionDTO `json:"question"` + FirstWrongTime time.Time `json:"first_wrong_time"` + LastWrongTime time.Time `json:"last_wrong_time"` + TotalWrongCount int `json:"total_wrong_count"` + MasteryLevel int `json:"mastery_level"` + ConsecutiveCorrect int `json:"consecutive_correct"` + IsMastered bool `json:"is_mastered"` + Tags []string `json:"tags"` + RecentHistory []WrongQuestionHistoryDTO `json:"recent_history,omitempty"` // 最近3次历史 } // WrongQuestionHistoryDTO 错误历史 DTO type WrongQuestionHistoryDTO struct { - ID uint `json:"id"` + ID int64 `json:"id"` UserAnswer interface{} `json:"user_answer"` CorrectAnswer interface{} `json:"correct_answer"` AnsweredAt time.Time `json:"answered_at"` @@ -128,15 +74,14 @@ type WrongQuestionHistoryDTO struct { // WrongQuestionStats 错题统计 type WrongQuestionStats struct { - TotalWrong int `json:"total_wrong"` // 总错题数 - Mastered int `json:"mastered"` // 已掌握数 - NotMastered int `json:"not_mastered"` // 未掌握数 - NeedReview int `json:"need_review"` // 需要复习数 - TypeStats map[string]int `json:"type_stats"` // 按题型统计 - CategoryStats map[string]int `json:"category_stats"` // 按分类统计 - MasteryLevelDist map[string]int `json:"mastery_level_dist"` // 掌握度分布 - TagStats map[string]int `json:"tag_stats"` // 按标签统计 - TrendData []TrendPoint `json:"trend_data"` // 错题趋势 + TotalWrong int `json:"total_wrong"` // 总错题数 + Mastered int `json:"mastered"` // 已掌握数 + NotMastered int `json:"not_mastered"` // 未掌握数 + NeedReview int `json:"need_review"` // 需要复习数 + TypeStats map[string]int `json:"type_stats"` // 按题型统计 + CategoryStats map[string]int `json:"category_stats"` // 按分类统计 + MasteryLevelDist map[string]int `json:"mastery_level_dist"` // 掌握度分布 + TrendData []TrendPoint `json:"trend_data"` // 错题趋势 } // TrendPoint 趋势数据点 @@ -175,24 +120,3 @@ func (wq *WrongQuestion) RecordCorrectAnswer() { wq.MasteryLevel = 100 } } - -// AddTag 添加标签 -func (wq *WrongQuestion) AddTag(tag string) { - for _, t := range wq.Tags { - if t == tag { - return // 标签已存在 - } - } - wq.Tags = append(wq.Tags, tag) -} - -// RemoveTag 移除标签 -func (wq *WrongQuestion) RemoveTag(tag string) { - newTags := []string{} - for _, t := range wq.Tags { - if t != tag { - newTags = append(newTags, t) - } - } - wq.Tags = newTags -} diff --git a/internal/services/wrong_question_service.go b/internal/services/wrong_question_service.go index b080f18..775c7dc 100644 --- a/internal/services/wrong_question_service.go +++ b/internal/services/wrong_question_service.go @@ -14,7 +14,7 @@ import ( // ==================== 错题服务 ==================== // RecordWrongAnswer 记录错误答案 -func RecordWrongAnswer(userID, questionID uint, userAnswer, correctAnswer interface{}, timeSpent int) error { +func RecordWrongAnswer(userID, questionID int64, userAnswer, correctAnswer interface{}, timeSpent int) error { db := database.GetDB() log.Printf("[错题记录] 开始记录错题 (userID: %d, questionID: %d)", userID, questionID) @@ -33,7 +33,6 @@ func RecordWrongAnswer(userID, questionID uint, userAnswer, correctAnswer interf wrongQuestion = models.WrongQuestion{ UserID: userID, QuestionID: questionID, - Tags: []string{}, } wrongQuestion.RecordWrongAnswer() @@ -73,7 +72,7 @@ func RecordWrongAnswer(userID, questionID uint, userAnswer, correctAnswer interf } // RecordCorrectAnswer 记录正确答案(用于错题练习) -func RecordCorrectAnswer(userID, questionID uint, userAnswer, correctAnswer interface{}, timeSpent int) error { +func RecordCorrectAnswer(userID, questionID int64, userAnswer, correctAnswer interface{}, timeSpent int) error { db := database.GetDB() // 查找错题记录 @@ -119,7 +118,6 @@ func GetWrongQuestionStats(userID uint) (*models.WrongQuestionStats, error) { TypeStats: make(map[string]int), CategoryStats: make(map[string]int), MasteryLevelDist: make(map[string]int), - TagStats: make(map[string]int), } // 基础统计 @@ -188,15 +186,6 @@ func GetWrongQuestionStats(userID uint) (*models.WrongQuestionStats, error) { stats.MasteryLevelDist[md.Level] = md.Count } - // 标签统计 - var wrongQuestions []models.WrongQuestion - db.Where("user_id = ?", userID).Find(&wrongQuestions) - for _, wq := range wrongQuestions { - for _, tag := range wq.Tags { - stats.TagStats[tag]++ - } - } - // 错题趋势(最近7天) stats.TrendData = calculateTrendData(db, userID, 7) @@ -288,49 +277,13 @@ func GetRecommendedWrongQuestions(userID uint, limit int, excludeQuestionID uint } // getIDs 获取错题记录的ID列表 -func getIDs(questions []models.WrongQuestion) []uint { +func getIDs(questions []models.WrongQuestion) []int64 { if len(questions) == 0 { - return []uint{0} // 避免 SQL 错误 + return []int64{0} // 避免 SQL 错误 } - ids := make([]uint, len(questions)) + ids := make([]int64, len(questions)) for i, q := range questions { ids[i] = q.ID } return ids } - -// AnalyzeWeakPoints 分析薄弱知识点 -func AnalyzeWeakPoints(userID uint) (map[string]float64, error) { - db := database.GetDB() - - // 按分类统计错误率 - var categoryStats []struct { - Category string - WrongCount int - TotalCount int - WrongRate float64 - } - - // 查询每个分类的错题数和总题数 - db.Raw(` - SELECT - pq.category, - COUNT(DISTINCT wq.question_id) as wrong_count, - (SELECT COUNT(*) FROM practice_questions WHERE category = pq.category) as total_count, - CAST(COUNT(DISTINCT wq.question_id) AS FLOAT) / NULLIF((SELECT COUNT(*) FROM practice_questions WHERE category = pq.category), 0) as wrong_rate - FROM wrong_questions wq - LEFT JOIN practice_questions pq ON wq.question_id = pq.id - WHERE wq.user_id = ? - GROUP BY pq.category - ORDER BY wrong_rate DESC - `, userID).Scan(&categoryStats) - - result := make(map[string]float64) - for _, cs := range categoryStats { - if cs.Category != "" { - result[cs.Category] = cs.WrongRate - } - } - - return result, nil -} diff --git a/main.go b/main.go index 1e1deb8..f946b27 100644 --- a/main.go +++ b/main.go @@ -63,23 +63,16 @@ func main() { auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情 auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题 auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 - auth.PUT("/wrong-questions/:id/tags", handlers.UpdateWrongQuestionTags) // 更新错题标签 - - // 标签管理API - auth.GET("/wrong-question-tags", handlers.GetWrongQuestionTags) // 获取标签列表 - auth.POST("/wrong-question-tags", handlers.CreateWrongQuestionTag) // 创建标签 - auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签 - auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签 // 模拟考试相关API - auth.POST("/exams", handlers.CreateExam) // 创建试卷 - auth.GET("/exams", handlers.GetExamList) // 获取试卷列表 - auth.GET("/exams/:id", handlers.GetExamDetail) // 获取试卷详情 - auth.POST("/exams/:id/start", handlers.StartExam) // 开始考试 - auth.POST("/exam-records/:record_id/submit", handlers.SubmitExam) // 提交试卷答案 - auth.GET("/exam-records/:record_id", handlers.GetExamRecord) // 获取考试记录详情 - auth.GET("/exam-records", handlers.GetExamRecordList) // 获取考试记录列表 - auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷 + auth.POST("/exams", handlers.CreateExam) // 创建试卷 + auth.GET("/exams", handlers.GetExamList) // 获取试卷列表 + auth.GET("/exams/:id", handlers.GetExamDetail) // 获取试卷详情 + auth.POST("/exams/:id/start", handlers.StartExam) // 开始考试 + auth.POST("/exam-records/:record_id/submit", handlers.SubmitExam) // 提交试卷答案 + auth.GET("/exam-records/:record_id", handlers.GetExamRecord) // 获取考试记录详情 + auth.GET("/exam-records", handlers.GetExamRecordList) // 获取考试记录列表 + auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷 auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度 auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案 } diff --git a/web/src/api/question.ts b/web/src/api/question.ts index bf5665d..9a49081 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -7,7 +7,6 @@ import type { ApiResponse, WrongQuestion, WrongQuestionStats, - WrongQuestionTag, WrongQuestionFilter } from '../types/question' @@ -31,6 +30,37 @@ export const getStatistics = () => { return request.get>('/practice/statistics') } +// ========== 练习进度相关 API ========== + +// 单题进度记录 +export interface PracticeProgressItem { + question_id: number + correct: boolean | null // null=未答,true=答对,false=答错 + answer_sequence: number // 答题序号(第几次答题) + user_answer: any + updated_at: string +} + +// 保存单题练习进度 +export const savePracticeProgress = (data: { + question_id: number + correct: boolean | null + answer_sequence: number + user_answer: any +}) => { + return request.post>('/practice/progress', data) +} + +// 获取练习进度(返回用户所有题目的进度) +export const getPracticeProgress = () => { + return request.get>('/practice/progress') +} + +// 清除练习进度 +export const clearPracticeProgress = () => { + return request.delete>('/practice/progress') +} + // 重置进度 (暂时返回模拟数据,后续实现) export const resetProgress = async () => { // TODO: 实现真实的重置接口 @@ -86,41 +116,6 @@ export const clearWrongQuestions = () => { return request.delete>('/wrong-questions') } -// 更新错题标签 -export const updateWrongQuestionTags = (id: number, tags: string[]) => { - return request.put>(`/wrong-questions/${id}/tags`, { tags }) -} - -// ========== 标签管理 API ========== - -// 获取标签列表 -export const getWrongQuestionTags = () => { - return request.get>('/wrong-question-tags') -} - -// 创建标签 -export const createWrongQuestionTag = (data: { - name: string - color?: string - description?: string -}) => { - return request.post>('/wrong-question-tags', data) -} - -// 更新标签 -export const updateWrongQuestionTag = (id: number, data: { - name?: string - color?: string - description?: string -}) => { - return request.put>(`/wrong-question-tags/${id}`, data) -} - -// 删除标签 -export const deleteWrongQuestionTag = (id: number) => { - return request.delete>(`/wrong-question-tags/${id}`) -} - // ========== 题库管理相关 API ========== // 创建题目 diff --git a/web/src/components/QuestionCard.tsx b/web/src/components/QuestionCard.tsx index a197065..14403da 100644 --- a/web/src/components/QuestionCard.tsx +++ b/web/src/components/QuestionCard.tsx @@ -37,12 +37,17 @@ const QuestionCard: React.FC = ({ }) => { const [fillAnswers, setFillAnswers] = useState([]) - // 当题目ID变化时,重置填空题答案 + // 当题目或答案变化时,同步填空题答案 useEffect(() => { if (question.type === 'fill-in-blank') { - setFillAnswers([]) + // 如果 selectedAnswer 是数组且有内容,使用它;否则重置为空数组 + if (Array.isArray(selectedAnswer) && selectedAnswer.length > 0) { + setFillAnswers(selectedAnswer) + } else { + setFillAnswers([]) + } } - }, [question.id, question.type]) + }, [question.id, question.type, selectedAnswer]) // 渲染填空题内容 const renderFillContent = () => { diff --git a/web/src/pages/Question.tsx b/web/src/pages/Question.tsx index 1570b8b..38847a4 100644 --- a/web/src/pages/Question.tsx +++ b/web/src/pages/Question.tsx @@ -87,8 +87,8 @@ const QuestionPage: React.FC = () => { return `question_progress_${type || mode || "default"}`; }; - // 保存答题进度 - const saveProgress = (index: number, correct: number, wrong: number, statusMap?: Map) => { + // 保存答题进度(仅保存到 localStorage 作为快速备份) + const saveProgressToLocal = (index: number, correct: number, wrong: number, statusMap?: Map) => { const key = getStorageKey(); const answeredStatusObj: Record = {}; const userAnswersObj: Record = {}; @@ -108,23 +108,69 @@ const QuestionPage: React.FC = () => { questionResultsObj[key] = value; }); - localStorage.setItem( - key, - JSON.stringify({ - currentIndex: index, - correctCount: correct, - wrongCount: wrong, - answeredStatus: answeredStatusObj, - userAnswers: userAnswersObj, - questionResults: questionResultsObj, - timestamp: Date.now(), - }) - ); + const progressData = { + currentIndex: index, + correctCount: correct, + wrongCount: wrong, + answeredStatus: answeredStatusObj, + userAnswers: userAnswersObj, + questionResults: questionResultsObj, + timestamp: Date.now(), + }; + + // 保存到 localStorage(作为本地快速备份) + localStorage.setItem(key, JSON.stringify(progressData)); }; - // 恢复答题进度 - const loadProgress = () => { + // 恢复答题进度(优先从后端加载,fallback 到 localStorage) + const loadProgress = async () => { const key = getStorageKey(); + + try { + // 优先从后端加载进度 + const res = await questionApi.getPracticeProgress(); + if (res.success && res.data && res.data.length > 0 && allQuestions.length > 0) { + // 将后端数据转换为前端的 Map 格式 + // 创建 question_id 到索引的映射 + const questionIdToIndex = new Map(); + allQuestions.forEach((q, idx) => { + questionIdToIndex.set(q.id, idx); + }); + + const statusMap = new Map(); + const answersMap = new Map(); + + let maxIndex = 0; + let correct = 0; + let wrong = 0; + + res.data.forEach((item) => { + const index = questionIdToIndex.get(item.question_id); + if (index !== undefined) { + statusMap.set(index, item.correct); + answersMap.set(index, item.user_answer); + + if (item.correct !== null) { + maxIndex = Math.max(maxIndex, index); + if (item.correct) correct++; + else wrong++; + } + } + }); + + setAnsweredStatus(statusMap); + setUserAnswers(answersMap); + setCorrectCount(correct); + setWrongCount(wrong); + + // 返回下一个未答题的索引或最后一个已答题的下一题 + return Math.min(maxIndex + 1, allQuestions.length - 1); + } + } catch (error) { + console.log('从后端加载进度失败,尝试从 localStorage 加载:', error); + } + + // 如果后端加载失败,从 localStorage 加载 const saved = localStorage.getItem(key); if (saved) { try { @@ -223,7 +269,7 @@ const QuestionPage: React.FC = () => { setAllQuestions(res.data); // 恢复答题进度 - const savedIndex = loadProgress(); + const savedIndex = await loadProgress(); const startIndex = savedIndex < res.data.length ? savedIndex : 0; if (res.data.length > 0) { @@ -304,13 +350,15 @@ const QuestionPage: React.FC = () => { if (res.data.correct) { const newCorrect = correctCount + 1; setCorrectCount(newCorrect); - saveProgress(currentIndex, newCorrect, wrongCount, newStatusMap); + saveProgressToLocal(currentIndex, newCorrect, wrongCount, newStatusMap); } else { const newWrong = wrongCount + 1; setWrongCount(newWrong); - saveProgress(currentIndex, correctCount, newWrong, newStatusMap); + saveProgressToLocal(currentIndex, correctCount, newWrong, newStatusMap); } + // 注意:进度已由后端的 /api/practice/submit 接口自动保存,无需前端再次调用 + // 如果答案正确且开启自动跳转,根据设置的延迟时间后自动进入下一题 if (res.data.correct && autoNext) { setAutoNextLoading(true); @@ -359,7 +407,9 @@ const QuestionPage: React.FC = () => { if (unansweredIndexes.length === 0) { setShowSummary(true); // 清除进度 - localStorage.removeItem(getStorageKey()); + const key = getStorageKey(); + localStorage.removeItem(key); + questionApi.clearPracticeProgress().catch(err => console.error('清除进度失败:', err)); return; } @@ -372,7 +422,9 @@ const QuestionPage: React.FC = () => { // 显示统计摘要 setShowSummary(true); // 清除进度 - localStorage.removeItem(getStorageKey()); + const key = getStorageKey(); + localStorage.removeItem(key); + questionApi.clearPracticeProgress().catch(err => console.error('清除进度失败:', err)); return; } nextIndex = currentIndex + 1; @@ -386,8 +438,8 @@ const QuestionPage: React.FC = () => { setShowResult(false); setAnswerResult(null); - // 保存进度 - saveProgress(nextIndex, correctCount, wrongCount); + // 保存进度到本地 + saveProgressToLocal(nextIndex, correctCount, wrongCount); // 滚动到页面顶部 window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -397,13 +449,16 @@ const QuestionPage: React.FC = () => { // 跳转到指定题目 const handleQuestionSelect = (index: number) => { if (index >= 0 && index < allQuestions.length) { - setCurrentIndex(index); - setCurrentQuestion(allQuestions[index]); + const question = allQuestions[index]; // 检查是否有保存的答案和结果 const savedAnswer = userAnswers.get(index); const savedResult = questionResults.get(index); + // 先更新题目索引和题目内容 + setCurrentIndex(index); + setCurrentQuestion(question); + if (savedAnswer !== undefined && savedResult !== undefined) { // 恢复之前的答案和结果 setSelectedAnswer(savedAnswer); @@ -412,14 +467,14 @@ const QuestionPage: React.FC = () => { } else { // 没有答案,重置状态 setSelectedAnswer( - allQuestions[index].type === "multiple-selection" ? [] : "" + question.type === "multiple-selection" ? [] : "" ); setShowResult(false); setAnswerResult(null); } - // 保存进度 - saveProgress(index, correctCount, wrongCount); + // 保存进度到本地 + saveProgressToLocal(index, correctCount, wrongCount); // 滚动到页面顶部 window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -442,12 +497,23 @@ const QuestionPage: React.FC = () => { }, [searchParams]); // 重试处理 - const handleRetry = () => { + const handleRetry = async () => { setShowSummary(false); setCurrentIndex(0); setCorrectCount(0); setWrongCount(0); - localStorage.removeItem(getStorageKey()); + + const key = getStorageKey(); + // 清除 localStorage + localStorage.removeItem(key); + + // 清除后端进度 + try { + await questionApi.clearPracticeProgress(); + } catch (error) { + console.error('清除后端进度失败:', error); + } + const typeParam = searchParams.get("type"); loadQuestions(typeParam || undefined); }; diff --git a/web/src/types/question.ts b/web/src/types/question.ts index 842cb1f..91ee1cb 100644 --- a/web/src/types/question.ts +++ b/web/src/types/question.ts @@ -100,21 +100,9 @@ export interface WrongQuestionStats { type_stats: Record // 按题型统计 category_stats: Record // 按分类统计 mastery_level_dist: Record // 掌握度分布 - tag_stats: Record // 按标签统计 trend_data: TrendPoint[] // 错题趋势(最近7天) } -// 错题标签 -export interface WrongQuestionTag { - id: number - user_id: number - name: string - color: string - description: string - created_at: string - updated_at: string -} - // 错题筛选参数 export interface WrongQuestionFilter { is_mastered?: boolean