fix(payment): NHN KCP 콜백 동시성 락 추가
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+74
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user