| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- using Application.Abstractions.Data;
- using Application.Abstractions.Messaging;
- using Application.Abstractions.Notification;
- using Domain.Entities.Developers;
- using Domain.Entities.Developers.ValueObject;
- using Domain.Entities.Notifications.ValueObject;
- using Microsoft.EntityFrameworkCore;
- using SharedKernel.Results;
- namespace Application.Features.Api.V1.Purchases.Register;
- /// <summary>
- /// 게임사 결제 등록 — 검증 후 Pending 원장 생성 (지갑 미적립).
- /// 수수료는 ConfirmDueAt(+14일, 달력일) 도래 시 ApiPurchaseConfirmService 가 채널주 StoreRevenue 에 적립.
- /// SKU 카탈로그 정가와 보고가 불일치 시 PriceMismatched flag (등록은 통과 — Admin 검토 대상).
- /// </summary>
- internal sealed class Handler(IAppDbContext db, INotificationService notificationService, IAdminAlertService adminAlertService) : ICommandHandler<Command, Result<Response>>
- {
- public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
- {
- // 1. 입력 검증
- if (string.IsNullOrWhiteSpace(request.ChannelCode))
- {
- return Result.Failure<Response>(Error.Problem("Purchase.ChannelCodeRequired", "channelCode 는 필수입니다."));
- }
- if (string.IsNullOrWhiteSpace(request.OrderID))
- {
- return Result.Failure<Response>(Error.Problem("Purchase.OrderIDRequired", "orderID 는 필수입니다."));
- }
- var orderID = request.OrderID.Trim();
- if (orderID.Length > 255)
- {
- return Result.Failure<Response>(Error.Problem("Purchase.OrderIDTooLong", "orderID 는 255자 이하여야 합니다."));
- }
- if (!Enum.IsDefined(typeof(ApiPurchaseMarketplace), request.Marketplace))
- {
- return Result.Failure<Response>(Error.Problem("Purchase.MarketplaceInvalid", "marketplace 는 1(구글)~6(기타) 값이어야 합니다."));
- }
- if (string.IsNullOrWhiteSpace(request.GameCode))
- {
- return Result.Failure<Response>(Error.Problem("Purchase.GameCodeRequired", "gameCode 는 필수입니다."));
- }
- if (request.OrderPrice <= 0)
- {
- return Result.Failure<Response>(Error.Problem("Purchase.OrderPriceInvalid", "orderPrice 는 1 이상이어야 합니다."));
- }
- if (request.ProductID is not null && request.ProductID.Trim().Length > 100)
- {
- return Result.Failure<Response>(Error.Problem("Purchase.ProductIDTooLong", "productID 는 100자 이하여야 합니다."));
- }
- if (request.SubID is not null && request.SubID.Trim().Length > 100)
- {
- return Result.Failure<Response>(Error.Problem("Purchase.SubIDTooLong", "subID 는 100자 이하여야 합니다."));
- }
- var marketplace = (ApiPurchaseMarketplace)request.Marketplace;
- // 2. 앱 상태 (토큰 발급 후 정지되었을 수 있음)
- var appActive = await db.ApiApplication.AsNoTracking().AnyAsync(c => c.ID == request.ApplicationID && c.Status == ApplicationStatus.Active, ct);
- if (!appActive)
- {
- return Result.Failure<Response>(Error.Forbidden("Purchase.AppNotActive", "앱이 활성 상태가 아닙니다."));
- }
- // 3. 채널 (후원 코드 — 대문자 저장)
- var channelCode = request.ChannelCode.Trim().ToUpperInvariant();
- var channel = await db.Channel
- .AsNoTracking()
- .Where(c => c.DonationCode == channelCode)
- .Select(c => new
- {
- c.ID,
- c.MemberID,
- c.SID,
- c.Name,
- c.IsActive,
- MemberGone = c.Member.IsWithdraw || c.Member.DeletedAt != null
- })
- .FirstOrDefaultAsync(ct);
- if (channel is null)
- {
- return Result.Failure<Response>(Error.NotFound("Channel.NotFound", "해당 후원 코드의 채널을 찾을 수 없습니다."));
- }
- if (!channel.IsActive || channel.MemberGone)
- {
- return Result.Failure<Response>(Error.Problem("Channel.Inactive", "비활성 또는 탈퇴한 채널입니다."));
- }
- // 4. 게임
- var gameCode = request.GameCode.Trim();
- var game = await db.Game
- .AsNoTracking()
- .Where(c => c.Code == gameCode)
- .Select(c => new
- {
- c.ID,
- c.Code,
- c.KorName,
- c.IsActive,
- c.ApiCommissionRate
- })
- .FirstOrDefaultAsync(ct);
- if (game is null)
- {
- return Result.Failure<Response>(Error.NotFound("Game.NotFound", "해당 코드의 게임을 찾을 수 없습니다."));
- }
- if (!game.IsActive)
- {
- return Result.Failure<Response>(Error.Problem("Game.Inactive", "비활성 게임입니다."));
- }
- if (game.ApiCommissionRate <= 0)
- {
- return Result.Failure<Response>(Error.Problem("Game.ApiCommissionNotConfigured", "해당 게임은 결제 보고 수수료 대상이 아닙니다."));
- }
- // 5. 중복 방지 — (앱, marketplace, orderID) 멱등
- var duplicated = await db.ApiPurchase.AsNoTracking().AnyAsync(c => c.ApplicationID == request.ApplicationID && c.Marketplace == marketplace && c.OrderID == orderID, ct);
- if (duplicated)
- {
- return Result.Failure<Response>(Error.Conflict("Purchase.Duplicate", "이미 등록된 주문입니다. (marketplace + orderID 중복)"));
- }
- // 6. SKU 카탈로그 대조 (신뢰 레이어 — 미등록 SKU 는 통과)
- int? catalogPrice = null;
- var productID = string.IsNullOrWhiteSpace(request.ProductID) ? null : request.ProductID.Trim();
- if (productID is not null)
- {
- catalogPrice = await db.GameProductCatalog.AsNoTracking().Where(c => c.GameID == game.ID && c.ProductID == productID && c.IsActive).Select(c => (int?)c.Price).FirstOrDefaultAsync(ct);
- }
- // 7. 원장 생성 — Pending (지갑 미적립, 확정 배치가 적립)
- var purchase = ApiPurchase.Create(
- applicationID: request.ApplicationID,
- channelID: channel.ID,
- creditedMemberID: channel.MemberID,
- gameID: game.ID,
- orderID: orderID,
- marketplace: marketplace,
- orderPrice: request.OrderPrice,
- commissionRate: game.ApiCommissionRate,
- productID: productID,
- subID: request.SubID,
- catalogPrice: catalogPrice
- );
- db.ApiPurchase.Add(purchase);
- try
- {
- await db.SaveChangesAsync(ct);
- }
- catch (DbUpdateException)
- {
- // 동시 요청이 unique index (ApplicationID, Marketplace, OrderID) 에 경합한 경우
- return Result.Failure<Response>(Error.Conflict("Purchase.Duplicate", "이미 등록된 주문입니다. (marketplace + orderID 중복)"));
- }
- // 8. 채널주 알림 — 보류 적립 예정
- await notificationService.SendAsync(
- memberID: channel.MemberID,
- type: NotificationType.ApiCommissionPending,
- title: "판매 수수료 보류 적립",
- message: $"「{game.KorName}」 결제 보고 — 수수료 {purchase.CommissionAmount:N0}원이 {purchase.ConfirmDueAt:yyyy-MM-dd} 확정 예정입니다.",
- actionUrl: "/studio/wallet/revenue",
- relatedType: "ApiPurchase",
- relatedID: purchase.ID,
- imageUrl: null,
- ct: ct
- );
- // 운영 알림 — 활동 방 (전 건) + 정가 불일치 시 알림 방
- var marketLabel = marketplace switch
- {
- ApiPurchaseMarketplace.GooglePlay => "구글",
- ApiPurchaseMarketplace.AppStore => "애플",
- ApiPurchaseMarketplace.Microsoft => "MS",
- ApiPurchaseMarketplace.GalaxyStore => "갤럭시",
- ApiPurchaseMarketplace.OneStore => "원스토어",
- _ => "기타"
- };
- await adminAlertService.SendActivityAsync($"💳 결제 등록 — {game.KorName} / {channel.Name}({channelCode}) / {purchase.OrderPrice:N0}원 → 수수료 {purchase.CommissionAmount:N0}원 / {marketLabel} / {purchase.OrderID}", ct);
- if (purchase.PriceMismatched)
- {
- await adminAlertService.SendAlertAsync($"⚠️ 정가 불일치 — {game.KorName} SKU {productID}: 보고 {purchase.OrderPrice:N0}원 vs 정가 {catalogPrice:N0}원 / {channel.Name}({channelCode}) / {purchase.OrderID}", ct);
- }
- return Result.Success(new Response(
- purchase.ID,
- purchase.OrderID,
- (int)purchase.Marketplace,
- channelCode,
- channel.SID,
- channel.Name,
- game.Code!,
- purchase.OrderPrice,
- purchase.CommissionRate,
- purchase.CommissionAmount,
- purchase.Status.ToString(),
- purchase.ConfirmDueAt,
- purchase.CreatedAt
- ));
- }
- }
|