using Application.Abstractions.Messaging; using Application.Abstractions.Messaging.Email; using Application.Abstractions.Data; using Application.Abstractions.Forum; using Domain.Entities.Forum.Comments; using Domain.Entities.Forum.ValueObject; using SharedKernel.Results; using SharedKernel.Storage; using Microsoft.EntityFrameworkCore; using System.Text.RegularExpressions; namespace Application.Features.Api.Forum.Comment.Create; public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IBoardPermissionService permissionService, IMailService mailService) : ICommandHandler> { private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]; 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"]; public async Task> Handle(Command request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Content)) { return Result.Failure(Error.Problem("Comment.ContentRequired", "내용을 입력해주세요.")); } var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.PostID, ct); if (post is null) { return Result.Failure(Error.NotFound("Comment.PostNotFound", "게시글을 찾을 수 없습니다.")); } var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct); if (member is null) { return Result.Failure(Error.NotFound("Comment.MemberNotFound", "회원 정보를 찾을 수 없습니다.")); } // 댓글 권한 확인 var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(x => x.BoardID == post.BoardID, ct); var isQnABoard = boardMeta?.List.Layout == BoardLayout.QnA; if (boardMeta is not null) { // 댓글 내용 길이 검증 var contentLength = request.Content.Trim().Length; if (boardMeta.Comment.MinContentLength > 0 && contentLength < boardMeta.Comment.MinContentLength) { return Result.Failure(Error.Problem("Comment.MinContentLength", $"내용은 {boardMeta.Comment.MinContentLength}자 이상 작성해주세요.")); } if (boardMeta.Comment.MaxContentLength > 0 && contentLength > boardMeta.Comment.MaxContentLength) { return Result.Failure(Error.Problem("Comment.MaxContentLength", $"내용은 {boardMeta.Comment.MaxContentLength}자 이내로 작성해주세요.")); } // 1:1 문의 게시판: 최고관리자·매니저만 댓글/대댓글 작성 가능 if (isQnABoard) { if (!member.IsAdmin) { var mgr = await permissionService.GetBoardManagerAsync(post.BoardID, member.ID, ct); if (mgr is null) { return Result.Failure(Error.Forbidden("Comment.QnAPermissionDenied", "1:1 문의 게시판은 관리자만 답변할 수 있습니다.")); } } } else { var requiredPermission = request.ParentID.HasValue ? boardMeta.Permission.ReplyWrite : boardMeta.Permission.CommentWrite; var requiredPermissionName = request.ParentID.HasValue ? "답글 작성 권한" : "댓글 작성 권한"; if (!await permissionService.HasPermissionAsync(member, post.BoardID, requiredPermission, ct)) { return Result.Failure(Error.Forbidden("Comment.PermissionDenied", $"{requiredPermissionName}이 없습니다.")); } } // 파일 업로드 권한 확인 if ((request.Images is { Count: > 0 } || request.Files is { Count: > 0 }) && !await permissionService.HasPermissionAsync(member, post.BoardID, boardMeta.Permission.FileUpload, ct) ) { return Result.Failure(Error.Forbidden("Comment.FileUploadDenied", "파일 업로드 권한이 없습니다.")); } } sbyte depth = 0; var isReply = false; // 부모 댓글이 있을 경우 답글로 처리 if (request.ParentID.HasValue) { var parent = await db.Comment.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ParentID.Value, ct); if (parent is null) { return Result.Failure(Error.NotFound("Comment.ParentNotFound", "상위 댓글을 찾을 수 없습니다.")); } depth = (sbyte)(parent.Depth + 1); isReply = true; // 부모 댓글 답글 수 증가 var parentEntity = await db.Comment.FirstOrDefaultAsync(x => x.ID == request.ParentID.Value, ct); if (parentEntity is not null) { parentEntity.Replies++; } } var comment = new Domain.Entities.Forum.Comments.Comment { BoardID = post.BoardID, PostID = request.PostID, MemberID = request.MemberID, ParentID = request.ParentID, Depth = depth, Content = request.Content.Trim(), IsReply = isReply, IsSecret = request.IsSecret, Name = member.Name, SID = member.SID, Email = member.Email }; // Mention 처리 if (!string.IsNullOrWhiteSpace(request.Mention)) { var rawHandle = request.Mention.Trim(); var handleValue = rawHandle.TrimStart('@').Trim(); var mentionMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.Name == handleValue || x.SID == handleValue, ct); if (mentionMember is not null) { comment.MentionMemberID = mentionMember.ID; comment.CommentMention = new CommentMention { BoardID = post.BoardID, PostID = request.PostID, MemberID = mentionMember.ID, RawHandle = rawHandle }; } } await db.Comment.AddAsync(comment, ct); // Post 댓글 카운트 증가 post.Comments++; post.LastCommentUpdatedAt = DateTime.UtcNow; // 1:1 문의 게시판: 댓글 작성 시 답변 완료 표시 if (isQnABoard && !request.ParentID.HasValue) { post.IsReply = true; } // Board 댓글 카운트 증가 var board = await db.Board.FirstOrDefaultAsync(x => x.ID == post.BoardID, ct); if (board is not null) { board.Comments++; board.UpdatedAt = DateTime.UtcNow; } // MemberStats 댓글 수 증가 var memberStats = await db.MemberStats.FirstOrDefaultAsync(x => x.MemberID == request.MemberID, ct); if (memberStats is not null) { memberStats.CommentCount++; } await db.SaveChangesAsync(ct); // 파일 처리 (comment.ID 확보 후) var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Comment, comment.ID); // 이미지 처리 if (request.Images is { Count: > 0 }) { byte imageCount = 0; var savedImageUrls = new List(); foreach (var image in request.Images) { var result = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExtensions, ct); if (result is not null) { var ext = Path.GetExtension(image.FileName).ToLowerInvariant(); await db.CommentImage.AddAsync(new CommentImage { BoardID = post.BoardID, PostID = request.PostID, CommentID = comment.ID, FileName = image.FileName, HashedName = result.FileName, Path = uploadPath.ToRelativePath(), Url = result.Url, Extension = ext, ContentType = image.ContentType, Size = result.Size, Width = result.Width, Height = result.Height }, ct); savedImageUrls.Add(result.Url); imageCount++; } } // content의 data:image/ 플레이스홀더를 실제 이미지 경로로 순서대로 치환 var content = comment.Content; foreach (var url in savedImageUrls) { var idx = content.IndexOf("data:image/", StringComparison.Ordinal); if (idx >= 0) { var endIdx = content.IndexOfAny(['"', '\''], idx); if (endIdx > idx) { content = string.Concat(content.AsSpan(0, idx), url, content.AsSpan(endIdx)); } } } comment.Content = content; comment.Images = imageCount; } // 파일 처리 if (request.Files is { Count: > 0 }) { // content HTML에서 file-embed의 data-uuid와 data-name 매핑 추출 var fileUuidMap = new List<(string Name, Guid Uuid)>(); var uuidMatches = Regex.Matches(comment.Content, @"data-uuid=""([^""]+)""\s+data-name=""([^""]+)"""); foreach (Match match in uuidMatches) { if (Guid.TryParse(match.Groups[1].Value, out var uuid)) { fileUuidMap.Add((match.Groups[2].Value, uuid)); } } byte fileCount = 0; foreach (var file in request.Files) { var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct); if (result is not null) { var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); // content의 file-embed에서 매칭되는 UUID 사용, 없으면 새로 생성 var mapIdx = fileUuidMap.FindIndex(x => x.Name == file.FileName); Guid fileUuid; if (mapIdx >= 0) { fileUuid = fileUuidMap[mapIdx].Uuid; fileUuidMap.RemoveAt(mapIdx); } else { fileUuid = Guid.NewGuid(); } await db.CommentFile.AddAsync(new Domain.Entities.Forum.Comments.CommentFile { BoardID = post.BoardID, PostID = request.PostID, CommentID = comment.ID, UUID = fileUuid, FileName = file.FileName, HashedName = result.FileName, Path = uploadPath.ToRelativePath(), Url = result.Url, Extension = ext, ContentType = file.ContentType, Size = result.Size }, ct); fileCount++; } } comment.Files = fileCount; } // 미디어 처리 if (request.Medias is { Count: > 0 }) { byte mediaCount = 0; foreach (var mediaUrl in request.Medias) { if (!string.IsNullOrWhiteSpace(mediaUrl)) { await db.CommentMedia.AddAsync(new CommentMedia { BoardID = post.BoardID, PostID = request.PostID, CommentID = comment.ID, Url = mediaUrl.Trim() }, ct); mediaCount++; } } comment.Medias = mediaCount; } await db.SaveChangesAsync(ct); // 이메일 알림 발송 if (boardMeta?.NotifyTemplate is not null && boardMeta.Notify is not null) { try { var notify = boardMeta.Notify; var template = boardMeta.NotifyTemplate; var notifyFlags = request.ParentID.HasValue ? notify.ReplyWriteNotifyEnum : notify.CommentWriteNotifyEnum; var emailSubject = request.ParentID.HasValue ? template.ReplyWriteEmailNotifySubject : template.CommentWriteEmailNotifySubject; var emailContent = request.ParentID.HasValue ? template.ReplyWriteEmailNotifyContent : template.CommentWriteEmailNotifyContent; if (notifyFlags != 0 && !string.IsNullOrWhiteSpace(emailSubject) && !string.IsNullOrWhiteSpace(emailContent)) { var recipients = new HashSet(StringComparer.OrdinalIgnoreCase); // 게시글 작성자 if (notifyFlags.HasFlag(BoardNotify.PostAuthor)) { var postAuthor = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == post.MemberID, ct); if (postAuthor is not null && !string.IsNullOrWhiteSpace(postAuthor.Email)) { recipients.Add(postAuthor.Email); } } // 부모 댓글 작성자 (대댓글일 때) if (notifyFlags.HasFlag(BoardNotify.CommentAuthor) && request.ParentID.HasValue) { var parentComment = await db.Comment.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ParentID.Value, ct); if (parentComment is not null) { var commentAuthor = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == parentComment.MemberID, ct); if (commentAuthor is not null && !string.IsNullOrWhiteSpace(commentAuthor.Email)) { recipients.Add(commentAuthor.Email); } } } // 최고관리자 if (notifyFlags.HasFlag(BoardNotify.Admin)) { var admins = await db.Member.AsNoTracking().Where(x => x.IsAdmin).ToListAsync(ct); foreach (var admin in admins) { if (!string.IsNullOrWhiteSpace(admin.Email)) { recipients.Add(admin.Email); } } } // 게시판 매니저 if (notifyFlags.HasFlag(BoardNotify.Manager)) { var managers = await db.BoardManager.AsNoTracking().Where(x => x.BoardID == post.BoardID).ToListAsync(ct); foreach (var mgr in managers) { var mgrMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == mgr.MemberID, ct); if (mgrMember is not null && !string.IsNullOrWhiteSpace(mgrMember.Email)) { recipients.Add(mgrMember.Email); } } } // 본인에게는 발송하지 않음 recipients.Remove(member.Email); foreach (var email in recipients) { await mailService.SendAsync(new SendData(email, emailSubject, emailContent), ct); } } } catch { // 이메일 발송 실패 시 댓글 등록은 성공 처리 } } return comment.ID; } }