业务任务与 Outbox 消息同一事务落库,避免“数据库成功但消息没发出去”。 RabbitMQ 使用持久化消息、发布确认、手动 ACK、Quorum Queue、死信队列。 增加处理租约、心跳、异常重试、最大重试次数和幂等状态控制。 保留 InMemory 模式,开发环境无需安装 RabbitMQ。 支持多实例竞争消费,后续可以横向扩容。 新增 /health/messaging 消息系统健康检查。
242 lines
9.7 KiB
C#
242 lines
9.7 KiB
C#
using System.Text.Json;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
|
|
|
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);
|
|
}
|
|
}
|