using Application.Abstractions.Messaging; using Application.Abstractions.Messaging.Email; using Application.Abstractions.Data; using Application.Abstractions.Forum; using Domain.Entities.Forum.ValueObject; using SharedKernel.Results; using SharedKernel.Storage; using Microsoft.EntityFrameworkCore; using System.Text.RegularExpressions; namespace Application.Features.Api.Forum.Post.Create; public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IBoardPermissionService permissionService, IMailService mailService) : 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"]; public async Task> Handle(Command request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Subject)) { return Result.Failure(Error.Problem("Post.SubjectRequired", "제목을 입력해주세요.")); } var board = await db.Board.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.BoardID, ct); if (board is null) { return Result.Failure(Error.NotFound("Post.BoardNotFound", "게시판을 찾을 수 없습니다.")); } var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct); if (member is null) { return Result.Failure(Error.NotFound("Post.MemberNotFound", "회원 정보를 찾을 수 없습니다.")); } // 게시판 권한 확인 var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(x => x.BoardID == request.BoardID, ct); if (boardMeta is not null) { if (!await permissionService.HasPermissionAsync(member, request.BoardID, boardMeta.Permission.PostWrite, ct)) { return Result.Failure(Error.Forbidden("Post.PermissionDenied", "글쓰기 권한이 없습니다.")); } if ((request.Images is { Count: > 0 } || request.Files is { Count: > 0 }) && !await permissionService.HasPermissionAsync(member, request.BoardID, boardMeta.Permission.FileUpload, ct) ) { return Result.Failure(Error.Forbidden("Post.FileUploadDenied", "파일 업로드 권한이 없습니다.")); } } var post = new Domain.Entities.Forum.Posts.Post { BoardID = request.BoardID, BoardPrefixID = request.BoardPrefixID, MemberID = request.MemberID, Subject = request.Subject.Trim(), Content = request.Content ?? string.Empty, IsSecret = request.IsSecret, IsNotice = request.IsNotice, IsSpeaker = request.IsSpeaker, Name = member.Name, SID = member.SID, Email = member.Email }; await db.Post.AddAsync(post, ct); await db.SaveChangesAsync(ct); var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID); // 이미지 처리 if (request.Images is { Count: > 0 }) { byte imageCount = 0; var savedImageUrls = new List(); 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 = request.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); imageCount++; } } // 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]; } post.Images = imageCount; } // 파일 처리 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)); } } byte fileCount = 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(); // 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 = request.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); fileCount++; } } post.Files = fileCount; } // 미디어 처리 if (request.Medias is { Count: > 0 }) { byte mediaCount = 0; foreach (var mediaUrl in request.Medias) { if (!string.IsNullOrWhiteSpace(mediaUrl)) { await db.PostMedia.AddAsync(new Domain.Entities.Forum.Posts.PostMedia { BoardID = request.BoardID, PostID = post.ID, Url = mediaUrl.Trim() }, ct); mediaCount++; } } post.Medias = mediaCount; } // 태그 처리 if (request.Tags is { Count: > 0 }) { byte tagCount = 0; foreach (var tagName in request.Tags) { if (string.IsNullOrWhiteSpace(tagName)) continue; var name = tagName.Trim(); var slug = name.ToLowerInvariant().Replace(' ', '-'); var tag = await db.Tag.FirstOrDefaultAsync(x => x.Name == name, ct); if (tag is null) { tag = new Domain.Entities.Forum.Posts.Tag { Name = name, 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 = request.BoardID, PostID = post.ID, TagID = tag.ID }, ct); tagCount++; } post.Tags = tagCount; } await db.SaveChangesAsync(ct); // Board 게시글 카운트 증가 var boardEntity = await db.Board.FirstOrDefaultAsync(x => x.ID == request.BoardID, ct); if (boardEntity is not null) { boardEntity.Posts++; boardEntity.UpdatedAt = DateTime.UtcNow; var boardGroup = await db.BoardGroup.FirstOrDefaultAsync(x => x.ID == boardEntity.BoardGroupID, ct); if (boardGroup is not null) { boardGroup.Posts++; boardGroup.UpdatedAt = DateTime.UtcNow; } // MemberStats 게시글 수 증가 var memberStats = await db.MemberStats.FirstOrDefaultAsync(x => x.MemberID == request.MemberID, ct); if (memberStats is not null) { memberStats.PostCount++; } await db.SaveChangesAsync(ct); } // 이메일 알림 발송 if (boardMeta?.NotifyTemplate is not null && boardMeta.Notify is not null) { try { var notify = boardMeta.Notify; var template = boardMeta.NotifyTemplate; var notifyFlags = notify.PostWriteNotifyEnum; var emailSubject = template.PostWriteEmailNotifySubject; var emailContent = template.PostWriteEmailNotifyContent; if (notifyFlags != 0 && !string.IsNullOrWhiteSpace(emailSubject) && !string.IsNullOrWhiteSpace(emailContent)) { var recipients = new HashSet(StringComparer.OrdinalIgnoreCase); // 최고관리자 if (notifyFlags.HasFlag(BoardNotify.Admin)) { var admins = await db.Member.AsNoTracking().Where(x => x.IsAdmin).ToListAsync(ct); foreach (var admin in admins) { if (!string.IsNullOrWhiteSpace(admin.Email)) { recipients.Add(admin.Email); } } } // 게시판 매니저 if (notifyFlags.HasFlag(BoardNotify.Manager)) { var managers = await db.BoardManager.AsNoTracking() .Where(x => x.BoardID == request.BoardID) .ToListAsync(ct); foreach (var mgr in managers) { var mgrMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == mgr.MemberID, ct); if (mgrMember is not null && !string.IsNullOrWhiteSpace(mgrMember.Email)) { recipients.Add(mgrMember.Email); } } } // 본인에게는 발송하지 않음 recipients.Remove(member.Email); foreach (var email in recipients) { await mailService.SendAsync(new SendData(email, emailSubject, emailContent), ct); } } } catch { // 이메일 발송 실패 시 게시글 등록은 성공 처리 } } return post.ID; } }