**统计功能**: - 新增UserAnswerRecord模型记录用户答题历史 - 实现GetStatistics接口,统计题库总数、已答题数、正确率 - 在提交答案时自动记录答题历史 - 前端连接真实统计接口,显示实时数据 **认证系统优化**: - 新增Auth中间件,实现基于Token的身份验证 - 登录和注册时自动生成并保存Token到数据库 - 所有需要登录的接口都通过Auth中间件保护 - 统一处理未授权请求,返回401状态码 **错题练习功能**: - 新增GetRandomWrongQuestion接口,随机获取错题 - 支持错题练习模式(/question?mode=wrong) - 优化错题本页面UI,移除已掌握功能 - 新增"开始错题练习"按钮,直接进入练习模式 **数据库迁移**: - 新增user_answer_records表,记录用户答题历史 - User表新增token字段,存储用户登录凭证 **技术改进**: - 统一错误处理,区分401未授权和404未找到 - 优化答题流程,记录历史和错题分离处理 - 移除异步记录错题,改为同步处理保证数据一致性 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
463 lines
11 KiB
Go
463 lines
11 KiB
Go
package handlers
|
||
|
||
import (
|
||
"ankao/internal/database"
|
||
"ankao/internal/models"
|
||
"encoding/json"
|
||
"log"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// GetPracticeQuestions 获取练习题目列表
|
||
func GetPracticeQuestions(c *gin.Context) {
|
||
typeParam := c.Query("type")
|
||
category := c.Query("category")
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
|
||
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 100
|
||
}
|
||
|
||
db := database.GetDB()
|
||
var questions []models.PracticeQuestion
|
||
var total int64
|
||
|
||
query := db.Model(&models.PracticeQuestion{})
|
||
|
||
// 根据题型过滤 - 将前端类型映射到后端类型
|
||
if typeParam != "" {
|
||
backendType := mapFrontendToBackendType(typeParam)
|
||
query = query.Where("type = ?", backendType)
|
||
}
|
||
|
||
// 根据分类过滤
|
||
if category != "" {
|
||
query = query.Where("type_name = ?", category)
|
||
}
|
||
|
||
// 获取总数
|
||
query.Count(&total)
|
||
|
||
// 分页查询
|
||
offset := (page - 1) * pageSize
|
||
err := query.Offset(offset).Limit(pageSize).Find(&questions).Error
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "查询题目失败",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 转换为DTO
|
||
dtos := make([]models.PracticeQuestionDTO, len(questions))
|
||
for i, q := range questions {
|
||
dto := convertToDTO(q)
|
||
dtos[i] = dto
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": dtos,
|
||
"total": total,
|
||
})
|
||
}
|
||
|
||
// GetPracticeQuestionByID 获取单个练习题目
|
||
func GetPracticeQuestionByID(c *gin.Context) {
|
||
idStr := c.Param("id")
|
||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "无效的题目ID",
|
||
})
|
||
return
|
||
}
|
||
|
||
db := database.GetDB()
|
||
var question models.PracticeQuestion
|
||
|
||
if err := db.First(&question, id).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{
|
||
"success": false,
|
||
"message": "题目不存在",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 转换为DTO
|
||
dto := convertToDTO(question)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": dto,
|
||
})
|
||
}
|
||
|
||
// GetRandomPracticeQuestion 获取随机练习题目
|
||
func GetRandomPracticeQuestion(c *gin.Context) {
|
||
typeParam := c.Query("type")
|
||
|
||
db := database.GetDB()
|
||
var question models.PracticeQuestion
|
||
|
||
query := db.Model(&models.PracticeQuestion{})
|
||
if typeParam != "" {
|
||
backendType := mapFrontendToBackendType(typeParam)
|
||
query = query.Where("type = ?", backendType)
|
||
}
|
||
|
||
// 使用PostgreSQL的随机排序
|
||
if err := query.Order("RANDOM()").First(&question).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{
|
||
"success": false,
|
||
"message": "暂无题目",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 转换为DTO
|
||
dto := convertToDTO(question)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": dto,
|
||
})
|
||
}
|
||
|
||
// SubmitPracticeAnswer 提交练习答案
|
||
func SubmitPracticeAnswer(c *gin.Context) {
|
||
var submit models.PracticeAnswerSubmit
|
||
if err := c.ShouldBindJSON(&submit); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "请求参数错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 获取用户ID(认证中间件已确保存在)
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"success": false,
|
||
"message": "未登录",
|
||
})
|
||
return
|
||
}
|
||
|
||
db := database.GetDB()
|
||
var question models.PracticeQuestion
|
||
|
||
if err := db.First(&question, submit.QuestionID).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{
|
||
"success": false,
|
||
"message": "题目不存在",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 解析正确答案
|
||
var correctAnswer interface{}
|
||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "答案数据错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 验证答案
|
||
correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
|
||
|
||
// 记录用户答题历史
|
||
if uid, ok := userID.(uint); ok {
|
||
record := models.UserAnswerRecord{
|
||
UserID: uid,
|
||
QuestionID: question.ID,
|
||
IsCorrect: correct,
|
||
AnsweredAt: time.Now(),
|
||
}
|
||
// 记录到数据库(忽略错误,不影响主流程)
|
||
if err := db.Create(&record).Error; err != nil {
|
||
log.Printf("记录答题历史失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// 如果答错,记录到错题本
|
||
if !correct {
|
||
if uid, ok := userID.(uint); ok {
|
||
// 记录错题
|
||
if err := recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer); err != nil {
|
||
// 记录错题失败不影响主流程,只记录日志
|
||
log.Printf("记录错题失败: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
result := models.PracticeAnswerResult{
|
||
Correct: correct,
|
||
UserAnswer: submit.Answer,
|
||
CorrectAnswer: correctAnswer, // 始终返回正确答案
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": result,
|
||
})
|
||
}
|
||
|
||
// GetPracticeQuestionTypes 获取题型列表
|
||
func GetPracticeQuestionTypes(c *gin.Context) {
|
||
types := []gin.H{
|
||
{
|
||
"type": models.FillInBlank,
|
||
"type_name": "填空题",
|
||
},
|
||
{
|
||
"type": models.TrueFalseType,
|
||
"type_name": "判断题",
|
||
},
|
||
{
|
||
"type": models.MultipleChoiceQ,
|
||
"type_name": "选择题",
|
||
},
|
||
{
|
||
"type": models.MultipleSelection,
|
||
"type_name": "多选题",
|
||
},
|
||
{
|
||
"type": models.ShortAnswer,
|
||
"type_name": "简答题",
|
||
},
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": types,
|
||
})
|
||
}
|
||
|
||
// checkPracticeAnswer 检查练习答案是否正确
|
||
func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, correctAnswer interface{}) bool {
|
||
switch questionType {
|
||
case models.TrueFalseType:
|
||
// 判断题: boolean 比较
|
||
userBool, ok1 := userAnswer.(bool)
|
||
correctBool, ok2 := correctAnswer.(bool)
|
||
return ok1 && ok2 && userBool == correctBool
|
||
|
||
case models.MultipleChoiceQ:
|
||
// 单选题: 字符串比较
|
||
userStr, ok1 := userAnswer.(string)
|
||
correctStr, ok2 := correctAnswer.(string)
|
||
return ok1 && ok2 && userStr == correctStr
|
||
|
||
case models.MultipleSelection:
|
||
// 多选题: 数组比较
|
||
userArr, ok1 := toStringArray(userAnswer)
|
||
correctArr, ok2 := toStringArray(correctAnswer)
|
||
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
|
||
return false
|
||
}
|
||
|
||
// 转换为map进行比较
|
||
userMap := make(map[string]bool)
|
||
for _, v := range userArr {
|
||
userMap[v] = true
|
||
}
|
||
for _, v := range correctArr {
|
||
if !userMap[v] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
|
||
case models.FillInBlank:
|
||
// 填空题: 数组比较
|
||
userArr, ok1 := toStringArray(userAnswer)
|
||
correctArr, ok2 := toStringArray(correctAnswer)
|
||
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
|
||
return false
|
||
}
|
||
|
||
// 逐个比较填空答案
|
||
for i := range correctArr {
|
||
if userArr[i] != correctArr[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
|
||
case models.ShortAnswer:
|
||
// 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断)
|
||
userStr, ok1 := userAnswer.(string)
|
||
correctStr, ok2 := correctAnswer.(string)
|
||
return ok1 && ok2 && userStr == correctStr
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// toStringArray 将interface{}转换为字符串数组
|
||
func toStringArray(v interface{}) ([]string, bool) {
|
||
switch arr := v.(type) {
|
||
case []string:
|
||
return arr, true
|
||
case []interface{}:
|
||
result := make([]string, len(arr))
|
||
for i, item := range arr {
|
||
if str, ok := item.(string); ok {
|
||
result[i] = str
|
||
} else {
|
||
return nil, false
|
||
}
|
||
}
|
||
return result, true
|
||
default:
|
||
return nil, false
|
||
}
|
||
}
|
||
|
||
// mapFrontendToBackendType 将前端类型映射到后端类型
|
||
func mapFrontendToBackendType(frontendType string) models.PracticeQuestionType {
|
||
typeMap := map[string]models.PracticeQuestionType{
|
||
"single": models.MultipleChoiceQ, // 单选
|
||
"multiple": models.MultipleSelection, // 多选
|
||
"judge": models.TrueFalseType, // 判断
|
||
"fill": models.FillInBlank, // 填空
|
||
"short": models.ShortAnswer, // 简答
|
||
}
|
||
|
||
if backendType, ok := typeMap[frontendType]; ok {
|
||
return backendType
|
||
}
|
||
return models.MultipleChoiceQ // 默认返回单选
|
||
}
|
||
|
||
// mapBackendToFrontendType 将后端类型映射到前端类型
|
||
func mapBackendToFrontendType(backendType models.PracticeQuestionType) string {
|
||
typeMap := map[models.PracticeQuestionType]string{
|
||
models.MultipleChoiceQ: "single", // 单选
|
||
models.MultipleSelection: "multiple", // 多选
|
||
models.TrueFalseType: "judge", // 判断
|
||
models.FillInBlank: "fill", // 填空
|
||
models.ShortAnswer: "short", // 简答
|
||
}
|
||
|
||
if frontendType, ok := typeMap[backendType]; ok {
|
||
return frontendType
|
||
}
|
||
return "single" // 默认返回单选
|
||
}
|
||
|
||
// convertToDTO 将数据库模型转换为前端DTO
|
||
func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
|
||
dto := models.PracticeQuestionDTO{
|
||
ID: question.ID,
|
||
QuestionID: question.QuestionID, // 添加题目编号
|
||
Type: mapBackendToFrontendType(question.Type),
|
||
Content: question.Question,
|
||
Category: question.TypeName,
|
||
Options: []models.Option{},
|
||
}
|
||
|
||
// 判断题自动生成选项
|
||
if question.Type == models.TrueFalseType {
|
||
dto.Options = []models.Option{
|
||
{Key: "true", Value: "正确"},
|
||
{Key: "false", Value: "错误"},
|
||
}
|
||
return dto
|
||
}
|
||
|
||
// 解析选项数据(如果有)
|
||
if question.OptionsData != "" {
|
||
var optionsMap map[string]string
|
||
if err := json.Unmarshal([]byte(question.OptionsData), &optionsMap); err == nil {
|
||
// 将map转换为Option数组
|
||
for key, value := range optionsMap {
|
||
dto.Options = append(dto.Options, models.Option{
|
||
Key: key,
|
||
Value: value,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return dto
|
||
}
|
||
|
||
// GetStatistics 获取用户统计数据
|
||
func GetStatistics(c *gin.Context) {
|
||
// 获取用户ID
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"success": false,
|
||
"message": "未登录",
|
||
})
|
||
return
|
||
}
|
||
|
||
uid, ok := userID.(uint)
|
||
if !ok {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "用户ID格式错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
db := database.GetDB()
|
||
|
||
// 获取题库总数
|
||
var totalQuestions int64
|
||
db.Model(&models.PracticeQuestion{}).Count(&totalQuestions)
|
||
|
||
// 获取用户已答题数(去重)
|
||
var answeredQuestions int64
|
||
db.Model(&models.UserAnswerRecord{}).
|
||
Where("user_id = ?", uid).
|
||
Distinct("question_id").
|
||
Count(&answeredQuestions)
|
||
|
||
// 获取用户答对题数
|
||
var correctAnswers int64
|
||
db.Model(&models.UserAnswerRecord{}).
|
||
Where("user_id = ? AND is_correct = ?", uid, true).
|
||
Count(&correctAnswers)
|
||
|
||
// 计算正确率
|
||
var accuracy float64
|
||
if answeredQuestions > 0 {
|
||
// 正确率 = 答对题数 / 总答题数
|
||
var totalAnswers int64
|
||
db.Model(&models.UserAnswerRecord{}).
|
||
Where("user_id = ?", uid).
|
||
Count(&totalAnswers)
|
||
accuracy = float64(correctAnswers) / float64(totalAnswers) * 100
|
||
}
|
||
|
||
stats := models.UserStatistics{
|
||
TotalQuestions: int(totalQuestions),
|
||
AnsweredQuestions: int(answeredQuestions),
|
||
CorrectAnswers: int(correctAnswers),
|
||
Accuracy: accuracy,
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": stats,
|
||
})
|
||
}
|