全部完成。定时检测系统概览:

新增组件

  ┌────────────────────────────────┬─────────────────────────────────────────────────────────┐
  │              组件              │                          说明                           │
  ├────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ WarningSchedule 实体           │ 定时配置:学期、星期几、时、分、启用/禁用、上次执行时间 │
  ├────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ WarningCheckWorker             │ BackgroundService,每分钟轮询一次,匹配到时间就执行检测 │
  ├────────────────────────────────┼─────────────────────────────────────────────────────────┤
  │ GET/PUT /api/warnings/schedule │ 读取/保存定时配置                                       │
  └────────────────────────────────┴─────────────────────────────────────────────────────────┘

  页面配置

  在学业预警页面顶部新增「定时检测」区域:
  - 启用开关:一键开/关自动检测
  - 星期选择:周一至周日
  - 时分选择:精确到分钟
  - 上次执行时间:自动显示

  工作流程

  每分钟 Worker 轮询
    → 查询 WarningSchedules 表,匹配当前星期+时+分
    → 检查是否已在本分钟执行过(防止重复)
    → 加载该学期全部启用规则 + 在籍学生
    → 逐条规则、逐个学生检测
    → 已存在的同型预警跳过,新预警写入 + 通知学生/辅导员
    → 更新 LastRunAt

  管理员配置"每周一 08:00 自动检测",系统就会在每周一早上 8 点整自动执行一次完整的学业预警检测。
This commit is contained in:
2026-07-25 17:17:05 +08:00 Unverified
parent db3a275601
commit d23b5156b4
7 changed files with 197 additions and 1 deletions
@@ -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;
}
}