From d23b5156b4c16dad69d5474255628bd0c5bf00e5 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 17:17:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E9=83=A8=E5=AE=8C=E6=88=90=E3=80=82?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E6=A3=80=E6=B5=8B=E7=B3=BB=E7=BB=9F=E6=A6=82?= =?UTF-8?q?=E8=A7=88=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增组件 ┌────────────────────────────────┬─────────────────────────────────────────────────────────┐ │ 组件 │ 说明 │ ├────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ WarningSchedule 实体 │ 定时配置:学期、星期几、时、分、启用/禁用、上次执行时间 │ ├────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ WarningCheckWorker │ BackgroundService,每分钟轮询一次,匹配到时间就执行检测 │ ├────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ GET/PUT /api/warnings/schedule │ 读取/保存定时配置 │ └────────────────────────────────┴─────────────────────────────────────────────────────────┘ 页面配置 在学业预警页面顶部新增「定时检测」区域: - 启用开关:一键开/关自动检测 - 星期选择:周一至周日 - 时分选择:精确到分钟 - 上次执行时间:自动显示 工作流程 每分钟 Worker 轮询 → 查询 WarningSchedules 表,匹配当前星期+时+分 → 检查是否已在本分钟执行过(防止重复) → 加载该学期全部启用规则 + 在籍学生 → 逐条规则、逐个学生检测 → 已存在的同型预警跳过,新预警写入 + 通知学生/辅导员 → 更新 LastRunAt 管理员配置"每周一 08:00 自动检测",系统就会在每周一早上 8 点整自动执行一次完整的学业预警检测。 --- .../Controllers/WarningsController.cs | 21 +++ .../Domain/Academic/WarningEntities.cs | 11 ++ .../Persistence/AppDbContext.cs | 7 + .../Persistence/DevelopmentSqliteMigrator.cs | 4 +- .../Scheduling/WarningCheckWorker.cs | 121 ++++++++++++++++++ src/Jiaowu.Api/Program.cs | 1 + web/src/views/WarningsView.vue | 33 +++++ 7 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Scheduling/WarningCheckWorker.cs diff --git a/src/Jiaowu.Api/Controllers/WarningsController.cs b/src/Jiaowu.Api/Controllers/WarningsController.cs index 84daebc..441247f 100644 --- a/src/Jiaowu.Api/Controllers/WarningsController.cs +++ b/src/Jiaowu.Api/Controllers/WarningsController.cs @@ -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 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 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); diff --git a/src/Jiaowu.Api/Domain/Academic/WarningEntities.cs b/src/Jiaowu.Api/Domain/Academic/WarningEntities.cs index 2e1452c..cf0a240 100644 --- a/src/Jiaowu.Api/Domain/Academic/WarningEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/WarningEntities.cs @@ -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, diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 21729fc..d86466a 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -62,6 +62,7 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet CourseSubstitutions => Set(); public DbSet WarningRules => Set(); public DbSet WarningRecords => Set(); + public DbSet WarningSchedules => Set(); public DbSet Notifications => Set(); public DbSet StudentStatusChanges => Set(); @@ -730,6 +731,12 @@ public sealed class AppDbContext(DbContextOptions options) entity.HasOne(x => x.Student).WithMany() .HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.HasIndex(x => x.AcademicTermId).IsUnique(); + entity.HasOne(x => x.AcademicTerm).WithMany() + .HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Cascade); + }); builder.Entity(entity => { diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 37b1d93..5d17229 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -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");""" ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/WarningCheckWorker.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/WarningCheckWorker.cs new file mode 100644 index 0000000..15b1739 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/WarningCheckWorker.cs @@ -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 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(); + 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() + .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(); + 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 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 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 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 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; + } +} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 08bc8f4..b05d486 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -101,6 +101,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services diff --git a/web/src/views/WarningsView.vue b/web/src/views/WarningsView.vue index 044fc21..4374582 100644 --- a/web/src/views/WarningsView.vue +++ b/web/src/views/WarningsView.vue @@ -16,6 +16,7 @@ const myWarnings = ref([]) const loading = ref(false) const detecting = ref(false) const filterType = ref('') +const schedule = reactive({ isEnabled: false, dayOfWeek: 1, hour: 8, minute: 0, lastRunAt: null as string | null }) const typeLabels: Record = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' } const statusLabels: Record = { 1: '生效中', 2: '已确认', 3: '已处理', 4: '已忽略' } @@ -54,6 +55,7 @@ async function load() { row.description = sr.description || '' } } + await loadSchedule() } if (isSuperAdmin.value || isCounselor.value) records.value = (await http.get('/warnings/records', { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined } })).data @@ -61,6 +63,16 @@ async function load() { } catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false } } +async function loadSchedule() { + if (!termId.value) return + try { const { data } = await http.get('/warnings/schedule', { params: { academicTermId: termId.value } }); Object.assign(schedule, data) } + catch (_) {} +} +async function saveSchedule() { + try { await http.put('/warnings/schedule', { isEnabled: schedule.isEnabled, dayOfWeek: schedule.dayOfWeek, hour: schedule.hour, minute: schedule.minute }, { params: { academicTermId: termId.value } }); ElMessage.success('已保存') } + catch (e) { ElMessage.error(apiErrorMessage(e)) } +} + async function saveRules() { try { const payload = ruleRows.map(r => ({ type: r.type, name: r.name, threshold: r.threshold, isEnabled: r.isEnabled, notifyStudent: r.notifyStudent, notifyCounselor: r.notifyCounselor, description: r.description })) @@ -102,6 +114,26 @@ onMounted(async () => { + +
+
+

定时检测

+ 保存定时 +
+
+ + + + + + + + + + 上次执行:{{ new Date(schedule.lastRunAt).toLocaleString('zh-CN') }} +
+
+
@@ -166,6 +198,7 @@ onMounted(async () => {