213 lines
8.5 KiB
C#
213 lines
8.5 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Jiaowu.Api.Infrastructure.Teaching;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
|
|
|
public sealed class ExamSignInExportJobProcessor(
|
|
AppDbContext db,
|
|
ILogger<ExamSignInExportJobProcessor> logger)
|
|
{
|
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
|
{
|
|
try
|
|
{
|
|
var job = await db.ExamSignInExportJobs
|
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
|
if (job is null ||
|
|
job.Status is ExamSignInExportJobStatus.Succeeded
|
|
or ExamSignInExportJobStatus.Failed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
job.Status = ExamSignInExportJobStatus.Running;
|
|
job.StartedAt ??= DateTime.UtcNow;
|
|
job.CompletedAt = null;
|
|
job.ErrorMessage = null;
|
|
job.CurrentStep = "正在生成考场签名单";
|
|
await db.SaveChangesAsync(stoppingToken);
|
|
|
|
var plan = await db.ExamPlans.AsNoTracking()
|
|
.Where(x => x.Id == job.PlanId)
|
|
.Select(x => new
|
|
{
|
|
x.Name,
|
|
TermName = x.AcademicTerm!.Name
|
|
})
|
|
.FirstOrDefaultAsync(stoppingToken);
|
|
if (plan is null)
|
|
{
|
|
await MarkFailedAsync(jobId, "考试计划不存在。");
|
|
return;
|
|
}
|
|
|
|
var rooms = await db.ExamRooms.AsNoTracking()
|
|
.Include(x => x.Course)
|
|
.Include(x => x.Classroom)
|
|
.ThenInclude(x => x!.Building)
|
|
.Include(x => x.Invigilators)
|
|
.ThenInclude(x => x.Teacher)
|
|
.Include(x => x.SessionLinks)
|
|
.ThenInclude(x => x.ExamSession)
|
|
.ThenInclude(x => x!.TeachingTask)
|
|
.Include(x => x.Seats)
|
|
.ThenInclude(x => x.Student)
|
|
.ThenInclude(x => x!.AdministrativeClass)
|
|
.Where(x => x.ExamPlanId == job.PlanId)
|
|
.OrderBy(x => x.ExamDate)
|
|
.ThenBy(x => x.StartPeriod)
|
|
.ThenBy(x => x.Classroom!.Building!.Name)
|
|
.ThenBy(x => x.Classroom!.Name)
|
|
.AsSplitQuery()
|
|
.ToListAsync(stoppingToken);
|
|
|
|
var legacySessions = await db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlanId == job.PlanId)
|
|
.Where(x => !x.RoomLinks.Any())
|
|
.OrderBy(x => x.ExamDate)
|
|
.ThenBy(x => x.StartPeriod)
|
|
.Select(session => new
|
|
{
|
|
session.Id,
|
|
session.TeachingTaskId,
|
|
session.ExamDate,
|
|
session.StartsAt,
|
|
session.EndsAt,
|
|
CourseCode = session.TeachingTask!.Course!.Code,
|
|
CourseName = session.TeachingTask.Course.Name,
|
|
session.TeachingTask.TaskNumber,
|
|
BuildingName = session.Classroom != null
|
|
? session.Classroom.Building!.Name
|
|
: null,
|
|
ClassroomName = session.Classroom != null
|
|
? session.Classroom.Name
|
|
: null,
|
|
InvigilatorNames = session.Invigilators
|
|
.OrderBy(item => item.Teacher!.TeacherNumber)
|
|
.Select(item => item.Teacher!.Name)
|
|
})
|
|
.ToListAsync(stoppingToken);
|
|
|
|
if (rooms.Count == 0 && legacySessions.Count == 0)
|
|
{
|
|
await MarkFailedAsync(jobId, "当前考试计划没有可导出的考试场次。");
|
|
return;
|
|
}
|
|
|
|
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
|
db,
|
|
legacySessions.Select(x => x.TeachingTaskId),
|
|
stoppingToken);
|
|
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
|
|
|
|
var sheets = rooms.Select(room => new ExamSignInSessionData(
|
|
room.Id,
|
|
room.ExamDate,
|
|
room.StartsAt,
|
|
room.EndsAt,
|
|
room.Course!.Code,
|
|
room.Course.Name,
|
|
string.Join(
|
|
"、",
|
|
room.SessionLinks
|
|
.Select(link =>
|
|
link.ExamSession!.TeachingTask!.TaskNumber)
|
|
.Distinct()
|
|
.OrderBy(x => x)),
|
|
room.Classroom!.Building!.Name,
|
|
room.Classroom.Name,
|
|
room.Invigilators
|
|
.OrderBy(item => item.Teacher!.TeacherNumber)
|
|
.Select(item => item.Teacher!.Name)
|
|
.ToList(),
|
|
room.Seats
|
|
.OrderBy(seat => seat.SeatNumber)
|
|
.Select(seat => new ExamSignInStudentData(
|
|
seat.StudentId,
|
|
seat.Student!.StudentNumber,
|
|
seat.Student.Name,
|
|
seat.Student.AdministrativeClass!.Name,
|
|
seat.SeatNumber))
|
|
.ToList()))
|
|
.Concat(legacySessions.Select(session =>
|
|
new ExamSignInSessionData(
|
|
session.Id,
|
|
session.ExamDate,
|
|
session.StartsAt,
|
|
session.EndsAt,
|
|
session.CourseCode,
|
|
session.CourseName,
|
|
session.TaskNumber,
|
|
session.BuildingName,
|
|
session.ClassroomName,
|
|
session.InvigilatorNames.ToList(),
|
|
studentsByTask[session.TeachingTaskId]
|
|
.Select((student, index) =>
|
|
new ExamSignInStudentData(
|
|
student.StudentId,
|
|
student.StudentNumber,
|
|
student.Name,
|
|
student.ClassName,
|
|
index + 1))
|
|
.ToList())))
|
|
.ToList();
|
|
|
|
var data = new ExamSignInWorkbookData(plan.Name, plan.TermName, sheets);
|
|
var bytes = ExamSignInWorkbookExporter.Create(data);
|
|
|
|
job.Status = ExamSignInExportJobStatus.Succeeded;
|
|
job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx";
|
|
job.FileBytes = bytes;
|
|
job.FileSize = bytes.Length;
|
|
job.CurrentStep = "生成完成";
|
|
job.CompletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(stoppingToken);
|
|
|
|
logger.LogInformation(
|
|
"Sign-in export job {JobId} for plan {PlanId} completed, {Size:N0} bytes.",
|
|
job.Id,
|
|
job.PlanId,
|
|
bytes.Length);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
logger.LogInformation(
|
|
"Sign-in export job {JobId} was interrupted by application shutdown.",
|
|
jobId);
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(exception, "Sign-in export job {JobId} failed.", jobId);
|
|
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
|
}
|
|
}
|
|
|
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
|
{
|
|
db.ChangeTracker.Clear();
|
|
var job = await db.ExamSignInExportJobs.FirstOrDefaultAsync(
|
|
x => x.Id == jobId,
|
|
CancellationToken.None);
|
|
if (job is null)
|
|
return;
|
|
|
|
job.Status = ExamSignInExportJobStatus.Failed;
|
|
job.CurrentStep = "生成失败";
|
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
|
job.CompletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(CancellationToken.None);
|
|
}
|
|
|
|
private static string SanitizeFileName(string value)
|
|
{
|
|
var invalid = System.IO.Path.GetInvalidFileNameChars().ToHashSet();
|
|
var normalized = new string(value
|
|
.Select(c => invalid.Contains(c) ? '_' : c)
|
|
.ToArray()).Trim();
|
|
return normalized.Length > 100 ? normalized[..100] : normalized;
|
|
}
|
|
}
|