总学业统计
This commit is contained in:
1 parent
f08cd94924
commit
0ec45c196e
18 files changed
+8124
-117
No files matched your search
@@ -994,6 +994,100 @@ public sealed class GradesController(
|
|||||||
return Ok(new { Student = student, Records = records });
|
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")]
|
[HttpGet("sheets/{id:guid}/statistics")]
|
||||||
[Authorize(Roles = StatisticsUsers)]
|
[Authorize(Roles = StatisticsUsers)]
|
||||||
public async Task<ActionResult> GetCourseStatistics(
|
public async Task<ActionResult> GetCourseStatistics(
|
||||||
@@ -1280,3 +1374,75 @@ public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
|
|||||||
|
|
||||||
public sealed record GradeReviewRequest(
|
public sealed record GradeReviewRequest(
|
||||||
[MaxLength(500)] string? Comment);
|
[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);
|
||||||
@@ -139,6 +139,37 @@ public sealed class CourseGradeStatisticsRefreshJob : EntityBase
|
|||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materialized all-term academic metrics for one student. Rankings are derived
|
||||||
|
/// from this compact table, never by scanning raw grade records on a request.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StudentAcademicStatistic : EntityBase
|
||||||
|
{
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public int CourseCount { get; set; }
|
||||||
|
public decimal? Gpa { get; set; }
|
||||||
|
public decimal? WeightedAverageScore { get; set; }
|
||||||
|
public decimal? AverageScore { get; set; }
|
||||||
|
public DateTime CalculatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materialized academic metrics for one student in one academic term.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StudentAcademicTermStatistic : EntityBase
|
||||||
|
{
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public AcademicTerm? AcademicTerm { get; set; }
|
||||||
|
public int CourseCount { get; set; }
|
||||||
|
public decimal? Gpa { get; set; }
|
||||||
|
public decimal? WeightedAverageScore { get; set; }
|
||||||
|
public decimal? AverageScore { get; set; }
|
||||||
|
public DateTime CalculatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public enum CourseGradeStatisticsRefreshJobStatus
|
public enum CourseGradeStatisticsRefreshJobStatus
|
||||||
{
|
{
|
||||||
Queued = 1,
|
Queued = 1,
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ public static class AppCacheKeys
|
|||||||
|
|
||||||
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
|
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
|
||||||
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
|
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
|
||||||
|
|
||||||
|
public static string StudentAcademicOverview(Guid studentId) =>
|
||||||
|
$"student-academic-overview:{studentId:N}:v1";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class AppCacheTags
|
public static class AppCacheTags
|
||||||
@@ -195,6 +198,7 @@ public static class AppCacheTags
|
|||||||
public const string BaseData = "base-data";
|
public const string BaseData = "base-data";
|
||||||
public const string Analytics = "analytics";
|
public const string Analytics = "analytics";
|
||||||
public const string CourseGradeStatistics = "grade-statistics";
|
public const string CourseGradeStatistics = "grade-statistics";
|
||||||
|
public const string StudentAcademicStatistics = "student-academic-statistics";
|
||||||
public const string Timetables = "timetables";
|
public const string Timetables = "timetables";
|
||||||
public const string TimetableOptions = "timetable:options";
|
public const string TimetableOptions = "timetable:options";
|
||||||
|
|
||||||
|
|||||||
Loaded 3 of 18 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user