Handler.cs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Abstractions.Notification;
  4. using Domain.Entities.Common;
  5. using Domain.Entities.Common.ValueObject;
  6. using Domain.Entities.Members;
  7. using Domain.Entities.Members.ValueObject;
  8. using Domain.Entities.Notifications.ValueObject;
  9. using Microsoft.EntityFrameworkCore;
  10. using SharedKernel.Results;
  11. namespace Application.Features.Api.Forum.PostCheer.Send;
  12. /// <summary>
  13. /// 분석글 응원(Cheer) 발신 — D3 M4 (Twitch Bits 모델).
  14. /// 후원자 지갑에서 캐시→토큰(CheerSpendOrder) 순으로 총액을 차감하고, 수수료를 제외한 순액을
  15. /// 작성자 지갑 Reward 파티션(출금 불가·상점/모의투자 소비 전용)으로 적립한다. 수수료는 소각(적립 없음).
  16. ///
  17. /// 규제 가드라인(§⑧): 응원은 사후·자율·무대가이며 글 열람 조건이 아니다(글은 항상 무료 — 언락 아님).
  18. /// 본인 글 응원 금지 + 일일 발신 캡으로 자전 응원(수수료만 소각) 어뷰징을 차단한다.
  19. /// 두 지갑을 동일 DbContext 에서 로드·수정 후 단일 SaveChanges 로 원자 커밋한다.
  20. /// </summary>
  21. public sealed class Handler(IAppDbContext db, INotificationService notifications) : ICommandHandler<Command, Result<int>>
  22. {
  23. private static readonly TimeZoneInfo KstZone = ResolveKst();
  24. public async Task<Result<int>> Handle(Command request, CancellationToken ct)
  25. {
  26. // 게시글 존재 + 작성자(수신자) 확인
  27. var post = await db.Post.AsNoTracking()
  28. .Where(c => c.ID == request.PostID && !c.IsDeleted)
  29. .Select(c => new { c.ID, c.MemberID, c.Subject })
  30. .FirstOrDefaultAsync(ct);
  31. if (post is null)
  32. {
  33. return Result.Failure<int>(Error.NotFound("Cheer.PostNotFound", "게시글을 찾을 수 없습니다."));
  34. }
  35. if (post.MemberID is not int authorID || authorID <= 0)
  36. {
  37. return Result.Failure<int>(Error.Problem("Cheer.NoAuthor", "작성자가 없는 글에는 응원할 수 없습니다."));
  38. }
  39. // 본인 글 응원 금지 (자전 응원으로 수수료만 소각하는 어뷰징 차단 — §⑧)
  40. if (authorID == request.MemberID)
  41. {
  42. return Result.Failure<int>(Error.Problem("Cheer.SelfNotAllowed", "본인 글에는 응원할 수 없습니다."));
  43. }
  44. var config = await LoadRewardConfigAsync(ct);
  45. if (config is null)
  46. {
  47. return Result.Failure<int>(Error.Problem("Cheer.ConfigMissing", "응원 설정이 없습니다."));
  48. }
  49. var minAmount = Math.Max(1, config.CheerMinAmount);
  50. if (request.Amount < minAmount)
  51. {
  52. return Result.Failure<int>(Error.Problem("Cheer.BelowMin", $"응원은 최소 {minAmount} 이상이어야 합니다."));
  53. }
  54. // 일일 발신 총액 캡 (0 = 무제한). SENDER 기준 오늘(KST) PostCheer.Amount 합산.
  55. if (config.CheerDailyMax > 0)
  56. {
  57. var (startUtc, endUtc) = TodayKstRangeUtc();
  58. var sentToday = await db.PostCheer.AsNoTracking()
  59. .Where(c => c.FromMemberID == request.MemberID && c.CreatedAt >= startUtc && c.CreatedAt < endUtc)
  60. .SumAsync(c => (long)c.Amount, ct);
  61. if (sentToday + request.Amount > config.CheerDailyMax)
  62. {
  63. return Result.Failure<int>(Error.Problem("Cheer.DailyCapExceeded", $"오늘 보낼 수 있는 응원 총액({config.CheerDailyMax})을 초과했습니다."));
  64. }
  65. }
  66. // 수수료/순액 산정 — fee = floor(amount × feePercent/100)
  67. var feePercent = Math.Clamp(config.CheerFeePercent, 0, 100);
  68. var fee = (int)((long)request.Amount * feePercent / 100);
  69. var net = request.Amount - fee;
  70. if (net <= 0)
  71. {
  72. return Result.Failure<int>(Error.Problem("Cheer.NetNotPositive", "수수료 차감 후 지급액이 0 이하입니다."));
  73. }
  74. // 발신자 지갑 (캐시+토큰 파티션 로드)
  75. var fromWallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct);
  76. if (fromWallet is null)
  77. {
  78. return Result.Failure<int>(Error.NotFound("Cheer.WalletNotFound", "지갑을 찾을 수 없습니다."));
  79. }
  80. if (fromWallet.GetCheerAvailable().Value < request.Amount)
  81. {
  82. return Result.Failure<int>(Error.Problem("Cheer.InsufficientBalance", "잔액이 부족합니다."));
  83. }
  84. // 수신자 지갑
  85. var toWallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == authorID, ct);
  86. if (toWallet is null)
  87. {
  88. return Result.Failure<int>(Error.NotFound("Cheer.AuthorWalletNotFound", "작성자 지갑을 찾을 수 없습니다."));
  89. }
  90. // 두 지갑 트랜잭션의 멱등 키 — 사전 생성 GUID (동일 요청의 CheerSent/CheerReceived 를 한 참조로 묶음)
  91. var refID = $"cheer:{Guid.NewGuid():N}";
  92. var message = string.IsNullOrWhiteSpace(request.Message)
  93. ? null
  94. : request.Message.Trim();
  95. if (message is { Length: > 100 })
  96. {
  97. message = message[..100];
  98. }
  99. // 차감(총액) → 순액 적립. 수수료(fee)는 어디에도 적립하지 않음 = 소각(유통 제거).
  100. fromWallet.DebitForCheer(Money.KRW(request.Amount), "CHEER_SENT", refID);
  101. toWallet.CreditCheerReceived(Money.KRW(net), "CHEER_RECEIVED", refID);
  102. var cheer = Domain.Entities.Forum.Posts.PostCheer.Create(
  103. postID: post.ID,
  104. fromMemberID: request.MemberID,
  105. toMemberID: authorID,
  106. amount: request.Amount,
  107. feeAmount: fee,
  108. netAmount: net,
  109. message: message
  110. );
  111. db.PostCheer.Add(cheer);
  112. // 수신자 일일 카운터에 추적용 기록 (CheerReceived=6, 수신은 캡 미적용 — 통계/어뷰징 모니터링용)
  113. var today = TodayKst();
  114. var counter = await db.MemberDailyRewardCounter
  115. .FirstOrDefaultAsync(c => c.MemberID == authorID && c.RewardDate == today && c.ActionType == RewardActionType.CheerReceived, ct);
  116. if (counter is null)
  117. {
  118. counter = MemberDailyRewardCounter.Create(authorID, today, RewardActionType.CheerReceived);
  119. counter.Add(0, net);
  120. db.MemberDailyRewardCounter.Add(counter);
  121. }
  122. else
  123. {
  124. counter.Add(0, net);
  125. }
  126. // 단일 커밋 — 두 지갑 차감/적립 + PostCheer + 카운터가 하나의 트랜잭션.
  127. await db.SaveChangesAsync(ct);
  128. // 작성자 알림 (best-effort — 자체 커밋). 커밋 완료 후이므로 실패해도 응원은 확정.
  129. await notifications.SendAsync(
  130. authorID,
  131. NotificationType.SystemAnnounce,
  132. "응원을 받았습니다",
  133. $"회원님의 글에 {request.Amount} 응원이 도착했습니다. (수령 {net})",
  134. actionUrl: $"/forum/posts/{post.ID}",
  135. relatedType: "PostCheer",
  136. relatedID: cheer.ID,
  137. imageUrl: null,
  138. ct: ct);
  139. return Result.Success(cheer.ID);
  140. }
  141. /// <summary>DB Config(권위본)에서 RewardConfig 로드. 쓰기 핸들러이므로 캐시 대신 최신 row 를 읽는다.</summary>
  142. private async Task<RewardConfig?> LoadRewardConfigAsync(CancellationToken ct)
  143. {
  144. var entity = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).FirstOrDefaultAsync(ct);
  145. return entity?.Reward;
  146. }
  147. private static DateOnly TodayKst()
  148. {
  149. var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone);
  150. return DateOnly.FromDateTime(nowKst);
  151. }
  152. /// <summary>오늘(KST) 자정~내일 자정 구간을 UTC 로 환산 — CreatedAt(UTC) 필터용.</summary>
  153. private static (DateTime StartUtc, DateTime EndUtc) TodayKstRangeUtc()
  154. {
  155. var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone);
  156. var startKst = DateTime.SpecifyKind(nowKst.Date, DateTimeKind.Unspecified);
  157. var startUtc = TimeZoneInfo.ConvertTimeToUtc(startKst, KstZone);
  158. return (startUtc, startUtc.AddDays(1));
  159. }
  160. private static TimeZoneInfo ResolveKst()
  161. {
  162. if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz))
  163. {
  164. return tz;
  165. }
  166. if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz))
  167. {
  168. return tz;
  169. }
  170. return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST");
  171. }
  172. }