refactor: 后端仓库层收口(A3:考试/部门/学习/证书/档案/公司介绍)
把 A 档剩余对象的 api 裸查询全部收进仓库,api 层裸 store.DB 从 182 降到 99, 剩下的全是 B 档(任务/项目/笔记等尚无仓库的对象)与 C 档(报表聚合查询)。 按对象补齐的仓库方法: - QuestionRepo:List 重写(status 档位改为显式 all/空/具体值)、 ListByIDs(判分不过滤 status)、ListActiveByIDs(下发剔除停用)、 ActivePool(抽题口径,主流程与蓝图共用)、DomainMap(能力雷达反查域) - ExamPaperRepo.List;ExamRecordRepo.ListByUserChronological(趋势图正序) - DepartmentRepo.ListByStatus / CountByName - UserRepo.ListEmployees / CountActiveByDepartment / RenameDepartment - LearningProgressRepo.ListAll;CertificateRepo.ListAll / GetByExamRecord - MediaFileRepo.ListApprovedByBindType 顺带修掉两处隐患: - CertificateRepo.GetByUserAndExam 按不存在的 exam_id 列查,一调即 SQL 报错, 换成按 exam_record_id 的 GetByExamRecord(颁发幂等本来就该按考试记录) - exam.go 与 system.go 各声明了一个 ExamRecordRepo 变量,同一个仓库两份变量 会导致测试覆写时行为分叉,统一为一个 examRecordRepo 考证来源(趋势图正序 vs 列表页倒序)与抽题口径(岗位蓝图/岗位知识映射两条路径) 各自抽成单一出处,避免两处手写漂移。聚合与百分比计算仍留在 handler,未搬进仓库。 验证:tmp 验证程序走真实路由 + 真实 HTTP,对 DB 副本跑 111 项断言全绿 (覆盖停用题仍可判分、错题重练剔除停用题、趋势正序、改名同步 user.department 且 updated_at 仍刷新、未通过的正式考不发证书、公司介绍只出 approved 素材等)。 另对其中 8 条关键语义做了变异测试:逐条注入反向实现,确认断言确实会失败, 并因此发现并修掉验证程序自身一处漏洞(写语句的约束错误只在 rows.Err() 浮出, 原先未检查,导致一条断言实为空断言)。 原始 data/eai_agentplatform.db 全程未触碰,md5 复核一致。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -17,11 +17,23 @@ import (
|
||||
"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
|
||||
// 考试域仓库,包内共享(知识源摄入也会写题目,见 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 {
|
||||
@@ -71,18 +83,8 @@ func questionView(q model.Question) gin.H {
|
||||
|
||||
// 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
|
||||
}
|
||||
// 管理端题库: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))
|
||||
@@ -117,7 +119,7 @@ func CreateQuestion(c *gin.Context) {
|
||||
Explanation: req.Explanation,
|
||||
Status: "active",
|
||||
}
|
||||
if err := store.DB.Create(&q).Error; err != nil {
|
||||
if !questionRepo.Insert(&q) {
|
||||
web.Fail(c, web.NewBadRequest("创建题目失败"))
|
||||
return
|
||||
}
|
||||
@@ -130,8 +132,8 @@ func UpdateQuestion(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var q model.Question
|
||||
if err := store.DB.First(&q, id).Error; err != nil {
|
||||
q, found := questionRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("题目不存在"))
|
||||
return
|
||||
}
|
||||
@@ -157,7 +159,7 @@ func UpdateQuestion(c *gin.Context) {
|
||||
q.Options = string(optsJSON)
|
||||
q.Answer = string(ansJSON)
|
||||
q.Explanation = req.Explanation
|
||||
if err := store.DB.Save(&q).Error; err != nil {
|
||||
if !questionRepo.Update(&q) {
|
||||
web.Fail(c, web.NewBadRequest("更新题目失败"))
|
||||
return
|
||||
}
|
||||
@@ -170,12 +172,11 @@ func DeleteQuestion(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var q model.Question
|
||||
if err := store.DB.First(&q, id).Error; err != nil {
|
||||
if _, found := questionRepo.GetByID(id); !found {
|
||||
web.Fail(c, web.NewNotFoundError("题目不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Model(&q).Update("status", "inactive").Error; err != nil {
|
||||
if !questionRepo.Delete(id) {
|
||||
web.Fail(c, web.NewBadRequest("停用题目失败"))
|
||||
return
|
||||
}
|
||||
@@ -186,12 +187,7 @@ func DeleteQuestion(c *gin.Context) {
|
||||
|
||||
// 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)
|
||||
web.OK(c, paperRepo.List())
|
||||
}
|
||||
|
||||
// CreatePaper POST /api/exam/papers (admin)
|
||||
@@ -212,7 +208,7 @@ func CreatePaper(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
p.Status = "active"
|
||||
if err := store.DB.Create(&p).Error; err != nil {
|
||||
if !paperRepo.Insert(&p) {
|
||||
web.Fail(c, web.NewBadRequest("创建考试失败"))
|
||||
return
|
||||
}
|
||||
@@ -225,8 +221,8 @@ func UpdatePaper(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.ExamPaper
|
||||
if err := store.DB.First(&p, id).Error; err != nil {
|
||||
p, found := paperRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("考试配置不存在"))
|
||||
return
|
||||
}
|
||||
@@ -257,7 +253,7 @@ func UpdatePaper(c *gin.Context) {
|
||||
if req.Status != "" {
|
||||
p.Status = req.Status
|
||||
}
|
||||
if err := store.DB.Save(&p).Error; err != nil {
|
||||
if !paperRepo.Update(&p) {
|
||||
web.Fail(c, web.NewBadRequest("更新考试失败"))
|
||||
return
|
||||
}
|
||||
@@ -270,12 +266,11 @@ func DeletePaper(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.ExamPaper
|
||||
if err := store.DB.First(&p, id).Error; err != nil {
|
||||
if _, found := paperRepo.GetByID(id); !found {
|
||||
web.Fail(c, web.NewNotFoundError("考试配置不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil {
|
||||
if !paperRepo.Delete(id) {
|
||||
web.Fail(c, web.NewBadRequest("停用考试失败"))
|
||||
return
|
||||
}
|
||||
@@ -287,8 +282,7 @@ func DeletePaper(c *gin.Context) {
|
||||
// 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)
|
||||
items := paperRepo.GetActive()
|
||||
|
||||
type row struct {
|
||||
ID uint `json:"id"`
|
||||
@@ -324,8 +318,8 @@ func ExamCover(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
id := uint(id64)
|
||||
var p model.ExamPaper
|
||||
if err := store.DB.First(&p, id).Error; err != nil || p.Status != "active" {
|
||||
p, found := paperRepo.GetByID(id)
|
||||
if !found || p.Status != "active" {
|
||||
web.Fail(c, web.NewNotFoundError("考试不存在或已停用"))
|
||||
return
|
||||
}
|
||||
@@ -336,56 +330,40 @@ func ExamCover(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
pool := store.DB.Where("status = ?", "active")
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 岗位考试:圈定岗位应学范围
|
||||
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)
|
||||
}
|
||||
domains, courseIDs = positionScope(positionRepo.Knowledge(*p.PositionID))
|
||||
} else {
|
||||
// 原有逻辑:按 exam_paper.domain 抽题
|
||||
domains := splitDomains(p.Domain)
|
||||
if len(domains) > 0 {
|
||||
pool = pool.Where("domain IN ?", domains)
|
||||
}
|
||||
domains = splitDomains(p.Domain)
|
||||
}
|
||||
|
||||
var qs []model.Question
|
||||
if err := pool.Order("id ASC").Find(&qs).Error; err != nil {
|
||||
return nil, err
|
||||
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))
|
||||
@@ -399,27 +377,8 @@ func pickQuestions(p model.ExamPaper) ([]model.Question, error) {
|
||||
// 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)
|
||||
}
|
||||
domains, courseIDs := positionScope(positionRepo.Knowledge(*p.PositionID))
|
||||
base := questionRepo.ActivePool(domains, courseIDs)
|
||||
|
||||
out := make([]model.Question, 0, p.QuestionCount)
|
||||
used := map[uint]bool{}
|
||||
@@ -429,8 +388,8 @@ func pickQuestionsByBlueprint(p model.ExamPaper, bps []model.PositionExamBluepri
|
||||
q = q.Where("domain = ?", bp.Domain)
|
||||
}
|
||||
var pool []model.Question
|
||||
if err := q.Order("id ASC").Find(&pool).Error; err != nil {
|
||||
return nil, err
|
||||
if !q.Order("id ASC").Find(&pool) {
|
||||
return nil, fmt.Errorf("查询题库失败")
|
||||
}
|
||||
// 排除已抽中的题,避免重复
|
||||
candidates := make([]model.Question, 0, len(pool))
|
||||
@@ -497,14 +456,12 @@ 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" {
|
||||
pos, found := positionRepo.GetByID(*positionID)
|
||||
if !found || 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 {
|
||||
if positionRepo.CountKnowledge(*positionID) == 0 {
|
||||
web.Fail(c, web.NewBadRequest("岗位考试必须先配置岗位知识映射"))
|
||||
return false
|
||||
}
|
||||
@@ -516,8 +473,7 @@ 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)
|
||||
bps := positionRepo.Blueprints(*p.PositionID)
|
||||
if len(bps) == 0 {
|
||||
return true
|
||||
}
|
||||
@@ -553,8 +509,8 @@ func ExamStart(c *gin.Context) {
|
||||
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" {
|
||||
p, found := paperRepo.GetByID(req.PaperID)
|
||||
if !found || p.Status != "active" {
|
||||
web.Fail(c, web.NewNotFoundError("考试不存在或已停用"))
|
||||
return
|
||||
}
|
||||
@@ -720,8 +676,7 @@ func ExamSubmit(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("练习会话无题目"))
|
||||
return
|
||||
}
|
||||
var qs []model.Question
|
||||
store.DB.Where("id IN ?", ids).Find(&qs)
|
||||
qs := questionRepo.ListByIDs(ids)
|
||||
qmap := make(map[uint]model.Question, len(qs))
|
||||
for _, q := range qs {
|
||||
qmap[q.ID] = q
|
||||
@@ -800,8 +755,8 @@ func ExamSubmit(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var p model.ExamPaper
|
||||
if err := store.DB.First(&p, uint(pid)).Error; err != nil {
|
||||
p, found := paperRepo.GetByID(uint(pid))
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("考试不存在"))
|
||||
return
|
||||
}
|
||||
@@ -817,8 +772,7 @@ func ExamSubmit(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("考试会话无题目"))
|
||||
return
|
||||
}
|
||||
var questions []model.Question
|
||||
store.DB.Where("id IN ?", ids).Find(&questions)
|
||||
questions := questionRepo.ListByIDs(ids)
|
||||
qmap := make(map[uint]model.Question, len(questions))
|
||||
for _, q := range questions {
|
||||
qmap[q.ID] = q
|
||||
@@ -913,7 +867,7 @@ func ExamSubmit(c *gin.Context) {
|
||||
CorrectCount: correctCount, WrongCount: wrongCount,
|
||||
DetailJSON: string(detailJSON), SubmittedAt: time.Now(),
|
||||
}
|
||||
store.DB.Create(&rec)
|
||||
examRecordRepo.Insert(&rec)
|
||||
if passed {
|
||||
awardPoints(u.ID, "formal_pass", ptFormalPass, "paper", p.ID)
|
||||
issueCertificate(u, rec)
|
||||
@@ -957,8 +911,8 @@ func ExamRecordDetail(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rec model.ExamRecord
|
||||
if err := store.DB.First(&rec, id).Error; err != nil {
|
||||
rec, found := examRecordRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("考试记录不存在"))
|
||||
return
|
||||
}
|
||||
@@ -1096,8 +1050,7 @@ func MistakePractice(c *gin.Context) {
|
||||
ids = dedupeUints(ids)
|
||||
|
||||
// 剔除已停用/删除的题目,仅下发可作答题目
|
||||
var questions []model.Question
|
||||
store.DB.Where("id IN ? AND status = ?", ids, "active").Find(&questions)
|
||||
questions := questionRepo.ListActiveByIDs(ids)
|
||||
if len(questions) == 0 {
|
||||
web.Fail(c, web.NewBadRequest("错题对应题目已失效,暂无可重练题目"))
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user