学业预警自动化
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user