| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- 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<Command, Result>
- {
- 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> 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<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 = 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();
- }
- }
|