全部完成。定时检测系统概览:
新增组件
┌────────────────────────────────┬─────────────────────────────────────────────────────────┐
│ 组件 │ 说明 │
├────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ WarningSchedule 实体 │ 定时配置:学期、星期几、时、分、启用/禁用、上次执行时间 │
├────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ WarningCheckWorker │ BackgroundService,每分钟轮询一次,匹配到时间就执行检测 │
├────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ GET/PUT /api/warnings/schedule │ 读取/保存定时配置 │
└────────────────────────────────┴─────────────────────────────────────────────────────────┘
页面配置
在学业预警页面顶部新增「定时检测」区域:
- 启用开关:一键开/关自动检测
- 星期选择:周一至周日
- 时分选择:精确到分钟
- 上次执行时间:自动显示
工作流程
每分钟 Worker 轮询
→ 查询 WarningSchedules 表,匹配当前星期+时+分
→ 检查是否已在本分钟执行过(防止重复)
→ 加载该学期全部启用规则 + 在籍学生
→ 逐条规则、逐个学生检测
→ 已存在的同型预警跳过,新预警写入 + 通知学生/辅导员
→ 更新 LastRunAt
管理员配置"每周一 08:00 自动检测",系统就会在每周一早上 8 点整自动执行一次完整的学业预警检测。
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
|
||||
public sealed class WarningCheckWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<WarningCheckWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await RunScheduledChecksAsync(db, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { logger.LogError(ex, "Warning check worker error"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunScheduledChecksAsync(AppDbContext db, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
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)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var schedule in schedules)
|
||||
{
|
||||
if (schedule.LastRunAt.HasValue && schedule.LastRunAt.Value >= currentMinute)
|
||||
continue;
|
||||
|
||||
schedule.LastRunAt = 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 generated = new List<WarningRecord>();
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
foreach (var sid in studentIds)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
||||
if (r.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<WarningRecord?> CheckFailedCredits(AppDbContext db, WarningRule rule, Guid termId, Guid sid, 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;
|
||||
}
|
||||
private static async Task<WarningRecord?> CheckLowGPA(AppDbContext db, WarningRule rule, Guid termId, Guid sid, 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