Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/MakeupExamsController.cs
T
biss 3ec0f117d3 参照现有的 ExamArrangementJob / SchedulePublishJob 后台任务模式,将发布改为通过 RabbitMQ
队列异步执行,拆分查询消除笛卡尔积。

  修改的文件(共 13 个)

  ┌───────────────────────────────────────────────────────────────┬──────────────────────────────────────────────────┐
  │                             文件                              │                       变更                       │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Domain/Academic/ExamEntities.cs                               │ 新增 ExamPublishJob 实体 + ExamPublishJobStatus  │
  │                                                               │ / ExamPublishJobKind 枚举                        │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Domain/System/BackgroundJobOutboxMessage.cs                   │ BackgroundJobKind 新增 ExamPublish = 6           │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Infrastructure/BackgroundJobs/BackgroundJobOptions.cs         │ 新增 ExamPublishConcurrency 配置项               │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Infrastructure/BackgroundJobs/BackgroundJobRunner.cs          │ RunAsync 和 MarkJobRetryLimitExceeded 添加       │
  │                                                               │ ExamPublish 分支                                 │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs       │ JobKinds 数组和 RoutingKey 添加 ExamPublish →    │
  │                                                               │ "exam.publish"                                   │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs │ 启动恢复逻辑添加 ExamPublishJobs                 │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Infrastructure/Persistence/AppDbContext.cs                    │ 新增 ExamPublishJobs DbSet                       │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Controllers/OperationsController.cs                           │ CountFailedJobsAsync / GetFailedJobs             │
  │                                                               │ 添加考试发布失败统计和筛选                       │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Program.cs                                                    │ 校验 ExamPublishConcurrency + 注册               │
  │                                                               │ ExamPublishJobProcessor                          │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Infrastructure/Exams/ExamPublishJobs.cs                       │ 新文件 —                                         │
  │                                                               │ ExamPublishJobProcessor,拆分查询校验后发布      │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Controllers/ExamsController.cs                                │ Publish 改为创建后台任务 + 202 返回;新增 GET    │
  │                                                               │ publish-jobs/{id} / GET plans/{id}/publish-job   │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ Controllers/MakeupExamsController.cs                          │ 同上改造                                         │
  ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
  │ tests/.../TeachingWorkflowRosterTests.cs                      │ 更新测试适配新的异步发布模式                     │
  └───────────────────────────────────────────────────────────────┴──────────────────────────────────────────────────┘

  笛卡尔积消除

  之前:一个 Include 链拉全部 → EF Core 生成 Sessions × Invigilators × RoomLinks × Seats 笛卡尔积

  之后:
  - 场次计数:db.ExamSessions.CountAsync(无 JOIN)
  - 场次摘要:Select new { Id, ClassroomId, InvigilatorCount, RoomLinkCount }(只查所需列)
  - 容量超限:db.ExamRooms.Select(r => new { SeatCount = r.Seats.Count, Capacity })(单表 JOIN)
  - 课程冲突:db.ExamRoomSessions.Where(link => ...CourseId != link.ExamRoom!.CourseId)(独立查询)
  - 每个查询只做自己需要的 JOIN,互不干扰

  测试结果

  213 通过,0 失败,0 跳过

  配置方式

  - BackgroundJobs__Transport=RabbitMq → 走 RabbitMQ 队列 jiaowu.background-jobs.exam.publish
  - BackgroundJobs__Transport=InMemory(默认) → 走内存 Channel
  - BackgroundJobs__ExamPublishConcurrency=1(默认,可调 1-16)
2026-07-28 08:10:22 +08:00

1358 lines
56 KiB
C#

using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/makeup-exams")]
public sealed class MakeupExamsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
MakeupExamEligibilityService eligibilityService) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string ScoreEnterers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.Teacher;
// ═══════════════════════════════════════════
// Plans
// ═══════════════════════════════════════════
[HttpGet("plans")]
public async Task<ActionResult> GetPlans(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = db.MakeupExamPlans.AsNoTracking().AsQueryable();
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (!IsManager())
source = source.Where(x => x.Status == MakeupExamPlanStatus.Published);
return Ok(await source.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.Status,
SessionCount = x.Sessions.Count,
x.Notes,
x.PublishedAt
}).ToListAsync(cancellationToken));
}
[HttpPost("plans")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreatePlan(
MakeupExamPlanRequest request,
CancellationToken cancellationToken)
{
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选学期不存在或已停用。");
var plan = new MakeupExamPlan
{
AcademicTermId = request.AcademicTermId,
Name = request.Name.Trim(),
Notes = Normalize(request.Notes)
};
db.MakeupExamPlans.Add(plan);
return await SaveAsync(plan.Id, true, cancellationToken);
}
[HttpGet("plans/{id:guid}")]
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
{
var manager = IsManager();
var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published))
.Select(x => new
{
x.Id,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.Notes,
x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
.ThenBy(item => item.StartPeriod).Select(item => new
{
item.Id,
item.TeachingTaskId,
item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name,
item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
item.RequiredBuildingIds,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
EnrolledCount = item.Enrollments.Count,
Enrollments = item.Enrollments.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.SourceGradeRecordId,
e.SourceDeferredExamId,
e.MakeupScore
})
})
}).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan);
}
[HttpPost("plans/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Status,
HasSessions = x.Sessions.Any()
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿补考计划可以发布。");
if (!plan.HasSessions)
return ConflictProblem("至少安排一个考试场次后才能发布。");
var userId = currentUserDataScope.Current.UserId;
var job = new ExamPublishJob
{
Kind = ExamPublishJobKind.MakeupExam,
PlanId = id,
ActivePlanId = id,
RequestedByUserId = userId == Guid.Empty ? null : userId,
CurrentStep = "等待后台校验"
};
db.ExamPublishJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamPublish,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
var existing = await FindActivePublishJobAsync(id, cancellationToken);
if (existing is not null)
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
throw;
}
return AcceptedPublishJob(job, "补考发布任务已提交。");
}
[HttpGet("publish-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetPublishJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Kind == ExamPublishJobKind.MakeupExam,
cancellationToken);
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
}
[HttpGet("plans/{planId:guid}/publish-job")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetLatestPublishJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.PlanId == planId &&
x.Kind == ExamPublishJobKind.MakeupExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
}
[HttpPost("plans/{id:guid}/archive")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Published)
return ConflictProblem("只有已发布的补考计划可以归档。");
plan.Status = MakeupExamPlanStatus.Archived;
return await SaveAsync(id, false, cancellationToken);
}
// ═══════════════════════════════════════════
// Sessions
// ═══════════════════════════════════════════
[HttpPost("plans/{planId:guid}/sessions")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSession(
Guid planId,
CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("已发布的补考计划不能调整场次。");
var timeResult = ResolveExamTime(plan.AcademicTermId,
request.ExamDate, request.StartPeriod, request.PeriodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var validation = await ValidateSessionAsync(plan, null,
request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
startsAt, endsAt, cancellationToken);
if (validation is not null) return validation;
var session = new MakeupExamSession
{
MakeupExamPlanId = planId,
TeachingTaskId = request.TeachingTaskId,
ClassroomId = request.ClassroomId,
ExamDate = request.ExamDate,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
StartsAt = startsAt,
EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes),
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
new MakeupExamSessionInvigilator { TeacherId = id }).ToList()
};
db.MakeupExamSessions.Add(session);
return await SaveAsync(session.Id, true, cancellationToken);
}
[HttpPost("plans/{planId:guid}/sessions/batch")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSessionsBatch(
Guid planId,
CreateMakeupExamSessionsBatchRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("已发布的补考计划不能调整场次。");
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
if (taskIds.Length == 0)
return ValidationProblem("请至少选择一个教学班。");
if (taskIds.Length > 100)
return ValidationProblem("一次最多选择100个教学班。");
var timeResult = ResolveExamTime(plan.AcademicTermId,
request.ExamDate, request.StartPeriod, request.PeriodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var validTaskIds = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (validTaskIds.Count != taskIds.Length)
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId)
.WhereIn(taskIds, x => x.TeachingTaskId)
.Select(x => x.TeachingTaskId)
.ToListAsync(cancellationToken);
if (duplicateTaskIds.Count > 0)
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。");
foreach (var taskId in taskIds)
{
var validation = await ValidateSessionAsync(plan, null,
taskId, null, null, startsAt, endsAt, cancellationToken);
if (validation is not null) return validation;
}
db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession
{
MakeupExamPlanId = planId,
TeachingTaskId = taskId,
ExamDate = request.ExamDate,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
StartsAt = startsAt,
EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes)
}));
try
{
await db.SaveChangesAsync(cancellationToken);
return Ok(new { createdCount = taskIds.Length });
}
catch (DbUpdateException)
{
return ConflictProblem("批量创建补考场次失败,关联数据可能已发生变化。");
}
}
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateSession(
Guid planId,
Guid id,
CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan)
.Include(x => x.Invigilators)
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
if (session is null) return NotFound();
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("已发布的补考计划不能调整场次。");
var timeResult = ResolveExamTime(session.MakeupExamPlan.AcademicTermId,
request.ExamDate, request.StartPeriod, request.PeriodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var validation = await ValidateSessionAsync(session.MakeupExamPlan, id,
request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
startsAt, endsAt, cancellationToken);
if (validation is not null) return validation;
session.TeachingTaskId = request.TeachingTaskId;
session.ClassroomId = request.ClassroomId;
session.ExamDate = request.ExamDate;
session.StartPeriod = request.StartPeriod;
session.PeriodCount = request.PeriodCount;
session.StartsAt = startsAt;
session.EndsAt = endsAt;
session.RequiredBuildingId = request.RequiredBuildingId;
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
session.Notes = Normalize(request.Notes);
db.MakeupExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
new MakeupExamSessionInvigilator { TeacherId = tid }).ToList();
return await SaveAsync(id, false, cancellationToken);
}
[HttpDelete("plans/{planId:guid}/sessions/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DeleteSession(
Guid planId,
Guid id,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
if (session is null) return NotFound();
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("已发布的补考计划不能调整场次。");
db.MakeupExamSessions.Remove(session);
return await SaveAsync(id, false, cancellationToken);
}
// ═══════════════════════════════════════════
// Auto-arrange
// ═══════════════════════════════════════════
[HttpPost("plans/{planId:guid}/auto-arrange")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> AutoArrange(
Guid planId,
ExamAutoArrangeRequest? request,
CancellationToken cancellationToken)
{
request ??= new ExamAutoArrangeRequest();
if (!request.AssignClassrooms && !request.AssignInvigilators)
return ValidationProblem("请至少选择分配考场或分配监考教师。");
var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
if (sessionIds.Length > 100)
return ValidationProblem("一次最多处理100个补考场次。");
var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == planId)
.Select(x => new
{
x.Status,
TotalSessions = x.Sessions.Count
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null)
return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
if (sessionIds.Length > 0)
{
var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId)
.WhereIn(sessionIds, x => x.Id)
.CountAsync(cancellationToken);
if (selectedSessionCount != sessionIds.Length)
return ConflictProblem("所选场次不存在或不属于当前补考计划。");
}
var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
.AnyAsync(
x => x.MakeupExamPlanId == planId &&
(x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running),
cancellationToken);
if (autoCreateActive)
return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。");
var existing = await FindActiveArrangementJobAsync(
planId,
cancellationToken);
if (existing is not null)
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
var userId = currentUserDataScope.Current.UserId;
var job = new ExamArrangementJob
{
Kind = ExamArrangementKind.MakeupExam,
PlanId = planId,
ActivePlanId = planId,
RequestedByUserId = userId == Guid.Empty ? null : userId,
SessionIdsJson = sessionIds.Length == 0
? null
: System.Text.Json.JsonSerializer.Serialize(sessionIds),
AssignClassrooms = request.AssignClassrooms,
AssignInvigilators = request.AssignInvigilators,
TotalSessions = sessionIds.Length == 0
? plan.TotalSessions
: sessionIds.Length,
CurrentStep = "等待后台编排"
};
db.ExamArrangementJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamArrangement,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
existing = await FindActiveArrangementJobAsync(
planId,
cancellationToken);
if (existing is not null)
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
throw;
}
return AcceptedArrangementJob(job, "补考编排任务已提交。");
}
[HttpGet("arrangement-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetArrangementJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamArrangementJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Kind == ExamArrangementKind.MakeupExam,
cancellationToken);
return job is null ? NotFound() : Ok(ToArrangementJobResponse(job));
}
[HttpGet("plans/{planId:guid}/arrangement-job")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetLatestArrangementJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.PlanId == planId &&
x.Kind == ExamArrangementKind.MakeupExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
}
// ═══════════════════════════════════════════
// Auto-create (background job)
// ═══════════════════════════════════════════
[HttpPost("plans/{planId:guid}/auto-create")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> AutoCreate(
Guid planId,
CancellationToken cancellationToken)
{
var plan = await db.MakeupExamPlans
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
// Check for existing active job
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId &&
(x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existing is not null)
return Ok(new
{
jobId = existing.Id,
status = existing.Status.ToString(),
message = "该计划已有正在执行的任务。"
});
var job = new MakeupExamAutoJob
{
MakeupExamPlanId = planId
};
db.MakeupExamAutoJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.MakeupExamAuto,
job.Id));
await db.SaveChangesAsync(cancellationToken);
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
new { jobId = job.Id, status = job.Status.ToString() });
}
[HttpGet("auto-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetAutoJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.MakeupExamAutoJobs.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
if (job is null) return NotFound();
return Ok(new
{
job.Id,
job.MakeupExamPlanId,
Status = job.Status.ToString(),
job.TotalCourses,
job.ProcessedCourses,
job.CreatedSessions,
job.EnrolledStudents,
Messages = !string.IsNullOrEmpty(job.MessagesJson)
? System.Text.Json.JsonSerializer.Deserialize<List<string>>(job.MessagesJson)
: new List<string>(),
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
});
}
// ═══════════════════════════════════════════
// Enrollments
// ═══════════════════════════════════════════
[HttpGet("eligible-students")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetEligibleStudents(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var students = await eligibilityService.GetEligibleStudentsAsync(
teachingTaskId, cancellationToken);
return Ok(students);
}
[HttpPost("sessions/{id:guid}/enroll")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> EnrollStudents(
Guid id,
EnrollStudentsRequest request,
CancellationToken cancellationToken)
{
var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan)
.Include(x => x.Enrollments)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null) return NotFound();
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("已发布的补考计划不能调整登记。");
var eligible = await eligibilityService.GetEligibleStudentsAsync(
session.TeachingTaskId, cancellationToken);
var eligibleIds = eligible.Select(e => e.StudentId).ToHashSet();
var alreadyEnrolled = session.Enrollments.Select(e => e.StudentId).ToHashSet();
var validIds = request.StudentIds
.Where(sid => !alreadyEnrolled.Contains(sid))
.Distinct()
.ToList();
if (validIds.Count == 0)
return ConflictProblem("所选学生均已登记或无效。");
var invalidIds = validIds.Where(sid => !eligibleIds.Contains(sid)).ToList();
if (invalidIds.Count > 0)
return ConflictProblem($"{invalidIds.Count} 名学生不符合补考资格。");
// Check for time conflicts with other makeup sessions
var conflictIds = await db.MakeupExamEnrollments.AsNoTracking()
.Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.MakeupExamSession.EndsAt)
.WhereIn(validIds, x => x.StudentId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (conflictIds.Count > 0)
{
var conflictNumbers = await db.Students
.WhereIn(conflictIds, s => s.Id)
.Select(s => s.StudentNumber)
.ToListAsync(cancellationToken);
return ConflictProblem(
$"以下学生该时段已有其他补考:{string.Join("", conflictNumbers)}");
}
foreach (var studentId in validIds)
{
var info = eligible.First(e => e.StudentId == studentId);
session.Enrollments.Add(new MakeupExamEnrollment
{
MakeupExamSessionId = id,
StudentId = studentId,
Reason = info.Reason,
SourceGradeRecordId = info.SourceGradeRecordId,
SourceDeferredExamId = info.SourceDeferredExamId
});
}
return await SaveAsync(id, false, cancellationToken);
}
[HttpDelete("sessions/{id:guid}/enrollments/{studentId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RemoveEnrollment(
Guid id,
Guid studentId,
CancellationToken cancellationToken)
{
var enrollment = await db.MakeupExamEnrollments
.Include(x => x.MakeupExamSession)
.ThenInclude(x => x!.MakeupExamPlan)
.FirstOrDefaultAsync(x => x.MakeupExamSessionId == id &&
x.StudentId == studentId, cancellationToken);
if (enrollment is null) return NotFound();
if (enrollment.MakeupExamSession!.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("已发布的补考计划不能调整登记。");
db.MakeupExamEnrollments.Remove(enrollment);
return await SaveAsync(id, false, cancellationToken);
}
// ═══════════════════════════════════════════
// Scores
// ═══════════════════════════════════════════
[HttpPut("sessions/{id:guid}/scores")]
[Authorize(Roles = ScoreEnterers)]
public async Task<ActionResult> RecordScores(
Guid id,
List<RecordMakeupScoreRequest> scores,
CancellationToken cancellationToken)
{
var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan)
.Include(x => x.Enrollments)
.Include(x => x.TeachingTask!)
.ThenInclude(x => x.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null) return NotFound();
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
return ConflictProblem("只有已发布的补考计划可以录入成绩。");
// Teachers can only record scores for courses they teach
if (!IsManager())
{
var userId = currentUserDataScope.Current.UserId;
var teaches = session.TeachingTask!.Teachers
.Any(t => t.Teacher!.UserId == userId);
if (!teaches)
return Forbid();
}
var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId);
foreach (var entry in scores)
{
if (!enrollmentByStudent.TryGetValue(entry.StudentId, out var enrollment))
continue;
enrollment.MakeupScore = entry.Score;
// Update the grade record — 补考合格按60分记
if (enrollment.SourceGradeRecordId.HasValue)
{
var record = await db.GradeRecords
.FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value,
cancellationToken);
if (record is not null)
{
record.ExamStatus = GradeExamStatus.Makeup;
// Cap passing score at 60
var cappedScore = entry.Score >= 60 ? 60m : entry.Score;
record.TotalScore = cappedScore;
record.GradePoint = GradeCalculator.CalculateGradePoint(cappedScore);
record.Notes = entry.Score >= 60
? $"补考合格(原始{entry.Score}分,按60分记)"
: $"补考不合格({entry.Score}分)";
}
}
}
return await SaveAsync(id, false, cancellationToken);
}
// ═══════════════════════════════════════════
// Roster
// ═══════════════════════════════════════════
[HttpGet("sessions/{id:guid}/roster")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
{
var session = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
CourseName = x.TeachingTask.Course!.Name,
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
x.StartsAt
}).FirstOrDefaultAsync(cancellationToken);
if (session is null) return NotFound();
var students = await db.MakeupExamEnrollments.AsNoTracking()
.Where(e => e.MakeupExamSessionId == id)
.OrderBy(e => e.Student!.StudentNumber)
.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.MakeupScore
})
.ToListAsync(cancellationToken);
return Ok(new
{
session.Id,
session.TeachingTaskId,
session.TaskNumber,
session.CourseName,
session.ClassroomName,
session.StartsAt,
Students = students.Select((student, index) => new
{
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName,
student.Reason,
student.MakeupScore,
SeatNumber = (index + 1).ToString("D3")
})
});
}
// ═══════════════════════════════════════════
// Query helpers
// ═══════════════════════════════════════════
[HttpGet("available-classrooms")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetAvailableClassrooms(
Guid academicTermId,
DateOnly examDate,
int startPeriod,
int periodCount,
Guid? requiredBuildingId,
Guid? planId,
Guid? excludeSessionId,
CancellationToken cancellationToken)
{
var timeResult = ResolveExamTime(
academicTermId, examDate, startPeriod, periodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled);
if (requiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == requiredBuildingId.Value);
var occupiedQuery = db.MakeupExamSessions.AsNoTracking()
.Where(x => x.ClassroomId != null &&
x.StartsAt < endsAt && startsAt < x.EndsAt);
if (planId.HasValue)
occupiedQuery = occupiedQuery.Where(x => x.MakeupExamPlanId == planId.Value);
if (excludeSessionId.HasValue)
occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value);
var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
if (occupiedIds.Count > 0)
query = query.WhereNotIn(occupiedIds, x => x.Id);
return Ok(await query.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Capacity)
.Select(x => new
{
x.Id,
x.Name,
x.Capacity,
x.RoomType,
x.BuildingId,
BuildingName = x.Building!.Name
}).ToListAsync(cancellationToken));
}
[HttpGet("available-teachers")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetAvailableTeachers(
Guid academicTermId,
DateOnly examDate,
int startPeriod,
int periodCount,
Guid? planId,
Guid? excludeSessionId,
CancellationToken cancellationToken)
{
var timeResult = ResolveExamTime(
academicTermId, examDate, startPeriod, periodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var busyQuery = db.MakeupExamSessionInvigilators.AsNoTracking()
.Where(x => x.MakeupExamSession!.StartsAt < endsAt &&
startsAt < x.MakeupExamSession!.EndsAt);
if (planId.HasValue)
busyQuery = busyQuery.Where(x =>
x.MakeupExamSession!.MakeupExamPlanId == planId.Value);
if (excludeSessionId.HasValue)
busyQuery = busyQuery.Where(x => x.MakeupExamSessionId != excludeSessionId.Value);
var busyIds = await busyQuery.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyIds, x => x.Id)
.OrderBy(x => x.Name)
.Select(x => new
{
x.Id,
x.TeacherNumber,
x.Name,
x.Title,
CollegeName = x.College!.Name
}).ToListAsync(cancellationToken));
}
[HttpGet("time-slots-for-term")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetTimeSlotsForTerm(
Guid academicTermId,
CancellationToken cancellationToken) =>
Ok(await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.Select(x => new
{
x.Id,
x.PeriodNumber,
x.Name,
StartsAt = x.StartsAt.ToString("HH:mm"),
EndsAt = x.EndsAt.ToString("HH:mm"),
x.IsEnabled
})
.ToListAsync(cancellationToken));
// ═══════════════════════════════════════════
// My schedule
// ═══════════════════════════════════════════
[HttpGet("my-schedule")]
public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
if (scope.IsInRole(SystemRoles.Student))
{
var studentId = await db.Students.Where(x => x.UserId == scope.UserId)
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var schedule = await db.MakeupExamEnrollments.AsNoTracking()
.Where(x => x.StudentId == studentId &&
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
.OrderBy(x => x.MakeupExamSession!.ExamDate)
.ThenBy(x => x.MakeupExamSession!.StartPeriod)
.Select(x => new
{
x.MakeupExamSession!.Id,
PlanName = x.MakeupExamSession.MakeupExamPlan!.Name,
x.MakeupExamSession.ExamDate,
x.MakeupExamSession.StartPeriod,
x.MakeupExamSession.PeriodCount,
x.MakeupExamSession.StartsAt,
x.MakeupExamSession.EndsAt,
x.MakeupExamSession.TeachingTask!.TaskNumber,
CourseCode = x.MakeupExamSession.TeachingTask.Course!.Code,
CourseName = x.MakeupExamSession.TeachingTask.Course.Name,
ClassroomName = x.MakeupExamSession.Classroom != null
? x.MakeupExamSession.Classroom.Name : null,
BuildingName = x.MakeupExamSession.Classroom != null
? x.MakeupExamSession.Classroom.Building!.Name : null,
x.Reason,
x.MakeupScore,
IsMakeup = true
})
.ToListAsync(cancellationToken);
if (schedule.Count == 0) return Ok(schedule);
var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray();
var seatRows = await db.MakeupExamEnrollments.AsNoTracking()
.WhereIn(sessionIds, x => x.MakeupExamSessionId)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.MakeupExamSessionId,
x.StudentId,
x.Student!.StudentNumber
})
.ToListAsync(cancellationToken);
var seatNumbers = seatRows
.GroupBy(x => x.MakeupExamSessionId)
.SelectMany(group => group.Select((row, index) => new
{
row.MakeupExamSessionId,
row.StudentId,
SeatNumber = (index + 1).ToString("D3")
}))
.ToDictionary(x => (x.MakeupExamSessionId, x.StudentId), x => x.SeatNumber);
return Ok(schedule.Select(x => new
{
x.Id,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TaskNumber,
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.Reason,
x.MakeupScore,
SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)),
x.IsMakeup
}));
}
if (scope.IsInRole(SystemRoles.Teacher))
{
return Ok(await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new
{
x.Id,
PlanName = x.MakeupExamPlan!.Name,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TeachingTask!.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
EnrolledCount = x.Enrollments.Count,
IsMakeup = true
})
.ToListAsync(cancellationToken));
}
return Ok(Array.Empty<object>());
}
// ═══════════════════════════════════════════
// My teaching sessions (for score entry)
// ═══════════════════════════════════════════
[HttpGet("my-teaching-sessions")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMyTeachingSessions(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
return Ok(await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
x.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new
{
x.Id,
PlanName = x.MakeupExamPlan!.Name,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TeachingTask!.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
EnrolledCount = x.Enrollments.Count,
GradedCount = x.Enrollments.Count(e => e.MakeupScore != null),
Enrollments = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.MakeupScore
}).ToList(),
IsMakeup = true
})
.ToListAsync(cancellationToken));
}
// ═══════════════════════════════════════════
// Private helpers
// ═══════════════════════════════════════════
private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime(
Guid academicTermId,
DateOnly examDate,
int startPeriod,
int periodCount)
{
var slots = db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToList();
if (slots.Count == 0)
return (default, default,
ValidationProblem("当前学期未配置上课时间表,请先在排课设置中配置节次时间。"));
var slotDict = slots.ToDictionary(x => x.PeriodNumber);
var startSlot = slotDict.GetValueOrDefault(startPeriod);
var endSlot = slotDict.GetValueOrDefault(startPeriod + periodCount - 1);
if (startSlot is null)
return (default, default,
ValidationProblem($"起始节次 {startPeriod} 不在已配置的时间表中。"));
if (endSlot is null)
return (default, default,
ValidationProblem(
$"结束节次 {startPeriod + periodCount - 1} 不在已配置的时间表中。"));
return (
examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc),
examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc),
null
);
}
private async Task<ActionResult?> ValidateSessionAsync(
MakeupExamPlan plan,
Guid? currentId,
Guid teachingTaskId,
Guid? classroomId,
IReadOnlyCollection<Guid>? invigilatorIds,
DateTime startsAt,
DateTime endsAt,
CancellationToken cancellationToken)
{
if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。");
var task = await db.TeachingTasks.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null || task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("教学班与补考计划必须属于同一学期。");
if (classroomId.HasValue)
{
var room = await db.Classrooms.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
}
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
if (teacherIds.Length > 0)
{
if (await db.Teachers
.Where(x => x.Status == TeacherStatus.Active)
.WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。");
}
var overlaps = db.MakeupExamSessions.Where(x => x.MakeupExamPlanId == plan.Id &&
x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt);
if (classroomId.HasValue)
{
if (await overlaps.AnyAsync(
x => x.ClassroomId == classroomId.Value, cancellationToken))
return ConflictProblem("该时段考场已被占用。");
}
if (teacherIds.Length > 0)
{
if (await db.MakeupExamSessionInvigilators
.Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId))
.WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有补考任务。");
}
return null;
}
private bool IsManager() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamArrangementKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedArrangementJob(
ExamArrangementJob job,
string message) =>
AcceptedAtAction(
nameof(GetArrangementJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.TotalSessions,
job.ProcessedSessions,
job.CurrentStep,
job.ResultMessage,
job.ErrorMessage,
job.AssignClassrooms,
job.AssignInvigilators,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private Task<ExamPublishJob?> FindActivePublishJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamPublishJobKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamPublishJobStatus.Queued ||
x.Status == ExamPublishJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedPublishJob(
ExamPublishJob job,
string message) =>
AcceptedAtAction(
nameof(GetPublishJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToPublishJobResponse(ExamPublishJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.CurrentStep,
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private async Task<ActionResult> SaveAsync(Guid id, bool created,
CancellationToken token)
{
try
{
await db.SaveChangesAsync(token);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("补考场次重复,或关联数据已发生变化。");
}
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成补考操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
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;
}
// ═══════════════════════════════════════════
// Request/Response models
// ═══════════════════════════════════════════
public sealed record MakeupExamPlanRequest(
Guid AcademicTermId,
[Required, MaxLength(120)] string Name,
[MaxLength(500)] string? Notes);
public sealed record CreateMakeupExamSessionRequest(
Guid TeachingTaskId,
Guid? ClassroomId,
DateOnly ExamDate,
[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);
public sealed record CreateMakeupExamSessionsBatchRequest(
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
DateOnly ExamDate,
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount,
[MaxLength(500)] string? Notes);
public sealed record EnrollStudentsRequest(
[Required] IReadOnlyCollection<Guid> StudentIds);
public sealed record RecordMakeupScoreRequest(
Guid StudentId,
[Range(0, 100)] decimal Score);