Handler.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. using Application.Abstractions.Messaging;
  2. using Application.Abstractions.Messaging.Email;
  3. using Application.Abstractions.Data;
  4. using Application.Abstractions.Forum;
  5. using Application.Common;
  6. using Domain.Entities.Forum.Comments;
  7. using Domain.Entities.Forum.ValueObject;
  8. using SharedKernel.Results;
  9. using SharedKernel.Storage;
  10. using Microsoft.EntityFrameworkCore;
  11. using System.Text.RegularExpressions;
  12. namespace Application.Features.Api.Forum.Comment.Create;
  13. public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IBoardPermissionService permissionService, IMailService mailService) : ICommandHandler<Command, Result<int>>
  14. {
  15. private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
  16. 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"];
  17. public async Task<Result<int>> Handle(Command request, CancellationToken ct)
  18. {
  19. if (string.IsNullOrWhiteSpace(request.Content))
  20. {
  21. return Result.Failure<int>(Error.Problem("Comment.ContentRequired", "내용을 입력해주세요."));
  22. }
  23. var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.PostID, ct);
  24. if (post is null)
  25. {
  26. return Result.Failure<int>(Error.NotFound("Comment.PostNotFound", "게시글을 찾을 수 없습니다."));
  27. }
  28. var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct);
  29. if (member is null)
  30. {
  31. return Result.Failure<int>(Error.NotFound("Comment.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
  32. }
  33. // 댓글 권한 확인
  34. var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(x => x.BoardID == post.BoardID, ct);
  35. var isQnABoard = boardMeta?.List.Layout == BoardLayout.QnA;
  36. if (boardMeta is not null)
  37. {
  38. // 댓글 내용 길이 검증
  39. var contentLength = request.Content.Trim().Length;
  40. if (boardMeta.Comment.MinContentLength > 0 && contentLength < boardMeta.Comment.MinContentLength)
  41. {
  42. return Result.Failure<int>(Error.Problem("Comment.MinContentLength", $"내용은 {boardMeta.Comment.MinContentLength}자 이상 작성해주세요."));
  43. }
  44. if (boardMeta.Comment.MaxContentLength > 0 && contentLength > boardMeta.Comment.MaxContentLength)
  45. {
  46. return Result.Failure<int>(Error.Problem("Comment.MaxContentLength", $"내용은 {boardMeta.Comment.MaxContentLength}자 이내로 작성해주세요."));
  47. }
  48. // 1:1 문의 게시판: 최고관리자·매니저만 댓글/대댓글 작성 가능
  49. if (isQnABoard)
  50. {
  51. if (!member.IsAdmin)
  52. {
  53. var mgr = await permissionService.GetBoardManagerAsync(post.BoardID, member.ID, ct);
  54. if (mgr is null)
  55. {
  56. return Result.Failure<int>(Error.Forbidden("Comment.QnAPermissionDenied", "1:1 문의 게시판은 관리자만 답변할 수 있습니다."));
  57. }
  58. }
  59. }
  60. else
  61. {
  62. var requiredPermission = request.ParentID.HasValue ? boardMeta.Permission.ReplyWrite : boardMeta.Permission.CommentWrite;
  63. var requiredPermissionName = request.ParentID.HasValue ? "답글 작성 권한" : "댓글 작성 권한";
  64. if (!await permissionService.HasPermissionAsync(member, post.BoardID, requiredPermission, ct))
  65. {
  66. return Result.Failure<int>(Error.Forbidden("Comment.PermissionDenied", $"{requiredPermissionName}이 없습니다."));
  67. }
  68. }
  69. // 파일 업로드 권한 확인
  70. if ((request.Images is { Count: > 0 } || request.Files is { Count: > 0 }) &&
  71. !await permissionService.HasPermissionAsync(member, post.BoardID, boardMeta.Permission.FileUpload, ct)
  72. ) {
  73. return Result.Failure<int>(Error.Forbidden("Comment.FileUploadDenied", "파일 업로드 권한이 없습니다."));
  74. }
  75. }
  76. sbyte depth = 0;
  77. var isReply = false;
  78. // 부모 댓글이 있을 경우 답글로 처리
  79. if (request.ParentID.HasValue)
  80. {
  81. var parent = await db.Comment.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ParentID.Value, ct);
  82. if (parent is null)
  83. {
  84. return Result.Failure<int>(Error.NotFound("Comment.ParentNotFound", "상위 댓글을 찾을 수 없습니다."));
  85. }
  86. depth = (sbyte)(parent.Depth + 1);
  87. isReply = true;
  88. // 부모 댓글 답글 수 증가
  89. var parentEntity = await db.Comment.FirstOrDefaultAsync(x => x.ID == request.ParentID.Value, ct);
  90. if (parentEntity is not null)
  91. {
  92. parentEntity.Replies++;
  93. }
  94. }
  95. var comment = new Domain.Entities.Forum.Comments.Comment
  96. {
  97. BoardID = post.BoardID,
  98. PostID = request.PostID,
  99. MemberID = request.MemberID,
  100. ParentID = request.ParentID,
  101. Depth = depth,
  102. Content = request.Content.Trim(),
  103. IsReply = isReply,
  104. IsSecret = request.IsSecret,
  105. Name = member.Name,
  106. SID = member.SID,
  107. Email = member.Email
  108. };
  109. // Mention 처리
  110. if (!string.IsNullOrWhiteSpace(request.Mention))
  111. {
  112. var rawHandle = request.Mention.Trim();
  113. var handleValue = rawHandle.TrimStart('@').Trim();
  114. var mentionMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.Name == handleValue || x.SID == handleValue, ct);
  115. if (mentionMember is not null)
  116. {
  117. comment.MentionMemberID = mentionMember.ID;
  118. comment.CommentMention = new CommentMention
  119. {
  120. BoardID = post.BoardID,
  121. PostID = request.PostID,
  122. MemberID = mentionMember.ID,
  123. RawHandle = rawHandle
  124. };
  125. }
  126. }
  127. await db.Comment.AddAsync(comment, ct);
  128. // Post 댓글 카운트 증가
  129. post.Comments++;
  130. post.LastCommentUpdatedAt = DateTime.UtcNow;
  131. // 1:1 문의 게시판: 댓글 작성 시 답변 완료 표시
  132. if (isQnABoard && !request.ParentID.HasValue)
  133. {
  134. post.IsReply = true;
  135. }
  136. // Board 댓글 카운트 증가
  137. var board = await db.Board.FirstOrDefaultAsync(x => x.ID == post.BoardID, ct);
  138. if (board is not null)
  139. {
  140. board.Comments++;
  141. board.UpdatedAt = DateTime.UtcNow;
  142. }
  143. // MemberStats 댓글 수 증가
  144. var memberStats = await db.MemberStats.FirstOrDefaultAsync(x => x.MemberID == request.MemberID, ct);
  145. if (memberStats is not null)
  146. {
  147. memberStats.CommentCount++;
  148. }
  149. await db.SaveChangesAsync(ct);
  150. // 파일 처리 (comment.ID 확보 후)
  151. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Comment, comment.ID);
  152. // 이미지 처리
  153. if (request.Images is { Count: > 0 })
  154. {
  155. byte imageCount = 0;
  156. var savedImageUrls = new List<string>();
  157. foreach (var image in request.Images)
  158. {
  159. var result = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExtensions, ct);
  160. if (result is not null)
  161. {
  162. var ext = Path.GetExtension(image.FileName).ToLowerInvariant();
  163. await db.CommentImage.AddAsync(new CommentImage
  164. {
  165. BoardID = post.BoardID,
  166. PostID = request.PostID,
  167. CommentID = comment.ID,
  168. FileName = image.FileName,
  169. HashedName = result.FileName,
  170. Path = uploadPath.ToRelativePath(),
  171. Url = result.Url,
  172. Extension = ext,
  173. ContentType = image.ContentType,
  174. Size = result.Size,
  175. Width = result.Width,
  176. Height = result.Height
  177. }, ct);
  178. savedImageUrls.Add(result.Url);
  179. imageCount++;
  180. }
  181. }
  182. // content의 data:image/ 플레이스홀더를 실제 이미지 경로로 순서대로 치환
  183. var content = comment.Content;
  184. foreach (var url in savedImageUrls)
  185. {
  186. var idx = content.IndexOf("data:image/", StringComparison.Ordinal);
  187. if (idx >= 0)
  188. {
  189. var endIdx = content.IndexOfAny(['"', '\''], idx);
  190. if (endIdx > idx)
  191. {
  192. content = string.Concat(content.AsSpan(0, idx), url, content.AsSpan(endIdx));
  193. }
  194. }
  195. }
  196. comment.Content = content;
  197. comment.Images = imageCount;
  198. }
  199. // 파일 처리
  200. if (request.Files is { Count: > 0 })
  201. {
  202. // content HTML에서 file-embed의 data-uuid와 data-name 매핑 추출
  203. var fileUuidMap = new List<(string Name, Guid Uuid)>();
  204. var uuidMatches = Regex.Matches(comment.Content, @"data-uuid=""([^""]+)""\s+data-name=""([^""]+)""");
  205. foreach (Match match in uuidMatches)
  206. {
  207. if (Guid.TryParse(match.Groups[1].Value, out var uuid))
  208. {
  209. fileUuidMap.Add((match.Groups[2].Value, uuid));
  210. }
  211. }
  212. byte fileCount = 0;
  213. foreach (var file in request.Files)
  214. {
  215. var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
  216. if (result is not null)
  217. {
  218. var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
  219. // content의 file-embed에서 매칭되는 UUID 사용, 없으면 새로 생성
  220. var mapIdx = fileUuidMap.FindIndex(x => x.Name == file.FileName);
  221. Guid fileUuid;
  222. if (mapIdx >= 0)
  223. {
  224. fileUuid = fileUuidMap[mapIdx].Uuid;
  225. fileUuidMap.RemoveAt(mapIdx);
  226. }
  227. else
  228. {
  229. fileUuid = Guid.NewGuid();
  230. }
  231. await db.CommentFile.AddAsync(new Domain.Entities.Forum.Comments.CommentFile
  232. {
  233. BoardID = post.BoardID,
  234. PostID = request.PostID,
  235. CommentID = comment.ID,
  236. UUID = fileUuid,
  237. FileName = file.FileName,
  238. HashedName = result.FileName,
  239. Path = uploadPath.ToRelativePath(),
  240. Url = result.Url,
  241. Extension = ext,
  242. ContentType = file.ContentType,
  243. Size = result.Size
  244. }, ct);
  245. fileCount++;
  246. }
  247. }
  248. comment.Files = fileCount;
  249. }
  250. // 미디어 처리
  251. if (request.Medias is { Count: > 0 })
  252. {
  253. byte mediaCount = 0;
  254. foreach (var mediaUrl in request.Medias)
  255. {
  256. if (!string.IsNullOrWhiteSpace(mediaUrl))
  257. {
  258. await db.CommentMedia.AddAsync(new CommentMedia
  259. {
  260. BoardID = post.BoardID,
  261. PostID = request.PostID,
  262. CommentID = comment.ID,
  263. Url = mediaUrl.Trim()
  264. }, ct);
  265. mediaCount++;
  266. }
  267. }
  268. comment.Medias = mediaCount;
  269. }
  270. await db.SaveChangesAsync(ct);
  271. // 이메일 알림 발송
  272. if (boardMeta?.NotifyTemplate is not null && boardMeta.Notify is not null)
  273. {
  274. try
  275. {
  276. var notify = boardMeta.Notify;
  277. var template = boardMeta.NotifyTemplate;
  278. var notifyFlags = request.ParentID.HasValue ? notify.ReplyWriteNotifyEnum : notify.CommentWriteNotifyEnum;
  279. var emailSubject = request.ParentID.HasValue ? template.ReplyWriteEmailNotifySubject : template.CommentWriteEmailNotifySubject;
  280. var emailContent = request.ParentID.HasValue ? template.ReplyWriteEmailNotifyContent : template.CommentWriteEmailNotifyContent;
  281. if (notifyFlags != 0 && !string.IsNullOrWhiteSpace(emailSubject) && !string.IsNullOrWhiteSpace(emailContent))
  282. {
  283. // 템플릿 {변수} 치환
  284. var siteName = await db.Config.AsNoTracking().Select(c => c.Basic.SiteName).FirstOrDefaultAsync(ct);
  285. var tokens = ForumNotifyRenderer.CommentTokens(siteName, board?.Name ?? string.Empty, post, comment);
  286. emailSubject = emailSubject.Render(tokens);
  287. emailContent = emailContent.Render(tokens);
  288. var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  289. // 게시글 작성자
  290. if (notifyFlags.HasFlag(BoardNotify.PostAuthor))
  291. {
  292. var postAuthor = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == post.MemberID, ct);
  293. if (postAuthor is not null && !string.IsNullOrWhiteSpace(postAuthor.Email))
  294. {
  295. recipients.Add(postAuthor.Email);
  296. }
  297. }
  298. // 부모 댓글 작성자 (대댓글일 때)
  299. if (notifyFlags.HasFlag(BoardNotify.CommentAuthor) && request.ParentID.HasValue)
  300. {
  301. var parentComment = await db.Comment.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ParentID.Value, ct);
  302. if (parentComment is not null)
  303. {
  304. var commentAuthor = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == parentComment.MemberID, ct);
  305. if (commentAuthor is not null && !string.IsNullOrWhiteSpace(commentAuthor.Email))
  306. {
  307. recipients.Add(commentAuthor.Email);
  308. }
  309. }
  310. }
  311. // 최고관리자
  312. if (notifyFlags.HasFlag(BoardNotify.Admin))
  313. {
  314. var admins = await db.Member.AsNoTracking().Where(x => x.IsAdmin).ToListAsync(ct);
  315. foreach (var admin in admins)
  316. {
  317. if (!string.IsNullOrWhiteSpace(admin.Email))
  318. {
  319. recipients.Add(admin.Email);
  320. }
  321. }
  322. }
  323. // 게시판 매니저
  324. if (notifyFlags.HasFlag(BoardNotify.Manager))
  325. {
  326. var managers = await db.BoardManager.AsNoTracking().Where(x => x.BoardID == post.BoardID).ToListAsync(ct);
  327. foreach (var mgr in managers)
  328. {
  329. var mgrMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == mgr.MemberID, ct);
  330. if (mgrMember is not null && !string.IsNullOrWhiteSpace(mgrMember.Email))
  331. {
  332. recipients.Add(mgrMember.Email);
  333. }
  334. }
  335. }
  336. // 본인에게는 발송하지 않음
  337. recipients.Remove(member.Email);
  338. foreach (var email in recipients)
  339. {
  340. await mailService.SendAsync(new SendData(email, emailSubject, emailContent), ct);
  341. }
  342. }
  343. }
  344. catch
  345. {
  346. // 이메일 발송 실패 시 댓글 등록은 성공 처리
  347. }
  348. }
  349. return comment.ID;
  350. }
  351. }