全部完成。定时检测系统概览:
新增组件
┌────────────────────────────────┬─────────────────────────────────────────────────────────┐
│ 组件 │ 说明 │
├────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ WarningSchedule 实体 │ 定时配置:学期、星期几、时、分、启用/禁用、上次执行时间 │
├────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ WarningCheckWorker │ BackgroundService,每分钟轮询一次,匹配到时间就执行检测 │
├────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ GET/PUT /api/warnings/schedule │ 读取/保存定时配置 │
└────────────────────────────────┴─────────────────────────────────────────────────────────┘
页面配置
在学业预警页面顶部新增「定时检测」区域:
- 启用开关:一键开/关自动检测
- 星期选择:周一至周日
- 时分选择:精确到分钟
- 上次执行时间:自动显示
工作流程
每分钟 Worker 轮询
→ 查询 WarningSchedules 表,匹配当前星期+时+分
→ 检查是否已在本分钟执行过(防止重复)
→ 加载该学期全部启用规则 + 在籍学生
→ 逐条规则、逐个学生检测
→ 已存在的同型预警跳过,新预警写入 + 通知学生/辅导员
→ 更新 LastRunAt
管理员配置"每周一 08:00 自动检测",系统就会在每周一早上 8 点整自动执行一次完整的学业预警检测。
This commit is contained in:
@@ -115,6 +115,26 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
||||
}
|
||||
|
||||
// ═══════════ Schedule ═══════════
|
||||
[HttpGet("schedule")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetSchedule(Guid academicTermId, CancellationToken ct)
|
||||
{
|
||||
var s = await db.WarningSchedules.AsNoTracking().FirstOrDefaultAsync(x => x.AcademicTermId == academicTermId, ct);
|
||||
return Ok(s is null ? new { isEnabled = false, dayOfWeek = 1, hour = 8, minute = 0, lastRunAt = (DateTime?)null } : new { s.IsEnabled, s.DayOfWeek, s.Hour, s.Minute, s.LastRunAt });
|
||||
}
|
||||
|
||||
[HttpPut("schedule")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> SaveSchedule(Guid academicTermId, [FromBody] ScheduleDto dto, CancellationToken ct)
|
||||
{
|
||||
var s = await db.WarningSchedules.FirstOrDefaultAsync(x => x.AcademicTermId == academicTermId, ct);
|
||||
if (s is null) { s = new WarningSchedule { AcademicTermId = academicTermId }; db.WarningSchedules.Add(s); }
|
||||
s.IsEnabled = dto.IsEnabled; s.DayOfWeek = dto.DayOfWeek; s.Hour = dto.Hour; s.Minute = dto.Minute;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══════════ Student ═══════════
|
||||
[HttpGet("my-warnings")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
@@ -213,4 +233,5 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
|
||||
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
||||
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description);
|
||||
public sealed record ScheduleDto(bool IsEnabled, int DayOfWeek, int Hour, int Minute);
|
||||
public sealed record AckBody([MaxLength(300)] string? Comment);
|
||||
|
||||
@@ -36,6 +36,17 @@ public enum WarningType
|
||||
GraduationDelay = 4
|
||||
}
|
||||
|
||||
public sealed class WarningSchedule : EntityBase
|
||||
{
|
||||
public Guid AcademicTermId { get; set; }
|
||||
public AcademicTerm? AcademicTerm { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public int DayOfWeek { get; set; } = 1;
|
||||
public int Hour { get; set; } = 8;
|
||||
public int Minute { get; set; }
|
||||
public DateTime? LastRunAt { get; set; }
|
||||
}
|
||||
|
||||
public enum WarningStatus
|
||||
{
|
||||
Active = 1,
|
||||
|
||||
@@ -62,6 +62,7 @@ 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>();
|
||||
@@ -730,6 +731,12 @@ 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 =>
|
||||
{
|
||||
|
||||
@@ -1623,6 +1623,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE UNIQUE INDEX "IX_WarningRules_AcademicTermId_Type" ON "WarningRules" ("AcademicTermId", "Type");""",
|
||||
"""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 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");"""
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ builder.Services.AddScoped<SchedulePlanPublisher>();
|
||||
builder.Services.AddScoped<SchedulePublishJobProcessor>();
|
||||
builder.Services.AddSingleton<SchedulePublishJobQueue>();
|
||||
builder.Services.AddHostedService<SchedulePublishJobWorker>();
|
||||
builder.Services.AddHostedService<WarningCheckWorker>();
|
||||
builder.Services.AddScoped<ExamArrangementService>();
|
||||
|
||||
builder.Services
|
||||
|
||||
Reference in New Issue
Block a user