using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/warnings")] public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; // ═══════════ Rules (admin) ═══════════ [HttpGet("rules")] [Authorize(Roles = Managers)] public async Task GetRules(Guid academicTermId, CancellationToken ct) => Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId) .OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description }) .ToListAsync(ct)); [HttpPut("rules")] [Authorize(Roles = Managers)] public async Task SaveRules(Guid academicTermId, List rules, CancellationToken ct) { var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct); db.WarningRules.RemoveRange(existing); foreach (var r in rules) { db.WarningRules.Add(new WarningRule { AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(), Threshold = r.Threshold, IsEnabled = r.IsEnabled, NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor, Description = r.Description?.Trim() }); } await db.SaveChangesAsync(ct); return NoContent(); } // ═══════════ Detection + Records ═══════════ [HttpPost("detect")] [Authorize(Roles = Managers)] public async Task DetectWarnings(Guid academicTermId, CancellationToken ct) { var rules = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled).ToListAsync(ct); if (rules.Count == 0) return Ok(new { Generated = 0, Message = "当前学期无启用的预警规则。" }); var studentInfos = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => new StudentInfo(x.Id, x.UserId, x.Name, x.AdministrativeClassId)).ToListAsync(ct); var generated = new List(); foreach (var rule in rules) { switch (rule.Type) { case WarningType.FailedCredits: generated.AddRange(await DetectFailedCredits(rule, academicTermId, studentInfos, ct)); break; case WarningType.LowGPA: generated.AddRange(await DetectLowGPA(rule, academicTermId, studentInfos, ct)); break; case WarningType.Absenteeism: generated.AddRange(await DetectAbsenteeism(rule, academicTermId, studentInfos, ct)); break; case WarningType.GraduationDelay: generated.AddRange(await DetectGraduationDelay(rule, academicTermId, studentInfos, ct)); break; } } if (generated.Count > 0) { db.WarningRecords.AddRange(generated); await db.SaveChangesAsync(ct); foreach (var w in generated) { var si = studentInfos.First(s => s.Id == w.StudentId); var r = rules.First(r => r.Type == w.Type); if (r.NotifyStudent && si.UserId.HasValue) await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct); if (r.NotifyCounselor) { var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct); if (counselorId != default) await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct); } } } return Ok(new { Generated = generated.Count }); } [HttpGet("records")] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)] public async Task GetRecords(Guid? academicTermId, WarningType? type, CancellationToken ct) { var q = db.WarningRecords.AsNoTracking().AsQueryable(); if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor)) { var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct); if (classIds.Count > 0) q = q.Where(x => classIds.Contains(x.Student!.AdministrativeClassId)); else if (scope.Current.RestrictedCollegeId.HasValue) q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId); } if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId); if (type.HasValue) q = q.Where(x => x.Type == type); return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct)); } // ═══════════ Student ═══════════ [HttpGet("my-warnings")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyWarnings(CancellationToken ct) { var sid = await GetStudentIdAsync(ct); if (sid is null) return StudentNotFound(); return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt) .Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId }) .ToListAsync(ct)); } [HttpPost("{id:guid}/acknowledge")] [Authorize(Roles = SystemRoles.Student)] public async Task Acknowledge(Guid id, [FromBody] AckBody body, CancellationToken ct) { var sid = await GetStudentIdAsync(ct); if (sid is null) return StudentNotFound(); var w = await db.WarningRecords.FirstOrDefaultAsync(x => x.Id == id && x.StudentId == sid, ct); if (w is null) return NotFound(); if (w.Status != WarningStatus.Active) return ConflictProblem("该预警已处理。"); w.Status = WarningStatus.Acknowledged; w.AcknowledgedAt = DateTime.UtcNow; w.AcknowledgeComment = body.Comment?.Trim(); await db.SaveChangesAsync(ct); return NoContent(); } // ═══════════ Detection logic ═══════════ private async Task> DetectFailedCredits(WarningRule rule, Guid termId, List students, CancellationToken ct) { var records = new List(); foreach (var s in students) { var failed = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct); if (failed >= rule.Threshold) { if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.FailedCredits, ct)) continue; records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = failed, Detail = $"不及格课程 {failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }); } } return records; } private async Task> DetectLowGPA(WarningRule rule, Guid termId, List students, CancellationToken ct) { var records = new List(); foreach (var s in students) { var grades = await db.GradeRecords.Where(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct); if (grades.Count == 0) continue; var gpa = grades.Average(x => x.GradePoint!.Value); if (gpa < rule.Threshold) { if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.LowGPA, ct)) continue; records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId }); } } return records; } private async Task> DetectAbsenteeism(WarningRule rule, Guid termId, List students, CancellationToken ct) { var records = new List(); foreach (var s in students) { var absent = await db.AttendanceRecords.CountAsync(x => x.StudentId == s.Id && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct); if (absent >= rule.Threshold) { if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.Absenteeism, ct)) continue; records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = absent, Detail = $"缺勤/迟到 {absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId }); } } return records; } private async Task> DetectGraduationDelay(WarningRule rule, Guid termId, List students, CancellationToken ct) { var records = new List(); foreach (var s in students) { var total = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct); if (total >= rule.Threshold) { if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.GraduationDelay, ct)) continue; records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = total, Detail = $"累计不及格 {total} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }); } } return records; } private async Task GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct); private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 }); private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 }); } public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId); public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description); public sealed record AckBody([MaxLength(300)] string? Comment);