补考增强

This commit is contained in:
2026-07-25 19:38:39 +08:00 Unverified
parent 44e90ae35f
commit 1da6543a38
7 changed files with 714 additions and 10 deletions
@@ -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);
}
}