| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- 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<Command, Result<int>>
- {
- 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<Result<int>> Handle(Command request, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(request.Content))
- {
- return Result.Failure<int>(Error.Problem("Comment.ContentRequired", "내용을 입력해주세요."));
- }
- var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.PostID, ct);
- if (post is null)
- {
- return Result.Failure<int>(Error.NotFound("Comment.PostNotFound", "게시글을 찾을 수 없습니다."));
- }
- var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct);
- if (member is null)
- {
- return Result.Failure<int>(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<int>(Error.Problem("Comment.MinContentLength", $"내용은 {boardMeta.Comment.MinContentLength}자 이상 작성해주세요."));
- }
- if (boardMeta.Comment.MaxContentLength > 0 && contentLength > boardMeta.Comment.MaxContentLength)
- {
- return Result.Failure<int>(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<int>(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<int>(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<int>(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<int>(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<string>();
- 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<string>(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;
- }
- }
|