Handler.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. using Application.Abstractions.Messaging;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Forum;
  4. using Domain.Entities.Forum.Comments;
  5. using SharedKernel.Results;
  6. using SharedKernel.Storage;
  7. using Microsoft.EntityFrameworkCore;
  8. using System.Text.RegularExpressions;
  9. namespace Application.Features.Api.Forum.Comment.Update;
  10. public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IBoardPermissionService permissionService) : ICommandHandler<Command, Result>
  11. {
  12. private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
  13. private static readonly string[] AllowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".zip", ".rar", ".7z", ".hwp", ".hwpx", ".csv"];
  14. public async Task<Result> Handle(Command request, CancellationToken ct)
  15. {
  16. if (string.IsNullOrWhiteSpace(request.Content))
  17. {
  18. return Result.Failure(Error.Problem("Comment.ContentRequired", "내용을 입력해주세요."));
  19. }
  20. var comment = await db.Comment.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
  21. if (comment is null)
  22. {
  23. return Result.Failure(Error.NotFound("Comment.NotFound", "댓글을 찾을 수 없습니다."));
  24. }
  25. if (comment.MemberID != request.MemberID)
  26. {
  27. var reqMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct);
  28. if (reqMember is null || !reqMember.IsAdmin)
  29. {
  30. var mgr = await permissionService.GetBoardManagerAsync(comment.BoardID, request.MemberID, ct);
  31. if (mgr is null || !mgr.CanEdit)
  32. {
  33. return Result.Failure(Error.Forbidden("Comment.Forbidden", "수정 권한이 없습니다."));
  34. }
  35. }
  36. }
  37. // 댓글 내용 길이 검증 + 파일 업로드 권한 확인
  38. var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(x => x.BoardID == comment.BoardID, ct);
  39. if (boardMeta is not null)
  40. {
  41. var contentLength = request.Content.Trim().Length;
  42. if (boardMeta.Comment.MinContentLength > 0 && contentLength < boardMeta.Comment.MinContentLength)
  43. {
  44. return Result.Failure(Error.Problem("Comment.MinContentLength", $"내용은 {boardMeta.Comment.MinContentLength}자 이상 작성해주세요."));
  45. }
  46. if (boardMeta.Comment.MaxContentLength > 0 && contentLength > boardMeta.Comment.MaxContentLength)
  47. {
  48. return Result.Failure(Error.Problem("Comment.MaxContentLength", $"내용은 {boardMeta.Comment.MaxContentLength}자 이내로 작성해주세요."));
  49. }
  50. if (request.Images is { Count: > 0 } || request.Files is { Count: > 0 })
  51. {
  52. var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct);
  53. if (member is null || !await permissionService.HasPermissionAsync(member, comment.BoardID, boardMeta.Permission.FileUpload, ct))
  54. {
  55. return Result.Failure(Error.Forbidden("Comment.FileUploadDenied", "파일 업로드 권한이 없습니다."));
  56. }
  57. }
  58. }
  59. comment.Content = request.Content.Trim();
  60. comment.IsSecret = request.IsSecret;
  61. comment.UpdatedAt = DateTime.UtcNow;
  62. // Mention 처리
  63. var existingMention = await db.CommentMention.FirstOrDefaultAsync(x => x.CommentID == comment.ID, ct);
  64. if (!string.IsNullOrWhiteSpace(request.Mention))
  65. {
  66. var rawHandle = request.Mention.Trim();
  67. var handleValue = rawHandle.TrimStart('@').Trim();
  68. var mentionMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.Name == handleValue || x.SID == handleValue, ct);
  69. if (mentionMember is not null)
  70. {
  71. comment.MentionMemberID = mentionMember.ID;
  72. if (existingMention is not null)
  73. {
  74. existingMention.MemberID = mentionMember.ID;
  75. existingMention.RawHandle = rawHandle;
  76. }
  77. else
  78. {
  79. db.CommentMention.Add(new CommentMention
  80. {
  81. BoardID = comment.BoardID,
  82. PostID = comment.PostID,
  83. CommentID = comment.ID,
  84. MemberID = mentionMember.ID,
  85. RawHandle = rawHandle
  86. });
  87. }
  88. }
  89. }
  90. else
  91. {
  92. comment.MentionMemberID = null;
  93. if (existingMention is not null)
  94. {
  95. db.CommentMention.Remove(existingMention);
  96. }
  97. }
  98. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Comment, comment.ID);
  99. // 이미지 처리 (새 이미지 추가)
  100. if (request.Images is { Count: > 0 })
  101. {
  102. var savedImageUrls = new List<string>();
  103. foreach (var image in request.Images)
  104. {
  105. var result = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExtensions, ct);
  106. if (result is not null)
  107. {
  108. var ext = Path.GetExtension(image.FileName).ToLowerInvariant();
  109. await db.CommentImage.AddAsync(new CommentImage
  110. {
  111. BoardID = comment.BoardID,
  112. PostID = comment.PostID,
  113. CommentID = comment.ID,
  114. FileName = image.FileName,
  115. HashedName = result.FileName,
  116. Path = uploadPath.ToRelativePath(),
  117. Url = result.Url,
  118. Extension = ext,
  119. ContentType = image.ContentType,
  120. Size = result.Size,
  121. Width = result.Width,
  122. Height = result.Height
  123. }, ct);
  124. savedImageUrls.Add(result.Url);
  125. }
  126. }
  127. // content의 data:image/ 플레이스홀더를 실제 이미지 경로로 순서대로 치환
  128. var content = comment.Content;
  129. foreach (var url in savedImageUrls)
  130. {
  131. var idx = content.IndexOf("data:image/", StringComparison.Ordinal);
  132. if (idx >= 0)
  133. {
  134. var endIdx = content.IndexOfAny(['"', '\''], idx);
  135. if (endIdx > idx)
  136. {
  137. content = string.Concat(content.AsSpan(0, idx), url, content.AsSpan(endIdx));
  138. }
  139. }
  140. }
  141. comment.Content = content;
  142. // 카운터 재계산
  143. var imageCount = await db.CommentImage.CountAsync(x => x.CommentID == comment.ID && !x.IsDisabled, ct);
  144. comment.Images = (int)Math.Min(imageCount + savedImageUrls.Count, int.MaxValue);
  145. }
  146. // 파일 처리 (새 파일 추가)
  147. if (request.Files is { Count: > 0 })
  148. {
  149. // content HTML에서 file-embed의 data-uuid와 data-name 매핑 추출
  150. var fileUuidMap = new List<(string Name, Guid Uuid)>();
  151. var uuidMatches = Regex.Matches(comment.Content, @"data-uuid=""([^""]+)""\s+data-name=""([^""]+)""");
  152. foreach (Match match in uuidMatches)
  153. {
  154. if (Guid.TryParse(match.Groups[1].Value, out var uuid))
  155. {
  156. fileUuidMap.Add((match.Groups[2].Value, uuid));
  157. }
  158. }
  159. foreach (var file in request.Files)
  160. {
  161. var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
  162. if (result is not null)
  163. {
  164. var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
  165. // content의 file-embed에서 매칭되는 UUID 사용, 없으면 새로 생성
  166. var mapIdx = fileUuidMap.FindIndex(x => x.Name == file.FileName);
  167. Guid fileUuid;
  168. if (mapIdx >= 0)
  169. {
  170. fileUuid = fileUuidMap[mapIdx].Uuid;
  171. fileUuidMap.RemoveAt(mapIdx);
  172. }
  173. else
  174. {
  175. fileUuid = Guid.NewGuid();
  176. }
  177. await db.CommentFile.AddAsync(new Domain.Entities.Forum.Comments.CommentFile
  178. {
  179. BoardID = comment.BoardID,
  180. PostID = comment.PostID,
  181. CommentID = comment.ID,
  182. UUID = fileUuid,
  183. FileName = file.FileName,
  184. HashedName = result.FileName,
  185. Path = uploadPath.ToRelativePath(),
  186. Url = result.Url,
  187. Extension = ext,
  188. ContentType = file.ContentType,
  189. Size = result.Size
  190. }, ct);
  191. }
  192. }
  193. var fileCount = await db.CommentFile.CountAsync(x => x.CommentID == comment.ID && !x.IsDisabled, ct);
  194. comment.Files = (byte)Math.Min(fileCount + request.Files.Count, byte.MaxValue);
  195. }
  196. // 미디어 처리 (새 미디어 추가)
  197. if (request.Medias is { Count: > 0 })
  198. {
  199. foreach (var mediaUrl in request.Medias)
  200. {
  201. if (!string.IsNullOrWhiteSpace(mediaUrl))
  202. {
  203. await db.CommentMedia.AddAsync(new CommentMedia
  204. {
  205. BoardID = comment.BoardID,
  206. PostID = comment.PostID,
  207. CommentID = comment.ID,
  208. Url = mediaUrl.Trim()
  209. }, ct);
  210. }
  211. }
  212. var mediaCount = await db.CommentMedia.CountAsync(x => x.CommentID == comment.ID && !x.IsDisabled, ct);
  213. comment.Medias = (byte)Math.Min(mediaCount + request.Medias.Count(m => !string.IsNullOrWhiteSpace(m)), byte.MaxValue);
  214. }
  215. await db.SaveChangesAsync(ct);
  216. return Result.Success();
  217. }
  218. }