using Application.Abstractions.Messaging; using Application.Abstractions.Data; using Application.Abstractions.Forum; using Domain.Entities.Forum.Comments; using SharedKernel.Results; using SharedKernel.Storage; using Microsoft.EntityFrameworkCore; using System.Text.RegularExpressions; namespace Application.Features.Api.Forum.Comment.Update; public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IBoardPermissionService permissionService) : 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 comment = await db.Comment.FirstOrDefaultAsync(x => x.ID == request.ID, ct); if (comment is null) { return Result.Failure(Error.NotFound("Comment.NotFound", "댓글을 찾을 수 없습니다.")); } if (comment.MemberID != request.MemberID) { var reqMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct); if (reqMember is null || !reqMember.IsAdmin) { var mgr = await permissionService.GetBoardManagerAsync(comment.BoardID, request.MemberID, ct); if (mgr is null || !mgr.CanEdit) { return Result.Failure(Error.Forbidden("Comment.Forbidden", "수정 권한이 없습니다.")); } } } // 댓글 내용 길이 검증 + 파일 업로드 권한 확인 var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(x => x.BoardID == comment.BoardID, ct); 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}자 이내로 작성해주세요.")); } if (request.Images is { Count: > 0 } || request.Files is { Count: > 0 }) { var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct); if (member is null || !await permissionService.HasPermissionAsync(member, comment.BoardID, boardMeta.Permission.FileUpload, ct)) { return Result.Failure(Error.Forbidden("Comment.FileUploadDenied", "파일 업로드 권한이 없습니다.")); } } } comment.Content = request.Content.Trim(); comment.IsSecret = request.IsSecret; comment.UpdatedAt = DateTime.UtcNow; // Mention 처리 var existingMention = await db.CommentMention.FirstOrDefaultAsync(x => x.CommentID == comment.ID, ct); 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; if (existingMention is not null) { existingMention.MemberID = mentionMember.ID; existingMention.RawHandle = rawHandle; } else { db.CommentMention.Add(new CommentMention { BoardID = comment.BoardID, PostID = comment.PostID, CommentID = comment.ID, MemberID = mentionMember.ID, RawHandle = rawHandle }); } } } else { comment.MentionMemberID = null; if (existingMention is not null) { db.CommentMention.Remove(existingMention); } } var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Comment, comment.ID); // 이미지 처리 (새 이미지 추가) if (request.Images is { Count: > 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 = comment.BoardID, PostID = comment.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); } } // 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; // 카운터 재계산 var imageCount = await db.CommentImage.CountAsync(x => x.CommentID == comment.ID && !x.IsDisabled, ct); comment.Images = (int)Math.Min(imageCount + savedImageUrls.Count, int.MaxValue); } // 파일 처리 (새 파일 추가) 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)); } } 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 = comment.BoardID, PostID = comment.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); } } var fileCount = await db.CommentFile.CountAsync(x => x.CommentID == comment.ID && !x.IsDisabled, ct); comment.Files = (byte)Math.Min(fileCount + request.Files.Count, byte.MaxValue); } // 미디어 처리 (새 미디어 추가) if (request.Medias is { Count: > 0 }) { foreach (var mediaUrl in request.Medias) { if (!string.IsNullOrWhiteSpace(mediaUrl)) { await db.CommentMedia.AddAsync(new CommentMedia { BoardID = comment.BoardID, PostID = comment.PostID, CommentID = comment.ID, Url = mediaUrl.Trim() }, ct); } } var mediaCount = await db.CommentMedia.CountAsync(x => x.CommentID == comment.ID && !x.IsDisabled, ct); comment.Medias = (byte)Math.Min(mediaCount + request.Medias.Count(m => !string.IsNullOrWhiteSpace(m)), byte.MaxValue); } await db.SaveChangesAsync(ct); return Result.Success(); } }