成绩统计图

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
{
@@ -54,6 +54,57 @@ public sealed class GradeItemScore
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
{
Draft = 1,
@@ -33,7 +33,8 @@ public enum BackgroundJobKind
MakeupExamAuto = 3,
ExamArrangement = 4,
ExamSignInExport = 5,
ExamPublish = 6
ExamPublish = 6,
CourseGradeStatisticsRefresh = 7
}
public enum BackgroundJobOutboxState
@@ -16,6 +16,7 @@ public sealed class BackgroundJobOptions
public int ExamArrangementConcurrency { get; set; } = 1;
public int ExamSignInExportConcurrency { get; set; } = 1;
public int ExamPublishConcurrency { get; set; } = 1;
public int CourseGradeStatisticsRefreshConcurrency { get; set; } = 1;
public string Exchange { get; set; } = "jiaowu.background-jobs";
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
public bool UseQuorumQueues { get; set; } = true;
@@ -35,6 +36,8 @@ public sealed class BackgroundJobOptions
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
BackgroundJobKind.CourseGradeStatisticsRefresh =>
CourseGradeStatisticsRefreshConcurrency,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
@@ -130,6 +130,15 @@ public sealed class BackgroundJobOutboxPublisher(
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var gradeStatisticsJobs = await db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
.Where(x =>
(x.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
x.Status == CourseGradeStatisticsRefreshJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.CourseGradeStatisticsRefresh &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var missingKeys = automaticJobs
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
@@ -143,6 +152,8 @@ public sealed class BackgroundJobOutboxPublisher(
(BackgroundJobKind.ExamSignInExport, id)))
.Concat(publishJobs2.Select(id =>
(BackgroundJobKind.ExamPublish, id)))
.Concat(gradeStatisticsJobs.Select(id =>
(BackgroundJobKind.CourseGradeStatisticsRefresh, id)))
.ToList();
foreach (var (kind, jobId) in missingKeys)
{
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore;
@@ -100,6 +101,11 @@ public sealed class BackgroundJobRunner(
.GetRequiredService<ExamPublishJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.CourseGradeStatisticsRefresh:
await scope.ServiceProvider
.GetRequiredService<CourseGradeStatisticsRefreshJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
default:
throw new InvalidOperationException(
$"Unsupported background job kind '{message.JobKind}'.");
@@ -308,6 +314,19 @@ public sealed class BackgroundJobRunner(
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.CourseGradeStatisticsRefresh:
await db.CourseGradeStatisticsRefreshJobs
.Where(x => x.Id == message.JobId &&
x.Status != CourseGradeStatisticsRefreshJobStatus.Succeeded &&
x.Status != CourseGradeStatisticsRefreshJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.Status,
CourseGradeStatisticsRefreshJobStatus.Failed)
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
default:
throw new ArgumentOutOfRangeException(
nameof(message.JobKind),
@@ -319,7 +319,8 @@ internal static class RabbitMqBackgroundJobTopology
BackgroundJobKind.MakeupExamAuto,
BackgroundJobKind.ExamArrangement,
BackgroundJobKind.ExamSignInExport,
BackgroundJobKind.ExamPublish
BackgroundJobKind.ExamPublish,
BackgroundJobKind.CourseGradeStatisticsRefresh
];
public static async Task<IConnection> CreateConnectionAsync(
@@ -418,6 +419,7 @@ internal static class RabbitMqBackgroundJobTopology
BackgroundJobKind.ExamArrangement => "exam.arrangement",
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
BackgroundJobKind.ExamPublish => "exam.publish",
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
@@ -182,12 +182,16 @@ public static class AppCacheKeys
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
}
public static string CourseGradeStatistics(Guid gradeSheetId) =>
$"grade-statistics:sheet:{gradeSheetId:N}";
}
public static class AppCacheTags
{
public const string BaseData = "base-data";
public const string Analytics = "analytics";
public const string CourseGradeStatistics = "grade-statistics";
public const string Timetables = "timetables";
public const string TimetableOptions = "timetable:options";
@@ -0,0 +1,146 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
/// <summary>
/// Rebuilds a course/term's denormalized result statistics. The operation is
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
/// </summary>
public sealed class CourseGradeStatisticsRefreshJobProcessor(
AppDbContext db,
IAppCache cache,
ILogger<CourseGradeStatisticsRefreshJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
{
var job = await db.CourseGradeStatisticsRefreshJobs
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded)
return;
job.Status = CourseGradeStatisticsRefreshJobStatus.Running;
job.StartedAt = DateTime.UtcNow;
job.ErrorMessage = null;
await db.SaveChangesAsync(cancellationToken);
var sheetData = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == job.GradeSheetId)
.Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId })
.FirstOrDefaultAsync(cancellationToken);
if (sheetData is null)
{
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return;
}
var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId);
try
{
// Statistics shown to students are based only on formally published
// scores. This prevents an unfinished class from exposing data.
var scores = await db.GradeRecords.AsNoTracking()
.Where(x => x.TotalScore != null &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
.Select(x => new ScoreRow(
x.TotalScore!.Value,
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.MajorId,
x.Student.AdministrativeClass.Major!.CollegeId))
.ToListAsync(cancellationToken);
var now = DateTime.UtcNow;
var rebuilt = new List<CourseGradeStatistic>();
AddStatistics(CourseGradeStatisticScope.AdministrativeClass,
scores.GroupBy(x => x.ClassId), rebuilt, target, now);
AddStatistics(CourseGradeStatisticScope.Major,
scores.GroupBy(x => x.MajorId), rebuilt, target, now);
AddStatistics(CourseGradeStatisticScope.College,
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
AddUniversityStatistic(scores, rebuilt, target, now);
await db.CourseGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ExecuteDeleteAsync(cancellationToken);
if (rebuilt.Count > 0)
db.CourseGradeStatistics.AddRange(rebuilt);
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = now;
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
cancellationToken);
}
catch (Exception exception)
{
job.Status = CourseGradeStatisticsRefreshJobStatus.Failed;
job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000,
exception.GetBaseException().Message.Length)];
await db.SaveChangesAsync(CancellationToken.None);
logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId);
throw;
}
}
private static void AddStatistics(
CourseGradeStatisticScope scope,
IEnumerable<IGrouping<Guid, ScoreRow>> groups,
ICollection<CourseGradeStatistic> target,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
foreach (var group in groups)
target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt));
}
private static void AddUniversityStatistic(
IReadOnlyCollection<ScoreRow> scores,
ICollection<CourseGradeStatistic> target,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
if (scores.Count > 0)
target.Add(Create(CourseGradeStatisticScope.University, null,
scores.Select(x => x.Score), targetInfo, calculatedAt));
}
private static CourseGradeStatistic Create(
CourseGradeStatisticScope scope,
Guid? scopeEntityId,
IEnumerable<decimal> source,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
var scores = source.ToArray();
var passed = scores.Count(x => x >= 60m);
return new CourseGradeStatistic
{
CourseId = targetInfo.CourseId,
AcademicTermId = targetInfo.AcademicTermId,
Scope = scope,
ScopeEntityId = scopeEntityId,
StudentCount = scores.Length,
PassedCount = passed,
Below60Count = scores.Count(x => x < 60m),
From60To69Count = scores.Count(x => x >= 60m && x < 70m),
From70To79Count = scores.Count(x => x >= 70m && x < 80m),
From80To89Count = scores.Count(x => x >= 80m && x < 90m),
From90To100Count = scores.Count(x => x >= 90m),
HighestScore = scores.Max(),
AverageScore = Math.Round(scores.Average(), 1),
LowestScore = scores.Min(),
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
CalculatedAt = calculatedAt
};
}
private sealed record ScoreRow(decimal Score, Guid ClassId, Guid MajorId, Guid CollegeId);
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
}
@@ -66,6 +66,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<CourseGradeStatistic> CourseGradeStatistics =>
Set<CourseGradeStatistic>();
public DbSet<CourseGradeStatisticsRefreshJob> CourseGradeStatisticsRefreshJobs =>
Set<CourseGradeStatisticsRefreshJob>();
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
@@ -847,6 +851,32 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseGradeStatistic>(entity =>
{
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
entity.Property(x => x.PassRate).HasPrecision(5, 2);
entity.HasIndex(x => new
{
x.CourseId, x.AcademicTermId, x.Scope, x.ScopeEntityId
}).IsUnique().HasDatabaseName("UX_CourseGradeStatistics_Scope");
entity.HasIndex(x => new { x.AcademicTermId, x.Scope, x.ScopeEntityId });
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseGradeStatisticsRefreshJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.GradeSheetId);
entity.HasOne<GradeSheet>().WithMany().HasForeignKey(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AttendanceSheet>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -84,6 +84,10 @@ public sealed class DevelopmentSqliteMigrator(
"20260803_44_student_personal_profile";
private const string OtherExamResultsMigration =
"20260808_45_other_exam_results";
private const string CourseGradeStatisticsMigration =
"20260808_46_course_grade_statistics";
private const string CourseGradeDistributionMigration =
"20260809_47_course_grade_distribution";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -639,6 +643,14 @@ public sealed class DevelopmentSqliteMigrator(
? OtherExamResultsStatements.Skip(1)
: OtherExamResultsStatements,
cancellationToken);
await ApplyMigrationAsync(
CourseGradeStatisticsMigration,
CourseGradeStatisticsStatements,
cancellationToken);
await ApplyMigrationAsync(
CourseGradeDistributionMigration,
CourseGradeDistributionStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -2210,6 +2222,25 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamResults_StudentId_OtherExamBatchId" ON "OtherExamResults" ("StudentId", "OtherExamBatchId");"""
];
private static readonly string[] CourseGradeStatisticsStatements =
[
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatistics" PRIMARY KEY, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Scope" INTEGER NOT NULL, "ScopeEntityId" TEXT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatistics_Courses_CourseId" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "UX_CourseGradeStatistics_Scope" ON "CourseGradeStatistics" ("CourseId", "AcademicTermId", "Scope", "ScopeEntityId");""",
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId" ON "CourseGradeStatistics" ("AcademicTermId", "Scope", "ScopeEntityId");""",
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshJobs" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshJobs" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "ErrorMessage" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE);""",
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId" ON "CourseGradeStatisticsRefreshJobs" ("GradeSheetId");""",
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt" ON "CourseGradeStatisticsRefreshJobs" ("Status", "CreatedAt");"""
];
private static readonly string[] CourseGradeDistributionStatements =
[
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "Below60Count" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From60To69Count" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From70To79Count" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From80To89Count" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From90To100Count" INTEGER NOT NULL DEFAULT 0;"""
];
private static readonly string[] ApprovalTableStatements =
[
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseGradeStatistics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseGradeStatistics",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Scope = table.Column<int>(type: "int", nullable: false),
ScopeEntityId = table.Column<Guid>(type: "char(36)", nullable: true),
StudentCount = table.Column<int>(type: "int", nullable: false),
PassedCount = table.Column<int>(type: "int", nullable: false),
Below60Count = table.Column<int>(type: "int", nullable: false),
From60To69Count = table.Column<int>(type: "int", nullable: false),
From70To79Count = table.Column<int>(type: "int", nullable: false),
From80To89Count = table.Column<int>(type: "int", nullable: false),
From90To100Count = table.Column<int>(type: "int", nullable: false),
HighestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
AverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
PassRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseGradeStatistics", x => x.Id);
table.ForeignKey(
name: "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CourseGradeStatistics_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseGradeStatisticsRefreshJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, 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_CourseGradeStatisticsRefreshJobs", x => x.Id);
table.ForeignKey(
name: "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId",
column: x => x.GradeSheetId,
principalTable: "GradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId",
table: "CourseGradeStatistics",
columns: new[] { "AcademicTermId", "Scope", "ScopeEntityId" });
migrationBuilder.CreateIndex(
name: "UX_CourseGradeStatistics_Scope",
table: "CourseGradeStatistics",
columns: new[] { "CourseId", "AcademicTermId", "Scope", "ScopeEntityId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId",
table: "CourseGradeStatisticsRefreshJobs",
column: "GradeSheetId");
migrationBuilder.CreateIndex(
name: "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt",
table: "CourseGradeStatisticsRefreshJobs",
columns: new[] { "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CourseGradeStatistics");
migrationBuilder.DropTable(
name: "CourseGradeStatisticsRefreshJobs");
}
}
}
@@ -983,6 +983,118 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CourseExemptions");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<decimal>("AverageScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("HighestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("LowestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("PassRate")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<int>("PassedCount")
.HasColumnType("int");
b.Property<int>("Below60Count")
.HasColumnType("int");
b.Property<int>("From60To69Count")
.HasColumnType("int");
b.Property<int>("From70To79Count")
.HasColumnType("int");
b.Property<int>("From80To89Count")
.HasColumnType("int");
b.Property<int>("From90To100Count")
.HasColumnType("int");
b.Property<int>("Scope")
.HasColumnType("int");
b.Property<Guid?>("ScopeEntityId")
.HasColumnType("char(36)");
b.Property<int>("StudentCount")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "Scope", "ScopeEntityId");
b.HasIndex("CourseId", "AcademicTermId", "Scope", "ScopeEntityId")
.IsUnique()
.HasDatabaseName("UX_CourseGradeStatistics_Scope");
b.ToTable("CourseGradeStatistics");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<Guid>("GradeSheetId")
.HasColumnType("char(36)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GradeSheetId");
b.HasIndex("Status", "CreatedAt");
b.ToTable("CourseGradeStatisticsRefreshJobs");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{
b.Property<Guid>("Id")
@@ -4871,6 +4983,30 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", null)
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", null)
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshJob", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", null)
.WithMany()
.HasForeignKey("GradeSheetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
+3
View File
@@ -2,6 +2,7 @@ using System.Text;
using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Configuration;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
@@ -223,6 +224,7 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
backgroundJobOptions.CourseGradeStatisticsRefreshConcurrency is < 1 or > 16 ||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
@@ -425,6 +427,7 @@ builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services.AddScoped<ExamArrangementJobProcessor>();
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
builder.Services.AddScoped<ExamPublishJobProcessor>();
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>();
+1
View File
@@ -56,6 +56,7 @@
"SchedulePublishConcurrency": 1,
"MakeupExamAutoConcurrency": 1,
"ExamArrangementConcurrency": 1,
"CourseGradeStatisticsRefreshConcurrency": 1,
"Exchange": "jiaowu.background-jobs",
"QueuePrefix": "jiaowu.background-jobs",
"UseQuorumQueues": true,