主要变更: - 数据库ID字段统一从 uint 改为 int64,提升数据容量和兼容性 - 重构答题检查逻辑,采用策略模式替代 switch-case - 新增 PracticeProgress 模型,支持练习进度持久化 - 优化错题本系统,自动记录答题进度和错误历史 - 添加 lib/pq PostgreSQL 驱动依赖 - 移除错题标签管理 API(待后续迁移) - 前端类型定义同步更新,适配后端模型变更 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
328 lines
8.8 KiB
Go
328 lines
8.8 KiB
Go
package handlers
|
||
|
||
import (
|
||
"ankao/internal/database"
|
||
"ankao/internal/models"
|
||
"ankao/internal/services"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ==================== 错题管理 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{"error": "未授权"})
|
||
return
|
||
}
|
||
|
||
db := database.GetDB()
|
||
query := db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID)
|
||
|
||
// 筛选条件
|
||
if isMastered := c.Query("is_mastered"); isMastered != "" {
|
||
query = query.Where("is_mastered = ?", isMastered == "true")
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 手动加载关联数据
|
||
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
|
||
}
|
||
|
||
// 加载最近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
|
||
}
|
||
}
|
||
|
||
// 转换为 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,
|
||
})
|
||
}
|
||
|
||
// 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{"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 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
|
||
}
|
||
|
||
// 转换为 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
|
||
}
|
||
|
||
stats, err := services.GetWrongQuestionStats(userID.(uint))
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取统计失败"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": stats,
|
||
})
|
||
}
|
||
|
||
// 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{"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
|
||
}
|
||
|
||
db := database.GetDB()
|
||
|
||
// 删除历史记录
|
||
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
|
||
}
|
||
|
||
if result.RowsAffected == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "删除成功",
|
||
})
|
||
}
|
||
|
||
// 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{"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{"error": "清空错题本失败"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"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,
|
||
}
|
||
|
||
// 转换题目信息
|
||
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
|
||
}
|