Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/WarningsController.cs
T
2026-08-23 09:21:04 +08:00

434 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
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, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
.ToListAsync(ct));
[HttpPut("rules")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
{
if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1))
return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest });
if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59))
return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest });
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
var incomingTypes = rules.Select(x => x.Type).ToHashSet();
db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type)));
foreach (var r in rules)
{
var entity = existing.FirstOrDefault(x => x.Type == r.Type);
if (entity is null)
{
entity = new WarningRule
{
AcademicTermId = academicTermId,
Type = r.Type,
Name = r.Name.Trim()
};
db.WarningRules.Add(entity);
}
entity.Name = r.Name.Trim();
entity.Threshold = r.Threshold;
entity.IsEnabled = r.IsEnabled;
entity.NotifyStudent = r.NotifyStudent;
entity.NotifyCounselor = r.NotifyCounselor;
entity.Description = r.Description?.Trim();
entity.AutoCheckEnabled = r.AutoCheckEnabled;
entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek;
entity.CheckHour = r.CheckHour;
entity.CheckMinute = r.CheckMinute;
}
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;
}
}
var followUpTaskCount = 0;
if (generated.Count > 0)
{
var studentsById = studentInfos.ToDictionary(student => student.Id);
var rulesByType = rules.ToDictionary(rule => rule.Type);
var classIds = studentInfos.Select(student => student.ClassId).Distinct().ToArray();
var counselorsByClass = await db.AdministrativeClasses.AsNoTracking()
.WhereIn(classIds, item => item.Id)
.Where(item => item.CounselorUserId != null)
.Select(item => new { item.Id, CounselorUserId = item.CounselorUserId!.Value })
.ToDictionaryAsync(item => item.Id, item => item.CounselorUserId, ct);
db.WarningRecords.AddRange(generated);
var riskCases = generated
.Where(warning => counselorsByClass.ContainsKey(studentsById[warning.StudentId].ClassId))
.Select(warning => new AcademicRiskCase
{
WarningRecord = warning,
StudentId = warning.StudentId,
AssignedCounselorUserId = counselorsByClass[studentsById[warning.StudentId].ClassId],
FollowUpDueDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7))
}).ToList();
followUpTaskCount = riskCases.Count;
db.AcademicRiskCases.AddRange(riskCases);
await db.SaveChangesAsync(ct);
foreach (var w in generated)
{
var si = studentsById[w.StudentId];
var r = rulesByType[w.Type];
if (r.NotifyStudent && si.UserId.HasValue)
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
if (r.NotifyCounselor && counselorsByClass.TryGetValue(si.ClassId, out var counselorId))
{
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
}
}
}
return Ok(new { Generated = generated.Count, FollowUpTasks = followUpTaskCount, Unassigned = generated.Count - followUpTaskCount });
}
[HttpGet("records")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult<CursorPagedResult<WarningRecordListItem>>> GetRecords(
Guid? academicTermId,
WarningType? type,
string? cursor = null,
int pageSize = 20,
CancellationToken ct = default)
{
if (pageSize is < 1 or > 100)
return ValidationProblem("每批条数应在 1 至 100 之间。");
if (!CursorToken.TryDecode<WarningCursor>(cursor, out var after))
return BadRequest("预警记录游标无效。");
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.WhereIn(classIds, x => 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);
if (after is not null)
{
q = q.Where(x => x.CreatedAt < after.CreatedAt ||
x.CreatedAt == after.CreatedAt && x.Id.CompareTo(after.Id) < 0);
}
var rows = await q
.OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.Take(pageSize + 1)
.Select(x => new WarningRecordListItem(
x.Id,
x.StudentId,
x.Student!.Name,
x.Student.StudentNumber,
x.Student.AdministrativeClass!.Name,
(int)x.Type,
(int)x.Status,
x.TriggerValue,
x.Detail,
x.AcknowledgedAt,
x.AcknowledgeComment,
x.CreatedAt))
.ToListAsync(ct);
var hasMore = rows.Count > pageSize;
var items = rows.Take(pageSize).ToArray();
var last = items.LastOrDefault();
return Ok(new CursorPagedResult<WarningRecordListItem>(
items,
hasMore && last is not null
? CursorToken.Encode(new WarningCursor(last.CreatedAt, last.Id))
: null,
hasMore,
pageSize));
}
private sealed record WarningCursor(DateTime CreatedAt, Guid Id);
// ═══════════ 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, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
.ToListAsync(ct));
}
[HttpGet("my-risk-cases")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyRiskCases(CancellationToken ct)
{
var studentId = await GetStudentIdAsync(ct);
if (studentId is null) return StudentNotFound();
return Ok(await db.AcademicRiskCases.AsNoTracking()
.Where(x => x.StudentId == studentId)
.OrderByDescending(x => x.CreatedAt)
.Select(x => new AcademicRiskCaseStudentItem(x.Id, (int)x.Status,
x.FollowUpDueDate, x.ReviewDate,
x.Entries.OrderByDescending(entry => entry.OccurredAt).Select(entry =>
new AcademicRiskCaseEntryItem(entry.Id, (int)entry.Type, entry.Content,
entry.OccurredAt, entry.RecordedByUser!.DisplayName)).ToList()))
.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();
}
// ═══════════ Confidential risk disposition ═══════════
[HttpGet("risk-cases")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> GetRiskCases(bool mineOnly = false, CancellationToken ct = default)
{
var current = scope.Current;
var q = db.AcademicRiskCases.AsNoTracking();
if (!IsRiskManager(current))
q = q.Where(x => x.AssignedCounselorUserId == current.UserId);
else if (mineOnly)
q = q.Where(x => x.AssignedCounselorUserId == current.UserId);
return Ok(await q.OrderBy(x => x.Status).ThenBy(x => x.FollowUpDueDate)
.Select(x => new AcademicRiskCaseListItem(x.Id, x.WarningRecordId, x.StudentId,
x.Student!.Name, x.Student.StudentNumber, x.Student.AdministrativeClass!.Name,
(int)x.WarningRecord!.Type, (int)x.Status, x.FollowUpDueDate, x.ReviewDate,
x.TaskCompletedAt, x.AssignedCounselorUser!.DisplayName, x.CreatedAt))
.ToListAsync(ct));
}
[HttpGet("risk-cases/{id:guid}")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> GetRiskCase(Guid id, CancellationToken ct)
{
var item = await RiskCaseQuery().FirstOrDefaultAsync(x => x.Id == id, ct);
if (item is null) return NotFound();
if (!CanAccessRiskCase(item)) return Forbid();
return Ok(new AcademicRiskCaseDetail(item.Id, item.WarningRecordId, item.StudentId,
item.Student!.Name, item.Student.StudentNumber, item.Student.AdministrativeClass!.Name,
item.WarningRecord!.Detail, (int)item.Status, item.FollowUpDueDate, item.ReviewDate,
item.TaskCompletedAt, item.AssignedCounselorUser!.DisplayName,
item.Entries.OrderByDescending(x => x.OccurredAt).Select(x => new AcademicRiskCaseEntryItem(
x.Id, (int)x.Type, x.Content, x.OccurredAt, x.RecordedByUser!.DisplayName)).ToList()));
}
[HttpPut("risk-cases/{id:guid}")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> UpdateRiskCase(Guid id, [FromBody] UpdateAcademicRiskCaseBody body, CancellationToken ct)
{
var item = await db.AcademicRiskCases.FirstOrDefaultAsync(x => x.Id == id, ct);
if (item is null) return NotFound();
if (!CanAccessRiskCase(item)) return Forbid();
if (!Enum.IsDefined(body.Status)) return ValidationProblem("处置状态无效。");
if (body.FollowUpDueDate < DateOnly.FromDateTime(item.CreatedAt))
return ValidationProblem("跟进截止日期不能早于任务创建日期。");
if (body.ReviewDate.HasValue && body.ReviewDate < body.FollowUpDueDate)
return ValidationProblem("复核日期不能早于跟进截止日期。");
item.FollowUpDueDate = body.FollowUpDueDate;
item.ReviewDate = body.ReviewDate;
item.Status = body.Status;
item.TaskCompletedAt = body.Status is AcademicRiskCaseStatus.PendingReview or AcademicRiskCaseStatus.Closed
? item.TaskCompletedAt ?? DateTime.UtcNow : null;
await db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("risk-cases/{id:guid}/entries")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> AddRiskCaseEntry(Guid id, [FromBody] CreateAcademicRiskCaseEntryBody body, CancellationToken ct)
{
var item = await db.AcademicRiskCases.FirstOrDefaultAsync(x => x.Id == id, ct);
if (item is null) return NotFound();
if (!CanAccessRiskCase(item)) return Forbid();
if (!Enum.IsDefined(body.Type)) return ValidationProblem("记录类别无效。");
if (string.IsNullOrWhiteSpace(body.Content)) return ValidationProblem("请填写记录内容。");
db.AcademicRiskCaseEntries.Add(new AcademicRiskCaseEntry
{
AcademicRiskCaseId = id, Type = body.Type, Content = body.Content.Trim(),
OccurredAt = body.OccurredAt ?? DateTime.UtcNow, RecordedByUserId = scope.Current.UserId
});
if (item.Status == AcademicRiskCaseStatus.PendingFollowUp) item.Status = AcademicRiskCaseStatus.InProgress;
await db.SaveChangesAsync(ct);
return NoContent();
}
private IQueryable<AcademicRiskCase> RiskCaseQuery() => db.AcademicRiskCases.AsNoTracking()
.Include(x => x.Student).ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.WarningRecord).Include(x => x.AssignedCounselorUser)
.Include(x => x.Entries).ThenInclude(x => x.RecordedByUser);
private static bool IsRiskManager(CurrentUserScope current) =>
current.IsInRole(SystemRoles.SuperAdmin) || current.IsInRole(SystemRoles.AcademicAdmin);
private bool CanAccessRiskCase(AcademicRiskCase item) =>
IsRiskManager(scope.Current) || item.AssignedCounselorUserId == scope.Current.UserId;
// ═══════════ Detection logic ═══════════
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{
var studentIds = students.Select(student => student.Id).ToArray();
var existingStudentIds = await db.WarningRecords.AsNoTracking()
.Where(record => record.AcademicTermId == termId && record.Type == rule.Type)
.Select(record => record.StudentId)
.ToHashSetAsync(ct);
var failedByStudent = await db.GradeRecords.AsNoTracking()
.Where(record => studentIds.Contains(record.StudentId) &&
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
record.GradeSheet.Status == GradeSheetStatus.Published &&
record.TotalScore < 60)
.GroupBy(record => record.StudentId)
.Select(group => new { StudentId = group.Key, Failed = group.Count() })
.ToListAsync(ct);
return failedByStudent
.Where(item => item.Failed >= rule.Threshold &&
!existingStudentIds.Contains(item.StudentId))
.Select(item => new WarningRecord
{
StudentId = item.StudentId,
Type = rule.Type,
TriggerValue = item.Failed,
Detail = $"不及格课程 {item.Failed} 门,达到预警阈值 {rule.Threshold} 门。",
AcademicTermId = termId
})
.ToList();
}
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{
var studentIds = students.Select(student => student.Id).ToArray();
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
var gpaByStudent = await db.GradeRecords.AsNoTracking()
.Where(record => studentIds.Contains(record.StudentId) &&
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
record.GradeSheet.Status == GradeSheetStatus.Published &&
record.GradePoint != null)
.GroupBy(record => record.StudentId)
.Select(group => new { StudentId = group.Key, Gpa = group.Average(record => record.GradePoint!.Value) })
.ToListAsync(ct);
return gpaByStudent.Where(item => item.Gpa < rule.Threshold && !existingStudentIds.Contains(item.StudentId))
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = Math.Round(item.Gpa, 2), Detail = $"平均绩点 {item.Gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId }).ToList();
}
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{
var studentIds = students.Select(student => student.Id).ToArray();
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
var absencesByStudent = await db.AttendanceRecords.AsNoTracking()
.Where(record => studentIds.Contains(record.StudentId) && record.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && record.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (record.Status == AttendanceStatus.Absent || record.Status == AttendanceStatus.Late))
.GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Absent = group.Count() }).ToListAsync(ct);
return absencesByStudent.Where(item => item.Absent >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Absent, Detail = $"缺勤/迟到 {item.Absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId }).ToList();
}
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{
var studentIds = students.Select(student => student.Id).ToArray();
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
var failedByStudent = await db.GradeRecords.AsNoTracking().Where(record => studentIds.Contains(record.StudentId) && record.GradeSheet!.Status == GradeSheetStatus.Published && record.TotalScore < 60).GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Failed = group.Count() }).ToListAsync(ct);
return failedByStudent.Where(item => item.Failed >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Failed, Detail = $"累计不及格 {item.Failed} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }).ToList();
}
private Task<HashSet<Guid>> ExistingWarningStudentIdsAsync(WarningType type, Guid termId, CancellationToken ct) =>
db.WarningRecords.AsNoTracking().Where(record => record.AcademicTermId == termId && record.Type == type).Select(record => record.StudentId).ToHashSetAsync(ct);
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 WarningRecordListItem(
Guid Id,
Guid StudentId,
string StudentName,
string StudentNumber,
string ClassName,
int Type,
int Status,
decimal TriggerValue,
string Detail,
DateTime? AcknowledgedAt,
string? AcknowledgeComment,
DateTime CreatedAt);
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
public sealed record AckBody([MaxLength(300)] string? Comment);
public sealed record AcademicRiskCaseListItem(Guid Id, Guid WarningRecordId, Guid StudentId,
string StudentName, string StudentNumber, string ClassName, int WarningType, int Status,
DateOnly FollowUpDueDate, DateOnly? ReviewDate, DateTime? TaskCompletedAt,
string AssignedCounselorName, DateTime CreatedAt);
public sealed record AcademicRiskCaseDetail(Guid Id, Guid WarningRecordId, Guid StudentId,
string StudentName, string StudentNumber, string ClassName, string WarningDetail, int Status,
DateOnly FollowUpDueDate, DateOnly? ReviewDate, DateTime? TaskCompletedAt,
string AssignedCounselorName, List<AcademicRiskCaseEntryItem> Entries);
public sealed record AcademicRiskCaseEntryItem(Guid Id, int Type, string Content, DateTime OccurredAt, string RecordedByName);
public sealed record AcademicRiskCaseStudentItem(Guid Id, int Status, DateOnly FollowUpDueDate,
DateOnly? ReviewDate, List<AcademicRiskCaseEntryItem> Entries);
public sealed record UpdateAcademicRiskCaseBody(AcademicRiskCaseStatus Status, DateOnly FollowUpDueDate, DateOnly? ReviewDate);
public sealed record CreateAcademicRiskCaseEntryBody(AcademicRiskEntryType Type, [MaxLength(2000)] string Content, DateTime? OccurredAt);