Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/TimetablesController.cs
T
biss 7e14b22296 变更摘要:考试安排注入课表
问题根因

  TimetableDataService.LoadExamEntriesAsync 只查询旧的 ExamSession(legacy 直接分配教室),完全忽略自动编排生成的
  ExamRoomAssignment(混排考场),导致:

  ┌──────────┬──────────────────────────┬───────────────────────────────────┐
  │ 课表类型 │           之前           │               之后                │
  ├──────────┼──────────────────────────┼───────────────────────────────────┤
  │ 教室课表 │ return [] 硬编码空数组   │ 查询 legacy + mixed room 两类数据 │
  ├──────────┼──────────────────────────┼───────────────────────────────────┤
  │ 教师课表 │ 只显示监考               │ 监考 + 自己授课的考试             │
  ├──────────┼──────────────────────────┼───────────────────────────────────┤
  │ 学生课表 │ 只通过 CourseEnrollments │ 同时查 ExamSeats(座位分配)      │
  ├──────────┼──────────────────────────┼───────────────────────────────────┤
  │ 空闲教室 │ 只排除 legacy 考试占用   │ 同时排除混排考场占用              │
  └──────────┴──────────────────────────┴───────────────────────────────────┘

  修改的文件

  1. TimetableDataService.cs — 重写 LoadExamEntriesAsync

  - 拆分为两个数据源:legacy ExamSessions + mixed ExamRoomAssignment(通过 ExamRoomSession 关联)
  - 新增 ApplyLegacyResourceFilter / ApplyMixedResourceFilter
  两个过滤方法,按资源类型(Classroom/Teacher/Student/Class)分别过滤
  - 新增 MapToEntryDto 统一映射到 TimetableEntryDto
  - 新增 ExamSessionProjection record 统一两个数据源的投影

  各资源类型的数据来源:

  ┌───────────┬────────────────────────────────┬──────────────────────────────────────┐
  │ 资源类型  │          Legacy 过滤           │           Mixed-room 过滤            │
  ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
  │ Classroom │ ExamSessions.ClassroomId == id │ ExamRoom.ClassroomId == id           │
  ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
  │ Teacher   │ 监考 + 授课教师                │ 监考 + 授课教师                      │
  ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
  │ Student   │ CourseEnrollments + 行政班     │ ExamSeats.StudentId                  │
  ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
  │ Class     │ TeachingTask.Classes           │ SessionLinks → ExamSession → Classes │
  └───────────┴────────────────────────────────┴──────────────────────────────────────┘

  2. ClassroomReservationAvailabilityService.cs — 补充混排考场占用

  在 GetOccupiedClassroomIdsAsync 新增 ExamRoomAssignment 查询,空闲教室查询现在会正确排除混排考场占用的教室。

  3. TimetablesController.cs — 课表选项含考试学期

  - 学期级的 HasPublishedTimetable 增加 ExamPlans.Status == Published 判断
  - 班级级的 HasPublishedTimetable 增加 ExamSessions 关联判断
2026-07-28 08:27:22 +08:00

521 lines
19 KiB
C#

using System.Security.Claims;
using System.Security.Cryptography;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/timetables")]
public sealed class TimetablesController(
AppDbContext db,
TimetableDataService timetableDataService,
PersonalCalendarService personalCalendarService,
IAppCache cache) : ControllerBase
{
[HttpGet("options")]
[AllowAnonymous]
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
{
var result = await cache.GetOrCreateAsync(
AppCacheKeys.TimetableOptions,
LoadOptionsAsync,
AppCacheProfile.ReferenceData,
[
AppCacheTags.BaseData,
AppCacheTags.Timetables,
AppCacheTags.TimetableOptions
],
cancellationToken);
return Ok(result);
}
[HttpGet("classes/{classId:guid}")]
[AllowAnonymous]
public Task<ActionResult> GetClassTimetable(
Guid classId,
Guid? academicTermId,
CancellationToken cancellationToken) =>
BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
[HttpGet("teachers/{teacherId:guid}")]
[AllowAnonymous]
public async Task<ActionResult> GetTeacherTimetable(
Guid teacherId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await GetPublishedTimetableAsync(
TimetableResourceType.Teacher,
teacherId,
academicTermId,
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("teachers/{teacherId:guid}/export.xlsx")]
[AllowAnonymous]
public async Task<ActionResult> ExportTeacherTimetable(
Guid teacherId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await GetPublishedTimetableAsync(
TimetableResourceType.Teacher,
teacherId,
academicTermId,
cancellationToken);
if (result is null) return NotFound();
return ExcelFile(result);
}
[HttpGet("classes/{classId:guid}/export.xlsx")]
[AllowAnonymous]
public async Task<ActionResult> ExportClassTimetable(
Guid classId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await GetPublishedTimetableAsync(
TimetableResourceType.Class,
classId,
academicTermId,
cancellationToken);
if (result is null) return NotFound();
return ExcelFile(result);
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> GetMyTimetable(
Guid? academicTermId,
CancellationToken cancellationToken)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized();
var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new
{
x.Id,
x.AdministrativeClassId,
x.StudentNumber,
x.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (student is not null)
{
return await BuildTimetableAsync(
student.AdministrativeClassId,
academicTermId,
student.Id,
cancellationToken,
new TimetableStudentDto(student.StudentNumber, student.Name));
}
var teacher = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
.Select(x => new { x.Id })
.FirstOrDefaultAsync(cancellationToken);
if (teacher is not null)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Teacher,
teacher.Id,
academicTermId,
null,
false,
null,
null,
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
return Conflict(new ProblemDetails
{
Title = "档案未关联",
Detail = "当前登录账号没有关联教师或学生档案,请联系教务管理员。",
Status = StatusCodes.Status409Conflict
});
}
[HttpGet("mine/export.xlsx")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> ExportMyTimetable(
Guid? academicTermId,
CancellationToken cancellationToken)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized();
var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new
{
x.Id,
x.AdministrativeClassId,
x.StudentNumber,
x.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (student is not null)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
academicTermId,
null,
false,
student.Id,
new TimetableStudentDto(student.StudentNumber, student.Name),
cancellationToken);
if (result is null) return NotFound();
return ExcelFile(result);
}
var teacher = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
.Select(x => new { x.Id })
.FirstOrDefaultAsync(cancellationToken);
if (teacher is not null)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Teacher,
teacher.Id,
academicTermId,
null,
false,
null,
null,
cancellationToken);
if (result is null) return NotFound();
return ExcelFile(result);
}
return NotFound();
}
[HttpGet("mine/calendar-subscription")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> GetMyCalendarSubscription(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
return Ok(BuildCalendarSubscriptionResponse(user));
}
[HttpPost("mine/calendar-subscription")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> EnableMyCalendarSubscription(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
{
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
return Ok(BuildCalendarSubscriptionResponse(user));
}
[HttpPost("mine/calendar-subscription/rotate")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> RotateMyCalendarSubscription(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return Ok(BuildCalendarSubscriptionResponse(user));
}
[HttpDelete("mine/calendar-subscription")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> DisableMyCalendarSubscription(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
user.CalendarSubscriptionStamp = null;
user.CalendarSubscriptionCreatedAt = null;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("mine/calendar.ics")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> DownloadMyCalendar(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
if (result is null) return CalendarProfileNotFound();
return File(
result.Content,
"text/calendar; charset=utf-8",
$"{SafeFileName(user.DisplayName)}-个人教学日历.ics");
}
[HttpGet("calendar/{userId:guid}/{accessToken}.ics")]
[AllowAnonymous]
public async Task<ActionResult> GetCalendarFeed(
Guid userId,
string accessToken,
CancellationToken cancellationToken)
{
var user = await db.Users.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
if (user is null ||
!personalCalendarService.IsAccessTokenValid(user, accessToken))
return NotFound();
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
if (result is null) return NotFound();
var etag = $"\"{Convert.ToHexString(SHA256.HashData(result.Content))}\"";
if (Request.Headers.IfNoneMatch.ToString()
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Contains(etag, StringComparer.Ordinal))
{
return StatusCode(StatusCodes.Status304NotModified);
}
Response.Headers.CacheControl = "private, max-age=300";
Response.Headers.ETag = etag;
Response.Headers.Append("X-Content-Type-Options", "nosniff");
return File(result.Content, "text/calendar; charset=utf-8");
}
private async Task<ActionResult> BuildTimetableAsync(
Guid classId,
Guid? academicTermId,
Guid? studentId,
CancellationToken cancellationToken,
TimetableStudentDto? student = null)
{
var result = studentId.HasValue || student is not null
? await timetableDataService.BuildAsync(
TimetableResourceType.Class,
classId,
academicTermId,
null,
false,
studentId,
student,
cancellationToken)
: await GetPublishedTimetableAsync(
TimetableResourceType.Class,
classId,
academicTermId,
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
private Task<TimetableData?> GetPublishedTimetableAsync(
TimetableResourceType resourceType,
Guid resourceId,
Guid? academicTermId,
CancellationToken cancellationToken) =>
cache.GetOrCreateAsync(
AppCacheKeys.PublishedTimetable(
resourceType.ToString().ToLowerInvariant(),
resourceId,
academicTermId),
token => timetableDataService.BuildAsync(
resourceType,
resourceId,
academicTermId,
null,
false,
null,
null,
token),
AppCacheProfile.PublishedTimetable,
[AppCacheTags.BaseData, AppCacheTags.Timetables],
cancellationToken);
private async Task<TimetableOptionsResponse> LoadOptionsAsync(
CancellationToken cancellationToken)
{
var terms = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderByDescending(x => x.StartDate)
.Select(x => new TimetableTermOption(
x.Id,
x.Name,
x.AcademicYear,
x.Season,
x.StartDate,
x.EndDate,
x.IsCurrent,
x.IsArchived,
db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
db.ExamPlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == ExamPlanStatus.Published)))
.ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
var classes = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
.OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code)
.Select(x => new TimetableClassOption(
x.Id,
x.Code,
x.Name,
x.Grade,
x.MajorId,
x.Major!.Name,
x.Major.CollegeId,
x.Major.College!.Name,
defaultTermId.HasValue &&
(db.ScheduleEntries.Any(entry =>
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == defaultTermId.Value &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
db.ExamSessions.Any(session =>
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
session.ExamPlan.Status == ExamPlanStatus.Published &&
session.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)))))
.ToListAsync(cancellationToken);
var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new TimetableCollegeOption(x.Id, x.Code, x.Name))
.ToListAsync(cancellationToken);
var majors = await db.Majors.AsNoTracking()
.Where(x => x.IsEnabled && x.College!.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new TimetableMajorOption(
x.Id, x.Code, x.Name, x.CollegeId))
.ToListAsync(cancellationToken);
return new TimetableOptionsResponse(terms, colleges, majors, classes);
}
private ActionResult ExcelFile(TimetableData result)
{
var bytes = TimetableExcelExporter.Create(result);
var fileName =
$"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.xlsx";
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
}
private static string SafeFileName(string value)
{
foreach (var character in Path.GetInvalidFileNameChars())
value = value.Replace(character, '-');
return value.Trim();
}
private async Task<ApplicationUser?> CurrentUserAsync(
CancellationToken cancellationToken)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return null;
return await db.Users.FirstOrDefaultAsync(
x => x.Id == userId && x.IsEnabled,
cancellationToken);
}
private PersonalCalendarSubscriptionResponse BuildCalendarSubscriptionResponse(
ApplicationUser user)
{
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
return new(false, null, null, null, null);
var accessToken = personalCalendarService.CreateAccessToken(user);
var feedPath =
$"timetables/calendar/{user.Id:N}/{accessToken}.ics";
var feedUrl =
$"{Request.Scheme}://{Request.Host}{Request.PathBase}" +
$"/api/{feedPath}";
var webcalUrl = feedUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
? $"webcal://{feedUrl[8..]}"
: feedUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
? $"webcal://{feedUrl[7..]}"
: feedUrl;
return new(
true,
feedUrl,
feedPath,
webcalUrl,
user.CalendarSubscriptionCreatedAt);
}
private ActionResult CalendarProfileNotFound() => Conflict(new ProblemDetails
{
Title = "档案未关联",
Detail = "当前登录账号没有关联有效的教师或学生档案,请联系教务管理员。",
Status = StatusCodes.Status409Conflict
});
}
public sealed record PersonalCalendarSubscriptionResponse(
bool IsEnabled,
string? FeedUrl,
string? FeedPath,
string? WebcalUrl,
DateTime? CreatedAt);
public sealed record TimetableOptionsResponse(
IReadOnlyList<TimetableTermOption> Terms,
IReadOnlyList<TimetableCollegeOption> Colleges,
IReadOnlyList<TimetableMajorOption> Majors,
IReadOnlyList<TimetableClassOption> Classes);
public sealed record TimetableTermOption(
Guid Id,
string Name,
string AcademicYear,
TermSeason Season,
DateOnly StartDate,
DateOnly EndDate,
bool IsCurrent,
bool IsArchived,
bool HasPublishedTimetable);
public sealed record TimetableCollegeOption(Guid Id, string Code, string Name);
public sealed record TimetableMajorOption(
Guid Id,
string Code,
string Name,
Guid CollegeId);
public sealed record TimetableClassOption(
Guid Id,
string Code,
string Name,
int Grade,
Guid MajorId,
string MajorName,
Guid CollegeId,
string CollegeName,
bool HasPublishedTimetable);