Handler.cs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. using Application.Abstractions.Messaging;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Forum;
  4. using SharedKernel.Results;
  5. using SharedKernel.Storage;
  6. using Microsoft.EntityFrameworkCore;
  7. using System.Text.RegularExpressions;
  8. namespace Application.Features.Api.Forum.Post.Update;
  9. public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IBoardPermissionService permissionService) : ICommandHandler<Command, Result>
  10. {
  11. private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
  12. 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"];
  13. public async Task<Result> Handle(Command request, CancellationToken ct)
  14. {
  15. if (string.IsNullOrWhiteSpace(request.Subject))
  16. {
  17. return Result.Failure(Error.Problem("Post.SubjectRequired", "제목을 입력해주세요."));
  18. }
  19. var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
  20. if (post is null)
  21. {
  22. return Result.Failure(Error.NotFound("Post.NotFound", "게시글을 찾을 수 없습니다."));
  23. }
  24. if (post.MemberID != request.MemberID)
  25. {
  26. var reqMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct);
  27. if (reqMember is null || !reqMember.IsAdmin)
  28. {
  29. var mgr = await permissionService.GetBoardManagerAsync(post.BoardID, request.MemberID, ct);
  30. if (mgr is null || !mgr.CanEdit)
  31. {
  32. return Result.Failure(Error.Forbidden("Post.Forbidden", "수정 권한이 없습니다."));
  33. }
  34. }
  35. }
  36. post.BoardPrefixID = request.BoardPrefixID;
  37. post.Subject = request.Subject.Trim();
  38. post.Content = request.Content ?? string.Empty;
  39. post.IsSecret = request.IsSecret;
  40. post.IsNotice = request.IsNotice;
  41. post.IsSpeaker = request.IsSpeaker;
  42. post.UpdatedAt = DateTime.UtcNow;
  43. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
  44. // 이미지 처리 (새 이미지 추가)
  45. if (request.Images is { Count: > 0 })
  46. {
  47. var savedImageUrls = new List<string>();
  48. foreach (var image in request.Images)
  49. {
  50. var result = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExtensions, ct);
  51. if (result is not null)
  52. {
  53. var ext = Path.GetExtension(image.FileName).ToLowerInvariant();
  54. await db.PostImage.AddAsync(new Domain.Entities.Forum.Posts.PostImage
  55. {
  56. BoardID = post.BoardID,
  57. PostID = post.ID,
  58. FileName = image.FileName,
  59. HashedName = result.FileName,
  60. Path = uploadPath.ToRelativePath(),
  61. Url = result.Url,
  62. Extension = ext,
  63. ContentType = image.ContentType,
  64. Size = result.Size,
  65. Width = result.Width,
  66. Height = result.Height
  67. }, ct);
  68. savedImageUrls.Add(result.Url);
  69. }
  70. }
  71. // content의 data:image/ 플레이스홀더를 실제 이미지 경로로 순서대로 치환
  72. var content = post.Content;
  73. foreach (var url in savedImageUrls)
  74. {
  75. var idx = content.IndexOf("data:image/", StringComparison.Ordinal);
  76. if (idx >= 0)
  77. {
  78. var endIdx = content.IndexOfAny(['"', '\''], idx);
  79. if (endIdx > idx)
  80. {
  81. content = string.Concat(content.AsSpan(0, idx), url, content.AsSpan(endIdx));
  82. }
  83. }
  84. }
  85. post.Content = content;
  86. if (string.IsNullOrEmpty(post.Thumbnail))
  87. {
  88. post.Thumbnail = savedImageUrls[0];
  89. }
  90. // 카운터 재계산
  91. var imageCount = await db.PostImage.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
  92. post.Images = (byte)Math.Min(imageCount + savedImageUrls.Count, byte.MaxValue);
  93. }
  94. // 파일 처리 (새 파일 추가)
  95. if (request.Files is { Count: > 0 })
  96. {
  97. // content HTML에서 file-embed의 data-uuid와 data-name 매핑 추출
  98. var fileUuidMap = new List<(string Name, Guid Uuid)>();
  99. var uuidMatches = Regex.Matches(post.Content, @"data-uuid=""([^""]+)""\s+data-name=""([^""]+)""");
  100. foreach (Match match in uuidMatches)
  101. {
  102. if (Guid.TryParse(match.Groups[1].Value, out var uuid))
  103. {
  104. fileUuidMap.Add((match.Groups[2].Value, uuid));
  105. }
  106. }
  107. foreach (var file in request.Files)
  108. {
  109. var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
  110. if (result is not null)
  111. {
  112. var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
  113. // content의 file-embed에서 매칭되는 UUID 사용, 없으면 새로 생성
  114. var mapIdx = fileUuidMap.FindIndex(x => x.Name == file.FileName);
  115. Guid fileUuid;
  116. if (mapIdx >= 0)
  117. {
  118. fileUuid = fileUuidMap[mapIdx].Uuid;
  119. fileUuidMap.RemoveAt(mapIdx);
  120. }
  121. else
  122. {
  123. fileUuid = Guid.NewGuid();
  124. }
  125. await db.PostFile.AddAsync(new Domain.Entities.Forum.Posts.PostFile
  126. {
  127. BoardID = post.BoardID,
  128. PostID = post.ID,
  129. UUID = fileUuid,
  130. FileName = file.FileName,
  131. HashedName = result.FileName,
  132. Path = uploadPath.ToRelativePath(),
  133. Url = result.Url,
  134. Extension = ext,
  135. ContentType = file.ContentType,
  136. Size = result.Size
  137. }, ct);
  138. }
  139. }
  140. var fileCount = await db.PostFile.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
  141. post.Files = (byte)Math.Min(fileCount + request.Files.Count, byte.MaxValue);
  142. }
  143. // 미디어 처리 (새 미디어 추가)
  144. if (request.Medias is { Count: > 0 })
  145. {
  146. foreach (var mediaUrl in request.Medias)
  147. {
  148. if (!string.IsNullOrWhiteSpace(mediaUrl))
  149. {
  150. await db.PostMedia.AddAsync(new Domain.Entities.Forum.Posts.PostMedia
  151. {
  152. BoardID = post.BoardID,
  153. PostID = post.ID,
  154. Url = mediaUrl.Trim()
  155. }, ct);
  156. }
  157. }
  158. var mediaCount = await db.PostMedia.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
  159. post.Medias = (byte)Math.Min(mediaCount + request.Medias.Count(m => !string.IsNullOrWhiteSpace(m)), byte.MaxValue);
  160. }
  161. // 태그 처리 (diff 로직)
  162. if (request.Tags is not null)
  163. {
  164. var existingPostTags = await db.PostTag.Include(x => x.Tag).Where(x => x.PostID == post.ID).ToListAsync(ct);
  165. var newTagSlugs = request.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim().ToLowerInvariant().Replace(' ', '-')).ToHashSet();
  166. var existingSlugs = existingPostTags.Select(x => x.Tag.Slug).ToHashSet();
  167. // 삭제할 태그: 기존에 있지만 새 목록에 없는 것
  168. var toRemove = existingPostTags.Where(x => !newTagSlugs.Contains(x.Tag.Slug)).ToList();
  169. foreach (var postTag in toRemove)
  170. {
  171. db.PostTag.Remove(postTag);
  172. if (postTag.Tag.UsageCount > 0)
  173. {
  174. postTag.Tag.UsageCount--;
  175. postTag.Tag.UpdatedAt = DateTime.UtcNow;
  176. }
  177. }
  178. // 추가할 태그: 새 목록에 있지만 기존에 없는 것
  179. var toAdd = request.Tags
  180. .Where(t => !string.IsNullOrWhiteSpace(t))
  181. .Select(t => t.Trim())
  182. .Where(t =>
  183. {
  184. return !existingSlugs.Contains(
  185. t.ToLowerInvariant().Replace(' ', '-')
  186. );
  187. })
  188. .ToList();
  189. foreach (var tagName in toAdd)
  190. {
  191. var slug = tagName.ToLowerInvariant().Replace(' ', '-');
  192. var tag = await db.Tag.FirstOrDefaultAsync(x => x.Slug == slug, ct);
  193. if (tag is null)
  194. {
  195. tag = new Domain.Entities.Forum.Posts.Tag
  196. {
  197. Name = tagName,
  198. Slug = slug
  199. };
  200. await db.Tag.AddAsync(tag, ct);
  201. await db.SaveChangesAsync(ct);
  202. }
  203. tag.UsageCount++;
  204. tag.UpdatedAt = DateTime.UtcNow;
  205. await db.PostTag.AddAsync(new Domain.Entities.Forum.Posts.PostTag
  206. {
  207. BoardID = post.BoardID,
  208. PostID = post.ID,
  209. TagID = tag.ID
  210. }, ct);
  211. }
  212. post.Tags = (byte)Math.Min(newTagSlugs.Count, byte.MaxValue);
  213. }
  214. await db.SaveChangesAsync(ct);
  215. return Result.Success();
  216. }
  217. }