队列异步执行,拆分查询消除笛卡尔积。
修改的文件(共 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)
1793 lines
72 KiB
C#
1793 lines
72 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.Caching;
|
|
using Jiaowu.Api.Infrastructure.Exams;
|
|
using Jiaowu.Api.Infrastructure.Excel;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Jiaowu.Api.Infrastructure.Teaching;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/exams")]
|
|
public sealed class ExamsController(
|
|
AppDbContext db,
|
|
ICurrentUserDataScope currentUserDataScope,
|
|
IAppCache cache) : ControllerBase
|
|
{
|
|
private const string Managers =
|
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
|
|
|
// ═══════════════════════════════════════════
|
|
// Plans
|
|
// ═══════════════════════════════════════════
|
|
|
|
[HttpGet("plans")]
|
|
public async Task<ActionResult> GetPlans(
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var source = db.ExamPlans.AsNoTracking().AsQueryable();
|
|
if (academicTermId.HasValue)
|
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
|
if (!IsManager())
|
|
source = source.Where(x => x.Status == ExamPlanStatus.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(
|
|
ExamPlanRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!await db.AcademicTerms.AnyAsync(
|
|
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
|
cancellationToken))
|
|
return ValidationProblem("所选学期不存在或已停用。");
|
|
var plan = new ExamPlan
|
|
{
|
|
AcademicTermId = request.AcademicTermId,
|
|
Name = request.Name.Trim(),
|
|
Notes = Normalize(request.Notes)
|
|
};
|
|
db.ExamPlans.Add(plan);
|
|
return await SaveAsync(plan.Id, true, cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("plans/{id:guid}")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> DeletePlan(
|
|
Guid id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
|
return ConflictProblem("考试计划正在后台编排,暂时不能删除。");
|
|
var plan = await db.ExamPlans
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
if (plan.Status != ExamPlanStatus.Draft)
|
|
return ConflictProblem("只有草稿考试计划可以删除。");
|
|
|
|
var rooms = await db.ExamRooms
|
|
.Where(x => x.ExamPlanId == id)
|
|
.ToListAsync(cancellationToken);
|
|
db.ExamRooms.RemoveRange(rooms);
|
|
db.ExamPlans.Remove(plan);
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await cache.RemoveByTagAsync(
|
|
AppCacheTags.Timetables,
|
|
cancellationToken);
|
|
return NoContent();
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return ConflictProblem("删除考试计划失败,关联数据可能已发生变化。");
|
|
}
|
|
}
|
|
|
|
[HttpGet("plans/{id:guid}")]
|
|
public async Task<ActionResult> GetPlan(
|
|
Guid id,
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
string? keyword = null,
|
|
string? allocation = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
page = Math.Max(1, page);
|
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
|
keyword = Normalize(keyword);
|
|
allocation = Normalize(allocation)?.ToLowerInvariant();
|
|
if (allocation is not null and not ("room" or "invigilator" or "complete"))
|
|
return ValidationProblem("分配状态筛选值无效。");
|
|
|
|
var manager = IsManager();
|
|
var plan = await db.ExamPlans.AsNoTracking()
|
|
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.Name,
|
|
x.AcademicTermId,
|
|
TermName = x.AcademicTerm!.Name,
|
|
x.Status,
|
|
x.Notes,
|
|
x.PublishedAt,
|
|
TotalSessionCount = x.Sessions.Count
|
|
}).FirstOrDefaultAsync(cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
|
|
var sessionQuery = db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlanId == id);
|
|
if (keyword is not null)
|
|
{
|
|
sessionQuery = sessionQuery.Where(x =>
|
|
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
|
x.TeachingTask.Name.Contains(keyword) ||
|
|
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
|
x.TeachingTask.Course.Name.Contains(keyword));
|
|
}
|
|
|
|
sessionQuery = allocation switch
|
|
{
|
|
"room" => sessionQuery.Where(x =>
|
|
!x.RoomLinks.Any() && !x.ClassroomId.HasValue),
|
|
"invigilator" => sessionQuery.Where(x =>
|
|
(x.RoomLinks.Any() && x.RoomLinks.Any(link =>
|
|
link.ExamRoom!.Invigilators.Count <
|
|
x.RequiredInvigilatorCount)) ||
|
|
(!x.RoomLinks.Any() &&
|
|
x.Invigilators.Count < x.RequiredInvigilatorCount)),
|
|
"complete" => sessionQuery.Where(x =>
|
|
(x.RoomLinks.Any() || x.ClassroomId.HasValue) &&
|
|
((x.RoomLinks.Any() && x.RoomLinks.All(link =>
|
|
link.ExamRoom!.Invigilators.Count >=
|
|
x.RequiredInvigilatorCount)) ||
|
|
(!x.RoomLinks.Any() &&
|
|
x.Invigilators.Count >= x.RequiredInvigilatorCount))),
|
|
_ => sessionQuery
|
|
};
|
|
|
|
var filteredSessionCount = await sessionQuery.CountAsync(cancellationToken);
|
|
var lastPage = Math.Max(1,
|
|
(int)Math.Ceiling(filteredSessionCount / (double)pageSize));
|
|
page = Math.Min(page, lastPage);
|
|
var sessions = await sessionQuery
|
|
.OrderBy(item => item.ExamDate)
|
|
.ThenBy(item => item.StartPeriod)
|
|
.ThenBy(item => item.TeachingTask!.TaskNumber)
|
|
.ThenBy(item => item.Id)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.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)
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var pageSessionIds = sessions.Select(x => x.Id).ToArray();
|
|
var scheduledTeachingTaskIds = await db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlanId == id)
|
|
.OrderBy(x => x.TeachingTaskId)
|
|
.Select(x => x.TeachingTaskId)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var roomSessionLinks = await db.ExamRoomSessions.AsNoTracking()
|
|
.WhereIn(pageSessionIds, x => x.ExamSessionId)
|
|
.Select(x => new
|
|
{
|
|
x.ExamRoomId,
|
|
x.ExamSessionId,
|
|
SeatCount = x.ExamRoom!.Seats.Count(seat =>
|
|
seat.ExamSessionId == x.ExamSessionId)
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var pageRoomIds = roomSessionLinks.Select(x => x.ExamRoomId).Distinct().ToArray();
|
|
var roomSummaries = await db.ExamRooms.AsNoTracking()
|
|
.WhereIn(pageRoomIds, x => x.Id)
|
|
.OrderBy(x => x.Classroom!.Building!.Name)
|
|
.ThenBy(x => x.Classroom!.Name)
|
|
.ThenBy(x => x.Id)
|
|
.Select(x => new
|
|
{
|
|
ExamRoomId = x.Id,
|
|
x.ClassroomId,
|
|
ClassroomName = x.Classroom!.Name,
|
|
BuildingName = x.Classroom.Building!.Name,
|
|
ClassroomCapacity = x.Classroom.Capacity,
|
|
TotalSeatCount = x.Seats.Count,
|
|
SessionCount = x.SessionLinks.Count
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var roomInvigilators = await db.ExamRoomInvigilators.AsNoTracking()
|
|
.WhereIn(pageRoomIds, x => x.ExamRoomId)
|
|
.OrderBy(x => x.Teacher!.TeacherNumber)
|
|
.ThenBy(x => x.Teacher!.Name)
|
|
.Select(x => new
|
|
{
|
|
x.ExamRoomId,
|
|
x.TeacherId,
|
|
TeacherName = x.Teacher!.Name
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var linksByRoom = roomSessionLinks.ToLookup(x => x.ExamRoomId);
|
|
var invigilatorsByRoom = roomInvigilators.ToLookup(x => x.ExamRoomId);
|
|
var roomsBySession = roomSummaries
|
|
.SelectMany(room => linksByRoom[room.ExamRoomId].Select(link => new
|
|
{
|
|
link.ExamSessionId,
|
|
Room = new
|
|
{
|
|
room.ExamRoomId,
|
|
room.ClassroomId,
|
|
room.ClassroomName,
|
|
room.BuildingName,
|
|
room.ClassroomCapacity,
|
|
link.SeatCount,
|
|
room.TotalSeatCount,
|
|
IsMixed = room.SessionCount > 1,
|
|
InvigilatorIds = invigilatorsByRoom[room.ExamRoomId]
|
|
.Select(invigilator => invigilator.TeacherId)
|
|
.ToList(),
|
|
InvigilatorNames = invigilatorsByRoom[room.ExamRoomId]
|
|
.Select(invigilator => invigilator.TeacherName)
|
|
.ToList()
|
|
}
|
|
}))
|
|
.ToLookup(x => x.ExamSessionId, x => x.Room);
|
|
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
|
|
db,
|
|
sessions.Select(x => x.TeachingTaskId),
|
|
cancellationToken))
|
|
.GroupBy(x => x.TeachingTaskId)
|
|
.ToDictionary(x => x.Key, x => x.Count());
|
|
return Ok(new
|
|
{
|
|
plan.Id,
|
|
plan.Name,
|
|
plan.AcademicTermId,
|
|
plan.TermName,
|
|
plan.Status,
|
|
plan.Notes,
|
|
plan.PublishedAt,
|
|
plan.TotalSessionCount,
|
|
FilteredSessionCount = filteredSessionCount,
|
|
SessionPage = page,
|
|
SessionPageSize = pageSize,
|
|
ScheduledTeachingTaskIds = scheduledTeachingTaskIds,
|
|
Sessions = sessions.Select(item => new
|
|
{
|
|
item.Id,
|
|
item.TeachingTaskId,
|
|
item.TaskNumber,
|
|
item.TaskName,
|
|
item.CourseCode,
|
|
item.CourseName,
|
|
item.ClassroomId,
|
|
item.ClassroomName,
|
|
item.BuildingName,
|
|
item.ClassroomCapacity,
|
|
item.ExamDate,
|
|
item.StartPeriod,
|
|
item.PeriodCount,
|
|
item.StartsAt,
|
|
item.EndsAt,
|
|
item.RequiredBuildingId,
|
|
item.RequiredBuildingIds,
|
|
item.RequiredBuildingName,
|
|
item.RequiredInvigilatorCount,
|
|
item.Notes,
|
|
item.InvigilatorIds,
|
|
item.InvigilatorNames,
|
|
ExamRooms = roomsBySession[item.Id],
|
|
StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId)
|
|
})
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// Sessions
|
|
// ═══════════════════════════════════════════
|
|
|
|
[HttpPost("plans/{planId:guid}/sessions")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> CreateSession(
|
|
Guid planId,
|
|
CreateExamSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
|
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
|
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
if (plan.Status != ExamPlanStatus.Draft)
|
|
return ConflictProblem("已发布的考试计划不能调整场次。");
|
|
|
|
// Resolve time from slots
|
|
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 ExamSession
|
|
{
|
|
ExamPlanId = 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 ExamSessionInvigilator { TeacherId = id }).ToList()
|
|
};
|
|
db.ExamSessions.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,
|
|
CreateExamSessionsBatchRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
|
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
|
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
if (plan.Status != ExamPlanStatus.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.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlanId == planId)
|
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
|
.Select(x => x.TeachingTaskId)
|
|
.ToListAsync(cancellationToken);
|
|
if (duplicateTaskIds.Count > 0)
|
|
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。");
|
|
|
|
var rosterPairs = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
|
db,
|
|
taskIds,
|
|
cancellationToken);
|
|
var conflictedStudentCount = rosterPairs
|
|
.GroupBy(x => x.StudentId)
|
|
.Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any());
|
|
if (conflictedStudentCount > 0)
|
|
return ConflictProblem(
|
|
$"所选教学班在同一时段安排会造成 {conflictedStudentCount} 名学生考试冲突,请分批选择不同时间。");
|
|
|
|
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.ExamSessions.AddRange(taskIds.Select(taskId => new ExamSession
|
|
{
|
|
ExamPlanId = 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);
|
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, 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,
|
|
CreateExamSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
|
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
|
var session = await db.ExamSessions
|
|
.Include(x => x.ExamPlan)
|
|
.Include(x => x.Invigilators)
|
|
.FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
|
|
if (session is null) return NotFound();
|
|
if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
|
|
return ConflictProblem("已发布的考试计划不能调整场次。");
|
|
|
|
var timeResult = ResolveExamTime(session.ExamPlan.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.ExamPlan, 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);
|
|
|
|
await RemoveRoomsForSessionsAsync([id], cancellationToken);
|
|
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
|
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
|
|
new ExamSessionInvigilator { 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.ExamSessions.Include(x => x.ExamPlan)
|
|
.FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
|
|
if (session is null) return NotFound();
|
|
if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
|
|
return ConflictProblem("已发布的考试计划不能调整场次。");
|
|
await RemoveRoomsForSessionsAsync([id], cancellationToken);
|
|
db.ExamSessions.Remove(session);
|
|
return await SaveAsync(id, false, cancellationToken);
|
|
}
|
|
|
|
[HttpPost("plans/{planId:guid}/sessions/batch-remove")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> RemoveSessionsBatch(
|
|
Guid planId,
|
|
RemoveExamSessionsBatchRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
|
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
|
var sessionIds = request.SessionIds.Distinct().ToArray();
|
|
if (sessionIds.Length == 0)
|
|
return ValidationProblem("请至少选择一个考试场次。");
|
|
if (sessionIds.Length > 100)
|
|
return ValidationProblem("一次最多移除100个考试场次。");
|
|
|
|
var plan = await db.ExamPlans.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
if (plan.Status != ExamPlanStatus.Draft)
|
|
return ConflictProblem("已发布的考试计划不能调整场次。");
|
|
|
|
var sessions = await db.ExamSessions
|
|
.Where(x => x.ExamPlanId == planId)
|
|
.WhereIn(sessionIds, x => x.Id)
|
|
.ToListAsync(cancellationToken);
|
|
if (sessions.Count != sessionIds.Length)
|
|
return ConflictProblem(
|
|
"所选考试场次包含不存在或不属于当前计划的记录,请刷新后重新选择。");
|
|
|
|
await RemoveRoomsForSessionsAsync(sessionIds, cancellationToken);
|
|
db.ExamSessions.RemoveRange(sessions);
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await cache.RemoveByTagAsync(
|
|
AppCacheTags.Timetables,
|
|
cancellationToken);
|
|
return Ok(new { removedCount = sessions.Count });
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return ConflictProblem("批量移除考试场次失败,关联数据可能已发生变化。");
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// 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.ExamPlans.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 != ExamPlanStatus.Draft)
|
|
return ConflictProblem("只有草稿状态的考试计划可以自动编排。");
|
|
if (sessionIds.Length > 0)
|
|
{
|
|
var selectedSessionCount = await db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlanId == planId)
|
|
.WhereIn(sessionIds, x => x.Id)
|
|
.CountAsync(cancellationToken);
|
|
if (selectedSessionCount != sessionIds.Length)
|
|
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.FormalExam,
|
|
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.FormalExam,
|
|
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.FormalExam)
|
|
.OrderByDescending(x => x.CreatedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// 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);
|
|
|
|
// Exclude classrooms already occupied
|
|
var occupiedQuery = db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ClassroomId != null &&
|
|
x.StartsAt < endsAt && startsAt < x.EndsAt);
|
|
if (planId.HasValue)
|
|
occupiedQuery = occupiedQuery.Where(x => x.ExamPlanId == planId.Value);
|
|
if (excludeSessionId.HasValue)
|
|
occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value);
|
|
|
|
var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
|
|
.ToListAsync(cancellationToken);
|
|
var occupiedRoomQuery = db.ExamRooms.AsNoTracking()
|
|
.Where(x => x.StartsAt < endsAt && startsAt < x.EndsAt);
|
|
if (planId.HasValue)
|
|
occupiedRoomQuery = occupiedRoomQuery.Where(
|
|
x => x.ExamPlanId == planId.Value);
|
|
if (excludeSessionId.HasValue)
|
|
occupiedRoomQuery = occupiedRoomQuery.Where(
|
|
x => !x.SessionLinks.Any(link =>
|
|
link.ExamSessionId == excludeSessionId.Value));
|
|
occupiedIds.AddRange(await occupiedRoomQuery
|
|
.Select(x => x.ClassroomId)
|
|
.ToListAsync(cancellationToken));
|
|
occupiedIds = occupiedIds.Distinct().ToList();
|
|
|
|
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.ExamSessionInvigilators.AsNoTracking()
|
|
.Where(x => x.ExamSession!.StartsAt < endsAt &&
|
|
startsAt < x.ExamSession!.EndsAt);
|
|
if (planId.HasValue)
|
|
busyQuery = busyQuery.Where(x => x.ExamSession!.ExamPlanId == planId.Value);
|
|
if (excludeSessionId.HasValue)
|
|
busyQuery = busyQuery.Where(x => x.ExamSessionId != excludeSessionId.Value);
|
|
|
|
var busyIds = await busyQuery.Select(x => x.TeacherId)
|
|
.ToListAsync(cancellationToken);
|
|
var roomBusyQuery = db.ExamRoomInvigilators.AsNoTracking()
|
|
.Where(x => x.ExamRoom!.StartsAt < endsAt &&
|
|
startsAt < x.ExamRoom.EndsAt);
|
|
if (planId.HasValue)
|
|
roomBusyQuery = roomBusyQuery.Where(
|
|
x => x.ExamRoom!.ExamPlanId == planId.Value);
|
|
if (excludeSessionId.HasValue)
|
|
roomBusyQuery = roomBusyQuery.Where(
|
|
x => !x.ExamRoom!.SessionLinks.Any(link =>
|
|
link.ExamSessionId == excludeSessionId.Value));
|
|
busyIds.AddRange(await roomBusyQuery
|
|
.Select(x => x.TeacherId)
|
|
.ToListAsync(cancellationToken));
|
|
busyIds = busyIds.Distinct().ToList();
|
|
|
|
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));
|
|
|
|
// ═══════════════════════════════════════════
|
|
// Publish
|
|
// ═══════════════════════════════════════════
|
|
|
|
[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.ExamPlans.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 != ExamPlanStatus.Draft)
|
|
return ConflictProblem("只有草稿考试计划可以发布。");
|
|
if (!plan.HasSessions)
|
|
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
|
|
|
var userId = currentUserDataScope.Current.UserId;
|
|
var job = new ExamPublishJob
|
|
{
|
|
Kind = ExamPublishJobKind.FormalExam,
|
|
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.FormalExam,
|
|
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.FormalExam)
|
|
.OrderByDescending(x => x.CreatedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// Roster
|
|
// ═══════════════════════════════════════════
|
|
|
|
[HttpGet("sessions/{id:guid}/roster")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var session = await db.ExamSessions.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 assignedStudents = await db.ExamSeats.AsNoTracking()
|
|
.Where(x => x.ExamSessionId == id)
|
|
.OrderBy(x => x.ExamRoom!.Classroom!.Building!.Name)
|
|
.ThenBy(x => x.ExamRoom!.Classroom!.Name)
|
|
.ThenBy(x => x.SeatNumber)
|
|
.Select(x => new
|
|
{
|
|
x.StudentId,
|
|
x.Student!.StudentNumber,
|
|
x.Student.Name,
|
|
ClassName = x.Student.AdministrativeClass!.Name,
|
|
x.ExamRoomId,
|
|
ClassroomName = x.ExamRoom!.Classroom!.Name,
|
|
BuildingName = x.ExamRoom.Classroom.Building!.Name,
|
|
x.SeatNumber
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
if (assignedStudents.Count > 0)
|
|
{
|
|
return Ok(new
|
|
{
|
|
session.Id,
|
|
session.TeachingTaskId,
|
|
session.TaskNumber,
|
|
session.CourseName,
|
|
ClassroomName = string.Join(
|
|
"、",
|
|
assignedStudents
|
|
.Select(x => $"{x.BuildingName} · {x.ClassroomName}")
|
|
.Distinct()),
|
|
session.StartsAt,
|
|
Students = assignedStudents.Select(student => new
|
|
{
|
|
student.StudentId,
|
|
student.StudentNumber,
|
|
student.Name,
|
|
student.ClassName,
|
|
student.ExamRoomId,
|
|
student.ClassroomName,
|
|
student.BuildingName,
|
|
SeatNumber = student.SeatNumber.ToString("D3")
|
|
})
|
|
});
|
|
}
|
|
|
|
var students = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
|
db,
|
|
[session.TeachingTaskId],
|
|
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,
|
|
SeatNumber = (index + 1).ToString("D3")
|
|
})
|
|
});
|
|
}
|
|
|
|
[HttpGet("plans/{id:guid}/sign-in-sheets.xlsx")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> ExportSignInSheets(
|
|
Guid id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var plan = await db.ExamPlans.AsNoTracking()
|
|
.Where(x => x.Id == id)
|
|
.Select(x => new
|
|
{
|
|
x.Name,
|
|
TermName = x.AcademicTerm!.Name
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
|
|
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 == id)
|
|
.OrderBy(x => x.ExamDate)
|
|
.ThenBy(x => x.StartPeriod)
|
|
.ThenBy(x => x.Classroom!.Building!.Name)
|
|
.ThenBy(x => x.Classroom!.Name)
|
|
.ToListAsync(cancellationToken);
|
|
var legacySessions = await db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlanId == id)
|
|
.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(cancellationToken);
|
|
if (rooms.Count == 0 && legacySessions.Count == 0)
|
|
return ConflictProblem("当前考试计划没有可导出的考试场次。");
|
|
|
|
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
|
db,
|
|
legacySessions.Select(x => x.TeachingTaskId),
|
|
cancellationToken);
|
|
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);
|
|
return File(
|
|
bytes,
|
|
ExcelWorkbookHelper.ContentType,
|
|
$"考场签名单-{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
|
|
// ═══════════════════════════════════════════
|
|
|
|
[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 assignedSchedule = await db.ExamSeats.AsNoTracking()
|
|
.Where(x =>
|
|
x.StudentId == studentId.Value &&
|
|
x.ExamRoom!.ExamPlan!.Status == ExamPlanStatus.Published)
|
|
.Select(x => new
|
|
{
|
|
x.ExamSessionId,
|
|
PlanName = x.ExamRoom!.ExamPlan!.Name,
|
|
x.ExamRoom.ExamDate,
|
|
x.ExamRoom.StartPeriod,
|
|
x.ExamRoom.PeriodCount,
|
|
x.ExamRoom.StartsAt,
|
|
x.ExamRoom.EndsAt,
|
|
x.ExamSession!.TeachingTask!.TaskNumber,
|
|
CourseCode = x.ExamRoom.Course!.Code,
|
|
CourseName = x.ExamRoom.Course.Name,
|
|
ClassroomName = x.ExamRoom.Classroom!.Name,
|
|
BuildingName = x.ExamRoom.Classroom.Building!.Name,
|
|
InvigilatorNames = x.ExamRoom.Invigilators
|
|
.Select(i => i.Teacher!.Name),
|
|
x.SeatNumber
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
|
|
db,
|
|
studentId.Value);
|
|
var legacySchedule = await db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
|
!x.RoomLinks.Any() &&
|
|
rosterTaskIds.Contains(x.TeachingTaskId))
|
|
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
PlanName = x.ExamPlan!.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),
|
|
x.TeachingTaskId,
|
|
IsExam = true
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var taskIds = legacySchedule
|
|
.Select(x => x.TeachingTaskId)
|
|
.Distinct()
|
|
.ToArray();
|
|
var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
|
db,
|
|
taskIds,
|
|
cancellationToken);
|
|
var seatNumbers = seatRows
|
|
.GroupBy(x => x.TeachingTaskId)
|
|
.SelectMany(group => group.Select((row, index) => new
|
|
{
|
|
row.TeachingTaskId,
|
|
row.StudentId,
|
|
SeatNumber = (index + 1).ToString("D3")
|
|
}))
|
|
.ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber);
|
|
|
|
return Ok(assignedSchedule.Select(x => new ExamScheduleResponse(
|
|
x.ExamSessionId,
|
|
x.PlanName,
|
|
x.ExamDate,
|
|
x.StartPeriod,
|
|
x.PeriodCount,
|
|
x.StartsAt,
|
|
x.EndsAt,
|
|
x.TaskNumber,
|
|
x.CourseCode,
|
|
x.CourseName,
|
|
x.ClassroomName,
|
|
x.BuildingName,
|
|
x.InvigilatorNames.ToList(),
|
|
x.SeatNumber.ToString("D3"),
|
|
null,
|
|
true))
|
|
.Concat(legacySchedule.Select(x => new ExamScheduleResponse(
|
|
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.InvigilatorNames.ToList(),
|
|
seatNumbers.GetValueOrDefault(
|
|
(x.TeachingTaskId, studentId.Value)),
|
|
null,
|
|
x.IsExam)))
|
|
.OrderBy(x => x.ExamDate)
|
|
.ThenBy(x => x.StartPeriod));
|
|
}
|
|
if (scope.IsInRole(SystemRoles.Teacher))
|
|
{
|
|
var roomSchedule = await db.ExamRooms.AsNoTracking()
|
|
.Where(x =>
|
|
x.ExamPlan!.Status == ExamPlanStatus.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.ExamPlan!.Name,
|
|
x.ExamDate,
|
|
x.StartPeriod,
|
|
x.PeriodCount,
|
|
x.StartsAt,
|
|
x.EndsAt,
|
|
TaskNumbers = x.SessionLinks
|
|
.Select(link =>
|
|
link.ExamSession!.TeachingTask!.TaskNumber),
|
|
CourseCode = x.Course!.Code,
|
|
CourseName = x.Course.Name,
|
|
ClassroomName = x.Classroom!.Name,
|
|
BuildingName = x.Classroom.Building!.Name,
|
|
InvigilatorNames = x.Invigilators
|
|
.Select(i => i.Teacher!.Name),
|
|
StudentCount = x.Seats.Count
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var legacySchedule = await db.ExamSessions.AsNoTracking()
|
|
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
|
!x.RoomLinks.Any() &&
|
|
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
|
|
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
PlanName = x.ExamPlan!.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),
|
|
x.TeachingTaskId,
|
|
IsExam = true
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
|
|
db,
|
|
legacySchedule.Select(x => x.TeachingTaskId),
|
|
cancellationToken))
|
|
.GroupBy(x => x.TeachingTaskId)
|
|
.ToDictionary(x => x.Key, x => x.Count());
|
|
return Ok(roomSchedule.Select(x => new ExamScheduleResponse(
|
|
x.Id,
|
|
x.PlanName,
|
|
x.ExamDate,
|
|
x.StartPeriod,
|
|
x.PeriodCount,
|
|
x.StartsAt,
|
|
x.EndsAt,
|
|
string.Join("、", x.TaskNumbers.OrderBy(value => value)),
|
|
x.CourseCode,
|
|
x.CourseName,
|
|
x.ClassroomName,
|
|
x.BuildingName,
|
|
x.InvigilatorNames.ToList(),
|
|
null,
|
|
x.StudentCount,
|
|
true))
|
|
.Concat(legacySchedule.Select(x => new ExamScheduleResponse(
|
|
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.InvigilatorNames.ToList(),
|
|
null,
|
|
rosterCounts.GetValueOrDefault(x.TeachingTaskId),
|
|
x.IsExam)))
|
|
.OrderBy(x => x.ExamDate)
|
|
.ThenBy(x => x.StartPeriod));
|
|
}
|
|
return Ok(Array.Empty<object>());
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// Private helpers
|
|
// ═══════════════════════════════════════════
|
|
|
|
private async Task RemoveRoomsForSessionsAsync(
|
|
IReadOnlyCollection<Guid> sessionIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var ids = sessionIds.Distinct().ToArray();
|
|
if (ids.Length == 0) return;
|
|
var roomIds = await db.ExamRoomSessions.AsNoTracking()
|
|
.WhereIn(ids, x => x.ExamSessionId)
|
|
.Select(x => x.ExamRoomId)
|
|
.Distinct()
|
|
.ToListAsync(cancellationToken);
|
|
if (roomIds.Count == 0) return;
|
|
var rooms = await db.ExamRooms
|
|
.WhereIn(roomIds, x => x.Id)
|
|
.ToListAsync(cancellationToken);
|
|
db.ExamRooms.RemoveRange(rooms);
|
|
}
|
|
|
|
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(
|
|
ExamPlan 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("教学班与考试计划必须属于同一学期。");
|
|
|
|
var studentCount = await TeachingTaskRosterQuery
|
|
.ForTask(db, task.Id)
|
|
.CountAsync(cancellationToken);
|
|
|
|
if (classroomId.HasValue)
|
|
{
|
|
var room = await db.Classrooms.AsNoTracking()
|
|
.FirstOrDefaultAsync(
|
|
x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
|
|
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
|
|
if (studentCount > room.Capacity)
|
|
return ConflictProblem(
|
|
$"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。");
|
|
}
|
|
|
|
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.ExamSessions.Where(x => x.ExamPlanId == 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 (await db.ExamRooms.AsNoTracking().AnyAsync(
|
|
room =>
|
|
room.ClassroomId == classroomId.Value &&
|
|
room.StartsAt < endsAt &&
|
|
startsAt < room.EndsAt &&
|
|
(!currentId.HasValue ||
|
|
!room.SessionLinks.Any(link =>
|
|
link.ExamSessionId == currentId.Value)),
|
|
cancellationToken))
|
|
return ConflictProblem("该时段考场已被混排考试占用。");
|
|
}
|
|
|
|
if (teacherIds.Length > 0)
|
|
{
|
|
if (await db.ExamSessionInvigilators
|
|
.Where(i => overlaps.Any(x => x.Id == i.ExamSessionId))
|
|
.WhereIn(teacherIds, i => i.TeacherId)
|
|
.AnyAsync(cancellationToken))
|
|
return ConflictProblem("监考教师在该时段已有考试任务。");
|
|
if (await db.ExamRoomInvigilators.AsNoTracking()
|
|
.Where(i =>
|
|
i.ExamRoom!.StartsAt < endsAt &&
|
|
startsAt < i.ExamRoom.EndsAt &&
|
|
(!currentId.HasValue ||
|
|
!i.ExamRoom.SessionLinks.Any(link =>
|
|
link.ExamSessionId == currentId.Value)))
|
|
.WhereIn(teacherIds, i => i.TeacherId)
|
|
.AnyAsync(cancellationToken))
|
|
return ConflictProblem("监考教师在该时段已有混排考场任务。");
|
|
}
|
|
|
|
var overlappingTaskIds = await overlaps
|
|
.Select(x => x.TeachingTaskId)
|
|
.Distinct()
|
|
.ToListAsync(cancellationToken);
|
|
if (overlappingTaskIds.Count > 0)
|
|
{
|
|
var rosterRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
|
db,
|
|
overlappingTaskIds.Append(task.Id),
|
|
cancellationToken);
|
|
var currentStudentIds = rosterRows
|
|
.Where(x => x.TeachingTaskId == task.Id)
|
|
.Select(x => x.StudentId)
|
|
.ToHashSet();
|
|
if (rosterRows.Any(x =>
|
|
x.TeachingTaskId != task.Id &&
|
|
currentStudentIds.Contains(x.StudentId)))
|
|
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.FormalExam &&
|
|
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.FormalExam &&
|
|
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);
|
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, 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;
|
|
private static string FileNamePart(string value)
|
|
{
|
|
var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
|
|
var normalized = new string(value
|
|
.Select(character =>
|
|
invalidCharacters.Contains(character) ? '-' : character)
|
|
.ToArray()).Trim();
|
|
if (normalized.Length == 0) return "考试计划";
|
|
return normalized.Length <= 80 ? normalized : normalized[..80];
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// Request/Response models
|
|
// ═══════════════════════════════════════════
|
|
|
|
public sealed record ExamPlanRequest(
|
|
Guid AcademicTermId,
|
|
[Required, MaxLength(120)] string Name,
|
|
[MaxLength(500)] string? Notes);
|
|
|
|
public sealed record CreateExamSessionRequest(
|
|
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 CreateExamSessionsBatchRequest(
|
|
[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 RemoveExamSessionsBatchRequest(
|
|
[Required] IReadOnlyCollection<Guid> SessionIds);
|
|
|
|
public sealed record ExamAutoArrangeRequest(
|
|
IReadOnlyCollection<Guid>? SessionIds = null,
|
|
bool AssignClassrooms = true,
|
|
bool AssignInvigilators = true);
|
|
|
|
public sealed record ExamScheduleResponse(
|
|
Guid Id,
|
|
string PlanName,
|
|
DateOnly ExamDate,
|
|
int StartPeriod,
|
|
int PeriodCount,
|
|
DateTime StartsAt,
|
|
DateTime EndsAt,
|
|
string TaskNumber,
|
|
string CourseCode,
|
|
string CourseName,
|
|
string? ClassroomName,
|
|
string? BuildingName,
|
|
IReadOnlyCollection<string> InvigilatorNames,
|
|
string? SeatNumber,
|
|
int? StudentCount,
|
|
bool IsExam);
|