学业预警自动化
This commit is contained in:
@@ -62,7 +62,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
|
||||
public DbSet<WarningRule> WarningRules => Set<WarningRule>();
|
||||
public DbSet<WarningRecord> WarningRecords => Set<WarningRecord>();
|
||||
public DbSet<WarningSchedule> WarningSchedules => Set<WarningSchedule>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||
Set<StudentStatusChange>();
|
||||
@@ -731,12 +730,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<WarningSchedule>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.AcademicTermId).IsUnique();
|
||||
entity.HasOne(x => x.AcademicTerm).WithMany()
|
||||
.HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSubstitution>(entity =>
|
||||
{
|
||||
|
||||
@@ -1624,7 +1624,10 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE TABLE "WarningRecords" ("Id" TEXT NOT NULL CONSTRAINT "PK_WarningRecords" PRIMARY KEY, "StudentId" TEXT NOT NULL, "Type" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "TriggerValue" TEXT NOT NULL, "Detail" TEXT NOT NULL, "AcknowledgedAt" TEXT NULL, "AcknowledgeComment" TEXT NULL, "AcademicTermId" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_WarningRecords_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT);""",
|
||||
"""CREATE UNIQUE INDEX "IX_WarningRecords_StudentId_AcademicTermId_Type" ON "WarningRecords" ("StudentId", "AcademicTermId", "Type");""",
|
||||
"""CREATE INDEX "IX_WarningRecords_Status" ON "WarningRecords" ("Status");""",
|
||||
"""CREATE TABLE "WarningSchedules" ("Id" TEXT NOT NULL CONSTRAINT "PK_WarningSchedules" PRIMARY KEY, "AcademicTermId" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "DayOfWeek" INTEGER NOT NULL, "Hour" INTEGER NOT NULL, "Minute" INTEGER NOT NULL, "LastRunAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_WarningSchedules_AcademicTerms" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE CASCADE);""",
|
||||
"""CREATE UNIQUE INDEX "IX_WarningSchedules_AcademicTermId" ON "WarningSchedules" ("AcademicTermId");"""
|
||||
"""ALTER TABLE "WarningRules" ADD COLUMN "AutoCheckEnabled" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "WarningRules" ADD COLUMN "CheckDayOfWeek" INTEGER NULL;""",
|
||||
"""ALTER TABLE "WarningRules" ADD COLUMN "CheckHour" INTEGER NOT NULL DEFAULT 8;""",
|
||||
"""ALTER TABLE "WarningRules" ADD COLUMN "CheckMinute" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;"""
|
||||
];
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
public partial class WarningAutoCheck : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable("WarningSchedules");
|
||||
migrationBuilder.AddColumn<bool>("AutoCheckEnabled", "WarningRules", type: "tinyint(1)", nullable: false, defaultValue: false);
|
||||
migrationBuilder.AddColumn<int?>("CheckDayOfWeek", "WarningRules", type: "int", nullable: true);
|
||||
migrationBuilder.AddColumn<int>("CheckHour", "WarningRules", type: "int", nullable: false, defaultValue: 8);
|
||||
migrationBuilder.AddColumn<int>("CheckMinute", "WarningRules", type: "int", nullable: false, defaultValue: 0);
|
||||
migrationBuilder.AddColumn<DateTime?>("LastCheckAt", "WarningRules", type: "datetime(6)", nullable: true);
|
||||
}
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn("LastCheckAt", "WarningRules");
|
||||
migrationBuilder.DropColumn("CheckMinute", "WarningRules");
|
||||
migrationBuilder.DropColumn("CheckHour", "WarningRules");
|
||||
migrationBuilder.DropColumn("CheckDayOfWeek", "WarningRules");
|
||||
migrationBuilder.DropColumn("AutoCheckEnabled", "WarningRules");
|
||||
// WarningSchedules table recreation omitted for brevity
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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