补考增强
This commit is contained in:
@@ -18,10 +18,13 @@ public sealed class MakeupExamsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
MakeupExamEligibilityService eligibilityService,
|
||||
MakeupExamArrangementService arrangementService) : ControllerBase
|
||||
MakeupExamArrangementService arrangementService,
|
||||
MakeupExamAutoJobQueue autoJobQueue) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
private const string ScoreEnterers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.Teacher;
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Plans
|
||||
@@ -284,6 +287,74 @@ public sealed class MakeupExamsController(
|
||||
return Ok(new { message = result.Message });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Auto-create (background job)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpPost("plans/{planId:guid}/auto-create")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> AutoCreate(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans
|
||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
|
||||
|
||||
// Check for existing active job
|
||||
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == planId &&
|
||||
(x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||
x.Status == MakeupExamAutoJobStatus.Running))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
return Ok(new { jobId = existing.Id, status = existing.Status.ToString(),
|
||||
message = "该计划已有正在执行的任务。" });
|
||||
|
||||
var job = new MakeupExamAutoJob
|
||||
{
|
||||
MakeupExamPlanId = planId
|
||||
};
|
||||
db.MakeupExamAutoJobs.Add(job);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
autoJobQueue.Enqueue(job.Id);
|
||||
|
||||
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
|
||||
new { jobId = job.Id, status = job.Status.ToString() });
|
||||
}
|
||||
|
||||
[HttpGet("auto-jobs/{jobId:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetAutoJob(
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
job.Id,
|
||||
job.MakeupExamPlanId,
|
||||
Status = job.Status.ToString(),
|
||||
job.TotalCourses,
|
||||
job.ProcessedCourses,
|
||||
job.CreatedSessions,
|
||||
job.EnrolledStudents,
|
||||
Messages = !string.IsNullOrEmpty(job.MessagesJson)
|
||||
? System.Text.Json.JsonSerializer.Deserialize<List<string>>(job.MessagesJson)
|
||||
: new List<string>(),
|
||||
job.ErrorMessage,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Enrollments
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -390,7 +461,7 @@ public sealed class MakeupExamsController(
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpPut("sessions/{id:guid}/scores")]
|
||||
[Authorize(Roles = Managers)]
|
||||
[Authorize(Roles = ScoreEnterers)]
|
||||
public async Task<ActionResult> RecordScores(
|
||||
Guid id,
|
||||
List<RecordMakeupScoreRequest> scores,
|
||||
@@ -399,11 +470,23 @@ public sealed class MakeupExamsController(
|
||||
var session = await db.MakeupExamSessions
|
||||
.Include(x => x.MakeupExamPlan)
|
||||
.Include(x => x.Enrollments)
|
||||
.Include(x => x.TeachingTask!)
|
||||
.ThenInclude(x => x.Teachers)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
|
||||
return ConflictProblem("只有已发布的补考计划可以录入成绩。");
|
||||
|
||||
// Teachers can only record scores for courses they teach
|
||||
if (!IsManager())
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var teaches = session.TeachingTask!.Teachers
|
||||
.Any(t => t.Teacher!.UserId == userId);
|
||||
if (!teaches)
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId);
|
||||
|
||||
foreach (var entry in scores)
|
||||
@@ -413,19 +496,22 @@ public sealed class MakeupExamsController(
|
||||
|
||||
enrollment.MakeupScore = entry.Score;
|
||||
|
||||
// Update the grade record
|
||||
// Update the grade record — 补考合格按60分记
|
||||
if (enrollment.SourceGradeRecordId.HasValue)
|
||||
{
|
||||
var record = await db.GradeRecords
|
||||
.Include(x => x.GradeSheet)
|
||||
.FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value,
|
||||
cancellationToken);
|
||||
if (record is not null)
|
||||
{
|
||||
record.ExamStatus = GradeExamStatus.Makeup;
|
||||
record.TotalScore = entry.Score;
|
||||
record.GradePoint = GradeCalculator.CalculateGradePoint(entry.Score);
|
||||
record.Notes = "补考成绩";
|
||||
// Cap passing score at 60
|
||||
var cappedScore = entry.Score >= 60 ? 60m : entry.Score;
|
||||
record.TotalScore = cappedScore;
|
||||
record.GradePoint = GradeCalculator.CalculateGradePoint(cappedScore);
|
||||
record.Notes = entry.Score >= 60
|
||||
? $"补考合格(原始{entry.Score}分,按60分记)"
|
||||
: $"补考不合格({entry.Score}分)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -650,6 +736,50 @@ public sealed class MakeupExamsController(
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// My teaching sessions (for score entry)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpGet("my-teaching-sessions")]
|
||||
[Authorize(Roles = SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMyTeachingSessions(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
return Ok(await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
|
||||
x.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId))
|
||||
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
PlanName = x.MakeupExamPlan!.Name,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||
EnrolledCount = x.Enrollments.Count,
|
||||
GradedCount = x.Enrollments.Count(e => e.MakeupScore != null),
|
||||
Enrollments = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
|
||||
.Select(e => new
|
||||
{
|
||||
e.StudentId,
|
||||
e.Student!.StudentNumber,
|
||||
e.Student.Name,
|
||||
ClassName = e.Student.AdministrativeClass!.Name,
|
||||
e.Reason,
|
||||
e.MakeupScore
|
||||
}).ToList(),
|
||||
IsMakeup = true
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Private helpers
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -69,3 +69,26 @@ public enum MakeupReason
|
||||
Absent = 2,
|
||||
DeferredApproved = 3
|
||||
}
|
||||
|
||||
public sealed class MakeupExamAutoJob : EntityBase
|
||||
{
|
||||
public Guid MakeupExamPlanId { get; set; }
|
||||
public MakeupExamPlan? MakeupExamPlan { get; set; }
|
||||
public MakeupExamAutoJobStatus Status { get; set; } = MakeupExamAutoJobStatus.Queued;
|
||||
public int TotalCourses { get; set; }
|
||||
public int ProcessedCourses { get; set; }
|
||||
public int CreatedSessions { get; set; }
|
||||
public int EnrolledStudents { get; set; }
|
||||
public string? MessagesJson { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum MakeupExamAutoJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class MakeupExamAutoJobQueue
|
||||
{
|
||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public void Enqueue(Guid jobId)
|
||||
{
|
||||
if (!_channel.Writer.TryWrite(jobId))
|
||||
throw new InvalidOperationException("补考自动生成任务队列当前不可用。");
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MakeupExamAutoJobWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
MakeupExamAutoJobQueue queue,
|
||||
ILogger<MakeupExamAutoJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Could not recover makeup exam auto jobs (table may not exist yet).");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var processor = scope.ServiceProvider
|
||||
.GetRequiredService<MakeupExamAutoJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Unexpected failure while dispatching makeup exam auto job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("Makeup exam auto job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var jobs = await db.MakeupExamAutoJobs
|
||||
.Where(x =>
|
||||
x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||
x.Status == MakeupExamAutoJobStatus.Running)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
job.Status = MakeupExamAutoJobStatus.Queued;
|
||||
job.StartedAt = null;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
}
|
||||
|
||||
if (jobs.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var job in jobs)
|
||||
queue.Enqueue(job.Id);
|
||||
|
||||
if (jobs.Count > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Recovered {JobCount} queued or interrupted makeup exam auto jobs.",
|
||||
jobs.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MakeupExamAutoJobProcessor(
|
||||
AppDbContext db,
|
||||
MakeupExamEligibilityService eligibilityService,
|
||||
ILogger<MakeupExamAutoJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.MakeupExamAutoJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is MakeupExamAutoJobStatus.Succeeded
|
||||
or MakeupExamAutoJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var plan = await db.MakeupExamPlans
|
||||
.Include(x => x.Sessions)
|
||||
.FirstOrDefaultAsync(x => x.Id == job.MakeupExamPlanId, stoppingToken);
|
||||
if (plan is null || plan.Status != MakeupExamPlanStatus.Draft)
|
||||
throw new InvalidOperationException("补考计划草稿不存在或已不允许修改。");
|
||||
|
||||
job.Status = MakeupExamAutoJobStatus.Running;
|
||||
job.StartedAt = DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.MessagesJson = null;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
// Get time slots for the term
|
||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
|
||||
.OrderBy(x => x.PeriodNumber)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (timeSlots.Count == 0)
|
||||
throw new InvalidOperationException("当前学期未配置上课时间表。");
|
||||
|
||||
var slotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
|
||||
|
||||
// Get all published teaching tasks for this term with grade sheets
|
||||
var tasks = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.Select(x => new { x.Id, x.TaskNumber, x.Name, CourseName = x.Course!.Name })
|
||||
.OrderBy(x => x.TaskNumber)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
job.TotalCourses = tasks.Count;
|
||||
job.ProcessedCourses = 0;
|
||||
job.CreatedSessions = 0;
|
||||
job.EnrolledStudents = 0;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
var messages = new List<string>();
|
||||
var existingTaskIds = plan.Sessions.Select(s => s.TeachingTaskId).ToHashSet();
|
||||
|
||||
// Get available classrooms for mixed-room assignment
|
||||
var allRooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Capacity)
|
||||
.Select(x => new { x.Id, x.Name, x.Capacity, x.BuildingId })
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
// Get available teachers
|
||||
var allTeachers = await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
// Auto-assign dates: spread sessions across available dates
|
||||
// Start from tomorrow, skip weekends, assign one session per time slot per day
|
||||
var startDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7));
|
||||
var dateCursor = startDate;
|
||||
var periodSlots = timeSlots
|
||||
.Where(x => x.PeriodNumber <= timeSlots.Count - 1)
|
||||
.Select(x => x.PeriodNumber)
|
||||
.ToList();
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
stoppingToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Skip if session already exists for this task
|
||||
if (existingTaskIds.Contains(task.Id))
|
||||
{
|
||||
job.ProcessedCourses++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Query eligible students
|
||||
var eligible = await eligibilityService.GetEligibleStudentsAsync(
|
||||
task.Id, stoppingToken);
|
||||
|
||||
if (eligible.Count == 0)
|
||||
{
|
||||
job.ProcessedCourses++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip weekends for date assignment
|
||||
while (dateCursor.DayOfWeek == DayOfWeek.Saturday ||
|
||||
dateCursor.DayOfWeek == DayOfWeek.Sunday)
|
||||
dateCursor = dateCursor.AddDays(1);
|
||||
|
||||
// Determine time slot (rotate through available period slots)
|
||||
var periodIdx = (job.CreatedSessions) % Math.Max(periodSlots.Count, 1);
|
||||
var startPeriod = periodSlots[periodIdx];
|
||||
var periodCount = 2;
|
||||
|
||||
// Resolve time
|
||||
var startSlot = slotLookup.GetValueOrDefault(startPeriod);
|
||||
var endSlot = slotLookup.GetValueOrDefault(startPeriod + periodCount - 1);
|
||||
if (startSlot is null || endSlot is null)
|
||||
{
|
||||
job.ProcessedCourses++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var startsAt = dateCursor.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
|
||||
var endsAt = dateCursor.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
|
||||
|
||||
// Find a classroom (mixed-subject: no room conflict check)
|
||||
var room = allRooms.FirstOrDefault(r => r.Capacity >= eligible.Count);
|
||||
|
||||
// Create session
|
||||
var session = new MakeupExamSession
|
||||
{
|
||||
MakeupExamPlanId = plan.Id,
|
||||
TeachingTaskId = task.Id,
|
||||
ClassroomId = room?.Id,
|
||||
ExamDate = dateCursor,
|
||||
StartPeriod = startPeriod,
|
||||
PeriodCount = periodCount,
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
RequiredInvigilatorCount = 2,
|
||||
Enrollments = eligible.Select(e => new MakeupExamEnrollment
|
||||
{
|
||||
StudentId = e.StudentId,
|
||||
Reason = e.Reason,
|
||||
SourceGradeRecordId = e.SourceGradeRecordId,
|
||||
SourceDeferredExamId = e.SourceDeferredExamId
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Auto-assign invigilators
|
||||
var excludeTeacherIds = new HashSet<Guid>();
|
||||
var assigned = 0;
|
||||
foreach (var teacher in allTeachers)
|
||||
{
|
||||
if (assigned >= 2) break;
|
||||
if (excludeTeacherIds.Contains(teacher.Id)) continue;
|
||||
session.Invigilators.Add(new MakeupExamSessionInvigilator
|
||||
{
|
||||
MakeupExamSessionId = session.Id,
|
||||
TeacherId = teacher.Id
|
||||
});
|
||||
excludeTeacherIds.Add(teacher.Id);
|
||||
assigned++;
|
||||
}
|
||||
|
||||
db.MakeupExamSessions.Add(session);
|
||||
existingTaskIds.Add(task.Id);
|
||||
|
||||
job.CreatedSessions++;
|
||||
job.EnrolledStudents += eligible.Count;
|
||||
job.ProcessedCourses++;
|
||||
|
||||
messages.Add(
|
||||
$"\"{task.CourseName}\"→{dateCursor:MM/dd} 第{startPeriod}节 " +
|
||||
$"{(room != null ? room.Name : "无可用教室")} · {eligible.Count}人");
|
||||
|
||||
// Advance date cursor every few sessions
|
||||
if (job.CreatedSessions % periodSlots.Count == 0)
|
||||
dateCursor = dateCursor.AddDays(1);
|
||||
|
||||
// Persist progress periodically
|
||||
if (job.ProcessedCourses % 10 == 0 || job.ProcessedCourses >= job.TotalCourses)
|
||||
await PersistProgressAsync(job, stoppingToken);
|
||||
}
|
||||
|
||||
job.Status = MakeupExamAutoJobStatus.Succeeded;
|
||||
job.MessagesJson = JsonSerializer.Serialize(messages.Take(20).ToList());
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Makeup exam auto job {JobId} completed: {Sessions} sessions, {Students} students.",
|
||||
job.Id, job.CreatedSessions, job.EnrolledStudents);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Makeup exam auto job {JobId} was interrupted by application shutdown.", jobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Makeup exam auto job {JobId} failed.", jobId);
|
||||
await MarkFailedAsync(jobId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistProgressAsync(
|
||||
MakeupExamAutoJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, Exception exception)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.MakeupExamAutoJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId, CancellationToken.None);
|
||||
if (job is null) return;
|
||||
|
||||
var message = exception.GetBaseException().Message;
|
||||
job.Status = MakeupExamAutoJobStatus.Failed;
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<MakeupExamSessionInvigilator>();
|
||||
public DbSet<MakeupExamEnrollment> MakeupExamEnrollments =>
|
||||
Set<MakeupExamEnrollment>();
|
||||
public DbSet<MakeupExamAutoJob> MakeupExamAutoJobs => Set<MakeupExamAutoJob>();
|
||||
public DbSet<CourseAdjustment> CourseAdjustments => Set<CourseAdjustment>();
|
||||
public DbSet<CourseExemption> CourseExemptions => Set<CourseExemption>();
|
||||
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
|
||||
@@ -650,6 +651,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.SourceDeferredExam).WithMany()
|
||||
.HasForeignKey(x => x.SourceDeferredExamId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
builder.Entity<MakeupExamAutoJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.MakeupExamPlanId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasOne(x => x.MakeupExamPlan).WithMany()
|
||||
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
builder.Entity<StudentStatusChange>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Reason).HasMaxLength(1000);
|
||||
|
||||
@@ -306,6 +306,14 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
MakeupExamsMigration,
|
||||
makeupExamsExist ? [] : MakeupExamStatements,
|
||||
cancellationToken);
|
||||
|
||||
var makeupAutoJobsExist = await db.Database
|
||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'MakeupExamAutoJobs'")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
MakeupExamAutoJobsMigration,
|
||||
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1675,4 +1683,13 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE TABLE "MakeupExamEnrollments" ("MakeupExamSessionId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "Reason" INTEGER NOT NULL, "SourceGradeRecordId" TEXT NULL, "SourceDeferredExamId" TEXT NULL, "MakeupScore" TEXT NULL, CONSTRAINT "PK_MakeupExamEnrollments" PRIMARY KEY ("MakeupExamSessionId", "StudentId"), CONSTRAINT "FK_MakeupExamEnrollments_MakeupExamSessions_MakeupExamSessionId" FOREIGN KEY ("MakeupExamSessionId") REFERENCES "MakeupExamSessions" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamEnrollments_Students_StudentId" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamEnrollments_GradeRecords_SourceGradeRecordId" FOREIGN KEY ("SourceGradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamEnrollments_DeferredExams_SourceDeferredExamId" FOREIGN KEY ("SourceDeferredExamId") REFERENCES "DeferredExams" ("Id") ON DELETE SET NULL);""",
|
||||
"""CREATE INDEX "IX_MakeupExamEnrollments_StudentId_MakeupExamSessionId" ON "MakeupExamEnrollments" ("StudentId", "MakeupExamSessionId");""",
|
||||
];
|
||||
|
||||
private const string MakeupExamAutoJobsMigration = "MakeupExamAutoJobs";
|
||||
|
||||
private static readonly string[] MakeupExamAutoJobStatements =
|
||||
[
|
||||
"""CREATE TABLE "MakeupExamAutoJobs" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "TotalCourses" INTEGER NOT NULL, "ProcessedCourses" INTEGER NOT NULL, "CreatedSessions" INTEGER NOT NULL, "EnrolledStudents" INTEGER NOT NULL, "MessagesJson" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamAutoJobs" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamAutoJobs_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE);""",
|
||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_MakeupExamPlanId_CreatedAt" ON "MakeupExamAutoJobs" ("MakeupExamPlanId", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ builder.Services.AddHostedService<WarningCheckWorker>();
|
||||
builder.Services.AddScoped<ExamArrangementService>();
|
||||
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
||||
builder.Services.AddScoped<MakeupExamArrangementService>();
|
||||
builder.Services.AddSingleton<MakeupExamAutoJobQueue>();
|
||||
builder.Services.AddHostedService<MakeupExamAutoJobWorker>();
|
||||
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
|
||||
Reference in New Issue
Block a user