Handler.cs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. using Application.Abstractions.Messaging;
  2. using Application.Abstractions.Data;
  3. using SharedKernel.Results;
  4. using SharedKernel.Storage;
  5. using Microsoft.EntityFrameworkCore;
  6. using System.Text.RegularExpressions;
  7. namespace Application.Features.Admin.Forum.Post.Update;
  8. public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler<Command, Result>
  9. {
  10. private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
  11. 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"];
  12. private static readonly Regex ImgBase64Regex = new(@"<img\b[^>]*?\bsrc\s*=\s*""(?<src>data:image/(?<ext>[a-zA-Z0-9.+-]+);base64,(?<data>[a-zA-Z0-9+/=\s]+))""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
  13. public async Task<Result> Handle(Command request, CancellationToken ct)
  14. {
  15. var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
  16. if (post is null)
  17. {
  18. return Result.Failure(Error.NotFound("Post.NotFound", "게시글을 찾을 수 없습니다."));
  19. }
  20. post.BoardPrefixID = request.BoardPrefixID;
  21. post.Subject = request.Subject;
  22. post.Content = request.Content ?? string.Empty;
  23. post.IsNotice = request.IsNotice;
  24. post.IsSecret = request.IsSecret;
  25. post.IsAnonymous = request.IsAnonymous;
  26. post.UpdatedAt = DateTime.UtcNow;
  27. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
  28. // 에디터 이미지 처리 (base64 → 파일 + PostImage 레코드)
  29. var content = post.Content;
  30. var matches = ImgBase64Regex.Matches(content);
  31. if (matches.Count > 0)
  32. {
  33. byte newImageCount = 0;
  34. var firstImageUrl = (string?)null;
  35. foreach (Match match in matches)
  36. {
  37. var ext = FileUtils.NormalizeExtension(match.Groups["ext"].Value);
  38. var bytes = Convert.FromBase64String(match.Groups["data"].Value);
  39. var result = await fileStorage.SaveBytesAsync(bytes, ext, uploadPath, ct);
  40. if (result is not null)
  41. {
  42. content = content.Replace(match.Groups["src"].Value, result.Url);
  43. firstImageUrl ??= result.Url;
  44. await db.PostImage.AddAsync(new Domain.Entities.Forum.Posts.PostImage
  45. {
  46. BoardID = post.BoardID,
  47. PostID = post.ID,
  48. FileName = result.FileName,
  49. HashedName = result.FileName,
  50. Path = uploadPath.ToRelativePath(),
  51. Url = result.Url,
  52. Extension = ext,
  53. ContentType = $"image/{match.Groups["ext"].Value}",
  54. Size = result.Size,
  55. Width = result.Width,
  56. Height = result.Height
  57. }, ct);
  58. newImageCount++;
  59. }
  60. }
  61. post.Content = content;
  62. var existingImageCount = await db.PostImage.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
  63. post.Images = (byte)Math.Min(existingImageCount + newImageCount, byte.MaxValue);
  64. if (string.IsNullOrEmpty(post.Thumbnail) && firstImageUrl is not null)
  65. {
  66. post.Thumbnail = firstImageUrl;
  67. }
  68. }
  69. // 썸네일 처리
  70. if (request.ThumbnailFile is not null)
  71. {
  72. if (!string.IsNullOrEmpty(post.Thumbnail))
  73. {
  74. fileStorage.DeleteByUrl(post.Thumbnail);
  75. }
  76. var result = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, AllowedImageExtensions, ct);
  77. post.Thumbnail = result?.Url;
  78. }
  79. // 파일 처리 (새 파일 추가)
  80. if (request.Files is { Count: > 0 })
  81. {
  82. foreach (var file in request.Files)
  83. {
  84. var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
  85. if (result is not null)
  86. {
  87. var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
  88. await db.PostFile.AddAsync(new Domain.Entities.Forum.Posts.PostFile
  89. {
  90. BoardID = post.BoardID,
  91. PostID = post.ID,
  92. UUID = Guid.NewGuid(),
  93. FileName = file.FileName,
  94. HashedName = result.FileName,
  95. Path = uploadPath.ToRelativePath(),
  96. Url = result.Url,
  97. Extension = ext,
  98. ContentType = file.ContentType,
  99. Size = result.Size
  100. }, ct);
  101. }
  102. }
  103. var fileCount = await db.PostFile.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
  104. post.Files = (byte)Math.Min(fileCount + request.Files.Count, byte.MaxValue);
  105. }
  106. // 태그 처리 (diff 기반)
  107. if (request.Tags is not null)
  108. {
  109. var existingPostTags = await db.PostTag.Include(x => x.Tag).Where(x => x.PostID == post.ID).ToListAsync(ct);
  110. var newTagSlugs = request.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim().ToLowerInvariant().Replace(' ', '-')).ToHashSet();
  111. var existingSlugs = existingPostTags.Select(x => x.Tag.Slug).ToHashSet();
  112. // 삭제할 태그: 기존에 있지만 새 목록에 없는 것
  113. var toRemove = existingPostTags.Where(x => !newTagSlugs.Contains(x.Tag.Slug)).ToList();
  114. foreach (var postTag in toRemove)
  115. {
  116. db.PostTag.Remove(postTag);
  117. if (postTag.Tag.UsageCount > 0)
  118. {
  119. postTag.Tag.UsageCount--;
  120. postTag.Tag.UpdatedAt = DateTime.UtcNow;
  121. }
  122. }
  123. // 추가할 태그: 새 목록에 있지만 기존에 없는 것
  124. var toAdd = request.Tags
  125. .Where(t => !string.IsNullOrWhiteSpace(t))
  126. .Select(t => t.Trim())
  127. .Where(t => !existingSlugs.Contains(t.ToLowerInvariant().Replace(' ', '-')))
  128. .ToList();
  129. foreach (var tagName in toAdd)
  130. {
  131. var slug = tagName.ToLowerInvariant().Replace(' ', '-');
  132. var tag = await db.Tag.FirstOrDefaultAsync(x => x.Slug == slug, ct);
  133. if (tag is null)
  134. {
  135. tag = new Domain.Entities.Forum.Posts.Tag
  136. {
  137. Name = tagName,
  138. Slug = slug
  139. };
  140. await db.Tag.AddAsync(tag, ct);
  141. await db.SaveChangesAsync(ct);
  142. }
  143. tag.UsageCount++;
  144. tag.UpdatedAt = DateTime.UtcNow;
  145. await db.PostTag.AddAsync(new Domain.Entities.Forum.Posts.PostTag
  146. {
  147. BoardID = post.BoardID,
  148. PostID = post.ID,
  149. TagID = tag.ID
  150. }, ct);
  151. }
  152. post.Tags = (byte)Math.Min(newTagSlugs.Count, byte.MaxValue);
  153. }
  154. await db.SaveChangesAsync(ct);
  155. return Result.Success();
  156. }
  157. }