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:
@@ -14,6 +14,7 @@ public sealed class BackgroundJobOptions
|
||||
public int SchedulePublishConcurrency { get; set; } = 1;
|
||||
public int MakeupExamAutoConcurrency { get; set; } = 1;
|
||||
public int ExamArrangementConcurrency { get; set; } = 1;
|
||||
public int ExamSignInExportConcurrency { get; set; } = 1;
|
||||
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
||||
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||
public bool UseQuorumQueues { get; set; } = true;
|
||||
@@ -31,6 +32,7 @@ public sealed class BackgroundJobOptions
|
||||
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
|
||||
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
|
||||
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +112,15 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var exportJobs = await db.ExamSignInExportJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == ExamSignInExportJobStatus.Queued ||
|
||||
x.Status == ExamSignInExportJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.ExamSignInExport &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missingKeys = automaticJobs
|
||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||
@@ -121,6 +130,8 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
(BackgroundJobKind.MakeupExamAuto, id)))
|
||||
.Concat(arrangementJobs.Select(id =>
|
||||
(BackgroundJobKind.ExamArrangement, id)))
|
||||
.Concat(exportJobs.Select(id =>
|
||||
(BackgroundJobKind.ExamSignInExport, id)))
|
||||
.ToList();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
|
||||
@@ -90,6 +90,11 @@ public sealed class BackgroundJobRunner(
|
||||
.GetRequiredService<ExamArrangementJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamSignInExport:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamSignInExportJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported background job kind '{message.JobKind}'.");
|
||||
|
||||
@@ -317,7 +317,8 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
BackgroundJobKind.SchedulePublish,
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
BackgroundJobKind.ExamArrangement
|
||||
BackgroundJobKind.ExamArrangement,
|
||||
BackgroundJobKind.ExamSignInExport
|
||||
];
|
||||
|
||||
public static async Task<IConnection> CreateConnectionAsync(
|
||||
@@ -414,6 +415,7 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.SchedulePublish => "schedule.publish",
|
||||
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
||||
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
DateOnly ExamDate,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
Guid? RequiredBuildingId);
|
||||
string RequiredBuildingIdsKey);
|
||||
|
||||
private sealed record RoomOccupancy(
|
||||
Guid ClassroomId,
|
||||
@@ -167,8 +167,10 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
|
||||
var availableRooms = rooms
|
||||
.Where(room =>
|
||||
(!group.Key.RequiredBuildingId.HasValue ||
|
||||
room.BuildingId == group.Key.RequiredBuildingId.Value) &&
|
||||
(group.Key.RequiredBuildingIdsKey.Length == 0 ||
|
||||
group.Key.RequiredBuildingIdsKey
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Contains(room.BuildingId.ToString())) &&
|
||||
occupiedRooms.All(occupied =>
|
||||
occupied.ClassroomId != room.Id ||
|
||||
!ExamConflictRules.TimeOverlaps(
|
||||
@@ -180,12 +182,12 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
var selectedRooms = SelectRooms(
|
||||
availableRooms,
|
||||
candidates.Count);
|
||||
if (selectedRooms.Sum(x => x.Capacity) < candidates.Count)
|
||||
if (selectedRooms.Sum(x => x.Capacity / 2) < candidates.Count)
|
||||
{
|
||||
unavailableStudents += candidates.Count;
|
||||
messages.Add(
|
||||
$"“{groupSessions[0].TeachingTask!.Course!.Name}”缺少足够考场容量," +
|
||||
$"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity)} 座。");
|
||||
$"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity / 2)} 座。");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -194,7 +196,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
{
|
||||
var roomCandidates = candidates
|
||||
.Skip(offset)
|
||||
.Take(classroom.Capacity)
|
||||
.Take(classroom.Capacity / 2)
|
||||
.ToList();
|
||||
if (roomCandidates.Count == 0) break;
|
||||
offset += roomCandidates.Count;
|
||||
@@ -465,17 +467,17 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
while (remainingSeats > 0 && remainingRooms.Count > 0)
|
||||
{
|
||||
var room = remainingRooms
|
||||
.Where(x => x.Capacity >= remainingSeats)
|
||||
.OrderBy(x => x.Capacity)
|
||||
.Where(x => x.Capacity / 2 >= remainingSeats)
|
||||
.OrderBy(x => x.Capacity / 2)
|
||||
.ThenBy(x => x.Name)
|
||||
.FirstOrDefault()
|
||||
?? remainingRooms
|
||||
.OrderByDescending(x => x.Capacity)
|
||||
.OrderByDescending(x => x.Capacity / 2)
|
||||
.ThenBy(x => x.Name)
|
||||
.First();
|
||||
selected.Add(room);
|
||||
remainingRooms.Remove(room);
|
||||
remainingSeats -= room.Capacity;
|
||||
remainingSeats -= room.Capacity / 2;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
@@ -562,12 +564,35 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
};
|
||||
}
|
||||
|
||||
private static RoomGroupKey GroupKey(ExamSession session) => new(
|
||||
session.TeachingTask!.CourseId,
|
||||
session.ExamDate,
|
||||
session.StartPeriod,
|
||||
session.PeriodCount,
|
||||
session.RequiredBuildingId);
|
||||
private static RoomGroupKey GroupKey(ExamSession session)
|
||||
{
|
||||
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||
if (session.RequiredBuildingId.HasValue)
|
||||
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||
var key = buildingIds.Count == 0
|
||||
? string.Empty
|
||||
: string.Join(",", buildingIds.OrderBy(x => x));
|
||||
return new(
|
||||
session.TeachingTask!.CourseId,
|
||||
session.ExamDate,
|
||||
session.StartPeriod,
|
||||
session.PeriodCount,
|
||||
key);
|
||||
}
|
||||
|
||||
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static void ComputeTimesFromSlots(
|
||||
ExamSession session,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
||||
assignedRooms++;
|
||||
messages.Add(
|
||||
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)");
|
||||
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity / 2}座)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -170,10 +170,13 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount);
|
||||
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2);
|
||||
|
||||
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||
if (session.RequiredBuildingId.HasValue)
|
||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
||||
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||
if (buildingIds.Count > 0)
|
||||
query = query.Where(x => buildingIds.Contains(x.BuildingId));
|
||||
|
||||
var occupiedRoomIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
@@ -234,4 +237,18 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.Take(needed)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
||||
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
||||
Set<ExamArrangementJob>();
|
||||
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
|
||||
Set<ExamSignInExportJob>();
|
||||
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
||||
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||
Set<ExamSessionInvigilator>();
|
||||
@@ -709,6 +711,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
|
||||
entity.HasIndex(x => x.RequiredBuildingId);
|
||||
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
@@ -798,6 +801,15 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.RequestedByUserId);
|
||||
});
|
||||
builder.Entity<ExamSignInExportJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||
entity.Property(x => x.FileName).HasMaxLength(200);
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.PlanId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.RequestedByUserId);
|
||||
});
|
||||
builder.Entity<MakeupExamPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -813,6 +825,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
|
||||
entity.HasIndex(x => x.RequiredBuildingId);
|
||||
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
|
||||
@@ -373,6 +373,14 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
examArrangementJobsExist ? [] : ExamArrangementJobStatements,
|
||||
cancellationToken);
|
||||
|
||||
var exportJobsExist = await db.Database
|
||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExamSignInExportJobs'")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ExamSignInExportJobsMigration,
|
||||
exportJobsExist ? [] : ExamSignInExportJobStatements,
|
||||
cancellationToken);
|
||||
|
||||
var academicTermArchivingExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
@@ -1666,6 +1674,9 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingIds" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2;
|
||||
""",
|
||||
"""
|
||||
@@ -1680,6 +1691,7 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"StartsAt" TEXT NOT NULL,
|
||||
"EndsAt" TEXT NOT NULL,
|
||||
"RequiredBuildingId" TEXT NULL,
|
||||
"RequiredBuildingIds" TEXT NULL,
|
||||
"RequiredInvigilatorCount" INTEGER NOT NULL,
|
||||
"Notes" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
@@ -1975,7 +1987,7 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
[
|
||||
"""CREATE TABLE "MakeupExamPlans" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamPlans" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamPlans_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||
"""CREATE INDEX "IX_MakeupExamPlans_AcademicTermId_Status" ON "MakeupExamPlans" ("AcademicTermId", "Status");""",
|
||||
"""CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""",
|
||||
"""CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredBuildingIds" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""",
|
||||
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_StartsAt" ON "MakeupExamSessions" ("MakeupExamPlanId", "StartsAt");""",
|
||||
"""CREATE INDEX "IX_MakeupExamSessions_TeachingTaskId" ON "MakeupExamSessions" ("TeachingTaskId");""",
|
||||
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_ExamDate" ON "MakeupExamSessions" ("MakeupExamPlanId", "ExamDate");""",
|
||||
@@ -2005,6 +2017,16 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE UNIQUE INDEX "UX_ExamArrangementJobs_Kind_ActivePlan" ON "ExamArrangementJobs" ("Kind", "ActivePlanId");""",
|
||||
];
|
||||
|
||||
private const string ExamSignInExportJobsMigration = "ExamSignInExportJobs";
|
||||
|
||||
private static readonly string[] ExamSignInExportJobStatements =
|
||||
[
|
||||
"""CREATE TABLE "ExamSignInExportJobs" ("Id" TEXT NOT NULL, "PlanId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "RequestedByUserId" TEXT NULL, "FileName" TEXT NULL, "FileBytes" BLOB NULL, "FileSize" INTEGER NOT NULL, "CurrentStep" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "PK_ExamSignInExportJobs" PRIMARY KEY ("Id"));""",
|
||||
"""CREATE INDEX "IX_ExamSignInExportJobs_PlanId_CreatedAt" ON "ExamSignInExportJobs" ("PlanId", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_ExamSignInExportJobs_Status_CreatedAt" ON "ExamSignInExportJobs" ("Status", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_ExamSignInExportJobs_RequestedByUserId" ON "ExamSignInExportJobs" ("RequestedByUserId");""",
|
||||
];
|
||||
|
||||
private static readonly string[] UnifiedMessageCenterStatements =
|
||||
[
|
||||
"""
|
||||
|
||||
+5221
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRequiredBuildingIds : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RequiredBuildingIds",
|
||||
table: "MakeupExamSessions",
|
||||
type: "longtext",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RequiredBuildingIds",
|
||||
table: "ExamSessions",
|
||||
type: "longtext",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RequiredBuildingIds",
|
||||
table: "MakeupExamSessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RequiredBuildingIds",
|
||||
table: "ExamSessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5277
File diff suppressed because it is too large
Load Diff
+61
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExamSignInExportJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamSignInExportJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
FileName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||
FileBytes = table.Column<byte[]>(type: "longblob", nullable: true),
|
||||
FileSize = table.Column<int>(type: "int", nullable: false),
|
||||
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExamSignInExportJobs", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamSignInExportJobs_PlanId_CreatedAt",
|
||||
table: "ExamSignInExportJobs",
|
||||
columns: new[] { "PlanId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamSignInExportJobs_RequestedByUserId",
|
||||
table: "ExamSignInExportJobs",
|
||||
column: "RequestedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamSignInExportJobs_Status_CreatedAt",
|
||||
table: "ExamSignInExportJobs",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamSignInExportJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -1778,6 +1778,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid?>("RequiredBuildingId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("RequiredBuildingIds")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("RequiredInvigilatorCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -1823,6 +1826,62 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("ExamSessionInvigilators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSignInExportJob", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("CurrentStep")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("varchar(2000)");
|
||||
|
||||
b.Property<byte[]>("FileBytes")
|
||||
.HasColumnType("longblob");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<int>("FileSize")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("PlanId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("RequestedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequestedByUserId");
|
||||
|
||||
b.HasIndex("PlanId", "CreatedAt");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("ExamSignInExportJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2488,6 +2547,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid?>("RequiredBuildingId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("RequiredBuildingIds")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("RequiredInvigilatorCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user