自动排考

This commit is contained in:
biss committed 2026-07-27 21:27:00 +08:00
1 parent c3e8a42641
commit 9d9f54a3b0
4 files changed
+443 -92

No files matched your search

+154 -50
View File
@@ -108,8 +108,21 @@ public sealed class ExamsController(
}
[HttpGet("plans/{id:guid}")]
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
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))
@@ -122,71 +135,157 @@ public sealed class ExamsController(
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,
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)
})
TotalSessionCount = x.Sessions.Count
}).FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound();
var roomEntities = await db.ExamRooms.AsNoTracking()
.Include(x => x.Classroom)
.ThenInclude(x => x!.Building)
.Include(x => x.SessionLinks)
.Include(x => x.Seats)
.Include(x => x.Invigilators)
.ThenInclude(x => x.Teacher)
.Where(x => x.ExamPlanId == id)
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,
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 roomsBySession = roomEntities
.SelectMany(room => room.SessionLinks.Select(link => new
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
{
link.ExamRoomId,
room.ExamRoomId,
room.ClassroomId,
ClassroomName = room.Classroom!.Name,
BuildingName = room.Classroom.Building!.Name,
ClassroomCapacity = room.Classroom.Capacity,
SeatCount = room.Seats.Count(seat =>
seat.ExamSessionId == link.ExamSessionId),
TotalSeatCount = room.Seats.Count,
IsMixed = room.SessionLinks.Count > 1,
InvigilatorIds = room.Invigilators
room.ClassroomName,
room.BuildingName,
room.ClassroomCapacity,
link.SeatCount,
room.TotalSeatCount,
IsMixed = room.SessionCount > 1,
InvigilatorIds = invigilatorsByRoom[room.ExamRoomId]
.Select(invigilator => invigilator.TeacherId)
.ToList(),
InvigilatorNames = room.Invigilators
.Select(invigilator => invigilator.Teacher!.Name)
InvigilatorNames = invigilatorsByRoom[room.ExamRoomId]
.Select(invigilator => invigilator.TeacherName)
.ToList()
}
}))
.ToLookup(x => x.ExamSessionId, x => x.Room);
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
plan.Sessions.Select(x => x.TeachingTaskId),
sessions.Select(x => x.TeachingTaskId),
cancellationToken))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
@@ -199,7 +298,12 @@ public sealed class ExamsController(
plan.Status,
plan.Notes,
plan.PublishedAt,
Sessions = plan.Sessions.Select(item => new
plan.TotalSessionCount,
FilteredSessionCount = filteredSessionCount,
SessionPage = page,
SessionPageSize = pageSize,
ScheduledTeachingTaskIds = scheduledTeachingTaskIds,
Sessions = sessions.Select(item => new
{
item.Id,
item.TeachingTaskId,
@@ -0,0 +1,137 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExamPlanPaginationTests
{
[Fact]
public async Task GetPlan_PaginatesSearchesAndFiltersSessionsOnServer()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "CS", Name = "计算机学院" };
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var tasks = Enumerable.Range(1, 11)
.Select(number => new TeachingTask
{
TaskNumber = $"2026-1-CS101-{number:00}",
Name = number == 11 ? "专项课程教学班" : $"程序设计教学班 {number}",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published
})
.ToList();
var plan = new ExamPlan
{
AcademicTermId = term.Id,
Name = "期末考试",
Status = ExamPlanStatus.Draft,
Sessions = tasks.Select((task, index) => new ExamSession
{
TeachingTaskId = task.Id,
ExamDate = new DateOnly(2027, 1, 8).AddDays(index),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc)
.AddDays(index),
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc)
.AddDays(index),
RequiredInvigilatorCount = 1
}).ToList()
};
db.AddRange(college, course, term);
db.AddRange(tasks);
db.Add(plan);
await db.SaveChangesAsync();
var controller = new ExamsController(
db,
new AllScope(),
new ExamArrangementService(db),
NoOpAppCache.Instance);
var secondPage = ReadPlan(await controller.GetPlan(
plan.Id,
page: 2,
pageSize: 10,
cancellationToken: CancellationToken.None));
Assert.Equal(11, ReadInt(secondPage, "TotalSessionCount"));
Assert.Equal(11, ReadInt(secondPage, "FilteredSessionCount"));
Assert.Equal(2, ReadInt(secondPage, "SessionPage"));
Assert.Single(ReadItems(secondPage, "Sessions"));
Assert.Equal(11, ReadItems(secondPage, "ScheduledTeachingTaskIds").Count);
var searchPage = ReadPlan(await controller.GetPlan(
plan.Id,
keyword: "专项",
cancellationToken: CancellationToken.None));
Assert.Equal(11, ReadInt(searchPage, "TotalSessionCount"));
Assert.Equal(1, ReadInt(searchPage, "FilteredSessionCount"));
Assert.Single(ReadItems(searchPage, "Sessions"));
var completedPage = ReadPlan(await controller.GetPlan(
plan.Id,
allocation: "complete",
cancellationToken: CancellationToken.None));
Assert.Equal(0, ReadInt(completedPage, "FilteredSessionCount"));
Assert.Empty(ReadItems(completedPage, "Sessions"));
}
private static object ReadPlan(ActionResult result) =>
Assert.IsType<OkObjectResult>(result).Value!;
private static int ReadInt(object value, string property) =>
(int)value.GetType().GetProperty(property)!.GetValue(value)!;
private static List<object> ReadItems(object value, string property) =>
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
value.GetType().GetProperty(property)!.GetValue(value))
.Cast<object>()
.ToList();
private sealed class AllScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin]));
}
}
@@ -256,10 +256,27 @@ public sealed class TeachingWorkflowRosterTests
Assert.IsType<NoContentResult>(publishResult);
var planResult = Assert.IsType<OkObjectResult>(
await exams.GetPlan(examPlan.Id, CancellationToken.None));
await exams.GetPlan(
examPlan.Id,
cancellationToken: CancellationToken.None));
var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions");
Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount"));
Assert.Single(ReadEnumerableProperty(planSessions.Single(), "ExamRooms"));
var returnedRoom = Assert.Single(
ReadEnumerableProperty(planSessions.Single(), "ExamRooms"));
Assert.Equal(
classroom.Id,
returnedRoom.GetType().GetProperty("ClassroomId")!.GetValue(returnedRoom));
Assert.Equal(1, ReadIntProperty(returnedRoom, "SeatCount"));
Assert.Equal(1, ReadIntProperty(returnedRoom, "TotalSeatCount"));
Assert.False(
(bool)returnedRoom.GetType().GetProperty("IsMixed")!
.GetValue(returnedRoom)!);
Assert.Equal(
[teacher.Id],
ReadEnumerableProperty(returnedRoom, "InvigilatorIds").Cast<Guid>());
Assert.Equal(
[teacher.Name],
ReadEnumerableProperty(returnedRoom, "InvigilatorNames").Cast<string>());
var examSessionId = examPlan.Sessions.Single().Id;
var rosterResult = Assert.IsType<OkObjectResult>(
Loaded 3 of 4 files, more files were not shown because too many files have changed in this diff. Show more