学业预警
This commit is contained in:
1 parent
5e4c76a9e4
commit
db3a275601
8 files changed
+536
No files matched your search
@@ -0,0 +1,216 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/warnings")]
|
||||||
|
public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
// ═══════════ Rules (admin) ═══════════
|
||||||
|
[HttpGet("rules")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
||||||
|
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
|
||||||
|
.OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description })
|
||||||
|
.ToListAsync(ct));
|
||||||
|
|
||||||
|
[HttpPut("rules")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
||||||
|
db.WarningRules.RemoveRange(existing);
|
||||||
|
foreach (var r in rules)
|
||||||
|
{
|
||||||
|
db.WarningRules.Add(new WarningRule
|
||||||
|
{
|
||||||
|
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(),
|
||||||
|
Threshold = r.Threshold, IsEnabled = r.IsEnabled,
|
||||||
|
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor,
|
||||||
|
Description = r.Description?.Trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════ Detection + Records ═══════════
|
||||||
|
[HttpPost("detect")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> DetectWarnings(Guid academicTermId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rules = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled).ToListAsync(ct);
|
||||||
|
if (rules.Count == 0) return Ok(new { Generated = 0, Message = "当前学期无启用的预警规则。" });
|
||||||
|
|
||||||
|
var studentInfos = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => new StudentInfo(x.Id, x.UserId, x.Name, x.AdministrativeClassId)).ToListAsync(ct);
|
||||||
|
var generated = new List<WarningRecord>();
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
switch (rule.Type)
|
||||||
|
{
|
||||||
|
case WarningType.FailedCredits:
|
||||||
|
generated.AddRange(await DetectFailedCredits(rule, academicTermId, studentInfos, ct));
|
||||||
|
break;
|
||||||
|
case WarningType.LowGPA:
|
||||||
|
generated.AddRange(await DetectLowGPA(rule, academicTermId, studentInfos, ct));
|
||||||
|
break;
|
||||||
|
case WarningType.Absenteeism:
|
||||||
|
generated.AddRange(await DetectAbsenteeism(rule, academicTermId, studentInfos, ct));
|
||||||
|
break;
|
||||||
|
case WarningType.GraduationDelay:
|
||||||
|
generated.AddRange(await DetectGraduationDelay(rule, academicTermId, studentInfos, ct));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generated.Count > 0)
|
||||||
|
{
|
||||||
|
db.WarningRecords.AddRange(generated);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
foreach (var w in generated)
|
||||||
|
{
|
||||||
|
var si = studentInfos.First(s => s.Id == w.StudentId);
|
||||||
|
var r = rules.First(r => r.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 counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||||
|
if (counselorId != default)
|
||||||
|
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { Generated = generated.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("records")]
|
||||||
|
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
|
||||||
|
public async Task<ActionResult> GetRecords(Guid? academicTermId, WarningType? type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = db.WarningRecords.AsNoTracking().AsQueryable();
|
||||||
|
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
|
||||||
|
{
|
||||||
|
var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct);
|
||||||
|
if (classIds.Count > 0)
|
||||||
|
q = q.Where(x => classIds.Contains(x.Student!.AdministrativeClassId));
|
||||||
|
else if (scope.Current.RestrictedCollegeId.HasValue)
|
||||||
|
q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId);
|
||||||
|
}
|
||||||
|
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (type.HasValue) q = q.Where(x => x.Type == type);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════ Student ═══════════
|
||||||
|
[HttpGet("my-warnings")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> GetMyWarnings(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sid = await GetStudentIdAsync(ct);
|
||||||
|
if (sid is null) return StudentNotFound();
|
||||||
|
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
||||||
|
.Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
||||||
|
.ToListAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/acknowledge")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> Acknowledge(Guid id, [FromBody] AckBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sid = await GetStudentIdAsync(ct);
|
||||||
|
if (sid is null) return StudentNotFound();
|
||||||
|
var w = await db.WarningRecords.FirstOrDefaultAsync(x => x.Id == id && x.StudentId == sid, ct);
|
||||||
|
if (w is null) return NotFound();
|
||||||
|
if (w.Status != WarningStatus.Active) return ConflictProblem("该预警已处理。");
|
||||||
|
w.Status = WarningStatus.Acknowledged;
|
||||||
|
w.AcknowledgedAt = DateTime.UtcNow;
|
||||||
|
w.AcknowledgeComment = body.Comment?.Trim();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════ Detection logic ═══════════
|
||||||
|
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var records = new List<WarningRecord>();
|
||||||
|
foreach (var s in students)
|
||||||
|
{
|
||||||
|
var failed = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
||||||
|
if (failed >= rule.Threshold)
|
||||||
|
{
|
||||||
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.FailedCredits, ct)) continue;
|
||||||
|
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = failed, Detail = $"不及格课程 {failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var records = new List<WarningRecord>();
|
||||||
|
foreach (var s in students)
|
||||||
|
{
|
||||||
|
var grades = await db.GradeRecords.Where(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct);
|
||||||
|
if (grades.Count == 0) continue;
|
||||||
|
var gpa = grades.Average(x => x.GradePoint!.Value);
|
||||||
|
if (gpa < rule.Threshold)
|
||||||
|
{
|
||||||
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.LowGPA, ct)) continue;
|
||||||
|
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var records = new List<WarningRecord>();
|
||||||
|
foreach (var s in students)
|
||||||
|
{
|
||||||
|
var absent = await db.AttendanceRecords.CountAsync(x => x.StudentId == s.Id && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct);
|
||||||
|
if (absent >= rule.Threshold)
|
||||||
|
{
|
||||||
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.Absenteeism, ct)) continue;
|
||||||
|
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = absent, Detail = $"缺勤/迟到 {absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var records = new List<WarningRecord>();
|
||||||
|
foreach (var s in students)
|
||||||
|
{
|
||||||
|
var total = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
||||||
|
if (total >= rule.Threshold)
|
||||||
|
{
|
||||||
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.GraduationDelay, ct)) continue;
|
||||||
|
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = total, Detail = $"累计不及格 {total} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||||
|
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 });
|
||||||
|
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AckBody([MaxLength(300)] string? Comment);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class WarningRule : EntityBase
|
||||||
|
{
|
||||||
|
public WarningType Type { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public decimal Threshold { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public bool NotifyStudent { get; set; } = true;
|
||||||
|
public bool NotifyCounselor { get; set; } = true;
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public AcademicTerm? AcademicTerm { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WarningRecord : EntityBase
|
||||||
|
{
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public WarningType Type { get; set; }
|
||||||
|
public WarningStatus Status { get; set; } = WarningStatus.Active;
|
||||||
|
public decimal TriggerValue { get; set; }
|
||||||
|
public required string Detail { get; set; }
|
||||||
|
public DateTime? AcknowledgedAt { get; set; }
|
||||||
|
public string? AcknowledgeComment { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WarningType
|
||||||
|
{
|
||||||
|
FailedCredits = 1,
|
||||||
|
LowGPA = 2,
|
||||||
|
Absenteeism = 3,
|
||||||
|
GraduationDelay = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WarningStatus
|
||||||
|
{
|
||||||
|
Active = 1,
|
||||||
|
Acknowledged = 2,
|
||||||
|
Resolved = 3,
|
||||||
|
Dismissed = 4
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
|
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
|
||||||
public DbSet<GradeModification> GradeModifications => Set<GradeModification>();
|
public DbSet<GradeModification> GradeModifications => Set<GradeModification>();
|
||||||
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
|
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
|
||||||
|
public DbSet<WarningRule> WarningRules => Set<WarningRule>();
|
||||||
|
public DbSet<WarningRecord> WarningRecords => Set<WarningRecord>();
|
||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||||
Set<StudentStatusChange>();
|
Set<StudentStatusChange>();
|
||||||
@@ -709,6 +711,26 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasOne(x => x.GradeRecord).WithMany()
|
entity.HasOne(x => x.GradeRecord).WithMany()
|
||||||
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
builder.Entity<WarningRule>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Name).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.Threshold).HasPrecision(7, 2);
|
||||||
|
entity.Property(x => x.Description).HasMaxLength(300);
|
||||||
|
entity.HasIndex(x => new { x.AcademicTermId, x.Type }).IsUnique();
|
||||||
|
entity.HasOne(x => x.AcademicTerm).WithMany()
|
||||||
|
.HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
builder.Entity<WarningRecord>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.TriggerValue).HasPrecision(7, 2);
|
||||||
|
entity.Property(x => x.Detail).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.AcknowledgeComment).HasMaxLength(300);
|
||||||
|
entity.HasIndex(x => new { x.StudentId, x.AcademicTermId, x.Type }).IsUnique();
|
||||||
|
entity.HasIndex(x => x.Status);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CourseSubstitution>(entity =>
|
builder.Entity<CourseSubstitution>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Reason).HasMaxLength(500);
|
entity.Property(x => x.Reason).HasMaxLength(500);
|
||||||
|
|||||||
Loaded 3 of 8 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user