Files
eaiadminandClaude Code e8aedd50d2 refactor: 后端仓库层更名为数据访问层(internal/repository → internal/dal)
「仓库层」是 repository 的直译,中文里与「代码仓库 / git 仓库」同词,
而这一层做的事就是数据访问。名字改成它实际在做的事。

改名口径(纯机械替换,无逻辑改动):
- 包:internal/repository → internal/dal(package repository → package dal)
- 类型:XxxRepo → XxxDAO(TaskRecordDAO / SpecialistDAO / PositionDAO …)
- 变量:xxxRepo → xxxDAO
- import 路径、包限定符、日志前缀 [repository] → [dal] 同步
- 注释里的「仓库层」→「数据访问层」;core.go 包注释补上 DAL/DAO 全称

命名规范补登(AR09 是命名问题的最高依据,改了名就得回去登记):
- AR09 §3.1 术语表新增「数据访问层 dal / DAO」一行
- AR09 §5.6 缩写表新增 DAO / dal —— 原文是「只有下表内的缩写允许使用」,
  不登记就是自己破自己的规矩
- PROJECT_STATE.md 新增 D27 记录本次更名决策

验证:全部在 db 副本上做,生产库 data/eai_agentplatform.db 未触碰。
- 等价性对照:拿 HEAD 源码 + 仅改名 造出第二棵树,两棵树各自起
  httptest 服务跑同一份探针(60 个 GET + 13 个写/回读,覆盖专员/技能/应用/
  任务/交付物/项目/岗位/考试/知识/积分/管理端只读等),逐端点比对响应体:
  73 项里 52 项字节完全一致、21 项仅运行期时间戳不同、内容差异 0。
- 探针非空:往改名后的树注入「SpecialistDAO.List 限 3 条」变异,
  /api/specialists 立刻被抓出 —— 证明上面那个 0 不是没测到。
- 暂存区自洽:把索引整个导出成源码树,go build / go vet / go test ./... 全绿。
- gofmt:因 import 排序变化而错位的 19 个文件已修;另 2 个文件(skill_definition.go、
  seed.go)的格式问题是工作区里别人的在制品带来的,未替其改动。

未纳入本次提交:工作区里正在进行中的「文生语音技能 + 技能展示色/交互卡」
(tts_handlers.go、text_to_speech/manifest.go、skillCatalog.js 等),
以及 router.go / skill_definition.go / seed.go 三个文件里属于该在制品的改动 ——
这三个文件只把「改名那一版」放进索引,工作区原样保留。

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

1116 lines
31 KiB
Go
Raw Permalink 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/dal"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/web"
)
// 考试域仓库,包内共享(知识源摄入也会写题目,见 knowledge.go)。
var (
mistakeDAO dal.MistakeRecordDAO
questionDAO dal.QuestionDAO
paperDAO dal.ExamPaperDAO
examRecordDAO dal.ExamRecordDAO
)
func init() {
mistakeDAO = dal.MistakeRecordDAO{}
questionDAO = dal.QuestionDAO{}
paperDAO = dal.ExamPaperDAO{}
examRecordDAO = dal.ExamRecordDAO{}
}
// 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 := questionDAO.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 !questionDAO.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 := questionDAO.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 !questionDAO.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 := questionDAO.GetByID(id); !found {
web.Fail(c, web.NewNotFoundError("题目不存在"))
return
}
if !questionDAO.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, paperDAO.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 !paperDAO.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 := paperDAO.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 !paperDAO.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 := paperDAO.GetByID(id); !found {
web.Fail(c, web.NewNotFoundError("考试配置不存在"))
return
}
if !paperDAO.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 := paperDAO.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 && examRecordDAO.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 := paperDAO.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 := positionDAO.Blueprints(*p.PositionID); len(bps) > 0 {
return pickQuestionsByBlueprint(p, bps)
}
// 岗位考试:圈定岗位应学范围
domains, courseIDs = positionScope(positionDAO.Knowledge(*p.PositionID))
} else {
// 原有逻辑:按 exam_paper.domain 抽题
domains = splitDomains(p.Domain)
}
var qs []model.Question
if !questionDAO.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(positionDAO.Knowledge(*p.PositionID))
base := questionDAO.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 := positionDAO.GetByID(*positionID)
if !found || pos.Status != "active" {
web.Fail(c, web.NewBadRequest("关联岗位不存在或已停用"))
return false
}
if positionDAO.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 := positionDAO.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 := paperDAO.GetByID(req.PaperID)
if !found || p.Status != "active" {
web.Fail(c, web.NewNotFoundError("考试不存在或已停用"))
return
}
if p.Type == "formal" && u != nil && examRecordDAO.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 := questionDAO.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 := paperDAO.GetByID(uint(pid))
if !found {
web.Fail(c, web.NewNotFoundError("考试不存在"))
return
}
// 正式考不可重复交卷
if stype == "formal" && u != nil && examRecordDAO.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 := questionDAO.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(),
}
examRecordDAO.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 := examRecordDAO.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 := examRecordDAO.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) {
mistakeDAO.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 := mistakeDAO.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 := mistakeDAO.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 !mistakeDAO.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 := mistakeDAO.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 := questionDAO.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 := mistakeDAO.TouchOnPractice(payload, resolved)
if !touched {
// 防御性兜底:理论上重练题目均来自错题本,这里创建一条
payload.Source = "re_practice"
mistakeDAO.RecordWrong(payload)
return
}
// 仅在「未掌握 → 已掌握」时加分,避免反复重练刷分
for _, id := range flipped {
awardPoints(userID, "mistake_resolved", ptMistakeResolved, "mistake", id)
}
}