| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- 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<Command, Result<Response>>
- {
- private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
- private const int MaxContentLength = 5000;
- public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
- {
- if (request.MemberID <= 0)
- {
- return Result.Failure<Response>(Error.Problem("Feed.MemberRequired", "로그인이 필요합니다."));
- }
- if (string.IsNullOrWhiteSpace(request.Content))
- {
- return Result.Failure<Response>(Error.Problem("Feed.ContentRequired", "내용을 입력해주세요."));
- }
- var content = request.Content.Trim();
- if (content.Length > MaxContentLength)
- {
- return Result.Failure<Response>(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<Response>(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<string>(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<int>();
- 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);
- }
- }
|