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:
eaiadmin
2026-09-19 01:41:33 +08:00
co-authored by Claude Code
parent 14f303459e
commit 19cf6fb5f2
21 changed files with 498 additions and 350 deletions
@@ -7,15 +7,28 @@ import (
// Certificate 证书仓库。
type CertificateRepo struct{ *QueryBuilder }
// GetByUserAndExam 获取某用户在某考试中的证书。
func (r CertificateRepo) GetByUserAndExam(userID uint, examID uint) (model.Certificate, bool) {
// GetByExamRecord 按考试记录取证书。
//
// 颁发幂等的依据:同一份 exam_record 只发一张证书。
// (此前这里叫 GetByUserAndExam、按不存在的 exam_id 列查,一调即 SQL 报错;
// 表上只有 exam_record_id,且颁发幂等本来就该按考试记录而不是按用户+考试。)
func (r CertificateRepo) GetByExamRecord(examRecordID uint) (model.Certificate, bool) {
var c model.Certificate
if r.Type(&c).Where("user_id = ? AND exam_id = ?", userID, examID).First(&c) {
if r.Type(&c).Where("exam_record_id = ?", examRecordID).First(&c) {
return c, true
}
return model.Certificate{}, false
}
// ListAll 取全部证书(issued_at 倒序,管理端全员视图)。
func (r CertificateRepo) ListAll() []model.Certificate {
var items []model.Certificate
if r.Type(&items).Order("issued_at DESC").Find(&items) {
return items
}
return nil
}
// ListByUser 获取某用户的证书列表。
func (r CertificateRepo) ListByUser(userID uint) []model.Certificate {
var items []model.Certificate
@@ -16,6 +16,41 @@ func (r DepartmentRepo) List() []model.Department {
return nil
}
// ListByStatus 按状态取部门列表(id 升序)。
//
// status 沿用本站列表的档位约定(与 CourseRepo.List 一致):
//
// "" → 只看 active(前台字典默认)
// "all" → 不过滤(管理员维护全量)
// 其它 → 按该 status 过滤
func (r DepartmentRepo) ListByStatus(status string) []model.Department {
q := r.Type(&model.Department{})
switch status {
case "":
q = q.Where("status = ?", "active")
case "all":
// 不过滤
default:
q = q.Where("status = ?", status)
}
var items []model.Department
if q.Order("id ASC").Find(&items) {
return items
}
return nil
}
// CountByName 统计同名部门数;excludeID 非空时排除该条(改名查重用)。
func (r DepartmentRepo) CountByName(name string, excludeID *uint) int64 {
q := r.Inner().Model(&model.Department{}).Where("name = ?", name)
if excludeID != nil {
q = q.Where("id <> ?", *excludeID)
}
var c int64
q.Count(&c)
return c
}
// GetByID 按 ID 获取。
func (r DepartmentRepo) GetByID(id uint) (model.Department, bool) {
var d model.Department
@@ -7,6 +7,15 @@ import (
// ExamPaper 试卷仓库。
type ExamPaperRepo struct{ *QueryBuilder }
// List 获取全部试卷(id 升序,含已停用)—— 管理端考试配置列表。
func (r ExamPaperRepo) List() []model.ExamPaper {
var items []model.ExamPaper
if r.Type(&items).Order("id ASC").Find(&items) {
return items
}
return nil
}
// GetByID 按 ID 获取。
func (r ExamPaperRepo) GetByID(id uint) (model.ExamPaper, bool) {
var p model.ExamPaper
@@ -79,6 +79,17 @@ func (r ExamRecordRepo) ListByUser(userID uint) []model.ExamRecord {
return nil
}
// ListByUserChronological 取某用户的考试记录(按提交时间正序)。
//
// 与 ListByUser 只差排序方向:列表页要最新在前,成绩趋势图要按时间从左到右。
func (r ExamRecordRepo) ListByUserChronological(userID uint) []model.ExamRecord {
var items []model.ExamRecord
if r.Type(&items).Where("user_id = ?", userID).Order("submitted_at ASC").Find(&items) {
return items
}
return nil
}
// ListAll 取全部考试记录(按提交时间倒序,供管理员导出与统计)。
func (r ExamRecordRepo) ListAll() []model.ExamRecord {
var items []model.ExamRecord
@@ -1,25 +1,63 @@
package repository
import (
"gorm.io/gorm"
"eai_agentplatform/backend/internal/model"
)
// KnowledgeFAQ FAQ 仓库。
//
// 知识空间一律用 knowledge_space_key 字符串关联,表上没有 space_id。
type KnowledgeFAQRepo struct{ *QueryBuilder }
// List 获取 FAQ 列表(按知识空间过滤)。
func (r KnowledgeFAQRepo) List(spaceID uint, keyword string) []model.KnowledgeFAQ {
q := r.Type(&model.KnowledgeFAQ{}).Where("space_id = ?", spaceID)
// ListForAdmin 后台 FAQ 列表(分页,sort_order 升序、id 倒序)。
//
// spaceKey / status / keyword 为空表示不过滤该维度。page 从 1 起,
// size 由调用方校验后传入。
func (r KnowledgeFAQRepo) ListForAdmin(spaceKey, status, keyword string, page, size int) (int64, []model.KnowledgeFAQ) {
q := r.Inner().Model(&model.KnowledgeFAQ{})
if spaceKey != "" {
q = q.Where("knowledge_space_key = ?", spaceKey)
}
if status != "" {
q = q.Where("status = ?", status)
}
if keyword != "" {
q = q.Where("question LIKE ?", "%"+keyword+"%")
like := "%" + keyword + "%"
q = q.Where("question LIKE ? OR answer LIKE ? OR keywords LIKE ?", like, like, like)
}
var total int64
q.Count(&total)
var items []model.KnowledgeFAQ
q.Order("sort_order ASC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items)
return total, items
}
// ActiveCandidates 取某个知识空间下可用于问答匹配的 FAQ 候选池。
//
// 口径:只取 status=active;spaceKey 为空 / general / all 时取全量,
// 否则取该空间 + general 两级(通用 FAQ 对所有空间生效)。
// 打分与命中计数由调用方负责,仓库只负责把候选池的口径固定下来。
func (r KnowledgeFAQRepo) ActiveCandidates(spaceKey string) []model.KnowledgeFAQ {
q := r.Type(&model.KnowledgeFAQ{}).Where("status = ?", "active")
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
q = q.Where("knowledge_space_key IN ?", []string{spaceKey, "general"})
}
var items []model.KnowledgeFAQ
if q.Order("id ASC").Find(&items) {
if q.Order("sort_order ASC, id DESC").Find(&items) {
return items
}
return nil
}
// IncrHit 命中计数 +1。
func (r KnowledgeFAQRepo) IncrHit(id uint) bool {
return r.Type(&model.KnowledgeFAQ{}).Where("id = ?", id).
UpdateColumn("hit_count", gorm.Expr("hit_count + ?", 1))
}
// GetByID 按 ID 获取。
func (r KnowledgeFAQRepo) GetByID(id uint) (model.KnowledgeFAQ, bool) {
var f model.KnowledgeFAQ
@@ -29,24 +67,20 @@ func (r KnowledgeFAQRepo) GetByID(id uint) (model.KnowledgeFAQ, bool) {
return model.KnowledgeFAQ{}, false
}
// CountBySpace 统计某知识空间的 FAQ 数。
func (r KnowledgeFAQRepo) CountBySpace(spaceID uint) int64 {
var c int64
r.Inner().Model(&model.KnowledgeFAQ{}).Where("space_id = ?", spaceID).Count(&c)
return c
}
// Insert 创建 FAQ。
func (r KnowledgeFAQRepo) Insert(f *model.KnowledgeFAQ) bool {
return r.QueryBuilder.Insert(f)
}
// Update 更新 FAQ。
// Update 更新 FAQ(整字段覆盖,调用方需带回原主键与创建时间)。
func (r KnowledgeFAQRepo) Update(f *model.KnowledgeFAQ) bool {
return r.Save(f)
}
// Delete 删除 FAQ。
// Delete 物理删除 FAQ。
//
// 与本站其它对象的「软删除(置 status)」不同,FAQ 是直接删行:
// 沿用原有行为,未改。若要改成软删需先确认后台 UI 与命中统计口径。
func (r KnowledgeFAQRepo) Delete(id uint) bool {
return r.DeleteByID(&model.KnowledgeFAQ{}, id)
}
@@ -5,21 +5,11 @@ import (
)
// KnowledgeSource 知识源仓库。
//
// 注意状态列是 audit_status(pending/approved/rejected),不是通用的 status;
// 表上也没有 space_id —— 知识空间一律用 knowledge_space_key 字符串关联。
type KnowledgeSourceRepo struct{ *QueryBuilder }
// List 获取知识源列表。
func (r KnowledgeSourceRepo) List(spaceID uint, status string) []model.KnowledgeSource {
q := r.Type(&model.KnowledgeSource{}).Where("space_id = ?", spaceID)
if status != "" {
q = q.Where("status = ?", status)
}
var items []model.KnowledgeSource
if q.Order("id ASC").Find(&items) {
return items
}
return nil
}
// GetByID 按 ID 获取。
func (r KnowledgeSourceRepo) GetByID(id uint) (model.KnowledgeSource, bool) {
var s model.KnowledgeSource
@@ -29,34 +19,53 @@ func (r KnowledgeSourceRepo) GetByID(id uint) (model.KnowledgeSource, bool) {
return model.KnowledgeSource{}, false
}
// GetByFilePath 按文件名获取知识源。
//
// 扫描入库时用它去重:file_path 存的是知识源目录下的文件名,不是绝对路径。
func (r KnowledgeSourceRepo) GetByFilePath(name string) (model.KnowledgeSource, bool) {
var s model.KnowledgeSource
if r.Type(&s).Where("file_path = ?", name).First(&s) {
return s, true
}
return model.KnowledgeSource{}, false
}
// ListForAudit 知识源审批列表(分页,id 升序)。
//
// status / spaceKey 为空表示不过滤该维度。page 从 1 起,size 由调用方校验后传入。
func (r KnowledgeSourceRepo) ListForAudit(status, spaceKey string, page, size int) (int64, []model.KnowledgeSource) {
q := r.Inner().Model(&model.KnowledgeSource{})
if status != "" {
q = q.Where("audit_status = ?", status)
}
if spaceKey != "" {
q = q.Where("knowledge_space_key = ?", spaceKey)
}
var total int64
q.Count(&total)
var items []model.KnowledgeSource
q.Order("id ASC").Offset((page - 1) * size).Limit(size).Find(&items)
return total, items
}
// ListApproved 全部已审批知识源(id 升序)。
//
// 供知识空间统计与检索元信息解析使用;调用方通常只需要 id → 记录 的映射。
func (r KnowledgeSourceRepo) ListApproved() []model.KnowledgeSource {
var items []model.KnowledgeSource
if r.Type(&items).Where("audit_status = ?", "approved").Find(&items) {
return items
}
return nil
}
// Insert 创建知识源。
func (r KnowledgeSourceRepo) Insert(s *model.KnowledgeSource) bool {
return r.QueryBuilder.Insert(s)
}
// Update 更新知识源。
// Update 更新知识源(整字段覆盖,调用方需带回原主键与创建时间)。
func (r KnowledgeSourceRepo) Update(s *model.KnowledgeSource) bool {
return r.Save(s)
}
// Delete 软删除。
func (r KnowledgeSourceRepo) Delete(id uint) bool {
return r.Type(&model.KnowledgeSource{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"})
}
// DeleteBySpace 删除某知识空间的所有知识源。
func (r KnowledgeSourceRepo) DeleteBySpace(spaceID uint) bool {
return r.Type(&model.KnowledgeSource{}).Where("space_id = ?", spaceID).Delete(&model.KnowledgeSource{})
}
// CountBySpace 统计某知识空间的知识源数。
func (r KnowledgeSourceRepo) CountBySpace(spaceID uint) int64 {
var c int64
r.Inner().Model(&model.KnowledgeSource{}).Where("space_id = ?", spaceID).Count(&c)
return c
}
// UpdateStatus 更新单个知识源的状态。
func (r KnowledgeSourceRepo) UpdateStatus(id uint, status string) bool {
return r.Type(&model.KnowledgeSource{}).Where("id = ?", id).UpdateColumn("status", status)
}
@@ -27,6 +27,15 @@ func (r LearningProgressRepo) ListByUser(userID uint) []model.LearningProgress {
return nil
}
// ListAll 取全部学习进度(updated_at 倒序,管理端全员视图)。
func (r LearningProgressRepo) ListAll() []model.LearningProgress {
var items []model.LearningProgress
if r.Type(&items).Order("updated_at DESC").Find(&items) {
return items
}
return nil
}
// Upsert 插入或更新学习进度。
func (r LearningProgressRepo) Upsert(p *model.LearningProgress) bool {
existing, found := r.Get(p.UserID, p.ItemType, p.ItemID)
@@ -45,6 +45,20 @@ func (r MediaFileRepo) ListByBind(bindType string, bindID uint) []model.MediaFil
return nil
}
// ListApprovedByBindType 获取某类绑定下、已审批通过的素材(id 升序)。
//
// 与 ListByBind 的区别:只按绑定类型取,不限定 bind_id。
// 公司介绍课件这类「全局素材」不挂具体实体,bind_id 无意义。
func (r MediaFileRepo) ListApprovedByBindType(bindType string) []model.MediaFile {
var items []model.MediaFile
if r.Type(&items).
Where("bind_type = ? AND status = ?", bindType, "approved").
Order("id ASC").Find(&items) {
return items
}
return nil
}
// ListForAudit 审批列表(分页):返回符合条件的总数与本页记录,created_at 倒序。
//
// status / spaceKey 为空表示不过滤该维度。page 从 1 起,size 由调用方校验后再传入。
@@ -7,17 +7,27 @@ import (
// Question 题目仓库。
type QuestionRepo struct{ *QueryBuilder }
// List 获取题目列表(按条件过滤)。
// List 获取题目列表(id 升序)。
//
// status 沿用本站列表的档位约定(与 CourseRepo.List 一致):
//
// "" → 只看 active(学员侧默认)
// "all" → 不过滤(管理端题库默认,含已停用)
// 其它 → 按该 status 过滤
//
// domain 为空表示不过滤该维度。
func (r QuestionRepo) List(domain, status string) []model.Question {
q := r.Type(&model.Question{})
if domain != "" {
q = q.Where("domain = ?", domain)
}
if status != "" {
q = q.Where("status = ?", status)
}
if status == "" {
switch status {
case "":
q = q.Where("status = ?", "active")
case "all":
// 不过滤
default:
q = q.Where("status = ?", status)
}
var items []model.Question
if q.Order("id ASC").Find(&items) {
@@ -26,6 +36,54 @@ func (r QuestionRepo) List(domain, status string) []model.Question {
return nil
}
// ListByIDs 按 ID 批量取题,**不过滤 status**(id 升序)。
//
// 判分专用:考试会话里下发的题目即使事后被停用,也必须能判分,
// 否则学员交了卷却判不出来。要「只取可作答题目」用 ListActiveByIDs。
func (r QuestionRepo) ListByIDs(ids []uint) []model.Question {
if len(ids) == 0 {
return nil
}
var items []model.Question
if r.Type(&items).Where("id IN ?", ids).Order("id ASC").Find(&items) {
return items
}
return nil
}
// ListActiveByIDs 按 ID 批量取「已启用」的题(id 升序)。
//
// 下发专用:错题重练等场景要剔除已停用/删除的题目。
func (r QuestionRepo) ListActiveByIDs(ids []uint) []model.Question {
if len(ids) == 0 {
return nil
}
var items []model.Question
if r.Type(&items).Where("id IN ? AND status = ?", ids, "active").Order("id ASC").Find(&items) {
return items
}
return nil
}
// ActivePool 返回「岗位应学范围内已启用题目」的查询构建器,供抽题逻辑继续细分。
//
// 口径(P0 岗位考试抽题,抽题主流程与蓝图抽题共用,避免两处手写漂移):
// - 只取 status=active
// - domains 非空时限定 domain IN domains
// - courseIDs 非空时「题目属于这些课程,或未绑定课程但域匹配」(宽松口径,沿用原行为)
//
// 两个参数都为空表示全题库。
func (r QuestionRepo) ActivePool(domains []string, courseIDs []uint) *QueryBuilder {
q := r.Type(&model.Question{}).Where("status = ?", "active")
if len(domains) > 0 {
q = q.Where("domain IN ?", domains)
}
if len(courseIDs) > 0 {
q = q.Where("course_id IN ? OR course_id IS NULL", courseIDs)
}
return q
}
// GetByID 按 ID 获取题目。
func (r QuestionRepo) GetByID(id uint) (model.Question, bool) {
var q model.Question
@@ -42,13 +100,17 @@ func (r QuestionRepo) GetActiveIDs(ids []uint) []uint {
return result
}
// GetByIDsWithDomain 获取题目列表(含知识域)。
func (r QuestionRepo) GetByIDsWithDomain(ids []uint) []model.Question {
var items []model.Question
if r.Type(&items).Where("id IN ? AND status = ?", ids, "active").Find(&items) {
return items
// DomainMap 返回「题目 ID → 知识域」映射。
//
// 学员能力雷达要拿答题明细里的题目 ID 反查所属域;只取两列,避免整表加载题干。
func (r QuestionRepo) DomainMap() map[uint]string {
var rows []model.Question
r.Inner().Model(&model.Question{}).Select("id", "domain").Find(&rows)
out := make(map[uint]string, len(rows))
for _, q := range rows {
out[q.ID] = q.Domain
}
return nil
return out
}
// CountByType 统计某类题目数量。
@@ -38,6 +38,44 @@ func (r UserRepo) Total(role, status string) int64 {
return c
}
// ListEmployees 取 role=employee 的用户(id 升序)。
//
// status 语义刻意写成显式档位,避免与列表接口的「空=active」约定混淆:
//
// "active" → 仅在职(部门成员数、部门学情统计用这份口径)
// "" → 不限状态(管理端全员视图,含已停用)
// 其它 → 按该状态过滤
func (r UserRepo) ListEmployees(status string) []model.User {
q := r.Type(&model.User{}).Where("role = ?", "employee")
if status != "" {
q = q.Where("status = ?", status)
}
var items []model.User
if q.Order("id ASC").Find(&items) {
return items
}
return nil
}
// CountActiveByDepartment 统计归属某部门的在职员工数(按 user.department 字符串匹配)。
func (r UserRepo) CountActiveByDepartment(name string) int64 {
var c int64
r.Inner().Model(&model.User{}).
Where("department = ? AND status = ?", name, "active").Count(&c)
return c
}
// RenameDepartment 部门改名后同步员工归属字符串。
//
// 部门是字典表、用户以字符串归属,改字典名必须回写 user.department,
// 否则按部门统计与展示会当场对不上。
// 用 Updates(而非 UpdateColumn)是为了让 GORM 照常带上 updated_at,
// 与原实现 `Model(&User{}).Where(...).Update("department", ...)` 的行为一致。
func (r UserRepo) RenameDepartment(oldName, newName string) bool {
return r.Type(&model.User{}).Where("department = ?", oldName).
Updates(map[string]any{"department": newName})
}
// GetByID 按 ID 获取。
func (r UserRepo) GetByID(id uint) (model.User, bool) {
var u model.User