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,22 +8,27 @@ import (
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/repository"
"eai_agentplatform/backend/internal/web"
)
// certRepo 证书仓库(便于测试时覆写),包内共享。
var certRepo repository.CertificateRepo
func init() {
certRepo = repository.CertificateRepo{}
}
// issueCertificate 正式考试通过后颁发证书(幂等:同一 exam_record 只发一张)。
func issueCertificate(u *model.User, rec model.ExamRecord) {
if u == nil || !rec.Passed {
return
}
var n int64
store.DB.Model(&model.Certificate{}).Where("exam_record_id = ?", rec.ID).Count(&n)
if n > 0 {
if _, found := certRepo.GetByExamRecord(rec.ID); found {
return
}
certNo := fmt.Sprintf("BST-%06d", rec.ID)
store.DB.Create(&model.Certificate{
certRepo.Insert(&model.Certificate{
UserID: u.ID, ExamRecordID: rec.ID, UserName: u.FullName,
ExamName: rec.ExamName, Score: rec.Score, TotalScore: rec.TotalScore, PassScore: rec.PassScore,
CertNo: certNo, IssuedAt: time.Now(),
@@ -37,8 +42,10 @@ func MyCertificates(c *gin.Context) {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var items []model.Certificate
store.DB.Where("user_id = ?", u.ID).Order("issued_at DESC").Find(&items)
items := certRepo.ListByUser(u.ID)
if items == nil {
items = []model.Certificate{}
}
web.OK(c, items)
}
@@ -49,8 +56,8 @@ func CertificateDetail(c *gin.Context) {
if !ok {
return
}
var cert model.Certificate
if err := store.DB.First(&cert, id).Error; err != nil {
cert, found := certRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("证书不存在"))
return
}
@@ -63,7 +70,5 @@ func CertificateDetail(c *gin.Context) {
// AdminCertificates GET /api/system/certificates —— 全员证书(管理员)
func AdminCertificates(c *gin.Context) {
var items []model.Certificate
store.DB.Order("issued_at DESC").Find(&items)
web.OK(c, items)
web.OK(c, certRepo.ListAll())
}
@@ -11,22 +11,15 @@ import (
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
// 本文件用到的仓库:configRepo 声明在 system.go,mediaRepo 声明在 media.go。
// GetCompanyTrain GET /api/company-train
// content 取 system_config(company_intro),medias 取已审批的公司绑定素材
func GetCompanyTrain(c *gin.Context) {
content := ""
var cfg model.SystemConfig
if err := store.DB.Where("config_key = ?", "company_intro").First(&cfg).Error; err == nil {
content = cfg.ConfigValue
}
var files []model.MediaFile
store.DB.Where("bind_type = ? AND status = ?", "company", "approved").
Order("id ASC").Find(&files)
content := configRepo.GetByKey("company_intro")
files := mediaRepo.ListApprovedByBindType("company")
type mediaItem struct {
ID uint `json:"id"`
@@ -107,7 +100,7 @@ func SuggestMaterial(c *gin.Context) {
BindType: "none",
Remark: remark,
}
if err := store.DB.Create(&m).Error; err != nil {
if !mediaRepo.Insert(&m) {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
return
}
@@ -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 {
+74 -121
View File
@@ -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
@@ -13,10 +13,17 @@ import (
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/repository"
"eai_agentplatform/backend/internal/web"
)
// sourceRepo 知识源仓库(便于测试时覆写),包内共享。
var sourceRepo repository.KnowledgeSourceRepo
func init() {
sourceRepo = repository.KnowledgeSourceRepo{}
}
// ============ 扫描 ============
// KnowledgeScan POST /api/knowledge/scan —— 扫描 knowledge_source 目录建 pending 记录
@@ -44,8 +51,7 @@ func KnowledgeScan(c *gin.Context) {
continue // 非知识源文档,跳过
}
var existing model.KnowledgeSource
if err := store.DB.Where("file_path = ?", e.Name()).First(&existing).Error; err == nil {
if existing, found := sourceRepo.GetByFilePath(e.Name()); found {
results = append(results, gin.H{"file_path": e.Name(), "status": "skipped", "title": existing.Title})
continue
}
@@ -58,7 +64,7 @@ func KnowledgeScan(c *gin.Context) {
AuditStatus: "pending",
KnowledgeSpaceKey: ensureKnowledgeSpaceKeyOrDefault(orDefault(fm["knowledge_space_key"], inferKnowledgeSpaceKey(parseTitle(string(data)), fm["domain"], fm["category"]+" "+e.Name()))),
}
if err := store.DB.Create(&src).Error; err != nil {
if !sourceRepo.Insert(&src) {
results = append(results, gin.H{"file_path": e.Name(), "status": "error", "title": src.Title})
continue
}
@@ -69,13 +75,6 @@ func KnowledgeScan(c *gin.Context) {
// KnowledgeAuditList GET /api/knowledge/audit-list?status=&page=&size=
func KnowledgeAuditList(c *gin.Context) {
q := store.DB.Model(&model.KnowledgeSource{})
if s := c.Query("status"); s != "" {
q = q.Where("audit_status = ?", s)
}
if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" {
q = q.Where("knowledge_space_key = ?", key)
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
@@ -84,10 +83,8 @@ func KnowledgeAuditList(c *gin.Context) {
if size < 1 || size > 100 {
size = 20
}
var total int64
q.Count(&total)
var items []model.KnowledgeSource
q.Order("id ASC").Offset((page - 1) * size).Limit(size).Find(&items)
total, items := sourceRepo.ListForAudit(
c.Query("status"), sanitizeSpaceKey(c.Query("knowledge_space_key")), page, size)
web.OK(c, gin.H{"total": total, "items": items})
}
@@ -98,8 +95,8 @@ func KnowledgeAudit(c *gin.Context) {
if !ok {
return
}
var src model.KnowledgeSource
if err := store.DB.First(&src, id).Error; err != nil {
src, found := sourceRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("知识源不存在"))
return
}
@@ -129,7 +126,7 @@ func KnowledgeAudit(c *gin.Context) {
src.AuditAt = &now
src.RejectReason = ""
src.Ingested = true
if err := store.DB.Save(&src).Error; err != nil {
if !sourceRepo.Update(&src) {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
@@ -147,7 +144,7 @@ func KnowledgeAudit(c *gin.Context) {
src.RejectReason = req.RejectReason
src.AuditBy = &auditBy
src.AuditAt = &now
if err := store.DB.Save(&src).Error; err != nil {
if !sourceRepo.Update(&src) {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
@@ -163,8 +160,8 @@ func KnowledgeStatus(c *gin.Context) {
if !ok {
return
}
var src model.KnowledgeSource
if err := store.DB.First(&src, id).Error; err != nil {
src, found := sourceRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("知识源不存在"))
return
}
@@ -302,7 +299,10 @@ func convertAnswer(raw []any) []string {
// ============ 摄入 ============
// ingestSource 解析 md → product / knowledge_chunk / question,返回三表写入计数
// ingestSource 解析 md → product / knowledge_chunk / question,返回三表计数
//
// 口径提示:counts 记的是**从 md 解析出的条目数**,不是落库成功数 ——
// 单条写入失败不中断、也不扣减计数(沿用原有行为,未改)。
func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
var counts [3]int
full := filepath.Join(Cfg.KnowledgeSourceDir, src.FilePath)
@@ -335,12 +335,12 @@ func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
ReportRules: kv["report_rules"],
Status: "active",
}
var existing model.Product
if err := store.DB.Where("code = ?", p.Code).First(&existing).Error; err == nil {
if existing, found := productRepo.GetByCode(p.Code); found {
p.ID = existing.ID
store.DB.Save(&p)
p.CreatedAt = existing.CreatedAt // Save 整字段覆盖,不回填会把创建时间写成零值
productRepo.Update(&p)
} else {
store.DB.Create(&p)
productRepo.Insert(&p)
}
counts[0]++
}
@@ -351,7 +351,7 @@ func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
if text == "" {
continue
}
store.DB.Create(&model.KnowledgeChunk{
chunkRepo.Insert(&model.KnowledgeChunk{
KnowledgeSourceID: &src.ID,
SourceType: "md",
SourceID: strconv.FormatUint(uint64(src.ID), 10),
@@ -383,7 +383,7 @@ func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
Explanation: kv["explanation"],
Status: "active",
}
store.DB.Create(&q)
questionRepo.Insert(&q)
counts[2]++
}
@@ -8,23 +8,19 @@ 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"
)
// faqRepo FAQ 仓库(便于测试时覆写),包内共享。
var faqRepo repository.KnowledgeFAQRepo
func init() {
faqRepo = repository.KnowledgeFAQRepo{}
}
// ListKnowledgeFAQs GET /api/knowledge/faqs?knowledge_space_key=&status=&keyword=&page=&size=
func ListKnowledgeFAQs(c *gin.Context) {
q := store.DB.Model(&model.KnowledgeFAQ{})
if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" {
q = q.Where("knowledge_space_key = ?", key)
}
if status := strings.TrimSpace(c.Query("status")); status != "" {
q = q.Where("status = ?", status)
}
if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" {
like := "%" + keyword + "%"
q = q.Where("question LIKE ? OR answer LIKE ? OR keywords LIKE ?", like, like, like)
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
@@ -33,13 +29,11 @@ func ListKnowledgeFAQs(c *gin.Context) {
if size < 1 || size > 100 {
size = 20
}
var total int64
q.Count(&total)
var items []model.KnowledgeFAQ
if err := q.Order("sort_order ASC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("加载 FAQ 失败"))
return
}
total, items := faqRepo.ListForAdmin(
sanitizeSpaceKey(c.Query("knowledge_space_key")),
strings.TrimSpace(c.Query("status")),
strings.TrimSpace(c.Query("keyword")),
page, size)
web.OK(c, gin.H{
"total": total,
"items": buildFAQItems(items),
@@ -77,7 +71,7 @@ func CreateKnowledgeFAQ(c *gin.Context) {
Status: orDefault(strings.TrimSpace(req.Status), "active"),
SortOrder: req.SortOrder,
}
if err := store.DB.Create(&faq).Error; err != nil {
if !faqRepo.Insert(&faq) {
web.Fail(c, web.NewBadRequest("创建 FAQ 失败"))
return
}
@@ -90,8 +84,8 @@ func UpdateKnowledgeFAQ(c *gin.Context) {
if !ok {
return
}
var faq model.KnowledgeFAQ
if err := store.DB.First(&faq, id).Error; err != nil {
faq, found := faqRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("FAQ 不存在"))
return
}
@@ -122,7 +116,7 @@ func UpdateKnowledgeFAQ(c *gin.Context) {
faq.Keywords = strings.Join(cleanStringList(req.Keywords), ",")
faq.Status = orDefault(strings.TrimSpace(req.Status), "active")
faq.SortOrder = req.SortOrder
if err := store.DB.Save(&faq).Error; err != nil {
if !faqRepo.Update(&faq) {
web.Fail(c, web.NewBadRequest("更新 FAQ 失败"))
return
}
@@ -135,12 +129,11 @@ func DeleteKnowledgeFAQ(c *gin.Context) {
if !ok {
return
}
var faq model.KnowledgeFAQ
if err := store.DB.First(&faq, id).Error; err != nil {
if _, found := faqRepo.GetByID(id); !found {
web.Fail(c, web.NewNotFoundError("FAQ 不存在"))
return
}
if err := store.DB.Delete(&faq).Error; err != nil {
if !faqRepo.Delete(id) {
web.Fail(c, web.NewBadRequest("删除 FAQ 失败"))
return
}
@@ -7,12 +7,9 @@ import (
"sort"
"strings"
"gorm.io/gorm"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
)
const (
@@ -178,15 +175,7 @@ func extractKnowledgeSpaceKey(ctx map[string]any) string {
}
func matchKnowledgeFAQ(query, spaceKey string) (model.KnowledgeFAQ, bool) {
spaceKey = sanitizeSpaceKey(spaceKey)
q := store.DB.Where("status = ?", "active")
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
q = q.Where("knowledge_space_key IN ?", []string{spaceKey, "general"})
}
var faqs []model.KnowledgeFAQ
if err := q.Order("sort_order ASC, id DESC").Find(&faqs).Error; err != nil {
return model.KnowledgeFAQ{}, false
}
faqs := faqRepo.ActiveCandidates(sanitizeSpaceKey(spaceKey))
queryNorm := normalizeQuestion(query)
queryTerms := buildSearchTerms(query)
bestScore := 0
@@ -201,8 +190,7 @@ func matchKnowledgeFAQ(query, spaceKey string) (model.KnowledgeFAQ, bool) {
if bestScore < 70 {
return model.KnowledgeFAQ{}, false
}
store.DB.Model(&model.KnowledgeFAQ{}).Where("id = ?", best.ID).
UpdateColumn("hit_count", gorm.Expr("hit_count + ?", 1))
faqRepo.IncrHit(best.ID)
return best, true
}
@@ -181,8 +181,6 @@ func SearchKnowledge(c *gin.Context) {
// 这里刻意不用 chunkRepo.ApprovedMetaMaps():本站已把分片限制在 400 条内,
// 按需只取被引用到的素材/知识源,比整表加载已审批记录更省。
// 未命中审批状态的记录由 resolveChunkMeta 兜底拦掉,语义与那份批量映射一致。
mediaRepo := repository.MediaFileRepo{}
sourceRepo := repository.KnowledgeSourceRepo{}
mediaMap := map[uint]model.MediaFile{}
sourceMap := map[uint]model.KnowledgeSource{}
for _, chunk := range chunks {
@@ -289,9 +287,8 @@ func buildKnowledgeSpaceMetrics() (map[string]spaceMetrics, error) {
metrics[item.Key] = spaceMetrics{}
}
mediaRepo := repository.MediaFileRepo{}
sourceRepo := repository.KnowledgeSourceRepo{}
// 这里要的是「全量已审批素材」,与 ListForAudit 的分页/排序口径不同,
// 单个调用点不值得再加一个仓库方法,直接用包级 mediaRepo 取。
var mediaFiles []model.MediaFile
mediaRepo.Type(&model.MediaFile{}).Where("status = ?", "approved").Find(&mediaFiles)
for _, f := range mediaFiles {
@@ -306,8 +303,7 @@ func buildKnowledgeSpaceMetrics() (map[string]spaceMetrics, error) {
}
}
var sources []model.KnowledgeSource
sourceRepo.Type(&model.KnowledgeSource{}).Where("audit_status = ?", "approved").Find(&sources)
sources := sourceRepo.ListApproved()
for _, s := range sources {
key := resolveKnowledgeSourceSpaceKey(s)
m := metrics[key]
@@ -7,10 +7,17 @@ import (
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/repository"
"eai_agentplatform/backend/internal/web"
)
// learningRepo 学习进度仓库(便于测试时覆写),包内共享。
var learningRepo repository.LearningProgressRepo
func init() {
learningRepo = repository.LearningProgressRepo{}
}
var validItemTypes = map[string]bool{"company": true, "product": true, "course": true}
// RecordLearningProgress POST /api/learning/progress —— 员工浏览内容时上报进度
@@ -33,16 +40,15 @@ func RecordLearningProgress(c *gin.Context) {
}
lp := model.LearningProgress{UserID: u.ID, ItemType: req.ItemType, ItemID: req.ItemID}
// 幂等:已存在则仅刷新 updated_at;首次记录才加分(避免重复刷分)
var existing model.LearningProgress
if err := store.DB.Where("user_id = ? AND item_type = ? AND item_id = ?", u.ID, req.ItemType, req.ItemID).
First(&existing).Error; err != nil {
if createErr := store.DB.Create(&lp).Error; createErr != nil {
if _, exists := learningRepo.Get(u.ID, req.ItemType, req.ItemID); !exists {
if !learningRepo.Insert(&lp) {
web.Fail(c, web.NewBadRequest("记录学习进度失败"))
return
}
awardFirstView(u.ID, req.ItemType, req.ItemID)
} else {
store.DB.Model(&existing).Update("updated_at", time.Now())
// Upsert 命中已存在分支时只刷新 updated_at,不新增行
learningRepo.Upsert(&lp)
}
web.OK(c, gin.H{"recorded": true, "item_type": req.ItemType, "item_id": req.ItemID})
}
@@ -54,18 +60,18 @@ func MyLearningProgress(c *gin.Context) {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var items []model.LearningProgress
store.DB.Where("user_id = ?", u.ID).Order("updated_at DESC").Find(&items)
items := learningRepo.ListByUser(u.ID)
if items == nil {
items = []model.LearningProgress{}
}
web.OK(c, items)
}
// AdminLearningProgress GET /api/system/learning-progress —— 全员学习进度(管理员)
func AdminLearningProgress(c *gin.Context) {
var employees []model.User
store.DB.Where("role = ?", "employee").Order("id ASC").Find(&employees)
var items []model.LearningProgress
store.DB.Order("updated_at DESC").Find(&items)
// 管理端全员视图:不限状态,已停用的员工也要能看到其历史进度
employees := userRepo.ListEmployees("")
items := learningRepo.ListAll()
type agg struct {
CompanyViewed bool `json:"company_viewed"`
@@ -11,6 +11,8 @@ import (
"eai_agentplatform/backend/internal/web"
)
// 本文件用到的仓库:questionRepo/examRecordRepo 声明在 exam.go,learningRepo 声明在 learning.go。
// domainLabels 能力雷达维度(正式考试按域聚合的掌握度)。
var domainLabels = map[string]string{
"company": "公司认知",
@@ -38,8 +40,7 @@ func MyProfile(c *gin.Context) {
}
// 学习进度(公司/产品/课程浏览)
var lps []model.LearningProgress
store.DB.Where("user_id = ?", u.ID).Find(&lps)
lps := learningRepo.ListByUser(u.ID)
companyViewed := false
productViewed, courseViewed := 0, 0
for _, lp := range lps {
@@ -61,17 +62,11 @@ func MyProfile(c *gin.Context) {
var selfTestCount int64
store.DB.Model(&model.PointEvent{}).Where("user_id = ? AND event_type = ?", u.ID, "self_test").Count(&selfTestCount)
// 正式考试记录 + 按域能力聚合
var recs []model.ExamRecord
store.DB.Where("user_id = ?", u.ID).Order("submitted_at ASC").Find(&recs)
// 正式考试记录 + 按域能力聚合(趋势图要按时间正序)
recs := examRecordRepo.ListByUserChronological(u.ID)
// 题目 → 域映射(用于从答题明细反推各域掌握度)
var questions []model.Question
store.DB.Select("id", "domain").Find(&questions)
qDomain := map[uint]string{}
for _, q := range questions {
qDomain[q.ID] = q.Domain
}
qDomain := questionRepo.DomainMap()
type domainAgg struct {
Correct int `json:"correct"`
@@ -15,15 +15,15 @@ import (
"eai_agentplatform/backend/internal/web"
)
// systemConfigRepo 声明在此;examRecordRepo 与考试域同属一个对象,
// 统一声明在 exam.go 的「考试域仓库」块内,避免同一个仓库有两份变量导致覆写时行为分叉。
var (
userRepo repository.UserRepo
examRecordRepo repository.ExamRecordRepo
configRepo repository.SystemConfigRepo
userRepo repository.UserRepo
configRepo repository.SystemConfigRepo
)
func init() {
userRepo = repository.UserRepo{}
examRecordRepo = repository.ExamRecordRepo{}
configRepo = repository.SystemConfigRepo{}
}
@@ -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