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 () => {