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:
1 parent
9e0d4062a4
commit
52e4a8a5be
24 files changed
+11216
-31
No files matched your search
@@ -205,6 +205,7 @@ public sealed class ExamsController(
|
|||||||
item.StartsAt,
|
item.StartsAt,
|
||||||
item.EndsAt,
|
item.EndsAt,
|
||||||
item.RequiredBuildingId,
|
item.RequiredBuildingId,
|
||||||
|
item.RequiredBuildingIds,
|
||||||
RequiredBuildingName = item.RequiredBuilding != null
|
RequiredBuildingName = item.RequiredBuilding != null
|
||||||
? item.RequiredBuilding.Name
|
? item.RequiredBuilding.Name
|
||||||
: null,
|
: null,
|
||||||
@@ -323,6 +324,7 @@ public sealed class ExamsController(
|
|||||||
item.StartsAt,
|
item.StartsAt,
|
||||||
item.EndsAt,
|
item.EndsAt,
|
||||||
item.RequiredBuildingId,
|
item.RequiredBuildingId,
|
||||||
|
item.RequiredBuildingIds,
|
||||||
item.RequiredBuildingName,
|
item.RequiredBuildingName,
|
||||||
item.RequiredInvigilatorCount,
|
item.RequiredInvigilatorCount,
|
||||||
item.Notes,
|
item.Notes,
|
||||||
@@ -374,6 +376,7 @@ public sealed class ExamsController(
|
|||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
RequiredBuildingId = request.RequiredBuildingId,
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
Notes = Normalize(request.Notes),
|
Notes = Normalize(request.Notes),
|
||||||
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||||
@@ -453,6 +456,7 @@ public sealed class ExamsController(
|
|||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
RequiredBuildingId = request.RequiredBuildingId,
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
}));
|
}));
|
||||||
@@ -505,6 +509,7 @@ public sealed class ExamsController(
|
|||||||
session.StartsAt = startsAt;
|
session.StartsAt = startsAt;
|
||||||
session.EndsAt = endsAt;
|
session.EndsAt = endsAt;
|
||||||
session.RequiredBuildingId = request.RequiredBuildingId;
|
session.RequiredBuildingId = request.RequiredBuildingId;
|
||||||
|
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
|
||||||
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||||
session.Notes = Normalize(request.Notes);
|
session.Notes = Normalize(request.Notes);
|
||||||
|
|
||||||
@@ -1123,6 +1128,89 @@ public sealed class ExamsController(
|
|||||||
$"考场签名单-{FileNamePart(plan.Name)}.xlsx");
|
$"考场签名单-{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
|
// My schedule
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -1573,6 +1661,10 @@ public sealed class ExamsController(
|
|||||||
});
|
});
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
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)
|
private static string FileNamePart(string value)
|
||||||
{
|
{
|
||||||
var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
|
var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
|
||||||
@@ -1601,6 +1693,7 @@ public sealed record CreateExamSessionRequest(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 6)] int PeriodCount,
|
[Range(1, 6)] int PeriodCount,
|
||||||
Guid? RequiredBuildingId,
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
@@ -1611,6 +1704,7 @@ public sealed record CreateExamSessionsBatchRequest(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 6)] int PeriodCount,
|
[Range(1, 6)] int PeriodCount,
|
||||||
Guid? RequiredBuildingId,
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ public sealed class MakeupExamsController(
|
|||||||
item.StartsAt,
|
item.StartsAt,
|
||||||
item.EndsAt,
|
item.EndsAt,
|
||||||
item.RequiredBuildingId,
|
item.RequiredBuildingId,
|
||||||
|
item.RequiredBuildingIds,
|
||||||
RequiredBuildingName = item.RequiredBuilding != null
|
RequiredBuildingName = item.RequiredBuilding != null
|
||||||
? item.RequiredBuilding.Name : null,
|
? item.RequiredBuilding.Name : null,
|
||||||
item.RequiredInvigilatorCount,
|
item.RequiredInvigilatorCount,
|
||||||
@@ -220,6 +221,7 @@ public sealed class MakeupExamsController(
|
|||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
RequiredBuildingId = request.RequiredBuildingId,
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
Notes = Normalize(request.Notes),
|
Notes = Normalize(request.Notes),
|
||||||
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||||
@@ -288,6 +290,7 @@ public sealed class MakeupExamsController(
|
|||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
RequiredBuildingId = request.RequiredBuildingId,
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
}));
|
}));
|
||||||
@@ -339,6 +342,7 @@ public sealed class MakeupExamsController(
|
|||||||
session.StartsAt = startsAt;
|
session.StartsAt = startsAt;
|
||||||
session.EndsAt = endsAt;
|
session.EndsAt = endsAt;
|
||||||
session.RequiredBuildingId = request.RequiredBuildingId;
|
session.RequiredBuildingId = request.RequiredBuildingId;
|
||||||
|
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
|
||||||
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||||
session.Notes = Normalize(request.Notes);
|
session.Notes = Normalize(request.Notes);
|
||||||
|
|
||||||
@@ -1222,6 +1226,10 @@ public sealed class MakeupExamsController(
|
|||||||
});
|
});
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
|
||||||
|
ids is { Count: > 0 }
|
||||||
|
? System.Text.Json.JsonSerializer.Serialize(ids)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -1240,6 +1248,7 @@ public sealed record CreateMakeupExamSessionRequest(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 6)] int PeriodCount,
|
[Range(1, 6)] int PeriodCount,
|
||||||
Guid? RequiredBuildingId,
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
@@ -1250,6 +1259,7 @@ public sealed record CreateMakeupExamSessionsBatchRequest(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 6)] int PeriodCount,
|
[Range(1, 6)] int PeriodCount,
|
||||||
Guid? RequiredBuildingId,
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public sealed class ExamSession : EntityBase
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public Guid? RequiredBuildingId { get; set; }
|
public Guid? RequiredBuildingId { get; set; }
|
||||||
public Building? RequiredBuilding { get; set; }
|
public Building? RequiredBuilding { get; set; }
|
||||||
|
public string? RequiredBuildingIds { get; set; }
|
||||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||||
|
|||||||
Loaded 3 of 24 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user