HTML (SendsKcpNotifyResponse trait) * IP 가드: RestrictKcpIp 미들웨어 (routes/web.php) */ class EscrowCommonNotifyController { use SendsKcpNotifyResponse; public function __construct( private readonly OrderProcessingService $orderService, ) {} /** * KCP 에스크로 통보 처리 (공통 webhook) * * @param EscrowCommonNotifyRequest $request 검증된 통보 페이로드 (order_no, tx_cd, cl_status 등) * @return Response 200 + KCP 표준 result HTML */ public function handle(EscrowCommonNotifyRequest $request): Response { $validated = $request->validated(); $txCd = (string) $validated['tx_cd']; $tno = (string) ($validated['tno'] ?? ''); $orderNo = (string) $validated['order_no']; $clStatus = (string) ($validated['cl_status'] ?? ''); Log::info('KCP: escrow common notify received', [ 'tx_cd' => $txCd, 'tno' => $tno, 'order_no' => $orderNo, 'cl_status' => $clStatus, ]); try { $order = $this->orderService->findByOrderNumber($orderNo); if (! $order) { Log::error('KCP: escrow common notify - order not found', ['order_no' => $orderNo]); // 영구 실패 — result=0000 으로 재통보 차단 return $this->kcpNotifyResponse(); } $contextError = $this->validateEscrowNotifyContext($order, $validated, $tno); if ($contextError !== null) { Log::warning('KCP: escrow common notify context mismatch — retry requested', [ 'reason' => $contextError, 'tx_cd' => $txCd, 'tno' => $tno, 'order_no' => $orderNo, ]); return $this->kcpNotifyRetry(); } $fingerprint = $this->escrowNotifyFingerprint($validated); if ($this->wasEscrowNotifyAlreadyProcessed($order, $fingerprint)) { Log::info('KCP: escrow common notify replay ignored', [ 'tx_cd' => $txCd, 'tno' => $tno, 'order_no' => $orderNo, 'cl_status' => $clStatus, ]); return $this->kcpNotifyResponse(); } match ($txCd) { 'TX02' => match ($clStatus) { '2' => HookManager::doAction('sirsoft-pay_nhnkcp.escrow.purchase_confirmed', $order, $validated), '8' => HookManager::doAction('sirsoft-pay_nhnkcp.escrow.purchase_cancelled', $order, $validated), '3' => HookManager::doAction('sirsoft-pay_nhnkcp.escrow.denial_confirmed', $order, $validated), default => Log::info('KCP: escrow TX02 unknown cl_status', ['cl_status' => $clStatus, 'order_no' => $orderNo]), }, 'TX03' => HookManager::doAction('sirsoft-pay_nhnkcp.escrow.delivery_started', $order, $validated), default => Log::info('KCP: escrow common notify - unhandled tx_cd', ['tx_cd' => $txCd, 'order_no' => $orderNo]), }; $this->recordEscrowNotifyProcessed($order, $fingerprint); Log::info('KCP: escrow common notify processed', [ 'tx_cd' => $txCd, 'order_no' => $orderNo, 'cl_status' => $clStatus, ]); } catch (\Exception $e) { Log::error('KCP: escrow common notify failed', [ 'tx_cd' => $txCd, 'order_no' => $orderNo, 'error' => $e->getMessage(), ]); // 일시적 실패 (DB 등) — result != 0000 으로 KCP 재통보 유도 return $this->kcpNotifyRetry(); } return $this->kcpNotifyResponse(); } /** * 에스크로 공통통보가 실제 결제 건과 같은 거래인지 확인한다. * * 현재 이 컨트롤러는 hook dispatch 만 하지만, 향후 listener 가 주문 상태를 바꿀 수 있으므로 * 주문번호 단독 신뢰를 피하고 저장된 tno/site_cd/escrow 플래그를 먼저 확인한다. */ private function validateEscrowNotifyContext(Order $order, array $validated, string $tno): ?string { $payment = $order->payment; if (! $payment) { return 'payment_not_found'; } if ($payment->pg_provider !== 'nhnkcp') { return 'pg_provider_mismatch'; } if (! (bool) $payment->is_escrow) { return 'not_escrow_payment'; } $storedTno = trim((string) ($payment->transaction_id ?? '')); if ($storedTno === '' || $tno === '' || ! hash_equals($storedTno, $tno)) { return 'tno_mismatch'; } $meta = $payment->payment_meta ?? []; $storedSiteCd = trim((string) ($meta['site_cd'] ?? '')); $receivedSiteCd = trim((string) ($validated['site_cd'] ?? '')); if ($storedSiteCd !== '' && $receivedSiteCd !== '' && ! hash_equals($storedSiteCd, $receivedSiteCd)) { return 'site_cd_mismatch'; } return null; } private function escrowNotifyFingerprint(array $validated): string { $keys = [ 'site_cd', 'tno', 'order_no', 'tx_cd', 'tx_tm', 'cl_status', 'st_cd', 'can_msg', 'waybill_no', 'waybill_corp', ]; $payload = []; foreach ($keys as $key) { $payload[$key] = (string) ($validated[$key] ?? ''); } return hash('sha256', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } private function wasEscrowNotifyAlreadyProcessed(Order $order, string $fingerprint): bool { $payment = $order->payment; $meta = $payment?->payment_meta ?? []; $processed = $meta['escrow_common_notify_fingerprints'] ?? []; $processed = is_array($processed) ? $processed : []; return in_array($fingerprint, $processed, true); } private function recordEscrowNotifyProcessed(Order $order, string $fingerprint): void { $payment = $order->payment; if (! $payment) { return; } $meta = $payment->payment_meta ?? []; $processed = $meta['escrow_common_notify_fingerprints'] ?? []; $processed = is_array($processed) ? $processed : []; $processed[] = $fingerprint; $meta['escrow_common_notify_fingerprints'] = array_values(array_unique(array_slice($processed, -20))); $payment->update(['payment_meta' => $meta]); } }