Handler.cs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Hub;
  3. using Application.Abstractions.Messaging;
  4. using Application.Abstractions.YouTube;
  5. using SharedKernel.Results;
  6. using Domain.Entities.Donations.ValueObject;
  7. using Microsoft.EntityFrameworkCore;
  8. namespace Application.Features.Api.Crew.GetCrewMembers;
  9. internal sealed class Handler(
  10. IAppDbContext db,
  11. IPresenceTracker presence,
  12. IYouTubeChannelCache channelCache
  13. ) : IQueryHandler<Query, Result<Response>>
  14. {
  15. public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
  16. {
  17. var crew = await db.Crew.AsNoTracking()
  18. .FirstOrDefaultAsync(c => c.ID == request.CrewID, ct);
  19. if (crew is null)
  20. {
  21. return Result.Failure<Response>(Error.NotFound("Crew.NotFound", "크루를 찾을 수 없습니다."));
  22. }
  23. // 1) 확정 멤버 (Accepted)
  24. var members = await db.CrewMember.AsNoTracking()
  25. .Where(m => m.CrewID == request.CrewID)
  26. .OrderBy(m => m.SortOrder)
  27. .ThenBy(m => m.JoinedAt)
  28. .Select(m => new
  29. {
  30. m.ID,
  31. m.MemberID,
  32. m.ChannelID,
  33. m.Nickname,
  34. m.Role,
  35. m.SortOrder,
  36. m.IsActive,
  37. m.JoinedAt,
  38. Thumb = m.Member != null ? m.Member.Thumb : null,
  39. ChannelName = m.Channel != null ? m.Channel.Name : null,
  40. ChannelSID = m.Channel != null ? m.Channel.SID : null,
  41. ChannelDbThumb = m.Channel != null ? m.Channel.ThumbnailUrl : null
  42. })
  43. .ToListAsync(ct);
  44. // 2) 미응답/거절/만료 초대
  45. var invitations = await db.CrewInvitation.AsNoTracking()
  46. .Where(i => i.CrewID == request.CrewID && i.Status != CrewInviteStatus.Accepted)
  47. .OrderByDescending(i => i.InvitedAt)
  48. .Select(i => new
  49. {
  50. ID = -i.ID, // 음수: CrewMember.ID와 충돌 방지
  51. MemberID = i.TargetMemberID,
  52. ChannelID = (int?)null,
  53. i.Nickname,
  54. i.Role,
  55. i.SortOrder,
  56. IsActive = false,
  57. JoinedAt = i.InvitedAt,
  58. Thumb = i.TargetMember != null ? i.TargetMember.Thumb : null,
  59. ChannelInfo = db.Channel.AsNoTracking()
  60. .Where(c => c.MemberID == i.TargetMemberID)
  61. .Select(c => new { c.Name, c.SID, c.ThumbnailUrl })
  62. .FirstOrDefault(),
  63. i.Status,
  64. i.ExpiresAt
  65. })
  66. .ToListAsync(ct);
  67. // 3) 모든 MemberID 일괄 online 체크 (Redis 1회)
  68. var allMemberIDs = members.Select(m => m.MemberID)
  69. .Concat(invitations.Select(i => i.MemberID))
  70. .Distinct()
  71. .ToList();
  72. var onlineSet = await presence.GetOnlineMembersAsync(allMemberIDs);
  73. // 4) 채널 SID 일괄 수집 → YouTube 캐시 조회 (Redis MGET 1회, 미스 시 DB ThumbnailUrl fallback)
  74. var channelSIDs = members.Where(m => m.ChannelSID != null).Select(m => m.ChannelSID!)
  75. .Concat(invitations.Where(i => i.ChannelInfo != null).Select(i => i.ChannelInfo!.SID))
  76. .Distinct()
  77. .ToList();
  78. var ytCache = channelSIDs.Count > 0
  79. ? await channelCache.GetManyAsync(channelSIDs)
  80. : [];
  81. string? ResolveChannelThumb(string? sid, string? dbThumb)
  82. {
  83. if (sid == null) { return null; }
  84. return ytCache.TryGetValue(sid, out var info) ? info.ThumbnailUrl : dbThumb;
  85. }
  86. // 5) 통합 응답
  87. var combined = new List<CrewMemberItem>(members.Count + invitations.Count);
  88. foreach (var m in members)
  89. {
  90. combined.Add(new CrewMemberItem(
  91. m.ID, m.MemberID, m.ChannelID, m.Nickname, m.Role,
  92. m.SortOrder, m.IsActive, m.JoinedAt,
  93. m.Thumb, m.ChannelName,
  94. ResolveChannelThumb(m.ChannelSID, m.ChannelDbThumb),
  95. "Accepted", null,
  96. onlineSet.Contains(m.MemberID)
  97. ));
  98. }
  99. var now = DateTime.UtcNow;
  100. foreach (var i in invitations)
  101. {
  102. // lazy expiration: Pending인데 ExpiresAt 지난 것은 Expired 처리
  103. var isExpired = i.Status == CrewInviteStatus.Pending && i.ExpiresAt < now;
  104. var statusLabel = i.Status == CrewInviteStatus.Declined ? "Declined"
  105. : isExpired || i.Status == CrewInviteStatus.Expired ? "Expired"
  106. : "Pending";
  107. combined.Add(new CrewMemberItem(
  108. i.ID, i.MemberID, i.ChannelID, i.Nickname, i.Role,
  109. i.SortOrder, i.IsActive, i.JoinedAt,
  110. i.Thumb, i.ChannelInfo?.Name,
  111. ResolveChannelThumb(i.ChannelInfo?.SID, i.ChannelInfo?.ThumbnailUrl),
  112. statusLabel,
  113. statusLabel == "Pending" ? i.ExpiresAt : null,
  114. onlineSet.Contains(i.MemberID)
  115. ));
  116. }
  117. return Result.Success(new Response(combined));
  118. }
  119. }