Handler.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. using Application.Abstractions.Messaging;
  2. using Application.Abstractions.Messaging.Email;
  3. using Application.Abstractions.Data;
  4. using Application.Abstractions.Forum;
  5. using Domain.Entities.Forum.ValueObject;
  6. using SharedKernel.Results;
  7. using SharedKernel.Storage;
  8. using Microsoft.EntityFrameworkCore;
  9. using System.Text.RegularExpressions;
  10. namespace Application.Features.Api.Forum.Post.Create;
  11. public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IBoardPermissionService permissionService, IMailService mailService) : ICommandHandler<Command, Result<int>>
  12. {
  13. private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
  14. 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"];
  15. public async Task<Result<int>> Handle(Command request, CancellationToken ct)
  16. {
  17. if (string.IsNullOrWhiteSpace(request.Subject))
  18. {
  19. return Result.Failure<int>(Error.Problem("Post.SubjectRequired", "제목을 입력해주세요."));
  20. }
  21. var board = await db.Board.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.BoardID, ct);
  22. if (board is null)
  23. {
  24. return Result.Failure<int>(Error.NotFound("Post.BoardNotFound", "게시판을 찾을 수 없습니다."));
  25. }
  26. var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct);
  27. if (member is null)
  28. {
  29. return Result.Failure<int>(Error.NotFound("Post.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
  30. }
  31. // 게시판 권한 확인
  32. var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(x => x.BoardID == request.BoardID, ct);
  33. if (boardMeta is not null)
  34. {
  35. if (!await permissionService.HasPermissionAsync(member, request.BoardID, boardMeta.Permission.PostWrite, ct))
  36. {
  37. return Result.Failure<int>(Error.Forbidden("Post.PermissionDenied", "글쓰기 권한이 없습니다."));
  38. }
  39. if ((request.Images is { Count: > 0 } || request.Files is { Count: > 0 }) &&
  40. !await permissionService.HasPermissionAsync(member, request.BoardID, boardMeta.Permission.FileUpload, ct)
  41. ) {
  42. return Result.Failure<int>(Error.Forbidden("Post.FileUploadDenied", "파일 업로드 권한이 없습니다."));
  43. }
  44. }
  45. var post = new Domain.Entities.Forum.Posts.Post
  46. {
  47. BoardID = request.BoardID,
  48. BoardPrefixID = request.BoardPrefixID,
  49. MemberID = request.MemberID,
  50. Subject = request.Subject.Trim(),
  51. Content = request.Content ?? string.Empty,
  52. IsSecret = request.IsSecret,
  53. IsNotice = request.IsNotice,
  54. IsSpeaker = request.IsSpeaker,
  55. Name = member.Name,
  56. SID = member.SID,
  57. Email = member.Email
  58. };
  59. await db.Post.AddAsync(post, ct);
  60. await db.SaveChangesAsync(ct);
  61. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
  62. // 이미지 처리
  63. if (request.Images is { Count: > 0 })
  64. {
  65. byte imageCount = 0;
  66. var savedImageUrls = new List<string>();
  67. foreach (var image in request.Images)
  68. {
  69. var result = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExtensions, ct);
  70. if (result is not null)
  71. {
  72. var ext = Path.GetExtension(image.FileName).ToLowerInvariant();
  73. await db.PostImage.AddAsync(new Domain.Entities.Forum.Posts.PostImage
  74. {
  75. BoardID = request.BoardID,
  76. PostID = post.ID,
  77. FileName = image.FileName,
  78. HashedName = result.FileName,
  79. Path = uploadPath.ToRelativePath(),
  80. Url = result.Url,
  81. Extension = ext,
  82. ContentType = image.ContentType,
  83. Size = result.Size,
  84. Width = result.Width,
  85. Height = result.Height
  86. }, ct);
  87. savedImageUrls.Add(result.Url);
  88. imageCount++;
  89. }
  90. }
  91. // content의 data:image/ 플레이스홀더를 실제 이미지 경로로 순서대로 치환
  92. var content = post.Content;
  93. foreach (var url in savedImageUrls)
  94. {
  95. var idx = content.IndexOf("data:image/", StringComparison.Ordinal);
  96. if (idx >= 0)
  97. {
  98. var endIdx = content.IndexOfAny(['"', '\''], idx);
  99. if (endIdx > idx)
  100. {
  101. content = string.Concat(content.AsSpan(0, idx), url, content.AsSpan(endIdx));
  102. }
  103. }
  104. }
  105. post.Content = content;
  106. if (string.IsNullOrEmpty(post.Thumbnail))
  107. {
  108. post.Thumbnail = savedImageUrls[0];
  109. }
  110. post.Images = imageCount;
  111. }
  112. // 파일 처리
  113. if (request.Files is { Count: > 0 })
  114. {
  115. // content HTML에서 file-embed의 data-uuid와 data-name 매핑 추출
  116. var fileUuidMap = new List<(string Name, Guid Uuid)>();
  117. var uuidMatches = Regex.Matches(post.Content, @"data-uuid=""([^""]+)""\s+data-name=""([^""]+)""");
  118. foreach (Match match in uuidMatches)
  119. {
  120. if (Guid.TryParse(match.Groups[1].Value, out var uuid))
  121. {
  122. fileUuidMap.Add((match.Groups[2].Value, uuid));
  123. }
  124. }
  125. byte fileCount = 0;
  126. foreach (var file in request.Files)
  127. {
  128. var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
  129. if (result is not null)
  130. {
  131. var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
  132. // content의 file-embed에서 매칭되는 UUID 사용, 없으면 새로 생성
  133. var mapIdx = fileUuidMap.FindIndex(x => x.Name == file.FileName);
  134. Guid fileUuid;
  135. if (mapIdx >= 0)
  136. {
  137. fileUuid = fileUuidMap[mapIdx].Uuid;
  138. fileUuidMap.RemoveAt(mapIdx);
  139. }
  140. else
  141. {
  142. fileUuid = Guid.NewGuid();
  143. }
  144. await db.PostFile.AddAsync(new Domain.Entities.Forum.Posts.PostFile
  145. {
  146. BoardID = request.BoardID,
  147. PostID = post.ID,
  148. UUID = fileUuid,
  149. FileName = file.FileName,
  150. HashedName = result.FileName,
  151. Path = uploadPath.ToRelativePath(),
  152. Url = result.Url,
  153. Extension = ext,
  154. ContentType = file.ContentType,
  155. Size = result.Size
  156. }, ct);
  157. fileCount++;
  158. }
  159. }
  160. post.Files = fileCount;
  161. }
  162. // 미디어 처리
  163. if (request.Medias is { Count: > 0 })
  164. {
  165. byte mediaCount = 0;
  166. foreach (var mediaUrl in request.Medias)
  167. {
  168. if (!string.IsNullOrWhiteSpace(mediaUrl))
  169. {
  170. await db.PostMedia.AddAsync(new Domain.Entities.Forum.Posts.PostMedia
  171. {
  172. BoardID = request.BoardID,
  173. PostID = post.ID,
  174. Url = mediaUrl.Trim()
  175. }, ct);
  176. mediaCount++;
  177. }
  178. }
  179. post.Medias = mediaCount;
  180. }
  181. // 태그 처리
  182. if (request.Tags is { Count: > 0 })
  183. {
  184. byte tagCount = 0;
  185. foreach (var tagName in request.Tags)
  186. {
  187. if (string.IsNullOrWhiteSpace(tagName)) continue;
  188. var name = tagName.Trim();
  189. var slug = name.ToLowerInvariant().Replace(' ', '-');
  190. var tag = await db.Tag.FirstOrDefaultAsync(x => x.Name == name, ct);
  191. if (tag is null)
  192. {
  193. tag = new Domain.Entities.Forum.Posts.Tag
  194. {
  195. Name = name,
  196. Slug = slug
  197. };
  198. await db.Tag.AddAsync(tag, ct);
  199. await db.SaveChangesAsync(ct);
  200. }
  201. tag.UsageCount++;
  202. tag.UpdatedAt = DateTime.UtcNow;
  203. await db.PostTag.AddAsync(new Domain.Entities.Forum.Posts.PostTag
  204. {
  205. BoardID = request.BoardID,
  206. PostID = post.ID,
  207. TagID = tag.ID
  208. }, ct);
  209. tagCount++;
  210. }
  211. post.Tags = tagCount;
  212. }
  213. await db.SaveChangesAsync(ct);
  214. // Board 게시글 카운트 증가
  215. var boardEntity = await db.Board.FirstOrDefaultAsync(x => x.ID == request.BoardID, ct);
  216. if (boardEntity is not null)
  217. {
  218. boardEntity.Posts++;
  219. boardEntity.UpdatedAt = DateTime.UtcNow;
  220. var boardGroup = await db.BoardGroup.FirstOrDefaultAsync(x => x.ID == boardEntity.BoardGroupID, ct);
  221. if (boardGroup is not null)
  222. {
  223. boardGroup.Posts++;
  224. boardGroup.UpdatedAt = DateTime.UtcNow;
  225. }
  226. // MemberStats 게시글 수 증가
  227. var memberStats = await db.MemberStats.FirstOrDefaultAsync(x => x.MemberID == request.MemberID, ct);
  228. if (memberStats is not null)
  229. {
  230. memberStats.PostCount++;
  231. }
  232. await db.SaveChangesAsync(ct);
  233. }
  234. // 이메일 알림 발송
  235. if (boardMeta?.NotifyTemplate is not null && boardMeta.Notify is not null)
  236. {
  237. try
  238. {
  239. var notify = boardMeta.Notify;
  240. var template = boardMeta.NotifyTemplate;
  241. var notifyFlags = notify.PostWriteNotifyEnum;
  242. var emailSubject = template.PostWriteEmailNotifySubject;
  243. var emailContent = template.PostWriteEmailNotifyContent;
  244. if (notifyFlags != 0 && !string.IsNullOrWhiteSpace(emailSubject) && !string.IsNullOrWhiteSpace(emailContent))
  245. {
  246. var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  247. // 최고관리자
  248. if (notifyFlags.HasFlag(BoardNotify.Admin))
  249. {
  250. var admins = await db.Member.AsNoTracking().Where(x => x.IsAdmin).ToListAsync(ct);
  251. foreach (var admin in admins)
  252. {
  253. if (!string.IsNullOrWhiteSpace(admin.Email))
  254. {
  255. recipients.Add(admin.Email);
  256. }
  257. }
  258. }
  259. // 게시판 매니저
  260. if (notifyFlags.HasFlag(BoardNotify.Manager))
  261. {
  262. var managers = await db.BoardManager.AsNoTracking()
  263. .Where(x => x.BoardID == request.BoardID)
  264. .ToListAsync(ct);
  265. foreach (var mgr in managers)
  266. {
  267. var mgrMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == mgr.MemberID, ct);
  268. if (mgrMember is not null && !string.IsNullOrWhiteSpace(mgrMember.Email))
  269. {
  270. recipients.Add(mgrMember.Email);
  271. }
  272. }
  273. }
  274. // 본인에게는 발송하지 않음
  275. recipients.Remove(member.Email);
  276. foreach (var email in recipients)
  277. {
  278. await mailService.SendAsync(new SendData(email, emailSubject, emailContent), ct);
  279. }
  280. }
  281. }
  282. catch
  283. {
  284. // 이메일 발송 실패 시 게시글 등록은 성공 처리
  285. }
  286. }
  287. return post.ID;
  288. }
  289. }