using Application.Abstractions.Data; using Application.Abstractions.Hub; using Application.Abstractions.Messaging; using Application.Abstractions.Notification; using Application.Features.Api.Feed.Shared; using Domain.Entities.Feed; using Domain.Entities.Notifications.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; using SharedKernel.Storage; namespace Application.Features.Api.Feed.Create; public sealed class Handler( IAppDbContext db, IFileStorage fileStorage, IFeedBroadcaster broadcaster, INotificationService notificationService ) : ICommandHandler> { private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]; private const int MaxContentLength = 5000; public async Task> Handle(Command request, CancellationToken ct) { if (request.MemberID <= 0) { return Result.Failure(Error.Problem("Feed.MemberRequired", "로그인이 필요합니다.")); } if (string.IsNullOrWhiteSpace(request.Content)) { return Result.Failure(Error.Problem("Feed.ContentRequired", "내용을 입력해주세요.")); } var content = request.Content.Trim(); if (content.Length > MaxContentLength) { return Result.Failure(Error.Problem("Feed.ContentTooLong", $"내용은 최대 {MaxContentLength}자까지 입력 가능합니다.")); } var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(m => m.ID == request.MemberID, ct); if (member is null) { return Result.Failure(Error.NotFound("Feed.MemberNotFound", "회원 정보를 찾을 수 없습니다.")); } // ── FeedPost 본체 저장 ── var feedPost = new FeedPost { MemberID = request.MemberID, Content = content }; await db.FeedPost.AddAsync(feedPost, ct); await db.SaveChangesAsync(ct); var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Feed, feedPost.ID); // ── 이미지 처리 ── string? firstImageUrl = null; if (request.Images is { Count: > 0 }) { byte imageCount = 0; foreach (var image in request.Images) { var result = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExtensions, ct); if (result is null) { continue; } var ext = Path.GetExtension(image.FileName).ToLowerInvariant(); await db.FeedPostImage.AddAsync(new FeedPostImage { FeedPostID = feedPost.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); firstImageUrl ??= result.Url; imageCount++; } feedPost.Images = imageCount; if (firstImageUrl is not null) { feedPost.Thumbnail = firstImageUrl; } } // ── 미디어 URL 처리 ── if (request.Medias is { Count: > 0 }) { byte mediaCount = 0; foreach (var mediaUrl in request.Medias) { if (string.IsNullOrWhiteSpace(mediaUrl)) { continue; } await db.FeedPostMedia.AddAsync(new FeedPostMedia { FeedPostID = feedPost.ID, Url = mediaUrl.Trim() }, ct); mediaCount++; } feedPost.Medias = mediaCount; } // ── 태그 처리: 해시태그 추출 + 요청 태그 병합 ── var hashtagsFromContent = FeedContent.ExtractHashtags(content); var mergedTags = new List(hashtagsFromContent); if (request.Tags is { Count: > 0 }) { foreach (var t in request.Tags) { if (!string.IsNullOrWhiteSpace(t) && !mergedTags.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) { mergedTags.Add(t.Trim()); } } } byte tagCount = 0; foreach (var tagName in mergedTags) { if (string.IsNullOrWhiteSpace(tagName)) { continue; } var name = tagName.Trim(); var slug = name.ToLowerInvariant().Replace(' ', '-'); var tag = await db.FeedTag.FirstOrDefaultAsync(x => x.Name == name, ct); if (tag is null) { tag = new FeedTag { Name = name, Slug = slug }; await db.FeedTag.AddAsync(tag, ct); await db.SaveChangesAsync(ct); } tag.UsageCount++; tag.UpdatedAt = DateTime.UtcNow; await db.FeedPostTag.AddAsync(new FeedPostTag { FeedPostID = feedPost.ID, FeedTagID = tag.ID }, ct); tagCount++; } feedPost.Tags = tagCount; // ── 멘션 처리 ── var mentionSIDs = FeedContent.ExtractMentions(content); var mentionedMemberIDs = new List(); if (mentionSIDs.Count > 0) { var mentionedMembers = await db.Member.AsNoTracking() .Where(m => mentionSIDs.Contains(m.SID) && m.ID != request.MemberID) .Select(m => new { m.ID, m.SID }) .ToListAsync(ct); foreach (var mm in mentionedMembers) { await db.FeedPostMention.AddAsync(new FeedPostMention { FeedPostID = feedPost.ID, MemberID = mm.ID, RawHandle = mm.SID }, ct); mentionedMemberIDs.Add(mm.ID); } } await db.SaveChangesAsync(ct); // ── SignalR 브로드캐스트 + 멘션 알림 ── var authorInfo = new { member.SID, member.Name, member.Thumb }; var toast = new FeedPostToast( PostID: feedPost.ID, AuthorSID: authorInfo.SID, AuthorName: authorInfo.Name, AuthorThumb: authorInfo.Thumb, Subject: FeedContent.Excerpt(content, 60), Thumbnail: feedPost.Thumbnail, CreatedAt: feedPost.CreatedAt ); var followerIDs = await db.MemberFollow.AsNoTracking() .Where(f => f.FolloweeMemberID == request.MemberID && !f.IsMuted && f.NotifyOnPost) .Select(f => f.FollowerMemberID) .ToListAsync(ct); if (followerIDs.Count > 0) { await broadcaster.BroadcastNewPostAsync(followerIDs, toast, ct); } foreach (var mid in mentionedMemberIDs) { await notificationService.SendAsync( memberID: mid, type: NotificationType.NewFeedPost, title: "새 멘션", message: $"{authorInfo.Name ?? authorInfo.SID}님이 회원님을 언급했습니다.", actionUrl: $"/feed/post/{feedPost.ID}", relatedType: "FeedPost", relatedID: feedPost.ID, ct: ct ); } return new Response(feedPost.ID); } }