434 lines
24 KiB
C#
434 lines
24 KiB
C#
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);
|