成绩统计图

This commit is contained in:
2026-08-09 10:13:12 +08:00 Unverified
parent 5f30dd6766
commit 924574256a
20 changed files with 7379 additions and 4 deletions
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -250,6 +251,13 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
// Auto-apply: update grade record
gm.GradeRecord!.TotalScore = 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 NotificationService.SendAsync(
db,
+135 -1
View File
@@ -3,7 +3,9 @@ using System.Globalization;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/grades")]
public sealed class GradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{
private const string SheetUsers =
SystemRoles.SuperAdmin + "," +
@@ -37,6 +40,9 @@ public sealed class GradesController(
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
private const string StatisticsUsers =
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
[HttpGet("sheets")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheets(
@@ -502,6 +508,7 @@ public sealed class GradesController(
}
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow;
QueueCourseStatisticsRefresh(sheet.Id);
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
@@ -661,6 +668,7 @@ public sealed class GradesController(
return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
QueueCourseStatisticsRefresh(sheet.Id);
// The grade sheet roster is authoritative at publication time. This also
// covers students added through approved roster corrections.
@@ -910,6 +918,7 @@ public sealed class GradesController(
if (errors.Count > 0)
return ImportValidationProblem(errors);
QueueCourseStatisticsRefresh(sheet.Id);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { updated, total = rows.Count });
}
@@ -948,6 +957,7 @@ public sealed class GradesController(
.Select(x => new
{
x.Id,
GradeSheetId = x.GradeSheetId,
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
x.GradeSheet.TeachingTaskId,
@@ -964,6 +974,121 @@ public sealed class GradesController(
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 = "059", Count = item.Below60Count },
new { Range = "6069", Count = item.From60To69Count },
new { Range = "7079", Count = item.From70To79Count },
new { Range = "8089", Count = item.From80To89Count },
new { Range = "90100", 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()
{
var source = db.TeachingTasks.AsQueryable();
@@ -1036,6 +1161,7 @@ public sealed class GradesController(
{
try
{
QueueCourseStatisticsRefresh(id);
await db.SaveChangesAsync(cancellationToken);
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) =>
Conflict(new ProblemDetails
{