using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Contracts; 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, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt }) .ToListAsync(ct)); [HttpPut("rules")] [Authorize(Roles = Managers)] public async Task SaveRules(Guid academicTermId, List rules, CancellationToken ct) { if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1)) return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest }); if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59)) return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest }); var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct); var incomingTypes = rules.Select(x => x.Type).ToHashSet(); db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type))); foreach (var r in rules) { var entity = existing.FirstOrDefault(x => x.Type == r.Type); if (entity is null) { entity = new WarningRule { AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim() }; db.WarningRules.Add(entity); } entity.Name = r.Name.Trim(); entity.Threshold = r.Threshold; entity.IsEnabled = r.IsEnabled; entity.NotifyStudent = r.NotifyStudent; entity.NotifyCounselor = r.NotifyCounselor; entity.Description = r.Description?.Trim(); entity.AutoCheckEnabled = r.AutoCheckEnabled; entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek; entity.CheckHour = r.CheckHour; entity.CheckMinute = r.CheckMinute; } 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; } } var followUpTaskCount = 0; if (generated.Count > 0) { var studentsById = studentInfos.ToDictionary(student => student.Id); var rulesByType = rules.ToDictionary(rule => rule.Type); var classIds = studentInfos.Select(student => student.ClassId).Distinct().ToArray(); var counselorsByClass = await db.AdministrativeClasses.AsNoTracking() .WhereIn(classIds, item => item.Id) .Where(item => item.CounselorUserId != null) .Select(item => new { item.Id, CounselorUserId = item.CounselorUserId!.Value }) .ToDictionaryAsync(item => item.Id, item => item.CounselorUserId, ct); db.WarningRecords.AddRange(generated); var riskCases = generated .Where(warning => counselorsByClass.ContainsKey(studentsById[warning.StudentId].ClassId)) .Select(warning => new AcademicRiskCase { WarningRecord = warning, StudentId = warning.StudentId, AssignedCounselorUserId = counselorsByClass[studentsById[warning.StudentId].ClassId], FollowUpDueDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)) }).ToList(); followUpTaskCount = riskCases.Count; db.AcademicRiskCases.AddRange(riskCases); await db.SaveChangesAsync(ct); foreach (var w in generated) { var si = studentsById[w.StudentId]; var r = rulesByType[w.Type]; if (r.NotifyStudent && si.UserId.HasValue) await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning); if (r.NotifyCounselor && counselorsByClass.TryGetValue(si.ClassId, out var counselorId)) { await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning); } } } return Ok(new { Generated = generated.Count, FollowUpTasks = followUpTaskCount, Unassigned = generated.Count - followUpTaskCount }); } [HttpGet("records")] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)] public async Task>> GetRecords( Guid? academicTermId, WarningType? type, string? cursor = null, int pageSize = 20, CancellationToken ct = default) { if (pageSize is < 1 or > 100) return ValidationProblem("每批条数应在 1 至 100 之间。"); if (!CursorToken.TryDecode(cursor, out var after)) return BadRequest("预警记录游标无效。"); 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.WhereIn(classIds, x => 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); if (after is not null) { q = q.Where(x => x.CreatedAt < after.CreatedAt || x.CreatedAt == after.CreatedAt && x.Id.CompareTo(after.Id) < 0); } var rows = await q .OrderByDescending(x => x.CreatedAt) .ThenByDescending(x => x.Id) .Take(pageSize + 1) .Select(x => new WarningRecordListItem( x.Id, x.StudentId, x.Student!.Name, x.Student.StudentNumber, x.Student.AdministrativeClass!.Name, (int)x.Type, (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt)) .ToListAsync(ct); var hasMore = rows.Count > pageSize; var items = rows.Take(pageSize).ToArray(); var last = items.LastOrDefault(); return Ok(new CursorPagedResult( items, hasMore && last is not null ? CursorToken.Encode(new WarningCursor(last.CreatedAt, last.Id)) : null, hasMore, pageSize)); } private sealed record WarningCursor(DateTime CreatedAt, Guid Id); // ═══════════ 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, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId }) .ToListAsync(ct)); } [HttpGet("my-risk-cases")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyRiskCases(CancellationToken ct) { var studentId = await GetStudentIdAsync(ct); if (studentId is null) return StudentNotFound(); return Ok(await db.AcademicRiskCases.AsNoTracking() .Where(x => x.StudentId == studentId) .OrderByDescending(x => x.CreatedAt) .Select(x => new AcademicRiskCaseStudentItem(x.Id, (int)x.Status, x.FollowUpDueDate, x.ReviewDate, x.Entries.OrderByDescending(entry => entry.OccurredAt).Select(entry => new AcademicRiskCaseEntryItem(entry.Id, (int)entry.Type, entry.Content, entry.OccurredAt, entry.RecordedByUser!.DisplayName)).ToList())) .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(); } // ═══════════ Confidential risk disposition ═══════════ [HttpGet("risk-cases")] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)] public async Task GetRiskCases(bool mineOnly = false, CancellationToken ct = default) { var current = scope.Current; var q = db.AcademicRiskCases.AsNoTracking(); if (!IsRiskManager(current)) q = q.Where(x => x.AssignedCounselorUserId == current.UserId); else if (mineOnly) q = q.Where(x => x.AssignedCounselorUserId == current.UserId); return Ok(await q.OrderBy(x => x.Status).ThenBy(x => x.FollowUpDueDate) .Select(x => new AcademicRiskCaseListItem(x.Id, x.WarningRecordId, x.StudentId, x.Student!.Name, x.Student.StudentNumber, x.Student.AdministrativeClass!.Name, (int)x.WarningRecord!.Type, (int)x.Status, x.FollowUpDueDate, x.ReviewDate, x.TaskCompletedAt, x.AssignedCounselorUser!.DisplayName, x.CreatedAt)) .ToListAsync(ct)); } [HttpGet("risk-cases/{id:guid}")] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)] public async Task GetRiskCase(Guid id, CancellationToken ct) { var item = await RiskCaseQuery().FirstOrDefaultAsync(x => x.Id == id, ct); if (item is null) return NotFound(); if (!CanAccessRiskCase(item)) return Forbid(); return Ok(new AcademicRiskCaseDetail(item.Id, item.WarningRecordId, item.StudentId, item.Student!.Name, item.Student.StudentNumber, item.Student.AdministrativeClass!.Name, item.WarningRecord!.Detail, (int)item.Status, item.FollowUpDueDate, item.ReviewDate, item.TaskCompletedAt, item.AssignedCounselorUser!.DisplayName, item.Entries.OrderByDescending(x => x.OccurredAt).Select(x => new AcademicRiskCaseEntryItem( x.Id, (int)x.Type, x.Content, x.OccurredAt, x.RecordedByUser!.DisplayName)).ToList())); } [HttpPut("risk-cases/{id:guid}")] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)] public async Task UpdateRiskCase(Guid id, [FromBody] UpdateAcademicRiskCaseBody body, CancellationToken ct) { var item = await db.AcademicRiskCases.FirstOrDefaultAsync(x => x.Id == id, ct); if (item is null) return NotFound(); if (!CanAccessRiskCase(item)) return Forbid(); if (!Enum.IsDefined(body.Status)) return ValidationProblem("处置状态无效。"); if (body.FollowUpDueDate < DateOnly.FromDateTime(item.CreatedAt)) return ValidationProblem("跟进截止日期不能早于任务创建日期。"); if (body.ReviewDate.HasValue && body.ReviewDate < body.FollowUpDueDate) return ValidationProblem("复核日期不能早于跟进截止日期。"); item.FollowUpDueDate = body.FollowUpDueDate; item.ReviewDate = body.ReviewDate; item.Status = body.Status; item.TaskCompletedAt = body.Status is AcademicRiskCaseStatus.PendingReview or AcademicRiskCaseStatus.Closed ? item.TaskCompletedAt ?? DateTime.UtcNow : null; await db.SaveChangesAsync(ct); return NoContent(); } [HttpPost("risk-cases/{id:guid}/entries")] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)] public async Task AddRiskCaseEntry(Guid id, [FromBody] CreateAcademicRiskCaseEntryBody body, CancellationToken ct) { var item = await db.AcademicRiskCases.FirstOrDefaultAsync(x => x.Id == id, ct); if (item is null) return NotFound(); if (!CanAccessRiskCase(item)) return Forbid(); if (!Enum.IsDefined(body.Type)) return ValidationProblem("记录类别无效。"); if (string.IsNullOrWhiteSpace(body.Content)) return ValidationProblem("请填写记录内容。"); db.AcademicRiskCaseEntries.Add(new AcademicRiskCaseEntry { AcademicRiskCaseId = id, Type = body.Type, Content = body.Content.Trim(), OccurredAt = body.OccurredAt ?? DateTime.UtcNow, RecordedByUserId = scope.Current.UserId }); if (item.Status == AcademicRiskCaseStatus.PendingFollowUp) item.Status = AcademicRiskCaseStatus.InProgress; await db.SaveChangesAsync(ct); return NoContent(); } private IQueryable RiskCaseQuery() => db.AcademicRiskCases.AsNoTracking() .Include(x => x.Student).ThenInclude(x => x!.AdministrativeClass) .Include(x => x.WarningRecord).Include(x => x.AssignedCounselorUser) .Include(x => x.Entries).ThenInclude(x => x.RecordedByUser); private static bool IsRiskManager(CurrentUserScope current) => current.IsInRole(SystemRoles.SuperAdmin) || current.IsInRole(SystemRoles.AcademicAdmin); private bool CanAccessRiskCase(AcademicRiskCase item) => IsRiskManager(scope.Current) || item.AssignedCounselorUserId == scope.Current.UserId; // ═══════════ Detection logic ═══════════ private async Task> DetectFailedCredits(WarningRule rule, Guid termId, List students, CancellationToken ct) { var studentIds = students.Select(student => student.Id).ToArray(); var existingStudentIds = await db.WarningRecords.AsNoTracking() .Where(record => record.AcademicTermId == termId && record.Type == rule.Type) .Select(record => record.StudentId) .ToHashSetAsync(ct); var failedByStudent = await db.GradeRecords.AsNoTracking() .Where(record => studentIds.Contains(record.StudentId) && record.GradeSheet!.TeachingTask!.AcademicTermId == termId && record.GradeSheet.Status == GradeSheetStatus.Published && record.TotalScore < 60) .GroupBy(record => record.StudentId) .Select(group => new { StudentId = group.Key, Failed = group.Count() }) .ToListAsync(ct); return failedByStudent .Where(item => item.Failed >= rule.Threshold && !existingStudentIds.Contains(item.StudentId)) .Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Failed, Detail = $"不及格课程 {item.Failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }) .ToList(); } private async Task> DetectLowGPA(WarningRule rule, Guid termId, List students, CancellationToken ct) { var studentIds = students.Select(student => student.Id).ToArray(); var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct); var gpaByStudent = await db.GradeRecords.AsNoTracking() .Where(record => studentIds.Contains(record.StudentId) && record.GradeSheet!.TeachingTask!.AcademicTermId == termId && record.GradeSheet.Status == GradeSheetStatus.Published && record.GradePoint != null) .GroupBy(record => record.StudentId) .Select(group => new { StudentId = group.Key, Gpa = group.Average(record => record.GradePoint!.Value) }) .ToListAsync(ct); return gpaByStudent.Where(item => item.Gpa < rule.Threshold && !existingStudentIds.Contains(item.StudentId)) .Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = Math.Round(item.Gpa, 2), Detail = $"平均绩点 {item.Gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId }).ToList(); } private async Task> DetectAbsenteeism(WarningRule rule, Guid termId, List students, CancellationToken ct) { var studentIds = students.Select(student => student.Id).ToArray(); var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct); var absencesByStudent = await db.AttendanceRecords.AsNoTracking() .Where(record => studentIds.Contains(record.StudentId) && record.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && record.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (record.Status == AttendanceStatus.Absent || record.Status == AttendanceStatus.Late)) .GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Absent = group.Count() }).ToListAsync(ct); return absencesByStudent.Where(item => item.Absent >= rule.Threshold && !existingStudentIds.Contains(item.StudentId)) .Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Absent, Detail = $"缺勤/迟到 {item.Absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId }).ToList(); } private async Task> DetectGraduationDelay(WarningRule rule, Guid termId, List students, CancellationToken ct) { var studentIds = students.Select(student => student.Id).ToArray(); var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct); var failedByStudent = await db.GradeRecords.AsNoTracking().Where(record => studentIds.Contains(record.StudentId) && record.GradeSheet!.Status == GradeSheetStatus.Published && record.TotalScore < 60).GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Failed = group.Count() }).ToListAsync(ct); return failedByStudent.Where(item => item.Failed >= rule.Threshold && !existingStudentIds.Contains(item.StudentId)) .Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Failed, Detail = $"累计不及格 {item.Failed} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }).ToList(); } private Task> ExistingWarningStudentIdsAsync(WarningType type, Guid termId, CancellationToken ct) => db.WarningRecords.AsNoTracking().Where(record => record.AcademicTermId == termId && record.Type == type).Select(record => record.StudentId).ToHashSetAsync(ct); 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 WarningRecordListItem( Guid Id, Guid StudentId, string StudentName, string StudentNumber, string ClassName, int Type, int Status, decimal TriggerValue, string Detail, DateTime? AcknowledgedAt, string? AcknowledgeComment, DateTime CreatedAt); public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute); public sealed record AckBody([MaxLength(300)] string? Comment); public sealed record AcademicRiskCaseListItem(Guid Id, Guid WarningRecordId, Guid StudentId, string StudentName, string StudentNumber, string ClassName, int WarningType, int Status, DateOnly FollowUpDueDate, DateOnly? ReviewDate, DateTime? TaskCompletedAt, string AssignedCounselorName, DateTime CreatedAt); public sealed record AcademicRiskCaseDetail(Guid Id, Guid WarningRecordId, Guid StudentId, string StudentName, string StudentNumber, string ClassName, string WarningDetail, int Status, DateOnly FollowUpDueDate, DateOnly? ReviewDate, DateTime? TaskCompletedAt, string AssignedCounselorName, List Entries); public sealed record AcademicRiskCaseEntryItem(Guid Id, int Type, string Content, DateTime OccurredAt, string RecordedByName); public sealed record AcademicRiskCaseStudentItem(Guid Id, int Status, DateOnly FollowUpDueDate, DateOnly? ReviewDate, List Entries); public sealed record UpdateAcademicRiskCaseBody(AcademicRiskCaseStatus Status, DateOnly FollowUpDueDate, DateOnly? ReviewDate); public sealed record CreateAcademicRiskCaseEntryBody(AcademicRiskEntryType Type, [MaxLength(2000)] string Content, DateTime? OccurredAt);