| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- using Application.Abstractions.Messaging;
- using Application.Abstractions.Data;
- using Application.Abstractions.Forum;
- using SharedKernel.Results;
- using SharedKernel.Storage;
- using Microsoft.EntityFrameworkCore;
- using System.Text.RegularExpressions;
- namespace Application.Features.Api.Forum.Post.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.Subject))
- {
- return Result.Failure(Error.Problem("Post.SubjectRequired", "제목을 입력해주세요."));
- }
- var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
- if (post is null)
- {
- return Result.Failure(Error.NotFound("Post.NotFound", "게시글을 찾을 수 없습니다."));
- }
- if (post.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(post.BoardID, request.MemberID, ct);
- if (mgr is null || !mgr.CanEdit)
- {
- return Result.Failure(Error.Forbidden("Post.Forbidden", "수정 권한이 없습니다."));
- }
- }
- }
- post.BoardPrefixID = request.BoardPrefixID;
- post.Subject = request.Subject.Trim();
- post.Content = request.Content ?? string.Empty;
- post.IsSecret = request.IsSecret;
- post.IsNotice = request.IsNotice;
- post.IsSpeaker = request.IsSpeaker;
- post.UpdatedAt = DateTime.UtcNow;
- var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.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.PostImage.AddAsync(new Domain.Entities.Forum.Posts.PostImage
- {
- BoardID = post.BoardID,
- PostID = post.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 = post.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));
- }
- }
- }
- post.Content = content;
- if (string.IsNullOrEmpty(post.Thumbnail))
- {
- post.Thumbnail = savedImageUrls[0];
- }
- // 카운터 재계산
- var imageCount = await db.PostImage.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
- post.Images = (byte)Math.Min(imageCount + savedImageUrls.Count, byte.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(post.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.PostFile.AddAsync(new Domain.Entities.Forum.Posts.PostFile
- {
- BoardID = post.BoardID,
- PostID = post.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.PostFile.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
- post.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.PostMedia.AddAsync(new Domain.Entities.Forum.Posts.PostMedia
- {
- BoardID = post.BoardID,
- PostID = post.ID,
- Url = mediaUrl.Trim()
- }, ct);
- }
- }
- var mediaCount = await db.PostMedia.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
- post.Medias = (byte)Math.Min(mediaCount + request.Medias.Count(m => !string.IsNullOrWhiteSpace(m)), 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 =>
- {
- return !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);
- return Result.Success();
- }
- }
|