教室预约

This commit is contained in:
2026-07-26 20:11:56 +08:00 Unverified
parent b0679a253d
commit 9f534c22f4
14 changed files with 7436 additions and 31 deletions
@@ -0,0 +1,708 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
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]
[Authorize]
[Route("api/classroom-reservations")]
public sealed class ClassroomReservationsController(
AppDbContext db,
ICurrentUserDataScope scope,
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
{
[HttpGet("options")]
public async Task<ActionResult> GetOptions(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var applicant = await ResolveApplicantAsync(cancellationToken);
var terms = await db.AcademicTerms.AsNoTracking()
.Where(term => term.IsEnabled && !term.IsArchived)
.OrderByDescending(term => term.IsCurrent)
.ThenByDescending(term => term.StartDate)
.Select(term => new
{
term.Id,
term.Name,
term.StartDate,
term.EndDate,
term.IsCurrent,
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == term.Id &&
plan.Status == SchedulePlanStatus.Published)
})
.ToListAsync(cancellationToken);
var selectedTermId = academicTermId
?? terms.FirstOrDefault(term =>
term.IsCurrent && term.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault(term => term.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault()?.Id;
var campuses = await db.Campuses.AsNoTracking()
.Where(campus => campus.IsEnabled)
.OrderBy(campus => campus.SortOrder)
.ThenBy(campus => campus.Code)
.Select(campus => new { campus.Id, campus.Code, campus.Name })
.ToListAsync(cancellationToken);
var buildings = await db.Buildings.AsNoTracking()
.Where(building =>
building.IsEnabled &&
building.Campus!.IsEnabled)
.OrderBy(building => building.Campus!.SortOrder)
.ThenBy(building => building.SortOrder)
.ThenBy(building => building.Code)
.Select(building => new
{
building.Id,
building.Code,
building.Name,
building.CampusId
})
.ToListAsync(cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking()
.Where(classroom =>
classroom.IsEnabled &&
classroom.Building!.IsEnabled &&
classroom.Building.Campus!.IsEnabled)
.OrderBy(classroom => classroom.Building!.Campus!.SortOrder)
.ThenBy(classroom => classroom.Building!.SortOrder)
.ThenBy(classroom => classroom.Code)
.Select(classroom => new
{
classroom.Id,
classroom.Code,
classroom.Name,
classroom.BuildingId,
classroom.Capacity,
classroom.RoomType
})
.ToListAsync(cancellationToken);
var timeSlots = await LoadTimeSlotsAsync(
selectedTermId,
cancellationToken);
return Ok(new
{
Applicant = applicant.Context is null
? null
: new
{
applicant.Context.User.Id,
applicant.Context.User.DisplayName,
CollegeId = applicant.Context.CollegeId,
CollegeName = applicant.Context.CollegeName
},
ApplicantProblem = applicant.Error,
Terms = terms,
SelectedTermId = selectedTermId,
Campuses = campuses,
Buildings = buildings,
Classrooms = classrooms,
TimeSlots = timeSlots
});
}
[HttpGet("availability")]
public async Task<ActionResult> GetAvailability(
Guid academicTermId,
DateOnly reservationDate,
[Range(1, 30)] int startPeriod,
[Range(1, 6)] int periodCount,
Guid? campusId,
Guid? buildingId,
[Range(1, 10000)] int? attendeeCount,
CancellationToken cancellationToken)
{
var validation = await ValidateSlotAsync(
academicTermId,
reservationDate,
startPeriod,
periodCount,
cancellationToken);
if (validation.Error is not null) return validation.Error;
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
validation.Term!,
reservationDate,
startPeriod,
periodCount,
null,
cancellationToken);
var rooms = db.Classrooms.AsNoTracking()
.Where(classroom =>
classroom.IsEnabled &&
classroom.Building!.IsEnabled &&
classroom.Building.Campus!.IsEnabled)
.WhereNotIn(occupiedIds, classroom => classroom.Id);
if (campusId.HasValue)
rooms = rooms.Where(room =>
room.Building!.CampusId == campusId.Value);
if (buildingId.HasValue)
rooms = rooms.Where(room => room.BuildingId == buildingId.Value);
if (attendeeCount.HasValue)
rooms = rooms.Where(room => room.Capacity >= attendeeCount.Value);
var items = await rooms
.OrderBy(room => room.Building!.Campus!.SortOrder)
.ThenBy(room => room.Building!.SortOrder)
.ThenBy(room => room.Code)
.Select(room => new
{
room.Id,
room.Code,
room.Name,
room.Capacity,
room.RoomType,
room.BuildingId,
BuildingName = room.Building!.Name,
CampusId = room.Building.CampusId,
CampusName = room.Building.Campus!.Name
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items });
}
[HttpGet("mine")]
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetMine(
ClassroomReservationStatus? status,
[Range(1, int.MaxValue)] int page = 1,
[Range(1, 100)] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = db.ClassroomReservations.AsNoTracking()
.Where(reservation =>
reservation.ApplicantUserId == scope.Current.UserId);
if (status.HasValue)
query = query.Where(reservation => reservation.Status == status.Value);
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
}
[HttpGet("review")]
[Authorize(Roles = SystemRoles.CollegeAdmin)]
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetReviewQueue(
ClassroomReservationStatus? status,
[Range(1, int.MaxValue)] int page = 1,
[Range(1, 100)] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var collegeError = ReviewCollegeError();
if (collegeError is not null) return collegeError;
var collegeId = scope.Current.CollegeId!.Value;
var query = db.ClassroomReservations.AsNoTracking()
.Where(reservation => reservation.ApplicantCollegeId == collegeId);
if (status.HasValue)
query = query.Where(reservation => reservation.Status == status.Value);
else
query = query.Where(reservation =>
reservation.Status == ClassroomReservationStatus.Submitted);
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
}
[HttpPost]
public async Task<ActionResult> Create(
CreateClassroomReservationRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Purpose))
return ValidationProblem("请填写借用用途。");
if (string.IsNullOrWhiteSpace(request.ContactPhone))
return ValidationProblem("请填写联系电话。");
var applicant = await ResolveApplicantAsync(cancellationToken);
if (applicant.Context is null)
return ConflictProblem(
applicant.Error ?? "当前账号未配置申请人学院,无法提交申请。");
var validation = await ValidateSlotAsync(
request.AcademicTermId,
request.ReservationDate,
request.StartPeriod,
request.PeriodCount,
cancellationToken);
if (validation.Error is not null) return validation.Error;
var classroom = await db.Classrooms.AsNoTracking()
.Where(room =>
room.Id == request.ClassroomId &&
room.IsEnabled &&
room.Building!.IsEnabled &&
room.Building.Campus!.IsEnabled)
.Select(room => new
{
room.Id,
room.Name,
room.Code,
room.Capacity,
BuildingName = room.Building!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (classroom is null) return NotFound();
if (request.AttendeeCount > classroom.Capacity)
return ValidationProblem(
$"申请人数超过教室容量({classroom.Capacity} 人)。");
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
validation.Term!,
request.ReservationDate,
request.StartPeriod,
request.PeriodCount,
null,
cancellationToken);
if (occupiedIds.Contains(classroom.Id))
return ConflictProblem("所选教室在该时段已有课程、考试或已批准预约。");
var duplicate = await db.ClassroomReservations.AsNoTracking()
.AnyAsync(reservation =>
reservation.ApplicantUserId == applicant.Context.User.Id &&
reservation.ClassroomId == classroom.Id &&
reservation.ReservationDate == request.ReservationDate &&
reservation.Status == ClassroomReservationStatus.Submitted &&
reservation.StartPeriod <
request.StartPeriod + request.PeriodCount &&
request.StartPeriod <
reservation.StartPeriod + reservation.PeriodCount,
cancellationToken);
if (duplicate)
return ConflictProblem("您已提交过同一教室、重叠时段的申请。");
var reservation = new ClassroomReservation
{
ApplicantUserId = applicant.Context.User.Id,
ApplicantName = applicant.Context.User.DisplayName,
ApplicantCollegeId = applicant.Context.CollegeId,
AcademicTermId = request.AcademicTermId,
ClassroomId = request.ClassroomId,
ReservationDate = request.ReservationDate,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
AttendeeCount = request.AttendeeCount,
Purpose = request.Purpose.Trim(),
ContactPhone = request.ContactPhone.Trim(),
Notes = Normalize(request.Notes)
};
db.ClassroomReservations.Add(reservation);
await db.SaveChangesAsync(cancellationToken);
await NotificationService.SendToRoleAsync(
db,
SystemRoles.CollegeAdmin,
"教室借用申请待审核",
$"{reservation.ApplicantName} 申请于 {reservation.ReservationDate:yyyy-MM-dd} " +
$"借用 {classroom.BuildingName} {classroom.Name}。",
reservation.ApplicantCollegeId,
"/classroom-reservations",
cancellationToken,
NotificationCategory.Approval);
return Created("", new { reservation.Id });
}
[HttpPost("{id:guid}/cancel")]
public async Task<ActionResult> Cancel(
Guid id,
CancellationToken cancellationToken)
{
var reservation = await db.ClassroomReservations
.FirstOrDefaultAsync(item =>
item.Id == id &&
item.ApplicantUserId == scope.Current.UserId,
cancellationToken);
if (reservation is null) return NotFound();
if (reservation.Status is not (
ClassroomReservationStatus.Submitted or
ClassroomReservationStatus.Approved))
return ConflictProblem("当前状态不能取消。");
if (reservation.ReservationDate < TodayInChina())
return ConflictProblem("已过期的预约不能取消。");
var wasApproved =
reservation.Status == ClassroomReservationStatus.Approved;
reservation.Status = ClassroomReservationStatus.Cancelled;
reservation.CancelledAt = DateTime.UtcNow;
reservation.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
if (wasApproved)
{
await NotificationService.SendToRoleAsync(
db,
SystemRoles.CollegeAdmin,
"已批准教室预约被申请人取消",
$"{reservation.ApplicantName} 已取消 " +
$"{reservation.ReservationDate:yyyy-MM-dd} 的教室预约。",
reservation.ApplicantCollegeId,
"/classroom-reservations",
cancellationToken,
NotificationCategory.Approval);
}
return NoContent();
}
[HttpPost("{id:guid}/approve")]
[Authorize(Roles = SystemRoles.CollegeAdmin)]
public Task<ActionResult> Approve(
Guid id,
ReviewClassroomReservationRequest request,
CancellationToken cancellationToken) =>
ReviewAsync(id, true, request.Comment, cancellationToken);
[HttpPost("{id:guid}/reject")]
[Authorize(Roles = SystemRoles.CollegeAdmin)]
public Task<ActionResult> Reject(
Guid id,
ReviewClassroomReservationRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Comment))
return Task.FromResult<ActionResult>(
ValidationProblem("驳回时必须填写审核意见。"));
return ReviewAsync(id, false, request.Comment, cancellationToken);
}
private async Task<ActionResult> ReviewAsync(
Guid id,
bool approve,
string? comment,
CancellationToken cancellationToken)
{
var collegeError = ReviewCollegeError();
if (collegeError is not null) return collegeError;
var reviewerCollegeId = scope.Current.CollegeId!.Value;
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var reservation = await db.ClassroomReservations
.Include(item => item.AcademicTerm)
.Include(item => item.Classroom)
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
if (reservation is null) return NotFound();
if (reservation.ApplicantCollegeId != reviewerCollegeId)
return Forbid();
if (reservation.Status != ClassroomReservationStatus.Submitted)
return ConflictProblem("该申请已处理,不能重复审核。");
if (approve)
{
if (reservation.ReservationDate < TodayInChina())
return ConflictProblem("预约日期已过,不能批准。");
var validation = await ValidateSlotAsync(
reservation.AcademicTermId,
reservation.ReservationDate,
reservation.StartPeriod,
reservation.PeriodCount,
cancellationToken);
if (validation.Error is not null) return validation.Error;
if (reservation.AttendeeCount > reservation.Classroom!.Capacity)
return ConflictProblem("申请人数已超过教室当前容量,不能批准。");
var occupiedIds =
await availabilityService.GetOccupiedClassroomIdsAsync(
validation.Term!,
reservation.ReservationDate,
reservation.StartPeriod,
reservation.PeriodCount,
reservation.Id,
cancellationToken);
if (occupiedIds.Contains(reservation.ClassroomId))
return ConflictProblem(
"教室在该时段已被课程、考试或其他已批准预约占用。");
reservation.Status = ClassroomReservationStatus.Approved;
}
else
{
reservation.Status = ClassroomReservationStatus.Rejected;
}
reservation.ReviewedByUserId = scope.Current.UserId;
reservation.ReviewedAt = DateTime.UtcNow;
reservation.ReviewComment = Normalize(comment);
reservation.UpdatedAt = DateTime.UtcNow;
await NotificationService.SendAsync(
db,
reservation.ApplicantUserId,
approve ? "教室借用申请已通过" : "教室借用申请已驳回",
approve
? $"{reservation.ReservationDate:yyyy-MM-dd} 的教室借用申请已通过。"
: reservation.ReviewComment ?? "审核未通过。",
"/classroom-reservations",
cancellationToken,
NotificationCategory.Approval);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken,
IsolationLevel.Serializable);
}
private async Task<PagedResult<ClassroomReservationDto>> BuildPageAsync(
IQueryable<ClassroomReservation> query,
int page,
int pageSize,
CancellationToken cancellationToken)
{
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(reservation =>
reservation.Status == ClassroomReservationStatus.Submitted)
.ThenByDescending(reservation => reservation.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(reservation => new ClassroomReservationDto(
reservation.Id,
reservation.ApplicantUserId,
reservation.ApplicantName,
reservation.ApplicantCollegeId,
reservation.ApplicantCollege!.Name,
reservation.AcademicTermId,
reservation.AcademicTerm!.Name,
reservation.ClassroomId,
reservation.Classroom!.Code,
reservation.Classroom.Name,
reservation.Classroom.Building!.Name,
reservation.Classroom.Building.Campus!.Name,
reservation.ReservationDate,
reservation.StartPeriod,
reservation.PeriodCount,
reservation.AttendeeCount,
reservation.Purpose,
reservation.ContactPhone,
reservation.Notes,
reservation.Status,
reservation.ReviewComment,
reservation.ReviewedAt,
reservation.CancelledAt,
reservation.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<ClassroomReservationDto>(
items,
total,
page,
pageSize);
}
private async Task<(
AcademicTerm? Term,
ActionResult? Error)> ValidateSlotAsync(
Guid academicTermId,
DateOnly reservationDate,
int startPeriod,
int periodCount,
CancellationToken cancellationToken)
{
var term = await db.AcademicTerms.AsNoTracking()
.FirstOrDefaultAsync(item =>
item.Id == academicTermId &&
item.IsEnabled &&
!item.IsArchived,
cancellationToken);
if (term is null) return (null, NotFound());
if (reservationDate < TodayInChina())
return (null, ValidationProblem("预约日期不能早于今天。"));
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
return (null, ValidationProblem("预约日期不在所选学期范围内。"));
var hasPublishedTimetable = await db.SchedulePlans.AsNoTracking()
.AnyAsync(plan =>
plan.AcademicTermId == term.Id &&
plan.Status == SchedulePlanStatus.Published,
cancellationToken);
if (!hasPublishedTimetable)
return (null, ConflictProblem(
"所选学期的正式课表尚未发布,暂不能提交教室预约。"));
var requestedPeriods = Enumerable.Range(startPeriod, periodCount).ToArray();
var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
.CountAsync(slot =>
slot.AcademicTermId == academicTermId,
cancellationToken);
if (configuredPeriodCount == 0)
{
if (requestedPeriods.Any(period => period > 12))
return (null, ValidationProblem("预约范围包含不存在的节次。"));
}
else
{
var enabledPeriods = await db.ScheduleTimeSlots.AsNoTracking()
.Where(slot =>
slot.AcademicTermId == academicTermId &&
slot.IsEnabled)
.WhereIn(requestedPeriods, slot => slot.PeriodNumber)
.CountAsync(cancellationToken);
if (enabledPeriods != requestedPeriods.Length)
return (null, ValidationProblem("预约范围包含不存在或未启用的节次。"));
}
return (term, null);
}
private async Task<List<ClassroomReservationTimeSlotDto>> LoadTimeSlotsAsync(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = academicTermId.HasValue
? await db.ScheduleTimeSlots.AsNoTracking()
.Where(slot =>
slot.AcademicTermId == academicTermId.Value &&
slot.IsEnabled)
.OrderBy(slot => slot.PeriodNumber)
.Select(slot => new ClassroomReservationTimeSlotDto(
slot.PeriodNumber,
slot.Name,
slot.StartsAt.ToString("HH:mm"),
slot.EndsAt.ToString("HH:mm")))
.ToListAsync(cancellationToken)
: [];
return result.Count > 0
? result
: Enumerable.Range(1, 12)
.Select(period => new ClassroomReservationTimeSlotDto(
period,
$"第 {period} 节",
"",
""))
.ToList();
}
private async Task<ApplicantResolution> ResolveApplicantAsync(
CancellationToken cancellationToken)
{
if (scope.Current.UserId == Guid.Empty)
return new(null, "无法识别当前登录账号。");
var user = await db.Users.AsNoTracking()
.FirstOrDefaultAsync(item =>
item.Id == scope.Current.UserId &&
item.IsEnabled,
cancellationToken);
if (user is null) return new(null, "当前账号不存在或已停用。");
var linkedCollegeIds = new List<Guid>();
if (user.CollegeId.HasValue) linkedCollegeIds.Add(user.CollegeId.Value);
var teacherCollegeId = await db.Teachers.AsNoTracking()
.Where(teacher => teacher.UserId == user.Id)
.Select(teacher => (Guid?)teacher.CollegeId)
.FirstOrDefaultAsync(cancellationToken);
if (teacherCollegeId.HasValue)
linkedCollegeIds.Add(teacherCollegeId.Value);
var studentCollegeId = await db.Students.AsNoTracking()
.Where(student => student.UserId == user.Id)
.Select(student =>
(Guid?)student.AdministrativeClass!.Major!.CollegeId)
.FirstOrDefaultAsync(cancellationToken);
if (studentCollegeId.HasValue)
linkedCollegeIds.Add(studentCollegeId.Value);
var distinctIds = linkedCollegeIds.Distinct().ToArray();
if (distinctIds.Length == 0)
return new(null, "当前账号未配置所在学院,请联系账号管理员补充学院信息。");
if (distinctIds.Length > 1)
return new(null, "账号与人员档案的学院信息不一致,请联系管理员修正后再申请。");
var college = await db.Colleges.AsNoTracking()
.Where(item => item.Id == distinctIds[0] && item.IsEnabled)
.Select(item => new { item.Id, item.Name })
.FirstOrDefaultAsync(cancellationToken);
return college is null
? new(null, "当前账号所属学院不存在或已停用。")
: new(
new ApplicantContext(user, college.Id, college.Name),
null);
}
private ActionResult? ReviewCollegeError()
{
if (!scope.Current.IsInRole(SystemRoles.CollegeAdmin))
return Forbid();
return scope.Current.CollegeId.HasValue
? null
: 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 DateOnly TodayInChina()
{
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
{
try
{
var zone = TimeZoneInfo.FindSystemTimeZoneById(id);
return DateOnly.FromDateTime(
TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone).DateTime);
}
catch (TimeZoneNotFoundException)
{
}
catch (InvalidTimeZoneException)
{
}
}
return DateOnly.FromDateTime(DateTime.Today);
}
private sealed record ApplicantContext(
ApplicationUser User,
Guid CollegeId,
string CollegeName);
private sealed record ApplicantResolution(
ApplicantContext? Context,
string? Error);
}
public sealed record CreateClassroomReservationRequest(
Guid AcademicTermId,
Guid ClassroomId,
DateOnly ReservationDate,
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
[Range(1, 10000)] int AttendeeCount,
[Required, StringLength(200)] string Purpose,
[Required, StringLength(30)] string ContactPhone,
[StringLength(500)] string? Notes);
public sealed record ReviewClassroomReservationRequest(
[StringLength(500)] string? Comment);
public sealed record ClassroomReservationTimeSlotDto(
int PeriodNumber,
string Name,
string StartTime,
string EndTime);
public sealed record ClassroomReservationDto(
Guid Id,
Guid ApplicantUserId,
string ApplicantName,
Guid ApplicantCollegeId,
string ApplicantCollegeName,
Guid AcademicTermId,
string AcademicTermName,
Guid ClassroomId,
string ClassroomCode,
string ClassroomName,
string BuildingName,
string CampusName,
DateOnly ReservationDate,
int StartPeriod,
int PeriodCount,
int AttendeeCount,
string Purpose,
string ContactPhone,
string? Notes,
ClassroomReservationStatus Status,
string? ReviewComment,
DateTime? ReviewedAt,
DateTime? CancelledAt,
DateTime CreatedAt);
@@ -209,7 +209,9 @@ public sealed class TimetableManagementController(
[ApiController] [ApiController]
[Route("api/timetables")] [Route("api/timetables")]
public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase public sealed class FreeClassroomsController(
AppDbContext db,
ClassroomReservationAvailabilityService reservationAvailability) : ControllerBase
{ {
[HttpGet("free-classrooms/options")] [HttpGet("free-classrooms/options")]
[Authorize(Roles = SystemRoles.Student)] [Authorize(Roles = SystemRoles.Student)]
@@ -297,9 +299,9 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var term = await db.AcademicTerms.AsNoTracking() var term = await db.AcademicTerms.AsNoTracking()
.Where(x => x.Id == academicTermId && x.IsEnabled) .FirstOrDefaultAsync(
.Select(x => new { x.Id, x.Name }) x => x.Id == academicTermId && x.IsEnabled,
.FirstOrDefaultAsync(cancellationToken); cancellationToken);
if (term is null) return NotFound(); if (term is null) return NotFound();
var plan = await db.SchedulePlans.AsNoTracking() var plan = await db.SchedulePlans.AsNoTracking()
.Where(x => .Where(x =>
@@ -329,33 +331,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
if (activePeriodCount != requestedPeriods.Length) if (activePeriodCount != requestedPeriods.Length)
return ValidationProblem("查询范围包含不存在或未启用的节次。"); return ValidationProblem("查询范围包含不存在或未启用的节次。");
var candidates = await db.ScheduleEntries.AsNoTracking() var reservationDate = ResolveReservationDate(term, week, dayOfWeek);
.Where(x => if (reservationDate < term.StartDate || reservationDate > term.EndDate)
x.SchedulePlanId == plan.Id && return ValidationProblem("所选周次和星期不在学期日期范围内。");
x.ClassroomId.HasValue && var occupiedIds =
x.DayOfWeek == dayOfWeek && await reservationAvailability.GetOccupiedClassroomIdsAsync(
x.StartWeek <= week && term,
x.EndWeek >= week && reservationDate,
x.StartPeriod < startPeriod + periodCount && startPeriod,
startPeriod < x.StartPeriod + x.PeriodCount) periodCount,
.Select(x => new null,
{ cancellationToken);
x.ClassroomId,
x.WeekPattern,
x.StartPeriod,
x.PeriodCount
})
.ToListAsync(cancellationToken);
var occupiedIds = candidates
.Where(x =>
FreeClassroomRules.MatchesWeek(x.WeekPattern, week) &&
FreeClassroomRules.PeriodsOverlap(
startPeriod,
periodCount,
x.StartPeriod,
x.PeriodCount))
.Select(x => x.ClassroomId.GetValueOrDefault())
.ToHashSet();
var rooms = db.Classrooms.AsNoTracking() var rooms = db.Classrooms.AsNoTracking()
.Where(x => .Where(x =>
@@ -397,6 +383,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
}); });
} }
private static DateOnly ResolveReservationDate(
AcademicTerm term,
int week,
int dayOfWeek)
{
var startDay = (int)term.StartDate.DayOfWeek;
var daysSinceMonday = (startDay + 6) % 7;
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
}
} }
public sealed record FreeClassroomTimeSlotDto( public sealed record FreeClassroomTimeSlotDto(
@@ -0,0 +1,39 @@
using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ClassroomReservation : EntityBase
{
public Guid ApplicantUserId { get; set; }
public ApplicationUser? ApplicantUser { get; set; }
public required string ApplicantName { get; set; }
public Guid ApplicantCollegeId { get; set; }
public College? ApplicantCollege { get; set; }
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly ReservationDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public int AttendeeCount { get; set; }
public required string Purpose { get; set; }
public required string ContactPhone { get; set; }
public string? Notes { get; set; }
public ClassroomReservationStatus Status { get; set; } =
ClassroomReservationStatus.Submitted;
public Guid? ReviewedByUserId { get; set; }
public ApplicationUser? ReviewedByUser { get; set; }
public DateTime? ReviewedAt { get; set; }
public string? ReviewComment { get; set; }
public DateTime? CancelledAt { get; set; }
}
public enum ClassroomReservationStatus
{
Submitted = 1,
Approved = 2,
Rejected = 3,
Cancelled = 4
}
@@ -41,6 +41,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<AutomaticScheduleJob>(); Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs => public DbSet<SchedulePublishJob> SchedulePublishJobs =>
Set<SchedulePublishJob>(); Set<SchedulePublishJob>();
public DbSet<ClassroomReservation> ClassroomReservations =>
Set<ClassroomReservation>();
public DbSet<CourseSelectionRound> CourseSelectionRounds => public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>(); Set<CourseSelectionRound>();
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades => public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
@@ -472,6 +474,49 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
}); });
builder.Entity<ClassroomReservation>(entity =>
{
entity.Property(x => x.ApplicantName).HasMaxLength(50);
entity.Property(x => x.Purpose).HasMaxLength(200);
entity.Property(x => x.ContactPhone).HasMaxLength(30);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.ApplicantUserId, x.Status, x.CreatedAt });
entity.HasIndex(x => new
{
x.ApplicantCollegeId,
x.Status,
x.ReservationDate
});
entity.HasIndex(x => new
{
x.ClassroomId,
x.ReservationDate,
x.Status,
x.StartPeriod
});
entity.HasOne(x => x.ApplicantUser)
.WithMany()
.HasForeignKey(x => x.ApplicantUserId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ApplicantCollege)
.WithMany()
.HasForeignKey(x => x.ApplicantCollegeId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ReviewedByUser)
.WithMany()
.HasForeignKey(x => x.ReviewedByUserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<CourseSelectionRound>(entity => builder.Entity<CourseSelectionRound>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -56,6 +56,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260726_30_official_documents"; "20260726_30_official_documents";
private const string UnifiedMessageCenterMigration = private const string UnifiedMessageCenterMigration =
"20260726_31_unified_message_center"; "20260726_31_unified_message_center";
private const string ClassroomReservationsMigration =
"20260726_32_classroom_reservations";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -406,6 +408,19 @@ public sealed class DevelopmentSqliteMigrator(
UnifiedMessageCenterMigration, UnifiedMessageCenterMigration,
messageDispatchesExist ? [] : UnifiedMessageCenterStatements, messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
cancellationToken); cancellationToken);
var classroomReservationsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ClassroomReservations'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ClassroomReservationsMigration,
classroomReservationsExist ? [] : ClassroomReservationStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -1917,4 +1932,76 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""", """CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");""" """CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
]; ];
private static readonly string[] ClassroomReservationStatements =
[
"""
CREATE TABLE "ClassroomReservations" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ClassroomReservations" PRIMARY KEY,
"ApplicantUserId" TEXT NOT NULL,
"ApplicantName" TEXT NOT NULL,
"ApplicantCollegeId" TEXT NOT NULL,
"AcademicTermId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
"ReservationDate" TEXT NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"AttendeeCount" INTEGER NOT NULL,
"Purpose" TEXT NOT NULL,
"ContactPhone" TEXT NOT NULL,
"Notes" TEXT NULL,
"Status" INTEGER NOT NULL,
"ReviewedByUserId" TEXT NULL,
"ReviewedAt" TEXT NULL,
"ReviewComment" TEXT NULL,
"CancelledAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ApplicantUserId"
FOREIGN KEY ("ApplicantUserId") REFERENCES "AspNetUsers" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ClassroomReservations_Colleges_ApplicantCollegeId"
FOREIGN KEY ("ApplicantCollegeId") REFERENCES "Colleges" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ClassroomReservations_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ClassroomReservations_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId"
FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id")
ON DELETE SET NULL
);
""",
"""
CREATE INDEX "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt"
ON "ClassroomReservations" ("ApplicantUserId", "Status", "CreatedAt");
""",
"""
CREATE INDEX "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationDate"
ON "ClassroomReservations" (
"ApplicantCollegeId",
"Status",
"ReservationDate"
);
""",
"""
CREATE INDEX "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_StartPeriod"
ON "ClassroomReservations" (
"ClassroomId",
"ReservationDate",
"Status",
"StartPeriod"
);
""",
"""
CREATE INDEX "IX_ClassroomReservations_AcademicTermId"
ON "ClassroomReservations" ("AcademicTermId");
""",
"""
CREATE INDEX "IX_ClassroomReservations_ReviewedByUserId"
ON "ClassroomReservations" ("ReviewedByUserId");
"""
];
} }
@@ -0,0 +1,108 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ClassroomReservations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ClassroomReservations",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
ApplicantName = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
ApplicantCollegeId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
ReservationDate = table.Column<DateTime>(type: "date", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
AttendeeCount = table.Column<int>(type: "int", nullable: false),
Purpose = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
ContactPhone = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClassroomReservations", x => x.Id);
table.ForeignKey(
name: "FK_ClassroomReservations_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClassroomReservations_AspNetUsers_ApplicantUserId",
column: x => x.ApplicantUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId",
column: x => x.ReviewedByUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ClassroomReservations_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClassroomReservations_Colleges_ApplicantCollegeId",
column: x => x.ApplicantCollegeId,
principalTable: "Colleges",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_AcademicTermId",
table: "ClassroomReservations",
column: "AcademicTermId");
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationD~",
table: "ClassroomReservations",
columns: new[] { "ApplicantCollegeId", "Status", "ReservationDate" });
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt",
table: "ClassroomReservations",
columns: new[] { "ApplicantUserId", "Status", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_Sta~",
table: "ClassroomReservations",
columns: new[] { "ClassroomId", "ReservationDate", "Status", "StartPeriod" });
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_ReviewedByUserId",
table: "ClassroomReservations",
column: "ReviewedByUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ClassroomReservations");
}
}
}
@@ -465,6 +465,92 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Classrooms"); b.ToTable("Classrooms");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<Guid>("ApplicantCollegeId")
.HasColumnType("char(36)");
b.Property<string>("ApplicantName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<Guid>("ApplicantUserId")
.HasColumnType("char(36)");
b.Property<int>("AttendeeCount")
.HasColumnType("int");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.Property<string>("ContactPhone")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("PeriodCount")
.HasColumnType("int");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<DateTime>("ReservationDate")
.HasColumnType("date");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ReviewedByUserId")
.HasColumnType("char(36)");
b.Property<int>("StartPeriod")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId");
b.HasIndex("ReviewedByUserId");
b.HasIndex("ApplicantCollegeId", "Status", "ReservationDate");
b.HasIndex("ApplicantUserId", "Status", "CreatedAt");
b.HasIndex("ClassroomId", "ReservationDate", "Status", "StartPeriod");
b.ToTable("ClassroomReservations");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -3522,6 +3608,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Building"); b.Navigation("Building");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.College", "ApplicantCollege")
.WithMany()
.HasForeignKey("ApplicantCollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ApplicantUser")
.WithMany()
.HasForeignKey("ApplicantUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ReviewedByUser")
.WithMany()
.HasForeignKey("ReviewedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("AcademicTerm");
b.Navigation("ApplicantCollege");
b.Navigation("ApplicantUser");
b.Navigation("Classroom");
b.Navigation("ReviewedByUser");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
@@ -0,0 +1,102 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Timetables;
public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
{
public async Task<HashSet<Guid>> GetOccupiedClassroomIdsAsync(
AcademicTerm term,
DateOnly reservationDate,
int startPeriod,
int periodCount,
Guid? excludedReservationId,
CancellationToken cancellationToken)
{
var occupiedIds = new HashSet<Guid>();
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(entry =>
entry.ClassroomId.HasValue &&
entry.SchedulePlan!.AcademicTermId == term.Id &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.DayOfWeek == dayOfWeek &&
entry.StartWeek <= week &&
entry.EndWeek >= week &&
entry.StartPeriod < startPeriod + periodCount &&
startPeriod < entry.StartPeriod + entry.PeriodCount)
.Select(entry => new
{
entry.ClassroomId,
entry.WeekPattern,
entry.StartPeriod,
entry.PeriodCount
})
.ToListAsync(cancellationToken);
foreach (var entry in scheduleEntries.Where(entry =>
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
FreeClassroomRules.PeriodsOverlap(
startPeriod,
periodCount,
entry.StartPeriod,
entry.PeriodCount)))
{
occupiedIds.Add(entry.ClassroomId!.Value);
}
var examRoomIds = await db.ExamSessions.AsNoTracking()
.Where(session =>
session.ClassroomId.HasValue &&
session.ExamPlan!.AcademicTermId == term.Id &&
session.ExamPlan.Status == ExamPlanStatus.Published &&
session.ExamDate == reservationDate &&
session.StartPeriod < startPeriod + periodCount &&
startPeriod < session.StartPeriod + session.PeriodCount)
.Select(session => session.ClassroomId!.Value)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(examRoomIds);
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
.Where(session =>
session.ClassroomId.HasValue &&
session.MakeupExamPlan!.AcademicTermId == term.Id &&
session.MakeupExamPlan.Status == MakeupExamPlanStatus.Published &&
session.ExamDate == reservationDate &&
session.StartPeriod < startPeriod + periodCount &&
startPeriod < session.StartPeriod + session.PeriodCount)
.Select(session => session.ClassroomId!.Value)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(makeupExamRoomIds);
var reservationQuery = db.ClassroomReservations.AsNoTracking()
.Where(reservation =>
reservation.AcademicTermId == term.Id &&
reservation.Status == ClassroomReservationStatus.Approved &&
reservation.ReservationDate == reservationDate &&
reservation.StartPeriod < startPeriod + periodCount &&
startPeriod <
reservation.StartPeriod + reservation.PeriodCount);
if (excludedReservationId.HasValue)
reservationQuery = reservationQuery.Where(reservation =>
reservation.Id != excludedReservationId.Value);
var reservationRoomIds = await reservationQuery
.Select(reservation => reservation.ClassroomId)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(reservationRoomIds);
return occupiedIds;
}
public static (int Week, int DayOfWeek) ResolveTeachingWeek(
AcademicTerm term,
DateOnly date)
{
var termStartDay = (int)term.StartDate.DayOfWeek;
var daysSinceMonday = (termStartDay + 6) % 7;
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
var week = (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
var dayOfWeek = ((int)date.DayOfWeek + 6) % 7 + 1;
return (week, dayOfWeek);
}
}
+1
View File
@@ -216,6 +216,7 @@ builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>(); builder.Services.AddScoped<TimetableDataService>();
builder.Services.AddScoped<AutomaticScheduleGenerator>(); builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<PersonalCalendarService>(); builder.Services.AddScoped<PersonalCalendarService>();
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>(); builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>(); builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
builder.Services.AddHostedService<AutomaticScheduleJobWorker>(); builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
@@ -0,0 +1,389 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ClassroomReservationsControllerTests
{
[Fact]
public async Task Create_UsesApplicantsCollegeAndRejectsPublishedClassConflict()
{
await using var fixture = await ReservationFixture.CreateAsync();
var controller = fixture.Controller(fixture.ApplicantScope);
var conflictedRequest = fixture.Request(
fixture.ScheduledClassroom.Id,
fixture.ReservationDate,
1,
2);
var conflicted = await controller.Create(
conflictedRequest,
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(conflicted);
Assert.Empty(fixture.Db.ClassroomReservations);
var created = await controller.Create(
fixture.Request(
fixture.AvailableClassroom.Id,
fixture.ReservationDate,
1,
2),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var reservation = Assert.Single(fixture.Db.ClassroomReservations);
Assert.Equal(fixture.Applicant.Id, reservation.ApplicantUserId);
Assert.Equal(fixture.CollegeA.Id, reservation.ApplicantCollegeId);
Assert.Equal(ClassroomReservationStatus.Submitted, reservation.Status);
}
[Fact]
public async Task Review_OnlyAllowsApplicantCollegeAndApprovesAvailableRoom()
{
await using var fixture = await ReservationFixture.CreateAsync();
var reservation = fixture.AddSubmittedReservation(
fixture.AvailableClassroom.Id,
fixture.ReservationDate,
3,
2);
await fixture.Db.SaveChangesAsync();
var wrongCollege = await fixture.Controller(fixture.CollegeBReviewerScope)
.Approve(
reservation.Id,
new ReviewClassroomReservationRequest(""),
CancellationToken.None);
Assert.IsType<ForbidResult>(wrongCollege);
Assert.Equal(
ClassroomReservationStatus.Submitted,
reservation.Status);
var approved = await fixture.Controller(fixture.CollegeAReviewerScope)
.Approve(
reservation.Id,
new ReviewClassroomReservationRequest("同意"),
CancellationToken.None);
Assert.IsType<NoContentResult>(approved);
var stored = await fixture.Db.ClassroomReservations
.AsNoTracking()
.SingleAsync(item => item.Id == reservation.Id);
Assert.Equal(ClassroomReservationStatus.Approved, stored.Status);
Assert.Equal(fixture.CollegeAReviewer.Id, stored.ReviewedByUserId);
}
[Fact]
public async Task Approve_RechecksApprovedReservationConflict()
{
await using var fixture = await ReservationFixture.CreateAsync();
fixture.Db.ClassroomReservations.Add(new ClassroomReservation
{
ApplicantUserId = fixture.Applicant.Id,
ApplicantName = fixture.Applicant.DisplayName,
ApplicantCollegeId = fixture.CollegeA.Id,
AcademicTermId = fixture.Term.Id,
ClassroomId = fixture.AvailableClassroom.Id,
ReservationDate = fixture.ReservationDate,
StartPeriod = 5,
PeriodCount = 2,
AttendeeCount = 20,
Purpose = "已批准活动",
ContactPhone = "13800000000",
Status = ClassroomReservationStatus.Approved
});
var competing = fixture.AddSubmittedReservation(
fixture.AvailableClassroom.Id,
fixture.ReservationDate,
6,
2);
await fixture.Db.SaveChangesAsync();
var result = await fixture.Controller(fixture.CollegeAReviewerScope)
.Approve(
competing.Id,
new ReviewClassroomReservationRequest(null),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
var stored = await fixture.Db.ClassroomReservations
.AsNoTracking()
.SingleAsync(item => item.Id == competing.Id);
Assert.Equal(ClassroomReservationStatus.Submitted, stored.Status);
}
private sealed class ReservationFixture : IAsyncDisposable
{
private ReservationFixture(
SqliteConnection connection,
AppDbContext db,
College collegeA,
ApplicationUser applicant,
ApplicationUser collegeAReviewer,
AcademicTerm term,
Classroom scheduledClassroom,
Classroom availableClassroom,
DateOnly reservationDate,
ICurrentUserDataScope applicantScope,
ICurrentUserDataScope collegeAReviewerScope,
ICurrentUserDataScope collegeBReviewerScope)
{
Connection = connection;
Db = db;
CollegeA = collegeA;
Applicant = applicant;
CollegeAReviewer = collegeAReviewer;
Term = term;
ScheduledClassroom = scheduledClassroom;
AvailableClassroom = availableClassroom;
ReservationDate = reservationDate;
ApplicantScope = applicantScope;
CollegeAReviewerScope = collegeAReviewerScope;
CollegeBReviewerScope = collegeBReviewerScope;
}
private SqliteConnection Connection { get; }
public AppDbContext Db { get; }
public College CollegeA { get; }
public ApplicationUser Applicant { get; }
public ApplicationUser CollegeAReviewer { get; }
public AcademicTerm Term { get; }
public Classroom ScheduledClassroom { get; }
public Classroom AvailableClassroom { get; }
public DateOnly ReservationDate { get; }
public ICurrentUserDataScope ApplicantScope { get; }
public ICurrentUserDataScope CollegeAReviewerScope { get; }
public ICurrentUserDataScope CollegeBReviewerScope { get; }
public static async Task<ReservationFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var building = new Building
{
Code = "A",
Name = "明德楼",
CampusId = campus.Id
};
var scheduledClassroom = new Classroom
{
Code = "A101",
Name = "A101",
BuildingId = building.Id,
Capacity = 60
};
var availableClassroom = new Classroom
{
Code = "A102",
Name = "A102",
BuildingId = building.Id,
Capacity = 80
};
var collegeA = new College
{
Code = "CS",
Name = "计算机学院"
};
var collegeB = new College
{
Code = "EE",
Name = "电子工程学院"
};
var applicant = User("student", "测试申请人", collegeA.Id);
var collegeAReviewer = User(
"college-a",
"计算机学院审核人",
collegeA.Id);
var collegeBReviewer = User(
"college-b",
"电子工程学院审核人",
collegeB.Id);
var term = new AcademicTerm
{
Code = "2099-1",
Name = "2099—2100 学年第一学期",
AcademicYear = "2099-2100",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2099, 9, 1),
EndDate = new DateOnly(2100, 1, 31),
IsCurrent = true
};
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = collegeA.Id,
Credits = 3,
TotalHours = 48,
LectureHours = 32,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var teachingTask = new TeachingTask
{
TaskNumber = "2099-1-CS101-01",
Name = "程序设计基础教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published
};
var reservationDate = term.StartDate;
var (_, dayOfWeek) =
ClassroomReservationAvailabilityService.ResolveTeachingWeek(
term,
reservationDate);
var plan = new SchedulePlan
{
AcademicTermId = term.Id,
Name = "正式课表",
Version = "v1",
Status = SchedulePlanStatus.Published,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = teachingTask.Id,
ClassroomId = scheduledClassroom.Id,
DayOfWeek = dayOfWeek,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 18,
WeekPattern = WeekPattern.All
}
]
};
db.AddRange(
campus,
building,
scheduledClassroom,
availableClassroom,
collegeA,
collegeB,
applicant,
collegeAReviewer,
collegeBReviewer,
term,
course,
teachingTask,
plan);
await db.SaveChangesAsync();
return new ReservationFixture(
connection,
db,
collegeA,
applicant,
collegeAReviewer,
term,
scheduledClassroom,
availableClassroom,
reservationDate,
Scope(applicant, SystemRoles.Student),
Scope(collegeAReviewer, SystemRoles.CollegeAdmin),
Scope(collegeBReviewer, SystemRoles.CollegeAdmin));
}
public ClassroomReservationsController Controller(
ICurrentUserDataScope currentScope) =>
new(
Db,
currentScope,
new ClassroomReservationAvailabilityService(Db));
public CreateClassroomReservationRequest Request(
Guid classroomId,
DateOnly date,
int startPeriod,
int periodCount) =>
new(
Term.Id,
classroomId,
date,
startPeriod,
periodCount,
30,
"学院学术活动",
"13800000000",
null);
public ClassroomReservation AddSubmittedReservation(
Guid classroomId,
DateOnly date,
int startPeriod,
int periodCount)
{
var reservation = new ClassroomReservation
{
ApplicantUserId = Applicant.Id,
ApplicantName = Applicant.DisplayName,
ApplicantCollegeId = CollegeA.Id,
AcademicTermId = Term.Id,
ClassroomId = classroomId,
ReservationDate = date,
StartPeriod = startPeriod,
PeriodCount = periodCount,
AttendeeCount = 20,
Purpose = "学院交流活动",
ContactPhone = "13800000000"
};
Db.ClassroomReservations.Add(reservation);
return reservation;
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await Connection.DisposeAsync();
}
private static ApplicationUser User(
string userName,
string displayName,
Guid collegeId) =>
new()
{
Id = Guid.NewGuid(),
UserName = userName,
NormalizedUserName = userName.ToUpperInvariant(),
DisplayName = displayName,
CollegeId = collegeId,
IsEnabled = true
};
private static ICurrentUserDataScope Scope(
ApplicationUser user,
string role) =>
new FixedScope(new CurrentUserScope(
user.Id,
user.DisplayName,
user.CollegeId,
role == SystemRoles.CollegeAdmin
? DataScope.College
: DataScope.Self,
new HashSet<string>([role])));
}
private sealed class FixedScope(CurrentUserScope current)
: ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = current;
}
}
+4
View File
@@ -130,6 +130,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
), ),
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }), ...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }), ...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
{
path: '/classroom-reservations',
label: hasAnyRole(['CollegeAdmin']) ? '教室借用审核' : '教室借用',
},
...whenVisible(isTeacher.value || isTeachingAdmin.value || hasAnyRole(['Counselor']), { path: '/teacher-attendance', label: '教学点名' }), ...whenVisible(isTeacher.value || isTeachingAdmin.value || hasAnyRole(['Counselor']), { path: '/teacher-attendance', label: '教学点名' }),
...whenVisible(isStudent.value, { path: '/my-attendance', label: '我的考勤' }), ...whenVisible(isStudent.value, { path: '/my-attendance', label: '我的考勤' }),
{ {
+5
View File
@@ -179,6 +179,11 @@ const router = createRouter({
component: () => import('../views/FreeClassroomsView.vue'), component: () => import('../views/FreeClassroomsView.vue'),
meta: { roles: ['Student'] }, meta: { roles: ['Student'] },
}, },
{
path: 'classroom-reservations',
name: 'classroom-reservations',
component: () => import('../views/ClassroomReservationsView.vue'),
},
{ {
path: 'course-selections', path: 'course-selections',
name: 'course-selections', name: 'course-selections',
File diff suppressed because it is too large Load Diff