Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/api/exam.go
T
eaiadminandClaude Code 19cf6fb5f2 refactor: 后端仓库层收口(A3:考试/部门/学习/证书/档案/公司介绍)
把 A 档剩余对象的 api 裸查询全部收进仓库,api 层裸 store.DB 从 182 降到 99,
剩下的全是 B 档(任务/项目/笔记等尚无仓库的对象)与 C 档(报表聚合查询)。

按对象补齐的仓库方法:
- QuestionRepo:List 重写(status 档位改为显式 all/空/具体值)、
  ListByIDs(判分不过滤 status)、ListActiveByIDs(下发剔除停用)、
  ActivePool(抽题口径,主流程与蓝图共用)、DomainMap(能力雷达反查域)
- ExamPaperRepo.List;ExamRecordRepo.ListByUserChronological(趋势图正序)
- DepartmentRepo.ListByStatus / CountByName
- UserRepo.ListEmployees / CountActiveByDepartment / RenameDepartment
- LearningProgressRepo.ListAll;CertificateRepo.ListAll / GetByExamRecord
- MediaFileRepo.ListApprovedByBindType

顺带修掉两处隐患:
- CertificateRepo.GetByUserAndExam 按不存在的 exam_id 列查,一调即 SQL 报错,
  换成按 exam_record_id 的 GetByExamRecord(颁发幂等本来就该按考试记录)
- exam.go 与 system.go 各声明了一个 ExamRecordRepo 变量,同一个仓库两份变量
  会导致测试覆写时行为分叉,统一为一个 examRecordRepo

考证来源(趋势图正序 vs 列表页倒序)与抽题口径(岗位蓝图/岗位知识映射两条路径)
各自抽成单一出处,避免两处手写漂移。聚合与百分比计算仍留在 handler,未搬进仓库。

验证:tmp 验证程序走真实路由 + 真实 HTTP,对 DB 副本跑 111 项断言全绿
(覆盖停用题仍可判分、错题重练剔除停用题、趋势正序、改名同步 user.department
且 updated_at 仍刷新、未通过的正式考不发证书、公司介绍只出 approved 素材等)。
另对其中 8 条关键语义做了变异测试:逐条注入反向实现,确认断言确实会失败,
并因此发现并修掉验证程序自身一处漏洞(写语句的约束错误只在 rows.Err() 浮出,
原先未检查,导致一条断言实为空断言)。

原始 data/eai_agentplatform.db 全程未触碰,md5 复核一致。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 01:41:33 +08:00

1116 lines
31 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"
"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)
}
}