把 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>
261 lines
6.6 KiB
Go
261 lines
6.6 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"eai_agentplatform/backend/internal/model"
|
|
"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) {
|
|
items := deptRepo.ListByStatus(c.Query("status"))
|
|
// 成员数按 user.department 字符串匹配(部门为字典、用户以字符串归属)
|
|
users := userRepo.ListEmployees("active")
|
|
nameCount := map[string]int{}
|
|
for _, u := range users {
|
|
if strings.TrimSpace(u.Department) != "" {
|
|
nameCount[u.Department]++
|
|
}
|
|
}
|
|
out := make([]gin.H, 0, len(items))
|
|
for _, d := range items {
|
|
out = append(out, gin.H{
|
|
"id": d.ID,
|
|
"name": d.Name,
|
|
"description": d.Description,
|
|
"status": d.Status,
|
|
"member_count": nameCount[d.Name],
|
|
"created_at": d.CreatedAt,
|
|
})
|
|
}
|
|
web.OK(c, out)
|
|
}
|
|
|
|
// CreateDepartment POST /api/departments (admin)
|
|
func CreateDepartment(c *gin.Context) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
|
web.Fail(c, web.NewBadRequest("部门名称为必填"))
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
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 !deptRepo.Insert(&d) {
|
|
web.Fail(c, web.NewBadRequest("创建部门失败"))
|
|
return
|
|
}
|
|
web.OK(c, d)
|
|
}
|
|
|
|
// UpdateDepartment PUT /api/departments/{id} (admin) —— 改名时同步 user.department 字符串
|
|
func UpdateDepartment(c *gin.Context) {
|
|
id, ok := parseID(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
d, found := deptRepo.GetByID(id)
|
|
if !found {
|
|
web.Fail(c, web.NewNotFoundError("部门不存在"))
|
|
return
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
|
web.Fail(c, web.NewBadRequest("部门名称为必填"))
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
oldName := d.Name
|
|
if req.Name != oldName {
|
|
if deptRepo.CountByName(req.Name, &id) > 0 {
|
|
web.Fail(c, web.NewConflictError("部门名称已存在"))
|
|
return
|
|
}
|
|
}
|
|
d.Name = req.Name
|
|
d.Description = req.Description
|
|
if req.Status == "active" || req.Status == "inactive" {
|
|
d.Status = req.Status
|
|
}
|
|
if !deptRepo.Update(&d) {
|
|
web.Fail(c, web.NewBadRequest("更新部门失败"))
|
|
return
|
|
}
|
|
// 改名后同步员工归属,保证按部门统计与展示一致
|
|
if req.Name != oldName {
|
|
userRepo.RenameDepartment(oldName, req.Name)
|
|
}
|
|
web.OK(c, d)
|
|
}
|
|
|
|
// DeleteDepartment DELETE /api/departments/{id} (admin) —— 有员工归属时拒绝删除
|
|
func DeleteDepartment(c *gin.Context) {
|
|
id, ok := parseID(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
d, found := deptRepo.GetByID(id)
|
|
if !found {
|
|
web.Fail(c, web.NewNotFoundError("部门不存在"))
|
|
return
|
|
}
|
|
if n := userRepo.CountActiveByDepartment(d.Name); n > 0 {
|
|
web.Fail(c, web.NewConflictError(fmt.Sprintf("该部门下仍有 %d 名员工,请先调整其部门", n)))
|
|
return
|
|
}
|
|
if !deptRepo.Delete(id) {
|
|
web.Fail(c, web.NewBadRequest("删除部门失败"))
|
|
return
|
|
}
|
|
web.OK(c, gin.H{"id": id, "deleted": true})
|
|
}
|
|
|
|
// DepartmentStats GET /api/system/department-stats —— 按部门学情聚合
|
|
func DepartmentStats(c *gin.Context) {
|
|
employees := userRepo.ListEmployees("active")
|
|
|
|
// 每用户聚合:积分 / 考试 / 学习进度
|
|
type userAgg struct {
|
|
Points int
|
|
FormalCount int
|
|
FormalPassed int
|
|
ScoreSum int
|
|
CompanyViewed bool
|
|
ProductCount int
|
|
CourseCount int
|
|
}
|
|
perUser := map[uint]*userAgg{}
|
|
get := func(id uint) *userAgg {
|
|
a := perUser[id]
|
|
if a == nil {
|
|
a = &userAgg{}
|
|
perUser[id] = a
|
|
}
|
|
return a
|
|
}
|
|
for _, e := range employees {
|
|
get(e.ID).Points = e.LearningPoints
|
|
}
|
|
recs := examRecordRepo.ListAll()
|
|
for _, r := range recs {
|
|
a := get(r.UserID)
|
|
a.FormalCount++
|
|
if r.Passed {
|
|
a.FormalPassed++
|
|
}
|
|
a.ScoreSum += r.Score
|
|
}
|
|
lps := learningRepo.ListAll()
|
|
for _, lp := range lps {
|
|
a := get(lp.UserID)
|
|
switch lp.ItemType {
|
|
case "company":
|
|
a.CompanyViewed = true
|
|
case "product":
|
|
a.ProductCount++
|
|
case "course":
|
|
a.CourseCount++
|
|
}
|
|
}
|
|
|
|
// 按部门字符串聚合
|
|
type deptAgg struct {
|
|
EmployeeCount int
|
|
PointsTotal int
|
|
FormalCount int
|
|
FormalPassed int
|
|
ScoreSum int
|
|
CompanyViewed int
|
|
ProductSum int
|
|
CourseSum int
|
|
}
|
|
perDept := map[string]*deptAgg{}
|
|
order := []string{}
|
|
for _, e := range employees {
|
|
name := strings.TrimSpace(e.Department)
|
|
if name == "" {
|
|
name = "未分组"
|
|
}
|
|
a := perDept[name]
|
|
if a == nil {
|
|
a = &deptAgg{}
|
|
perDept[name] = a
|
|
order = append(order, name)
|
|
}
|
|
a.EmployeeCount++
|
|
if u := perUser[e.ID]; u != nil {
|
|
a.PointsTotal += u.Points
|
|
a.FormalCount += u.FormalCount
|
|
a.FormalPassed += u.FormalPassed
|
|
a.ScoreSum += u.ScoreSum
|
|
if u.CompanyViewed {
|
|
a.CompanyViewed++
|
|
}
|
|
a.ProductSum += u.ProductCount
|
|
a.CourseSum += u.CourseCount
|
|
}
|
|
}
|
|
|
|
// 排序:未分组沉底,其余按名称
|
|
sort.Slice(order, func(i, j int) bool {
|
|
if order[i] == "未分组" {
|
|
return false
|
|
}
|
|
if order[j] == "未分组" {
|
|
return true
|
|
}
|
|
return order[i] < order[j]
|
|
})
|
|
|
|
out := make([]gin.H, 0, len(order))
|
|
for _, name := range order {
|
|
a := perDept[name]
|
|
passRate, avgScore, companyRate, productAvg, courseAvg := 0.0, 0.0, 0.0, 0.0, 0.0
|
|
if a.FormalCount > 0 {
|
|
passRate = round1(float64(a.FormalPassed) * 100 / float64(a.FormalCount))
|
|
avgScore = round1(float64(a.ScoreSum) / float64(a.FormalCount))
|
|
}
|
|
if a.EmployeeCount > 0 {
|
|
companyRate = round1(float64(a.CompanyViewed) * 100 / float64(a.EmployeeCount))
|
|
productAvg = round1(float64(a.ProductSum) / float64(a.EmployeeCount))
|
|
courseAvg = round1(float64(a.CourseSum) / float64(a.EmployeeCount))
|
|
}
|
|
out = append(out, gin.H{
|
|
"department": name,
|
|
"employee_count": a.EmployeeCount,
|
|
"points_total": a.PointsTotal,
|
|
"formal_count": a.FormalCount,
|
|
"formal_passed": a.FormalPassed,
|
|
"pass_rate": passRate,
|
|
"avg_score": avgScore,
|
|
"company_rate": companyRate,
|
|
"product_avg": productAvg,
|
|
"course_avg": courseAvg,
|
|
})
|
|
}
|
|
web.OK(c, gin.H{"items": out})
|
|
}
|