1. 错题本系统重构: - 新增错题服务层 (wrong_question_service.go) - 实现智能推荐算法(基于掌握度和错误次数) - 添加掌握度追踪机制(连续答对6次标记为已掌握) - 支持错题筛选和排序功能 - 新增错题统计趋势分析 2. UI优化: - 美化错题本界面,采用毛玻璃卡片设计 - 添加四宫格统计卡片(错题总数、已掌握、未掌握、掌握率) - 优化筛选和操作按钮布局 - 使用条状进度条显示掌握度 - 改进响应式设计,优化移动端体验 3. 功能完善: - 修复判断题答案显示问题 - 当掌握率100%时禁用"开始练习"按钮 - 删除测试文件和 nul 文件 - 更新文档 (CLAUDE.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
533 lines
14 KiB
Go
533 lines
14 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": "错题本已清空",
|
||
})
|
||
}
|
||
|
||
// 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(可选是否包含最近历史)
|
||
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
|
||
}
|