Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/ExperimentsController.cs
T

1578 lines
67 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
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,
Guid? offeringCollegeId,
string? courseKeyword,
CancellationToken cancellationToken)
{
var tasks = AccessibleTeachingTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
if (offeringCollegeId.HasValue)
tasks = tasks.Where(x => x.Course!.CollegeId == offeringCollegeId);
var normalizedCourseKeyword = Normalize(courseKeyword);
if (normalizedCourseKeyword is not null)
tasks = tasks.Where(x =>
x.Course!.Code.Contains(normalizedCourseKeyword) ||
x.Course.Name.Contains(normalizedCourseKeyword));
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,
x.CourseId,
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)
})
.Take(200)
.ToListAsync(cancellationToken),
Colleges = await AccessibleTeachingTasks().AsNoTracking()
.Where(x => !academicTermId.HasValue || x.AcademicTermId == academicTermId.Value)
.Select(x => new { x.Course!.CollegeId, CollegeName = x.Course.College!.Name })
.Distinct()
.OrderBy(x => x.CollegeName)
.ToListAsync(cancellationToken),
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.Kind == ScheduleEntryKind.Experiment &&
x.ClassroomId.HasValue &&
AccessibleTeachingTasks().Select(task => task.Id)
.Contains(x.TeachingTaskId))
.Where(x => !academicTermId.HasValue ||
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
.Where(x => !offeringCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId == offeringCollegeId.Value)
.Where(x => normalizedCourseKeyword == null ||
x.TeachingTask!.Course!.Code.Contains(normalizedCourseKeyword) ||
x.TeachingTask.Course.Name.Contains(normalizedCourseKeyword))
.OrderBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ThenBy(x => x.DayOfWeek)
.ThenBy(x => x.StartPeriod)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
TaskNumber = x.TeachingTask!.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
x.DayOfWeek,
x.StartPeriod,
x.PeriodCount,
x.StartWeek,
x.EndWeek,
x.WeekPattern,
ClassNames = x.TeachingTask!.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name),
ClassroomName = x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name,
CampusName = x.Classroom.Building.Campus!.Name
})
.Take(500)
.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
,x.TeachingVenueNature
})
.ToListAsync(cancellationToken),
Periods = periodItems
});
}
[HttpGet("management")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetManagementProjects(
Guid? academicTermId,
ExperimentArrangementMode? arrangementMode,
ExperimentProjectStatus? status,
Guid? offeringCollegeId,
string? courseKeyword,
string? classKeyword,
string? teacherKeyword,
string? taskKeyword,
CancellationToken cancellationToken,
int page = 1,
int pageSize = 20)
{
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);
var normalizedCourseKeyword = Normalize(courseKeyword);
var normalizedClassKeyword = Normalize(classKeyword);
var normalizedTeacherKeyword = Normalize(teacherKeyword);
var normalizedTaskKeyword = Normalize(taskKeyword);
var taskSource = AccessibleTeachingTasks().AsNoTracking()
.Where(task => source.Select(project => project.TeachingTaskId).Contains(task.Id));
if (academicTermId.HasValue)
taskSource = taskSource.Where(x => x.AcademicTermId == academicTermId);
if (offeringCollegeId.HasValue)
taskSource = taskSource.Where(x => x.Course!.CollegeId == offeringCollegeId);
if (normalizedCourseKeyword is not null)
taskSource = taskSource.Where(x =>
x.Course!.Code.Contains(normalizedCourseKeyword) ||
x.Course.Name.Contains(normalizedCourseKeyword));
if (normalizedClassKeyword is not null)
taskSource = taskSource.Where(x => x.Classes.Any(item =>
item.AdministrativeClass!.Code.Contains(normalizedClassKeyword) ||
item.AdministrativeClass.Name.Contains(normalizedClassKeyword)));
if (normalizedTeacherKeyword is not null)
taskSource = taskSource.Where(x => x.Teachers.Any(item =>
item.Teacher!.TeacherNumber.Contains(normalizedTeacherKeyword) ||
item.Teacher.Name.Contains(normalizedTeacherKeyword)));
if (normalizedTaskKeyword is not null)
taskSource = taskSource.Where(x =>
x.TaskNumber.Contains(normalizedTaskKeyword) ||
x.Name.Contains(normalizedTaskKeyword));
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var total = await taskSource.CountAsync(cancellationToken);
var taskIds = await taskSource
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var items = await source
.Where(x => taskIds.Contains(x.TeachingTaskId))
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
.ThenBy(x => x.StartDate)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.ScheduleEntryId,
x.ScheduleWeek,
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),
ScheduleEntry = x.ScheduleEntryId == null ? null : new
{
x.ScheduleEntry!.DayOfWeek,
x.ScheduleEntry.StartPeriod,
x.ScheduleEntry.PeriodCount,
x.ScheduleEntry.StartWeek,
x.ScheduleEntry.EndWeek,
x.ScheduleEntry.WeekPattern,
ProjectWeek = x.ScheduleWeek,
ClassroomName = x.ScheduleEntry.Classroom!.Name,
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.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);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[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.ScheduleEntryId,
x.ScheduleWeek,
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),
ScheduleEntry = x.ScheduleEntryId == null ? null : new
{
x.ScheduleEntry!.DayOfWeek,
x.ScheduleEntry.StartPeriod,
x.ScheduleEntry.PeriodCount,
x.ScheduleEntry.StartWeek,
x.ScheduleEntry.EndWeek,
x.ScheduleEntry.WeekPattern,
ProjectWeek = x.ScheduleWeek,
ClassroomName = x.ScheduleEntry.Classroom!.Name,
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.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 scheduleEntry = await ValidateScheduleEntryAsync(
request, task.Id, cancellationToken);
if (scheduleEntry.Problem is not null) return ValidationProblem(scheduleEntry.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,
ScheduleEntryId = scheduleEntry.Entry?.Id,
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 });
}
[HttpPost("batch")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateProjects(
ExperimentProjectBatchRequest request,
CancellationToken cancellationToken)
{
var taskIds = request.TeachingTaskIds
.Where(x => x != Guid.Empty)
.Distinct()
.ToList();
if (taskIds.Count == 0)
return ValidationProblem("请至少选择一个教学任务。");
if (taskIds.Count > 100)
return ValidationProblem("单次最多为 100 个教学任务创建实验项目。");
var tasks = await AccessibleTeachingTasks().AsNoTracking()
.Include(x => x.AcademicTerm)
.WhereIn(taskIds, x => x.Id)
.Where(x => x.Status == TeachingTaskStatus.Published)
.OrderBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Count)
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
var first = tasks[0];
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
x.AcademicTermId != first.AcademicTermId ||
x.CourseId != first.CourseId))
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
var code = request.Code.Trim();
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
{
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code)
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
if (conflictingTaskNumbers.Count > 0)
return ConflictProblem(
$"以下教学任务已存在实验项目编码 {code}{string.Join("", conflictingTaskNumbers)}。");
}
var scheduledEntries = request.ArrangementMode == ExperimentArrangementMode.Centralized
? await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.Kind == ScheduleEntryKind.Experiment &&
x.ClassroomId.HasValue &&
taskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.TeachingTaskId)
.ThenBy(x => x.DayOfWeek)
.ThenBy(x => x.StartPeriod)
.ToListAsync(cancellationToken)
: [];
if (request.ArrangementMode == ExperimentArrangementMode.Centralized &&
tasks.Any(task => scheduledEntries.All(entry => entry.TeachingTaskId != task.Id)))
return ValidationProblem("所选教学班中包含未排入实验室的已发布实验课,请先完成课表安排。");
var scheduledOccurrences = scheduledEntries
.SelectMany(entry => Enumerable.Range(
entry.StartWeek,
entry.EndWeek - entry.StartWeek + 1)
.Where(week => FreeClassroomRules.MatchesWeek(entry.WeekPattern, week))
.Select(week => (Entry: entry, Week: week)))
.ToList();
var legacyProjects = scheduledEntries.Count > 0
? await db.ExperimentProjects
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
x.ScheduleWeek == null &&
taskIds.Contains(x.TeachingTaskId))
.ToListAsync(cancellationToken)
: [];
if (legacyProjects.Any(x => x.Status != ExperimentProjectStatus.Draft))
return ConflictProblem("存在旧版已发布实验项目,不能自动拆分为每周项目;请先关闭后重新设置。");
if (scheduledOccurrences.Count > 0)
{
var entryIds = scheduledOccurrences.Select(x => x.Entry.Id).Distinct().ToList();
var existingOccurrences = await db.ExperimentProjects.AsNoTracking()
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
x.ScheduleWeek.HasValue && entryIds.Contains(x.ScheduleEntryId.Value))
.Select(x => new { ScheduleEntryId = x.ScheduleEntryId!.Value, ScheduleWeek = x.ScheduleWeek!.Value })
.ToListAsync(cancellationToken);
var existingKeys = existingOccurrences
.Select(x => (x.ScheduleEntryId, x.ScheduleWeek))
.ToHashSet();
if (scheduledOccurrences.Any(x => existingKeys.Contains((x.Entry.Id, x.Week))))
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
}
var projects = new List<ExperimentProject>(tasks.Count);
foreach (var task in tasks)
{
var item = request.ForTeachingTask(task.Id);
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
if (problem is not null) return ValidationProblem(problem);
IEnumerable<(ScheduleEntry? Entry, int? Week)> taskOccurrences =
request.ArrangementMode == ExperimentArrangementMode.Centralized
? scheduledOccurrences
.Where(item => item.Entry.TeachingTaskId == task.Id)
.Select(item => ((ScheduleEntry?)item.Entry, (int?)item.Week))
: [(null, null)];
foreach (var occurrence in taskOccurrences)
{
var scheduleEntry = occurrence.Entry;
var legacy = scheduleEntry is null ? null : legacyProjects
.SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id);
if (legacy is not null)
{
var firstWeek = scheduledOccurrences
.Where(item => item.Entry.Id == scheduleEntry!.Id)
.Min(item => item.Week);
if (occurrence.Week == firstWeek)
{
legacy.ScheduleWeek = occurrence.Week;
continue;
}
}
projects.Add(new ExperimentProject
{
TeachingTaskId = task.Id,
ScheduleEntryId = occurrence.Entry?.Id,
ScheduleWeek = occurrence.Week,
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.AddRange(projects);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new
{
Count = projects.Count,
ProjectIds = projects.Select(x => x.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 scheduleEntry = await ValidateScheduleEntryAsync(
request, project.TeachingTaskId, cancellationToken);
if (scheduleEntry.Problem is not null) return ValidationProblem(scheduleEntry.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.ScheduleEntryId = scheduleEntry.Entry?.Id;
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();
}
[HttpDelete("batch")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DeleteProjects(
ExperimentProjectBulkRequest request,
CancellationToken cancellationToken)
{
var ids = ValidateBulkProjectIds(request.ProjectIds);
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
var projects = await ScopedProjects()
.Where(x => ids.Contains(x.Id))
.ToListAsync(cancellationToken);
if (projects.Count != ids.Count) return NotFound();
if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
return ConflictProblem("批量删除只能包含草稿实验项目。");
db.ExperimentProjects.RemoveRange(projects);
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("只有草稿实验项目可以发布。");
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x =>
x.Status == ExperimentSessionStatus.Scheduled)
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
if (!hasSchedule)
return ConflictProblem(project.ArrangementMode == ExperimentArrangementMode.Centralized
? "请先绑定已发布课表中的实验课后再发布。"
: "请至少安排一个有效实验场次后再发布。");
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);
await NotifyProjectPublishedAsync(project, cancellationToken);
return NoContent();
}
[HttpPost("batch/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> PublishProjects(
ExperimentProjectBulkRequest request,
CancellationToken cancellationToken)
{
var ids = ValidateBulkProjectIds(request.ProjectIds);
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count)
return NotFound();
var userId = currentUserDataScope.Current.UserId;
var job = new ExamPublishJob
{
Kind = ExamPublishJobKind.ExperimentProjects,
PlanId = ids[0],
RequestedByUserId = userId == Guid.Empty ? null : userId,
ProjectIdsJson = JsonSerializer.Serialize(ids),
CurrentStep = "等待后台校验"
};
db.ExamPublishJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamPublish, job.Id));
await db.SaveChangesAsync(cancellationToken);
return Accepted(new { JobId = job.Id, Status = job.Status, Message = "实验项目发布任务已提交。" });
}
[HttpGet("batch/publish-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetPublishJob(Guid jobId, CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking().FirstOrDefaultAsync(x =>
x.Id == jobId && x.Kind == ExamPublishJobKind.ExperimentProjects,
cancellationToken);
if (job is null) return NotFound();
return Ok(new { job.Id, job.Status, job.CurrentStep, job.ErrorMessage, job.StartedAt, job.CompletedAt });
}
[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("已关闭实验项目不能再增加场次。");
if (project.ArrangementMode == ExperimentArrangementMode.Centralized)
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 });
}
[HttpPost("sessions/batch")]
[Authorize(Roles = Managers)]
public Task<ActionResult> CreateSessions(
ExperimentSessionBatchRequest request,
CancellationToken cancellationToken)
{
if (request.Items.Count == 0)
return Task.FromResult<ActionResult>(
ValidationProblem("请至少添加一条实验排课。"));
if (request.Items.Count > 100)
return Task.FromResult<ActionResult>(
ValidationProblem("单次最多安排 100 条实验场次。"));
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
request.Items.Count)
return Task.FromResult<ActionResult>(
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
var projectIds = request.Items.Select(x => x.ProjectId).ToList();
var projects = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.WhereIn(projectIds, x => x.Id)
.ToDictionaryAsync(x => x.Id, cancellationToken);
if (projects.Count != projectIds.Count)
return ValidationProblem(
"部分实验项目不存在或不在当前管理范围内。");
var createdSessions = new List<(ExperimentProject Project, ExperimentSession Session)>(
request.Items.Count);
foreach (var item in request.Items)
{
var project = projects[item.ProjectId];
if (project.Status == ExperimentProjectStatus.Closed)
return ConflictProblem(
$"实验项目“{project.Name}”已关闭,不能再增加场次。");
if (project.ArrangementMode == ExperimentArrangementMode.Centralized)
return ConflictProblem(
$"实验项目“{project.Name}”为集中安排,请直接使用已发布课表中的实验课。");
var sessionRequest = item.ToSessionRequest();
var problem = await ValidateSessionAsync(
project,
sessionRequest,
cancellationToken);
if (problem is not null)
return ConflictProblem(
$"实验项目“{project.Name}”:{problem}");
var session = new ExperimentSession
{
ExperimentProjectId = project.Id,
ClassroomId = item.ClassroomId,
SessionDate = item.SessionDate,
StartPeriod = item.StartPeriod,
PeriodCount = item.PeriodCount,
Capacity = await ResolveSessionCapacityAsync(
project,
item.Capacity,
cancellationToken),
Notes = Normalize(item.Notes)
};
db.ExperimentSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
createdSessions.Add((project, session));
}
foreach (var (project, session) in createdSessions.Where(x =>
x.Project.Status == ExperimentProjectStatus.Published))
{
var userIds = await RosterUserIdsAsync(
project.TeachingTaskId,
cancellationToken);
if (userIds.Count == 0) continue;
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"新增实验场次",
$"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
await transaction.CommitAsync(cancellationToken);
return Created(string.Empty, new
{
Count = createdSessions.Count,
SessionIds = createdSessions.Select(x => x.Session.Id)
});
},
cancellationToken,
IsolationLevel.Serializable);
}
[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 (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
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 async Task NotifyProjectPublishedAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
if (userIds.Count == 0) return;
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized
? "集中安排"
: "自行预约";
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
{
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
return ids is { Count: > 0 and <= 100 } ? ids : null;
}
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 async Task<(ScheduleEntry? Entry, string? Problem)> ValidateScheduleEntryAsync(
ExperimentProjectRequest request,
Guid teachingTaskId,
CancellationToken cancellationToken)
{
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
return request.ScheduleEntryId.HasValue
? (null, "自行安排的实验项目不能绑定课表实验课。")
: (null, null);
if (!request.ScheduleEntryId.HasValue)
return (null, "集中安排的实验项目必须绑定已发布课表中的实验课。");
var entry = await db.ScheduleEntries
.Include(x => x.SchedulePlan)
.FirstOrDefaultAsync(x => x.Id == request.ScheduleEntryId, cancellationToken);
if (entry is null || entry.SchedulePlan!.Status != SchedulePlanStatus.Published ||
entry.Kind != ScheduleEntryKind.Experiment || !entry.ClassroomId.HasValue ||
entry.TeachingTaskId != teachingTaskId)
return (null, "只能绑定本教学任务已发布、已安排实验室的实验课。");
if (!await AccessibleTeachingTasks().AnyAsync(x => x.Id == teachingTaskId, cancellationToken))
return (null, "该教学任务不在当前管理范围内。");
return (entry, 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,
Guid? ScheduleEntryId = null);
public sealed record ExperimentProjectBatchRequest(
[Required] IReadOnlyList<Guid> TeachingTaskIds,
[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 ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
new(
teachingTaskId,
Code,
Name,
ArrangementMode,
Description,
Requirements,
StartDate,
EndDate);
}
public sealed record ExperimentProjectBulkRequest(
[Required] IReadOnlyList<Guid> ProjectIds);
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 ExperimentSessionBatchRequest(
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
public sealed record ExperimentSessionBatchItem(
Guid ProjectId,
Guid ClassroomId,
DateOnly SessionDate,
[Range(1, 30)] int StartPeriod,
[Range(1, 30)] int PeriodCount,
[Range(1, 10000)] int Capacity,
[MaxLength(500)] string? Notes)
{
public ExperimentSessionRequest ToSessionRequest() =>
new(
ClassroomId,
SessionDate,
StartPeriod,
PeriodCount,
Capacity,
Notes);
}
public sealed record ExperimentPeriodOption(
Guid AcademicTermId,
int PeriodNumber,
string Name,
string StartsAt,
string EndsAt);