Handler.cs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Abstractions.Notification;
  4. using Domain.Entities.Developers;
  5. using Domain.Entities.Developers.ValueObject;
  6. using Domain.Entities.Notifications.ValueObject;
  7. using Microsoft.EntityFrameworkCore;
  8. using SharedKernel.Results;
  9. namespace Application.Features.Api.V1.Purchases.Register;
  10. /// <summary>
  11. /// 게임사 결제 등록 — 검증 후 Pending 원장 생성 (지갑 미적립).
  12. /// 수수료는 ConfirmDueAt(+14일, 달력일) 도래 시 ApiPurchaseConfirmService 가 채널주 StoreRevenue 에 적립.
  13. /// SKU 카탈로그 정가와 보고가 불일치 시 PriceMismatched flag (등록은 통과 — Admin 검토 대상).
  14. /// </summary>
  15. internal sealed class Handler(IAppDbContext db, INotificationService notificationService, IAdminAlertService adminAlertService) : ICommandHandler<Command, Result<Response>>
  16. {
  17. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  18. {
  19. // 1. 입력 검증
  20. if (string.IsNullOrWhiteSpace(request.ChannelCode))
  21. {
  22. return Result.Failure<Response>(Error.Problem("Purchase.ChannelCodeRequired", "channelCode 는 필수입니다."));
  23. }
  24. if (string.IsNullOrWhiteSpace(request.OrderID))
  25. {
  26. return Result.Failure<Response>(Error.Problem("Purchase.OrderIDRequired", "orderID 는 필수입니다."));
  27. }
  28. var orderID = request.OrderID.Trim();
  29. if (orderID.Length > 255)
  30. {
  31. return Result.Failure<Response>(Error.Problem("Purchase.OrderIDTooLong", "orderID 는 255자 이하여야 합니다."));
  32. }
  33. if (!Enum.IsDefined(typeof(ApiPurchaseMarketplace), request.Marketplace))
  34. {
  35. return Result.Failure<Response>(Error.Problem("Purchase.MarketplaceInvalid", "marketplace 는 1(구글)~6(기타) 값이어야 합니다."));
  36. }
  37. if (string.IsNullOrWhiteSpace(request.GameCode))
  38. {
  39. return Result.Failure<Response>(Error.Problem("Purchase.GameCodeRequired", "gameCode 는 필수입니다."));
  40. }
  41. if (request.OrderPrice <= 0)
  42. {
  43. return Result.Failure<Response>(Error.Problem("Purchase.OrderPriceInvalid", "orderPrice 는 1 이상이어야 합니다."));
  44. }
  45. if (request.ProductID is not null && request.ProductID.Trim().Length > 100)
  46. {
  47. return Result.Failure<Response>(Error.Problem("Purchase.ProductIDTooLong", "productID 는 100자 이하여야 합니다."));
  48. }
  49. if (request.SubID is not null && request.SubID.Trim().Length > 100)
  50. {
  51. return Result.Failure<Response>(Error.Problem("Purchase.SubIDTooLong", "subID 는 100자 이하여야 합니다."));
  52. }
  53. var marketplace = (ApiPurchaseMarketplace)request.Marketplace;
  54. // 2. 앱 상태 (토큰 발급 후 정지되었을 수 있음)
  55. var appActive = await db.ApiApplication.AsNoTracking().AnyAsync(c => c.ID == request.ApplicationID && c.Status == ApplicationStatus.Active, ct);
  56. if (!appActive)
  57. {
  58. return Result.Failure<Response>(Error.Forbidden("Purchase.AppNotActive", "앱이 활성 상태가 아닙니다."));
  59. }
  60. // 3. 채널 (후원 코드 — 대문자 저장)
  61. var channelCode = request.ChannelCode.Trim().ToUpperInvariant();
  62. var channel = await db.Channel
  63. .AsNoTracking()
  64. .Where(c => c.DonationCode == channelCode)
  65. .Select(c => new
  66. {
  67. c.ID,
  68. c.MemberID,
  69. c.SID,
  70. c.Name,
  71. c.IsActive,
  72. MemberGone = c.Member.IsWithdraw || c.Member.DeletedAt != null
  73. })
  74. .FirstOrDefaultAsync(ct);
  75. if (channel is null)
  76. {
  77. return Result.Failure<Response>(Error.NotFound("Channel.NotFound", "해당 후원 코드의 채널을 찾을 수 없습니다."));
  78. }
  79. if (!channel.IsActive || channel.MemberGone)
  80. {
  81. return Result.Failure<Response>(Error.Problem("Channel.Inactive", "비활성 또는 탈퇴한 채널입니다."));
  82. }
  83. // 4. 게임
  84. var gameCode = request.GameCode.Trim();
  85. var game = await db.Game
  86. .AsNoTracking()
  87. .Where(c => c.Code == gameCode)
  88. .Select(c => new
  89. {
  90. c.ID,
  91. c.Code,
  92. c.KorName,
  93. c.IsActive,
  94. c.ApiCommissionRate
  95. })
  96. .FirstOrDefaultAsync(ct);
  97. if (game is null)
  98. {
  99. return Result.Failure<Response>(Error.NotFound("Game.NotFound", "해당 코드의 게임을 찾을 수 없습니다."));
  100. }
  101. if (!game.IsActive)
  102. {
  103. return Result.Failure<Response>(Error.Problem("Game.Inactive", "비활성 게임입니다."));
  104. }
  105. if (game.ApiCommissionRate <= 0)
  106. {
  107. return Result.Failure<Response>(Error.Problem("Game.ApiCommissionNotConfigured", "해당 게임은 결제 보고 수수료 대상이 아닙니다."));
  108. }
  109. // 5. 중복 방지 — (앱, marketplace, orderID) 멱등
  110. var duplicated = await db.ApiPurchase.AsNoTracking().AnyAsync(c => c.ApplicationID == request.ApplicationID && c.Marketplace == marketplace && c.OrderID == orderID, ct);
  111. if (duplicated)
  112. {
  113. return Result.Failure<Response>(Error.Conflict("Purchase.Duplicate", "이미 등록된 주문입니다. (marketplace + orderID 중복)"));
  114. }
  115. // 6. SKU 카탈로그 대조 (신뢰 레이어 — 미등록 SKU 는 통과)
  116. int? catalogPrice = null;
  117. var productID = string.IsNullOrWhiteSpace(request.ProductID) ? null : request.ProductID.Trim();
  118. if (productID is not null)
  119. {
  120. catalogPrice = await db.GameProductCatalog.AsNoTracking().Where(c => c.GameID == game.ID && c.ProductID == productID && c.IsActive).Select(c => (int?)c.Price).FirstOrDefaultAsync(ct);
  121. }
  122. // 7. 원장 생성 — Pending (지갑 미적립, 확정 배치가 적립)
  123. var purchase = ApiPurchase.Create(
  124. applicationID: request.ApplicationID,
  125. channelID: channel.ID,
  126. creditedMemberID: channel.MemberID,
  127. gameID: game.ID,
  128. orderID: orderID,
  129. marketplace: marketplace,
  130. orderPrice: request.OrderPrice,
  131. commissionRate: game.ApiCommissionRate,
  132. productID: productID,
  133. subID: request.SubID,
  134. catalogPrice: catalogPrice
  135. );
  136. db.ApiPurchase.Add(purchase);
  137. try
  138. {
  139. await db.SaveChangesAsync(ct);
  140. }
  141. catch (DbUpdateException)
  142. {
  143. // 동시 요청이 unique index (ApplicationID, Marketplace, OrderID) 에 경합한 경우
  144. return Result.Failure<Response>(Error.Conflict("Purchase.Duplicate", "이미 등록된 주문입니다. (marketplace + orderID 중복)"));
  145. }
  146. // 8. 채널주 알림 — 보류 적립 예정
  147. await notificationService.SendAsync(
  148. memberID: channel.MemberID,
  149. type: NotificationType.ApiCommissionPending,
  150. title: "판매 수수료 보류 적립",
  151. message: $"「{game.KorName}」 결제 보고 — 수수료 {purchase.CommissionAmount:N0}원이 {purchase.ConfirmDueAt:yyyy-MM-dd} 확정 예정입니다.",
  152. actionUrl: "/studio/wallet/revenue",
  153. relatedType: "ApiPurchase",
  154. relatedID: purchase.ID,
  155. imageUrl: null,
  156. ct: ct
  157. );
  158. // 운영 알림 — 활동 방 (전 건) + 정가 불일치 시 알림 방
  159. var marketLabel = marketplace switch
  160. {
  161. ApiPurchaseMarketplace.GooglePlay => "구글",
  162. ApiPurchaseMarketplace.AppStore => "애플",
  163. ApiPurchaseMarketplace.Microsoft => "MS",
  164. ApiPurchaseMarketplace.GalaxyStore => "갤럭시",
  165. ApiPurchaseMarketplace.OneStore => "원스토어",
  166. _ => "기타"
  167. };
  168. await adminAlertService.SendActivityAsync($"💳 결제 등록 — {game.KorName} / {channel.Name}({channelCode}) / {purchase.OrderPrice:N0}원 → 수수료 {purchase.CommissionAmount:N0}원 / {marketLabel} / {purchase.OrderID}", ct);
  169. if (purchase.PriceMismatched)
  170. {
  171. await adminAlertService.SendAlertAsync($"⚠️ 정가 불일치 — {game.KorName} SKU {productID}: 보고 {purchase.OrderPrice:N0}원 vs 정가 {catalogPrice:N0}원 / {channel.Name}({channelCode}) / {purchase.OrderID}", ct);
  172. }
  173. return Result.Success(new Response(
  174. purchase.ID,
  175. purchase.OrderID,
  176. (int)purchase.Marketplace,
  177. channelCode,
  178. channel.SID,
  179. channel.Name,
  180. game.Code!,
  181. purchase.OrderPrice,
  182. purchase.CommissionRate,
  183. purchase.CommissionAmount,
  184. purchase.Status.ToString(),
  185. purchase.ConfirmDueAt,
  186. purchase.CreatedAt
  187. ));
  188. }
  189. }