12 Commits
31 changed files with 9044 additions and 503 deletions

No files matched your search

+6 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Set up .NET
uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: "10.0.302"
dotnet-version: "10.0.400"
- name: Set up Node.js
uses: https://github.com/actions/setup-node@v4
@@ -46,6 +46,11 @@ jobs:
npm ci
npm run build
- name: Install Chinese PDF font for backend tests
run: |
sudo apt-get update
sudo apt-get install --yes fonts-noto-cjk
- name: Run backend tests
run: dotnet test Jiaowu.slnx --configuration Release
@@ -503,12 +503,32 @@ public sealed class ExperimentsController(
x.CourseId != first.CourseId))
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
request.ProjectCount is < 1 or > 100)
return ValidationProblem("自行安排一次可生成 1 至 100 个实验项目。");
var code = request.Code.Trim();
var names = NormalizeBatchProjectNames(request.Names);
var definitionCount = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? request.ProjectCount
: 1;
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
names is not null && names.Count != definitionCount)
return ValidationProblem($"已填写 {names.Count} 个项目名称,请将生成项目数设为相同数量。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
taskIds.Count * definitionCount > 1000)
return ValidationProblem("本次最多生成 1000 个自行安排实验项目,请缩小教学任务或项目数量。");
var projectCodes = Enumerable.Range(1, definitionCount)
.Select(index => ProjectCode(code, index, definitionCount))
.ToList();
if (projectCodes.Any(item => item.Length > 40))
return ValidationProblem("项目编码前缀过长,无法追加顺序号。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
{
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code)
.Where(x => projectCodes.Contains(x.Code))
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
@@ -565,7 +585,7 @@ public sealed class ExperimentsController(
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
}
var projects = new List<ExperimentProject>(tasks.Count);
var projects = new List<ExperimentProject>(tasks.Count * definitionCount);
foreach (var task in tasks)
{
var item = request.ForTeachingTask(task.Id);
@@ -594,43 +614,57 @@ public sealed class ExperimentsController(
continue;
}
}
projects.Add(new ExperimentProject
for (var definitionIndex = 1; definitionIndex <= definitionCount; definitionIndex++)
{
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,
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
});
projects.Add(new ExperimentProject
{
TeachingTaskId = task.Id,
ScheduleEntryId = occurrence.Entry?.Id,
ScheduleWeek = occurrence.Week,
Code = ProjectCode(code, definitionIndex, definitionCount),
Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements),
StartDate = request.StartDate,
EndDate = request.EndDate,
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
});
}
}
}
var names = NormalizeBatchProjectNames(request.Names);
if (request.GenerateSequentialNames)
{
if (names is not null)
return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。");
var width = Math.Max(2, projects.Count.ToString().Length);
var width = Math.Max(2, (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? definitionCount
: projects.Count).ToString().Length);
if (request.Name.Trim().Length + width + 1 > 120)
return ValidationProblem("项目名称前缀过长,无法追加顺序号。");
for (var index = 0; index < projects.Count; index++)
projects[index].Name = $"{request.Name.Trim()} {(index + 1).ToString($"D{width}")}";
{
var number = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? index % definitionCount + 1
: index + 1;
projects[index].Name = $"{request.Name.Trim()} {number.ToString($"D{width}")}";
}
}
else if (names is not null)
{
if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
return ValidationProblem("每个实验项目名称均不能为空,且不能超过 120 个字符。");
if (names.Count != projects.Count)
var expectedNameCount = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? definitionCount
: projects.Count;
if (names.Count != expectedNameCount)
return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。");
for (var index = 0; index < projects.Count; index++)
projects[index].Name = names[index];
projects[index].Name = names[request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? index % definitionCount
: index];
}
db.ExperimentProjects.AddRange(projects);
@@ -1174,6 +1208,106 @@ public sealed class ExperimentsController(
.ToListAsync(cancellationToken));
}
[HttpGet("sessions/{id:guid}/eligible-students")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetProxyBookingStudents(
Guid id,
string? keyword,
CancellationToken cancellationToken)
{
var session = await LoadManagedSessionAsync(id, cancellationToken);
if (session is null) return NotFound();
if (!CanManageSession(session)) return Forbid();
if (session.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled)
return ConflictProblem("集中安排实验无需代选场次。");
var bookedStudentIds = await db.ExperimentBookings.AsNoTracking()
.Where(x => x.ExperimentProjectId == session.ExperimentProjectId &&
x.Status == ExperimentBookingStatus.Booked)
.Select(x => x.StudentId)
.ToListAsync(cancellationToken);
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db, [session.ExperimentProject.TeachingTaskId], cancellationToken);
return Ok(roster
.Where(x => !bookedStudentIds.Contains(x.StudentId))
.Where(x => normalizedKeyword is null ||
x.StudentNumber.Contains(normalizedKeyword) ||
x.Name.Contains(normalizedKeyword) ||
x.ClassName.Contains(normalizedKeyword))
.OrderBy(x => x.StudentNumber)
.Take(100)
.Select(x => new { x.StudentId, x.StudentNumber, x.Name, x.ClassName }));
}
[HttpPost("sessions/{id:guid}/proxy-bookings")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateProxyBooking(
Guid id,
ProxyExperimentBookingRequest request,
CancellationToken cancellationToken)
{
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var session = await LoadManagedSessionAsync(id, cancellationToken);
if (session is null) return NotFound();
if (!CanManageSession(session)) return Forbid();
if (session.Status != ExperimentSessionStatus.Scheduled ||
session.ExperimentProject!.Status != ExperimentProjectStatus.Published ||
session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled)
return ConflictProblem("只能为已发布的自行安排实验场次代选学生。");
var student = await db.Students.FirstOrDefaultAsync(x =>
x.Id == request.StudentId && x.Status == StudentStatus.Active,
cancellationToken);
if (student is null) return ValidationProblem("学生不存在或不在学。");
if (!await TeachingTaskRosterQuery.TaskIdsForStudent(db, student.Id)
.ContainsAsync(session.ExperimentProject.TeachingTaskId, cancellationToken))
return ValidationProblem("该学生不在本实验项目的教学任务名单内。");
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);
}
[HttpPost("sessions/{id:guid}/book")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Book(
@@ -1370,9 +1504,7 @@ public sealed class ExperimentsController(
.FirstOrDefaultAsync(x =>
x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return "实验教室不存在或已停用。";
if (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
return "所选场地未标注实验教学性质。";
if (classroom is null) return "实验场地不存在或已停用。";
if (request.Capacity > classroom.Capacity)
return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
if (project.ArrangementMode ==
@@ -1572,6 +1704,24 @@ public sealed class ExperimentsController(
taskIds.Contains(x.TeachingTaskId));
}
private Task<ExperimentSession?> LoadManagedSessionAsync(
Guid id,
CancellationToken cancellationToken) =>
db.ExperimentSessions
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.Include(x => x.Instructors)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private bool CanManageSession(ExperimentSession session)
{
var scope = currentUserDataScope.Current;
@@ -1737,6 +1887,11 @@ public sealed class ExperimentsController(
return names.Select(name => name.Trim()).ToList();
}
private static string ProjectCode(string prefix, int index, int count) =>
count == 1
? prefix
: $"{prefix}-{index.ToString($"D{Math.Max(2, count.ToString().Length)}")}";
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
@@ -1776,7 +1931,8 @@ public sealed record ExperimentProjectBatchRequest(
IReadOnlyList<string>? Names = null,
bool GenerateSequentialNames = false,
DateTime? SelectionStartsAt = null,
DateTime? SelectionEndsAt = null)
DateTime? SelectionEndsAt = null,
[Range(1, 100)] int ProjectCount = 1)
{
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
new(
@@ -1809,6 +1965,8 @@ public sealed record ExperimentSessionRequest(
[MaxLength(500)] string? Notes,
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
public sealed record ProxyExperimentBookingRequest(Guid StudentId);
public sealed record ExperimentSessionBatchRequest(
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
@@ -994,6 +994,100 @@ public sealed class GradesController(
return Ok(new { Student = student, Records = records });
}
/// <summary>
/// Returns the student's all-term academic metrics and the corresponding
/// rankings within their current administrative class, major and college.
/// Only formally published course grades participate in this calculation.
/// </summary>
[HttpGet("student/academic-overview")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentAcademicOverview(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new StudentAcademicScope(
x.Id,
x.AdministrativeClassId,
x.AdministrativeClass!.MajorId,
x.AdministrativeClass.Major!.CollegeId))
.FirstOrDefaultAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
var overview = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.StudentAcademicOverview(student.Id),
token => BuildStudentAcademicOverviewAsync(student, token),
AppCacheProfile.Analytics,
[AppCacheTags.StudentAcademicStatistics],
cancellationToken);
return Ok(overview);
}
private async Task<StudentAcademicOverviewResponse> BuildStudentAcademicOverviewAsync(
StudentAcademicScope student,
CancellationToken cancellationToken)
{
var summary = await db.StudentAcademicStatistics.AsNoTracking()
.FirstOrDefaultAsync(x => x.StudentId == student.Id, cancellationToken);
var students = await db.StudentAcademicStatistics.AsNoTracking()
.Select(x => new StudentAcademicAggregate(
x.StudentId,
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.MajorId,
x.Student.AdministrativeClass.Major!.CollegeId,
x.CourseCount,
x.Gpa,
x.WeightedAverageScore,
x.AverageScore))
.ToListAsync(cancellationToken);
var current = summary is null
? StudentAcademicAggregate.Empty(
student.Id,
student.AdministrativeClassId,
student.MajorId,
student.CollegeId)
: new StudentAcademicAggregate(
student.Id,
student.AdministrativeClassId,
student.MajorId,
student.CollegeId,
summary.CourseCount,
summary.Gpa,
summary.WeightedAverageScore,
summary.AverageScore);
var termTrend = await db.StudentAcademicTermStatistics.AsNoTracking()
.Where(x => x.StudentId == student.Id)
.OrderBy(x => x.AcademicTerm!.StartDate)
.Select(x => new StudentTermAcademicTrend(
x.AcademicTermId,
x.AcademicTerm!.Name,
x.CourseCount,
x.Gpa,
x.WeightedAverageScore,
x.AverageScore))
.ToListAsync(cancellationToken);
return new StudentAcademicOverviewResponse(
summary is null,
current.CourseCount,
summary?.CalculatedAt,
termTrend,
new StudentAcademicMetrics(
AcademicMetric.Create(current.Gpa, students, x => x.Gpa, current),
AcademicMetric.Create(
current.WeightedAverageScore,
students,
x => x.WeightedAverageScore,
current),
AcademicMetric.Create(
current.AverageScore,
students,
x => x.AverageScore,
current)));
}
[HttpGet("sheets/{id:guid}/statistics")]
[Authorize(Roles = StatisticsUsers)]
public async Task<ActionResult> GetCourseStatistics(
@@ -1280,3 +1374,75 @@ public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);
internal sealed record StudentAcademicScope(
Guid Id,
Guid AdministrativeClassId,
Guid MajorId,
Guid CollegeId);
internal sealed record StudentAcademicOverviewResponse(
bool IsRefreshing,
int CourseCount,
DateTime? CalculatedAt,
IReadOnlyCollection<StudentTermAcademicTrend> TermTrend,
StudentAcademicMetrics Metrics);
internal sealed record StudentAcademicMetrics(
AcademicMetric Gpa,
AcademicMetric WeightedAverageScore,
AcademicMetric AverageScore);
internal sealed record StudentAcademicAggregate(
Guid StudentId,
Guid ClassId,
Guid MajorId,
Guid CollegeId,
int CourseCount,
decimal? Gpa,
decimal? WeightedAverageScore,
decimal? AverageScore)
{
public static StudentAcademicAggregate Empty(
Guid studentId,
Guid classId,
Guid majorId,
Guid collegeId) => new(studentId, classId, majorId, collegeId, 0, null, null, null);
}
internal sealed record AcademicMetric(
decimal? Value,
AcademicRank ClassRank,
AcademicRank MajorRank,
AcademicRank CollegeRank)
{
public static AcademicMetric Create(
decimal? value,
IReadOnlyCollection<StudentAcademicAggregate> students,
Func<StudentAcademicAggregate, decimal?> selector,
StudentAcademicAggregate current) => new(
value.HasValue ? Math.Round(value.Value, 2, MidpointRounding.AwayFromZero) : null,
Rank(value, students.Where(x => x.ClassId == current.ClassId), selector),
Rank(value, students.Where(x => x.MajorId == current.MajorId), selector),
Rank(value, students.Where(x => x.CollegeId == current.CollegeId), selector));
private static AcademicRank Rank(
decimal? value,
IEnumerable<StudentAcademicAggregate> peers,
Func<StudentAcademicAggregate, decimal?> selector)
{
var values = peers.Select(selector).Where(x => x.HasValue).Select(x => x!.Value).ToList();
if (!value.HasValue) return new AcademicRank(null, values.Count);
return new AcademicRank(values.Count(x => x > value.Value) + 1, values.Count);
}
}
internal sealed record AcademicRank(int? Rank, int PeerCount);
internal sealed record StudentTermAcademicTrend(
Guid AcademicTermId,
string TermName,
int CourseCount,
decimal? Gpa,
decimal? WeightedAverageScore,
decimal? AverageScore);
Loaded 3 of 31 files, more files were not shown because too many files have changed in this diff. Show more