|
|
@@ -1,12 +1,21 @@
|
|
|
using Application.Abstractions.Messaging;
|
|
|
+using Application.Abstractions.Messaging.Email;
|
|
|
using Application.Abstractions.Data;
|
|
|
-using Microsoft.EntityFrameworkCore;
|
|
|
+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) : ICommandHandler<Command, Result<int>>
|
|
|
+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))
|
|
|
@@ -26,9 +35,60 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<
|
|
|
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);
|
|
|
@@ -50,7 +110,7 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<
|
|
|
|
|
|
var comment = new Domain.Entities.Forum.Comments.Comment
|
|
|
{
|
|
|
- BoardID = request.BoardID,
|
|
|
+ BoardID = post.BoardID,
|
|
|
PostID = request.PostID,
|
|
|
MemberID = request.MemberID,
|
|
|
ParentID = request.ParentID,
|
|
|
@@ -63,14 +123,40 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<
|
|
|
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 == request.BoardID, ct);
|
|
|
+ var board = await db.Board.FirstOrDefaultAsync(x => x.ID == post.BoardID, ct);
|
|
|
if (board is not null)
|
|
|
{
|
|
|
board.Comments++;
|
|
|
@@ -86,6 +172,227 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
-}
|
|
|
+}
|