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
@@ -205,6 +205,7 @@ public sealed class ExamsController(
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
item.RequiredBuildingIds,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name
: null,
@@ -323,6 +324,7 @@ public sealed class ExamsController(
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
item.RequiredBuildingIds,
item.RequiredBuildingName,
item.RequiredInvigilatorCount,
item.Notes,
@@ -374,6 +376,7 @@ public sealed class ExamsController(
StartsAt = startsAt,
EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes),
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
@@ -453,6 +456,7 @@ public sealed class ExamsController(
StartsAt = startsAt,
EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes)
}));
@@ -505,6 +509,7 @@ public sealed class ExamsController(
session.StartsAt = startsAt;
session.EndsAt = endsAt;
session.RequiredBuildingId = request.RequiredBuildingId;
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
session.Notes = Normalize(request.Notes);
@@ -1123,6 +1128,89 @@ public sealed class ExamsController(
$"考场签名单-{FileNamePart(plan.Name)}.xlsx");
}
// ═══════════════════════════════════════════
// Sign-in sheet export (background job)
// ═══════════════════════════════════════════
[HttpPost("plans/{id:guid}/sign-in-export")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> StartSignInExport(
Guid id,
CancellationToken cancellationToken)
{
var planExists = await db.ExamPlans.AsNoTracking()
.AnyAsync(x => x.Id == id, cancellationToken);
if (!planExists) return NotFound();
var userId = currentUserDataScope.Current.UserId;
var job = new ExamSignInExportJob
{
PlanId = id,
RequestedByUserId = userId == Guid.Empty ? null : userId,
CurrentStep = "等待后台生成"
};
db.ExamSignInExportJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamSignInExport,
job.Id));
await db.SaveChangesAsync(cancellationToken);
return AcceptedAtAction(
nameof(GetSignInExportJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message = "考场签名单导出任务已提交。"
});
}
[HttpGet("sign-in-exports/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetSignInExportJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamSignInExportJobs.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
if (job is null) return NotFound();
return Ok(new
{
job.Id,
job.PlanId,
Status = job.Status.ToString(),
job.FileName,
job.FileSize,
job.CurrentStep,
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
});
}
[HttpGet("sign-in-exports/{jobId:guid}/download")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DownloadSignInExport(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamSignInExportJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Status == ExamSignInExportJobStatus.Succeeded,
cancellationToken);
if (job is null || job.FileBytes is null || job.FileBytes.Length == 0)
return NotFound();
return File(
job.FileBytes,
ExcelWorkbookHelper.ContentType,
job.FileName ?? "考场签名单.xlsx");
}
// ═══════════════════════════════════════════
// My schedule
// ═══════════════════════════════════════════
@@ -1573,6 +1661,10 @@ public sealed class ExamsController(
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
ids is { Count: > 0 }
? System.Text.Json.JsonSerializer.Serialize(ids)
: null;
private static string FileNamePart(string value)
{
var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
@@ -1601,6 +1693,7 @@ public sealed record CreateExamSessionRequest(
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount,
IReadOnlyCollection<Guid>? InvigilatorIds,
[MaxLength(500)] string? Notes);
@@ -1611,6 +1704,7 @@ public sealed record CreateExamSessionsBatchRequest(
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount,
[MaxLength(500)] string? Notes);