- 后端:新增 /api/audio/transcribe 接口,调用 Ollama whisper 进行语音识别 - 前端:新增 AudioTranscribePage.vue 页面,支持 MP3/WAV/M4A/OGG/FLAC 等格式 - 注册路由、工具卡片、智能助手欢迎语更新 Co-Authored-By: Claude Code <noreply@anthropic.com>
1213 lines
34 KiB
Go
1213 lines
34 KiB
Go
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/store"
|
||
"eai_agentplatform/backend/internal/web"
|
||
)
|
||
|
||
// 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) {
|
||
q := store.DB.Model(&model.Question{})
|
||
if d := c.Query("domain"); d != "" {
|
||
q = q.Where("domain = ?", d)
|
||
}
|
||
if s := c.Query("status"); s != "" {
|
||
q = q.Where("status = ?", s)
|
||
}
|
||
var items []model.Question
|
||
if err := q.Order("id ASC").Find(&items).Error; err != nil {
|
||
web.Fail(c, web.NewBadRequest("查询题目失败"))
|
||
return
|
||
}
|
||
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 err := store.DB.Create(&q).Error; err != nil {
|
||
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
|
||
}
|
||
var q model.Question
|
||
if err := store.DB.First(&q, id).Error; err != nil {
|
||
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 err := store.DB.Save(&q).Error; err != nil {
|
||
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
|
||
}
|
||
var q model.Question
|
||
if err := store.DB.First(&q, id).Error; err != nil {
|
||
web.Fail(c, web.NewNotFoundError("题目不存在"))
|
||
return
|
||
}
|
||
if err := store.DB.Model(&q).Update("status", "inactive").Error; err != nil {
|
||
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) {
|
||
var items []model.ExamPaper
|
||
if err := store.DB.Order("id ASC").Find(&items).Error; err != nil {
|
||
web.Fail(c, web.NewBadRequest("查询考试配置失败"))
|
||
return
|
||
}
|
||
web.OK(c, items)
|
||
}
|
||
|
||
// 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 err := store.DB.Create(&p).Error; err != nil {
|
||
web.Fail(c, web.NewBadRequest("创建考试失败"))
|
||
return
|
||
}
|
||
// 发布正式考试:通知全员(提醒及时参加)
|
||
if p.Type == "formal" {
|
||
notifyAllEmployees("exam_publish", "新考试发布",
|
||
fmt.Sprintf("管理员发布了正式考试「%s」,请及时参加", p.Name), "/exam/formal")
|
||
}
|
||
web.OK(c, p)
|
||
}
|
||
|
||
// UpdatePaper PUT /api/exam/papers/{id} (admin)
|
||
func UpdatePaper(c *gin.Context) {
|
||
id, ok := parseID(c, "id")
|
||
if !ok {
|
||
return
|
||
}
|
||
var p model.ExamPaper
|
||
if err := store.DB.First(&p, id).Error; err != nil {
|
||
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 err := store.DB.Save(&p).Error; err != nil {
|
||
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
|
||
}
|
||
var p model.ExamPaper
|
||
if err := store.DB.First(&p, id).Error; err != nil {
|
||
web.Fail(c, web.NewNotFoundError("考试配置不存在"))
|
||
return
|
||
}
|
||
if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil {
|
||
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)
|
||
var items []model.ExamPaper
|
||
store.DB.Where("status = ?", "active").Order("id ASC").Find(&items)
|
||
|
||
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 {
|
||
var n int64
|
||
store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n)
|
||
if n > 0 {
|
||
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?id={paperId}
|
||
func ExamCover(c *gin.Context) {
|
||
id64, err := strconv.ParseUint(c.Query("id"), 10, 64)
|
||
if err != nil || id64 == 0 {
|
||
web.Fail(c, web.NewBadRequest("无效的 id"))
|
||
return
|
||
}
|
||
id := uint(id64)
|
||
var p model.ExamPaper
|
||
if err := store.DB.First(&p, id).Error; err != nil || 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,
|
||
})
|
||
}
|
||
|
||
// pickQuestions 按 paper 抽题:岗位有蓝图优先按蓝图,否则岗位知识映射圈定题池,再否则回退 domain 抽题
|
||
func pickQuestions(p model.ExamPaper) ([]model.Question, error) {
|
||
if p.PositionID != nil {
|
||
// 岗位考试:优先蓝图,其次岗位知识映射
|
||
var bps []model.PositionExamBlueprint
|
||
if err := store.DB.Where("position_id = ?", *p.PositionID).Order("id ASC").Find(&bps).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if len(bps) > 0 {
|
||
return pickQuestionsByBlueprint(p, bps)
|
||
}
|
||
}
|
||
|
||
pool := store.DB.Where("status = ?", "active")
|
||
|
||
if p.PositionID != nil {
|
||
// 岗位考试:圈定岗位应学范围
|
||
var pks []model.PositionKnowledge
|
||
if err := store.DB.Where("position_id = ?", *p.PositionID).Find(&pks).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
domains = dedupeStrings(domains)
|
||
courseIDs = dedupeUints(courseIDs)
|
||
if len(domains) > 0 {
|
||
pool = pool.Where("domain IN ?", domains)
|
||
}
|
||
if len(courseIDs) > 0 {
|
||
// 岗位明确绑定了课程:题目要么属于这些课程,要么未绑定课程但域匹配。
|
||
// 如需以课程为强约束,可改为严格匹配 `course_id IN ?`。
|
||
pool = pool.Where("course_id IN ? OR course_id IS NULL", courseIDs)
|
||
}
|
||
} else {
|
||
// 原有逻辑:按 exam_paper.domain 抽题
|
||
domains := splitDomains(p.Domain)
|
||
if len(domains) > 0 {
|
||
pool = pool.Where("domain IN ?", domains)
|
||
}
|
||
}
|
||
|
||
var qs []model.Question
|
||
if err := pool.Order("id ASC").Find(&qs).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
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 岗位知识映射抽题一致)
|
||
base := store.DB.Where("status = ?", "active")
|
||
var pks []model.PositionKnowledge
|
||
if err := store.DB.Where("position_id = ?", *p.PositionID).Find(&pks).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
domains = dedupeStrings(domains)
|
||
courseIDs = dedupeUints(courseIDs)
|
||
if len(domains) > 0 {
|
||
base = base.Where("domain IN ?", domains)
|
||
}
|
||
if len(courseIDs) > 0 {
|
||
base = base.Where("course_id IN ? OR course_id IS NULL", 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 err := q.Order("id ASC").Find(&pool).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
// 排除已抽中的题,避免重复
|
||
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
|
||
}
|
||
var pos model.Position
|
||
if err := store.DB.First(&pos, *positionID).Error; err != nil || pos.Status != "active" {
|
||
web.Fail(c, web.NewBadRequest("关联岗位不存在或已停用"))
|
||
return false
|
||
}
|
||
var n int64
|
||
store.DB.Model(&model.PositionKnowledge{}).Where("position_id = ?", *positionID).Count(&n)
|
||
if n == 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
|
||
}
|
||
var bps []model.PositionExamBlueprint
|
||
store.DB.Where("position_id = ?", *p.PositionID).Find(&bps)
|
||
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
|
||
}
|
||
var p model.ExamPaper
|
||
if err := store.DB.First(&p, req.PaperID).Error; err != nil || p.Status != "active" {
|
||
web.Fail(c, web.NewNotFoundError("考试不存在或已停用"))
|
||
return
|
||
}
|
||
if p.Type == "formal" && u != nil {
|
||
var n int64
|
||
store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n)
|
||
if n > 0 {
|
||
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
|
||
}
|
||
var qs []model.Question
|
||
store.DB.Where("id IN ?", ids).Find(&qs)
|
||
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
|
||
}
|
||
|
||
var p model.ExamPaper
|
||
if err := store.DB.First(&p, uint(pid)).Error; err != nil {
|
||
web.Fail(c, web.NewNotFoundError("考试不存在"))
|
||
return
|
||
}
|
||
|
||
// 正式考不可重复交卷
|
||
if stype == "formal" && u != nil {
|
||
var n int64
|
||
store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n)
|
||
if n > 0 {
|
||
web.Fail(c, web.NewConflictError("已参加过该正式考试"))
|
||
return
|
||
}
|
||
}
|
||
|
||
ids := splitIDs(qidsStr)
|
||
if len(ids) == 0 {
|
||
web.Fail(c, web.NewBadRequest("考试会话无题目"))
|
||
return
|
||
}
|
||
var questions []model.Question
|
||
store.DB.Where("id IN ?", ids).Find(&questions)
|
||
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(),
|
||
}
|
||
store.DB.Create(&rec)
|
||
if passed {
|
||
awardPoints(u.ID, "formal_pass", ptFormalPass, "paper", p.ID)
|
||
issueCertificate(u, rec)
|
||
notifyUser(u.ID, "exam_pass", "考试通过",
|
||
fmt.Sprintf("恭喜通过「%s」考试,成绩 %d 分,已颁发合格证书", p.Name, score),
|
||
"/exam/my-certificates")
|
||
}
|
||
} 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)
|
||
q := store.DB.Model(&model.ExamRecord{}).Where("user_id = ?", u.ID)
|
||
var items []model.ExamRecord
|
||
if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil {
|
||
web.Fail(c, web.NewBadRequest("查询考试记录失败"))
|
||
return
|
||
}
|
||
web.OK(c, items)
|
||
}
|
||
|
||
// ExamRecordDetail GET /api/exam/record/{recordId} —— 记录详情回溯
|
||
func ExamRecordDetail(c *gin.Context) {
|
||
u := middleware.CurrentUser(c)
|
||
id, ok := parseID(c, "recordId")
|
||
if !ok {
|
||
return
|
||
}
|
||
var rec model.ExamRecord
|
||
if err := store.DB.First(&rec, id).Error; err != nil {
|
||
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,
|
||
})
|
||
}
|
||
|
||
// ============ 错题本(学员自助) ============
|
||
|
||
// recordMistake 答错入本:按 (user_id, question_id, source) 去重,再次答错则更新并重置为未掌握。
|
||
func recordMistake(userID, questionID uint, q model.Question, userAns any, correct []string, source string) {
|
||
userAnsJSON, _ := json.Marshal(userAns)
|
||
correctJSON, _ := json.Marshal(correct)
|
||
var rec model.MistakeRecord
|
||
err := store.DB.Where("user_id = ? AND question_id = ? AND source = ?", userID, questionID, source).First(&rec).Error
|
||
if err != nil {
|
||
store.DB.Create(&model.MistakeRecord{
|
||
UserID: userID, QuestionID: questionID, Source: source,
|
||
QuestionType: q.Type, QuestionStem: q.Stem,
|
||
UserAnswer: string(userAnsJSON), CorrectAnswer: string(correctJSON),
|
||
Explanation: q.Explanation, Resolved: false,
|
||
})
|
||
return
|
||
}
|
||
rec.QuestionType = q.Type
|
||
rec.QuestionStem = q.Stem
|
||
rec.UserAnswer = string(userAnsJSON)
|
||
rec.CorrectAnswer = string(correctJSON)
|
||
rec.Explanation = q.Explanation
|
||
rec.Resolved = false
|
||
store.DB.Save(&rec)
|
||
}
|
||
|
||
// 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)
|
||
var items []model.MistakeRecord
|
||
if err := store.DB.Where("user_id = ?", u.ID).Order("updated_at DESC").Find(&items).Error; err != nil {
|
||
web.Fail(c, web.NewBadRequest("查询错题本失败"))
|
||
return
|
||
}
|
||
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
|
||
}
|
||
var rec model.MistakeRecord
|
||
if err := store.DB.First(&rec, id).Error; err != nil {
|
||
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 err := store.DB.Save(&rec).Error; err != nil {
|
||
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)
|
||
|
||
q := store.DB.Where("user_id = ?", u.ID)
|
||
if req.Source != "" {
|
||
q = q.Where("source = ?", req.Source)
|
||
}
|
||
if req.OnlyUnresolved {
|
||
q = q.Where("resolved = ?", false)
|
||
}
|
||
var recs []model.MistakeRecord
|
||
if err := q.Order("updated_at DESC").Find(&recs).Error; err != nil {
|
||
web.Fail(c, web.NewBadRequest("查询错题失败"))
|
||
return
|
||
}
|
||
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)
|
||
|
||
// 剔除已停用/删除的题目,仅下发可作答题目
|
||
var questions []model.Question
|
||
store.DB.Where("id IN ? AND status = ?", ids, "active").Find(&questions)
|
||
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) {
|
||
var recs []model.MistakeRecord
|
||
store.DB.Where("user_id = ? AND question_id = ?", userID, questionID).Find(&recs)
|
||
if len(recs) == 0 {
|
||
// 防御性兜底:理论上重练题目均来自错题本,这里创建一条
|
||
recordMistake(userID, questionID, q, userAns, correct, "re_practice")
|
||
return
|
||
}
|
||
userAnsJSON, _ := json.Marshal(userAns)
|
||
correctJSON, _ := json.Marshal(correct)
|
||
for i := range recs {
|
||
was := recs[i].Resolved
|
||
recs[i].Resolved = resolved
|
||
recs[i].QuestionType = q.Type
|
||
recs[i].QuestionStem = q.Stem
|
||
recs[i].UserAnswer = string(userAnsJSON)
|
||
recs[i].CorrectAnswer = string(correctJSON)
|
||
recs[i].Explanation = q.Explanation
|
||
store.DB.Save(&recs[i])
|
||
if resolved && !was {
|
||
awardPoints(userID, "mistake_resolved", ptMistakeResolved, "mistake", recs[i].ID)
|
||
}
|
||
}
|
||
}
|