Handler.cs 14 KB

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