Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/api/exam.go
T
eaiadminandClaude Code 14f303459e refactor: 后端仓库层收口(A1:课程/产品/素材)+ 收进工作区既有对象化重构
本提交含两部分。第一部分是本轮工作;第二部分是此前一直留在工作区、
从未提交的对象化重构,与第一部分在文件上互相咬合(internal/repository
整个包都是未跟踪状态,且 api 层已有文件引用它),无法拆成两个可编译的提交。

一、仓库层收口 A1 批(本轮工作)

把 api 层手写的 store.DB 查询收进具名仓库方法,只给真正获益的对象做方法,
不机械包裹全量。本批迁移 22 处裸查询(courses.go 9 / media.go 12 / products.go 1),
新增方法:

- MediaFileRepo.ListByBind / ListForAudit / MarkExtracted
- KnowledgeChunkRepo.CountByMediaFile
- ProductRepo.GetVisibleByID

两条业务口径改由仓库单点持有,避免各处手写漂移:
「只有 approved 素材出现在课程详情」与「已停用产品不在课程详情露出」。

修掉两个真实缺陷:
- ProductRepo.GetByID 缺 Where 条件。此前 GET /api/products/{id} 对任意 id 都返回
  第一条产品、对不存在的 id 返回 200,且 PUT /api/products/{id} 会覆盖第一条产品
  —— 数据损坏级。全仓扫描确认这是唯一一处同型写法。
- ProductRepo.Delete 写 status="deleted",而 DELETE 处理器文档与回包都声称
  "inactive",接口在说谎;管理员用 status=all 拉列表会看到前端不认识的状态。
  已对齐为 inactive(与 CourseRepo.Delete 一致)。

删除 8 个零调用且列名不存在的死方法(一调即 SQL 报错):
- media_file 上的 file_path / file_type / approval_status 三列并不存在,
  GetByPath / ListByType / UpdateStatus 全废
- knowledge_chunk 上的 space_id 列不存在(模型早已改为 knowledge_space_key),
  List / Total / ListBySpaceIDs / DeleteBySpace / SearchByVector 全废
取舍边界:能对当前 schema 跑通的死方法保留,跑不通的删或修。

CourseRepo.List 补齐 status=all 档(此前传给它会当作 status='all' 过滤出空列表)。
该方法此前零调用,现与产品列表语义对齐。

验证:go build ./... 与 go test ./... 全绿;另用真实 HTTP 请求验证 34 项
(课程 17 / 产品 3 / 素材 14),跑在数据库副本与独立 KB_DATA_DIR 上,
含 multipart 真上传 → 审批 → pdftotext 提取 → 分片入库的完整链路。

二、此前未提交的对象化重构(非本轮工作)

- 新增 internal/repository 仓库层、connectors、skills、specialists、xapps、jsonutil,
  model/task_record|task_run|task_artifact、api/task_runtime|action_definition|chat_message
- 删除 api/app_definition、connectors、my_app_center、notification、office_skill、
  export_docx|pptx|xlsx、official_account_* 等,随 XApp/Skill/Specialist/Connector
  可插拔打包方向(AR10/AR11)调整
- 资产目录归位:backend-go/knowledge_source → assets/knowledge/source、
  training_materials → assets/training/materials;README 内相对路径同步加深两级;
  deploy env 补 ASSET_ROOT_DIR 并改 KNOWLEDGE_SOURCE_DIR / TRAINING_MATERIALS_DIR
- 前端新增 skills/ specialists/ connectors/ xapps/ 目录与对应页面

验证:前端 npm run build 通过(7.26s)。

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

1163 lines
33 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/store"
"eai_agentplatform/backend/internal/web"
)
var mistakeRepo repository.MistakeRecordRepo
// 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
}
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 && 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)
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 && 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
}
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 && 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
}
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)
}
} 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
}
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,
})
}
// ============ 错题本(学员自助) ============
// 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)
// 剔除已停用/删除的题目,仅下发可作答题目
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) {
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)
}
}