329 lines
14 KiB
C#
329 lines
14 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
||
using Jiaowu.Api.Domain.System;
|
||
using Jiaowu.Api.Infrastructure.Caching;
|
||
using Jiaowu.Api.Controllers;
|
||
using Jiaowu.Api.Infrastructure.Persistence;
|
||
using Jiaowu.Api.Infrastructure.Teaching;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using System.Text.Json;
|
||
|
||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||
|
||
public sealed class ExamPublishJobProcessor(
|
||
AppDbContext db,
|
||
IAppCache cache,
|
||
ILogger<ExamPublishJobProcessor> logger)
|
||
{
|
||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||
{
|
||
try
|
||
{
|
||
var job = await db.ExamPublishJobs
|
||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||
if (job is null ||
|
||
job.Status is ExamPublishJobStatus.Succeeded
|
||
or ExamPublishJobStatus.Failed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
job.Status = ExamPublishJobStatus.Running;
|
||
job.StartedAt ??= DateTime.UtcNow;
|
||
job.CompletedAt = null;
|
||
job.ErrorMessage = null;
|
||
job.CurrentStep = "正在校验考试计划";
|
||
await db.SaveChangesAsync(stoppingToken);
|
||
|
||
switch (job.Kind)
|
||
{
|
||
case ExamPublishJobKind.FormalExam:
|
||
await PublishFormalExamAsync(job, stoppingToken);
|
||
break;
|
||
case ExamPublishJobKind.MakeupExam:
|
||
await PublishMakeupExamAsync(job, stoppingToken);
|
||
break;
|
||
case ExamPublishJobKind.ExperimentProjects:
|
||
await PublishExperimentProjectsAsync(job, stoppingToken);
|
||
break;
|
||
default:
|
||
throw new InvalidOperationException(
|
||
$"不支持的考试发布类型:{job.Kind}。");
|
||
}
|
||
|
||
job.Status = ExamPublishJobStatus.Succeeded;
|
||
job.ActivePlanId = null;
|
||
job.CurrentStep = "发布完成";
|
||
job.CompletedAt = DateTime.UtcNow;
|
||
await db.SaveChangesAsync(stoppingToken);
|
||
|
||
await cache.RemoveByTagAsync(
|
||
AppCacheTags.Timetables,
|
||
stoppingToken);
|
||
|
||
logger.LogInformation(
|
||
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
|
||
job.Id,
|
||
job.Kind,
|
||
job.PlanId);
|
||
}
|
||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||
{
|
||
logger.LogInformation(
|
||
"Exam publish job {JobId} was interrupted by application shutdown.",
|
||
jobId);
|
||
throw;
|
||
}
|
||
catch (ExamPublishValidationException validationException)
|
||
{
|
||
logger.LogWarning(
|
||
validationException,
|
||
"Exam publish job {JobId} validation failed.",
|
||
jobId);
|
||
await MarkFailedAsync(jobId, validationException.Message);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
|
||
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||
}
|
||
}
|
||
|
||
private async Task PublishFormalExamAsync(
|
||
ExamPublishJob job,
|
||
CancellationToken ct)
|
||
{
|
||
var plan = await db.ExamPlans
|
||
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||
if (plan is null)
|
||
throw new ExamPublishValidationException("考试计划不存在。");
|
||
if (plan.Status != ExamPlanStatus.Draft)
|
||
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
|
||
|
||
// Step 1: check sessions exist — simple count, no JOIN
|
||
var sessionCount = await db.ExamSessions
|
||
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
|
||
if (sessionCount == 0)
|
||
throw new ExamPublishValidationException(
|
||
"至少安排一个考试场次后才能发布。");
|
||
|
||
// Step 2: load session summaries — only necessary columns, no Include chains
|
||
var sessions = await db.ExamSessions
|
||
.AsNoTracking()
|
||
.Where(x => x.ExamPlanId == job.PlanId)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.TeachingTaskId,
|
||
x.ClassroomId,
|
||
InvigilatorCount = x.Invigilators.Count,
|
||
RoomLinkCount = x.RoomLinks.Count
|
||
})
|
||
.ToListAsync(ct);
|
||
|
||
// Step 3: load roster counts — independent query
|
||
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
|
||
var rosterCounts = (await TeachingTaskRosterQuery
|
||
.LoadForTasksAsync(db, taskIds, ct))
|
||
.GroupBy(x => x.TeachingTaskId)
|
||
.ToDictionary(x => x.Key, x => x.Count());
|
||
|
||
// Step 4: validate each session's assignment completeness
|
||
var unassignedSessionIds = new List<Guid>();
|
||
foreach (var session in sessions)
|
||
{
|
||
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
|
||
if (session.RoomLinkCount > 0)
|
||
{
|
||
// Has room links — check seat assignment via separate query
|
||
var assignedSeatCount = await db.ExamSeats
|
||
.CountAsync(seat =>
|
||
seat.ExamSessionId == session.Id,
|
||
ct);
|
||
if (assignedSeatCount != rosterCount)
|
||
unassignedSessionIds.Add(session.Id);
|
||
}
|
||
else if (!session.ClassroomId.HasValue ||
|
||
session.InvigilatorCount == 0)
|
||
{
|
||
unassignedSessionIds.Add(session.Id);
|
||
}
|
||
}
|
||
|
||
if (unassignedSessionIds.Count > 0)
|
||
throw new ExamPublishValidationException(
|
||
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
|
||
"请先完成自动编排。");
|
||
|
||
// Step 5: check room invigilator sufficiency — separate query
|
||
var insufficientInvigilatorCount = await db.ExamRoomSessions
|
||
.Where(link =>
|
||
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||
link.ExamRoom.Invigilators.Count <
|
||
link.ExamRoom.RequiredInvigilatorCount)
|
||
.Select(link => link.ExamSessionId)
|
||
.Distinct()
|
||
.CountAsync(ct);
|
||
if (insufficientInvigilatorCount > 0)
|
||
throw new ExamPublishValidationException(
|
||
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
|
||
"请先完成自动编排。");
|
||
|
||
// Step 6: validate mixed rooms — split into two independent queries
|
||
// to avoid Seats × SessionLinks Cartesian product
|
||
|
||
// 6a: capacity overflow
|
||
var overCapacityCount = await db.ExamRooms
|
||
.Where(r => r.ExamPlanId == job.PlanId)
|
||
.Select(r => new
|
||
{
|
||
r.Id,
|
||
SeatCount = r.Seats.Count,
|
||
Capacity = r.Classroom!.Capacity
|
||
})
|
||
.CountAsync(x => x.SeatCount > x.Capacity, ct);
|
||
if (overCapacityCount > 0)
|
||
throw new ExamPublishValidationException(
|
||
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
|
||
|
||
// 6b: course mismatch
|
||
var courseMismatchCount = await db.ExamRoomSessions
|
||
.Where(link =>
|
||
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||
link.ExamSession!.TeachingTask!.CourseId !=
|
||
link.ExamRoom.CourseId)
|
||
.Select(link => link.ExamRoomId)
|
||
.Distinct()
|
||
.CountAsync(ct);
|
||
if (courseMismatchCount > 0)
|
||
throw new ExamPublishValidationException(
|
||
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
|
||
|
||
// Step 7: publish
|
||
plan.Status = ExamPlanStatus.Published;
|
||
plan.PublishedAt = DateTime.UtcNow;
|
||
await db.SaveChangesAsync(ct);
|
||
}
|
||
|
||
private async Task PublishMakeupExamAsync(
|
||
ExamPublishJob job,
|
||
CancellationToken ct)
|
||
{
|
||
var plan = await db.MakeupExamPlans
|
||
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||
if (plan is null)
|
||
throw new ExamPublishValidationException("补考计划不存在。");
|
||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
|
||
|
||
// Step 1: check sessions exist — simple count
|
||
var sessionCount = await db.MakeupExamSessions
|
||
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
|
||
if (sessionCount == 0)
|
||
throw new ExamPublishValidationException(
|
||
"至少安排一个考试场次后才能发布。");
|
||
|
||
// Step 2: load session summaries — only necessary columns
|
||
var sessions = await db.MakeupExamSessions
|
||
.AsNoTracking()
|
||
.Where(x => x.MakeupExamPlanId == job.PlanId)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.ClassroomId,
|
||
InvigilatorCount = x.Invigilators.Count,
|
||
EnrollmentCount = x.Enrollments.Count
|
||
})
|
||
.ToListAsync(ct);
|
||
|
||
// Step 3: validate completeness — no Include chain needed
|
||
var unassigned = sessions.Count(x =>
|
||
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
|
||
if (unassigned > 0)
|
||
throw new ExamPublishValidationException(
|
||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||
|
||
// Step 4: validate enrollments
|
||
var empty = sessions.Count(x => x.EnrollmentCount == 0);
|
||
if (empty > 0)
|
||
throw new ExamPublishValidationException(
|
||
$"还有 {empty} 个场次没有登记补考学生。");
|
||
|
||
// Step 5: publish
|
||
plan.Status = MakeupExamPlanStatus.Published;
|
||
plan.PublishedAt = DateTime.UtcNow;
|
||
await db.SaveChangesAsync(ct);
|
||
}
|
||
|
||
private async Task PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct)
|
||
{
|
||
var ids = JsonSerializer.Deserialize<List<Guid>>(job.ProjectIdsJson ?? "[]")?
|
||
.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
|
||
if (ids.Count is 0 or > 100)
|
||
throw new ExamPublishValidationException("实验发布任务的数据无效。请重新提交。");
|
||
|
||
var projects = await db.ExperimentProjects
|
||
.Include(x => x.Sessions)
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Course)
|
||
.Where(x => ids.Contains(x.Id))
|
||
.ToListAsync(ct);
|
||
if (projects.Count != ids.Count)
|
||
throw new ExamPublishValidationException("部分实验项目不存在,请刷新后重新提交。");
|
||
|
||
foreach (var project in projects)
|
||
{
|
||
if (project.Status != ExperimentProjectStatus.Draft)
|
||
throw new ExamPublishValidationException("批量发布只能包含草稿实验项目。");
|
||
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)
|
||
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
|
||
if (!hasSchedule)
|
||
throw new ExamPublishValidationException($"“{project.Name}”尚未具备发布条件。");
|
||
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
|
||
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
|
||
}
|
||
|
||
job.CurrentStep = "正在发布实验项目";
|
||
await db.SaveChangesAsync(ct);
|
||
var publishedAt = DateTime.UtcNow;
|
||
foreach (var project in projects)
|
||
{
|
||
project.Status = ExperimentProjectStatus.Published;
|
||
project.PublishedAt = publishedAt;
|
||
}
|
||
await db.SaveChangesAsync(ct);
|
||
|
||
foreach (var project in projects)
|
||
{
|
||
var userIds = await TeachingTaskRosterQuery.ForTask(db, project.TeachingTaskId)
|
||
.Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct().ToListAsync(ct);
|
||
if (userIds.Count == 0) continue;
|
||
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约";
|
||
await NotificationService.SendToUserIdsAsync(db, userIds, "实验项目已发布",
|
||
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
||
"/experiments", ct, NotificationCategory.Schedule);
|
||
}
|
||
}
|
||
|
||
private async Task MarkFailedAsync(Guid jobId, string message)
|
||
{
|
||
db.ChangeTracker.Clear();
|
||
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
|
||
x => x.Id == jobId,
|
||
CancellationToken.None);
|
||
if (job is null)
|
||
return;
|
||
|
||
job.Status = ExamPublishJobStatus.Failed;
|
||
job.ActivePlanId = null;
|
||
job.CurrentStep = "发布失败";
|
||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||
job.CompletedAt = DateTime.UtcNow;
|
||
await db.SaveChangesAsync(CancellationToken.None);
|
||
}
|
||
}
|
||
|
||
public sealed class ExamPublishValidationException(string message)
|
||
: InvalidOperationException(message);
|