From b428734a0632516c2e528fb3dac79fd68dc30aa1 Mon Sep 17 00:00:00 2001 From: thisgun Date: Fri, 3 Jul 2026 21:58:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(payment):=20NHN=20KCP=20=EC=BD=9C=EB=B0=B1?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=9D=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md | 1 + .../Concerns/SerializesPaymentCallbacks.php | 57 ++++++++++++++ .../Controllers/PaymentCallbackController.php | 40 ++++++++++ .../SerializesPaymentCallbacksTest.php | 74 +++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/SerializesPaymentCallbacks.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index 1e900316..f7563fcf 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - 간편결제 주문의 원 결제수단이 사용자 주문완료/주문상세, 관리자 주문목록/주문상세에서 신용카드로만 보이지 않도록 표시 라벨과 관리자 목록 표시 맵을 보강했습니다. +- 동일 주문의 NHN KCP 승인/가상계좌 콜백이 동시에 도착해도 결제완료 훅과 주문 부작용이 중복 실행되지 않도록 주문번호 단위 콜백 락을 추가했습니다. - 결제창 닫힘, 인증 취소, 승인 실패, 금액 불일치 등 NHN KCP 실패 경로에서 주문 실패 상태와 결제 레코드의 PG별 실패 메타가 함께 기록되도록 보강했습니다. - 결제 실패/취소 후 같은 주문번호로 결제창을 다시 열 때 안전한 실패 주문만 `pending_order` / `ready` 상태로 복구하는 재시도 준비 API를 추가했습니다. - PC/모바일 결제창 호출 직전에 재시도 준비 API를 호출해 사용자가 결제 실패 후 동일 주문 결제를 다시 시도할 수 있도록 보강했습니다. diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/SerializesPaymentCallbacks.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/SerializesPaymentCallbacks.php new file mode 100644 index 00000000..4c001a6f --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/SerializesPaymentCallbacks.php @@ -0,0 +1,57 @@ +callbackLockKey($context, $orderNumber); + $lock = Cache::lock($key, $this->callbackLockTtlSeconds()); + + try { + $lock->block($this->callbackLockWaitSeconds()); + } catch (LockTimeoutException $e) { + Log::warning('NHN KCP: callback lock timeout', [ + 'context' => $context, + 'order_number' => $orderNumber, + 'lock_key' => $key, + ]); + + throw $e; + } + + return $lock; + } + + protected function releaseOrderCallbackLock(mixed $lock): void + { + if (is_object($lock) && method_exists($lock, 'release')) { + $lock->release(); + } + } + + protected function callbackLockKey(string $context, string $orderNumber): string + { + return 'sirsoft-pay_nhnkcp:callback:' + . $context + . ':' + . sha1(trim($orderNumber)); + } + + protected function callbackLockTtlSeconds(): int + { + return 30; + } + + protected function callbackLockWaitSeconds(): int + { + return 10; + } +} diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php index d7b078eb..345e0fc4 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php @@ -7,6 +7,7 @@ namespace Plugins\Sirsoft\PayNhnkcp\Controllers; use App\Extension\HookManager; use App\Services\PluginSettingsService; use Carbon\Carbon; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -21,6 +22,7 @@ use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback; use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure; use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay; use Plugins\Sirsoft\PayNhnkcp\Concerns\SanitizesPgResponse; +use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks; use Plugins\Sirsoft\PayNhnkcp\Concerns\SendsKcpNotifyResponse; use Plugins\Sirsoft\PayNhnkcp\Http\Requests\AuthCallbackRequest; use Plugins\Sirsoft\PayNhnkcp\Http\Requests\VbankNotifyRequest; @@ -39,6 +41,7 @@ class PaymentCallbackController use RecordsPaymentWindowClosure; use ResolvesEasyPayDisplay; use SanitizesPgResponse; + use SerializesPaymentCallbacks; use SendsKcpNotifyResponse; private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp'; @@ -180,6 +183,7 @@ class PaymentCallbackController $order = null; $approvedTno = null; $approvedAmtForCancel = 0; + $callbackLock = null; try { // 2단계: 주문 조회 @@ -191,6 +195,9 @@ class PaymentCallbackController return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $ordrIdxx])); } + $callbackLock = $this->acquireOrderCallbackLock('authCallback', $ordrIdxx); + $order = $order->fresh('payment') ?? $order; + if ($order->order_status === OrderStatusEnum::CANCELLED) { $restored = $this->restoreRetryableKcpOrder($order, $goodMny > 0 ? $goodMny : null); if (! $restored) { @@ -345,6 +352,22 @@ class PaymentCallbackController return redirect($this->resolveSuccessUrl($ordrIdxx)); + } catch (LockTimeoutException $e) { + Log::warning('KCP: authCallback skipped because callback lock is busy', [ + 'ordr_idxx' => $ordrIdxx, + ]); + + $latestOrder = $this->orderService->findByOrderNumber($ordrIdxx); + if (($latestOrder?->payment?->isPaid() ?? false) + || $latestOrder?->order_status === OrderStatusEnum::PAYMENT_COMPLETE) { + return redirect($this->resolveSuccessUrl($ordrIdxx)); + } + + return redirect($this->resolveFailUrl([ + 'error' => 'callback_locked', + 'orderId' => $ordrIdxx, + ])); + } catch (PaymentAmountMismatchException $e) { Log::error('KCP: amount mismatch', [ 'ordr_idxx' => $ordrIdxx, @@ -390,6 +413,8 @@ class PaymentCallbackController 'message' => $e->getMessage(), 'orderId' => $ordrIdxx, ])); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } } @@ -418,6 +443,7 @@ class PaymentCallbackController $opCd = (string) ($validated['op_cd'] ?? ''); $ipgmMny = (int) ($validated['ipgm_mnyx'] ?? 0); $notiId = $validated['noti_id'] ?? null; + $callbackLock = null; // tx_cd 가 TX00 가 아니면 본 endpoint 가 처리하지 않음. // (KCP 가 단일 webhook URL 에 모든 tx_cd 를 보낼 수도 있어 안전하게 무시 + result=0000) @@ -454,6 +480,9 @@ class PaymentCallbackController return $this->kcpNotifyResponse(); } + $callbackLock = $this->acquireOrderCallbackLock('vbankNotify', $orderNo); + $order = $order->fresh('payment') ?? $order; + $contextError = $this->validateVbankNotifyContext($order, $validated, $tno, $ipgmMny); if ($contextError !== null) { Log::warning('KCP: vbank notify context mismatch — retry requested', [ @@ -498,6 +527,15 @@ class PaymentCallbackController return $this->kcpNotifyResponse(); + } catch (LockTimeoutException $e) { + Log::warning('KCP: vbank notify lock timeout — retry requested', [ + 'tno' => $tno, + 'order_no' => $orderNo, + 'noti_id' => $notiId, + ]); + + return $this->kcpNotifyRetry(); + } catch (\Exception $e) { Log::error('KCP: vbank notify failed', [ 'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId, @@ -506,6 +544,8 @@ class PaymentCallbackController // 일시적 실패 (DB 등) — result != 0000 으로 KCP 재통보 유도 return $this->kcpNotifyRetry(); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php new file mode 100644 index 00000000..14725fc0 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php @@ -0,0 +1,74 @@ +traitSubject = new class { + use SerializesPaymentCallbacks { + acquireOrderCallbackLock as public; + releaseOrderCallbackLock as public; + callbackLockKey as public; + } + + protected function callbackLockWaitSeconds(): int + { + return 0; + } + + protected function callbackLockTtlSeconds(): int + { + return 5; + } + }; + } + + public function test_same_context_and_order_are_serialized(): void + { + $orderNumber = 'ORD-KCP-LOCK-' . random_int(10000, 99999); + $firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber); + + try { + $this->expectException(LockTimeoutException::class); + + $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber); + } finally { + $this->traitSubject->releaseOrderCallbackLock($firstLock); + } + } + + public function test_released_lock_allows_next_callback(): void + { + $orderNumber = 'ORD-KCP-LOCK-' . random_int(10000, 99999); + $firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber); + $this->traitSubject->releaseOrderCallbackLock($firstLock); + + $secondLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber); + + $this->assertNotNull($secondLock); + $this->traitSubject->releaseOrderCallbackLock($secondLock); + } + + public function test_lock_key_is_scoped_by_context_and_order(): void + { + $this->assertNotSame( + $this->traitSubject->callbackLockKey('authCallback', 'ORD-KCP-LOCK-A'), + $this->traitSubject->callbackLockKey('vbankNotify', 'ORD-KCP-LOCK-A'), + ); + + $this->assertNotSame( + $this->traitSubject->callbackLockKey('authCallback', 'ORD-KCP-LOCK-A'), + $this->traitSubject->callbackLockKey('authCallback', 'ORD-KCP-LOCK-B'), + ); + } +}