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 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; } }