fix(payment): NHN KCP 콜백 동시성 락 추가

This commit is contained in:
thisgun
2026-07-03 21:58:48 +09:00
parent 0e9bb39980
commit b428734a06
4 changed files with 172 additions and 0 deletions
@@ -9,6 +9,7 @@
### Fixed
- 간편결제 주문의 원 결제수단이 사용자 주문완료/주문상세, 관리자 주문목록/주문상세에서 신용카드로만 보이지 않도록 표시 라벨과 관리자 목록 표시 맵을 보강했습니다.
- 동일 주문의 NHN KCP 승인/가상계좌 콜백이 동시에 도착해도 결제완료 훅과 주문 부작용이 중복 실행되지 않도록 주문번호 단위 콜백 락을 추가했습니다.
- 결제창 닫힘, 인증 취소, 승인 실패, 금액 불일치 등 NHN KCP 실패 경로에서 주문 실패 상태와 결제 레코드의 PG별 실패 메타가 함께 기록되도록 보강했습니다.
- 결제 실패/취소 후 같은 주문번호로 결제창을 다시 열 때 안전한 실패 주문만 `pending_order` / `ready` 상태로 복구하는 재시도 준비 API를 추가했습니다.
- PC/모바일 결제창 호출 직전에 재시도 준비 API를 호출해 사용자가 결제 실패 후 동일 주문 결제를 다시 시도할 수 있도록 보강했습니다.
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
trait SerializesPaymentCallbacks
{
protected function acquireOrderCallbackLock(string $context, string $orderNumber): mixed
{
$key = $this->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;
}
}
@@ -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);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Concerns;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
class SerializesPaymentCallbacksTest extends PluginTestCase
{
private object $traitSubject;
protected function setUp(): void
{
parent::setUp();
$this->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'),
);
}
}