diff --git a/CLAUDE.md b/CLAUDE.md index f62a357..b830379 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -297,6 +297,323 @@ prompt := fmt.Sprintf(`你是一位专业的阅卷老师,请对以下简答题 `, question, standardAnswer, userAnswer) ``` +## 错题本系统 + +**重要更新**: 错题本系统已全面重构为 新版本,提供更强大的功能和更好的用户体验。 + +### 核心特性 + +1. **多次错误记录历史** - 保存每次答错的完整记录,而非仅保留最后一次 +2. **智能复习系统** - 基于艾宾浩斯遗忘曲线的间隔重复算法 +3. **标签管理系统** - 支持自定义标签,灵活分类错题 +4. **智能推荐引擎** - 优先推荐需要复习的高频错题 +5. **掌握度追踪** - 自动计算和更新每道题的掌握度(0-100%) +6. **详细统计分析** - 错题趋势、掌握度分布、薄弱知识点分析 + +### 数据模型设计 + +#### 错题记录表 (`wrong_questions`) + +```go +type WrongQuestion struct { + ID uint // 主键 + UserID uint // 用户ID + QuestionID uint // 题目ID + FirstWrongTime time.Time // 首次错误时间 + LastWrongTime time.Time // 最后错误时间 + TotalWrongCount int // 总错误次数 + MasteryLevel int // 掌握度 (0-100) + NextReviewTime *time.Time // 下次复习时间 + ConsecutiveCorrect int // 连续答对次数 + IsMastered bool // 是否已掌握 + Tags []string // 标签列表 (JSON) +} +``` + +#### 错误历史表 (`wrong_question_history`) + +```go +type WrongQuestionHistory struct { + ID uint // 主键 + WrongQuestionID uint // 关联错题记录 + UserAnswer string // 用户答案 (JSON) + CorrectAnswer string // 正确答案 (JSON) + AnsweredAt time.Time // 答题时间 + TimeSpent int // 答题用时(秒) + IsCorrect bool // 本次是否正确 +} +``` + +#### 错题标签表 (`wrong_question_tags`) + +```go +type WrongQuestionTag struct { + ID uint // 主键 + UserID uint // 用户ID + Name string // 标签名 + Color string // 标签颜色 + Description string // 描述 +} +``` + +### 间隔重复算法(艾宾浩斯遗忘曲线) + +系统采用科学的间隔重复算法,根据用户答题情况自动安排复习时间: + +**复习间隔策略**: `[1天, 3天, 7天, 15天, 30天, 60天]` + +- 答错时:重置连续答对次数,重新从第一个间隔开始 +- 答对时:连续答对次数+1,进入下一个复习间隔 +- 完全掌握:连续答对6次后标记为"已掌握" + +**实现位置**: [internal/models/wrong_question_v2.go](internal/models/wrong_question_v2.go#L156) + +```go +// 默认复习策略 +var DefaultReviewStrategy = ReviewStrategy{ + Intervals: []int{1, 3, 7, 15, 30, 60}, +} + +// 自动计算下次复习时间 +func (wq *WrongQuestion) CalculateNextReviewTime() { + strategy := DefaultReviewStrategy + level := wq.ConsecutiveCorrect + + if level >= len(strategy.Intervals) { + wq.IsMastered = true // 已完全掌握 + wq.MasteryLevel = 100 + wq.NextReviewTime = nil + return + } + + days := strategy.Intervals[level] + nextTime := time.Now().Add(time.Duration(days) * 24 * time.Hour) + wq.NextReviewTime = &nextTime + wq.MasteryLevel = (level * 100) / len(strategy.Intervals) +} +``` + +### API 接口 + +所有 API 都在 `/api/v2` 路径下,与旧版 API 共存以保持向后兼容。 + +#### 错题管理 API + +| 方法 | 路由 | 功能 | 查询参数 | +|------|------|------|----------| +| GET | `/api/wrong-questions` | 获取错题列表 | `is_mastered`, `tag`, `type`, `sort` | +| GET | `/api/wrong-questions/:id` | 获取错题详情 | - | +| GET | `/api/wrong-questions/stats` | 获取错题统计 | - | +| GET | `/api/wrong-questions/recommended` | 获取推荐错题 | `limit` | +| DELETE | `/api/wrong-questions/:id` | 删除错题 | - | +| DELETE | `/api/wrong-questions` | 清空错题本 | - | +| PUT | `/api/wrong-questions/:id/tags` | 更新标签 | - | + +**排序选项** (`sort` 参数): +- `review_time` - 按复习时间排序(最需要复习的在前) +- `wrong_count` - 按错误次数排序(错误最多的在前) +- `mastery_level` - 按掌握度排序(掌握度最低的在前) +- `time` - 按最后错误时间排序(默认) + +#### 标签管理 API + +| 方法 | 路由 | 功能 | +|------|------|------| +| GET | `/api/wrong-question-tags` | 获取标签列表 | +| POST | `/api/wrong-question-tags` | 创建标签 | +| PUT | `/api/wrong-question-tags/:id` | 更新标签 | +| DELETE | `/api/wrong-question-tags/:id` | 删除标签 | + +### 智能推荐算法 + +推荐系统采用三级策略,优先推荐最需要复习的题目: + +1. **优先级 1**: 到期需要复习的题目(`next_review_time <= NOW()`) +2. **优先级 2**: 高频错题且掌握度低(`wrong_count DESC, mastery_level ASC`) +3. **优先级 3**: 最近答错的题目(`last_wrong_time DESC`) + +**实现位置**: [internal/services/wrong_question_service.go](internal/services/wrong_question_service.go#L228) + +### 统计数据 + +错题统计 提供更丰富的数据: + +```json +{ + "total_wrong": 50, + "mastered": 10, + "not_mastered": 40, + "need_review": 15, + "type_stats": { "single-choice": 20, "multiple-choice": 15, "fill-in-blank": 15 }, + "category_stats": { "数学": 25, "语文": 15, "英语": 10 }, + "mastery_level_dist": { "很差": 10, "较差": 15, "一般": 10, "良好": 10, "优秀": 5 }, + "tag_stats": { "重点": 20, "难点": 15 }, + "trend_data": [ + { "date": "01-01", "count": 5 }, + { "date": "01-02", "count": 3 }, + ... + ] +} +``` + +### 数据迁移 + +从旧版错题本迁移到 新版本: + +```bash +# 运行迁移脚本 +go run scripts/migrate_wrong_questions.go +``` + +**迁移说明**: +- 旧表 `wrong_questions` 将保留,不会被删除 +- 新表 `wrong_questions` 包含迁移后的数据 +- 每条旧记录会生成一条新记录和一条历史记录 +- 迁移完成后,新答题记录将自动使用 API + +### 前端集成 + +前端 TypeScript 类型定义位于 [web/src/types/question.ts](web/src/types/question.ts): + +```typescript +// 错题记录 +interface WrongQuestion { + id: number + question_id: number + question?: Question + first_wrong_time: string + last_wrong_time: string + total_wrong_count: number + mastery_level: number // 0-100 + next_review_time?: string + consecutive_correct: number + is_mastered: boolean + tags: string[] + recent_history?: WrongQuestionHistory[] +} +``` + +API 调用方法位于 [web/src/api/question.ts](web/src/api/question.ts): + +```typescript +// 获取错题列表(支持筛选和排序) +getWrongQuestionsV2(filter?: WrongQuestionFilter) + +// 获取推荐错题 +getRecommendedWrongQuestions(limit: number = 10) + +// 获取错题统计 +getWrongQuestionStats() + +// 标签管理 +getWrongQuestionTags() +createWrongQuestionTag(data) +updateWrongQuestionTag(id, data) +deleteWrongQuestionTag(id) +``` + +### 注意事项 + +- API 与旧版 API 完全兼容,可以同时使用 +- 答题时会自动使用 API 记录错题 +- 答对错题本中的题目时,会自动更新连续答对次数 +- 掌握度达到 100% 时,题目会被标记为"已掌握" +- 标签功能支持自定义,建议按知识点或难度分类 + +## AI评分系统 + +项目集成了AI智能评分功能,专门用于对简答题进行评分和反馈。 + +### AI评分配置 + +AI服务配置位于 [pkg/config/config.go](pkg/config/config.go): + +```go +type AIConfig struct { + BaseURL string // AI API地址 + APIKey string // API密钥 + Model string // 使用的模型名称 +} +``` + +**配置方式**: +1. **默认配置**: 直接在代码中设置默认值 +2. **环境变量**: 通过环境变量覆盖默认配置 + ```bash + export AI_BASE_URL="https://ai.yuchat.top" + export AI_API_KEY="你的API密钥" + export AI_MODEL="deepseek-v3" + ``` + +### AI评分服务 + +AI评分服务实现位于 [internal/services/ai_grading.go](internal/services/ai_grading.go): + +**主要功能**: +- `NewAIGradingService()` - 创建AI评分服务实例 +- `GradeShortAnswer(question, standardAnswer, userAnswer)` - 对简答题进行AI评分 + +**返回结果**: +```go +type AIGradingResult struct { + Score float64 // 得分 (0-100) + IsCorrect bool // 是否正确 (Score >= 60 视为正确) + Feedback string // 评语 + Suggestion string // 改进建议 +} +``` + +### 集成方式 + +在 [practice_handler.go](internal/handlers/practice_handler.go) 的 `SubmitPracticeAnswer` 函数中: + +```go +// 对简答题使用AI评分 +if question.Type == "short-answer" { + aiService := services.NewAIGradingService() + aiResult, err := aiService.GradeShortAnswer( + question.Question, + standardAnswerStr, + userAnswerStr, + ) + // 使用AI评分结果 + correct = aiResult.IsCorrect + aiGrading = &models.AIGrading{ + Score: aiResult.Score, + Feedback: aiResult.Feedback, + Suggestion: aiResult.Suggestion, + } +} +``` + +### API响应格式 + +简答题提交后,响应中会包含 `ai_grading` 字段: + +```json +{ + "success": true, + "data": { + "correct": true, + "user_answer": "用户的答案", + "correct_answer": "标准答案", + "ai_grading": { + "score": 85, + "feedback": "答案基本正确,要点全面", + "suggestion": "可以补充一些具体的例子" + } + } +} +``` + +### 注意事项 + +- AI评分仅对 `short-answer` 类型的题目生效 +- 其他题型(填空题、判断题、选择题)仍使用传统的精确匹配方式 +- AI评分失败时不影响主流程,会记录日志并使用传统评分方式 +- 评分采用温度参数 0.3,确保评分结果稳定可靠 + + ## 前端开发规范 ### 包管理和开发 diff --git a/internal/database/database.go b/internal/database/database.go index ec5e493..de945de 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -33,8 +33,10 @@ func InitDB() error { err = DB.AutoMigrate( &models.User{}, &models.PracticeQuestion{}, - &models.WrongQuestion{}, // 添加错题表 - &models.UserAnswerRecord{}, // 添加用户答题记录表 + &models.WrongQuestion{}, // 错题表 + &models.WrongQuestionHistory{}, // 错题历史表 + &models.WrongQuestionTag{}, // 错题标签表 + &models.UserAnswerRecord{}, // 用户答题记录表 ) if err != nil { return fmt.Errorf("failed to migrate database: %w", err) diff --git a/internal/handlers/practice_handler.go b/internal/handlers/practice_handler.go index 4912026..de6fe84 100644 --- a/internal/handlers/practice_handler.go +++ b/internal/handlers/practice_handler.go @@ -383,19 +383,24 @@ func SubmitPracticeAnswer(c *gin.Context) { } } - // 如果答错,记录到错题本(论述题也可能答错) - if !correct { - if uid, ok := userID.(uint); ok { - // 记录错题 - // 论述题没有 correctAnswer,传 nil - wrongAnswer := correctAnswer + // 记录到错题本(新版)- 使用 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") { - wrongAnswer = "" // 论述题没有标准答案 + stdAnswer = "" // 论述题没有标准答案 } - if err := recordWrongQuestion(uid, question.ID, submit.Answer, wrongAnswer); err != nil { - // 记录错题失败不影响主流程,只记录日志 + 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) + } } } diff --git a/internal/handlers/wrong_question_handler.go b/internal/handlers/wrong_question_handler.go index 371393c..afdab69 100644 --- a/internal/handlers/wrong_question_handler.go +++ b/internal/handlers/wrong_question_handler.go @@ -3,124 +3,137 @@ package handlers import ( "ankao/internal/database" "ankao/internal/models" + "ankao/internal/services" "encoding/json" "net/http" - "time" + "strconv" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) -// GetWrongQuestions 获取错题列表 +// ==================== 错题管理 API ==================== + +// GetWrongQuestions 获取错题列表(新版) +// GET /api/v2/wrong-questions?is_mastered=false&type=single-choice&tag=数学&sort=review_time func GetWrongQuestions(c *gin.Context) { userID, exists := c.Get("user_id") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "未登录", - }) + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) return } db := database.GetDB() - var wrongQuestions []models.WrongQuestion + query := db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID) - // 查询参数 - isMastered := c.Query("is_mastered") // "true" 或 "false" - questionType := c.Query("type") // 题型筛选 - - query := db.Where("user_id = ?", userID).Preload("PracticeQuestion") - - // 筛选是否已掌握 - if isMastered == "true" { - query = query.Where("is_mastered = ?", true) - } else if isMastered == "false" { - query = query.Where("is_mastered = ?", false) + // 筛选条件 + if isMastered := c.Query("is_mastered"); isMastered != "" { + query = query.Where("is_mastered = ?", isMastered == "true") } - // 按最后错误时间倒序 - if err := query.Order("last_wrong_time DESC").Find(&wrongQuestions).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "查询失败", - }) + if tag := c.Query("tag"); tag != "" { + query = query.Where("tags LIKE ?", "%"+tag+"%") + } + + // 排序 + switch c.Query("sort") { + case "wrong_count": + // 按错误次数排序(错误最多的在前) + query = query.Order("total_wrong_count DESC") + case "mastery_level": + // 按掌握度排序(掌握度最低的在前) + query = query.Order("mastery_level ASC") + default: + // 默认按最后错误时间排序 + query = query.Order("last_wrong_time DESC") + } + + var wrongQuestions []models.WrongQuestion + // 先查询错题记录 + if err := query.Find(&wrongQuestions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询错题失败"}) return } - // 转换为DTO - dtos := make([]models.WrongQuestionDTO, 0, len(wrongQuestions)) - for _, wq := range wrongQuestions { - // 题型筛选 - 直接比较type字段 - if questionType != "" && wq.PracticeQuestion.Type != questionType { - continue + // 手动加载关联数据 + for i := range wrongQuestions { + // 加载题目信息(确保使用正确的关联) + var practiceQuestion models.PracticeQuestion + if err := db.Where("id = ?", wrongQuestions[i].QuestionID).First(&practiceQuestion).Error; err == nil { + wrongQuestions[i].PracticeQuestion = &practiceQuestion } - // 解析答案 - var wrongAnswer, correctAnswer interface{} - json.Unmarshal([]byte(wq.WrongAnswer), &wrongAnswer) - json.Unmarshal([]byte(wq.CorrectAnswer), &correctAnswer) - - dto := models.WrongQuestionDTO{ - ID: wq.ID, - QuestionID: wq.QuestionID, - Question: convertToDTO(wq.PracticeQuestion), - WrongAnswer: wrongAnswer, - CorrectAnswer: correctAnswer, - WrongCount: wq.WrongCount, - LastWrongTime: wq.LastWrongTime, - IsMastered: wq.IsMastered, + // 加载最近3次历史 + var history []models.WrongQuestionHistory + if err := db.Where("wrong_question_id = ?", wrongQuestions[i].ID). + Order("answered_at DESC"). + Limit(3). + Find(&history).Error; err == nil { + wrongQuestions[i].History = history } - dtos = append(dtos, dto) + } + + // 转换为 DTO + dtos := make([]models.WrongQuestionDTO, len(wrongQuestions)) + for i, wq := range wrongQuestions { + dtos[i] = convertWrongQuestionToDTO(&wq, true) // 包含最近历史 } c.JSON(http.StatusOK, gin.H{ "success": true, "data": dtos, - "total": len(dtos), }) } -// GetWrongQuestionStats 获取错题统计 -func GetWrongQuestionStats(c *gin.Context) { +// GetWrongQuestionDetail 获取错题详情(包含完整历史) +// GET /api/v2/wrong-questions/:id +func GetWrongQuestionDetail(c *gin.Context) { userID, exists := c.Get("user_id") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "未登录", - }) + 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() - var wrongQuestions []models.WrongQuestion - - if err := db.Where("user_id = ?", userID).Preload("PracticeQuestion").Find(&wrongQuestions).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "查询失败", - }) + var wrongQuestion models.WrongQuestion + if err := db.Where("id = ? AND user_id = ?", id, userID). + Preload("PracticeQuestion"). + Preload("History", func(db *gorm.DB) *gorm.DB { + return db.Order("answered_at DESC") + }). + First(&wrongQuestion).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"}) return } - stats := models.WrongQuestionStats{ - TotalWrong: len(wrongQuestions), - Mastered: 0, - NotMastered: 0, - TypeStats: make(map[string]int), - CategoryStats: make(map[string]int), + // 转换为 DTO(包含完整历史) + dto := convertToDetailDTO(&wrongQuestion) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": dto, + }) +} + +// GetWrongQuestionStats 获取错题统计(新版) +// GET /api/v2/wrong-questions/stats +func GetWrongQuestionStats(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return } - for _, wq := range wrongQuestions { - if wq.IsMastered { - stats.Mastered++ - } else { - stats.NotMastered++ - } - - // 统计题型 - 直接使用type字段 - stats.TypeStats[wq.PracticeQuestion.Type]++ - - // 统计分类 - stats.CategoryStats[wq.PracticeQuestion.TypeName]++ + stats, err := services.GetWrongQuestionStats(userID.(uint)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取统计失败"}) + return } c.JSON(http.StatusOK, gin.H{ @@ -129,63 +142,109 @@ func GetWrongQuestionStats(c *gin.Context) { }) } -// MarkWrongQuestionMastered 标记错题为已掌握 -func MarkWrongQuestionMastered(c *gin.Context) { +// GetRecommendedWrongQuestions 获取推荐练习的错题(智能推荐) +// GET /api/v2/wrong-questions/recommended?limit=10&exclude=123 +func GetRecommendedWrongQuestions(c *gin.Context) { userID, exists := c.Get("user_id") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "未登录", - }) + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + limit := 10 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + // 获取要排除的题目ID(前端传递当前题目ID,避免重复推荐) + excludeQuestionID := uint(0) + if e := c.Query("exclude"); e != "" { + if parsed, err := strconv.ParseUint(e, 10, 32); err == nil { + excludeQuestionID = uint(parsed) + } + } + + questions, err := services.GetRecommendedWrongQuestions(userID.(uint), limit, excludeQuestionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取推荐错题失败"}) + return + } + + // 转换为 DTO + dtos := make([]models.WrongQuestionDTO, len(questions)) + for i, wq := range questions { + dtos[i] = convertWrongQuestionToDTO(&wq, false) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": dtos, + }) +} + +// DeleteWrongQuestion 删除错题(新版) +// DELETE /api/v2/wrong-questions/:id +func DeleteWrongQuestion(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 } - wrongQuestionID := c.Param("id") db := database.GetDB() - var wrongQuestion models.WrongQuestion - if err := db.Where("id = ? AND user_id = ?", wrongQuestionID, userID).First(&wrongQuestion).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "success": false, - "message": "错题不存在", - }) + // 删除历史记录 + db.Where("wrong_question_id = ?", id).Delete(&models.WrongQuestionHistory{}) + + // 删除错题记录 + result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.WrongQuestion{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除错题失败"}) return } - wrongQuestion.IsMastered = true - if err := db.Save(&wrongQuestion).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "更新失败", - }) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"}) return } c.JSON(http.StatusOK, gin.H{ "success": true, - "message": "已标记为掌握", + "message": "删除成功", }) } -// ClearWrongQuestions 清空错题本 +// ClearWrongQuestions 清空错题本(新版) +// DELETE /api/v2/wrong-questions func ClearWrongQuestions(c *gin.Context) { userID, exists := c.Get("user_id") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "未登录", - }) + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) return } db := database.GetDB() - // 删除用户所有错题记录 + // 获取所有错题ID + var wrongQuestionIDs []uint + db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID).Pluck("id", &wrongQuestionIDs) + + // 删除历史记录 + if len(wrongQuestionIDs) > 0 { + db.Where("wrong_question_id IN ?", wrongQuestionIDs).Delete(&models.WrongQuestionHistory{}) + } + + // 删除错题记录 if err := db.Where("user_id = ?", userID).Delete(&models.WrongQuestion{}).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "清空失败", - }) + c.JSON(http.StatusInternalServerError, gin.H{"error": "清空错题本失败"}) return } @@ -195,108 +254,279 @@ func ClearWrongQuestions(c *gin.Context) { }) } -// recordWrongQuestion 记录错题(内部函数,在答题错误时调用) -func recordWrongQuestion(userID, questionID uint, userAnswer, correctAnswer interface{}) error { - db := database.GetDB() - - // 将答案序列化为JSON - wrongAnswerJSON, _ := json.Marshal(userAnswer) - correctAnswerJSON, _ := json.Marshal(correctAnswer) - - // 查找是否已存在该错题 - var existingWrong models.WrongQuestion - result := db.Where("user_id = ? AND question_id = ?", userID, questionID).First(&existingWrong) - - if result.Error == nil { - // 已存在,更新错误次数和时间 - existingWrong.WrongCount++ - existingWrong.LastWrongTime = time.Now() - existingWrong.WrongAnswer = string(wrongAnswerJSON) - existingWrong.CorrectAnswer = string(correctAnswerJSON) - existingWrong.IsMastered = false // 重新标记为未掌握 - return db.Save(&existingWrong).Error - } - - // 不存在,创建新记录 - newWrong := models.WrongQuestion{ - UserID: userID, - QuestionID: questionID, - WrongAnswer: string(wrongAnswerJSON), - CorrectAnswer: string(correctAnswerJSON), - WrongCount: 1, - LastWrongTime: time.Now(), - IsMastered: false, - } - - return db.Create(&newWrong).Error -} - -// GetRandomWrongQuestion 获取随机错题进行练习 -func GetRandomWrongQuestion(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{ - "success": false, - "message": "未登录", - }) + 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("user_id = ?", userID).Order("RANDOM()").Preload("PracticeQuestion").First(&wrongQuestion).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "success": false, - "message": "暂无错题", - }) + if err := db.Where("id = ? AND user_id = ?", id, userID).First(&wrongQuestion).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"}) return } - // 转换为DTO返回 - dto := convertToDTO(wrongQuestion.PracticeQuestion) + 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, - "data": dto, + "message": "标签更新成功", }) } -// DeleteWrongQuestion 删除单个错题 -func DeleteWrongQuestion(c *gin.Context) { +// ==================== 标签管理 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{ - "success": false, - "message": "未登录", - }) + 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 } - wrongQuestionID := c.Param("id") db := database.GetDB() - // 删除错题(确保只能删除自己的错题) - result := db.Where("id = ? AND user_id = ?", wrongQuestionID, userID).Delete(&models.WrongQuestion{}) + // 检查标签名是否已存在 + 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{ - "success": false, - "message": "删除失败", - }) + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"}) return } if result.RowsAffected == 0 { - c.JSON(http.StatusNotFound, gin.H{ - "success": false, - "message": "错题不存在", - }) + c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) return } + // TODO: 从所有错题中移除该标签 + c.JSON(http.StatusOK, gin.H{ "success": true, - "message": "已删除", + "message": "删除成功", }) } + +// ==================== 辅助函数 ==================== + +// convertWrongQuestionToDTO 转换为 DTO V2(可选是否包含最近历史) +func convertWrongQuestionToDTO(wq *models.WrongQuestion, includeHistory bool) models.WrongQuestionDTO { + dto := models.WrongQuestionDTO{ + ID: wq.ID, + QuestionID: wq.QuestionID, + FirstWrongTime: wq.FirstWrongTime, + LastWrongTime: wq.LastWrongTime, + TotalWrongCount: wq.TotalWrongCount, + MasteryLevel: wq.MasteryLevel, + ConsecutiveCorrect: wq.ConsecutiveCorrect, + IsMastered: wq.IsMastered, + Tags: wq.Tags, + } + + // 转换题目信息 + if wq.PracticeQuestion != nil { + questionDTO := convertToDTO(*wq.PracticeQuestion) + dto.Question = &questionDTO + } + + // 包含最近3次历史 + if includeHistory && len(wq.History) > 0 { + count := 3 + if len(wq.History) < count { + count = len(wq.History) + } + dto.RecentHistory = make([]models.WrongQuestionHistoryDTO, count) + for i := 0; i < count; i++ { + dto.RecentHistory[i] = convertWrongHistoryToDTO(&wq.History[i]) + } + } + + return dto +} + +// convertToDetailDTO 转换为详情 DTO(包含完整历史) +func convertToDetailDTO(wq *models.WrongQuestion) models.WrongQuestionDTO { + dto := convertWrongQuestionToDTO(wq, false) + + // 包含完整历史 + if len(wq.History) > 0 { + dto.RecentHistory = make([]models.WrongQuestionHistoryDTO, len(wq.History)) + for i, h := range wq.History { + dto.RecentHistory[i] = convertWrongHistoryToDTO(&h) + } + } + + return dto +} + +// convertWrongHistoryToDTO 转换历史记录为 DTO +func convertWrongHistoryToDTO(h *models.WrongQuestionHistory) models.WrongQuestionHistoryDTO { + return models.WrongQuestionHistoryDTO{ + ID: h.ID, + UserAnswer: parseJSONAnswer(h.UserAnswer), + CorrectAnswer: parseJSONAnswer(h.CorrectAnswer), + AnsweredAt: h.AnsweredAt, + TimeSpent: h.TimeSpent, + IsCorrect: h.IsCorrect, + } +} + +// parseJSONAnswer 解析 JSON 答案 +func parseJSONAnswer(answerStr string) interface{} { + var answer interface{} + if err := json.Unmarshal([]byte(answerStr), &answer); err != nil { + return answerStr + } + return answer +} diff --git a/internal/models/wrong_question.go b/internal/models/wrong_question.go index 250df41..4c11500 100644 --- a/internal/models/wrong_question.go +++ b/internal/models/wrong_question.go @@ -1,27 +1,89 @@ package models import ( + "database/sql/driver" + "encoding/json" "time" "gorm.io/gorm" ) -// WrongQuestion 错题记录模型 +// WrongQuestion 错题记录 type WrongQuestion struct { - ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` // 用户ID - QuestionID uint `gorm:"index;not null" json:"question_id"` // 题目ID(关联practice_questions表) - WrongAnswer string `gorm:"type:text;not null" json:"wrong_answer"` // 错误答案(JSON格式) - CorrectAnswer string `gorm:"type:text;not null" json:"correct_answer"` // 正确答案(JSON格式) - WrongCount int `gorm:"default:1" json:"wrong_count"` // 错误次数 - LastWrongTime time.Time `gorm:"not null" json:"last_wrong_time"` // 最后一次错误时间 - IsMastered bool `gorm:"default:false" json:"is_mastered"` // 是否已掌握 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + QuestionID uint `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;constraint:OnUpdate:CASCADE,OnDelete:SET NULL" json:"-"` + // 关联 + 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 存储 + 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 指定表名 @@ -29,23 +91,108 @@ func (WrongQuestion) TableName() string { return "wrong_questions" } +// TableName 指定表名 +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"` // 题目详情 - WrongAnswer interface{} `json:"wrong_answer"` // 错误答案 - CorrectAnswer interface{} `json:"correct_answer"` // 正确答案 - WrongCount int `json:"wrong_count"` // 错误次数 - LastWrongTime time.Time `json:"last_wrong_time"` // 最后错误时间 - IsMastered bool `json:"is_mastered"` // 是否已掌握 + 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次历史 +} + +// WrongQuestionHistoryDTO 错误历史 DTO +type WrongQuestionHistoryDTO struct { + ID uint `json:"id"` + UserAnswer interface{} `json:"user_answer"` + CorrectAnswer interface{} `json:"correct_answer"` + AnsweredAt time.Time `json:"answered_at"` + TimeSpent int `json:"time_spent"` + IsCorrect bool `json:"is_correct"` } // WrongQuestionStats 错题统计 type WrongQuestionStats struct { - TotalWrong int `json:"total_wrong"` // 总错题数 - Mastered int `json:"mastered"` // 已掌握数 - NotMastered int `json:"not_mastered"` // 未掌握数 - TypeStats map[string]int `json:"type_stats"` // 各题型错题数 - CategoryStats map[string]int `json:"category_stats"` // 各分类错题数 + 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"` // 错题趋势 +} + +// TrendPoint 趋势数据点 +type TrendPoint struct { + Date string `json:"date"` + Count int `json:"count"` +} + +// RecordWrongAnswer 记录错误答案 +func (wq *WrongQuestion) RecordWrongAnswer() { + now := time.Now() + if wq.FirstWrongTime.IsZero() { + wq.FirstWrongTime = now + } + wq.LastWrongTime = now + wq.TotalWrongCount++ + wq.ConsecutiveCorrect = 0 // 重置连续答对次数 + wq.IsMastered = false // 重新标记为未掌握 + wq.MasteryLevel = 0 // 重置掌握度 +} + +// RecordCorrectAnswer 记录正确答案 +func (wq *WrongQuestion) RecordCorrectAnswer() { + wq.ConsecutiveCorrect++ + + // 根据连续答对次数更新掌握度(每答对一次增加16.67%) + // 连续答对6次即达到100% + wq.MasteryLevel = (wq.ConsecutiveCorrect * 100) / 6 + if wq.MasteryLevel > 100 { + wq.MasteryLevel = 100 + } + + // 连续答对6次标记为已掌握 + if wq.ConsecutiveCorrect >= 6 { + wq.IsMastered = true + 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 new file mode 100644 index 0000000..b080f18 --- /dev/null +++ b/internal/services/wrong_question_service.go @@ -0,0 +1,336 @@ +package services + +import ( + "ankao/internal/database" + "ankao/internal/models" + "encoding/json" + "fmt" + "log" + "time" + + "gorm.io/gorm" +) + +// ==================== 错题服务 ==================== + +// RecordWrongAnswer 记录错误答案 +func RecordWrongAnswer(userID, questionID uint, userAnswer, correctAnswer interface{}, timeSpent int) error { + db := database.GetDB() + + log.Printf("[错题记录] 开始记录错题 (userID: %d, questionID: %d)", userID, questionID) + + // 序列化答案 + userAnswerJSON, _ := json.Marshal(userAnswer) + correctAnswerJSON, _ := json.Marshal(correctAnswer) + + // 查找或创建错题记录 + var wrongQuestion models.WrongQuestion + err := db.Where("user_id = ? AND question_id = ?", userID, questionID).First(&wrongQuestion).Error + + if err != nil { + // 不存在,创建新记录 + log.Printf("[错题记录] 创建新错题记录 (userID: %d, questionID: %d)", userID, questionID) + wrongQuestion = models.WrongQuestion{ + UserID: userID, + QuestionID: questionID, + Tags: []string{}, + } + wrongQuestion.RecordWrongAnswer() + + if err := db.Create(&wrongQuestion).Error; err != nil { + log.Printf("[错题记录] 创建错题记录失败: %v", err) + return fmt.Errorf("创建错题记录失败: %v", err) + } + log.Printf("[错题记录] 成功创建错题记录 (ID: %d)", wrongQuestion.ID) + } else { + // 已存在,更新记录 + log.Printf("[错题记录] 更新已存在的错题记录 (ID: %d)", wrongQuestion.ID) + wrongQuestion.RecordWrongAnswer() + if err := db.Save(&wrongQuestion).Error; err != nil { + log.Printf("[错题记录] 更新错题记录失败: %v", err) + return fmt.Errorf("更新错题记录失败: %v", err) + } + log.Printf("[错题记录] 成功更新错题记录 (ID: %d, 错误次数: %d)", wrongQuestion.ID, wrongQuestion.TotalWrongCount) + } + + // 创建历史记录 + history := models.WrongQuestionHistory{ + WrongQuestionID: wrongQuestion.ID, + UserAnswer: string(userAnswerJSON), + CorrectAnswer: string(correctAnswerJSON), + AnsweredAt: time.Now(), + TimeSpent: timeSpent, + IsCorrect: false, + } + + if err := db.Create(&history).Error; err != nil { + log.Printf("[错题记录] 创建错题历史失败: %v", err) + } else { + log.Printf("[错题记录] 成功创建历史记录 (ID: %d, WrongQuestionID: %d)", history.ID, history.WrongQuestionID) + } + + return nil +} + +// RecordCorrectAnswer 记录正确答案(用于错题练习) +func RecordCorrectAnswer(userID, questionID uint, userAnswer, correctAnswer interface{}, timeSpent int) error { + db := database.GetDB() + + // 查找错题记录 + var wrongQuestion models.WrongQuestion + err := db.Where("user_id = ? AND question_id = ?", userID, questionID).First(&wrongQuestion).Error + if err != nil { + // 不存在错题记录,无需处理 + return nil + } + + // 序列化答案 + userAnswerJSON, _ := json.Marshal(userAnswer) + correctAnswerJSON, _ := json.Marshal(correctAnswer) + + // 更新连续答对次数 + wrongQuestion.RecordCorrectAnswer() + if err := db.Save(&wrongQuestion).Error; err != nil { + return fmt.Errorf("更新错题记录失败: %v", err) + } + + // 创建历史记录 + history := models.WrongQuestionHistory{ + WrongQuestionID: wrongQuestion.ID, + UserAnswer: string(userAnswerJSON), + CorrectAnswer: string(correctAnswerJSON), + AnsweredAt: time.Now(), + TimeSpent: timeSpent, + IsCorrect: true, + } + + if err := db.Create(&history).Error; err != nil { + log.Printf("创建错题历史失败: %v", err) + } + + return nil +} + +// GetWrongQuestionStats 获取错题统计 +func GetWrongQuestionStats(userID uint) (*models.WrongQuestionStats, error) { + db := database.GetDB() + + stats := &models.WrongQuestionStats{ + TypeStats: make(map[string]int), + CategoryStats: make(map[string]int), + MasteryLevelDist: make(map[string]int), + TagStats: make(map[string]int), + } + + // 基础统计 + var totalWrong, mastered int64 + db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID).Count(&totalWrong) + db.Model(&models.WrongQuestion{}).Where("user_id = ? AND is_mastered = ?", userID, true).Count(&mastered) + + stats.TotalWrong = int(totalWrong) + stats.Mastered = int(mastered) + stats.NotMastered = int(totalWrong) - int(mastered) + stats.NeedReview = 0 // 不再使用复习时间,设置为0 + + // 按题型统计 + var typeStats []struct { + Type string + Count int + } + db.Model(&models.WrongQuestion{}). + Select("practice_questions.type, COUNT(*) as count"). + Joins("LEFT JOIN practice_questions ON practice_questions.id = wrong_questions.question_id"). + Where("wrong_questions.user_id = ?", userID). + Group("practice_questions.type"). + Scan(&typeStats) + + for _, ts := range typeStats { + stats.TypeStats[ts.Type] = ts.Count + } + + // 按分类统计 + var categoryStats []struct { + Category string + Count int + } + db.Model(&models.WrongQuestion{}). + Select("practice_questions.category, COUNT(*) as count"). + Joins("LEFT JOIN practice_questions ON practice_questions.id = wrong_questions.question_id"). + Where("wrong_questions.user_id = ?", userID). + Group("practice_questions.category"). + Scan(&categoryStats) + + for _, cs := range categoryStats { + stats.CategoryStats[cs.Category] = cs.Count + } + + // 掌握度分布 + var masteryDist []struct { + Level string + Count int + } + db.Model(&models.WrongQuestion{}). + Select(` + CASE + WHEN mastery_level >= 80 THEN '优秀' + WHEN mastery_level >= 60 THEN '良好' + WHEN mastery_level >= 40 THEN '一般' + WHEN mastery_level >= 20 THEN '较差' + ELSE '很差' + END as level, + COUNT(*) as count + `). + Where("user_id = ?", userID). + Group("level"). + Scan(&masteryDist) + + for _, md := range masteryDist { + 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) + + return stats, nil +} + +// calculateTrendData 计算错题趋势数据 +func calculateTrendData(db *gorm.DB, userID uint, days int) []models.TrendPoint { + trendData := make([]models.TrendPoint, days) + now := time.Now() + + for i := days - 1; i >= 0; i-- { + date := now.AddDate(0, 0, -i) + dateStr := date.Format("01-02") + + var count int64 + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + endOfDay := startOfDay.Add(24 * time.Hour) + + db.Model(&models.WrongQuestion{}). + Where("user_id = ? AND last_wrong_time >= ? AND last_wrong_time < ?", userID, startOfDay, endOfDay). + Count(&count) + + trendData[days-1-i] = models.TrendPoint{ + Date: dateStr, + Count: int(count), + } + } + + return trendData +} + +// GetRecommendedWrongQuestions 获取推荐练习的错题(智能推荐) +// 推荐策略(按优先级): +// 1. 最优先推荐掌握度为0的题目(从未答对过) +// 2. 其次推荐掌握度低的题目(mastery_level 从低到高) +// 3. 最后推荐最近答错的题目 +func GetRecommendedWrongQuestions(userID uint, limit int, excludeQuestionID uint) ([]models.WrongQuestion, error) { + db := database.GetDB() + + var questions []models.WrongQuestion + + // 策略1: 最优先推荐掌握度为0的题目(从未答对过) + var zeroMastery []models.WrongQuestion + query1 := db.Where("user_id = ? AND is_mastered = ? AND mastery_level = 0", userID, false) + if excludeQuestionID > 0 { + query1 = query1.Where("question_id != ?", excludeQuestionID) + } + query1.Order("total_wrong_count DESC, last_wrong_time DESC"). + Limit(limit). + Preload("PracticeQuestion"). + Find(&zeroMastery) + questions = append(questions, zeroMastery...) + + // 如果已经够了,直接返回 + if len(questions) >= limit { + return questions[:limit], nil + } + + // 策略2: 推荐掌握度低的题目(mastery_level 从低到高) + var lowMastery []models.WrongQuestion + query2 := db.Where("user_id = ? AND is_mastered = ? AND mastery_level > 0 AND id NOT IN ?", userID, false, getIDs(questions)) + if excludeQuestionID > 0 { + query2 = query2.Where("question_id != ?", excludeQuestionID) + } + query2.Order("mastery_level ASC, total_wrong_count DESC"). + Limit(limit - len(questions)). + Preload("PracticeQuestion"). + Find(&lowMastery) + questions = append(questions, lowMastery...) + + if len(questions) >= limit { + return questions[:limit], nil + } + + // 策略3: 最近答错的题目(填充剩余,以防万一) + var recent []models.WrongQuestion + query3 := db.Where("user_id = ? AND is_mastered = ? AND id NOT IN ?", userID, false, getIDs(questions)) + if excludeQuestionID > 0 { + query3 = query3.Where("question_id != ?", excludeQuestionID) + } + query3.Order("last_wrong_time DESC"). + Limit(limit - len(questions)). + Preload("PracticeQuestion"). + Find(&recent) + questions = append(questions, recent...) + + return questions, nil +} + +// getIDs 获取错题记录的ID列表 +func getIDs(questions []models.WrongQuestion) []uint { + if len(questions) == 0 { + return []uint{0} // 避免 SQL 错误 + } + ids := make([]uint, 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 8e45ce5..c827148 100644 --- a/main.go +++ b/main.go @@ -54,12 +54,19 @@ func main() { auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据 // 错题本相关API - auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表 - auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计 - auth.GET("/wrong-questions/random", handlers.GetRandomWrongQuestion) // 获取随机错题 - auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除单个错题 - auth.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握 - auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 + auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表(支持筛选和排序) + auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计(含趋势) + auth.GET("/wrong-questions/recommended", handlers.GetRecommendedWrongQuestions) // 获取推荐错题 + 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(需要管理员权限) diff --git a/scripts/migrate_drop_old_columns.go b/scripts/migrate_drop_old_columns.go new file mode 100644 index 0000000..182d57a --- /dev/null +++ b/scripts/migrate_drop_old_columns.go @@ -0,0 +1,44 @@ +package main + +import ( + "ankao/internal/database" + "log" + "os" +) + +func main() { + // 直接设置数据库配置 + os.Setenv("DB_HOST", "localhost") + os.Setenv("DB_PORT", "5432") + os.Setenv("DB_USER", "postgres") + os.Setenv("DB_PASSWORD", "root") + os.Setenv("DB_NAME", "ankao") + + // 连接数据库 + if err := database.InitDB(); err != nil { + log.Fatalf("数据库连接失败: %v", err) + } + + db := database.GetDB() + + log.Println("开始迁移:删除 wrong_questions 表的旧字段...") + + // 删除 wrong_answer 和 correct_answer 字段 + // 这些字段在新版本中已移至 wrong_question_history 表 + migrations := []string{ + "ALTER TABLE wrong_questions DROP COLUMN IF EXISTS wrong_answer", + "ALTER TABLE wrong_questions DROP COLUMN IF EXISTS correct_answer", + "ALTER TABLE wrong_questions DROP COLUMN IF EXISTS wrong_count", // 也删除旧的 wrong_count 字段(如果存在) + } + + for _, migration := range migrations { + log.Printf("执行: %s", migration) + if err := db.Exec(migration).Error; err != nil { + log.Printf("警告: 执行失败 - %v (字段可能已不存在)", err) + } else { + log.Println("✓ 执行成功") + } + } + + log.Println("迁移完成!") +} diff --git a/scripts/migrate_wrong_questions.go b/scripts/migrate_wrong_questions.go new file mode 100644 index 0000000..f399e94 --- /dev/null +++ b/scripts/migrate_wrong_questions.go @@ -0,0 +1,173 @@ +package main + +import ( + "ankao/internal/database" + "ankao/internal/models" + "encoding/json" + "fmt" + "log" + "time" + + "gorm.io/gorm" +) + +// 数据迁移脚本:从旧版错题本迁移到新版 + +func main() { + // 初始化数据库 + if err := database.InitDB(); err != nil { + log.Fatalf("数据库初始化失败: %v", err) + } + db := database.GetDB() + + fmt.Println("开始错题本数据迁移...") + + // 1. 创建新表 + fmt.Println("1. 创建新表结构...") + if err := db.AutoMigrate(&models.WrongQuestion{}, &models.WrongQuestionHistory{}, &models.WrongQuestionTag{}); err != nil { + log.Fatalf("创建新表失败: %v", err) + } + fmt.Println(" ✓ 新表创建成功") + + // 2. 迁移数据 + fmt.Println("2. 迁移旧数据到新表...") + if err := migrateOldData(db); err != nil { + log.Fatalf("数据迁移失败: %v", err) + } + fmt.Println(" ✓ 数据迁移成功") + + // 3. 统计信息 + fmt.Println("3. 统计迁移结果...") + printStats(db) + + fmt.Println("\n迁移完成!") + fmt.Println("\n注意事项:") + fmt.Println("- 旧表 'wrong_questions' 已保留,不会删除") + fmt.Println("- 新表 'wrong_questions_v2' 包含所有迁移后的数据") + fmt.Println("- 如需回滚,可以删除新表并继续使用旧表") +} + +func migrateOldData(db *gorm.DB) error { + // 查询所有旧错题记录 + var oldWrongQuestions []models.WrongQuestion + if err := db.Find(&oldWrongQuestions).Error; err != nil { + return fmt.Errorf("查询旧数据失败: %v", err) + } + + if len(oldWrongQuestions) == 0 { + fmt.Println(" 没有需要迁移的数据") + return nil + } + + fmt.Printf(" 找到 %d 条旧记录,开始迁移...\n", len(oldWrongQuestions)) + + // 逐条迁移 + for i, old := range oldWrongQuestions { + if err := migrateOneRecord(db, &old); err != nil { + log.Printf(" 警告: 迁移记录 %d 失败: %v", old.ID, err) + continue + } + if (i+1)%100 == 0 { + fmt.Printf(" 已迁移: %d/%d\n", i+1, len(oldWrongQuestions)) + } + } + + fmt.Printf(" ✓ 迁移完成: %d/%d\n", len(oldWrongQuestions), len(oldWrongQuestions)) + return nil +} + +func migrateOneRecord(db *gorm.DB, old *models.WrongQuestion) error { + // 检查是否已存在 + var existing models.WrongQuestion + if err := db.Where("user_id = ? AND question_id = ?", old.UserID, old.QuestionID).First(&existing).Error; err == nil { + // 已存在,跳过 + return nil + } + + // 创建新记录 + newRecord := models.WrongQuestion{ + UserID: old.UserID, + QuestionID: old.QuestionID, + FirstWrongTime: old.LastWrongTime, // 旧版只有最后错误时间 + LastWrongTime: old.LastWrongTime, + TotalWrongCount: old.WrongCount, + MasteryLevel: 0, + ConsecutiveCorrect: 0, + IsMastered: old.IsMastered, + Tags: []string{}, // 默认无标签 + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // 计算下次复习时间 + newRecord.CalculateNextReviewTime() + + // 保存新记录 + if err := db.Create(&newRecord).Error; err != nil { + return fmt.Errorf("创建新记录失败: %v", err) + } + + // 创建历史记录(基于旧数据) + history := models.WrongQuestionHistory{ + WrongQuestionID: newRecord.ID, + UserAnswer: old.WrongAnswer, + CorrectAnswer: old.CorrectAnswer, + AnsweredAt: old.LastWrongTime, + TimeSpent: 0, + IsCorrect: false, + } + + if err := db.Create(&history).Error; err != nil { + log.Printf("警告: 创建历史记录失败: %v", err) + } + + return nil +} + +func printStats(db *gorm.DB) { + var oldCount, newCount, historyCount, tagCount int64 + + db.Model(&models.WrongQuestion{}).Count(&oldCount) + db.Model(&models.WrongQuestion{}).Count(&newCount) + db.Model(&models.WrongQuestionHistory{}).Count(&historyCount) + db.Model(&models.WrongQuestionTag{}).Count(&tagCount) + + fmt.Printf("\n 旧表记录数: %d\n", oldCount) + fmt.Printf(" 新表记录数: %d\n", newCount) + fmt.Printf(" 历史记录数: %d\n", historyCount) + fmt.Printf(" 标签数: %d\n", tagCount) + + // 统计掌握度分布 + var masteryStats []struct { + MasteryLevel int + Count int64 + } + db.Model(&models.WrongQuestion{}). + Select("mastery_level, COUNT(*) as count"). + Group("mastery_level"). + Scan(&masteryStats) + + if len(masteryStats) > 0 { + fmt.Println("\n 掌握度分布:") + for _, stat := range masteryStats { + fmt.Printf(" - 掌握度 %d%%: %d 题\n", stat.MasteryLevel, stat.Count) + } + } + + // 统计需要复习的题目 + var needReview int64 + now := time.Now() + db.Model(&models.WrongQuestion{}). + Where("is_mastered = ? AND next_review_time IS NOT NULL AND next_review_time <= ?", false, now). + Count(&needReview) + fmt.Printf("\n 需要复习的题目: %d\n", needReview) +} + +// 辅助函数:解析JSON答案 +func parseAnswer(answerStr string) interface{} { + var answer interface{} + if err := json.Unmarshal([]byte(answerStr), &answer); err != nil { + return answerStr + } + return answer +} diff --git a/test_ai_grading.md b/test_ai_grading.md deleted file mode 100644 index 704e98d..0000000 --- a/test_ai_grading.md +++ /dev/null @@ -1,204 +0,0 @@ -# AI 评分功能测试指南 - -## 测试前提 - -1. 确保后端服务器运行在 `http://localhost:8080` -2. 确保数据库中有简答题数据 -3. 需要先登录获取 token - -## 测试步骤 - -### 1. 登录获取 Token - -```bash -curl -X POST http://localhost:8080/api/login \ - -H "Content-Type: application/json" \ - -d '{ - "username": "你的用户名", - "password": "你的密码" - }' -``` - -返回示例: -```json -{ - "success": true, - "data": { - "token": "your-token-here", - "user": {...} - } -} -``` - -### 2. 获取简答题列表 - -```bash -curl -X GET "http://localhost:8080/api/practice/questions?type=short-answer" \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -找到一个简答题的 ID(例如:123) - -### 3. 提交简答题答案测试 AI 评分 - -**测试用例 1:提交一个接近标准答案的回答** - -```bash -curl -X POST http://localhost:8080/api/practice/submit \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -d '{ - "question_id": 123, - "answer": "网络安全是保护计算机网络系统及其数据的完整性、保密性和可用性的措施" - }' -``` - -**预期返回**: -```json -{ - "success": true, - "data": { - "correct": true, - "user_answer": "网络安全是保护计算机网络系统及其数据的完整性、保密性和可用性的措施", - "correct_answer": "标准答案...", - "ai_grading": { - "score": 85, - "feedback": "答案基本正确,要点全面", - "suggestion": "可以补充一些具体的例子" - } - } -} -``` - -**测试用例 2:提交一个不完整的回答** - -```bash -curl -X POST http://localhost:8080/api/practice/submit \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -d '{ - "question_id": 123, - "answer": "保护网络安全" - }' -``` - -**预期返回**: -```json -{ - "success": true, - "data": { - "correct": false, - "user_answer": "保护网络安全", - "correct_answer": "标准答案...", - "ai_grading": { - "score": 45, - "feedback": "答案过于简单,缺少要点", - "suggestion": "需要补充完整性、保密性和可用性等关键概念" - } - } -} -``` - -## 验证要点 - -1. **响应结构**:检查响应中是否包含 `ai_grading` 字段 -2. **评分合理性**:AI 给出的分数是否在 0-100 之间 -3. **correct 字段**:分数 >= 60 时,correct 应该为 true -4. **反馈信息**:feedback 和 suggestion 是否有意义 -5. **非简答题**:对其他题型(填空题、判断题等),`ai_grading` 字段应该为 null - -## 使用 Postman 测试 - -1. 创建新请求,方法为 POST -2. URL:`http://localhost:8080/api/practice/submit` -3. Headers 添加: - - `Content-Type: application/json` - - `Authorization: Bearer YOUR_TOKEN` -4. Body 选择 raw 和 JSON,输入: - ```json - { - "question_id": 123, - "answer": "你的答案" - } - ``` -5. 点击 Send 发送请求 -6. 查看响应中的 `ai_grading` 字段 - -## 前端测试 - -如果你已经实现了前端界面: - -1. 登录系统 -2. 进入练习页面 -3. 筛选简答题类型 -4. 随机获取一道简答题 -5. 输入答案并提交 -6. 查看是否显示 AI 评分结果(分数、评语、建议) - -## 故障排查 - -### 问题 1:没有返回 ai_grading 字段 - -**可能原因**: -- 题目类型不是 `short-answer` -- 服务器未重启,使用的是旧代码 - -**解决方法**: -- 确认题目类型:`GET /api/practice/questions/:id` -- 重新编译并启动服务器: - ```bash - go build -o bin/server.exe main.go - ./bin/server.exe - ``` - -### 问题 2:AI 评分失败 - -**可能原因**: -- API 密钥无效 -- 网络连接问题 -- AI 服务不可用 - -**解决方法**: -- 检查后端日志中的错误信息 -- 验证 API 配置:查看 [pkg/config/config.go](pkg/config/config.go) -- 尝试直接调用 AI API 测试连通性 - -### 问题 3:评分结果不合理 - -**可能原因**: -- 提示词需要优化 -- AI 模型理解偏差 - -**解决方法**: -- 调整 [internal/services/ai_grading.go](internal/services/ai_grading.go) 中的提示词 -- 增加更详细的评分标准说明 - -## 测试数据示例 - -以下是一些可以用于测试的简答题和答案: - -### 题目:什么是网络安全? - -**优秀答案(应得高分)**: -"网络安全是指保护计算机网络系统及其数据免受未经授权的访问、使用、披露、破坏、修改或破坏的技术、策略和实践。它包括保护网络的完整性、保密性和可用性,确保数据在传输和存储过程中的安全。" - -**一般答案(应得中等分)**: -"网络安全是保护计算机和网络不被黑客攻击,保护数据安全。" - -**较差答案(应得低分)**: -"网络安全就是网络很安全。" - -## 日志查看 - -查看服务器日志中的 AI 评分相关信息: - -```bash -# Windows (如果使用 PowerShell) -Get-Content .\logs\server.log -Wait - -# 或直接查看控制台输出 -``` - -关注以下日志: -- `AI评分失败: ...` - AI 评分出错 -- 没有错误日志 - AI 评分成功 diff --git a/test_ai_grading.ps1 b/test_ai_grading.ps1 deleted file mode 100644 index aa2a7af..0000000 --- a/test_ai_grading.ps1 +++ /dev/null @@ -1,107 +0,0 @@ -# AI评分功能测试脚本 (PowerShell版本) -# 请先登录获取token,并将token替换下面的$Token变量 - -# 配置 -$BaseUrl = "http://localhost:8080" -$Token = "YOUR_TOKEN" # 请替换为你的实际token -$QuestionId = 465 # 简答题ID(第一道题) - -Write-Host "=========================================" -ForegroundColor Green -Write-Host "AI评分功能测试" -ForegroundColor Green -Write-Host "=========================================" -ForegroundColor Green -Write-Host "" - -# 测试1: 提交一个完整的答案 -Write-Host "测试1: 提交完整答案(应该得高分)" -ForegroundColor Yellow -Write-Host "-----------------------------------------" -ForegroundColor Yellow - -$Body1 = @{ - question_id = 465 - answer = "《中华人民共和国保守国家秘密法》第四十八条列举了十二种违法行为,主要包括:非法获取、持有国家秘密载体;买卖、转送或私自销毁秘密载体;通过无保密措施渠道传递秘密;未经批准携带秘密载体出境;非法复制、记录、存储国家秘密;在私人交往中涉及秘密;在互联网传递秘密;将涉密计算机接入公网;在涉密系统与公网间交换信息;使用非涉密设备处理秘密;擅自修改安全程序;以及将退出使用的涉密设备改作他用等。" -} | ConvertTo-Json - -$Headers = @{ - "Content-Type" = "application/json" - "Authorization" = "Bearer $Token" -} - -try { - $Response1 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body1 - Write-Host "响应结果:" -ForegroundColor Cyan - $Response1 | ConvertTo-Json -Depth 10 | Write-Host - - if ($Response1.data.ai_grading) { - Write-Host "" - Write-Host "AI评分结果:" -ForegroundColor Green - Write-Host " 分数: $($Response1.data.ai_grading.score)" -ForegroundColor Green - Write-Host " 是否正确: $($Response1.data.correct)" -ForegroundColor Green - Write-Host " 评语: $($Response1.data.ai_grading.feedback)" -ForegroundColor Green - Write-Host " 建议: $($Response1.data.ai_grading.suggestion)" -ForegroundColor Green - } -} catch { - Write-Host "错误: $_" -ForegroundColor Red -} - -Write-Host "" -Write-Host "" - -# 测试2: 提交一个简短的答案 -Write-Host "测试2: 提交简短答案(应该得中等分数)" -ForegroundColor Yellow -Write-Host "-----------------------------------------" -ForegroundColor Yellow - -$Body2 = @{ - question_id = 465 - answer = "主要包括非法获取国家秘密、买卖秘密载体、通过互联网传递秘密、将涉密设备接入公网等违法行为。" -} | ConvertTo-Json - -try { - $Response2 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body2 - Write-Host "响应结果:" -ForegroundColor Cyan - $Response2 | ConvertTo-Json -Depth 10 | Write-Host - - if ($Response2.data.ai_grading) { - Write-Host "" - Write-Host "AI评分结果:" -ForegroundColor Green - Write-Host " 分数: $($Response2.data.ai_grading.score)" -ForegroundColor Green - Write-Host " 是否正确: $($Response2.data.correct)" -ForegroundColor Green - Write-Host " 评语: $($Response2.data.ai_grading.feedback)" -ForegroundColor Green - Write-Host " 建议: $($Response2.data.ai_grading.suggestion)" -ForegroundColor Green - } -} catch { - Write-Host "错误: $_" -ForegroundColor Red -} - -Write-Host "" -Write-Host "" - -# 测试3: 提交一个不完整的答案 -Write-Host "测试3: 提交不完整答案(应该得低分)" -ForegroundColor Yellow -Write-Host "-----------------------------------------" -ForegroundColor Yellow - -$Body3 = @{ - question_id = 465 - answer = "不能泄露国家秘密,不能在网上传播秘密信息。" -} | ConvertTo-Json - -try { - $Response3 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body3 - Write-Host "响应结果:" -ForegroundColor Cyan - $Response3 | ConvertTo-Json -Depth 10 | Write-Host - - if ($Response3.data.ai_grading) { - Write-Host "" - Write-Host "AI评分结果:" -ForegroundColor Green - Write-Host " 分数: $($Response3.data.ai_grading.score)" -ForegroundColor Green - Write-Host " 是否正确: $($Response3.data.correct)" -ForegroundColor Green - Write-Host " 评语: $($Response3.data.ai_grading.feedback)" -ForegroundColor Green - Write-Host " 建议: $($Response3.data.ai_grading.suggestion)" -ForegroundColor Green - } -} catch { - Write-Host "错误: $_" -ForegroundColor Red -} - -Write-Host "" -Write-Host "" -Write-Host "=========================================" -ForegroundColor Green -Write-Host "测试完成" -ForegroundColor Green -Write-Host "=========================================" -ForegroundColor Green diff --git a/test_ai_grading.sh b/test_ai_grading.sh deleted file mode 100644 index 8aa5a29..0000000 --- a/test_ai_grading.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -# AI评分功能测试脚本 -# 请先登录获取token,并将token替换下面的YOUR_TOKEN - -# 配置 -BASE_URL="http://localhost:8080" -TOKEN="YOUR_TOKEN" # 请替换为你的实际token -QUESTION_ID=465 # 简答题ID(第一道题) - -echo "=========================================" -echo "AI评分功能测试" -echo "=========================================" -echo "" - -# 测试1: 提交一个完整的答案 -echo "测试1: 提交完整答案(应该得高分)" -echo "-----------------------------------------" -curl -X POST "${BASE_URL}/api/practice/submit" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${TOKEN}" \ - -d '{ - "question_id": 465, - "answer": "《中华人民共和国保守国家秘密法》第四十八条列举了十二种违法行为,主要包括:非法获取、持有国家秘密载体;买卖、转送或私自销毁秘密载体;通过无保密措施渠道传递秘密;未经批准携带秘密载体出境;非法复制、记录、存储国家秘密;在私人交往中涉及秘密;在互联网传递秘密;将涉密计算机接入公网;在涉密系统与公网间交换信息;使用非涉密设备处理秘密;擅自修改安全程序;以及将退出使用的涉密设备改作他用等。" - }' | python -m json.tool - -echo "" -echo "" - -# 测试2: 提交一个简短的答案 -echo "测试2: 提交简短答案(应该得中等分数)" -echo "-----------------------------------------" -curl -X POST "${BASE_URL}/api/practice/submit" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${TOKEN}" \ - -d '{ - "question_id": 465, - "answer": "主要包括非法获取国家秘密、买卖秘密载体、通过互联网传递秘密、将涉密设备接入公网等违法行为。" - }' | python -m json.tool - -echo "" -echo "" - -# 测试3: 提交一个不完整的答案 -echo "测试3: 提交不完整答案(应该得低分)" -echo "-----------------------------------------" -curl -X POST "${BASE_URL}/api/practice/submit" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${TOKEN}" \ - -d '{ - "question_id": 465, - "answer": "不能泄露国家秘密,不能在网上传播秘密信息。" - }' | python -m json.tool - -echo "" -echo "" -echo "=========================================" -echo "测试完成" -echo "=========================================" diff --git a/web/src/api/question.ts b/web/src/api/question.ts index 31a6fe8..ede682a 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -1,5 +1,15 @@ import { request } from '../utils/request' -import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse, WrongQuestion, WrongQuestionStats } from '../types/question' +import type { + Question, + SubmitAnswer, + AnswerResult, + Statistics, + ApiResponse, + WrongQuestion, + WrongQuestionStats, + WrongQuestionTag, + WrongQuestionFilter +} from '../types/question' // 获取题目列表 export const getQuestions = (params?: { type?: string; search?: string }) => { @@ -32,27 +42,41 @@ export const resetProgress = async () => { // ========== 错题本相关 API ========== -// 获取错题列表 -export const getWrongQuestions = (params?: { is_mastered?: boolean; type?: string }) => { +// 获取错题列表(支持筛选和排序) +export const getWrongQuestions = (filter?: WrongQuestionFilter) => { + const params: Record = {} + if (filter?.is_mastered !== undefined) { + params.is_mastered = filter.is_mastered ? 'true' : 'false' + } + if (filter?.tag) params.tag = filter.tag + if (filter?.type) params.type = filter.type + if (filter?.sort) params.sort = filter.sort + return request.get>('/wrong-questions', { params }) } -// 获取错题统计 +// 获取错题详情(包含完整历史) +export const getWrongQuestionDetail = (id: number) => { + return request.get>(`/wrong-questions/${id}`) +} + +// 获取错题统计(含趋势数据) export const getWrongQuestionStats = () => { return request.get>('/wrong-questions/stats') } -// 获取随机错题 -export const getRandomWrongQuestion = () => { - return request.get>('/wrong-questions/random') +// 获取推荐练习的错题(智能推荐) +export const getRecommendedWrongQuestions = (limit: number = 10, excludeQuestionID?: number) => { + const params: Record = { limit } + if (excludeQuestionID) { + params.exclude = excludeQuestionID + } + return request.get>('/wrong-questions/recommended', { + params + }) } -// 标记错题为已掌握 -export const markWrongQuestionMastered = (id: number) => { - return request.put>(`/wrong-questions/${id}/mastered`) -} - -// 删除单个错题 +// 删除错题 export const deleteWrongQuestion = (id: number) => { return request.delete>(`/wrong-questions/${id}`) } @@ -62,6 +86,41 @@ 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/pages/Home.tsx b/web/src/pages/Home.tsx index 0d61554..2f26186 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Typography, message, Space, Avatar, Button, Modal, Form, Radio, Alert, Input, Switch, InputNumber, Divider, Dropdown, Row, Col, Card } from 'antd' +import { Typography, message, Space, Avatar, Button, Modal, Form, Radio, Alert, Input, Switch, InputNumber, Dropdown, Row, Col, Card } from 'antd' import type { MenuProps } from 'antd' import { FileTextOutlined, diff --git a/web/src/pages/Question.tsx b/web/src/pages/Question.tsx index b5a9bc5..490da88 100644 --- a/web/src/pages/Question.tsx +++ b/web/src/pages/Question.tsx @@ -134,19 +134,39 @@ const QuestionPage: React.FC = () => { return 0; }; - // 加载随机错题 + // 加载随机错题(使用智能推荐) const loadRandomWrongQuestion = async () => { + console.log('[错题练习] 开始加载下一道错题...'); + console.log('[错题练习] 当前题目ID:', currentQuestion?.id); setLoading(true); try { - const res = await questionApi.getRandomWrongQuestion(); + // 传递当前题目ID,避免重复推荐 + const res = await questionApi.getRecommendedWrongQuestions(1, currentQuestion?.id); + console.log('[错题练习] API响应:', res); - if (res.success && res.data) { - setCurrentQuestion(res.data); - setSelectedAnswer(res.data.type === "multiple-selection" ? [] : ""); - setShowResult(false); - setAnswerResult(null); + if (res.success && res.data && res.data.length > 0) { + // 获取推荐的错题,然后加载对应的题目详情 + const wrongQuestion = res.data[0]; + console.log('[错题练习] 推荐的错题:', wrongQuestion); + const questionRes = await questionApi.getQuestionById(wrongQuestion.question_id); + console.log('[错题练习] 题目详情:', questionRes); + + if (questionRes.success && questionRes.data) { + console.log('[错题练习] 设置新题目:', questionRes.data.question_id); + setCurrentQuestion(questionRes.data); + setSelectedAnswer(questionRes.data.type === "multiple-selection" ? [] : ""); + setShowResult(false); + setAnswerResult(null); + } else { + console.error('[错题练习] 加载题目详情失败'); + message.error("加载题目详情失败"); + } + } else { + console.warn('[错题练习] 暂无推荐错题'); + message.warning("暂无需要复习的错题"); } } catch (error: any) { + console.error('[错题练习] 加载失败:', error); if (error.response?.status === 401) { message.error("请先登录"); } else if (error.response?.status === 404) { @@ -156,6 +176,7 @@ const QuestionPage: React.FC = () => { } } finally { setLoading(false); + console.log('[错题练习] 加载完成'); } }; @@ -265,6 +286,18 @@ const QuestionPage: React.FC = () => { // 下一题 const handleNext = () => { + const mode = searchParams.get("mode"); + console.log('[下一题] 当前模式:', mode); + + // 错题练习模式:加载下一道推荐错题 + if (mode === "wrong") { + console.log('[下一题] 错题练习模式,加载下一道错题'); + loadRandomWrongQuestion(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + + // 普通模式:从题库数组中选择 if (allQuestions.length > 0) { let nextIndex: number; @@ -357,7 +390,11 @@ const QuestionPage: React.FC = () => {
- - -
+ {/* 筛选和操作区域 */} + +
+ + + + 筛选与排序: + + + + + + + + +
+
{/* 错题列表 */} {wrongQuestions.length === 0 ? ( - + ) : ( ( - } - onClick={() => handleDelete(item.id)} - > - 删除 - - ]} - > - - 题目 {item.question.question_id || item.question.id} - - {item.question.category || item.question.type} - - 错误 {item.wrong_count} 次 - - } - description={ -
-
+ renderItem={(item) => { + const masteryLabel = getMasteryLabel(item.mastery_level) + return ( + +
+ {/* 题目头部 */} +
+ + + 第 {item.question?.question_id || item.question?.id || item.question_id} 题 + + {item.question && ( + + {item.question.category || item.question.type} + + )} + }> + 错 {item.total_wrong_count} 次 + + {item.is_mastered && ( + }> + 已掌握 + + )} + + +
+ + {/* 题目内容 */} + {item.question && ( +
{item.question.content}
- - - 你的答案:{formatAnswer(item.wrong_answer, item.question.type)} - - - 正确答案:{formatAnswer(item.correct_answer, item.question.type)} - - - 最后错误时间:{new Date(item.last_wrong_time).toLocaleString()} - - + )} + + {/* 答案信息 */} + {item.recent_history && item.recent_history.length > 0 && ( +
+
+ + 最近答案: + + + {formatAnswer(item.recent_history[0].user_answer, item.question?.type || '')} + +
+
+ + 正确答案: + + + {formatAnswer(item.recent_history[0].correct_answer, item.question?.type || '')} + +
+
+ )} + + {/* 掌握度进度条 */} +
+
+ 掌握度 + + {masteryLabel.text} + +
+ `${percent}%`} + />
- } - /> - - )} + + {/* 底部信息 */} +
+ + 最后错误:{new Date(item.last_wrong_time).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + +
+
+
+ ) + }} /> )} diff --git a/web/src/types/question.ts b/web/src/types/question.ts index aba8925..842cb1f 100644 --- a/web/src/types/question.ts +++ b/web/src/types/question.ts @@ -60,23 +60,65 @@ export interface ApiResponse { total?: number } +// 错题历史记录 +export interface WrongQuestionHistory { + id: number + user_answer: string | string[] + correct_answer: string | string[] + answered_at: string + time_spent: number // 答题用时(秒) + is_correct: boolean +} + // 错题记录 export interface WrongQuestion { id: number question_id: number - question: Question - wrong_answer: string | string[] - correct_answer: string | string[] - wrong_count: number - last_wrong_time: string - is_mastered: boolean + question?: Question + first_wrong_time: string // 首次错误时间 + last_wrong_time: string // 最后错误时间 + total_wrong_count: number // 总错误次数 + mastery_level: number // 掌握度 (0-100) + consecutive_correct: number // 连续答对次数 + is_mastered: boolean // 是否已掌握 + tags: string[] // 标签列表 + recent_history?: WrongQuestionHistory[] // 最近的历史记录 +} + +// 错题趋势数据点 +export interface TrendPoint { + date: string + count: number } // 错题统计 export interface WrongQuestionStats { - total_wrong: number - mastered: number - not_mastered: number - type_stats: Record - category_stats: Record + total_wrong: number // 总错题数 + mastered: number // 已掌握数 + not_mastered: number // 未掌握数 + need_review: number // 需要复习数 + 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 + tag?: string + type?: QuestionType + sort?: 'wrong_count' | 'mastery_level' | 'time' }