Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/ClassroomReservationsController.cs
T
2026-07-26 20:11:56 +08:00

709 lines
28 KiB
C#

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);