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.Files; using Jiaowu.Api.Infrastructure.OfficialDocuments; 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, OfficialDocumentOptions officialDocumentOptions, 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; private const string OverallAnalyticsUsers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Leader; [HttpGet("courses/{courseId:guid}/comments")] public async Task GetComments( Guid courseId, DateTime? beforeCreatedAt, Guid? beforeId, int pageSize = 30, CancellationToken cancellationToken = default) { if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound(); if (beforeCreatedAt.HasValue != beforeId.HasValue) return BadRequest("评论游标不完整。"); pageSize = Math.Clamp(pageSize, 1, 50); var currentUserId = currentUserDataScope.Current.UserId; var canModerate = CanModerateComments(); var source = db.GradeAnalyticsComments.AsNoTracking() .Where(x => x.CourseId == courseId && x.ParentCommentId == null); if (beforeCreatedAt.HasValue) { var cursorAt = beforeCreatedAt.Value; var cursorId = beforeId!.Value; source = source.Where(x => x.CreatedAt < cursorAt || x.CreatedAt == cursorAt && x.Id.CompareTo(cursorId) < 0); } var rows = await source .OrderByDescending(x => x.CreatedAt) .ThenByDescending(x => x.Id) .Take(pageSize + 1) .Join(db.Users.AsNoTracking(), comment => comment.AuthorUserId, user => user.Id, (comment, user) => new { comment.Id, comment.Content, comment.IsAnonymous, comment.CreatedAt, AuthorName = comment.IsAnonymous ? "匿名用户" : user.DisplayName, CanDelete = comment.AuthorUserId == currentUserId || canModerate, ReplyCount = db.GradeAnalyticsComments.Count(reply => reply.ParentCommentId == comment.Id), LikeCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Like), InsightfulCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Insightful), AgreeCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Agree), HoorayCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Hooray), HeartCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Heart), RocketCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Rocket), EyesCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Eyes), LaughCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Laugh), HasLiked = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Like), HasInsightful = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Insightful), HasAgreed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Agree), HasHoorayed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Hooray), HasHearted = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Heart), HasRocketed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Rocket), HasEyed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Eyes), HasLaughed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Laugh) }) .ToListAsync(cancellationToken); var hasMore = rows.Count > pageSize; var items = rows.Take(pageSize).ToArray(); var last = items.LastOrDefault(); return Ok(new { Items = items, HasMore = hasMore, NextCursor = hasMore && last is not null ? new { last.CreatedAt, last.Id } : null }); } [HttpPost("courses/{courseId:guid}/comments")] public async Task CreateComment( Guid courseId, CreateGradeAnalyticsCommentRequest request, CancellationToken cancellationToken) { if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound(); var content = request.Content?.Trim(); if (string.IsNullOrWhiteSpace(content)) return ValidationProblem("评论内容不能为空。"); if (content.Length > 1000) return ValidationProblem("评论不能超过 1000 个字符。"); var userId = currentUserDataScope.Current.UserId; if (userId == Guid.Empty) return Forbid(); var comment = new GradeAnalyticsComment { CourseId = courseId, AuthorUserId = userId, IsAnonymous = request.IsAnonymous, Content = content }; db.GradeAnalyticsComments.Add(comment); await db.SaveChangesAsync(cancellationToken); return CreatedAtAction(nameof(GetComments), new { courseId }); } [HttpGet("courses/{courseId:guid}/comments/{commentId:guid}/replies")] public async Task GetReplies(Guid courseId, Guid commentId, CancellationToken cancellationToken) { if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound(); var currentUserId = currentUserDataScope.Current.UserId; var canModerate = CanModerateComments(); var items = await db.GradeAnalyticsComments.AsNoTracking() .Where(x => x.CourseId == courseId && x.ParentCommentId == commentId) .OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(100) .Join(db.Users.AsNoTracking(), comment => comment.AuthorUserId, user => user.Id, (comment, user) => new { comment.Id, comment.Content, comment.IsAnonymous, comment.CreatedAt, AuthorName = comment.IsAnonymous ? "匿名用户" : user.DisplayName, CanDelete = comment.AuthorUserId == currentUserId || canModerate }) .ToListAsync(cancellationToken); return Ok(new { Items = items }); } [HttpPost("courses/{courseId:guid}/comments/{commentId:guid}/replies")] public async Task CreateReply(Guid courseId, Guid commentId, CreateGradeAnalyticsCommentRequest request, CancellationToken cancellationToken) { if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound(); if (!await db.GradeAnalyticsComments.AnyAsync(x => x.Id == commentId && x.CourseId == courseId && x.ParentCommentId == null, cancellationToken)) return NotFound(); var content = request.Content?.Trim(); if (string.IsNullOrWhiteSpace(content) || content.Length > 1000) return ValidationProblem("回复内容应为 1 到 1000 个字符。"); var userId = currentUserDataScope.Current.UserId; if (userId == Guid.Empty) return Forbid(); db.GradeAnalyticsComments.Add(new GradeAnalyticsComment { CourseId = courseId, ParentCommentId = commentId, AuthorUserId = userId, IsAnonymous = request.IsAnonymous, Content = content }); await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("courses/{courseId:guid}/comments/{commentId:guid}/reactions/{reaction}")] public async Task ToggleReaction(Guid courseId, Guid commentId, string reaction, CancellationToken cancellationToken) { if (!Enum.TryParse(reaction, true, out var type)) return BadRequest("不支持的 reaction。"); if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound(); if (!await db.GradeAnalyticsComments.AnyAsync(x => x.Id == commentId && x.CourseId == courseId, cancellationToken)) return NotFound(); var userId = currentUserDataScope.Current.UserId; var existing = await db.GradeAnalyticsCommentReactions.SingleOrDefaultAsync(x => x.CommentId == commentId && x.UserId == userId && x.Type == type, cancellationToken); if (existing is null) db.GradeAnalyticsCommentReactions.Add(new GradeAnalyticsCommentReaction { CommentId = commentId, UserId = userId, Type = type }); else db.GradeAnalyticsCommentReactions.Remove(existing); await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpDelete("comments/{id:guid}")] public async Task DeleteComment(Guid id, CancellationToken cancellationToken) { var comment = await db.GradeAnalyticsComments .SingleOrDefaultAsync(x => x.Id == id, cancellationToken); if (comment is null) return NotFound(); if (comment.AuthorUserId != currentUserDataScope.Current.UserId && !CanModerateComments()) return Forbid(); db.GradeAnalyticsComments.Remove(comment); await db.SaveChangesAsync(cancellationToken); return NoContent(); } private bool CanModerateComments() => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); private Task CanAccessCommentCourseAsync( Guid courseId, CancellationToken cancellationToken) => db.TeachingTaskGradeStatistics.AsNoTracking().AnyAsync(x => x.CourseId == courseId && VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId), cancellationToken); [HttpGet("refresh-schedule")] [Authorize(Roles = ScheduleManagers)] public async Task 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 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("courses")] [Authorize(Roles = OverallAnalyticsUsers)] public async Task GetCourseOverviews( Guid? academicTermId, string? keyword, Guid? collegeId = null, bool? riskOnly = null, 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.Value); if (collegeId.HasValue) source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value); if (keyword is not null) source = source.Where(x => x.TeachingTask!.Course!.Code.Contains(keyword) || x.TeachingTask.Course.Name.Contains(keyword)); var rows = await source.Select(x => new CourseOverviewSourceRow( x.CourseId, x.TeachingTask!.Course!.Code, x.TeachingTask.Course.Name, x.TeachingTask.Course.CollegeId, x.TeachingTask.Course.College!.Name, x.AcademicTermId, x.StudentCount, x.PassedCount, x.ExcellentCount, x.HighestScore, x.AverageScore, x.LowestScore, x.CalculatedAt)) .ToListAsync(cancellationToken); var overviews = rows.GroupBy(x => new { x.CourseId, x.CourseCode, x.CourseName, x.CollegeId, x.CollegeName }) .Select(group => BuildCourseOverview(group.Key.CourseId, group.Key.CourseCode, group.Key.CourseName, group.Key.CollegeId, group.Key.CollegeName, group)) .Where(x => riskOnly != true || x.PassRate < 60m || x.AverageScore < 60m) .OrderBy(x => x.PassRate < 60m || x.AverageScore < 60m ? 0 : 1) .ThenBy(x => x.CourseCode) .ToArray(); var total = overviews.Length; var items = overviews.Skip((page - 1) * pageSize).Take(pageSize).ToArray(); return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize }); } [HttpGet("courses/{courseId:guid}")] [Authorize(Roles = OverallAnalyticsUsers)] public async Task GetCourseOverview( Guid courseId, Guid? academicTermId, CancellationToken cancellationToken) { var report = await BuildCourseOverallAnalysisReportAsync( courseId, academicTermId, cancellationToken); return report is null ? NotFound() : Ok(report); } [HttpGet("courses/{courseId:guid}/report.docx")] [Authorize(Roles = OverallAnalyticsUsers)] public async Task ExportCourseOverallAnalysisReport( Guid courseId, Guid? academicTermId, CancellationToken cancellationToken) { var report = await BuildCourseOverallAnalysisReportAsync( courseId, academicTermId, cancellationToken); if (report is null) return NotFound(); var content = GradeAnalysisWordReportGenerator.Generate( report, DateTime.Now, officialDocumentOptions.FontPath); var fileName = DownloadFileName.Export("科目总体成绩分析报告", ".docx", report.Summary.CourseCode, academicTermId.HasValue ? "指定学期" : "全部学期"); return File(content, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", fileName); } private async Task BuildCourseOverallAnalysisReportAsync( Guid courseId, Guid? academicTermId, CancellationToken cancellationToken) { var source = db.TeachingTaskGradeStatistics.AsNoTracking() .Where(x => x.CourseId == courseId && VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId)); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId.Value); var rows = await source.Select(x => new CourseDetailSourceRow( x.GradeSheetId, x.TeachingTaskId, x.CourseId, x.TeachingTask!.Course!.Code, x.TeachingTask.Course.Name, x.TeachingTask.Course.CollegeId, x.TeachingTask.Course.College!.Name, x.AcademicTermId, x.TeachingTask.AcademicTerm!.Name, x.TeachingTask.AcademicTerm.StartDate, x.TeachingTask.TaskNumber, x.TeachingTask.Name, x.TeachingTask.Teachers.OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name).ToArray(), 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())) .ToListAsync(cancellationToken); if (rows.Count == 0) return null; var first = rows[0]; var summary = BuildCourseOverview(first.CourseId, first.CourseCode, first.CourseName, first.CollegeId, first.CollegeName, rows.Select(x => new CourseOverviewSourceRow( x.CourseId, x.CourseCode, x.CourseName, x.CollegeId, x.CollegeName, x.AcademicTermId, x.StudentCount, x.PassedCount, x.ExcellentCount, x.HighestScore, x.AverageScore, x.LowestScore, x.CalculatedAt))); var termIds = rows.Select(x => x.AcademicTermId).Distinct().ToArray(); var originRows = await db.CourseGradeStatistics.AsNoTracking() .Where(x => x.CourseId == courseId && termIds.Contains(x.AcademicTermId) && x.Scope == CourseGradeStatisticScope.College) .Join(db.Colleges.AsNoTracking(), x => x.ScopeEntityId, x => x.Id, (statistic, college) => new { college.Name, statistic.StudentCount, statistic.PassedCount, statistic.From90To100Count, statistic.AverageScore }) .ToListAsync(cancellationToken); var distribution = rows.SelectMany(x => x.ScoreBands) .GroupBy(x => new { x.Label, x.LowerBound, x.UpperBound }) .OrderBy(x => x.Key.LowerBound) .Select(x => new ScoreBand(x.Key.Label, x.Key.LowerBound, x.Key.UpperBound, x.Sum(item => item.StudentCount))).ToArray(); var terms = rows.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate }) .OrderBy(x => x.Key.StartDate) .Select(x => BuildCourseTermOverview(x.Key.AcademicTermId, x.Key.TermName, x.Select(item => new CourseOverviewSourceRow(item.CourseId, item.CourseCode, item.CourseName, item.CollegeId, item.CollegeName, item.AcademicTermId, item.StudentCount, item.PassedCount, item.ExcellentCount, item.HighestScore, item.AverageScore, item.LowestScore, item.CalculatedAt)))) .ToArray(); var studentOrigins = originRows.GroupBy(x => x.Name) .Select(x => new CourseStudentOrigin( x.Key, x.Sum(item => item.StudentCount), WeightedAverage(x.Select(item => (item.AverageScore, item.StudentCount))), Percentage(x.Sum(item => item.PassedCount), x.Sum(item => item.StudentCount)), Percentage(x.Sum(item => item.From90To100Count), x.Sum(item => item.StudentCount)))) .OrderByDescending(x => x.StudentCount).ToArray(); var teachingClasses = rows.OrderByDescending(x => x.AverageScore) .Select(x => new CourseTeachingClassOverview(x.GradeSheetId, x.TeachingTaskId, x.TermName, x.TaskNumber, x.TaskName, string.Join("、", x.TeacherNames), x.StudentCount, x.HighestScore, x.AverageScore, x.MedianScore, x.LowestScore, x.StandardDeviation, x.PassRate, x.ExcellentRate)).ToArray(); return new CourseOverallAnalysisReport(summary, distribution, terms, studentOrigins, teachingClasses); } private static CourseOverview BuildCourseOverview( Guid courseId, string courseCode, string courseName, Guid collegeId, string collegeName, IEnumerable source) { var rows = source.ToArray(); var studentCount = rows.Sum(x => x.StudentCount); return new CourseOverview(courseId, courseCode, courseName, collegeId, collegeName, rows.Select(x => x.AcademicTermId).Distinct().Count(), rows.Length, studentCount, rows.Sum(x => x.PassedCount), rows.Sum(x => x.ExcellentCount), rows.Max(x => x.HighestScore), WeightedAverage(rows.Select(x => (x.AverageScore, x.StudentCount))), rows.Min(x => x.LowestScore), Percentage(rows.Sum(x => x.PassedCount), studentCount), Percentage(rows.Sum(x => x.ExcellentCount), studentCount), rows.Max(x => x.CalculatedAt)); } private static CourseTermOverview BuildCourseTermOverview( Guid academicTermId, string termName, IEnumerable source) { var rows = source.ToArray(); var studentCount = rows.Sum(x => x.StudentCount); return new CourseTermOverview(academicTermId, termName, rows.Length, studentCount, WeightedAverage(rows.Select(x => (x.AverageScore, x.StudentCount))), Percentage(rows.Sum(x => x.PassedCount), studentCount), Percentage(rows.Sum(x => x.ExcellentCount), studentCount)); } private static decimal WeightedAverage(IEnumerable<(decimal Value, int Weight)> source) { var values = source.ToArray(); var weight = values.Sum(x => x.Weight); return weight == 0 ? 0m : Math.Round(values.Sum(x => x.Value * x.Weight) / weight, 1); } private static decimal Percentage(int numerator, int denominator) => denominator == 0 ? 0m : Math.Round((decimal)numerator / denominator * 100m, 2); [HttpGet("teaching-classes")] public async Task GetTeachingClasses( Guid? academicTermId, string? keyword, Guid? collegeId = null, string? teacherKeyword = null, bool? riskOnly = null, 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 (collegeId.HasValue) source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value); 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)); if (!string.IsNullOrWhiteSpace(teacherKeyword)) { teacherKeyword = teacherKeyword.Trim(); source = source.Where(x => x.TeachingTask!.Teachers.Any(item => item.Teacher!.Name.Contains(teacherKeyword) || item.Teacher.TeacherNumber.Contains(teacherKeyword))); } if (riskOnly == true) source = source.Where(x => x.PassRate < 60 || x.AverageScore < 60); 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 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 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, officialDocumentOptions.FontPath); var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber); return File( content, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", fileName); } [HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")] public async Task 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 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 classProfiles, IReadOnlyCollection statistics) { var profiles = classProfiles.ToArray(); var rows = new List(); 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 target, IEnumerable 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 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); private sealed record CourseOverviewSourceRow( Guid CourseId, string CourseCode, string CourseName, Guid CollegeId, string CollegeName, Guid AcademicTermId, int StudentCount, int PassedCount, int ExcellentCount, decimal HighestScore, decimal AverageScore, decimal LowestScore, DateTime CalculatedAt); private sealed record CourseDetailSourceRow( Guid GradeSheetId, Guid TeachingTaskId, Guid CourseId, string CourseCode, string CourseName, Guid CollegeId, string CollegeName, Guid AcademicTermId, string TermName, DateOnly StartDate, string TaskNumber, string TaskName, string[] TeacherNames, int StudentCount, int PassedCount, int ExcellentCount, decimal HighestScore, decimal AverageScore, decimal MedianScore, decimal LowestScore, decimal StandardDeviation, decimal PassRate, decimal ExcellentRate, DateTime CalculatedAt, ScoreBand[] ScoreBands); public sealed record CourseOverview( Guid CourseId, string CourseCode, string CourseName, Guid CollegeId, string CollegeName, int TermCount, int TeachingClassCount, int StudentCount, int PassedCount, int ExcellentCount, decimal HighestScore, decimal AverageScore, decimal LowestScore, decimal PassRate, decimal ExcellentRate, DateTime CalculatedAt); public sealed record CourseTermOverview( Guid AcademicTermId, string TermName, int TeachingClassCount, int StudentCount, decimal AverageScore, decimal PassRate, decimal ExcellentRate); public sealed record CourseStudentOrigin( string CollegeName, int StudentCount, decimal AverageScore, decimal PassRate, decimal ExcellentRate); public sealed record CourseTeachingClassOverview( Guid GradeSheetId, Guid TeachingTaskId, string TermName, string TaskNumber, string TaskName, string TeacherNames, int StudentCount, decimal HighestScore, decimal AverageScore, decimal MedianScore, decimal LowestScore, decimal StandardDeviation, decimal PassRate, decimal ExcellentRate); public sealed record CourseOverallAnalysisReport( CourseOverview Summary, IReadOnlyList Distribution, IReadOnlyList Terms, IReadOnlyList StudentOrigins, IReadOnlyList TeachingClasses); 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 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 PeerTeachingClasses, IReadOnlyList ScopeBenchmarks, IReadOnlyList History, ComparisonDelta? UniversityDelta); } public sealed record SaveGradeStatisticsRefreshScheduleRequest( bool IsEnabled, int IntervalSeconds, int BatchSize); public sealed record CreateGradeAnalyticsCommentRequest( string? Content, bool IsAnonymous);