1183 lines
33 KiB
Go
1183 lines
33 KiB
Go
package handlers
|
||
|
||
import (
|
||
"ankao/internal/database"
|
||
"ankao/internal/models"
|
||
"ankao/internal/services"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// checkEssayPermission 检查用户是否有权限访问论述题
|
||
// 返回 true 表示有权限,false 表示无权限
|
||
func checkEssayPermission(c *gin.Context, questionType string) bool {
|
||
// 如果不是论述题,直接允许访问
|
||
if !strings.HasSuffix(questionType, "-essay") {
|
||
log.Printf("[论述题权限检查] 非论述题类型,直接允许访问 (type: %s)", questionType)
|
||
return true
|
||
}
|
||
|
||
log.Printf("[论述题权限检查] 开始检查 (question_type: %s)", questionType)
|
||
|
||
// 从上下文获取用户信息(Auth中间件已设置)
|
||
username, exists := c.Get("username")
|
||
if !exists {
|
||
log.Printf("[论述题权限检查] 失败: 未登录用户 (question_type: %s)", questionType)
|
||
return false
|
||
}
|
||
|
||
log.Printf("[论述题权限检查] 已登录用户 (username: %v, question_type: %s)", username, questionType)
|
||
|
||
// 管理员可以访问所有论述题
|
||
if username == "yanlongqi" {
|
||
log.Printf("[论述题权限检查] 通过: 管理员用户,允许访问所有论述题 (username: %s)", username)
|
||
return true
|
||
}
|
||
|
||
// 获取用户信息
|
||
db := database.GetDB()
|
||
var user models.User
|
||
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
|
||
log.Printf("[论述题权限检查] 失败: 查询用户失败 (username: %v, error: %v)", username, err)
|
||
return false
|
||
}
|
||
|
||
log.Printf("[论述题权限检查] 用户信息 (username: %s, user_type: '%s', question_type: '%s')",
|
||
user.Username, user.UserType, questionType)
|
||
|
||
// 检查用户类型是否匹配
|
||
if questionType == "ordinary-essay" && user.UserType == "ordinary-person" {
|
||
log.Printf("[论述题权限检查] 通过: 普通涉密人员访问普通论述题 (username: %s)", user.Username)
|
||
return true
|
||
}
|
||
if questionType == "management-essay" && user.UserType == "management-person" {
|
||
log.Printf("[论述题权限检查] 通过: 保密管理人员访问管理论述题 (username: %s)", user.Username)
|
||
return true
|
||
}
|
||
|
||
log.Printf("[论述题权限检查] 失败: 类型不匹配 (username: %s, user_type: '%s', question_type: '%s')",
|
||
user.Username, user.UserType, questionType)
|
||
return false
|
||
}
|
||
|
||
// GetPracticeQuestions 获取练习题目列表
|
||
func GetPracticeQuestions(c *gin.Context) {
|
||
typeParam := c.Query("type")
|
||
searchQuery := c.Query("search")
|
||
|
||
log.Printf("[GetPracticeQuestions] 收到请求 (type: '%s', search: '%s')", typeParam, searchQuery)
|
||
|
||
db := database.GetDB()
|
||
var questions []models.PracticeQuestion
|
||
var total int64
|
||
|
||
query := db.Model(&models.PracticeQuestion{})
|
||
|
||
// 根据题型过滤
|
||
if typeParam != "" {
|
||
// 如果请求论述题,检查权限
|
||
if strings.HasSuffix(typeParam, "-essay") && !checkEssayPermission(c, typeParam) {
|
||
// 权限不足,返回空列表
|
||
log.Printf("[GetPracticeQuestions] 权限检查失败,返回空列表 (type: '%s')", typeParam)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": []models.PracticeQuestionDTO{},
|
||
"total": 0,
|
||
})
|
||
return
|
||
}
|
||
query = query.Where("type = ?", typeParam)
|
||
log.Printf("[GetPracticeQuestions] 添加题型过滤 (type: '%s')", typeParam)
|
||
}
|
||
|
||
// 根据搜索关键词过滤(搜索题目内容或题目编号)
|
||
if searchQuery != "" {
|
||
// 将 question_id 显式转换为文本类型,避免 PostgreSQL 将其作为数字类型处理
|
||
query = query.Where("question LIKE ? OR question_id::text LIKE ?", "%"+searchQuery+"%", "%"+searchQuery+"%")
|
||
}
|
||
|
||
// 获取总数
|
||
query.Count(&total)
|
||
|
||
log.Printf("[GetPracticeQuestions] 查询结果统计 (total: %d)", total)
|
||
|
||
// 查询所有题目 - 按题型和题目编号升序排序
|
||
// 先将 question_id 转为文本,提取数字部分,再转为整数排序
|
||
// 显式查询所有字段,包括 answer_data
|
||
err := query.Select("*").Order("type ASC, CAST(COALESCE(NULLIF(REGEXP_REPLACE(question_id::text, '[^0-9]', '', 'g'), ''), '0') AS INTEGER) ASC").Find(&questions).Error
|
||
if err != nil {
|
||
log.Printf("[GetPracticeQuestions] 查询失败 (error: %v)", err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "查询题目失败",
|
||
})
|
||
return
|
||
}
|
||
|
||
log.Printf("[GetPracticeQuestions] 查询成功,返回 %d 条数据", len(questions))
|
||
|
||
// 转换为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,
|
||
})
|
||
}
|
||
|
||
// 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 strings.HasSuffix(question.Type, "-essay") {
|
||
log.Printf("[论述题] 跳过答案解析 (题目ID: %d, 类型: %s)", question.ID, question.Type)
|
||
correctAnswer = "" // 论述题没有标准答案
|
||
} else {
|
||
// 其他题型需要解析标准答案
|
||
if question.AnswerData == "" {
|
||
log.Printf("[错误] 题目缺少答案数据 (题目ID: %d, 类型: %s)", question.ID, question.Type)
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "题目答案数据缺失",
|
||
})
|
||
return
|
||
}
|
||
|
||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
|
||
log.Printf("[错误] 答案数据解析失败 (题目ID: %d, 类型: %s, AnswerData: '%s', 错误: %v)",
|
||
question.ID, question.Type, question.AnswerData, err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "答案数据错误",
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
// 验证答案(论述题不需要验证,因为没有标准答案)
|
||
correct := false
|
||
if strings.HasSuffix(question.Type, "-essay") {
|
||
// 论述题默认为false,实际结果由AI评分决定
|
||
correct = false
|
||
} else {
|
||
// 其他题型使用标准答案验证
|
||
correct = checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
|
||
}
|
||
|
||
// AI评分结果(简答题和论述题)
|
||
var aiGrading *models.AIGrading = nil
|
||
|
||
// 对简答题和论述题使用AI评分(必须成功,失败重试最多5次)
|
||
if question.Type == "short-answer" || strings.HasSuffix(question.Type, "-essay") {
|
||
// 获取用户答案字符串
|
||
userAnswerStr, ok := submit.Answer.(string)
|
||
if !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "答案格式错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 调用AI评分服务(带重试机制)
|
||
aiService, err := services.NewAIGradingService()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": fmt.Sprintf("AI评分服务初始化失败: %v", err),
|
||
})
|
||
return
|
||
}
|
||
|
||
var aiResult *services.AIGradingResult
|
||
maxRetries := 3 // 减少重试次数,从5次改为3次
|
||
|
||
// 区分简答题和论述题的评分方式
|
||
if strings.HasSuffix(question.Type, "-essay") {
|
||
// 论述题:不需要标准答案,直接评分
|
||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||
log.Printf("论述题AI评分尝试第 %d 次 (题目ID: %d)", attempt, question.ID)
|
||
aiResult, err = aiService.GradeEssay(question.Question, userAnswerStr)
|
||
if err == nil {
|
||
log.Printf("论述题AI评分成功 (题目ID: %d, 得分: %.1f)", question.ID, aiResult.Score)
|
||
break
|
||
}
|
||
log.Printf("论述题AI评分失败 (第 %d 次尝试): %v", attempt, err)
|
||
if attempt < maxRetries {
|
||
// 减少等待时间,使用固定延迟500ms而不是指数退避
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
}
|
||
} else {
|
||
// 简答题:需要标准答案对比
|
||
// 获取标准答案字符串
|
||
standardAnswerStr, ok := correctAnswer.(string)
|
||
if !ok {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "题目答案格式错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||
log.Printf("简答题AI评分尝试第 %d 次 (题目ID: %d)", attempt, question.ID)
|
||
aiResult, err = aiService.GradeShortAnswer(question.Question, standardAnswerStr, userAnswerStr)
|
||
if err == nil {
|
||
log.Printf("简答题AI评分成功 (题目ID: %d, 得分: %.1f)", question.ID, aiResult.Score)
|
||
break
|
||
}
|
||
log.Printf("简答题AI评分失败 (第 %d 次尝试): %v", attempt, err)
|
||
if attempt < maxRetries {
|
||
// 减少等待时间,使用固定延迟500ms而不是指数退避
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
}
|
||
}
|
||
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": fmt.Sprintf("AI评分服务暂时不可用,已重试%d次,请稍后再试", maxRetries),
|
||
})
|
||
return
|
||
}
|
||
|
||
// 使用AI的评分结果
|
||
correct = aiResult.IsCorrect
|
||
aiGrading = &models.AIGrading{
|
||
Score: aiResult.Score,
|
||
Feedback: aiResult.Feedback,
|
||
Suggestion: aiResult.Suggestion,
|
||
ReferenceAnswer: aiResult.ReferenceAnswer,
|
||
ScoringRationale: aiResult.ScoringRationale,
|
||
}
|
||
}
|
||
|
||
userAnswer, _ := json.Marshal(submit.Answer)
|
||
uid := userID.(int64)
|
||
record := models.UserAnswerRecord{
|
||
UserID: uid,
|
||
QuestionID: question.ID,
|
||
IsCorrect: correct,
|
||
AnsweredAt: time.Now(),
|
||
UserAnswer: userAnswer,
|
||
}
|
||
|
||
// 如果有 AI 评分,保存到数据库
|
||
if aiGrading != nil {
|
||
record.AIScore = &aiGrading.Score
|
||
record.AIFeedback = &aiGrading.Feedback
|
||
record.AISuggestion = &aiGrading.Suggestion
|
||
log.Printf("[AI评分] 保存AI评分到数据库: score=%.2f, feedback=%s", aiGrading.Score, aiGrading.Feedback)
|
||
}
|
||
|
||
// 记录到数据库(忽略错误,不影响主流程)
|
||
if err := db.Create(&record).Error; err != nil {
|
||
log.Printf("记录答题历史失败: %v", err)
|
||
}
|
||
|
||
// 记录到错题本(新版)- 使用 V2 API
|
||
timeSpent := 0
|
||
if !correct {
|
||
if err := services.RecordWrongAnswer(uid, question.ID, submit.Answer, correctAnswer, timeSpent); err != nil {
|
||
log.Printf("记录错题失败: %v", err)
|
||
}
|
||
} else {
|
||
// 答对,如果这道题在错题本中,更新连续答对次数
|
||
if err := services.RecordCorrectAnswer(uid, question.ID, submit.Answer, correctAnswer, timeSpent); err != nil {
|
||
log.Printf("更新错题记录失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// 保存练习进度到数据库
|
||
// 查找或创建进度记录
|
||
var progress models.PracticeProgress
|
||
findResult := db.Where("user_id = ? and type = ?", uid, question.Type).First(&progress)
|
||
if errors.Is(findResult.Error, gorm.ErrRecordNotFound) {
|
||
// 创建新记录
|
||
answerRecords := map[int64]int64{submit.QuestionID: record.ID}
|
||
answerRecordsJSON, _ := json.Marshal(answerRecords)
|
||
progress = models.PracticeProgress{
|
||
UserID: uid,
|
||
CurrentQuestionID: submit.QuestionID,
|
||
Type: question.Type,
|
||
UserAnswerRecords: answerRecordsJSON,
|
||
}
|
||
if err := db.Create(&progress).Error; err != nil {
|
||
log.Printf("保存练习进度失败: %v", err)
|
||
}
|
||
} else if findResult.Error == nil {
|
||
// 解析现有的答题记录
|
||
var answerRecords map[int64]int64
|
||
if err := json.Unmarshal(progress.UserAnswerRecords, &answerRecords); err != nil {
|
||
answerRecords = make(map[int64]int64)
|
||
}
|
||
// 更新记录
|
||
progress.CurrentQuestionID = submit.QuestionID
|
||
answerRecords[submit.QuestionID] = record.ID
|
||
answerRecordsJSON, _ := json.Marshal(answerRecords)
|
||
progress.UserAnswerRecords = answerRecordsJSON
|
||
if err := db.Save(&progress).Error; err != nil {
|
||
log.Printf("更新练习进度失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// 构建返回结果
|
||
result := models.PracticeAnswerResult{
|
||
Correct: correct,
|
||
UserAnswer: submit.Answer,
|
||
AIGrading: aiGrading, // AI评分结果(简答题和论述题)
|
||
}
|
||
|
||
// 论述题不返回标准答案(因为没有固定答案)
|
||
if !strings.HasSuffix(question.Type, "-essay") {
|
||
result.CorrectAnswer = correctAnswer
|
||
} else {
|
||
result.CorrectAnswer = "" // 论述题返回空字符串
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": result,
|
||
})
|
||
}
|
||
|
||
type CheckAnswer interface {
|
||
check(userAnswer, correctAnswer interface{}) bool
|
||
}
|
||
|
||
type CheckTrueFalse struct {
|
||
}
|
||
|
||
func (*CheckTrueFalse) check(userAnswer, correctAnswer interface{}) bool {
|
||
userBool, ok1 := userAnswer.(bool)
|
||
correctBool, ok2 := correctAnswer.(bool)
|
||
return ok1 && ok2 && userBool == correctBool
|
||
}
|
||
|
||
type MultipleChoice struct {
|
||
}
|
||
|
||
func (*MultipleChoice) check(userAnswer, correctAnswer interface{}) bool {
|
||
userStr, ok1 := userAnswer.(string)
|
||
correctStr, ok2 := correctAnswer.(string)
|
||
return ok1 && ok2 && userStr == correctStr
|
||
}
|
||
|
||
type MultipleSelection struct {
|
||
}
|
||
|
||
func (*MultipleSelection) check(userAnswer, correctAnswer interface{}) bool {
|
||
// 多选题: 数组比较
|
||
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
|
||
}
|
||
|
||
type FillInBlank struct {
|
||
}
|
||
|
||
func (*FillInBlank) check(userAnswer, correctAnswer interface{}) bool {
|
||
// 填空题: 数组比较
|
||
userArr, ok1 := toStringArray(userAnswer)
|
||
correctArr, ok2 := toStringArray(correctAnswer)
|
||
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
|
||
log.Printf("填空题验证失败 - 数组转换或长度不匹配: ok1=%v, ok2=%v, userLen=%d, correctLen=%d",
|
||
ok1, ok2, len(userArr), len(correctArr))
|
||
return false
|
||
}
|
||
|
||
// 逐个比较填空答案(去除前后空格)
|
||
for i := range correctArr {
|
||
userTrimmed := strings.TrimSpace(userArr[i])
|
||
correctTrimmed := strings.TrimSpace(correctArr[i])
|
||
if userTrimmed != correctTrimmed {
|
||
log.Printf("填空题验证失败 - 第%d个答案不匹配: user='%s', correct='%s'",
|
||
i+1, userTrimmed, correctTrimmed)
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
type ShortAnswer struct {
|
||
}
|
||
|
||
func (*ShortAnswer) check(userAnswer, correctAnswer interface{}) bool {
|
||
// 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断)
|
||
userStr, ok1 := userAnswer.(string)
|
||
correctStr, ok2 := correctAnswer.(string)
|
||
return ok1 && ok2 && userStr == correctStr
|
||
}
|
||
|
||
type CheckAnswers struct {
|
||
checkAnswers map[string]CheckAnswer
|
||
}
|
||
|
||
func (c CheckAnswers) check(questionType string, userAnswer, correctAnswer interface{}) bool {
|
||
checkAnswer := c.checkAnswers[questionType]
|
||
return checkAnswer.check(userAnswer, correctAnswer)
|
||
}
|
||
|
||
func NewCheckAnswers() *CheckAnswers {
|
||
return &CheckAnswers{
|
||
checkAnswers: map[string]CheckAnswer{
|
||
"true-false": &CheckTrueFalse{},
|
||
"multiple-choice": &MultipleChoice{},
|
||
"multiple-selection": &MultipleSelection{},
|
||
"fill-in-blank": &FillInBlank{},
|
||
"short-answer": &ShortAnswer{},
|
||
},
|
||
}
|
||
}
|
||
|
||
// checkPracticeAnswer 检查练习答案是否正确
|
||
func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool {
|
||
answers := NewCheckAnswers()
|
||
if answers == nil {
|
||
return false
|
||
}
|
||
return answers.check(questionType, userAnswer, correctAnswer)
|
||
}
|
||
|
||
// toStringArray 将interface{}转换为字符串数组
|
||
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
|
||
}
|
||
}
|
||
|
||
// convertToDTO 将数据库模型转换为前端DTO
|
||
func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
|
||
dto := models.PracticeQuestionDTO{
|
||
ID: question.ID,
|
||
QuestionID: question.QuestionID,
|
||
Type: question.Type, // 直接使用数据库中的type,不做映射
|
||
Content: question.Question,
|
||
Category: question.TypeName, // 使用typeName作为分类显示
|
||
Options: []models.Option{},
|
||
}
|
||
|
||
// 解析答案数据
|
||
if question.AnswerData != "" {
|
||
// 对于简答题和论述题,答案可能是纯字符串或JSON格式
|
||
if question.Type == "short-answer" || strings.HasSuffix(question.Type, "-essay") {
|
||
// 尝试JSON解析
|
||
var answer interface{}
|
||
if err := json.Unmarshal([]byte(question.AnswerData), &answer); err != nil {
|
||
// JSON解析失败,直接使用原始字符串
|
||
dto.Answer = question.AnswerData
|
||
} else {
|
||
// JSON解析成功
|
||
dto.Answer = answer
|
||
}
|
||
} else {
|
||
// 其他题型必须是JSON格式
|
||
var answer interface{}
|
||
if err := json.Unmarshal([]byte(question.AnswerData), &answer); err != nil {
|
||
log.Printf("[convertToDTO] 解析答案失败 (id: %d, type: %s, error: %v)",
|
||
question.ID, question.Type, err)
|
||
} else {
|
||
dto.Answer = answer
|
||
}
|
||
}
|
||
}
|
||
|
||
// 判断题自动生成选项(正确在前,错误在后)
|
||
if question.Type == "true-false" {
|
||
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数组,并按key排序
|
||
keys := make([]string, 0, len(optionsMap))
|
||
for key := range optionsMap {
|
||
keys = append(keys, key)
|
||
}
|
||
// 对keys进行排序
|
||
sort.Strings(keys)
|
||
|
||
// 按排序后的key顺序添加选项
|
||
for _, key := range keys {
|
||
dto.Options = append(dto.Options, models.Option{
|
||
Key: key,
|
||
Value: optionsMap[key],
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
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.(int64)
|
||
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 wrongQuestions int64
|
||
db.Model(&models.WrongQuestion{}).
|
||
Where("user_id = ?", uid).
|
||
Count(&wrongQuestions)
|
||
|
||
// 计算正确率和总答题数
|
||
var accuracy float64
|
||
var totalAnswers int64
|
||
db.Model(&models.UserAnswerRecord{}).
|
||
Where("user_id = ?", uid).
|
||
Count(&totalAnswers)
|
||
|
||
if totalAnswers > 0 {
|
||
// 正确率 = 答对题数 / 总答题数
|
||
accuracy = float64(correctAnswers) / float64(totalAnswers) * 100
|
||
}
|
||
|
||
stats := gin.H{
|
||
"total_questions": int(totalQuestions),
|
||
"answered_questions": int(answeredQuestions),
|
||
"correct_answers": int(correctAnswers),
|
||
"wrong_questions": int(wrongQuestions),
|
||
"total_answers": int(totalAnswers), // 刷题次数
|
||
"accuracy": accuracy,
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": stats,
|
||
})
|
||
}
|
||
|
||
// CreatePracticeQuestion 创建新的练习题目
|
||
func CreatePracticeQuestion(c *gin.Context) {
|
||
var req struct {
|
||
Type string `json:"type" binding:"required"`
|
||
TypeName string `json:"type_name"`
|
||
Question string `json:"question" binding:"required"`
|
||
Answer interface{} `json:"answer" binding:"required"`
|
||
Options map[string]string `json:"options"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "请求参数错误: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
db := database.GetDB()
|
||
|
||
// 自动生成题目编号:找到该题型的最大编号并+1
|
||
var maxQuestionID string
|
||
err := db.Model(&models.PracticeQuestion{}).
|
||
Where("type = ?", req.Type).
|
||
Select("question_id").
|
||
Order("CAST(COALESCE(NULLIF(REGEXP_REPLACE(question_id::text, '[^0-9]', '', 'g'), ''), '0') AS INTEGER) DESC").
|
||
Limit(1).
|
||
Pluck("question_id", &maxQuestionID).Error
|
||
|
||
// 生成新的题目编号
|
||
var newQuestionID string
|
||
if err != nil || maxQuestionID == "" {
|
||
// 没有找到该题型的题目,从1开始
|
||
newQuestionID = "1"
|
||
} else {
|
||
// 从最大编号中提取数字并+1
|
||
var maxNum int
|
||
_, scanErr := strconv.Atoi(maxQuestionID)
|
||
if scanErr == nil {
|
||
maxNum, _ = strconv.Atoi(maxQuestionID)
|
||
}
|
||
newQuestionID = strconv.Itoa(maxNum + 1)
|
||
}
|
||
|
||
// 将答案序列化为JSON字符串
|
||
answerData, err := json.Marshal(req.Answer)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "答案格式错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 将选项序列化为JSON字符串
|
||
var optionsData string
|
||
if req.Options != nil && len(req.Options) > 0 {
|
||
optionsBytes, err := json.Marshal(req.Options)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "选项格式错误",
|
||
})
|
||
return
|
||
}
|
||
optionsData = string(optionsBytes)
|
||
}
|
||
|
||
question := models.PracticeQuestion{
|
||
QuestionID: newQuestionID,
|
||
Type: req.Type,
|
||
TypeName: req.TypeName,
|
||
Question: req.Question,
|
||
AnswerData: string(answerData),
|
||
OptionsData: optionsData,
|
||
}
|
||
|
||
if err := db.Create(&question).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "创建题目失败",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 返回创建的题目
|
||
dto := convertToDTO(question)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": dto,
|
||
"message": "创建成功",
|
||
})
|
||
}
|
||
|
||
// UpdatePracticeQuestion 更新练习题目
|
||
func UpdatePracticeQuestion(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
|
||
}
|
||
|
||
var req struct {
|
||
Type string `json:"type"`
|
||
TypeName string `json:"type_name"`
|
||
Question string `json:"question"`
|
||
Answer interface{} `json:"answer"`
|
||
Options map[string]string `json:"options"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "请求参数错误: " + err.Error(),
|
||
})
|
||
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
|
||
}
|
||
|
||
// 更新字段(注意:不允许修改 QuestionID,由系统自动生成)
|
||
if req.Type != "" {
|
||
question.Type = req.Type
|
||
}
|
||
if req.TypeName != "" {
|
||
question.TypeName = req.TypeName
|
||
}
|
||
if req.Question != "" {
|
||
question.Question = req.Question
|
||
}
|
||
if req.Answer != nil {
|
||
answerData, err := json.Marshal(req.Answer)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "答案格式错误",
|
||
})
|
||
return
|
||
}
|
||
question.AnswerData = string(answerData)
|
||
}
|
||
if req.Options != nil {
|
||
optionsBytes, err := json.Marshal(req.Options)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "选项格式错误",
|
||
})
|
||
return
|
||
}
|
||
question.OptionsData = string(optionsBytes)
|
||
}
|
||
|
||
// 保存更新
|
||
if err := db.Save(&question).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "更新题目失败",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 返回更新后的题目
|
||
dto := convertToDTO(question)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": dto,
|
||
"message": "更新成功",
|
||
})
|
||
}
|
||
|
||
// DeletePracticeQuestion 删除练习题目
|
||
func DeletePracticeQuestion(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
|
||
}
|
||
|
||
// 删除题目
|
||
if err := db.Delete(&question).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "删除题目失败",
|
||
})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "删除成功",
|
||
})
|
||
}
|
||
|
||
// ExplainQuestion 生成题目解析
|
||
func ExplainQuestion(c *gin.Context) {
|
||
var req struct {
|
||
QuestionID uint `json:"question_id" binding:"required"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "请求参数错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
db := database.GetDB()
|
||
var question models.PracticeQuestion
|
||
|
||
// 查询题目
|
||
if err := db.First(&question, req.QuestionID).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{
|
||
"success": false,
|
||
"message": "题目不存在",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 解析标准答案
|
||
var standardAnswer interface{}
|
||
if err := json.Unmarshal([]byte(question.AnswerData), &standardAnswer); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "解析题目答案失败",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 将标准答案转为字符串
|
||
var standardAnswerStr string
|
||
switch v := standardAnswer.(type) {
|
||
case string:
|
||
standardAnswerStr = v
|
||
case []interface{}:
|
||
// 多选题或填空题,将数组转为逗号分隔的字符串
|
||
parts := make([]string, len(v))
|
||
for i, item := range v {
|
||
parts[i] = fmt.Sprint(item)
|
||
}
|
||
standardAnswerStr = strings.Join(parts, ", ")
|
||
case bool:
|
||
// 判断题
|
||
if v {
|
||
standardAnswerStr = "正确"
|
||
} else {
|
||
standardAnswerStr = "错误"
|
||
}
|
||
default:
|
||
standardAnswerStr = fmt.Sprint(v)
|
||
}
|
||
|
||
// 设置SSE响应头
|
||
c.Header("Content-Type", "text/event-stream")
|
||
c.Header("Cache-Control", "no-cache")
|
||
c.Header("Connection", "keep-alive")
|
||
c.Header("X-Accel-Buffering", "no")
|
||
|
||
// 调用AI服务生成流式解析
|
||
aiService, err := services.NewAIGradingService()
|
||
if err != nil {
|
||
log.Printf("AI服务初始化失败: %v", err)
|
||
// SSE格式的错误消息
|
||
fmt.Fprintf(c.Writer, "data: [ERROR] AI服务暂时不可用,请稍后再试\n\n")
|
||
c.Writer.(http.Flusher).Flush()
|
||
return
|
||
}
|
||
|
||
err = aiService.ExplainQuestionStream(c.Writer, question.Question, standardAnswerStr, question.Type)
|
||
if err != nil {
|
||
log.Printf("AI流式解析失败: %v", err)
|
||
// SSE格式的错误消息
|
||
fmt.Fprintf(c.Writer, "data: {\"error\": \"生成解析失败\"}\n\n")
|
||
c.Writer.(http.Flusher).Flush()
|
||
}
|
||
}
|
||
|
||
// GetPracticeProgress 获取练习进度
|
||
func GetPracticeProgress(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.(int64)
|
||
if !ok {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "用户ID格式错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 获取可选的 type 参数(题目类型)
|
||
typeParam := c.Query("type")
|
||
|
||
db := database.GetDB()
|
||
|
||
// 构建查询
|
||
query := db.Where("user_id = ?", uid)
|
||
if typeParam != "" {
|
||
query = query.Where("type = ?", typeParam)
|
||
}
|
||
|
||
// 查询进度记录
|
||
var progressList []models.PracticeProgress
|
||
if err := query.Find(&progressList).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "查询进度失败",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 构建返回数据
|
||
type AnsweredQuestion struct {
|
||
QuestionID int64 `json:"question_id"`
|
||
RecordID int64 `json:"record_id"`
|
||
IsCorrect bool `json:"is_correct"`
|
||
UserAnswer interface{} `json:"user_answer"`
|
||
CorrectAnswer interface{} `json:"correct_answer"` // 正确答案
|
||
AnsweredAt string `json:"answered_at"`
|
||
// AI 评分相关(仅简答题有值)
|
||
AIScore *float64 `json:"ai_score,omitempty"`
|
||
AIFeedback *string `json:"ai_feedback,omitempty"`
|
||
AISuggestion *string `json:"ai_suggestion,omitempty"`
|
||
}
|
||
|
||
type ProgressData struct {
|
||
Type string `json:"type"`
|
||
CurrentQuestionID int64 `json:"current_question_id"`
|
||
AnsweredQuestions []AnsweredQuestion `json:"answered_questions"`
|
||
}
|
||
|
||
result := make([]ProgressData, 0, len(progressList))
|
||
|
||
for _, progress := range progressList {
|
||
// 解析 UserAnswerRecords(map[question_id]record_id)
|
||
var answerRecords map[int64]int64
|
||
if err := json.Unmarshal(progress.UserAnswerRecords, &answerRecords); err != nil {
|
||
log.Printf("解析答题记录失败: %v", err)
|
||
continue
|
||
}
|
||
|
||
// 查询每个答题记录的详细信息
|
||
answeredQuestions := make([]AnsweredQuestion, 0, len(answerRecords))
|
||
for questionID, recordID := range answerRecords {
|
||
var record models.UserAnswerRecord
|
||
if err := db.First(&record, recordID).Error; err != nil {
|
||
log.Printf("查询答题记录失败 (record_id: %d): %v", recordID, err)
|
||
continue
|
||
}
|
||
|
||
// 解析用户答案
|
||
var userAnswer interface{}
|
||
if err := json.Unmarshal(record.UserAnswer, &userAnswer); err != nil {
|
||
log.Printf("解析用户答案失败 (record_id: %d): %v", recordID, err)
|
||
continue
|
||
}
|
||
|
||
// 查询题目的正确答案
|
||
var question models.PracticeQuestion
|
||
var correctAnswer interface{}
|
||
if err := db.First(&question, questionID).Error; err == nil {
|
||
if question.AnswerData != "" {
|
||
// 解析正确答案
|
||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
|
||
log.Printf("解析正确答案失败 (question_id: %d): %v", questionID, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
answeredQuestions = append(answeredQuestions, AnsweredQuestion{
|
||
QuestionID: questionID,
|
||
RecordID: recordID,
|
||
IsCorrect: record.IsCorrect,
|
||
UserAnswer: userAnswer,
|
||
CorrectAnswer: correctAnswer, // 包含正确答案
|
||
AnsweredAt: record.AnsweredAt.Format("2006-01-02 15:04:05"),
|
||
// AI 评分字段(如果有的话)
|
||
AIScore: record.AIScore,
|
||
AIFeedback: record.AIFeedback,
|
||
AISuggestion: record.AISuggestion,
|
||
})
|
||
}
|
||
|
||
result = append(result, ProgressData{
|
||
Type: progress.Type,
|
||
CurrentQuestionID: progress.CurrentQuestionID,
|
||
AnsweredQuestions: answeredQuestions,
|
||
})
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": result,
|
||
})
|
||
}
|
||
|
||
// ClearPracticeProgress 清除练习进度
|
||
func ClearPracticeProgress(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.(int64)
|
||
if !ok {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "用户ID格式错误",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 获取可选的 type 参数
|
||
questionType := c.Query("type")
|
||
|
||
db := database.GetDB()
|
||
|
||
// 如果指定了 type,只删除该类型的进度记录;否则删除所有进度记录
|
||
query := db.Where("user_id = ?", uid)
|
||
if questionType != "" {
|
||
query = query.Where("type = ?", questionType)
|
||
log.Printf("[清除进度] 用户 %d 清除类型 %s 的进度", uid, questionType)
|
||
} else {
|
||
log.Printf("[清除进度] 用户 %d 清除所有进度", uid)
|
||
}
|
||
|
||
if err := query.Delete(&models.PracticeProgress{}).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"success": false,
|
||
"message": "清除进度失败",
|
||
})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "进度已清除",
|
||
})
|
||
}
|