学业预警自动化

This commit is contained in:
2026-07-25 17:28:53 +08:00 Unverified
parent d23b5156b4
commit 3d4d9953e8
7 changed files with 120 additions and 146 deletions
@@ -5,117 +5,99 @@ using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class WarningCheckWorker(
IServiceScopeFactory scopeFactory,
ILogger<WarningCheckWorker> logger) : BackgroundService
public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogger<WarningCheckWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!stoppingToken.IsCancellationRequested)
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(1), ct);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await RunScheduledChecksAsync(db, stoppingToken);
await RunAsync(db, ct);
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { logger.LogError(ex, "Warning check worker error"); }
catch (Exception ex) { logger.LogError(ex, "Warning check error"); }
}
}
private static async Task RunScheduledChecksAsync(AppDbContext db, CancellationToken ct)
private static async Task RunAsync(AppDbContext db, CancellationToken ct)
{
var now = DateTime.UtcNow;
var dow = (int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek;
var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
var schedules = await db.Set<WarningSchedule>()
.Where(s => s.IsEnabled &&
s.DayOfWeek == ((int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek) &&
s.Hour == now.Hour &&
s.Minute == now.Minute)
var rules = await db.WarningRules
.Where(r => r.IsEnabled && r.AutoCheckEnabled &&
r.CheckHour == now.Hour && r.CheckMinute == now.Minute &&
(r.CheckDayOfWeek == null || r.CheckDayOfWeek == dow))
.ToListAsync(ct);
foreach (var schedule in schedules)
foreach (var rule in rules)
{
if (schedule.LastRunAt.HasValue && schedule.LastRunAt.Value >= currentMinute)
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinute)
continue;
schedule.LastRunAt = now;
rule.LastCheckAt = now;
await db.SaveChangesAsync(ct);
var rules = await db.WarningRules
.Where(x => x.AcademicTermId == schedule.AcademicTermId && x.IsEnabled)
.ToListAsync(ct);
if (rules.Count == 0) continue;
var studentIds = await db.Students
.Where(x => x.Status == StudentStatus.Active)
.Select(x => x.Id)
.ToListAsync(ct);
var studentIds = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => x.Id).ToListAsync(ct);
var generated = new List<WarningRecord>();
foreach (var rule in rules)
foreach (var sid in studentIds)
{
foreach (var sid in studentIds)
if (await db.WarningRecords.AnyAsync(x => x.StudentId == sid && x.AcademicTermId == rule.AcademicTermId && x.Type == rule.Type, ct))
continue;
var w = rule.Type switch
{
if (await db.WarningRecords.AnyAsync(x => x.StudentId == sid && x.AcademicTermId == schedule.AcademicTermId && x.Type == rule.Type, ct))
continue;
WarningRecord? w = rule.Type switch
{
WarningType.FailedCredits => await CheckFailedCredits(db, rule, schedule.AcademicTermId, sid, ct),
WarningType.LowGPA => await CheckLowGPA(db, rule, schedule.AcademicTermId, sid, ct),
WarningType.Absenteeism => await CheckAbsenteeism(db, rule, schedule.AcademicTermId, sid, ct),
WarningType.GraduationDelay => await CheckDelay(db, rule, schedule.AcademicTermId, sid, ct),
_ => null
};
if (w is not null) generated.Add(w);
}
WarningType.FailedCredits => await CountWarning(db, rule, sid, rule.AcademicTermId,
sid => db.GradeRecords.CountAsync(x => x.StudentId == sid && x.GradeSheet!.TeachingTask!.AcademicTermId == rule.AcademicTermId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct),
n => $"不及格课程 {n} 门", ct),
WarningType.LowGPA => await GPACheck(db, rule, sid, rule.AcademicTermId, ct),
WarningType.Absenteeism => await CountWarning(db, rule, sid, rule.AcademicTermId,
sid => db.AttendanceRecords.CountAsync(x => x.StudentId == sid && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == rule.AcademicTermId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct),
n => $"缺勤/迟到 {n} 次", ct),
WarningType.GraduationDelay => await CountWarning(db, rule, sid, rule.AcademicTermId,
sid => db.GradeRecords.CountAsync(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct),
n => $"累计不及格 {n} 门", ct),
_ => null
};
if (w is not null) generated.Add(w);
}
if (generated.Count > 0)
{
db.WarningRecords.AddRange(generated);
await db.SaveChangesAsync(ct);
foreach (var w in generated)
{
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
var r = rules.First(x => x.Type == w.Type);
if (r.NotifyStudent && si.UserId.HasValue)
if (rule.NotifyStudent && si.UserId.HasValue)
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
if (r.NotifyCounselor)
if (rule.NotifyCounselor)
{
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
if (cid != default)
await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", cancellationToken: ct);
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", cancellationToken: ct);
}
}
}
}
}
private static async Task<WarningRecord?> CheckFailedCredits(AppDbContext db, WarningRule rule, Guid termId, Guid sid, CancellationToken ct)
private static async Task<WarningRecord?> CountWarning(AppDbContext db, WarningRule rule, Guid sid, Guid termId,
Func<Guid, Task<int>> counter, Func<int, string> msg, CancellationToken ct)
{
var n = await db.GradeRecords.CountAsync(x => x.StudentId == sid && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
return n >= rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.FailedCredits, TriggerValue = n, Detail = $"不及格课程 {n} 门,达到预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
var n = await counter(sid);
return n >= rule.Threshold ? new WarningRecord { StudentId = sid, Type = rule.Type, TriggerValue = n, Detail = $"{msg(n)},达到预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
}
private static async Task<WarningRecord?> CheckLowGPA(AppDbContext db, WarningRule rule, Guid termId, Guid sid, CancellationToken ct)
private static async Task<WarningRecord?> GPACheck(AppDbContext db, WarningRule rule, Guid sid, Guid termId, CancellationToken ct)
{
var grades = await db.GradeRecords.Where(x => x.StudentId == sid && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct);
if (grades.Count == 0) return null;
var gpa = grades.Average(x => x.GradePoint!.Value);
return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
}
private static async Task<WarningRecord?> CheckAbsenteeism(AppDbContext db, WarningRule rule, Guid termId, Guid sid, CancellationToken ct)
{
var n = await db.AttendanceRecords.CountAsync(x => x.StudentId == sid && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct);
return n >= rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.Absenteeism, TriggerValue = n, Detail = $"缺勤/迟到 {n} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId } : null;
}
private static async Task<WarningRecord?> CheckDelay(AppDbContext db, WarningRule rule, Guid termId, Guid sid, CancellationToken ct)
{
var n = await db.GradeRecords.CountAsync(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
return n >= rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.GraduationDelay, TriggerValue = n, Detail = $"累计不及格 {n} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId } : null;
}
}