1. 教室容量按 1/2 计算

- ExamArrangementService.cs: SelectRooms() 使用 Capacity / 2 选择房间、计算剩余座位和总容量
  - MakeupExamArrangementService.cs: 数据库查询用 x.Capacity >= enrolledCount * 2(等价于 Capacity/2 >=
  enrolledCount),消息显示有效座位数

  2. 教学楼限制多选

  - Domain: ExamSession 和 MakeupExamSession 新增 RequiredBuildingIds (JSON string),保留旧 RequiredBuildingId 向后兼容
  - Service: RoomGroupKey 改为字符串键确保值相等;GroupKey() 合并新旧字段
  - Controller: 请求 DTO 增加 RequiredBuildingIds (Guid 数组),响应包含该字段
  - DB: MySQL 迁移 + SQLite migrator 添加新列
  - Frontend: <el-select> 改为 multiple,新增 parseBuildingIds() 解析服务器返回的 JSON

  3. 导出签名单后台任务

  - 新增: ExamSignInExportJob 实体、ExamSignInExportJobProcessor、ExamSignInExportJobStatus 枚举
  - BackgroundJobKind: 新增 ExamSignInExport = 5
  - RabbitMQ: routing key exam.sign-in-export,队列 jiaowu.background-jobs.exam.sign-in-export
  - API: POST /sign-in-export 创建任务返回 202;GET /sign-in-exports/{jobId} 查询状态;GET
  /sign-in-exports/{jobId}/download 下载文件
  - Frontend: 导出改为异步任务 + 轮询 + 自动下载,显示进度条
  - 恢复: OutboxPublisher 启动时恢复未完成的任务,重试超限自动标记失败
This commit is contained in:
2026-07-27 22:00:35 +08:00 Unverified
parent 9e0d4062a4
commit 52e4a8a5be
24 changed files with 11220 additions and 35 deletions
@@ -0,0 +1,211 @@
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)
.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;
}
}