成绩统计图

This commit is contained in:
biss committed 2026-08-09 10:13:12 +08:00
1 parent 5f30dd6766
commit 924574256a
20 files changed
+7379 -4

No files matched your search

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
@@ -250,6 +251,13 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
// Auto-apply: update grade record // Auto-apply: update grade record
gm.GradeRecord!.TotalScore = gm.RequestedScore; gm.GradeRecord!.TotalScore = gm.RequestedScore;
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore); gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
var statisticsJob = new CourseGradeStatisticsRefreshJob
{
GradeSheetId = gm.GradeRecord.GradeSheetId
};
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await NotificationService.SendAsync( await NotificationService.SendAsync(
db, db,
+135 -1
View File
@@ -3,7 +3,9 @@ using System.Globalization;
using Jiaowu.Api.Contracts; using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/grades")] [Route("api/grades")]
public sealed class GradesController( public sealed class GradesController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{ {
private const string SheetUsers = private const string SheetUsers =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -37,6 +40,9 @@ public sealed class GradesController(
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin; SystemRoles.AcademicAdmin;
private const string StatisticsUsers =
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
[HttpGet("sheets")] [HttpGet("sheets")]
[Authorize(Roles = SheetUsers)] [Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheets( public async Task<ActionResult> GetSheets(
@@ -502,6 +508,7 @@ public sealed class GradesController(
} }
targetItem.SourceType = GradeItemSourceType.ExperimentSummary; targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow; targetItem.SourceSnapshotAt = DateTime.UtcNow;
QueueCourseStatisticsRefresh(sheet.Id);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Ok(new return Ok(new
{ {
@@ -661,6 +668,7 @@ public sealed class GradesController(
return ConflictProblem("只有审核通过的成绩单可以发布。"); return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published; sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow; sheet.PublishedAt = DateTime.UtcNow;
QueueCourseStatisticsRefresh(sheet.Id);
// The grade sheet roster is authoritative at publication time. This also // The grade sheet roster is authoritative at publication time. This also
// covers students added through approved roster corrections. // covers students added through approved roster corrections.
@@ -910,6 +918,7 @@ public sealed class GradesController(
if (errors.Count > 0) if (errors.Count > 0)
return ImportValidationProblem(errors); return ImportValidationProblem(errors);
QueueCourseStatisticsRefresh(sheet.Id);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Ok(new { updated, total = rows.Count }); return Ok(new { updated, total = rows.Count });
} }
@@ -948,6 +957,7 @@ public sealed class GradesController(
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
GradeSheetId = x.GradeSheetId,
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId, AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name, TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
x.GradeSheet.TeachingTaskId, x.GradeSheet.TeachingTaskId,
@@ -964,6 +974,121 @@ public sealed class GradesController(
return Ok(new { Student = student, Records = records }); return Ok(new { Student = student, Records = records });
} }
[HttpGet("sheets/{id:guid}/statistics")]
[Authorize(Roles = StatisticsUsers)]
public async Task<ActionResult> GetCourseStatistics(
Guid id,
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.Status,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
CourseName = x.TeachingTask.Course!.Name,
CourseCode = x.TeachingTask.Course.Code,
TermName = x.TeachingTask.AcademicTerm!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
Guid? classId = null;
Guid? majorId = null;
Guid? collegeId = null;
if (scope.IsInRole(SystemRoles.Student))
{
if (sheet.Status != GradeSheetStatus.Published)
return NotFound();
var student = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
.Select(x => new
{
x.Student!.AdministrativeClassId,
MajorId = x.Student.AdministrativeClass!.MajorId,
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
})
.FirstOrDefaultAsync(cancellationToken);
if (student is null) return Forbid();
classId = student.AdministrativeClassId;
majorId = student.MajorId;
collegeId = student.CollegeId;
}
else if (scope.Scope == DataScope.College)
{
collegeId = scope.RestrictedCollegeId;
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
await db.Courses.Where(x => x.Id == sheet.CourseId)
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
return Forbid();
}
else if (scope.IsInRole(SystemRoles.Counselor))
{
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.CounselorUserId == scope.UserId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (allowedClassIds.Count == 0) return Forbid();
// A counselor sees their classes plus the matching major/college
// benchmarks, never an unrelated class-level statistic.
classId = allowedClassIds.First();
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
.Select(x => x.MajorId).FirstAsync(cancellationToken);
collegeId = await db.Majors.Where(x => x.Id == majorId)
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
}
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
{
var source = db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == sheet.CourseId &&
x.AcademicTermId == sheet.AcademicTermId);
return await source.Select(x => new
{
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
x.Below60Count, x.From60To69Count, x.From70To79Count,
x.From80To89Count, x.From90To100Count,
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
x.CalculatedAt
}).ToListAsync(token);
}, AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics], cancellationToken);
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
{
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
x.ScopeEntityId == entityId);
return item is null ? null : new
{
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
item.CalculatedAt,
Distribution = new[]
{
new { Range = "0–59", Count = item.Below60Count },
new { Range = "60–69", Count = item.From60To69Count },
new { Range = "70–79", Count = item.From70To79Count },
new { Range = "80–89", Count = item.From80To89Count },
new { Range = "90–100", Count = item.From90To100Count }
}
};
}
return Ok(new
{
sheet.CourseName, sheet.CourseCode, sheet.TermName,
IsRefreshing = !statistics.Any(),
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
? Find(CourseGradeStatisticScope.University, null) : null
});
}
private IQueryable<TeachingTask> AccessibleTasks() private IQueryable<TeachingTask> AccessibleTasks()
{ {
var source = db.TeachingTasks.AsQueryable(); var source = db.TeachingTasks.AsQueryable();
@@ -1036,6 +1161,7 @@ public sealed class GradesController(
{ {
try try
{ {
QueueCourseStatisticsRefresh(id);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent(); return created ? Created(string.Empty, new { id }) : NoContent();
} }
@@ -1045,6 +1171,14 @@ public sealed class GradesController(
} }
} }
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
{
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
}
private ActionResult ConflictProblem(string detail) => private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails Conflict(new ProblemDetails
{ {
@@ -54,6 +54,57 @@ public sealed class GradeItemScore
public decimal? Score { get; set; } public decimal? Score { get; set; }
} }
/// <summary>
/// Persisted course-result aggregate. One course/term is materialized at each
/// organizational level so the result-analysis page never aggregates raw
/// grade records on request.
/// </summary>
public sealed class CourseGradeStatistic : EntityBase
{
public Guid CourseId { get; set; }
public Guid AcademicTermId { get; set; }
public CourseGradeStatisticScope Scope { get; set; }
public Guid? ScopeEntityId { get; set; }
public int StudentCount { get; set; }
public int PassedCount { get; set; }
public int Below60Count { get; set; }
public int From60To69Count { get; set; }
public int From70To79Count { get; set; }
public int From80To89Count { get; set; }
public int From90To100Count { get; set; }
public decimal HighestScore { get; set; }
public decimal AverageScore { get; set; }
public decimal LowestScore { get; set; }
public decimal PassRate { get; set; }
public DateTime CalculatedAt { get; set; }
}
public enum CourseGradeStatisticScope
{
AdministrativeClass = 1,
Major = 2,
College = 3,
University = 4
}
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
{
public Guid GradeSheetId { get; set; }
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
CourseGradeStatisticsRefreshJobStatus.Queued;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
}
public enum CourseGradeStatisticsRefreshJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public enum GradeSheetStatus public enum GradeSheetStatus
{ {
Draft = 1, Draft = 1,
Loaded 3 of 20 files, more files were not shown because too many files have changed in this diff. Show more