package api import ( "encoding/json" "fmt" "math" "math/rand" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "eai_agentplatform/backend/internal/auth" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" "eai_agentplatform/backend/internal/repository" "eai_agentplatform/backend/internal/web" ) // 考试域仓库,包内共享(知识源摄入也会写题目,见 knowledge.go)。 var ( mistakeRepo repository.MistakeRecordRepo questionRepo repository.QuestionRepo paperRepo repository.ExamPaperRepo examRecordRepo repository.ExamRecordRepo ) func init() { mistakeRepo = repository.MistakeRecordRepo{} questionRepo = repository.QuestionRepo{} paperRepo = repository.ExamPaperRepo{} examRecordRepo = repository.ExamRecordRepo{} } // Option 题目选项 type Option struct { Key string `json:"key"` Text string `json:"text"` } type questionReq struct { Domain string `json:"domain"` CourseID *uint `json:"course_id"` Type string `json:"type"` Stem string `json:"stem"` Options []Option `json:"options"` Answer []string `json:"answer"` Explanation string `json:"explanation"` } var validDomains = map[string]bool{"company": true, "product": true, "sales": true} var validQTypes = map[string]bool{"single": true, "multiple": true, "judge": true, "essay": true} // questionView 题目出参(options/answer 反序列化为对象) func questionView(q model.Question) gin.H { var opts []Option _ = json.Unmarshal([]byte(q.Options), &opts) if opts == nil { opts = []Option{} } var ans []string _ = json.Unmarshal([]byte(q.Answer), &ans) if ans == nil { ans = []string{} } return gin.H{ "id": q.ID, "domain": q.Domain, "course_id": q.CourseID, "type": q.Type, "stem": q.Stem, "options": opts, "answer": ans, "explanation": q.Explanation, "status": q.Status, } } // ============ 题库 CRUD(管理员) ============ // ListQuestions GET /api/exam/questions?domain=&status= func ListQuestions(c *gin.Context) { // 管理端题库:status 缺省要看到全部(含已停用),按约定传 "all" items := questionRepo.List(c.Query("domain"), orDefault(c.Query("status"), "all")) out := make([]gin.H, 0, len(items)) for _, it := range items { out = append(out, questionView(it)) } web.OK(c, out) } // CreateQuestion POST /api/exam/questions (admin) func CreateQuestion(c *gin.Context) { var req questionReq if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } if !validDomains[req.Domain] || !validQTypes[req.Type] || req.Stem == "" || len(req.Answer) == 0 { web.Fail(c, web.NewBadRequest("domain/type/stem/answer 必填且合法")) return } if (req.Type == "single" || req.Type == "multiple") && len(req.Options) == 0 { web.Fail(c, web.NewBadRequest("单选题/多选题必须提供选项")) return } optsJSON, _ := json.Marshal(req.Options) ansJSON, _ := json.Marshal(req.Answer) q := model.Question{ Domain: req.Domain, CourseID: req.CourseID, Type: req.Type, Stem: req.Stem, Options: string(optsJSON), Answer: string(ansJSON), Explanation: req.Explanation, Status: "active", } if !questionRepo.Insert(&q) { web.Fail(c, web.NewBadRequest("创建题目失败")) return } web.OK(c, questionView(q)) } // UpdateQuestion PUT /api/exam/questions/{id} (admin) func UpdateQuestion(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } q, found := questionRepo.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("题目不存在")) return } var req questionReq if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } if !validDomains[req.Domain] || !validQTypes[req.Type] || req.Stem == "" || len(req.Answer) == 0 { web.Fail(c, web.NewBadRequest("domain/type/stem/answer 必填且合法")) return } if (req.Type == "single" || req.Type == "multiple") && len(req.Options) == 0 { web.Fail(c, web.NewBadRequest("单选题/多选题必须提供选项")) return } optsJSON, _ := json.Marshal(req.Options) ansJSON, _ := json.Marshal(req.Answer) q.Domain = req.Domain q.CourseID = req.CourseID q.Type = req.Type q.Stem = req.Stem q.Options = string(optsJSON) q.Answer = string(ansJSON) q.Explanation = req.Explanation if !questionRepo.Update(&q) { web.Fail(c, web.NewBadRequest("更新题目失败")) return } web.OK(c, questionView(q)) } // DeleteQuestion DELETE /api/exam/questions/{id} (admin) —— 软删除 func DeleteQuestion(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } if _, found := questionRepo.GetByID(id); !found { web.Fail(c, web.NewNotFoundError("题目不存在")) return } if !questionRepo.Delete(id) { web.Fail(c, web.NewBadRequest("停用题目失败")) return } web.OK(c, gin.H{"id": id, "status": "inactive"}) } // ============ 考试配置 CRUD(管理员) ============ // ListPapers GET /api/exam/papers func ListPapers(c *gin.Context) { web.OK(c, paperRepo.List()) } // CreatePaper POST /api/exam/papers (admin) func CreatePaper(c *gin.Context) { var p model.ExamPaper if err := c.ShouldBindJSON(&p); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } if p.Name == "" || (p.Type != "self_test" && p.Type != "formal") || p.QuestionCount <= 0 || p.TotalScore <= 0 || p.PassScore < 0 { web.Fail(c, web.NewBadRequest("考试名称/类型/题量/总分/合格线必填且合法")) return } if !validatePaperPosition(c, p.PositionID) { return } if !validatePaperBlueprintCount(c, p) { return } p.Status = "active" if !paperRepo.Insert(&p) { web.Fail(c, web.NewBadRequest("创建考试失败")) return } web.OK(c, p) } // UpdatePaper PUT /api/exam/papers/{id} (admin) func UpdatePaper(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } p, found := paperRepo.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("考试配置不存在")) return } var req model.ExamPaper if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } if req.Name == "" || (req.Type != "self_test" && req.Type != "formal") || req.QuestionCount <= 0 || req.TotalScore <= 0 { web.Fail(c, web.NewBadRequest("考试名称/类型/题量/总分必填且合法")) return } if !validatePaperPosition(c, req.PositionID) { return } if !validatePaperBlueprintCount(c, req) { return } p.Name = req.Name p.Type = req.Type p.Domain = req.Domain p.QuestionCount = req.QuestionCount p.TotalScore = req.TotalScore p.PassScore = req.PassScore p.DurationMinutes = req.DurationMinutes p.Randomize = req.Randomize p.PositionID = req.PositionID if req.Status != "" { p.Status = req.Status } if !paperRepo.Update(&p) { web.Fail(c, web.NewBadRequest("更新考试失败")) return } web.OK(c, p) } // DeletePaper DELETE /api/exam/papers/{id} (admin) —— 软删除 func DeletePaper(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } if _, found := paperRepo.GetByID(id); !found { web.Fail(c, web.NewNotFoundError("考试配置不存在")) return } if !paperRepo.Delete(id) { web.Fail(c, web.NewBadRequest("停用考试失败")) return } web.OK(c, gin.H{"id": id, "status": "inactive"}) } // ============ 学员端考试 ============ // ExamList GET /api/exam/list —— 我的考试列表(含完成状态) func ExamList(c *gin.Context) { u := middleware.CurrentUser(c) items := paperRepo.GetActive() type row struct { ID uint `json:"id"` Name string `json:"name"` Type string `json:"type"` Domain string `json:"domain"` QuestionCount int `json:"question_count"` TotalScore int `json:"total_score"` PassScore int `json:"pass_score"` DurationMinutes int `json:"duration_minutes"` Status string `json:"status"` // available / completed } out := make([]row, 0, len(items)) for _, p := range items { st := "available" if p.Type == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) { st = "completed" } out = append(out, row{ ID: p.ID, Name: p.Name, Type: p.Type, Domain: p.Domain, QuestionCount: p.QuestionCount, TotalScore: p.TotalScore, PassScore: p.PassScore, DurationMinutes: p.DurationMinutes, Status: st, }) } web.OK(c, out) } // ExamCover GET /api/exam/cover?paper_id={paper_id} func ExamCover(c *gin.Context) { id64, err := strconv.ParseUint(c.Query("paper_id"), 10, 64) if err != nil || id64 == 0 { web.Fail(c, web.NewBadRequest("无效的 id")) return } id := uint(id64) p, found := paperRepo.GetByID(id) if !found || p.Status != "active" { web.Fail(c, web.NewNotFoundError("考试不存在或已停用")) return } web.OK(c, gin.H{ "id": p.ID, "name": p.Name, "type": p.Type, "domain": p.Domain, "question_count": p.QuestionCount, "total_score": p.TotalScore, "pass_score": p.PassScore, "duration_minutes": p.DurationMinutes, }) } // positionScope 把岗位知识映射折算成抽题范围:知识域集合 + 课程集合。 // // 岗位考试的两种抽题路径(直接抽 / 按蓝图抽)共用同一份口径,避免两处各写一遍。 func positionScope(pks []model.PositionKnowledge) ([]string, []uint) { domains := make([]string, 0, len(pks)) courseIDs := make([]uint, 0, len(pks)) for _, pk := range pks { domains = append(domains, pk.Domain) if pk.CourseID != nil { courseIDs = append(courseIDs, *pk.CourseID) } } return dedupeStrings(domains), dedupeUints(courseIDs) } // pickQuestions 按 paper 抽题:岗位有蓝图优先按蓝图,否则岗位知识映射圈定题池,再否则回退 domain 抽题 func pickQuestions(p model.ExamPaper) ([]model.Question, error) { var domains []string var courseIDs []uint if p.PositionID != nil { // 岗位考试:优先蓝图,其次岗位知识映射 if bps := positionRepo.Blueprints(*p.PositionID); len(bps) > 0 { return pickQuestionsByBlueprint(p, bps) } // 岗位考试:圈定岗位应学范围 domains, courseIDs = positionScope(positionRepo.Knowledge(*p.PositionID)) } else { // 原有逻辑:按 exam_paper.domain 抽题 domains = splitDomains(p.Domain) } var qs []model.Question if !questionRepo.ActivePool(domains, courseIDs).Order("id ASC").Find(&qs) { return nil, fmt.Errorf("查询题库失败") } if len(qs) < p.QuestionCount { return nil, fmt.Errorf("题库题目不足:需要 %d,可用 %d", p.QuestionCount, len(qs)) } if p.Randomize { rand.Shuffle(len(qs), func(i, j int) { qs[i], qs[j] = qs[j], qs[i] }) } return qs[:p.QuestionCount], nil } // pickQuestionsByBlueprint 按岗位考试蓝图逐条抽题:先在岗位应学范围内,再按 (domain, type) 细分抽样。 func pickQuestionsByBlueprint(p model.ExamPaper, bps []model.PositionExamBlueprint) ([]model.Question, error) { // 圈定岗位应学范围(与 P0 岗位知识映射抽题一致) domains, courseIDs := positionScope(positionRepo.Knowledge(*p.PositionID)) base := questionRepo.ActivePool(domains, courseIDs) out := make([]model.Question, 0, p.QuestionCount) used := map[uint]bool{} for _, bp := range bps { q := base.Where("type = ?", bp.Type) if bp.Domain != "" { q = q.Where("domain = ?", bp.Domain) } var pool []model.Question if !q.Order("id ASC").Find(&pool) { return nil, fmt.Errorf("查询题库失败") } // 排除已抽中的题,避免重复 candidates := make([]model.Question, 0, len(pool)) for _, qq := range pool { if !used[qq.ID] { candidates = append(candidates, qq) } } if len(candidates) < bp.Count { domainLabel := bp.Domain if domainLabel == "" { domainLabel = "不限" } return nil, fmt.Errorf("蓝图[域 %s / 题型 %s]题目不足:需要 %d,可用 %d", domainLabel, bp.Type, bp.Count, len(candidates)) } if p.Randomize { rand.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] }) } for i := 0; i < bp.Count; i++ { out = append(out, candidates[i]) used[candidates[i].ID] = true } } if len(out) < p.QuestionCount { return nil, fmt.Errorf("蓝图抽题总数不足:需要 %d,蓝图提供 %d", p.QuestionCount, len(out)) } if len(out) > p.QuestionCount { out = out[:p.QuestionCount] } if p.Randomize { rand.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) } return out, nil } // dedupeStrings 去重并保持顺序 func dedupeStrings(in []string) []string { seen := make(map[string]bool, len(in)) out := make([]string, 0, len(in)) for _, s := range in { if s != "" && !seen[s] { seen[s] = true out = append(out, s) } } return out } // dedupeUints 去重并保持顺序 func dedupeUints(in []uint) []uint { seen := make(map[uint]bool, len(in)) out := make([]uint, 0, len(in)) for _, v := range in { if !seen[v] { seen[v] = true out = append(out, v) } } return out } // validatePaperPosition 校验岗位考试:岗位必须存在且已配置知识映射 func validatePaperPosition(c *gin.Context, positionID *uint) bool { if positionID == nil { return true } pos, found := positionRepo.GetByID(*positionID) if !found || pos.Status != "active" { web.Fail(c, web.NewBadRequest("关联岗位不存在或已停用")) return false } if positionRepo.CountKnowledge(*positionID) == 0 { web.Fail(c, web.NewBadRequest("岗位考试必须先配置岗位知识映射")) return false } return true } // validatePaperBlueprintCount 校验岗位考试蓝图题量不小于试卷题量(蓝图存在时)。 func validatePaperBlueprintCount(c *gin.Context, p model.ExamPaper) bool { if p.PositionID == nil { return true } bps := positionRepo.Blueprints(*p.PositionID) if len(bps) == 0 { return true } sum := 0 for _, bp := range bps { sum += bp.Count } if sum < p.QuestionCount { web.Fail(c, web.NewBadRequest(fmt.Sprintf("岗位考试蓝图题量(%d)小于试卷题量(%d),请调整蓝图或题量", sum, p.QuestionCount))) return false } return true } func splitDomains(s string) []string { var out []string for _, d := range strings.Split(s, ",") { d = strings.TrimSpace(d) if d != "" { out = append(out, d) } } return out } // ExamStart POST /api/exam/start —— 下发题目,返回无状态会话 func ExamStart(c *gin.Context) { u := middleware.CurrentUser(c) var req struct { PaperID uint `json:"paper_id"` } if err := c.ShouldBindJSON(&req); err != nil || req.PaperID == 0 { web.Fail(c, web.NewBadRequest("paper_id 必填")) return } p, found := paperRepo.GetByID(req.PaperID) if !found || p.Status != "active" { web.Fail(c, web.NewNotFoundError("考试不存在或已停用")) return } if p.Type == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) { web.Fail(c, web.NewConflictError("已参加过该正式考试")) return } questions, err := pickQuestions(p) if err != nil { web.Fail(c, web.NewBadRequest(err.Error())) return } ids := make([]string, len(questions)) for i, q := range questions { ids[i] = strconv.FormatUint(uint64(q.ID), 10) } now := time.Now() session, err := auth.SignClaims(jwt.MapClaims{ "uid": u.ID, "pid": p.ID, "type": p.Type, "qids": strings.Join(ids, ","), "iat": now.Unix(), "exp": now.Add(time.Duration(p.DurationMinutes+5) * time.Minute).Unix(), }, Cfg.JWTSecret) if err != nil { web.Fail(c, web.NewBadRequest("生成考试会话失败")) return } type qView struct { ID uint `json:"id"` Order int `json:"order"` Type string `json:"type"` Stem string `json:"stem"` Options []Option `json:"options"` } qs := make([]qView, 0, len(questions)) for i, q := range questions { var opts []Option _ = json.Unmarshal([]byte(q.Options), &opts) if opts == nil { opts = []Option{} } qs = append(qs, qView{ID: q.ID, Order: i + 1, Type: q.Type, Stem: q.Stem, Options: opts}) } web.OK(c, gin.H{ "session_id": session, "exam_name": p.Name, "time_limit_min": p.DurationMinutes, "questions": qs, }) } // normalizeUserAnswer 把提交的答案规整为 string 或 []string func normalizeUserAnswer(v any) any { switch t := v.(type) { case string: return t case bool: return strconv.FormatBool(t) case []string: return t case []any: s := make([]string, len(t)) for i, e := range t { s[i] = fmt.Sprintf("%v", e) } return s case nil: return nil default: return fmt.Sprintf("%v", v) } } func sortedEqual(a, b []string) bool { if len(a) != len(b) { return false } aa := append([]string(nil), a...) bb := append([]string(nil), b...) sort.Strings(aa) sort.Strings(bb) for i := range aa { if aa[i] != bb[i] { return false } } return true } // isCorrect 判分(对齐 BE02 _is_correct) func isCorrect(qtype string, correct []string, user any) bool { switch qtype { case "multiple": us, ok := user.([]string) if !ok || len(us) != len(correct) { return false } return sortedEqual(correct, us) case "judge": us, ok := user.(string) if !ok || len(correct) == 0 { return false } return strings.EqualFold(correct[0], us) default: // single us, ok := user.(string) if !ok || len(correct) == 0 { return false } return correct[0] == us } } type questionDetail struct { QuestionID uint `json:"question_id"` Stem string `json:"stem"` Type string `json:"type"` UserAnswer any `json:"user_answer"` CorrectAnswer []string `json:"correct_answer"` IsCorrect bool `json:"is_correct"` ScoreRate *float64 `json:"score_rate"` // essay 专用:0~1 得分率,其余题型为 null Comment string `json:"comment"` // essay 专用:LLM 评语 Explanation string `json:"explanation"` } // ExamSubmit POST /api/exam/submit —— 交卷判分 func ExamSubmit(c *gin.Context) { u := middleware.CurrentUser(c) var req struct { SessionID string `json:"session_id"` Answers map[string]any `json:"answers"` TimeSpentSec int `json:"time_spent_sec"` } if err := c.ShouldBindJSON(&req); err != nil || req.SessionID == "" { web.Fail(c, web.NewBadRequest("session_id 必填")) return } claims, err := auth.ParseToken(req.SessionID, Cfg.JWTSecret) if err != nil { web.Fail(c, web.NewAuthError("考试会话无效或已过期")) return } pid, _ := claims["pid"].(float64) qidsStr, _ := claims["qids"].(string) stype, _ := claims["type"].(string) sessUID, _ := claims["uid"].(float64) // 会话与当前用户绑定,防止跨用户使用他人会话交卷 if u == nil || uint(sessUID) != u.ID { web.Fail(c, web.NewForbiddenError("考试会话与当前用户不匹配")) return } // 错题重练:无试卷、即时判分,答对即掌握对应错题 if stype == "re_practice" { ids := splitIDs(qidsStr) if len(ids) == 0 { web.Fail(c, web.NewBadRequest("练习会话无题目")) return } qs := questionRepo.ListByIDs(ids) qmap := make(map[uint]model.Question, len(qs)) for _, q := range qs { qmap[q.ID] = q } correctCount := 0 scoreRateSum := 0.0 details := make([]questionDetail, 0, len(ids)) for _, qid := range ids { q, exists := qmap[qid] if !exists { continue } var correct []string _ = json.Unmarshal([]byte(q.Answer), &correct) userAns := normalizeUserAnswer(req.Answers[strconv.FormatUint(uint64(qid), 10)]) ok := false rate := 0.0 comment := "" if q.Type == "essay" { rubric := strings.Join(correct, "\n") userText := "" if s, isStr := userAns.(string); isStr { userText = s } r, cmt, err := gradeEssay(u.ID, q.Stem, rubric, userText) if err != nil { r = 0 cmt = "评分失败:" + err.Error() } rate = r comment = cmt ok = rate >= 0.6 } else { ok = isCorrect(q.Type, correct, userAns) if ok { rate = 1 } } if ok { correctCount++ } scoreRateSum += rate var scoreRate *float64 if q.Type == "essay" { v := rate scoreRate = &v } details = append(details, questionDetail{ QuestionID: qid, Stem: q.Stem, Type: q.Type, UserAnswer: userAns, CorrectAnswer: correct, IsCorrect: ok, ScoreRate: scoreRate, Comment: comment, Explanation: q.Explanation, }) touchMistakeOnPractice(u.ID, qid, q, userAns, correct, ok) } total := len(ids) score := 0 if total > 0 { score = int(math.Round(100 * scoreRateSum / float64(total))) } web.OK(c, gin.H{ "session_id": req.SessionID, "exam_name": "错题重练", "score": score, "total_score": 100, "pass_score": 0, "passed": correctCount == total, "correct_count": correctCount, "wrong_count": total - correctCount, "detail": details, }) return } p, found := paperRepo.GetByID(uint(pid)) if !found { web.Fail(c, web.NewNotFoundError("考试不存在")) return } // 正式考不可重复交卷 if stype == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) { web.Fail(c, web.NewConflictError("已参加过该正式考试")) return } ids := splitIDs(qidsStr) if len(ids) == 0 { web.Fail(c, web.NewBadRequest("考试会话无题目")) return } questions := questionRepo.ListByIDs(ids) qmap := make(map[uint]model.Question, len(questions)) for _, q := range questions { qmap[q.ID] = q } correctCount := 0 scoreRateSum := 0.0 details := make([]questionDetail, 0, len(ids)) for _, qid := range ids { q, exists := qmap[qid] if !exists { continue } var correct []string _ = json.Unmarshal([]byte(q.Answer), &correct) userAns := normalizeUserAnswer(req.Answers[strconv.FormatUint(uint64(qid), 10)]) ok := false rate := 0.0 comment := "" if q.Type == "essay" { // 简答题:LLM 按评分标准打分(0~1 得分率) rubric := strings.Join(correct, "\n") userText := "" if s, isStr := userAns.(string); isStr { userText = s } r, cmt, err := gradeEssay(u.ID, q.Stem, rubric, userText) if err != nil { // 评分失败:不静默吞掉 —— 0 分 + 可见评语(G02 失败可见、不静默回退) r = 0 cmt = "评分失败:" + err.Error() } rate = r comment = cmt ok = rate >= 0.6 } else { ok = isCorrect(q.Type, correct, userAns) if ok { rate = 1 } } if ok { correctCount++ } scoreRateSum += rate var scoreRate *float64 if q.Type == "essay" { v := rate scoreRate = &v } details = append(details, questionDetail{ QuestionID: qid, Stem: q.Stem, Type: q.Type, UserAnswer: userAns, CorrectAnswer: correct, IsCorrect: ok, ScoreRate: scoreRate, Comment: comment, Explanation: q.Explanation, }) // 错题入本(自测/正式均写入,供学员复盘) if !ok { recordMistake(u.ID, qid, q, userAns, correct, stype) } } total := len(ids) score := 0 if total > 0 { score = int(math.Round(float64(p.TotalScore) * scoreRateSum / float64(total))) } passed := score >= p.PassScore wrongCount := total - correctCount result := gin.H{ "session_id": req.SessionID, "exam_name": p.Name, "score": score, "total_score": p.TotalScore, "pass_score": p.PassScore, "passed": passed, "correct_count": correctCount, "wrong_count": wrongCount, "detail": details, } // 正式考持久化 + 通过发证 + 积分 if stype == "formal" && u != nil { detailJSON, _ := json.Marshal(gin.H{"questions": details, "time_spent_sec": req.TimeSpentSec}) rec := model.ExamRecord{ UserID: u.ID, PaperID: p.ID, ExamName: p.Name, Score: score, TotalScore: p.TotalScore, PassScore: p.PassScore, Passed: passed, CorrectCount: correctCount, WrongCount: wrongCount, DetailJSON: string(detailJSON), SubmittedAt: time.Now(), } examRecordRepo.Insert(&rec) if passed { awardPoints(u.ID, "formal_pass", ptFormalPass, "paper", p.ID) issueCertificate(u, rec) } } else if stype == "self_test" && u != nil { // 自测不落分、不存档(P03),仅计一次练习积分 awardPoints(u.ID, "self_test", ptSelfTestSubmit, "paper", p.ID) } web.OK(c, result) } func splitIDs(s string) []uint { var out []uint for _, part := range strings.Split(s, ",") { part = strings.TrimSpace(part) if part == "" { continue } if n, err := strconv.ParseUint(part, 10, 64); err == nil && n > 0 { out = append(out, uint(n)) } } return out } // ExamRecordList GET /api/exam/record?page=&size= —— 我的考试记录 func ExamRecordList(c *gin.Context) { u := middleware.CurrentUser(c) items := examRecordRepo.ListByUser(u.ID) if items == nil { items = []model.ExamRecord{} } web.OK(c, items) } // ExamRecordDetail GET /api/exam/record/{record_id} —— 记录详情回溯 func ExamRecordDetail(c *gin.Context) { u := middleware.CurrentUser(c) id, ok := parseID(c, "record_id") if !ok { return } rec, found := examRecordRepo.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("考试记录不存在")) return } if u.Role != "admin" && rec.UserID != u.ID { web.Fail(c, web.NewForbiddenError("无权查看他人考试记录")) return } var detail any _ = json.Unmarshal([]byte(rec.DetailJSON), &detail) web.OK(c, gin.H{ "id": rec.ID, "user_id": rec.UserID, "paper_id": rec.PaperID, "exam_name": rec.ExamName, "score": rec.Score, "total_score": rec.TotalScore, "pass_score": rec.PassScore, "passed": rec.Passed, "correct_count": rec.CorrectCount, "wrong_count": rec.WrongCount, "detail": detail, "submitted_at": rec.SubmittedAt, }) } // ============ 错题本(学员自助) ============ // mistakePayload 组装错题本记录:作答与正确答案以 JSON 落库, // 与 mistakeView 的反序列化、MyMistakes 的出参形状对齐。 func mistakePayload(userID, questionID uint, source string, q model.Question, userAns any, correct []string) model.MistakeRecord { userAnsJSON, _ := json.Marshal(userAns) correctJSON, _ := json.Marshal(correct) return model.MistakeRecord{ UserID: userID, QuestionID: questionID, Source: source, QuestionType: q.Type, QuestionStem: q.Stem, UserAnswer: string(userAnsJSON), CorrectAnswer: string(correctJSON), Explanation: q.Explanation, } } // recordMistake 答错入本:按 (user_id, question_id, source) 去重,再次答错则更新并重置为未掌握。 func recordMistake(userID, questionID uint, q model.Question, userAns any, correct []string, source string) { mistakeRepo.RecordWrong(mistakePayload(userID, questionID, source, q, userAns, correct)) } // mistakeView 错题出参(answer 反序列化,便于前端直接展示) type mistakeView struct { ID uint `json:"id"` QuestionID uint `json:"question_id"` Source string `json:"source"` QuestionType string `json:"question_type"` QuestionStem string `json:"question_stem"` UserAnswer any `json:"user_answer"` CorrectAnswer []string `json:"correct_answer"` Explanation string `json:"explanation"` Resolved bool `json:"resolved"` UpdatedAt time.Time `json:"updated_at"` } // MyMistakes GET /api/exam/mistakes —— 我的错题本 func MyMistakes(c *gin.Context) { u := middleware.CurrentUser(c) items := mistakeRepo.ListByUser(u.ID) out := make([]mistakeView, 0, len(items)) for _, it := range items { var userAns any _ = json.Unmarshal([]byte(it.UserAnswer), &userAns) var correct []string _ = json.Unmarshal([]byte(it.CorrectAnswer), &correct) if correct == nil { correct = []string{} } out = append(out, mistakeView{ ID: it.ID, QuestionID: it.QuestionID, Source: it.Source, QuestionType: it.QuestionType, QuestionStem: it.QuestionStem, UserAnswer: userAns, CorrectAnswer: correct, Explanation: it.Explanation, Resolved: it.Resolved, UpdatedAt: it.UpdatedAt, }) } web.OK(c, out) } // ResolveMistake PUT /api/exam/mistakes/{id}/resolve —— 标记 / 取消「已掌握」 func ResolveMistake(c *gin.Context) { u := middleware.CurrentUser(c) id, ok := parseID(c, "id") if !ok { return } rec, found := mistakeRepo.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("错题记录不存在")) return } if rec.UserID != u.ID { web.Fail(c, web.NewForbiddenError("无权操作他人错题")) return } var req struct { Resolved *bool `json:"resolved"` } _ = c.ShouldBindJSON(&req) target := true if req.Resolved != nil { target = *req.Resolved } wasResolved := rec.Resolved rec.Resolved = target if !mistakeRepo.Update(&rec) { web.Fail(c, web.NewBadRequest("更新错题状态失败")) return } // 仅在「未掌握 → 已掌握」时加分,避免反复切换刷分 if !wasResolved && target { awardPoints(u.ID, "mistake_resolved", ptMistakeResolved, "mistake", rec.ID) } web.OK(c, gin.H{"id": rec.ID, "resolved": rec.Resolved}) } // MistakePractice POST /api/exam/mistakes/practice —— 错题重练:从错题生成练习会话 func MistakePractice(c *gin.Context) { u := middleware.CurrentUser(c) if u == nil { web.Fail(c, web.NewAuthError("未登录")) return } var req struct { Source string `json:"source"` OnlyUnresolved bool `json:"only_unresolved"` } _ = c.ShouldBindJSON(&req) recs := mistakeRepo.ListForPractice(u.ID, req.Source, req.OnlyUnresolved) if len(recs) == 0 { web.Fail(c, web.NewBadRequest("暂无可重练的错题")) return } ids := make([]uint, 0, len(recs)) for _, r := range recs { ids = append(ids, r.QuestionID) } ids = dedupeUints(ids) // 剔除已停用/删除的题目,仅下发可作答题目 questions := questionRepo.ListActiveByIDs(ids) if len(questions) == 0 { web.Fail(c, web.NewBadRequest("错题对应题目已失效,暂无可重练题目")) return } qidStrs := make([]string, len(questions)) for i, q := range questions { qidStrs[i] = strconv.FormatUint(uint64(q.ID), 10) } now := time.Now() session, err := auth.SignClaims(jwt.MapClaims{ "uid": u.ID, "type": "re_practice", "qids": strings.Join(qidStrs, ","), "iat": now.Unix(), "exp": now.Add(2 * time.Hour).Unix(), }, Cfg.JWTSecret) if err != nil { web.Fail(c, web.NewBadRequest("生成练习会话失败")) return } type qView struct { ID uint `json:"id"` Order int `json:"order"` Type string `json:"type"` Stem string `json:"stem"` Options []Option `json:"options"` } qs := make([]qView, 0, len(questions)) for i, q := range questions { var opts []Option _ = json.Unmarshal([]byte(q.Options), &opts) if opts == nil { opts = []Option{} } qs = append(qs, qView{ID: q.ID, Order: i + 1, Type: q.Type, Stem: q.Stem, Options: opts}) } web.OK(c, gin.H{ "session_id": session, "exam_name": "错题重练", "time_limit_min": 0, "questions": qs, }) } // touchMistakeOnPractice 错题重练判分后同步错题状态:答对置为已掌握(加积分),答错重置为未掌握。 func touchMistakeOnPractice(userID, questionID uint, q model.Question, userAns any, correct []string, resolved bool) { payload := mistakePayload(userID, questionID, "", q, userAns, correct) flipped, touched := mistakeRepo.TouchOnPractice(payload, resolved) if !touched { // 防御性兜底:理论上重练题目均来自错题本,这里创建一条 payload.Source = "re_practice" mistakeRepo.RecordWrong(payload) return } // 仅在「未掌握 → 已掌握」时加分,避免反复重练刷分 for _, id := range flipped { awardPoints(userID, "mistake_resolved", ptMistakeResolved, "mistake", id) } }