Handler.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Hub;
  3. using Application.Abstractions.Messaging;
  4. using Application.Abstractions.Notification;
  5. using Application.Features.Api.Feed.Shared;
  6. using Domain.Entities.Feed;
  7. using Domain.Entities.Notifications.ValueObject;
  8. using Microsoft.EntityFrameworkCore;
  9. using SharedKernel.Results;
  10. using SharedKernel.Storage;
  11. namespace Application.Features.Api.Feed.Create;
  12. public sealed class Handler(
  13. IAppDbContext db,
  14. IFileStorage fileStorage,
  15. IFeedBroadcaster broadcaster,
  16. INotificationService notificationService
  17. ) : ICommandHandler<Command, Result<Response>>
  18. {
  19. private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
  20. private const int MaxContentLength = 5000;
  21. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  22. {
  23. if (request.MemberID <= 0)
  24. {
  25. return Result.Failure<Response>(Error.Problem("Feed.MemberRequired", "로그인이 필요합니다."));
  26. }
  27. if (string.IsNullOrWhiteSpace(request.Content))
  28. {
  29. return Result.Failure<Response>(Error.Problem("Feed.ContentRequired", "내용을 입력해주세요."));
  30. }
  31. var content = request.Content.Trim();
  32. if (content.Length > MaxContentLength)
  33. {
  34. return Result.Failure<Response>(Error.Problem("Feed.ContentTooLong", $"내용은 최대 {MaxContentLength}자까지 입력 가능합니다."));
  35. }
  36. var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(m => m.ID == request.MemberID, ct);
  37. if (member is null)
  38. {
  39. return Result.Failure<Response>(Error.NotFound("Feed.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
  40. }
  41. // ── FeedPost 본체 저장 ──
  42. var feedPost = new FeedPost
  43. {
  44. MemberID = request.MemberID,
  45. Content = content
  46. };
  47. await db.FeedPost.AddAsync(feedPost, ct);
  48. await db.SaveChangesAsync(ct);
  49. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Feed, feedPost.ID);
  50. // ── 이미지 처리 ──
  51. string? firstImageUrl = null;
  52. if (request.Images is { Count: > 0 })
  53. {
  54. byte imageCount = 0;
  55. foreach (var image in request.Images)
  56. {
  57. var result = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExtensions, ct);
  58. if (result is null)
  59. {
  60. continue;
  61. }
  62. var ext = Path.GetExtension(image.FileName).ToLowerInvariant();
  63. await db.FeedPostImage.AddAsync(new FeedPostImage
  64. {
  65. FeedPostID = feedPost.ID,
  66. FileName = image.FileName,
  67. HashedName = result.FileName,
  68. Path = uploadPath.ToRelativePath(),
  69. Url = result.Url,
  70. Extension = ext,
  71. ContentType = image.ContentType,
  72. Size = result.Size,
  73. Width = result.Width,
  74. Height = result.Height
  75. }, ct);
  76. firstImageUrl ??= result.Url;
  77. imageCount++;
  78. }
  79. feedPost.Images = imageCount;
  80. if (firstImageUrl is not null)
  81. {
  82. feedPost.Thumbnail = firstImageUrl;
  83. }
  84. }
  85. // ── 미디어 URL 처리 ──
  86. if (request.Medias is { Count: > 0 })
  87. {
  88. byte mediaCount = 0;
  89. foreach (var mediaUrl in request.Medias)
  90. {
  91. if (string.IsNullOrWhiteSpace(mediaUrl))
  92. {
  93. continue;
  94. }
  95. await db.FeedPostMedia.AddAsync(new FeedPostMedia
  96. {
  97. FeedPostID = feedPost.ID,
  98. Url = mediaUrl.Trim()
  99. }, ct);
  100. mediaCount++;
  101. }
  102. feedPost.Medias = mediaCount;
  103. }
  104. // ── 태그 처리: 해시태그 추출 + 요청 태그 병합 ──
  105. var hashtagsFromContent = FeedContent.ExtractHashtags(content);
  106. var mergedTags = new List<string>(hashtagsFromContent);
  107. if (request.Tags is { Count: > 0 })
  108. {
  109. foreach (var t in request.Tags)
  110. {
  111. if (!string.IsNullOrWhiteSpace(t) && !mergedTags.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase)))
  112. {
  113. mergedTags.Add(t.Trim());
  114. }
  115. }
  116. }
  117. byte tagCount = 0;
  118. foreach (var tagName in mergedTags)
  119. {
  120. if (string.IsNullOrWhiteSpace(tagName))
  121. {
  122. continue;
  123. }
  124. var name = tagName.Trim();
  125. var slug = name.ToLowerInvariant().Replace(' ', '-');
  126. var tag = await db.FeedTag.FirstOrDefaultAsync(x => x.Name == name, ct);
  127. if (tag is null)
  128. {
  129. tag = new FeedTag
  130. {
  131. Name = name,
  132. Slug = slug
  133. };
  134. await db.FeedTag.AddAsync(tag, ct);
  135. await db.SaveChangesAsync(ct);
  136. }
  137. tag.UsageCount++;
  138. tag.UpdatedAt = DateTime.UtcNow;
  139. await db.FeedPostTag.AddAsync(new FeedPostTag
  140. {
  141. FeedPostID = feedPost.ID,
  142. FeedTagID = tag.ID
  143. }, ct);
  144. tagCount++;
  145. }
  146. feedPost.Tags = tagCount;
  147. // ── 멘션 처리 ──
  148. var mentionSIDs = FeedContent.ExtractMentions(content);
  149. var mentionedMemberIDs = new List<int>();
  150. if (mentionSIDs.Count > 0)
  151. {
  152. var mentionedMembers = await db.Member.AsNoTracking()
  153. .Where(m => mentionSIDs.Contains(m.SID) && m.ID != request.MemberID)
  154. .Select(m => new { m.ID, m.SID })
  155. .ToListAsync(ct);
  156. foreach (var mm in mentionedMembers)
  157. {
  158. await db.FeedPostMention.AddAsync(new FeedPostMention
  159. {
  160. FeedPostID = feedPost.ID,
  161. MemberID = mm.ID,
  162. RawHandle = mm.SID
  163. }, ct);
  164. mentionedMemberIDs.Add(mm.ID);
  165. }
  166. }
  167. await db.SaveChangesAsync(ct);
  168. // ── SignalR 브로드캐스트 + 멘션 알림 ──
  169. var authorInfo = new
  170. {
  171. member.SID,
  172. member.Name,
  173. member.Thumb
  174. };
  175. var toast = new FeedPostToast(
  176. PostID: feedPost.ID,
  177. AuthorSID: authorInfo.SID,
  178. AuthorName: authorInfo.Name,
  179. AuthorThumb: authorInfo.Thumb,
  180. Subject: FeedContent.Excerpt(content, 60),
  181. Thumbnail: feedPost.Thumbnail,
  182. CreatedAt: feedPost.CreatedAt
  183. );
  184. var followerIDs = await db.MemberFollow.AsNoTracking()
  185. .Where(f => f.FolloweeMemberID == request.MemberID && !f.IsMuted && f.NotifyOnPost)
  186. .Select(f => f.FollowerMemberID)
  187. .ToListAsync(ct);
  188. if (followerIDs.Count > 0)
  189. {
  190. await broadcaster.BroadcastNewPostAsync(followerIDs, toast, ct);
  191. }
  192. foreach (var mid in mentionedMemberIDs)
  193. {
  194. await notificationService.SendAsync(
  195. memberID: mid,
  196. type: NotificationType.NewFeedPost,
  197. title: "새 멘션",
  198. message: $"{authorInfo.Name ?? authorInfo.SID}님이 회원님을 언급했습니다.",
  199. actionUrl: $"/feed/post/{feedPost.ID}",
  200. relatedType: "FeedPost",
  201. relatedID: feedPost.ID,
  202. ct: ct
  203. );
  204. }
  205. return new Response(feedPost.ID);
  206. }
  207. }