Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/WarningsController.cs
T
biss 08396a6e23 优化学业预警检测:
通知阶段移除逐条查询学生、规则、辅导员的 N+1 数据库访问。
不及格预警从“每个学生一次统计 + 一次查重”改为批量分组统计与一次性已有记录集合查询。
保持原有阈值、去重、通知内容与权限行为。
2026-08-11 12:06:04 +08:00

301 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ActionResult> 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<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> 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<ActionResult> 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<WarningRecord>();
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);
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);
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 });
}
[HttpGet("records")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult<PagedResult<WarningRecordListItem>>> GetRecords(
Guid? academicTermId,
WarningType? type,
int page = 1,
int pageSize = 20,
CancellationToken ct = default)
{
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
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);
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.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);
return Ok(new PagedResult<WarningRecordListItem>(items, total, page, pageSize));
}
// ═══════════ Student ═══════════
[HttpGet("my-warnings")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> 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));
}
[HttpPost("{id:guid}/acknowledge")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> 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<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> 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<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{
var records = new List<WarningRecord>();
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<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{
var records = new List<WarningRecord>();
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<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{
var records = new List<WarningRecord>();
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<Guid?> 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);