using Application.Abstractions.Messaging; using Application.Abstractions.Messaging.Email; using Application.Abstractions.Data; using Application.Common; using Domain.Entities.Forum.ValueObject; using SharedKernel.Results; using SharedKernel.Storage; using Microsoft.EntityFrameworkCore; using System.Text.RegularExpressions; namespace Application.Features.Admin.Forum.Comment.CreateAnswer; /// /// 1:1 문의(QnA 게시판) 게시글에 관리자 답변(root 댓글)을 등록한다. /// 사용자단과 동일하게 답변 = root Comment, 등록 시 post.IsReply = true 로 답변완료 처리. /// public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IMailService mailService) : ICommandHandler> { 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) { if (string.IsNullOrWhiteSpace(request.Content)) { return Result.Failure(Error.Problem("Answer.ContentRequired", "답변 내용을 입력해주세요.")); } var post = await db.Post.FirstOrDefaultAsync(c => c.ID == request.PostID, ct); if (post is null) { return Result.Failure(Error.NotFound("Answer.PostNotFound", "게시글을 찾을 수 없습니다.")); } var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(c => c.BoardID == post.BoardID, ct); if (boardMeta?.List.Layout != BoardLayout.QnA) { return Result.Failure(Error.Problem("Answer.NotQnA", "1:1 문의 게시판의 게시글이 아닙니다.")); } // 답변 작성자 귀속: Identity 이메일 → Member, 없으면 최고관리자 첫 멤버 var answerMember = !string.IsNullOrWhiteSpace(request.AdminEmail) ? await db.Member.AsNoTracking().FirstOrDefaultAsync(c => c.Email == request.AdminEmail, ct) : null; answerMember ??= await db.Member.AsNoTracking().Where(c => c.IsAdmin).OrderBy(c => c.ID).FirstOrDefaultAsync(ct); if (answerMember is null) { return Result.Failure(Error.NotFound("Answer.AdminMemberNotFound", "답변을 귀속할 관리자 회원 계정을 찾을 수 없습니다.")); } var comment = new Domain.Entities.Forum.Comments.Comment { BoardID = post.BoardID, PostID = post.ID, MemberID = answerMember.ID, Content = request.Content.Trim(), Name = answerMember.Name, SID = answerMember.SID, Email = answerMember.Email }; await db.Comment.AddAsync(comment, ct); // 답변완료 처리 + 카운트 post.Comments++; post.LastCommentUpdatedAt = DateTime.UtcNow; post.IsReply = true; var board = await db.Board.FirstOrDefaultAsync(c => c.ID == post.BoardID, ct); if (board is not null) { board.Comments++; board.UpdatedAt = DateTime.UtcNow; } await db.SaveChangesAsync(ct); var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Comment, comment.ID); // 에디터 base64 이미지 → 파일 + CommentImage var content = comment.Content; var matches = ImgBase64Regex.Matches(content); if (matches.Count > 0) { var imageCount = 0; 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); await db.CommentImage.AddAsync(new Domain.Entities.Forum.Comments.CommentImage { BoardID = post.BoardID, PostID = post.ID, CommentID = comment.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); imageCount++; } } comment.Content = content; comment.Images = imageCount; } // 첨부파일 → CommentFile if (request.Files is { Count: > 0 }) { 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(); await db.CommentFile.AddAsync(new Domain.Entities.Forum.Comments.CommentFile { BoardID = post.BoardID, PostID = post.ID, CommentID = comment.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); fileCount++; } } comment.Files = fileCount; } await db.SaveChangesAsync(ct); // 답변 알림 메일: 게시판 '댓글 작성 시' 알림 설정에 게시글 작성자가 포함된 경우 발송 if (boardMeta.Notify is not null && boardMeta.NotifyTemplate is not null) { try { var notifyFlags = boardMeta.Notify.CommentWriteNotifyEnum; var emailSubject = boardMeta.NotifyTemplate.CommentWriteEmailNotifySubject; var emailContent = boardMeta.NotifyTemplate.CommentWriteEmailNotifyContent; if (notifyFlags.HasFlag(BoardNotify.PostAuthor) && !string.IsNullOrWhiteSpace(emailSubject) && !string.IsNullOrWhiteSpace(emailContent)) { var authorEmail = post.Email; if (post.MemberID.HasValue) { var author = await db.Member.AsNoTracking().FirstOrDefaultAsync(c => c.ID == post.MemberID.Value, ct); authorEmail = author?.Email ?? authorEmail; } if (!string.IsNullOrWhiteSpace(authorEmail) && !string.Equals(authorEmail, answerMember.Email, StringComparison.OrdinalIgnoreCase)) { var siteName = await db.Config.AsNoTracking().Select(c => c.Basic.SiteName).FirstOrDefaultAsync(ct); var tokens = ForumNotifyRenderer.CommentTokens(siteName, board?.Name ?? string.Empty, post, comment); await mailService.SendAsync(new SendData(authorEmail, emailSubject.Render(tokens), emailContent.Render(tokens)), ct); } } } catch { // 이메일 발송 실패 시 답변 등록은 성공 처리 } } return comment.ID; } }