Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/ExperimentsController.cs
T
2026-07-29 09:40:11 +08:00

1046 lines
42 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.Data;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Experiments;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/experiments")]
public sealed class ExperimentsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
ClassroomReservationAvailabilityService classroomAvailability) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher;
[HttpGet("options")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetOptions(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var tasks = AccessibleTeachingTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
var periods = db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.IsEnabled);
if (academicTermId.HasValue)
periods = periods.Where(x => x.AcademicTermId == academicTermId);
var periodItems = await periods
.OrderBy(x => x.PeriodNumber)
.Select(x => new ExperimentPeriodOption(
x.AcademicTermId,
x.PeriodNumber,
x.Name,
x.StartsAt.ToString("HH:mm"),
x.EndsAt.ToString("HH:mm")))
.ToListAsync(cancellationToken);
if (academicTermId.HasValue && periodItems.Count == 0)
{
periodItems = Enumerable.Range(1, 12)
.Select(period => new ExperimentPeriodOption(
academicTermId.Value,
period,
$"第 {period} 节",
"",
""))
.ToList();
}
return Ok(new
{
Tasks = await tasks
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
TermStartDate = x.AcademicTerm.StartDate,
TermEndDate = x.AcademicTerm.EndDate,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
CollegeName = x.Course.College!.Name,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.ThenBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name),
ClassNames = x.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name)
})
.ToListAsync(cancellationToken),
Classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Building!.SortOrder)
.ThenBy(x => x.SortOrder)
.Select(x => new
{
x.Id,
x.Name,
BuildingName = x.Building!.Name,
CampusName = x.Building.Campus!.Name,
x.Capacity
})
.ToListAsync(cancellationToken),
Periods = periodItems
});
}
[HttpGet("management")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetManagementProjects(
Guid? academicTermId,
ExperimentArrangementMode? arrangementMode,
ExperimentProjectStatus? status,
CancellationToken cancellationToken)
{
var source = ScopedProjects().AsNoTracking();
if (academicTermId.HasValue)
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
if (arrangementMode.HasValue)
source = source.Where(x =>
x.ArrangementMode == arrangementMode);
if (status.HasValue)
source = source.Where(x => x.Status == status);
return Ok(await source
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
.ThenBy(x => x.StartDate)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.Code,
x.Name,
x.ArrangementMode,
x.Description,
x.Requirements,
x.StartDate,
x.EndDate,
x.Status,
x.PublishedAt,
x.ClosedAt,
AcademicTermId = x.TeachingTask!.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
TaskNumber = x.TeachingTask.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name),
Sessions = x.Sessions
.OrderBy(item => item.SessionDate)
.ThenBy(item => item.StartPeriod)
.Select(item => new
{
item.Id,
item.SessionDate,
item.StartPeriod,
item.PeriodCount,
item.Capacity,
item.ReservedCount,
item.Notes,
item.Status,
item.ClassroomId,
ClassroomName = item.Classroom!.Name,
BuildingName = item.Classroom.Building!.Name,
CampusName = item.Classroom.Building.Campus!.Name
})
})
.ToListAsync(cancellationToken));
}
[HttpGet("student")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentProjects(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
var taskIds = TeachingTaskRosterQuery.TaskIdsForStudent(db, student.Id);
var source = db.ExperimentProjects.AsNoTracking()
.Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
(x.Status == ExperimentProjectStatus.Published ||
x.Status == ExperimentProjectStatus.Closed));
if (academicTermId.HasValue)
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
return Ok(await source
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
.ThenBy(x => x.EndDate)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.ArrangementMode,
x.Description,
x.Requirements,
x.StartDate,
x.EndDate,
x.Status,
AcademicTermId = x.TeachingTask!.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
TaskNumber = x.TeachingTask.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
Sessions = x.Sessions
.Where(item => item.Status == ExperimentSessionStatus.Scheduled)
.OrderBy(item => item.SessionDate)
.ThenBy(item => item.StartPeriod)
.Select(item => new
{
item.Id,
item.SessionDate,
item.StartPeriod,
item.PeriodCount,
item.Capacity,
item.ReservedCount,
RemainingCount = item.Capacity - item.ReservedCount,
item.Notes,
ClassroomName = item.Classroom!.Name,
BuildingName = item.Classroom.Building!.Name,
CampusName = item.Classroom.Building.Campus!.Name
}),
MyBooking = x.Bookings
.Where(item =>
item.StudentId == student.Id &&
item.Status == ExperimentBookingStatus.Booked)
.Select(item => new
{
item.Id,
item.ExperimentSessionId,
item.BookedAt
})
.FirstOrDefault()
})
.ToListAsync(cancellationToken));
}
[HttpPost]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateProject(
ExperimentProjectRequest request,
CancellationToken cancellationToken)
{
var task = await AccessibleTeachingTasks().AsNoTracking()
.Include(x => x.AcademicTerm)
.FirstOrDefaultAsync(x =>
x.Id == request.TeachingTaskId &&
x.Status == TeachingTaskStatus.Published,
cancellationToken);
if (task is null)
return ValidationProblem("教学任务不存在、未发布或不在当前管理范围内。");
var problem = ValidateProjectRequest(request, task.AcademicTerm!);
if (problem is not null) return ValidationProblem(problem);
var code = request.Code.Trim();
if (await db.ExperimentProjects.AnyAsync(x =>
x.TeachingTaskId == request.TeachingTaskId &&
x.Code == code,
cancellationToken))
return ConflictProblem("该教学任务下已存在相同实验项目编码。");
var project = new ExperimentProject
{
TeachingTaskId = request.TeachingTaskId,
Code = code,
Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements),
StartDate = request.StartDate,
EndDate = request.EndDate
};
db.ExperimentProjects.Add(project);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { project.Id });
}
[HttpPut("{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateProject(
Guid id,
ExperimentProjectRequest request,
CancellationToken cancellationToken)
{
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (project is null) return NotFound();
if (project.Status != ExperimentProjectStatus.Draft)
return ConflictProblem("只有草稿实验项目可以修改。");
if (project.TeachingTaskId != request.TeachingTaskId)
return ValidationProblem("创建后不能更换实验项目所属教学任务。");
var problem = ValidateProjectRequest(
request,
project.TeachingTask!.AcademicTerm!);
if (problem is not null) return ValidationProblem(problem);
var code = request.Code.Trim();
if (await db.ExperimentProjects.AnyAsync(x =>
x.Id != id &&
x.TeachingTaskId == request.TeachingTaskId &&
x.Code == code,
cancellationToken))
return ConflictProblem("该教学任务下已存在相同实验项目编码。");
project.Code = code;
project.Name = request.Name.Trim();
project.ArrangementMode = request.ArrangementMode;
project.Description = Normalize(request.Description);
project.Requirements = Normalize(request.Requirements);
project.StartDate = request.StartDate;
project.EndDate = request.EndDate;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DeleteProject(
Guid id,
CancellationToken cancellationToken)
{
var project = await ScopedProjects()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (project is null) return NotFound();
if (project.Status != ExperimentProjectStatus.Draft)
return ConflictProblem("只有草稿实验项目可以删除。");
db.ExperimentProjects.Remove(project);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> PublishProject(
Guid id,
CancellationToken cancellationToken)
{
var project = await ScopedProjects()
.Include(x => x.Sessions)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (project is null) return NotFound();
if (project.Status != ExperimentProjectStatus.Draft)
return ConflictProblem("只有草稿实验项目可以发布。");
if (!project.Sessions.Any(x =>
x.Status == ExperimentSessionStatus.Scheduled))
return ConflictProblem("请至少安排一个有效实验场次后再发布。");
if (project.Sessions.Any(x =>
x.Status == ExperimentSessionStatus.Scheduled &&
(x.SessionDate < project.StartDate ||
x.SessionDate > project.EndDate)))
return ConflictProblem("存在不在项目开放日期范围内的实验场次。");
project.Status = ExperimentProjectStatus.Published;
project.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var userIds = await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.Where(x => x.UserId.HasValue)
.Select(x => x.UserId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
if (userIds.Count > 0)
{
var mode = project.ArrangementMode ==
ExperimentArrangementMode.Centralized
? "集中安排"
: "自行预约";
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
return NoContent();
}
[HttpPost("{id:guid}/close")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CloseProject(
Guid id,
CancellationToken cancellationToken)
{
var project = await ScopedProjects()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (project is null) return NotFound();
if (project.Status != ExperimentProjectStatus.Published)
return ConflictProblem("只有已发布实验项目可以关闭。");
project.Status = ExperimentProjectStatus.Closed;
project.ClosedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("{projectId:guid}/sessions")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSession(
Guid projectId,
ExperimentSessionRequest request,
CancellationToken cancellationToken)
{
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.FirstOrDefaultAsync(x => x.Id == projectId, cancellationToken);
if (project is null) return NotFound();
if (project.Status == ExperimentProjectStatus.Closed)
return ConflictProblem("已关闭实验项目不能再增加场次。");
var problem = await ValidateSessionAsync(
project,
request,
cancellationToken);
if (problem is not null) return ConflictProblem(problem);
var session = new ExperimentSession
{
ExperimentProjectId = project.Id,
ClassroomId = request.ClassroomId,
SessionDate = request.SessionDate,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
Capacity = await ResolveSessionCapacityAsync(
project,
request.Capacity,
cancellationToken),
Notes = Normalize(request.Notes)
};
db.ExperimentSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
if (project.Status == ExperimentProjectStatus.Published)
{
var userIds = await RosterUserIdsAsync(
project.TeachingTaskId,
cancellationToken);
if (userIds.Count > 0)
{
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"新增实验场次",
$"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
}
return Created(string.Empty, new { session.Id });
}
[HttpDelete("sessions/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CancelSession(
Guid id,
CancellationToken cancellationToken)
{
var session = await db.ExperimentSessions
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null ||
!await ScopedProjects().AnyAsync(
x => x.Id == session.ExperimentProjectId,
cancellationToken))
return NotFound();
if (session.Status == ExperimentSessionStatus.Cancelled)
return NoContent();
if (session.ExperimentProject!.Status == ExperimentProjectStatus.Draft)
{
db.ExperimentSessions.Remove(session);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
var affectedUserIds =
session.ExperimentProject.ArrangementMode ==
ExperimentArrangementMode.Centralized
? await RosterUserIdsAsync(
session.ExperimentProject.TeachingTaskId,
cancellationToken)
: await db.ExperimentBookings
.Where(x =>
x.ExperimentSessionId == id &&
x.Status == ExperimentBookingStatus.Booked &&
x.Student!.UserId.HasValue)
.Select(x => x.Student!.UserId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
var bookings = await db.ExperimentBookings
.Where(x =>
x.ExperimentSessionId == id &&
x.Status == ExperimentBookingStatus.Booked)
.ToListAsync(cancellationToken);
foreach (var booking in bookings)
{
booking.Status = ExperimentBookingStatus.Cancelled;
booking.CancelledAt = DateTime.UtcNow;
}
session.Status = ExperimentSessionStatus.Cancelled;
session.CancelledAt = DateTime.UtcNow;
session.ReservedCount = 0;
await db.SaveChangesAsync(cancellationToken);
if (affectedUserIds.Count > 0)
{
await NotificationService.SendToUserIdsAsync(
db,
affectedUserIds,
"实验场次已取消",
$"“{session.ExperimentProject.Name}”原定于 {session.SessionDate:yyyy-MM-dd} 的实验场次已取消,请重新查看安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
return NoContent();
}
[HttpGet("sessions/{id:guid}/participants")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetParticipants(
Guid id,
CancellationToken cancellationToken)
{
var session = await db.ExperimentSessions.AsNoTracking()
.Include(x => x.ExperimentProject)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null ||
!await ScopedProjects().AnyAsync(
x => x.Id == session.ExperimentProjectId,
cancellationToken))
return NotFound();
if (session.ExperimentProject!.ArrangementMode ==
ExperimentArrangementMode.Centralized)
{
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
[session.ExperimentProject.TeachingTaskId],
cancellationToken);
return Ok(roster.Select(x => new
{
x.StudentId,
x.StudentNumber,
x.Name,
x.ClassName,
ParticipationType = "Centralized"
}));
}
return Ok(await db.ExperimentBookings.AsNoTracking()
.Where(x =>
x.ExperimentSessionId == id &&
x.Status == ExperimentBookingStatus.Booked)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
ParticipationType = "Booked",
x.BookedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("sessions/{id:guid}/book")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Book(
Guid id,
CancellationToken cancellationToken)
{
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var student = await CurrentStudentAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
var session = await db.ExperimentSessions
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null ||
session.Status != ExperimentSessionStatus.Scheduled ||
session.ExperimentProject!.Status !=
ExperimentProjectStatus.Published)
return NotFound();
if (session.ExperimentProject.ArrangementMode !=
ExperimentArrangementMode.SelfScheduled)
return ConflictProblem("集中安排实验无需学生预约。");
if (!await TeachingTaskRosterQuery
.TaskIdsForStudent(db, student.Id)
.ContainsAsync(
session.ExperimentProject.TeachingTaskId,
cancellationToken))
return Forbid();
if (session.SessionDate <
DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)))
return ConflictProblem("该实验场次已经结束,不能预约。");
var existing = await db.ExperimentBookings
.FirstOrDefaultAsync(x =>
x.ExperimentProjectId ==
session.ExperimentProjectId &&
x.StudentId == student.Id,
cancellationToken);
if (existing?.Status == ExperimentBookingStatus.Booked)
{
return existing.ExperimentSessionId == session.Id
? NoContent()
: ConflictProblem("该实验项目已有预约,请先取消原预约。");
}
var scheduleProblem = await StudentConflictAsync(
student.Id,
session,
cancellationToken);
if (scheduleProblem is not null)
return ConflictProblem(scheduleProblem);
var reservedCount = await db.ExperimentBookings
.CountAsync(x =>
x.ExperimentSessionId == session.Id &&
x.Status == ExperimentBookingStatus.Booked,
cancellationToken);
if (reservedCount >= session.Capacity)
return ConflictProblem("该实验场次名额已满,请选择其他时间。");
if (existing is null)
{
db.ExperimentBookings.Add(new ExperimentBooking
{
ExperimentProjectId = session.ExperimentProjectId,
ExperimentSessionId = session.Id,
StudentId = student.Id
});
}
else
{
existing.ExperimentSessionId = session.Id;
existing.Status = ExperimentBookingStatus.Booked;
existing.BookedAt = DateTime.UtcNow;
existing.CancelledAt = null;
}
session.ReservedCount = reservedCount + 1;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken,
IsolationLevel.Serializable);
}
[HttpDelete("bookings/{id:guid}")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> CancelBooking(
Guid id,
CancellationToken cancellationToken)
{
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var student = await CurrentStudentAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
var booking = await db.ExperimentBookings
.Include(x => x.ExperimentSession)
.FirstOrDefaultAsync(x =>
x.Id == id &&
x.StudentId == student.Id,
cancellationToken);
if (booking is null) return NotFound();
if (booking.Status == ExperimentBookingStatus.Cancelled)
return NoContent();
booking.Status = ExperimentBookingStatus.Cancelled;
booking.CancelledAt = DateTime.UtcNow;
booking.ExperimentSession!.ReservedCount = Math.Max(
0,
await db.ExperimentBookings.CountAsync(x =>
x.ExperimentSessionId ==
booking.ExperimentSessionId &&
x.Status == ExperimentBookingStatus.Booked &&
x.Id != booking.Id,
cancellationToken));
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken,
IsolationLevel.Serializable);
}
private async Task<string?> ValidateSessionAsync(
ExperimentProject project,
ExperimentSessionRequest request,
CancellationToken cancellationToken)
{
if (request.SessionDate < project.StartDate ||
request.SessionDate > project.EndDate)
return "实验场次日期必须在项目开放日期范围内。";
var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
.CountAsync(x =>
x.AcademicTermId ==
project.TeachingTask!.AcademicTermId &&
x.IsEnabled,
cancellationToken);
var validPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
.CountAsync(x =>
x.AcademicTermId ==
project.TeachingTask!.AcademicTermId &&
x.IsEnabled &&
x.PeriodNumber >= request.StartPeriod &&
x.PeriodNumber <
request.StartPeriod + request.PeriodCount,
cancellationToken);
var usesValidDefaultPeriods =
configuredPeriodCount == 0 &&
request.StartPeriod + request.PeriodCount - 1 <= 12;
if (!usesValidDefaultPeriods &&
validPeriodCount != request.PeriodCount)
return "所选实验节次不在该学期启用节次范围内。";
var classroom = await db.Classrooms.AsNoTracking()
.FirstOrDefaultAsync(x =>
x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return "实验教室不存在或已停用。";
if (request.Capacity > classroom.Capacity)
return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
if (project.ArrangementMode ==
ExperimentArrangementMode.Centralized)
{
var rosterCount = await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.CountAsync(cancellationToken);
if (rosterCount > classroom.Capacity)
return $"集中实验应到 {rosterCount} 人,超过教室容量 {classroom.Capacity} 人。";
}
var occupiedRooms =
await classroomAvailability.GetOccupiedClassroomIdsAsync(
project.TeachingTask!.AcademicTerm!,
request.SessionDate,
request.StartPeriod,
request.PeriodCount,
null,
cancellationToken);
if (occupiedRooms.Contains(request.ClassroomId))
return "所选教室与已发布课程、考试或已批准借用安排冲突。";
var experimentRoomConflict = await db.ExperimentSessions.AsNoTracking()
.AnyAsync(x =>
x.ClassroomId == request.ClassroomId &&
x.SessionDate == request.SessionDate &&
x.Status == ExperimentSessionStatus.Scheduled &&
x.StartPeriod <
request.StartPeriod + request.PeriodCount &&
request.StartPeriod < x.StartPeriod + x.PeriodCount,
cancellationToken);
if (experimentRoomConflict)
return "所选教室与其他实验场次冲突。";
if (project.ArrangementMode ==
ExperimentArrangementMode.Centralized)
{
return await CentralizedTaskConflictAsync(
project.TeachingTaskId,
project.TeachingTask.AcademicTerm!,
request.SessionDate,
request.StartPeriod,
request.PeriodCount,
cancellationToken);
}
return null;
}
private async Task<string?> CentralizedTaskConflictAsync(
Guid teachingTaskId,
AcademicTerm term,
DateOnly date,
int startPeriod,
int periodCount,
CancellationToken cancellationToken)
{
var teacherIds = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
var classIds = await db.TeachingTaskClasses.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId)
.Select(x => x.AdministrativeClassId)
.ToListAsync(cancellationToken);
var (week, dayOfWeek) =
ClassroomReservationAvailabilityService.ResolveTeachingWeek(
term,
date);
var relatedTaskIds = (await CentralizedExperimentConflictQuery
.TaskIdsForTeachers(db, teacherIds)
.ToListAsync(cancellationToken))
.Concat(await CentralizedExperimentConflictQuery
.TaskIdsForClasses(db, classIds)
.ToListAsync(cancellationToken))
.Distinct()
.ToArray();
var scheduleConflicts = await CentralizedExperimentConflictQuery
.ScheduleEntries(
db,
relatedTaskIds,
term.Id,
dayOfWeek,
week,
startPeriod,
periodCount)
.Select(x => new { x.TeachingTaskId, x.WeekPattern })
.ToListAsync(cancellationToken);
if (scheduleConflicts.Any(x =>
FreeClassroomRules.MatchesWeek(x.WeekPattern, week)))
return "集中实验与相关教师或行政班的已发布课表冲突。";
var experimentConflict = await CentralizedExperimentConflictQuery
.ExperimentSessions(
db,
relatedTaskIds,
date,
startPeriod,
periodCount)
.AnyAsync(cancellationToken);
return experimentConflict
? "集中实验与相关教师或行政班的其他实验安排冲突。"
: null;
}
private async Task<string?> StudentConflictAsync(
Guid studentId,
ExperimentSession target,
CancellationToken cancellationToken)
{
var project = target.ExperimentProject!;
var term = project.TeachingTask!.AcademicTerm!;
var taskIds = await TeachingTaskRosterQuery
.TaskIdsForStudent(db, studentId)
.ToListAsync(cancellationToken);
var (week, dayOfWeek) =
ClassroomReservationAvailabilityService.ResolveTeachingWeek(
term,
target.SessionDate);
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.SchedulePlan.AcademicTermId == term.Id &&
x.DayOfWeek == dayOfWeek &&
x.StartWeek <= week &&
x.EndWeek >= week &&
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
target.StartPeriod < x.StartPeriod + x.PeriodCount)
.Select(x => x.WeekPattern)
.ToListAsync(cancellationToken);
if (scheduleEntries.Any(pattern =>
FreeClassroomRules.MatchesWeek(pattern, week)))
return "该时间与您的已发布课表冲突。";
var bookingConflict = await db.ExperimentBookings.AsNoTracking()
.AnyAsync(x =>
x.StudentId == studentId &&
x.Status == ExperimentBookingStatus.Booked &&
x.ExperimentSessionId != target.Id &&
x.ExperimentSession!.Status ==
ExperimentSessionStatus.Scheduled &&
x.ExperimentSession.SessionDate == target.SessionDate &&
x.ExperimentSession.StartPeriod <
target.StartPeriod + target.PeriodCount &&
target.StartPeriod <
x.ExperimentSession.StartPeriod +
x.ExperimentSession.PeriodCount,
cancellationToken);
if (bookingConflict) return "该时间与您已预约的其他实验冲突。";
var centralizedConflict = await db.ExperimentSessions.AsNoTracking()
.AnyAsync(x =>
x.Id != target.Id &&
x.Status == ExperimentSessionStatus.Scheduled &&
x.SessionDate == target.SessionDate &&
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
target.StartPeriod < x.StartPeriod + x.PeriodCount &&
x.ExperimentProject!.Status ==
ExperimentProjectStatus.Published &&
x.ExperimentProject.ArrangementMode ==
ExperimentArrangementMode.Centralized &&
taskIds.Contains(x.ExperimentProject.TeachingTaskId),
cancellationToken);
return centralizedConflict
? "该时间与您的集中实验安排冲突。"
: null;
}
private IQueryable<TeachingTask> AccessibleTeachingTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private IQueryable<ExperimentProject> ScopedProjects()
{
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
return db.ExperimentProjects.Where(x =>
taskIds.Contains(x.TeachingTaskId));
}
private Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken) =>
db.Students.FirstOrDefaultAsync(x =>
x.UserId == currentUserDataScope.Current.UserId &&
x.Status == StudentStatus.Active,
cancellationToken);
private async Task<int> ResolveSessionCapacityAsync(
ExperimentProject project,
int requestedCapacity,
CancellationToken cancellationToken)
{
if (project.ArrangementMode !=
ExperimentArrangementMode.Centralized)
return requestedCapacity;
var rosterCount = await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.CountAsync(cancellationToken);
return rosterCount > 0 ? rosterCount : requestedCapacity;
}
private Task<List<Guid>> RosterUserIdsAsync(
Guid teachingTaskId,
CancellationToken cancellationToken) =>
TeachingTaskRosterQuery.ForTask(db, teachingTaskId)
.Where(x => x.UserId.HasValue)
.Select(x => x.UserId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
private static string? ValidateProjectRequest(
ExperimentProjectRequest request,
AcademicTerm term)
{
if (string.IsNullOrWhiteSpace(request.Code))
return "请填写实验项目编码。";
if (string.IsNullOrWhiteSpace(request.Name))
return "请填写实验项目名称。";
if (!Enum.IsDefined(request.ArrangementMode))
return "实验安排方式无效。";
if (request.StartDate > request.EndDate)
return "项目开始日期不能晚于结束日期。";
if (request.StartDate < term.StartDate ||
request.EndDate > term.EndDate)
return "实验项目日期必须在所属学期起止日期内。";
return null;
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record ExperimentProjectRequest(
Guid TeachingTaskId,
[Required, MaxLength(40)] string Code,
[Required, MaxLength(120)] string Name,
ExperimentArrangementMode ArrangementMode,
[MaxLength(1000)] string? Description,
[MaxLength(1000)] string? Requirements,
DateOnly StartDate,
DateOnly EndDate);
public sealed record ExperimentSessionRequest(
Guid ClassroomId,
DateOnly SessionDate,
[Range(1, 30)] int StartPeriod,
[Range(1, 30)] int PeriodCount,
[Range(1, 10000)] int Capacity,
[MaxLength(500)] string? Notes);
public sealed record ExperimentPeriodOption(
Guid AcademicTermId,
int PeriodNumber,
string Name,
string StartsAt,
string EndsAt);