Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs
T
biss cd073885b5 “教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。
配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。
后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。
扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
2026-08-09 20:51:57 +08:00

597 lines
23 KiB
C#

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.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AnalyticsUsers)]
[Route("api/grade-analytics")]
public sealed class GradeAnalyticsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{
private const string AnalyticsUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader + "," +
SystemRoles.Teacher;
private const string ScheduleManagers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
{
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
var defaults = new CourseGradeStatisticsRefreshSetting();
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
var lastRunAt = setting?.LastRunAt;
return Ok(new
{
IsEnabled = enabled,
IntervalSeconds = intervalSeconds,
BatchSize = batchSize,
LastRunAt = lastRunAt,
NextRunAt = enabled && lastRunAt.HasValue
? lastRunAt.Value.AddSeconds(intervalSeconds)
: null as DateTime?
});
}
[HttpPut("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> SaveRefreshSchedule(
SaveGradeStatisticsRefreshScheduleRequest request,
CancellationToken cancellationToken)
{
if (request.IntervalSeconds is < 10 or > 86400)
return BadRequest(new ProblemDetails
{
Title = "刷新间隔应在 10 秒到 24 小时之间。",
Status = StatusCodes.Status400BadRequest
});
if (request.BatchSize is < 1 or > 5000)
return BadRequest(new ProblemDetails
{
Title = "单次刷新批量应在 1 到 5000 之间。",
Status = StatusCodes.Status400BadRequest
});
var setting = await db.CourseGradeStatisticsRefreshSettings
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
if (setting is null)
{
setting = new CourseGradeStatisticsRefreshSetting();
db.CourseGradeStatisticsRefreshSettings.Add(setting);
}
setting.IsEnabled = request.IsEnabled;
setting.IntervalSeconds = request.IntervalSeconds;
setting.BatchSize = request.BatchSize;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId,
string? keyword,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (keyword is not null)
source = source.Where(x =>
x.TeachingTask!.TaskNumber.Contains(keyword) ||
x.TeachingTask.Name.Contains(keyword) ||
x.TeachingTask.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword));
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
TermName = x.TeachingTask.AcademicTerm!.Name,
x.AcademicTermId,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
public async Task<ActionResult> GetTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
.Select(x => new AnalysisTarget(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
x.TeachingTask.Course!.CollegeId,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Course.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.AcademicTerm!.Name))
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
token => BuildReportAsync(sheet, token),
AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics],
cancellationToken);
return Ok(report);
}
[HttpGet("teaching-classes/{gradeSheetId:guid}/report.docx")]
public async Task<ActionResult> ExportTeachingClassAnalysisReport(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
.Select(x => new AnalysisTarget(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
x.TeachingTask.Course!.CollegeId,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Course.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.AcademicTerm!.Name))
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
token => BuildReportAsync(sheet, token),
AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics],
cancellationToken);
if (report.IsRefreshing || report.Summary is null)
return Conflict(new ProblemDetails
{
Title = "成绩统计尚未生成",
Detail = "请先重新计算当前教学班,待统计完成后再导出。",
Status = StatusCodes.Status409Conflict
});
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now);
var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx";
return File(
content,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileName);
}
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
public async Task<ActionResult> RefreshTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var exists = await db.GradeSheets.AsNoTracking()
.AnyAsync(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
cancellationToken);
if (!exists) return NotFound();
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh,
job.Id));
await db.SaveChangesAsync(cancellationToken);
return Accepted(new { job.Id });
}
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
AnalysisTarget target,
CancellationToken cancellationToken)
{
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new TeachingClassMetrics(
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt,
x.ScoreBands.OrderBy(band => band.SortOrder)
.Select(band => new ScoreBand(
band.Label,
band.LowerBound,
band.UpperBound,
band.StudentCount))
.ToArray()))
.FirstOrDefaultAsync(cancellationToken);
if (statistic is null)
return new TeachingClassAnalysisReport(
true,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
null,
[],
[],
[],
null);
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.OrderByDescending(x => x.AverageScore)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name).ToArray(),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name).ToArray(),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate
})
.ToListAsync(cancellationToken);
var peers = peerRows.Select(x => new TeachingClassComparison(
x.GradeSheetId,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
string.Join("、", x.TeacherNames),
string.Join("、", x.ClassNames),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.GradeSheetId == target.GradeSheetId)).ToArray();
var classProfiles = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new ClassProfile(
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.Name,
x.Student.AdministrativeClass.MajorId,
x.Student.AdministrativeClass.Major!.Name,
x.Student.AdministrativeClass.Major.CollegeId,
x.Student.AdministrativeClass.Major.College!.Name))
.Distinct()
.ToListAsync(cancellationToken);
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ToListAsync(cancellationToken);
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.TeachingTask!.Teachers.Any(link =>
selectedTeacherIds.Contains(link.TeacherId)))
.Select(x => new
{
x.AcademicTermId,
TermName = x.TeachingTask!.AcademicTerm!.Name,
x.TeachingTask.AcademicTerm.StartDate,
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.AverageScore
})
.ToListAsync(cancellationToken);
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.Scope == CourseGradeStatisticScope.University)
.Select(x => new
{
x.AcademicTermId,
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.Name).First(),
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.StartDate).First(),
x.StudentCount,
x.AverageScore,
x.PassRate,
ExcellentRate = x.StudentCount == 0 ? 0m :
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
})
.ToListAsync(cancellationToken);
var teacherByTerm = historicalTaskRows
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
.ToDictionary(group => group.Key.AcademicTermId, group =>
{
var count = group.Sum(x => x.StudentCount);
return new HistoricalSeriesValue(
count,
count == 0 ? 0m : Math.Round(
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
});
var history = courseHistory
.OrderBy(x => x.StartDate)
.Select(x => new HistoricalComparison(
x.AcademicTermId,
x.TermName,
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
.ToArray();
var university = scopeStatistics.FirstOrDefault(x =>
x.Scope == CourseGradeStatisticScope.University);
return new TeachingClassAnalysisReport(
false,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
statistic,
peers,
benchmarks,
history,
university is null ? null : new ComparisonDelta(
Math.Round(statistic.AverageScore - university.AverageScore, 1),
Math.Round(statistic.PassRate - university.PassRate, 2),
university.AverageScore,
university.PassRate));
}
private static ScopeBenchmark[] BuildBenchmarks(
IEnumerable<ClassProfile> classProfiles,
IReadOnlyCollection<CourseGradeStatistic> statistics)
{
var profiles = classProfiles.ToArray();
var rows = new List<ScopeBenchmark>();
foreach (var profile in profiles)
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
profile.ClassId, "行政班", profile.ClassName);
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
profile.MajorId, "专业", profile.MajorName);
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
profile.CollegeId, "学院", profile.CollegeName);
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
null, "全校", "全校同课程");
return rows.ToArray();
}
private static void AddBenchmark(
ICollection<ScopeBenchmark> target,
IEnumerable<CourseGradeStatistic> source,
CourseGradeStatisticScope scope,
Guid? entityId,
string scopeLabel,
string name)
{
var item = source.FirstOrDefault(x =>
x.Scope == scope && x.ScopeEntityId == entityId);
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
target.Add(new ScopeBenchmark(
scopeLabel,
name,
item.StudentCount,
item.HighestScore,
item.AverageScore,
item.LowestScore,
item.PassRate));
}
private IQueryable<TeachingTask> VisibleTeachingTasks()
{
var scope = currentUserDataScope.Current;
var source = db.TeachingTasks.AsQueryable();
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(link => link.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private static string SanitizeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars();
return string.Concat(value.Select(character => invalid.Contains(character) ? '_' : character));
}
private sealed record AnalysisTarget(
Guid GradeSheetId,
Guid TeachingTaskId,
Guid CourseId,
Guid AcademicTermId,
Guid CourseCollegeId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName);
private sealed record ClassProfile(
Guid ClassId,
string ClassName,
Guid MajorId,
string MajorName,
Guid CollegeId,
string CollegeName);
public sealed record ScoreBand(
string Label,
decimal LowerBound,
decimal? UpperBound,
int StudentCount);
public sealed record TeachingClassMetrics(
int StudentCount,
int PassedCount,
int ExcellentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
DateTime CalculatedAt,
IReadOnlyList<ScoreBand> ScoreBands);
public sealed record TeachingClassComparison(
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string TeacherNames,
string ClassNames,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
bool IsSelected);
public sealed record ScopeBenchmark(
string Scope,
string Name,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal LowestScore,
decimal PassRate);
public sealed record HistoricalSeriesValue(
int StudentCount,
decimal AverageScore,
decimal PassRate,
decimal ExcellentRate);
public sealed record HistoricalComparison(
Guid AcademicTermId,
string TermName,
int CourseStudentCount,
decimal CourseAverageScore,
decimal CoursePassRate,
decimal CourseExcellentRate,
HistoricalSeriesValue? Instructor);
public sealed record ComparisonDelta(
decimal AverageScoreDifference,
decimal PassRateDifference,
decimal UniversityAverageScore,
decimal UniversityPassRate);
public sealed record TeachingClassAnalysisReport(
bool IsRefreshing,
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName,
TeachingClassMetrics? Summary,
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
IReadOnlyList<HistoricalComparison> History,
ComparisonDelta? UniversityDelta);
}
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
bool IsEnabled,
int IntervalSeconds,
int BatchSize);