Handler.cs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. using Application.Abstractions.Messaging;
  2. using Application.Abstractions.Data;
  3. using Domain.Entities.Forum.ValueObject;
  4. using SharedKernel.Results;
  5. using Microsoft.EntityFrameworkCore;
  6. namespace Application.Features.Api.Forum.Comment.List;
  7. public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Response>>
  8. {
  9. public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
  10. {
  11. // 1. 댓글 총 개수 (표시용: 루트+대댓글) & 루트 댓글 개수 (페이지네이션용)
  12. var total = await db.Comment.AsNoTracking().CountAsync(c => c.PostID == request.PostID && !c.IsDeleted, ct);
  13. var totalRoots = await db.Comment.AsNoTracking().CountAsync(c => c.PostID == request.PostID && !c.IsDeleted && c.ParentID == null, ct);
  14. if (totalRoots == 0)
  15. {
  16. return new Response(total, 0, []);
  17. }
  18. // 비밀글 열람 권한 판별을 위한 게시글 정보 조회
  19. var post = await db.Post.AsNoTracking().Where(p => p.ID == request.PostID).Select(p => new { p.MemberID, p.BoardID }).FirstOrDefaultAsync(ct);
  20. var canViewAllSecrets = false;
  21. if (request.MemberID.HasValue && post is not null)
  22. {
  23. var reqMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID.Value, ct);
  24. if (reqMember is not null && reqMember.IsAdmin)
  25. {
  26. canViewAllSecrets = true;
  27. }
  28. else if (reqMember is not null)
  29. {
  30. canViewAllSecrets = await db.BoardManager.AsNoTracking().AnyAsync(x => x.BoardID == post.BoardID && x.MemberID == request.MemberID.Value, ct);
  31. }
  32. }
  33. // 2. 루트 댓글 페이지네이션 + 정렬
  34. var rootQuery = db.Comment.AsNoTracking().Include(c => c.Member).ThenInclude(m => m.MemberGrade).Include(c => c.CommentMention).Where(c => c.PostID == request.PostID && !c.IsDeleted && c.ParentID == null);
  35. rootQuery = request.Sort switch
  36. {
  37. // 인기순
  38. 1 => rootQuery.OrderByDescending(c => c.Likes).ThenByDescending(c => c.ID),
  39. // 최신순
  40. _ => rootQuery.OrderByDescending(c => c.ID)
  41. };
  42. var roots = await rootQuery.Skip((request.Page - 1) * request.PerPage).Take(request.PerPage).ToListAsync(ct);
  43. // 3. 루트 댓글의 자식 댓글 일괄 로드
  44. var rootIDs = roots.Select(c => c.ID).ToHashSet();
  45. var children = await db.Comment.AsNoTracking()
  46. .Include(c => c.Member).ThenInclude(m => m.MemberGrade)
  47. .Include(c => c.CommentMention)
  48. .Where(c => c.PostID == request.PostID && !c.IsDeleted && c.ParentID != null && rootIDs.Contains(c.ParentID.Value))
  49. .OrderBy(c => c.ID)
  50. .ToListAsync(ct);
  51. var allComments = roots.Concat(children).ToList();
  52. // 4. 사용자별 상태 벌크 로드
  53. HashSet<int> likeIDs = [], dislikeIDs = [], reportedIDs = [];
  54. if (request.MemberID is int memberID)
  55. {
  56. var allCommentIds = allComments.Select(c => c.ID).ToHashSet();
  57. likeIDs = (await db.CommentReaction.AsNoTracking()
  58. .Where(x => x.PostID == request.PostID && x.MemberID == memberID && x.Reaction == Reaction.Like && allCommentIds.Contains(x.CommentID))
  59. .Select(x => x.CommentID)
  60. .ToListAsync(ct)).ToHashSet();
  61. dislikeIDs = (await db.CommentReaction.AsNoTracking()
  62. .Where(x => x.PostID == request.PostID && x.MemberID == memberID && x.Reaction == Reaction.Dislike && allCommentIds.Contains(x.CommentID))
  63. .Select(x => x.CommentID)
  64. .ToListAsync(ct)).ToHashSet();
  65. reportedIDs = (await db.CommentReport.AsNoTracking()
  66. .Where(x => x.PostID == request.PostID && x.MemberID == memberID && allCommentIds.Contains(x.CommentID))
  67. .Select(x => x.CommentID)
  68. .ToListAsync(ct)).ToHashSet();
  69. }
  70. // 5. CommentItem 매핑
  71. var itemMap = new Dictionary<int, Response.CommentItem>();
  72. var rootItems = new List<Response.CommentItem>();
  73. foreach (var c in allComments)
  74. {
  75. var writer = new Response.WriterDto(
  76. c.Member.ID,
  77. c.Member.SID,
  78. c.Member.Name,
  79. c.Member.Thumb,
  80. c.Member.Icon,
  81. c.Member.MemberGrade?.Image,
  82. c.Member.CreatedAt
  83. );
  84. Response.MentionDto? mention = c.CommentMention is not null ? new Response.MentionDto(c.CommentMention.MemberID, c.CommentMention.RawHandle) : null;
  85. // 비밀글: 본인, 게시글 작성자, 관리자/매니저만 열람 가능
  86. var content = c.Content;
  87. if (c.IsSecret && !canViewAllSecrets && request.MemberID != c.MemberID && request.MemberID != post?.MemberID)
  88. {
  89. content = "";
  90. }
  91. var item = new Response.CommentItem(
  92. c.ID,
  93. c.PostID,
  94. c.MemberID,
  95. c.ParentID,
  96. writer,
  97. mention,
  98. content,
  99. c.IsReply,
  100. c.IsSecret,
  101. c.Likes,
  102. c.Dislikes,
  103. c.Reports,
  104. c.Replies,
  105. likeIDs.Contains(c.ID),
  106. dislikeIDs.Contains(c.ID),
  107. reportedIDs.Contains(c.ID),
  108. c.CreatedAt,
  109. []
  110. );
  111. itemMap[c.ID] = item;
  112. }
  113. // 6. 트리 빌드
  114. foreach (var item in itemMap.Values)
  115. {
  116. if (item.ParentID is int parentID && itemMap.TryGetValue(parentID, out var parent))
  117. {
  118. parent.Children.Add(item);
  119. }
  120. else
  121. {
  122. rootItems.Add(item);
  123. }
  124. }
  125. return new Response(total, totalRoots, rootItems);
  126. }
  127. }