Handler.cs 17 KB

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