3 Commits
16 changed files with 9879 additions and 37 deletions

No files matched your search

@@ -0,0 +1,177 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/grades/sheets/{sheetId:guid}/discussion")]
public sealed class CourseDiscussionsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet("comments")]
public async Task<ActionResult> GetComments(Guid sheetId, DateTime? beforeCreatedAt,
Guid? beforeId, int pageSize = 30, CancellationToken cancellationToken = default)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue) return NotFound();
if (beforeCreatedAt.HasValue != beforeId.HasValue) return BadRequest("评论游标不完整。");
pageSize = Math.Clamp(pageSize, 1, 50);
var userId = currentUserDataScope.Current.UserId;
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 == userId || CanModerate(),
ReplyCount = db.GradeAnalyticsComments.Count(reply => reply.ParentCommentId == comment.Id),
LikeCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Like),
InsightfulCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Insightful),
AgreeCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Agree),
HoorayCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Hooray),
HeartCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Heart),
RocketCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Rocket),
EyesCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Eyes),
LaughCount = db.GradeAnalyticsCommentReactions.Count(x => x.CommentId == comment.Id && x.Type == GradeAnalyticsCommentReactionType.Laugh),
HasLiked = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Like),
HasInsightful = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Insightful),
HasAgreed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Agree),
HasHoorayed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Hooray),
HasHearted = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Heart),
HasRocketed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Rocket),
HasEyed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.Type == GradeAnalyticsCommentReactionType.Eyes),
HasLaughed = db.GradeAnalyticsCommentReactions.Any(x => x.CommentId == comment.Id && x.UserId == userId && x.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("comments")]
public async Task<ActionResult> CreateComment(Guid sheetId, CreateCourseDiscussionRequest request,
CancellationToken cancellationToken)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue) 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.Value, AuthorUserId = userId, IsAnonymous = request.IsAnonymous, Content = content });
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("comments/{commentId:guid}/replies")]
public async Task<ActionResult> GetReplies(Guid sheetId, Guid commentId, CancellationToken cancellationToken)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue) return NotFound();
var userId = currentUserDataScope.Current.UserId;
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 == userId || CanModerate() })
.ToListAsync(cancellationToken);
return Ok(new { Items = items });
}
[HttpPost("comments/{commentId:guid}/replies")]
public async Task<ActionResult> CreateReply(Guid sheetId, Guid commentId,
CreateCourseDiscussionRequest request, CancellationToken cancellationToken)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue || !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.Value, ParentCommentId = commentId, AuthorUserId = userId, IsAnonymous = request.IsAnonymous, Content = content });
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("comments/{commentId:guid}/reactions/{reaction}")]
public async Task<ActionResult> ToggleReaction(Guid sheetId, Guid commentId, string reaction,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<GradeAnalyticsCommentReactionType>(reaction, true, out var type)) return BadRequest("不支持的 reaction。");
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue || !await db.GradeAnalyticsComments.AnyAsync(x => x.Id == commentId && x.CourseId == courseId, cancellationToken)) return NotFound();
var userId = currentUserDataScope.Current.UserId;
if (userId == Guid.Empty) return Forbid();
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/{commentId:guid}")]
public async Task<ActionResult> DeleteComment(Guid sheetId, Guid commentId, CancellationToken cancellationToken)
{
var courseId = await GetAccessibleCourseIdAsync(sheetId, cancellationToken);
if (!courseId.HasValue) return NotFound();
var comment = await db.GradeAnalyticsComments.SingleOrDefaultAsync(x => x.Id == commentId && x.CourseId == courseId, cancellationToken);
if (comment is null) return NotFound();
if (comment.AuthorUserId != currentUserDataScope.Current.UserId && !CanModerate()) return Forbid();
db.GradeAnalyticsComments.Remove(comment);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private bool CanModerate() => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private async Task<Guid?> GetAccessibleCourseIdAsync(Guid sheetId, CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var sheet = await db.GradeSheets.AsNoTracking().Where(x => x.Id == sheetId)
.Select(x => new { x.Status, x.TeachingTask!.CourseId, CourseCollegeId = x.TeachingTask.Course!.CollegeId })
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return null;
if (scope.IsInRole(SystemRoles.Student))
{
if (sheet.Status != GradeSheetStatus.Published) return null;
return await db.GradeRecords.AsNoTracking().AnyAsync(x => x.GradeSheetId == sheetId && x.Student!.UserId == scope.UserId, cancellationToken)
? sheet.CourseId : null;
}
if (scope.Scope == DataScope.All) return sheet.CourseId;
if (scope.Scope == DataScope.College && scope.CanAccessCollege(sheet.CourseCollegeId)) return sheet.CourseId;
if (scope.IsInRole(SystemRoles.Teacher))
return await db.TeachingTasks.AsNoTracking().AnyAsync(x =>
x.CourseId == sheet.CourseId && x.Teachers.Any(t => t.Teacher!.UserId == scope.UserId),
cancellationToken) ? sheet.CourseId : null;
if (scope.IsInRole(SystemRoles.Counselor))
return await db.GradeRecords.AsNoTracking().AnyAsync(x => x.GradeSheetId == sheetId && x.Student!.AdministrativeClass!.CounselorUserId == scope.UserId, cancellationToken) ? sheet.CourseId : null;
return null;
}
}
public sealed record CreateCourseDiscussionRequest(string Content, bool IsAnonymous);
@@ -5,6 +5,7 @@ using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Files;
using Jiaowu.Api.Infrastructure.OfficialDocuments;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -18,6 +19,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class GradeAnalyticsController( public sealed class GradeAnalyticsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope, ICurrentUserDataScope currentUserDataScope,
OfficialDocumentOptions officialDocumentOptions,
IAppCache? cache = null) : ControllerBase IAppCache? cache = null) : ControllerBase
{ {
private const string AnalyticsUsers = private const string AnalyticsUsers =
@@ -28,6 +30,180 @@ public sealed class GradeAnalyticsController(
SystemRoles.Teacher; SystemRoles.Teacher;
private const string ScheduleManagers = private const string ScheduleManagers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string OverallAnalyticsUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader;
[HttpGet("courses/{courseId:guid}/comments")]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> ToggleReaction(Guid courseId, Guid commentId, string reaction, CancellationToken cancellationToken)
{
if (!Enum.TryParse<GradeAnalyticsCommentReactionType>(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<ActionResult> 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<bool> CanAccessCommentCourseAsync(
Guid courseId,
CancellationToken cancellationToken) =>
db.TeachingTaskGradeStatistics.AsNoTracking().AnyAsync(x =>
x.CourseId == courseId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
cancellationToken);
[HttpGet("refresh-schedule")] [HttpGet("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)] [Authorize(Roles = ScheduleManagers)]
@@ -90,6 +266,224 @@ public sealed class GradeAnalyticsController(
return NoContent(); return NoContent();
} }
[HttpGet("courses")]
[Authorize(Roles = OverallAnalyticsUsers)]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<CourseOverallAnalysisReport?> 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<CourseOverviewSourceRow> 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<CourseOverviewSourceRow> 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")] [HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses( public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId, Guid? academicTermId,
@@ -227,7 +621,8 @@ public sealed class GradeAnalyticsController(
Status = StatusCodes.Status409Conflict Status = StatusCodes.Status409Conflict
}); });
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now); var content = GradeAnalysisWordReportGenerator.Generate(
report, DateTime.Now, officialDocumentOptions.FontPath);
var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber); var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber);
return File( return File(
content, content,
@@ -522,6 +917,105 @@ public sealed class GradeAnalyticsController(
Guid CollegeId, Guid CollegeId,
string CollegeName); 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<ScoreBand> Distribution,
IReadOnlyList<CourseTermOverview> Terms,
IReadOnlyList<CourseStudentOrigin> StudentOrigins,
IReadOnlyList<CourseTeachingClassOverview> TeachingClasses);
public sealed record ScoreBand( public sealed record ScoreBand(
string Label, string Label,
decimal LowerBound, decimal LowerBound,
@@ -609,3 +1103,7 @@ public sealed record SaveGradeStatisticsRefreshScheduleRequest(
bool IsEnabled, bool IsEnabled,
int IntervalSeconds, int IntervalSeconds,
int BatchSize); int BatchSize);
public sealed record CreateGradeAnalyticsCommentRequest(
string? Content,
bool IsAnonymous);
@@ -139,6 +139,39 @@ public sealed class CourseGradeStatisticsRefreshJob : EntityBase
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
} }
/// <summary>
/// Discussion messages for the grade-analytics centre. Anonymous messages still
/// retain their author internally, so the author can remove them and academic
/// administrators can moderate them without exposing the author's identity.
/// </summary>
public sealed class GradeAnalyticsComment : EntityBase
{
public Guid CourseId { get; set; }
public Guid? ParentCommentId { get; set; }
public Guid AuthorUserId { get; set; }
public bool IsAnonymous { get; set; }
public required string Content { get; set; }
}
public sealed class GradeAnalyticsCommentReaction : EntityBase
{
public Guid CommentId { get; set; }
public Guid UserId { get; set; }
public GradeAnalyticsCommentReactionType Type { get; set; }
}
public enum GradeAnalyticsCommentReactionType
{
Like = 1,
Insightful = 2,
Agree = 3,
Hooray = 4,
Heart = 5,
Rocket = 6,
Eyes = 7,
Laugh = 8
}
/// <summary> /// <summary>
/// Materialized all-term academic metrics for one student. Rankings are derived /// Materialized all-term academic metrics for one student. Rankings are derived
/// from this compact table, never by scanning raw grade records on a request. /// from this compact table, never by scanning raw grade records on a request.
Loaded 3 of 16 files, more files were not shown because too many files have changed in this diff. Show more