把 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>
1116 lines
31 KiB
Go
1116 lines
31 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/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)
|
||
}
|
||
}
|