Files
pj0235-eai_agentplatform/eai_ap_app/backend-go/internal/api/exam.go
T
eaiadmin 4e8817d768 init: 数字员工平台初始代码
包含前端(Vue3 + VueFlow 画布)、后端(Go)、文档体系。
- 工作台画布:节点拖放、连线模式、右键菜单、AI 助手
- 后端:连接器 API、专员种子数据
- 导航:左侧导航、工坊、市场、控制台
2026-08-18 20:19:58 +08:00

1213 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"fmt"
"math"
"math/rand"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/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)
}
}
}