using Application.Abstractions.Messaging; using Application.Abstractions.Data; using SharedKernel.Storage; using Microsoft.EntityFrameworkCore; using System.Text.RegularExpressions; namespace Application.Features.Admin.Forum.Post.Update; public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : 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"]; private static readonly Regex ImgBase64Regex = new(@"]*?\bsrc\s*=\s*""(?data:image/(?[a-zA-Z0-9.+-]+);base64,(?[a-zA-Z0-9+/=\s]+))""", RegexOptions.Compiled | RegexOptions.IgnoreCase); public async Task Handle(Command request, CancellationToken ct) { var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.ID, ct); if (post is null) { throw new KeyNotFoundException("게시글을 찾을 수 없습니다."); } post.BoardPrefixID = request.BoardPrefixID; post.Subject = request.Subject; post.Content = request.Content ?? string.Empty; post.IsNotice = request.IsNotice; post.IsSecret = request.IsSecret; post.IsAnonymous = request.IsAnonymous; post.UpdatedAt = DateTime.UtcNow; var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID); // 에디터 이미지 처리 (base64 → 파일 + PostImage 레코드) var content = post.Content; var matches = ImgBase64Regex.Matches(content); if (matches.Count > 0) { byte newImageCount = 0; var firstImageUrl = (string?)null; foreach (Match match in matches) { var ext = FileUtils.NormalizeExtension(match.Groups["ext"].Value); var bytes = Convert.FromBase64String(match.Groups["data"].Value); var result = await fileStorage.SaveBytesAsync(bytes, ext, uploadPath, ct); if (result is not null) { content = content.Replace(match.Groups["src"].Value, result.Url); firstImageUrl ??= result.Url; await db.PostImage.AddAsync(new Domain.Entities.Forum.Posts.PostImage { BoardID = post.BoardID, PostID = post.ID, FileName = result.FileName, HashedName = result.FileName, Path = uploadPath.ToRelativePath(), Url = result.Url, Extension = ext, ContentType = $"image/{match.Groups["ext"].Value}", Size = result.Size, Width = result.Width, Height = result.Height }, ct); newImageCount++; } } post.Content = content; var existingImageCount = await db.PostImage.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct); post.Images = (byte)Math.Min(existingImageCount + newImageCount, byte.MaxValue); if (string.IsNullOrEmpty(post.Thumbnail) && firstImageUrl is not null) { post.Thumbnail = firstImageUrl; } } // 썸네일 처리 if (request.ThumbnailFile is not null) { if (!string.IsNullOrEmpty(post.Thumbnail)) { fileStorage.DeleteByUrl(post.Thumbnail); } var result = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, AllowedImageExtensions, ct); post.Thumbnail = result?.Url; } // 파일 처리 (새 파일 추가) if (request.Files is { Count: > 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(); await db.PostFile.AddAsync(new Domain.Entities.Forum.Posts.PostFile { BoardID = post.BoardID, PostID = post.ID, UUID = Guid.NewGuid(), 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.PostFile.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct); post.Files = (byte)Math.Min(fileCount + request.Files.Count, byte.MaxValue); } // 태그 처리 (diff 기반) if (request.Tags is not null) { var existingPostTags = await db.PostTag.Include(x => x.Tag).Where(x => x.PostID == post.ID).ToListAsync(ct); var newTagSlugs = request.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim().ToLowerInvariant().Replace(' ', '-')).ToHashSet(); var existingSlugs = existingPostTags.Select(x => x.Tag.Slug).ToHashSet(); // 삭제할 태그: 기존에 있지만 새 목록에 없는 것 var toRemove = existingPostTags.Where(x => !newTagSlugs.Contains(x.Tag.Slug)).ToList(); foreach (var postTag in toRemove) { db.PostTag.Remove(postTag); if (postTag.Tag.UsageCount > 0) { postTag.Tag.UsageCount--; postTag.Tag.UpdatedAt = DateTime.UtcNow; } } // 추가할 태그: 새 목록에 있지만 기존에 없는 것 var toAdd = request.Tags .Where(t => !string.IsNullOrWhiteSpace(t)) .Select(t => t.Trim()) .Where(t => !existingSlugs.Contains(t.ToLowerInvariant().Replace(' ', '-'))) .ToList(); foreach (var tagName in toAdd) { var slug = tagName.ToLowerInvariant().Replace(' ', '-'); var tag = await db.Tag.FirstOrDefaultAsync(x => x.Slug == slug, ct); if (tag is null) { tag = new Domain.Entities.Forum.Posts.Tag { Name = tagName, Slug = slug }; await db.Tag.AddAsync(tag, ct); await db.SaveChangesAsync(ct); } tag.UsageCount++; tag.UpdatedAt = DateTime.UtcNow; await db.PostTag.AddAsync(new Domain.Entities.Forum.Posts.PostTag { BoardID = post.BoardID, PostID = post.ID, TagID = tag.ID }, ct); } post.Tags = (byte)Math.Min(newTagSlugs.Count, byte.MaxValue); } await db.SaveChangesAsync(ct); } }