选课批次:时间窗口、退课截止、学分上限、开放/关闭。

教学班投放:容量、班级范围、全校开放。
学生选退课:容量、重复课程、学分上限、课表冲突校验。
教学班实时名单。
校级、学院级、学生角色分级操作。
SQLite 本地增量升级及演示数据。
MySQL 正式 EF Core 迁移。
Vue 已编译进 wwwroot,单服务运行无需 npm run dev。
学生端和管理端均完成桌面、手机响应式检查。
This commit is contained in:
2026-07-24 15:17:26 +08:00 Unverified
parent bcc4d33bd5
commit 151a22554f
19 changed files with 3972 additions and 6 deletions
@@ -0,0 +1,746 @@
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.CourseSelection;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/course-selections")]
public sealed class CourseSelectionsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string RoundManagers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
private const string OfferingManagers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string SelectionUsers =
OfferingManagers + "," +
SystemRoles.Student;
private const string RosterReaders =
OfferingManagers + "," +
SystemRoles.Teacher;
[HttpGet("rounds")]
[Authorize(Roles = SelectionUsers)]
public async Task<ActionResult> GetRounds(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = db.CourseSelectionRounds.AsNoTracking().AsQueryable();
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (currentUserDataScope.Current.IsInRole(SystemRoles.Student))
source = source.Where(x => x.Status != CourseSelectionRoundStatus.Draft);
var now = DateTime.UtcNow;
return Ok(await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenByDescending(x => x.StartsAt)
.Select(x => new
{
x.Id,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.StartsAt,
x.EndsAt,
x.WithdrawalEndsAt,
x.MaxCredits,
x.Status,
IsAvailableNow =
x.Status == CourseSelectionRoundStatus.Open &&
now >= x.StartsAt &&
now <= x.EndsAt,
OfferingCount = x.Offerings.Count,
x.Notes,
x.UpdatedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("rounds")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> CreateRound(
CourseSelectionRoundRequest request,
CancellationToken cancellationToken)
{
var validation = await ValidateRoundAsync(request, cancellationToken);
if (validation is not null) return validation;
var round = new CourseSelectionRound
{
AcademicTermId = request.AcademicTermId,
Name = request.Name.Trim(),
StartsAt = request.StartsAt.ToUniversalTime(),
EndsAt = request.EndsAt.ToUniversalTime(),
WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(),
MaxCredits = request.MaxCredits,
Notes = Normalize(request.Notes)
};
db.CourseSelectionRounds.Add(round);
return await SaveAsync(round.Id, true, cancellationToken);
}
[HttpPut("rounds/{id:guid}")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> UpdateRound(
Guid id,
CourseSelectionRoundRequest request,
CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("只有草稿选课批次可以修改。");
var validation = await ValidateRoundAsync(request, cancellationToken);
if (validation is not null) return validation;
round.AcademicTermId = request.AcademicTermId;
round.Name = request.Name.Trim();
round.StartsAt = request.StartsAt.ToUniversalTime();
round.EndsAt = request.EndsAt.ToUniversalTime();
round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
round.MaxCredits = request.MaxCredits;
round.Notes = Normalize(request.Notes);
return await SaveAsync(id, false, cancellationToken);
}
[HttpDelete("rounds/{id:guid}")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> DeleteRound(Guid id, CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("只有草稿选课批次可以删除。");
db.CourseSelectionRounds.Remove(round);
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("rounds/{id:guid}/open")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> OpenRound(Guid id, CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds
.Include(x => x.Offerings)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("只有草稿选课批次可以开放。");
if (round.Offerings.Count == 0)
return ConflictProblem("至少配置一个可选教学班后才能开放。");
round.Status = CourseSelectionRoundStatus.Open;
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("rounds/{id:guid}/close")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> CloseRound(Guid id, CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Open)
return ConflictProblem("只有开放中的选课批次可以关闭。");
round.Status = CourseSelectionRoundStatus.Closed;
return await SaveAsync(id, false, cancellationToken);
}
[HttpGet("rounds/{roundId:guid}/offerings")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> GetOfferings(
Guid roundId,
CancellationToken cancellationToken)
{
var source = ScopedOfferings()
.AsNoTracking()
.Where(x => x.CourseSelectionRoundId == roundId);
return Ok(await source
.OrderBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.Select(x => new
{
x.Id,
x.CourseSelectionRoundId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
x.TeachingTask.Course.Credits,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.Capacity,
EnrolledCount = x.Enrollments.Count(item =>
item.Status == CourseEnrollmentStatus.Enrolled),
x.IsOpenToAll,
x.Notes,
x.UpdatedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("rounds/{roundId:guid}/offerings")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> CreateOffering(
Guid roundId,
CourseSelectionOfferingRequest request,
CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds.FindAsync([roundId], cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后不能调整教学班。");
var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken);
var validation = ValidateOffering(round, task, request);
if (validation is not null) return validation;
var offering = new CourseSelectionOffering
{
CourseSelectionRoundId = roundId,
TeachingTaskId = request.TeachingTaskId,
Capacity = request.Capacity,
IsOpenToAll = request.IsOpenToAll,
Notes = Normalize(request.Notes)
};
db.CourseSelectionOfferings.Add(offering);
return await SaveAsync(offering.Id, true, cancellationToken);
}
[HttpPut("rounds/{roundId:guid}/offerings/{id:guid}")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> UpdateOffering(
Guid roundId,
Guid id,
CourseSelectionOfferingRequest request,
CancellationToken cancellationToken)
{
var offering = await ScopedOfferings()
.Include(x => x.CourseSelectionRound)
.FirstOrDefaultAsync(
x => x.Id == id && x.CourseSelectionRoundId == roundId,
cancellationToken);
if (offering is null) return NotFound();
if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后不能调整教学班。");
var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken);
var validation = ValidateOffering(offering.CourseSelectionRound, task, request);
if (validation is not null) return validation;
offering.TeachingTaskId = request.TeachingTaskId;
offering.Capacity = request.Capacity;
offering.IsOpenToAll = request.IsOpenToAll;
offering.Notes = Normalize(request.Notes);
return await SaveAsync(id, false, cancellationToken);
}
[HttpDelete("rounds/{roundId:guid}/offerings/{id:guid}")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> DeleteOffering(
Guid roundId,
Guid id,
CancellationToken cancellationToken)
{
var offering = await ScopedOfferings()
.Include(x => x.CourseSelectionRound)
.FirstOrDefaultAsync(
x => x.Id == id && x.CourseSelectionRoundId == roundId,
cancellationToken);
if (offering is null) return NotFound();
if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后不能调整教学班。");
db.CourseSelectionOfferings.Remove(offering);
return await SaveAsync(id, false, cancellationToken);
}
[HttpGet("offerings/{id:guid}/roster")]
[Authorize(Roles = RosterReaders)]
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
{
var offering = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseName = x.TeachingTask.Course!.Name,
CollegeId = x.TeachingTask.Course.CollegeId,
TeacherUserIds = x.TeachingTask.Teachers
.Select(item => item.Teacher!.UserId),
x.Capacity
})
.FirstOrDefaultAsync(cancellationToken);
if (offering is null) return NotFound();
var scope = currentUserDataScope.Current;
var isAssignedTeacher =
scope.IsInRole(SystemRoles.Teacher) &&
offering.TeacherUserIds.Contains(scope.UserId);
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
return Forbid();
var students = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
x.EnrolledAt
})
.ToListAsync(cancellationToken);
return Ok(new
{
offering.Id,
offering.TeachingTaskId,
offering.TaskNumber,
offering.TaskName,
offering.CourseName,
offering.Capacity,
EnrolledCount = students.Count,
Students = students
});
}
[HttpGet("student/options")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentOptions(
Guid roundId,
CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null) return ProfileNotFound();
var round = await db.CourseSelectionRounds.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == roundId, cancellationToken);
if (round is null || round.Status == CourseSelectionRoundStatus.Draft)
return NotFound();
var offerings = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x =>
x.CourseSelectionRoundId == roundId &&
x.TeachingTask!.Status == TeachingTaskStatus.Published &&
(x.IsOpenToAll ||
x.TeachingTask.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)))
.OrderBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new StudentOfferingDto(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
x.TeachingTask.Course!.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.Course.Credits,
x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Capacity,
x.Enrollments.Count(item =>
item.Status == CourseEnrollmentStatus.Enrolled),
x.IsOpenToAll,
x.Enrollments
.Where(item => item.StudentId == student.Id)
.Select(item => (CourseEnrollmentStatus?)item.Status)
.FirstOrDefault(),
db.ScheduleEntries
.Where(entry =>
entry.TeachingTaskId == x.TeachingTaskId &&
entry.SchedulePlan!.AcademicTermId == round.AcademicTermId &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published)
.OrderBy(entry => entry.DayOfWeek)
.ThenBy(entry => entry.StartPeriod)
.Select(entry => new StudentScheduleDto(
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Classroom!.Name))
.ToList()))
.ToListAsync(cancellationToken);
return Ok(new
{
Round = new
{
round.Id,
round.Name,
round.StartsAt,
round.EndsAt,
round.WithdrawalEndsAt,
round.MaxCredits,
round.Status,
IsAvailableNow = CourseSelectionRules.IsSelectionOpen(
round,
DateTime.UtcNow)
},
Student = new
{
student.Id,
student.StudentNumber,
student.Name,
ClassName = student.AdministrativeClass!.Name
},
Offerings = offerings
});
}
[HttpGet("student/enrollments")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentEnrollments(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null) return ProfileNotFound();
var source = db.CourseEnrollments.AsNoTracking()
.Where(x => x.StudentId == student.Id);
if (academicTermId.HasValue)
{
source = source.Where(x =>
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
academicTermId);
}
return Ok(await source
.OrderByDescending(x => x.EnrolledAt)
.Select(x => new
{
x.Id,
x.CourseSelectionOfferingId,
RoundId = x.CourseSelectionOffering!.CourseSelectionRoundId,
RoundName = x.CourseSelectionOffering.CourseSelectionRound!.Name,
TermName = x.CourseSelectionOffering.CourseSelectionRound.AcademicTerm!.Name,
x.CourseSelectionOffering.TeachingTaskId,
TaskNumber = x.CourseSelectionOffering.TeachingTask!.TaskNumber,
CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code,
CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name,
x.CourseSelectionOffering.TeachingTask.Course.Credits,
TeacherNames = x.CourseSelectionOffering.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Status,
x.EnrolledAt,
x.WithdrawnAt,
CanWithdraw =
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering.CourseSelectionRound.Status ==
CourseSelectionRoundStatus.Open &&
DateTime.UtcNow <=
x.CourseSelectionOffering.CourseSelectionRound.WithdrawalEndsAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("student/enrollments")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Enroll(
StudentEnrollmentRequest request,
CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null) return ProfileNotFound();
if (student.Status != StudentStatus.Active)
return ConflictProblem("只有在籍学生可以选课。");
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
var offering = await db.CourseSelectionOfferings
.Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
var now = DateTime.UtcNow;
if (!CourseSelectionRules.IsSelectionOpen(round, now))
return ConflictProblem("当前不在该选课批次的开放时间内。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可选。");
if (!offering.IsOpenToAll &&
!task.Classes.Any(x =>
x.AdministrativeClassId == student.AdministrativeClassId))
return Forbid();
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.StudentId == student.Id,
cancellationToken);
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
return ConflictProblem("你已经选择了该教学班。");
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
if (enrolledCount >= offering.Capacity)
return ConflictProblem("该教学班名额已满。");
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId,
cancellationToken);
if (duplicateCourse)
return ConflictProblem("同一学期不能重复选择相同课程。");
var selectedCredits = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
.SumAsync(
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
cancellationToken) ?? 0;
if (selectedCredits + task.Course!.Credits > round.MaxCredits)
{
return ConflictProblem(
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分,超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId,
[task.Id],
cancellationToken);
if (candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
.Distinct()
.ToArrayAsync(cancellationToken);
var selectedEntries = await PublishedScheduleEntries(
round.AcademicTermId,
selectedTaskIds,
cancellationToken);
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
if (existing is null)
{
existing = new CourseEnrollment
{
CourseSelectionOfferingId = offering.Id,
StudentId = student.Id
};
db.CourseEnrollments.Add(existing);
}
else
{
existing.Status = CourseEnrollmentStatus.Enrolled;
existing.EnrolledAt = now;
existing.WithdrawnAt = null;
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Created(string.Empty, new { existing.Id });
}
[HttpDelete("student/enrollments/{id:guid}")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Withdraw(Guid id, CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null) return ProfileNotFound();
var enrollment = await db.CourseEnrollments
.Include(x => x.CourseSelectionOffering)
.ThenInclude(x => x!.CourseSelectionRound)
.FirstOrDefaultAsync(
x => x.Id == id && x.StudentId == student.Id,
cancellationToken);
if (enrollment is null) return NotFound();
if (enrollment.Status != CourseEnrollmentStatus.Enrolled)
return ConflictProblem("该课程已经退选。");
if (!CourseSelectionRules.CanWithdraw(
enrollment.CourseSelectionOffering!.CourseSelectionRound!,
DateTime.UtcNow))
return ConflictProblem("当前批次已停止退课。");
enrollment.Status = CourseEnrollmentStatus.Withdrawn;
enrollment.WithdrawnAt = DateTime.UtcNow;
return await SaveAsync(id, false, cancellationToken);
}
private IQueryable<CourseSelectionOffering> ScopedOfferings()
{
var source = db.CourseSelectionOfferings.AsQueryable();
var scope = currentUserDataScope.Current;
return scope.Scope == DataScope.All
? source
: source.Where(x =>
x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId);
}
private async Task<TeachingTask?> FindAccessibleTaskAsync(
Guid id,
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var source = db.TeachingTasks.AsNoTracking().AsQueryable();
if (scope.Scope != DataScope.All)
{
source = source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
}
return await source
.Include(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
private ActionResult? ValidateOffering(
CourseSelectionRound round,
TeachingTask? task,
CourseSelectionOfferingRequest request)
{
if (task is null) return ValidationProblem("所选教学班不存在或超出数据范围。");
if (task.AcademicTermId != round.AcademicTermId)
return ValidationProblem("教学班与选课批次必须属于同一学期。");
if (task.Status != TeachingTaskStatus.Published)
return ValidationProblem("只有已发布的教学班可以进入选课。");
if (request.Capacity > task.Capacity)
return ValidationProblem($"选课容量不能超过教学班容量 {task.Capacity}。");
return null;
}
private async Task<ActionResult?> ValidateRoundAsync(
CourseSelectionRoundRequest request,
CancellationToken cancellationToken)
{
var startsAt = request.StartsAt.ToUniversalTime();
var endsAt = request.EndsAt.ToUniversalTime();
var withdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
if (startsAt >= endsAt)
return ValidationProblem("选课开始时间必须早于结束时间。");
if (withdrawalEndsAt < endsAt)
return ValidationProblem("退课截止时间不能早于选课结束时间。");
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选学期不存在或已停用。");
return null;
}
private async Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
return await db.Students
.Include(x => x.AdministrativeClass)
.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
}
private async Task<List<ScheduleEntry>> PublishedScheduleEntries(
Guid academicTermId,
IReadOnlyCollection<Guid> taskIds,
CancellationToken cancellationToken)
{
if (taskIds.Count == 0) return [];
return await db.ScheduleEntries.AsNoTracking()
.Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
x.SchedulePlan!.AcademicTermId == academicTermId &&
x.SchedulePlan.Status == SchedulePlanStatus.Published)
.ToListAsync(cancellationToken);
}
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("记录重复、容量已变化,或关联数据已失效。");
}
}
private ActionResult ProfileNotFound() =>
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();
}
public sealed record CourseSelectionRoundRequest(
Guid AcademicTermId,
[Required, MaxLength(120)] string Name,
DateTime StartsAt,
DateTime EndsAt,
DateTime WithdrawalEndsAt,
[Range(typeof(decimal), "0.5", "99")] decimal MaxCredits,
[MaxLength(500)] string? Notes);
public sealed record CourseSelectionOfferingRequest(
Guid TeachingTaskId,
[Range(1, 10000)] int Capacity,
bool IsOpenToAll,
[MaxLength(500)] string? Notes);
public sealed record StudentEnrollmentRequest(Guid OfferingId);
public sealed record StudentOfferingDto(
Guid Id,
Guid TeachingTaskId,
string TaskNumber,
string CourseCode,
string CourseName,
decimal Credits,
IEnumerable<string> TeacherNames,
int Capacity,
int EnrolledCount,
bool IsOpenToAll,
CourseEnrollmentStatus? EnrollmentStatus,
IEnumerable<StudentScheduleDto> Schedules);
public sealed record StudentScheduleDto(
int DayOfWeek,
int StartPeriod,
int PeriodCount,
int StartWeek,
int EndWeek,
WeekPattern WeekPattern,
string ClassroomName);
@@ -1,3 +1,4 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -35,6 +36,14 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
CourseSelectionRounds = await db.CourseSelectionRounds
.CountAsync(cancellationToken),
CourseSelectionOfferings = await db.CourseSelectionOfferings
.CountAsync(cancellationToken),
CourseEnrollments = await db.CourseEnrollments
.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
@@ -0,0 +1,54 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class CourseSelectionRound : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public required string Name { get; set; }
public DateTime StartsAt { get; set; }
public DateTime EndsAt { get; set; }
public DateTime WithdrawalEndsAt { get; set; }
public decimal MaxCredits { get; set; } = 30;
public CourseSelectionRoundStatus Status { get; set; } =
CourseSelectionRoundStatus.Draft;
public string? Notes { get; set; }
public ICollection<CourseSelectionOffering> Offerings { get; set; } = [];
}
public sealed class CourseSelectionOffering : EntityBase
{
public Guid CourseSelectionRoundId { get; set; }
public CourseSelectionRound? CourseSelectionRound { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public int Capacity { get; set; }
public bool IsOpenToAll { get; set; }
public string? Notes { get; set; }
public ICollection<CourseEnrollment> Enrollments { get; set; } = [];
}
public sealed class CourseEnrollment : EntityBase
{
public Guid CourseSelectionOfferingId { get; set; }
public CourseSelectionOffering? CourseSelectionOffering { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
public DateTime? WithdrawnAt { get; set; }
}
public enum CourseSelectionRoundStatus
{
Draft = 1,
Open = 2,
Closed = 3
}
public enum CourseEnrollmentStatus
{
Enrolled = 1,
Withdrawn = 2
}
@@ -0,0 +1,23 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Scheduling;
namespace Jiaowu.Api.Infrastructure.CourseSelection;
public static class CourseSelectionRules
{
public static bool IsSelectionOpen(CourseSelectionRound round, DateTime nowUtc) =>
round.Status == CourseSelectionRoundStatus.Open &&
nowUtc >= round.StartsAt &&
nowUtc <= round.EndsAt;
public static bool CanWithdraw(CourseSelectionRound round, DateTime nowUtc) =>
round.Status == CourseSelectionRoundStatus.Open &&
nowUtc <= round.WithdrawalEndsAt;
public static bool HasScheduleConflict(
IEnumerable<ScheduleEntry> candidateEntries,
IEnumerable<ScheduleEntry> selectedEntries) =>
candidateEntries.Any(candidate =>
selectedEntries.Any(selected =>
ScheduleConflictDetector.TimeOverlaps(candidate, selected)));
}
@@ -28,6 +28,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
Set<CourseSelectionOffering>();
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -267,6 +272,51 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSelectionRound>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.MaxCredits).HasPrecision(6, 1);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSelectionOffering>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
x.CourseSelectionRoundId,
x.TeachingTaskId
}).IsUnique();
entity.HasOne(x => x.CourseSelectionRound)
.WithMany(x => x.Offerings)
.HasForeignKey(x => x.CourseSelectionRoundId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseEnrollment>(entity =>
{
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new { x.StudentId, x.Status });
entity.HasOne(x => x.CourseSelectionOffering)
.WithMany(x => x.Enrollments)
.HasForeignKey(x => x.CourseSelectionOfferingId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -421,6 +421,42 @@ public sealed class DatabaseInitializer(
}
await SeedDevelopmentUsersAsync(computerCollege.Id);
await SeedDevelopmentCourseSelectionAsync();
}
private async Task SeedDevelopmentCourseSelectionAsync()
{
if (await db.CourseSelectionRounds.AnyAsync())
{
return;
}
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var now = DateTime.UtcNow;
db.CourseSelectionRounds.Add(new CourseSelectionRound
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期第一轮选课",
StartsAt = now.AddDays(-2),
EndsAt = now.AddDays(14),
WithdrawalEndsAt = now.AddDays(21),
MaxCredits = 30,
Status = CourseSelectionRoundStatus.Open,
Notes = "本地开发演示轮次,可用于验证选课、退课和教学班名单。",
Offerings =
[
new CourseSelectionOffering
{
TeachingTaskId = task.Id,
Capacity = 60,
IsOpenToAll = false,
Notes = "面向计科 2026-1 班开放。"
}
]
});
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
@@ -11,6 +11,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
private const string SchedulesMigration = "20260724_04_schedules";
private const string ClassCounselorMigration = "20260724_05_class_counselor";
private const string CourseSelectionMigration = "20260724_06_course_selection";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -58,6 +59,10 @@ public sealed class DevelopmentSqliteMigrator(
? ClassCounselorStatements.Skip(1)
: ClassCounselorStatements,
cancellationToken);
await ApplyMigrationAsync(
CourseSelectionMigration,
CourseSelectionStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -426,4 +431,79 @@ public sealed class DevelopmentSqliteMigrator(
ON "AdministrativeClasses" ("CounselorUserId");
"""
];
private static readonly string[] CourseSelectionStatements =
[
"""
CREATE TABLE IF NOT EXISTS "CourseSelectionRounds" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseSelectionRounds" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"WithdrawalEndsAt" TEXT NOT NULL,
"MaxCredits" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseSelectionRounds_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseSelectionRounds_AcademicTermId_Status"
ON "CourseSelectionRounds" ("AcademicTermId", "Status");
""",
"""
CREATE TABLE IF NOT EXISTS "CourseSelectionOfferings" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseSelectionOfferings" PRIMARY KEY,
"CourseSelectionRoundId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"Capacity" INTEGER NOT NULL,
"IsOpenToAll" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseSelectionOfferings_CourseSelectionRounds_CourseSelectionRoundId"
FOREIGN KEY ("CourseSelectionRoundId") REFERENCES "CourseSelectionRounds" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_CourseSelectionOfferings_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseSelectionOfferings_CourseSelectionRoundId_TeachingTaskId"
ON "CourseSelectionOfferings" ("CourseSelectionRoundId", "TeachingTaskId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseSelectionOfferings_TeachingTaskId"
ON "CourseSelectionOfferings" ("TeachingTaskId");
""",
"""
CREATE TABLE IF NOT EXISTS "CourseEnrollments" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseEnrollments" PRIMARY KEY,
"CourseSelectionOfferingId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"EnrolledAt" TEXT NOT NULL,
"WithdrawnAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseEnrollments_CourseSelectionOfferings_CourseSelectionOfferingId"
FOREIGN KEY ("CourseSelectionOfferingId") REFERENCES "CourseSelectionOfferings" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_CourseEnrollments_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseEnrollments_CourseSelectionOfferingId_StudentId"
ON "CourseEnrollments" ("CourseSelectionOfferingId", "StudentId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseEnrollments_StudentId_Status"
ON "CourseEnrollments" ("StudentId", "Status");
"""
];
}
@@ -0,0 +1,145 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseSelection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseSelectionRounds",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
WithdrawalEndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
MaxCredits = table.Column<decimal>(type: "decimal(6,1)", precision: 6, scale: 1, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseSelectionRounds", x => x.Id);
table.ForeignKey(
name: "FK_CourseSelectionRounds_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseSelectionOfferings",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseSelectionRoundId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Capacity = table.Column<int>(type: "int", nullable: false),
IsOpenToAll = table.Column<bool>(type: "tinyint(1)", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseSelectionOfferings", x => x.Id);
table.ForeignKey(
name: "FK_CourseSelectionOfferings_CourseSelectionRounds_CourseSelecti~",
column: x => x.CourseSelectionRoundId,
principalTable: "CourseSelectionRounds",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CourseSelectionOfferings_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseEnrollments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseSelectionOfferingId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
EnrolledAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
WithdrawnAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseEnrollments", x => x.Id);
table.ForeignKey(
name: "FK_CourseEnrollments_CourseSelectionOfferings_CourseSelectionOf~",
column: x => x.CourseSelectionOfferingId,
principalTable: "CourseSelectionOfferings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CourseEnrollments_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseEnrollments_CourseSelectionOfferingId_StudentId",
table: "CourseEnrollments",
columns: new[] { "CourseSelectionOfferingId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseEnrollments_StudentId_Status",
table: "CourseEnrollments",
columns: new[] { "StudentId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionOfferings_CourseSelectionRoundId_TeachingTask~",
table: "CourseSelectionOfferings",
columns: new[] { "CourseSelectionRoundId", "TeachingTaskId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionOfferings_TeachingTaskId",
table: "CourseSelectionOfferings",
column: "TeachingTaskId");
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionRounds_AcademicTermId_Status",
table: "CourseSelectionRounds",
columns: new[] { "AcademicTermId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CourseEnrollments");
migrationBuilder.DropTable(
name: "CourseSelectionOfferings");
migrationBuilder.DropTable(
name: "CourseSelectionRounds");
}
}
}
@@ -382,6 +382,128 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CourseSelectionOfferingId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EnrolledAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("WithdrawnAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CourseSelectionOfferingId", "StudentId")
.IsUnique();
b.HasIndex("StudentId", "Status");
b.ToTable("CourseEnrollments");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("Capacity")
.HasColumnType("int");
b.Property<Guid>("CourseSelectionRoundId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsOpenToAll")
.HasColumnType("tinyint(1)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("TeachingTaskId");
b.HasIndex("CourseSelectionRoundId", "TeachingTaskId")
.IsUnique();
b.ToTable("CourseSelectionOfferings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EndsAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("MaxCredits")
.HasPrecision(6, 1)
.HasColumnType("decimal(6,1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime>("StartsAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("WithdrawalEndsAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "Status");
b.ToTable("CourseSelectionRounds");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
{
b.Property<Guid>("Id")
@@ -1218,6 +1340,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering")
.WithMany("Enrollments")
.HasForeignKey("CourseSelectionOfferingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CourseSelectionOffering");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
.WithMany("Offerings")
.HasForeignKey("CourseSelectionRoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CourseSelectionRound");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
@@ -1453,6 +1624,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Students");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.Navigation("Enrollments");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.Navigation("Offerings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b =>
{
b.Navigation("Courses");