成绩评论
This commit is contained in:
1 parent
9ba83c3dc1
commit
124c4ba033
13 files changed
+9123
-2
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);
|
||||||
@@ -36,6 +36,175 @@ public sealed class GradeAnalyticsController(
|
|||||||
SystemRoles.CollegeAdmin + "," +
|
SystemRoles.CollegeAdmin + "," +
|
||||||
SystemRoles.Leader;
|
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)]
|
||||||
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
|
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
|
||||||
@@ -934,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 13 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user