package api import ( "math" "time" "github.com/gin-gonic/gin" "eaisalestrain/backend/internal/model" "eaisalestrain/backend/internal/store" "eaisalestrain/backend/internal/web" ) // round1 保留 1 位小数 func round1(f float64) float64 { return math.Round(f*10) / 10 } // examSummary 成绩汇总(传入某集合的 exam_record) func examSummary(recs []model.ExamRecord) gin.H { s := gin.H{"total": 0, "passed": 0, "failed": 0, "pass_rate": 0.0, "avg_score": 0.0, "max_score": 0, "min_score": 0} if len(recs) == 0 { return s } passed, sum, maxS, minS := 0, 0, -1, 1000000 for _, r := range recs { if r.Passed { passed++ } sum += r.Score if r.Score > maxS { maxS = r.Score } if r.Score < minS { minS = r.Score } } s["total"] = len(recs) s["passed"] = passed s["failed"] = len(recs) - passed s["pass_rate"] = round1(float64(passed) * 100 / float64(len(recs))) s["avg_score"] = round1(float64(sum) / float64(len(recs))) s["max_score"] = maxS s["min_score"] = minS return s } // AdminDashboard GET /api/system/dashboard —— 管理员首页运营指标 func AdminDashboard(c *gin.Context) { var employeeCount, adminCount, productCount, courseCount, questionCount, formalPaperCount int64 var pendingMedia, pendingKnowledge, recentUsers int64 store.DB.Model(&model.User{}).Where("role = ?", "employee").Count(&employeeCount) store.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount) store.DB.Model(&model.Product{}).Where("status = ?", "active").Count(&productCount) store.DB.Model(&model.Course{}).Where("status = ?", "active").Count(&courseCount) store.DB.Model(&model.Question{}).Where("status = ?", "active").Count(&questionCount) store.DB.Model(&model.ExamPaper{}).Where("type = ? AND status = ?", "formal", "active").Count(&formalPaperCount) store.DB.Model(&model.MediaFile{}).Where("status = ?", "pending").Count(&pendingMedia) store.DB.Model(&model.KnowledgeSource{}).Where("audit_status = ?", "pending").Count(&pendingKnowledge) store.DB.Model(&model.User{}).Where("created_at >= ?", time.Now().AddDate(0, 0, -7)).Count(&recentUsers) // 近 7 天每日新增(首页迷你柱状图数据源) now := time.Now() dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6) var newRows []struct { Date string `gorm:"column:d"` Count int64 `gorm:"column:c"` } store.DB.Raw(`SELECT strftime('%Y-%m-%d', created_at) AS d, COUNT(*) AS c FROM user WHERE created_at >= ? GROUP BY d`, dayStart).Scan(&newRows) newByDay := map[string]int64{} for _, r := range newRows { newByDay[r.Date] = r.Count } newUsers7d := make([]gin.H, 0, 7) for i := 0; i < 7; i++ { d := dayStart.AddDate(0, 0, i).Format("2006-01-02") newUsers7d = append(newUsers7d, gin.H{"date": d, "count": newByDay[d]}) } var recs []model.ExamRecord store.DB.Find(&recs) web.OK(c, gin.H{ "employee_count": employeeCount, "admin_count": adminCount, "product_count": productCount, "course_count": courseCount, "question_count": questionCount, "formal_paper_count": formalPaperCount, "pending_material_count": pendingMedia + pendingKnowledge, "recent_new_users": recentUsers, "new_users_7d": newUsers7d, "exam": examSummary(recs), }) } // ExamStats GET /api/system/exam-stats —— 成绩统计(汇总 + 按考试 + 分数段) func ExamStats(c *gin.Context) { var recs []model.ExamRecord store.DB.Order("submitted_at DESC").Find(&recs) // 按 paper 聚合 var papers []model.ExamPaper store.DB.Order("id ASC").Find(&papers) paperName := map[uint]string{} paperType := map[uint]string{} for _, p := range papers { paperName[p.ID] = p.Name paperType[p.ID] = p.Type } type paperAgg struct { PaperID uint `json:"paper_id"` ExamName string `json:"exam_name"` Type string `json:"type"` TakenCount int `json:"taken_count"` Passed int `json:"passed_count"` PassRate float64 `json:"pass_rate"` AvgScore float64 `json:"avg_score"` } pmap := map[uint]*paperAgg{} for _, r := range recs { a := pmap[r.PaperID] if a == nil { a = &paperAgg{PaperID: r.PaperID, ExamName: paperName[r.PaperID], Type: paperType[r.PaperID]} if a.ExamName == "" { a.ExamName = r.ExamName } pmap[r.PaperID] = a } a.TakenCount++ if r.Passed { a.Passed++ } } papersOut := make([]paperAgg, 0, len(pmap)) for _, a := range pmap { a.PassRate = round1(float64(a.Passed) * 100 / float64(a.TakenCount)) a.AvgScore = round1(avgScoreOf(recs, a.PaperID)) papersOut = append(papersOut, *a) } // 分数段分布 bands := []string{"0-59", "60-69", "70-79", "80-89", "90-100"} bandCount := map[string]int{} for _, b := range bands { bandCount[b] = 0 } for _, r := range recs { bandCount[scoreBand(r.Score)]++ } distribution := make([]gin.H, 0, len(bands)) for _, b := range bands { distribution = append(distribution, gin.H{"band": b, "count": bandCount[b]}) } web.OK(c, gin.H{ "summary": examSummary(recs), "papers": papersOut, "distribution": distribution, }) } func avgScoreOf(recs []model.ExamRecord, paperID uint) float64 { sum, n := 0, 0 for _, r := range recs { if r.PaperID == paperID { sum += r.Score n++ } } if n == 0 { return 0 } return float64(sum) / float64(n) } func scoreBand(score int) string { switch { case score < 60: return "0-59" case score < 70: return "60-69" case score < 80: return "70-79" case score < 90: return "80-89" default: return "90-100" } } // ExamCoverage GET /api/system/exam-coverage —— 正式考试覆盖度(缺考名单) func ExamCoverage(c *gin.Context) { var papers []model.ExamPaper store.DB.Where("type = ? AND status = ?", "formal", "active").Order("id ASC").Find(&papers) var employees []model.User store.DB.Where("role = ? AND status = ?", "employee", "active").Order("id ASC").Find(&employees) var recs []model.ExamRecord store.DB.Order("submitted_at DESC").Find(&recs) // 每人每场取最新一次(正式考单次,取首条即可) type key struct{ uid, pid uint } lmap := map[key]model.ExamRecord{} for _, r := range recs { k := key{r.UserID, r.PaperID} if _, ok := lmap[k]; !ok { lmap[k] = r } } papersOut := make([]gin.H, 0, len(papers)) for _, p := range papers { var taken []gin.H var pending []gin.H for _, e := range employees { r, ok := lmap[key{e.ID, p.ID}] if ok { taken = append(taken, gin.H{ "user_id": e.ID, "username": e.Username, "full_name": e.FullName, "department": e.Department, "score": r.Score, "passed": r.Passed, "submitted_at": r.SubmittedAt, }) } else { pending = append(pending, gin.H{ "user_id": e.ID, "username": e.Username, "full_name": e.FullName, "department": e.Department, }) } } papersOut = append(papersOut, gin.H{ "paper_id": p.ID, "exam_name": p.Name, "employee_count": len(employees), "taken_count": len(taken), "pending_count": len(pending), "taken": taken, "pending": pending, }) } web.OK(c, gin.H{"papers": papersOut}) } // PositionExamStats GET /api/system/exam-stats-by-position —— 成绩按岗位聚合 func PositionExamStats(c *gin.Context) { var positions []model.Position store.DB.Where("status = ?", "active").Order("id ASC").Find(&positions) var users []model.User store.DB.Find(&users) userPos := map[uint]uint{} // user_id -> position_id posEmployee := map[uint]int{} // position_id -> 员工数 for _, u := range users { if u.PositionID == nil { continue } userPos[u.ID] = *u.PositionID posEmployee[*u.PositionID]++ } type posAgg struct { PositionID uint `json:"position_id"` PositionName string `json:"position_name"` EmployeeCount int `json:"employee_count"` TakenCount int `json:"taken_count"` PassedCount int `json:"passed_count"` PassRate float64 `json:"pass_rate"` AvgScore float64 `json:"avg_score"` } agg := map[uint]*posAgg{} for _, p := range positions { agg[p.ID] = &posAgg{PositionID: p.ID, PositionName: p.Name, EmployeeCount: posEmployee[p.ID]} } scoreSum := map[uint]int{} var recs []model.ExamRecord store.DB.Find(&recs) for _, r := range recs { pid, ok := userPos[r.UserID] if !ok { continue } a := agg[pid] if a == nil { a = &posAgg{PositionID: pid} agg[pid] = a } a.TakenCount++ if r.Passed { a.PassedCount++ } scoreSum[pid] += r.Score } out := make([]posAgg, 0, len(agg)) for _, p := range positions { a := agg[p.ID] if a == nil { continue } if a.TakenCount > 0 { a.PassRate = round1(float64(a.PassedCount) * 100 / float64(a.TakenCount)) a.AvgScore = round1(float64(scoreSum[p.ID]) / float64(a.TakenCount)) } out = append(out, *a) } web.OK(c, gin.H{"items": out}) } // UserOverview GET /api/system/users/{id}/overview —— 员工综合画像(考试 + AI 用量 + 学习进度) func UserOverview(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } var u model.User if err := store.DB.First(&u, id).Error; err != nil { web.Fail(c, web.NewNotFoundError("用户不存在")) return } var recs []model.ExamRecord store.DB.Where("user_id = ?", id).Order("submitted_at DESC").Find(&recs) type aiAgg struct { Calls int64 `json:"calls"` Success int64 `json:"success"` Credits int64 `json:"credits"` } var ai aiAgg store.DB.Raw(`SELECT COUNT(*) AS calls, COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success, COALESCE(SUM(credits_charged), 0) AS credits FROM ai_call_log WHERE user_id = ?`, id).Scan(&ai) var lps []model.LearningProgress store.DB.Where("user_id = ?", id).Order("updated_at DESC").Find(&lps) companyViewed := false productCount, courseCount := 0, 0 var lastViewed *time.Time for _, lp := range lps { switch lp.ItemType { case "company": companyViewed = true case "product": productCount++ case "course": courseCount++ } if lastViewed == nil || lp.UpdatedAt.After(*lastViewed) { t := lp.UpdatedAt lastViewed = &t } } web.OK(c, gin.H{ "user": gin.H{ "id": u.ID, "username": u.Username, "full_name": u.FullName, "role": u.Role, "status": u.Status, "ai_points": u.AiPoints, "department": u.Department, "position": u.Position, "hire_batch": u.HireBatch, "created_at": u.CreatedAt, }, "exam": gin.H{ "summary": examSummary(recs), "records": recs, }, "ai_usage": gin.H{ "total_calls": ai.Calls, "success_calls": ai.Success, "total_credits": ai.Credits, "ai_points_left": u.AiPoints, }, "learning": gin.H{ "company_viewed": companyViewed, "product_count": productCount, "course_count": courseCount, "total_items": len(lps), "last_viewed_at": lastViewed, }, }) }