Handler.cs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. using Application.Abstractions.Messaging;
  2. using Application.Abstractions.Messaging.Email;
  3. using Application.Abstractions.Data;
  4. using Application.Common;
  5. using Domain.Entities.Forum.ValueObject;
  6. using SharedKernel.Results;
  7. using SharedKernel.Storage;
  8. using Microsoft.EntityFrameworkCore;
  9. using System.Text.RegularExpressions;
  10. namespace Application.Features.Admin.Forum.Comment.CreateAnswer;
  11. /// <summary>
  12. /// 1:1 문의(QnA 게시판) 게시글에 관리자 답변(root 댓글)을 등록한다.
  13. /// 사용자단과 동일하게 답변 = root Comment, 등록 시 post.IsReply = true 로 답변완료 처리.
  14. /// </summary>
  15. public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IMailService mailService) : ICommandHandler<Command, Result<int>>
  16. {
  17. 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"];
  18. private static readonly Regex ImgBase64Regex = new(@"<img\b[^>]*?\bsrc\s*=\s*""(?<src>data:image/(?<ext>[a-zA-Z0-9.+-]+);base64,(?<data>[a-zA-Z0-9+/=\s]+))""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
  19. public async Task<Result<int>> Handle(Command request, CancellationToken ct)
  20. {
  21. if (string.IsNullOrWhiteSpace(request.Content))
  22. {
  23. return Result.Failure<int>(Error.Problem("Answer.ContentRequired", "답변 내용을 입력해주세요."));
  24. }
  25. var post = await db.Post.FirstOrDefaultAsync(c => c.ID == request.PostID, ct);
  26. if (post is null)
  27. {
  28. return Result.Failure<int>(Error.NotFound("Answer.PostNotFound", "게시글을 찾을 수 없습니다."));
  29. }
  30. var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(c => c.BoardID == post.BoardID, ct);
  31. if (boardMeta?.List.Layout != BoardLayout.QnA)
  32. {
  33. return Result.Failure<int>(Error.Problem("Answer.NotQnA", "1:1 문의 게시판의 게시글이 아닙니다."));
  34. }
  35. // 답변 작성자 귀속: Identity 이메일 → Member, 없으면 최고관리자 첫 멤버
  36. var answerMember = !string.IsNullOrWhiteSpace(request.AdminEmail)
  37. ? await db.Member.AsNoTracking().FirstOrDefaultAsync(c => c.Email == request.AdminEmail, ct)
  38. : null;
  39. answerMember ??= await db.Member.AsNoTracking().Where(c => c.IsAdmin).OrderBy(c => c.ID).FirstOrDefaultAsync(ct);
  40. if (answerMember is null)
  41. {
  42. return Result.Failure<int>(Error.NotFound("Answer.AdminMemberNotFound", "답변을 귀속할 관리자 회원 계정을 찾을 수 없습니다."));
  43. }
  44. var comment = new Domain.Entities.Forum.Comments.Comment
  45. {
  46. BoardID = post.BoardID,
  47. PostID = post.ID,
  48. MemberID = answerMember.ID,
  49. Content = request.Content.Trim(),
  50. Name = answerMember.Name,
  51. SID = answerMember.SID,
  52. Email = answerMember.Email
  53. };
  54. await db.Comment.AddAsync(comment, ct);
  55. // 답변완료 처리 + 카운트
  56. post.Comments++;
  57. post.LastCommentUpdatedAt = DateTime.UtcNow;
  58. post.IsReply = true;
  59. var board = await db.Board.FirstOrDefaultAsync(c => c.ID == post.BoardID, ct);
  60. if (board is not null)
  61. {
  62. board.Comments++;
  63. board.UpdatedAt = DateTime.UtcNow;
  64. }
  65. await db.SaveChangesAsync(ct);
  66. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Comment, comment.ID);
  67. // 에디터 base64 이미지 → 파일 + CommentImage
  68. var content = comment.Content;
  69. var matches = ImgBase64Regex.Matches(content);
  70. if (matches.Count > 0)
  71. {
  72. var imageCount = 0;
  73. foreach (Match match in matches)
  74. {
  75. var ext = FileUtils.NormalizeExtension(match.Groups["ext"].Value);
  76. var bytes = Convert.FromBase64String(match.Groups["data"].Value);
  77. var result = await fileStorage.SaveBytesAsync(bytes, ext, uploadPath, ct);
  78. if (result is not null)
  79. {
  80. content = content.Replace(match.Groups["src"].Value, result.Url);
  81. await db.CommentImage.AddAsync(new Domain.Entities.Forum.Comments.CommentImage
  82. {
  83. BoardID = post.BoardID,
  84. PostID = post.ID,
  85. CommentID = comment.ID,
  86. FileName = result.FileName,
  87. HashedName = result.FileName,
  88. Path = uploadPath.ToRelativePath(),
  89. Url = result.Url,
  90. Extension = ext,
  91. ContentType = $"image/{match.Groups["ext"].Value}",
  92. Size = result.Size,
  93. Width = result.Width,
  94. Height = result.Height
  95. }, ct);
  96. imageCount++;
  97. }
  98. }
  99. comment.Content = content;
  100. comment.Images = imageCount;
  101. }
  102. // 첨부파일 → CommentFile
  103. if (request.Files is { Count: > 0 })
  104. {
  105. byte fileCount = 0;
  106. foreach (var file in request.Files)
  107. {
  108. var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
  109. if (result is not null)
  110. {
  111. var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
  112. await db.CommentFile.AddAsync(new Domain.Entities.Forum.Comments.CommentFile
  113. {
  114. BoardID = post.BoardID,
  115. PostID = post.ID,
  116. CommentID = comment.ID,
  117. UUID = Guid.NewGuid(),
  118. FileName = file.FileName,
  119. HashedName = result.FileName,
  120. Path = uploadPath.ToRelativePath(),
  121. Url = result.Url,
  122. Extension = ext,
  123. ContentType = file.ContentType,
  124. Size = result.Size
  125. }, ct);
  126. fileCount++;
  127. }
  128. }
  129. comment.Files = fileCount;
  130. }
  131. await db.SaveChangesAsync(ct);
  132. // 답변 알림 메일: 게시판 '댓글 작성 시' 알림 설정에 게시글 작성자가 포함된 경우 발송
  133. if (boardMeta.Notify is not null && boardMeta.NotifyTemplate is not null)
  134. {
  135. try
  136. {
  137. var notifyFlags = boardMeta.Notify.CommentWriteNotifyEnum;
  138. var emailSubject = boardMeta.NotifyTemplate.CommentWriteEmailNotifySubject;
  139. var emailContent = boardMeta.NotifyTemplate.CommentWriteEmailNotifyContent;
  140. if (notifyFlags.HasFlag(BoardNotify.PostAuthor) && !string.IsNullOrWhiteSpace(emailSubject) && !string.IsNullOrWhiteSpace(emailContent))
  141. {
  142. var authorEmail = post.Email;
  143. if (post.MemberID.HasValue)
  144. {
  145. var author = await db.Member.AsNoTracking().FirstOrDefaultAsync(c => c.ID == post.MemberID.Value, ct);
  146. authorEmail = author?.Email ?? authorEmail;
  147. }
  148. if (!string.IsNullOrWhiteSpace(authorEmail) && !string.Equals(authorEmail, answerMember.Email, StringComparison.OrdinalIgnoreCase))
  149. {
  150. var siteName = await db.Config.AsNoTracking().Select(c => c.Basic.SiteName).FirstOrDefaultAsync(ct);
  151. var tokens = ForumNotifyRenderer.CommentTokens(siteName, board?.Name ?? string.Empty, post, comment);
  152. await mailService.SendAsync(new SendData(authorEmail, emailSubject.Render(tokens), emailContent.Render(tokens)), ct);
  153. }
  154. }
  155. }
  156. catch
  157. {
  158. // 이메일 발송 실패 시 답변 등록은 성공 처리
  159. }
  160. }
  161. return comment.ID;
  162. }
  163. }