Handler.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Abstractions.Notification;
  4. using Domain.Entities.Common.ValueObject;
  5. using Domain.Entities.Notifications.ValueObject;
  6. using Domain.Entities.Store.ValueObject;
  7. using Domain.Entities.Wallets.ValueObject;
  8. using Microsoft.EntityFrameworkCore;
  9. using SharedKernel.Results;
  10. using OrderEntity = Domain.Entities.Store.Order;
  11. using OrderItemEntity = Domain.Entities.Store.OrderItem;
  12. using GameSettlementEntity = Domain.Entities.Store.GameSettlement;
  13. using MemberInventoryEntity = Domain.Entities.Store.MemberInventory;
  14. namespace Application.Features.Api.Store.Orders.Place;
  15. /// <summary>
  16. /// 상점 결제 핸들러 — 사용자 정책 핵심:
  17. /// • PgCharged(다날 충전 예치금) 잔액에서만 차감 (Donation/Reward 사용 불가)
  18. /// • 채널 선택 시 채널주의 신규 StoreRevenue 잔액에 즉시 입금 (Donation 과 분리)
  19. /// • 게임사 몫은 GameSettlement 큐에 적재 (월별 정산)
  20. /// • Digital 상품은 결제 즉시 쿠폰 코드 발급 + MemberInventory 등록 + Delivered 점프
  21. /// • Physical 상품은 Paid 상태로 머무름 (이후 배송 단계 진행)
  22. /// </summary>
  23. internal sealed class Handler(IAppDbContext db, INotificationService notificationService) : ICommandHandler<Command, Result<Response>>
  24. {
  25. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  26. {
  27. // 0. 입력 검증
  28. if (request.Items is null || request.Items.Count == 0)
  29. {
  30. return Result.Failure<Response>(Error.Problem("Order.NoItems", "주문할 상품이 없습니다."));
  31. }
  32. if (request.Items.Any(i => i.Quantity <= 0))
  33. {
  34. return Result.Failure<Response>(Error.Problem("Order.InvalidQuantity", "수량은 1 이상이어야 합니다."));
  35. }
  36. // 1. 채널 검증 (선택된 경우) — StoreCommissionRate 스냅샷 동시 확보
  37. int? channelMemberID = null;
  38. decimal channelStoreCommissionRate = 0;
  39. if (request.ChannelID.HasValue)
  40. {
  41. var channel = await db.Channel.AsNoTracking()
  42. .Where(c => c.ID == request.ChannelID.Value && c.IsActive)
  43. .Select(c => new { c.ID, c.MemberID, c.StoreCommissionRate })
  44. .FirstOrDefaultAsync(ct);
  45. if (channel is null)
  46. {
  47. return Result.Failure<Response>(Error.NotFound("Order.ChannelNotFound", "후원 채널을 찾을 수 없습니다."));
  48. }
  49. // 본인 채널에는 채널 보상 적용 금지 (자기 자신에게 채널 보상 송금 차단)
  50. if (channel.MemberID == request.MemberID)
  51. {
  52. return Result.Failure<Response>(Error.Problem("Order.SelfChannelNotAllowed", "본인 채널은 후원 코드로 선택할 수 없습니다."));
  53. }
  54. channelMemberID = channel.MemberID;
  55. channelStoreCommissionRate = channel.StoreCommissionRate;
  56. }
  57. // 2. 상품 로드 (Include Game)
  58. var productIDs = request.Items.Select(i => i.ProductID).Distinct().ToList();
  59. var products = await db.Product
  60. .Include(p => p.Game)
  61. .Where(p => productIDs.Contains(p.ID))
  62. .ToListAsync(ct);
  63. if (products.Count != productIDs.Count)
  64. {
  65. return Result.Failure<Response>(Error.NotFound("Order.ProductNotFound", "일부 상품을 찾을 수 없습니다."));
  66. }
  67. var now = DateTime.UtcNow;
  68. foreach (var p in products)
  69. {
  70. if (!p.IsActive)
  71. {
  72. return Result.Failure<Response>(Error.Problem("Order.ProductInactive", $"비활성 상품입니다: {p.Name}"));
  73. }
  74. if (p.SaleStartAt.HasValue && p.SaleStartAt.Value > now)
  75. {
  76. return Result.Failure<Response>(Error.Problem("Order.NotYetOnSale", $"판매 시작 전 상품입니다: {p.Name}"));
  77. }
  78. if (p.SaleEndAt.HasValue && p.SaleEndAt.Value <= now)
  79. {
  80. return Result.Failure<Response>(Error.Problem("Order.SaleEnded", $"판매 종료된 상품입니다: {p.Name}"));
  81. }
  82. }
  83. // 2.5 후원 채널 필수 상품 검증 — 하나라도 필수면 ChannelID 가 반드시 있어야 함 (프런트 우회 방어)
  84. if (!request.ChannelID.HasValue && products.Any(p => p.RequireDonationChannel))
  85. {
  86. return Result.Failure<Response>(Error.Problem("Order.DonationChannelRequired", "후원 채널 선택이 필수인 상품이 포함되어 있습니다. 후원 채널을 선택해 주세요."));
  87. }
  88. // 3. 라인별 합계 + 게임별 그룹화 (수익 배분용)
  89. var lineTotals = new Dictionary<int, int>(); // ProductID -> Quantity (입력 중복 합산)
  90. foreach (var item in request.Items)
  91. {
  92. lineTotals[item.ProductID] = lineTotals.GetValueOrDefault(item.ProductID) + item.Quantity;
  93. }
  94. var productByID = products.ToDictionary(p => p.ID);
  95. var totalAmount = 0;
  96. int gameRevenueTotal = 0;
  97. int channelRewardTotal = 0;
  98. var perGameRevenue = new Dictionary<int, int>(); // GameID -> 누적 GameRevenueAmount (정산 큐 적재용)
  99. foreach (var (productID, qty) in lineTotals)
  100. {
  101. var p = productByID[productID];
  102. if (qty < p.MinPurchase)
  103. {
  104. return Result.Failure<Response>(Error.Problem("Order.BelowMinPurchase",
  105. $"{p.Name}: 최소 {p.MinPurchase}개 이상 주문해야 합니다."));
  106. }
  107. if (qty > p.MaxPurchase)
  108. {
  109. return Result.Failure<Response>(Error.Problem("Order.AboveMaxPurchase",
  110. $"{p.Name}: 최대 {p.MaxPurchase}개까지 주문 가능합니다."));
  111. }
  112. // 할인 반영 — 서버 기준 GetSalePrice() 사용 (정가 p.Price 가 아님)
  113. var unitPrice = p.GetSalePrice();
  114. var lineAmount = checked(unitPrice * qty);
  115. totalAmount = checked(totalAmount + lineAmount);
  116. // 게임별 수익 배분 (스냅샷)
  117. var gameCommission = p.Game!.CommissionRate;
  118. var gameRevenue = (int)Math.Floor(lineAmount * gameCommission / 100m);
  119. gameRevenueTotal = checked(gameRevenueTotal + gameRevenue);
  120. perGameRevenue[p.GameID] = perGameRevenue.GetValueOrDefault(p.GameID) + gameRevenue;
  121. if (request.ChannelID.HasValue)
  122. {
  123. // 채널별 보상률 (Channel.StoreCommissionRate) — 게임과 무관하게 채널 정책 적용
  124. var channelReward = (int)Math.Floor(lineAmount * channelStoreCommissionRate / 100m);
  125. channelRewardTotal = checked(channelRewardTotal + channelReward);
  126. }
  127. }
  128. var platformFeeTotal = checked(totalAmount - gameRevenueTotal - channelRewardTotal);
  129. if (platformFeeTotal < 0)
  130. {
  131. return Result.Failure<Response>(Error.Problem("Order.NegativeFee", "수익 배분 계산에 실패했습니다 (PlatformFee < 0)."));
  132. }
  133. // 3.5 판매 정책 검증 — 회원당 누적 구매 수량 제한 (ProductLimitConfig)
  134. var limitProductIDs = lineTotals.Keys.ToList();
  135. var limitConfigs = await db.ProductLimitConfig.AsNoTracking()
  136. .Where(c => limitProductIDs.Contains(c.ProductID))
  137. .ToDictionaryAsync(c => c.ProductID, ct);
  138. foreach (var (productID, qty) in lineTotals)
  139. {
  140. if (!limitConfigs.TryGetValue(productID, out var cfg))
  141. {
  142. continue;
  143. }
  144. if (!cfg.UseQuantityLimit || cfg.MaxQuantity <= 0)
  145. {
  146. continue;
  147. }
  148. var product = productByID[productID];
  149. var windowStart = cfg.GetWindowStart(now);
  150. var windowEnd = cfg.GetWindowEnd(now);
  151. var pastQtyQuery = db.OrderItem.AsNoTracking()
  152. .Where(oi => oi.ProductID == productID
  153. && oi.Order!.MemberID == request.MemberID
  154. && oi.Order.Status != Domain.Entities.Store.ValueObject.OrderStatus.Cancelled
  155. && oi.Order.Status != Domain.Entities.Store.ValueObject.OrderStatus.Refunded);
  156. if (windowStart.HasValue)
  157. {
  158. var ws = windowStart.Value;
  159. pastQtyQuery = pastQtyQuery.Where(oi => oi.Order!.CreatedAt >= ws);
  160. }
  161. if (windowEnd.HasValue)
  162. {
  163. var we = windowEnd.Value;
  164. pastQtyQuery = pastQtyQuery.Where(oi => oi.Order!.CreatedAt < we);
  165. }
  166. var pastQty = await pastQtyQuery.SumAsync(oi => oi.Quantity, ct);
  167. if (pastQty + qty > cfg.MaxQuantity)
  168. {
  169. var windowDesc = (windowStart.HasValue || windowEnd.HasValue)
  170. ? GetWindowLabel(cfg.PeriodType, cfg.PeriodStartAt, cfg.PeriodEndAt)
  171. : "전체 기간";
  172. return Result.Failure<Response>(Error.Problem("Order.LimitExceeded",
  173. $"{product.Name}: 회원당 최대 {cfg.MaxQuantity}개까지 구매 가능합니다 ({windowDesc}). 누적 {pastQty}개 / 신규 {qty}개"));
  174. }
  175. }
  176. // 4. 구매자 지갑 로드 + PgCharged 잔액 검증
  177. var buyerWallet = await db.Wallet
  178. .Include(w => w.Balances)
  179. .Include(w => w.Transactions)
  180. .FirstOrDefaultAsync(w => w.MemberID == request.MemberID, ct);
  181. if (buyerWallet is null)
  182. {
  183. return Result.Failure<Response>(Error.NotFound("Wallet.NotFound", "지갑을 찾을 수 없습니다."));
  184. }
  185. var storeAvailable = buyerWallet.GetStoreOrderAvailable();
  186. if (storeAvailable.Value < totalAmount)
  187. {
  188. return Result.Failure<Response>(Error.Problem("Wallet.InsufficientBalance",
  189. $"포인트가 부족합니다. 필요: {totalAmount:N0}원 / 사용 가능: {(int)storeAvailable.Value:N0}원."));
  190. }
  191. // 5. 재고 차감 (RowVersion 동시성 보호 — SaveChanges 가 자동 처리)
  192. foreach (var (productID, qty) in lineTotals)
  193. {
  194. productByID[productID].DecreaseStock(qty);
  195. }
  196. // 6. Digital 상품 — 쿠폰 코드 점유 (Available → Issued, 직접 발급)
  197. var digitalLineCodes = new Dictionary<int, List<Domain.Entities.Store.CouponCode>>(); // ProductID -> 발급된 코드 리스트
  198. foreach (var (productID, qty) in lineTotals)
  199. {
  200. var product = productByID[productID];
  201. if (product.Type != ProductType.Digital)
  202. {
  203. continue;
  204. }
  205. // Digital 상품에 연결된 Coupon 1건 (1:1 가정 — 첫 번째 활성 쿠폰 사용)
  206. var coupon = await db.Coupon.Where(c => c.ProductID == productID).FirstOrDefaultAsync(ct);
  207. if (coupon is null)
  208. {
  209. return Result.Failure<Response>(Error.Problem("Order.NoCouponForDigital", $"상품에 쿠폰이 등록되지 않았습니다: {product.Name}"));
  210. }
  211. // 사용 가능한 코드 qty 개 점유
  212. var availableCodes = await db.CouponCode
  213. .Where(c => c.CouponID == coupon.ID && c.Status == CouponCodeStatus.Available)
  214. .OrderBy(c => c.ID)
  215. .Take(qty)
  216. .ToListAsync(ct);
  217. if (availableCodes.Count < qty)
  218. {
  219. return Result.Failure<Response>(Error.Problem("Order.InsufficientCouponCodes",
  220. $"쿠폰 코드가 부족합니다 ({product.Name} 잔여 {availableCodes.Count}개, 필요 {qty}개)"));
  221. }
  222. foreach (var code in availableCodes)
  223. {
  224. code.Reserve();
  225. code.Issue(request.MemberID);
  226. }
  227. digitalLineCodes[productID] = availableCodes;
  228. }
  229. // 7. Order 생성
  230. var orderNumber = GenerateOrderNumber();
  231. var order = OrderEntity.Create(
  232. orderNumber: orderNumber,
  233. memberID: request.MemberID,
  234. totalAmount: totalAmount,
  235. channelID: request.ChannelID,
  236. platformFeeAmount: platformFeeTotal,
  237. gameRevenueAmount: gameRevenueTotal,
  238. channelRewardAmount: channelRewardTotal
  239. );
  240. // OrderItem 추가 (각 라인별)
  241. bool hasPhysical = false;
  242. bool hasDigital = false;
  243. foreach (var (productID, qty) in lineTotals)
  244. {
  245. var product = productByID[productID];
  246. // OrderItem 의 unitPrice 는 결제 시점의 할인된 단가 (영수증/환불 일관성)
  247. var orderItem = OrderItemEntity.Create(productID, qty, product.GetSalePrice());
  248. if (product.Type == ProductType.Digital)
  249. {
  250. hasDigital = true;
  251. }
  252. else
  253. {
  254. hasPhysical = true;
  255. }
  256. order.AddItem(orderItem);
  257. }
  258. order.MarkPaid();
  259. // Digital 단독 주문이면 즉시 Delivered 점프
  260. if (hasDigital && !hasPhysical)
  261. {
  262. order.MarkDelivered();
  263. }
  264. db.Order.Add(order);
  265. // 8. 구매자 잔액 차감 (SpendPolicy.StoreOrderSpendOrder 순서로 분할, 출처별 트랜잭션 기록)
  266. buyerWallet.DebitForStoreOrder(Money.KRW(totalAmount), reason: "ORDER_PAY", refID: orderNumber);
  267. // 9. 채널 보상 — 채널주 StoreRevenue 입금
  268. if (channelMemberID.HasValue && channelRewardTotal > 0)
  269. {
  270. var channelWallet = await db.Wallet
  271. .Include(w => w.Balances)
  272. .Include(w => w.Transactions)
  273. .FirstOrDefaultAsync(w => w.MemberID == channelMemberID.Value, ct);
  274. if (channelWallet is null)
  275. {
  276. return Result.Failure<Response>(Error.NotFound("Order.ChannelWalletNotFound", "채널주의 지갑을 찾을 수 없습니다."));
  277. }
  278. channelWallet.CreditStoreRevenue(Money.KRW(channelRewardTotal), reason: "STORE_CHANNEL_REWARD", refID: orderNumber);
  279. }
  280. // 10. GameSettlement 큐 적재 (월별)
  281. var year = now.Year;
  282. var month = now.Month;
  283. foreach (var (gameID, revenue) in perGameRevenue)
  284. {
  285. if (revenue <= 0)
  286. {
  287. continue;
  288. }
  289. var settlement = await db.GameSettlement
  290. .FirstOrDefaultAsync(s => s.GameID == gameID && s.Year == year && s.Month == month, ct);
  291. if (settlement is null)
  292. {
  293. settlement = GameSettlementEntity.Create(gameID, year, month);
  294. db.GameSettlement.Add(settlement);
  295. }
  296. settlement.AddRevenue(revenue);
  297. }
  298. // 11. 1차 저장 — Order.ID, OrderItem.ID 확보
  299. await db.SaveChangesAsync(ct);
  300. // 11.5 채널주 실시간 알림 — DB Notification 저장 + AppHub SignalR push (NotificationBell 자동 반영)
  301. // Order.ID 확보 이후 호출하여 relatedID 연결.
  302. if (channelMemberID.HasValue && channelRewardTotal > 0)
  303. {
  304. await notificationService.SendAsync(
  305. memberID: channelMemberID.Value,
  306. type: NotificationType.StoreRewardReceived,
  307. title: "상점 판매 보상이 입금되었습니다",
  308. message: $"주문 {orderNumber} — {channelRewardTotal:N0}원이 잔액에 입금되었습니다.",
  309. actionUrl: "/studio/wallet/balance",
  310. relatedType: "Order",
  311. relatedID: order.ID,
  312. imageUrl: null,
  313. ct: ct
  314. );
  315. }
  316. // 12. Digital 상품: MemberInventory + OrderItem.IssuedCouponCodeID 연결
  317. if (hasDigital)
  318. {
  319. foreach (var orderItem in order.Items)
  320. {
  321. var product = productByID[orderItem.ProductID];
  322. if (product.Type != ProductType.Digital)
  323. {
  324. continue;
  325. }
  326. if (!digitalLineCodes.TryGetValue(product.ID, out var codes))
  327. {
  328. continue;
  329. }
  330. // 첫 코드를 OrderItem 에 연결, 모든 코드를 MemberInventory 에 등록
  331. if (codes.Count > 0)
  332. {
  333. orderItem.AssignCouponCode(codes[0].ID);
  334. }
  335. foreach (var code in codes)
  336. {
  337. DateTime? expiresAt = null;
  338. var coupon = await db.Coupon.AsNoTracking().FirstOrDefaultAsync(c => c.ID == code.CouponID, ct);
  339. if (coupon is not null)
  340. {
  341. expiresAt = coupon.ExpiresAt;
  342. }
  343. var inv = MemberInventoryEntity.Create(request.MemberID, orderItem.ID, code.ID, expiresAt);
  344. db.MemberInventory.Add(inv);
  345. }
  346. }
  347. await db.SaveChangesAsync(ct);
  348. }
  349. return Result.Success(new Response(
  350. OrderID: order.ID,
  351. OrderNumber: orderNumber,
  352. TotalAmount: totalAmount,
  353. PlatformFeeAmount: platformFeeTotal,
  354. GameRevenueAmount: gameRevenueTotal,
  355. ChannelRewardAmount: channelRewardTotal,
  356. HasPhysical: hasPhysical,
  357. HasDigital: hasDigital
  358. ));
  359. }
  360. private static string GenerateOrderNumber()
  361. {
  362. var date = DateTime.UtcNow.ToString("yyyyMMdd");
  363. var random = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
  364. return $"{date}-{random}";
  365. }
  366. private static string GetWindowLabel(
  367. Domain.Entities.Store.ValueObject.LimitPeriodType type,
  368. DateTime? periodStartAt,
  369. DateTime? periodEndAt
  370. ) => type switch
  371. {
  372. Domain.Entities.Store.ValueObject.LimitPeriodType.Day => "최근 1일",
  373. Domain.Entities.Store.ValueObject.LimitPeriodType.Week => "최근 1주",
  374. Domain.Entities.Store.ValueObject.LimitPeriodType.Month => "최근 30일",
  375. Domain.Entities.Store.ValueObject.LimitPeriodType.Custom => FormatCustomRange(periodStartAt, periodEndAt),
  376. _ => "전체 기간"
  377. };
  378. private static string FormatCustomRange(DateTime? startAt, DateTime? endAt)
  379. {
  380. if (!startAt.HasValue || !endAt.HasValue)
  381. {
  382. return "지정 기간";
  383. }
  384. return $"{startAt.Value:yyyy-MM-dd HH:mm:ss} ~ {endAt.Value:yyyy-MM-dd HH:mm:ss} (UTC)";
  385. }
  386. }