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
@@ -8,28 +8,22 @@ import (
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/repository"
"eai_agentplatform/backend/internal/web"
)
// deptRepo 部门仓库(便于测试时覆写),包内共享。
var deptRepo repository.DepartmentRepo
func init() {
deptRepo = repository.DepartmentRepo{}
}
// ListDepartments GET /api/departments?status= —— 部门字典列表(含成员数)
func ListDepartments(c *gin.Context) {
q := store.DB.Model(&model.Department{})
switch st := c.Query("status"); st {
case "": // 默认仅 active
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Department
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询部门失败"))
return
}
items := deptRepo.ListByStatus(c.Query("status"))
// 成员数按 user.department 字符串匹配(部门为字典、用户以字符串归属)
var users []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Find(&users)
users := userRepo.ListEmployees("active")
nameCount := map[string]int{}
for _, u := range users {
if strings.TrimSpace(u.Department) != "" {
@@ -61,14 +55,12 @@ func CreateDepartment(c *gin.Context) {
return
}
req.Name = strings.TrimSpace(req.Name)
var n int64
store.DB.Model(&model.Department{}).Where("name = ?", req.Name).Count(&n)
if n > 0 {
if deptRepo.CountByName(req.Name, nil) > 0 {
web.Fail(c, web.NewConflictError("部门名称已存在"))
return
}
d := model.Department{Name: req.Name, Description: req.Description, Status: "active"}
if err := store.DB.Create(&d).Error; err != nil {
if !deptRepo.Insert(&d) {
web.Fail(c, web.NewBadRequest("创建部门失败"))
return
}
@@ -81,8 +73,8 @@ func UpdateDepartment(c *gin.Context) {
if !ok {
return
}
var d model.Department
if err := store.DB.First(&d, id).Error; err != nil {
d, found := deptRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("部门不存在"))
return
}
@@ -98,9 +90,7 @@ func UpdateDepartment(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name)
oldName := d.Name
if req.Name != oldName {
var n int64
store.DB.Model(&model.Department{}).Where("name = ? AND id <> ?", req.Name, id).Count(&n)
if n > 0 {
if deptRepo.CountByName(req.Name, &id) > 0 {
web.Fail(c, web.NewConflictError("部门名称已存在"))
return
}
@@ -110,13 +100,13 @@ func UpdateDepartment(c *gin.Context) {
if req.Status == "active" || req.Status == "inactive" {
d.Status = req.Status
}
if err := store.DB.Save(&d).Error; err != nil {
if !deptRepo.Update(&d) {
web.Fail(c, web.NewBadRequest("更新部门失败"))
return
}
// 改名后同步员工归属,保证按部门统计与展示一致
if req.Name != oldName {
store.DB.Model(&model.User{}).Where("department = ?", oldName).Update("department", req.Name)
userRepo.RenameDepartment(oldName, req.Name)
}
web.OK(c, d)
}
@@ -127,18 +117,16 @@ func DeleteDepartment(c *gin.Context) {
if !ok {
return
}
var d model.Department
if err := store.DB.First(&d, id).Error; err != nil {
d, found := deptRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("部门不存在"))
return
}
var n int64
store.DB.Model(&model.User{}).Where("department = ? AND status = ?", d.Name, "active").Count(&n)
if n > 0 {
if n := userRepo.CountActiveByDepartment(d.Name); n > 0 {
web.Fail(c, web.NewConflictError(fmt.Sprintf("该部门下仍有 %d 名员工,请先调整其部门", n)))
return
}
if err := store.DB.Delete(&d).Error; err != nil {
if !deptRepo.Delete(id) {
web.Fail(c, web.NewBadRequest("删除部门失败"))
return
}
@@ -147,8 +135,7 @@ func DeleteDepartment(c *gin.Context) {
// DepartmentStats GET /api/system/department-stats —— 按部门学情聚合
func DepartmentStats(c *gin.Context) {
var employees []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Find(&employees)
employees := userRepo.ListEmployees("active")
// 每用户聚合:积分 / 考试 / 学习进度
type userAgg struct {
@@ -172,8 +159,7 @@ func DepartmentStats(c *gin.Context) {
for _, e := range employees {
get(e.ID).Points = e.LearningPoints
}
var recs []model.ExamRecord
store.DB.Find(&recs)
recs := examRecordRepo.ListAll()
for _, r := range recs {
a := get(r.UserID)
a.FormalCount++
@@ -182,8 +168,7 @@ func DepartmentStats(c *gin.Context) {
}
a.ScoreSum += r.Score
}
var lps []model.LearningProgress
store.DB.Find(&lps)
lps := learningRepo.ListAll()
for _, lp := range lps {
a := get(lp.UserID)
switch lp.ItemType {