课表查询中最明显的性能优化。

将混合考场查询从“加载整个学期的考场、座位、教师、班级后在内存筛选”,改为在数据库中按班级、教师或教室直接筛选。
改为窄字段投影,避免创建大量 EF 实体和关系对象。
添加查询标签,便于后续在 MySQL 慢查询和链路追踪中定位。
保留现有 HybridCache/可选 Redis 缓存体系,无需新增依赖或数据库迁移。
增加教室、无关教室、监考教师三种回归场景。
This commit is contained in:
2026-07-31 07:56:30 +08:00 Unverified
parent 7bcfa1a7a4
commit a369b4fe02
2 changed files with 103 additions and 76 deletions
@@ -356,72 +356,49 @@ public sealed class TimetableDataService(AppDbContext db)
MapToEntryDto(x, slotLookup))); MapToEntryDto(x, slotLookup)));
// ── Mixed-room sessions (ExamRoomAssignment) ── // ── Mixed-room sessions (ExamRoomAssignment) ──
// Load all mixed rooms for this term into memory, then filter // Start from room/session links so the resource predicate stays in SQL.
var allMixedRooms = await db.ExamRooms.AsNoTracking() // Loading every room, seat and roster for the term made a single
.Where(room => // timetable request scale with the entire exam plan.
room.ExamPlan!.AcademicTermId == academicTermId && var mixedQuery = db.ExamRoomSessions.AsNoTracking()
room.ExamPlan.Status == ExamPlanStatus.Published) .Where(link =>
.Include(room => room.ExamPlan) link.ExamRoom!.ExamPlan!.AcademicTermId == academicTermId &&
.Include(room => room.Classroom) link.ExamRoom.ExamPlan.Status == ExamPlanStatus.Published);
.ThenInclude(c => c!.Building) mixedQuery = ApplyMixedResourceFilter(
.ThenInclude(b => b!.Campus) mixedQuery, resourceType, resourceId, studentId);
.Include(room => room.Invigilators)
.ThenInclude(i => i.Teacher) var mixedSessions = await mixedQuery
.Include(room => room.SessionLinks) .TagWith("Timetable.LoadMixedExamEntries")
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Course)
.Include(room => room.SessionLinks)
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Teachers)
.ThenInclude(tt => tt.Teacher)
.Include(room => room.SessionLinks)
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Classes)
.ThenInclude(tc => tc.AdministrativeClass)
.Include(room => room.Seats)
.AsSplitQuery() .AsSplitQuery()
.ToListAsync(cancellationToken); .OrderBy(link => link.ExamRoom!.ExamDate)
.ThenBy(link => link.ExamRoom!.StartPeriod)
foreach (var room in allMixedRooms) .ThenBy(link => link.ExamSession!.TeachingTask!.Course!.Code)
{ .Select(link => new ExamSessionProjection(
foreach (var link in room.SessionLinks) link.ExamRoomId,
{ link.ExamSession!.TeachingTaskId,
var session = link.ExamSession; link.ExamSession.TeachingTask!.TaskNumber,
if (session == null || session.TeachingTask == null) continue; link.ExamSession.TeachingTask.Name,
link.ExamSession.TeachingTask.Course!.Code,
// Apply resource filter in memory link.ExamSession.TeachingTask.Course.Name,
if (!MatchesMixedResource( link.ExamSession.TeachingTask.Teachers
room, session, resourceType, resourceId, studentId))
continue;
result.Add(MapToEntryDto(new ExamSessionProjection(
room.Id,
session.TeachingTaskId,
session.TeachingTask.TaskNumber,
session.TeachingTask.Name,
session.TeachingTask.Course!.Code,
session.TeachingTask.Course.Name,
session.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary) .OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name).ToList(), .Select(t => t.Teacher!.Name).ToList(),
session.TeachingTask.Classes link.ExamSession.TeachingTask.Classes
.Select(c => c.AdministrativeClass!.Name).ToList(), .Select(c => c.AdministrativeClass!.Name).ToList(),
room.Classroom!.Name, link.ExamRoom!.Classroom!.Name,
room.Classroom.Building!.Name, link.ExamRoom.Classroom.Building!.Name,
room.Classroom.Building.Campus!.Name, link.ExamRoom.Classroom.Building.Campus!.Name,
room.ExamDate, link.ExamRoom.ExamDate,
room.StartPeriod, link.ExamRoom.StartPeriod,
room.PeriodCount, link.ExamRoom.PeriodCount,
room.ExamPlan!.Name, link.ExamRoom.ExamPlan!.Name,
room.Invigilators link.ExamRoom.Invigilators
.Select(i => i.Teacher!.Name).ToList(), .Select(i => i.Teacher!.Name).ToList(),
null, null,
room.UpdatedAt), slotLookup)); link.ExamRoom.UpdatedAt))
} .ToListAsync(cancellationToken);
}
result.AddRange(mixedSessions.Select(x =>
MapToEntryDto(x, slotLookup)));
return result; return result;
} }
@@ -535,9 +512,8 @@ public sealed class TimetableDataService(AppDbContext db)
return date.AddDays(1 - dayOfWeek); return date.AddDays(1 - dayOfWeek);
} }
private static bool MatchesMixedResource( private static IQueryable<ExamRoomSession> ApplyMixedResourceFilter(
ExamRoomAssignment room, IQueryable<ExamRoomSession> source,
ExamSession session,
TimetableResourceType resourceType, TimetableResourceType resourceType,
Guid resourceId, Guid resourceId,
Guid? studentId) Guid? studentId)
@@ -545,20 +521,28 @@ public sealed class TimetableDataService(AppDbContext db)
switch (resourceType) switch (resourceType)
{ {
case TimetableResourceType.Classroom: case TimetableResourceType.Classroom:
return room.ClassroomId == resourceId; return source.Where(link =>
link.ExamRoom!.ClassroomId == resourceId);
case TimetableResourceType.Teacher: case TimetableResourceType.Teacher:
return room.Invigilators.Any(i => i.TeacherId == resourceId) || return source.Where(link =>
session.TeachingTask!.Teachers.Any( link.ExamRoom!.Invigilators.Any(i =>
t => t.TeacherId == resourceId); i.TeacherId == resourceId) ||
link.ExamSession!.TeachingTask!.Teachers.Any(t =>
t.TeacherId == resourceId));
case TimetableResourceType.Class: case TimetableResourceType.Class:
if (studentId.HasValue) if (studentId.HasValue)
return session.TeachingTask!.Classes.Any(c => {
return source.Where(link =>
link.ExamSession!.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId) || c.AdministrativeClassId == resourceId) ||
room.Seats.Any(s => s.StudentId == studentId.Value); link.ExamRoom!.Seats.Any(s =>
return session.TeachingTask!.Classes.Any(c => s.StudentId == studentId.Value));
c.AdministrativeClassId == resourceId); }
return source.Where(link =>
link.ExamSession!.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId));
default: default:
return true; return source;
} }
} }
@@ -1,6 +1,7 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -55,6 +56,48 @@ public sealed class ExamArrangementServiceTests
Assert.Contains("1个教学班场次处理完成", result.Message); Assert.Contains("1个教学班场次处理完成", result.Message);
Assert.Contains("1名监考教师", result.Message); Assert.Contains("1名监考教师", result.Message);
Assert.Contains("程序设计", result.Message); Assert.Contains("程序设计", result.Message);
plan = await db.ExamPlans.SingleAsync(x => x.Id == plan.Id);
plan.Status = ExamPlanStatus.Published;
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var timetableService = new TimetableDataService(db);
var classroomTimetable = await timetableService.BuildAsync(
TimetableResourceType.Classroom,
seed.LargeClassroom.Id,
seed.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
var unrelatedClassroomTimetable = await timetableService.BuildAsync(
TimetableResourceType.Classroom,
seed.SmallClassroom.Id,
seed.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
var invigilatorTimetable = await timetableService.BuildAsync(
TimetableResourceType.Teacher,
room.Invigilators.Single().TeacherId,
seed.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
Assert.Equal(
"程序设计",
Assert.Single(classroomTimetable!.ExamEntries).CourseName);
Assert.Empty(unrelatedClassroomTimetable!.ExamEntries);
Assert.Contains(
"监考:监考教师",
Assert.Single(invigilatorTimetable!.ExamEntries).TeacherNames);
} }
[Fact] [Fact]