From cebe352e465a8a8196b12afd376879c2ffabaad1 Mon Sep 17 00:00:00 2001 From: thisgun Date: Tue, 7 Jul 2026 14:11:16 +0900 Subject: [PATCH] =?UTF-8?q?fix(payment):=20NHN=20KCP=20close-report=20?= =?UTF-8?q?=EA=B2=BD=ED=95=A9=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md | 1 + .../PaymentCloseReportController.php | 124 ++++++++++-------- .../PaymentCloseReportControllerTest.php | 6 +- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index c703b07f..e7dff7e3 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -10,6 +10,7 @@ - 비회원 NHN KCP 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다. - 관리자 주문상세의 NHN KCP 결제 정보 패널이 원승인 정보와 현재 취소/환불 상태를 함께 표시하도록 보강했습니다. +- PC 결제창 close-report와 승인 콜백이 경쟁할 때 결제 성공 주문이 실패 처리로 덮이지 않도록 주문번호 단위 콜백 락과 최신 결제 상태 가드를 적용했습니다. ## [1.0.0-beta.5] - 2026-06-23 diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php index 459ec7b9..c7f7eb33 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php @@ -5,15 +5,18 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayNhnkcp\Controllers; use App\Helpers\ResponseHelper; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\RateLimiter; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure; +use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks; use Plugins\Sirsoft\PayNhnkcp\Http\Requests\PaymentCloseReportRequest; class PaymentCloseReportController { use RecordsPaymentWindowClosure; + use SerializesPaymentCallbacks; private const FAILURE_CODE = 'USER_CANCEL'; @@ -43,67 +46,82 @@ class PaymentCloseReportController } RateLimiter::hit($rateLimitKey, 60); - $order = $this->orderService->findByOrderNumber($oid); - if (! $order) { - return ResponseHelper::error('messages.failed', 404, [ - 'message' => ['Order not found.'], - ]); - } + $callbackLock = null; - if (! $this->requestMatchesOrderBuyer($request, $order)) { - return ResponseHelper::error('messages.failed', 403, [ - 'message' => ['Order buyer verification failed.'], - ]); - } + try { + // PC close-report 는 PC authCallback 과 경쟁할 수 있으므로 같은 주문 락을 공유한다. + // authCallback 이 먼저 결제완료를 확정했다면 아래 최신 주문 조회에서 close-report 를 무시한다. + $callbackLock = $this->acquireOrderCallbackLock('authCallback', $oid); - $expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [ - 'oid' => $oid, - 'received_price' => $price, - 'ip' => $request->ip(), - ]); - if ($expectedPrice === null) { - return ResponseHelper::error('messages.failed', 422, [ - 'message' => ['Payment currency is not chargeable.'], - ]); - } + $order = $this->orderService->findByOrderNumber($oid); + if (! $order) { + return ResponseHelper::error('messages.failed', 404, [ + 'message' => ['Order not found.'], + ]); + } - if ($price !== $expectedPrice) { - return ResponseHelper::error('messages.failed', 422, [ - 'message' => ['Payment amount does not match the order amount.'], - ]); - } + if (! $order->order_status->isBeforePayment()) { + return ResponseHelper::success('messages.success', [ + 'status' => 'ignored', + 'reason' => 'order_not_payable', + ]); + } - if (! $order->order_status->isBeforePayment()) { + // 결제 성공 콜백과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지 + // order_status=PENDING_ORDER 라 위 가드를 통과한다. payment_status 가 이미 PAID 면 결제가 + // 성공한 것이므로 실패 처리하지 않는다(failPayment 가 옵션을 취소로 덮어 주문/옵션 상태가 + // 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나 여기서 차단해 불필요한 + // 결제취소 이력 기록까지 미연에 방지한다. + if ($order->payment?->isPaid()) { + return ResponseHelper::success('messages.success', [ + 'status' => 'ignored', + 'reason' => 'payment_already_paid', + ]); + } + + if (! $this->requestMatchesOrderBuyer($request, $order)) { + return ResponseHelper::error('messages.failed', 403, [ + 'message' => ['Order buyer verification failed.'], + ]); + } + + $expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [ + 'oid' => $oid, + 'received_price' => $price, + 'ip' => $request->ip(), + ]); + if ($expectedPrice === null) { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Payment currency is not chargeable.'], + ]); + } + + if ($price !== $expectedPrice) { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Payment amount does not match the order amount.'], + ]); + } + + $closeReason = trim((string) ($validated['reason'] ?? '')); + $this->markPaymentWindowClosed( + $this->orderService, + $order, + self::FAILURE_CODE, + self::FAILURE_MESSAGE, + $closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE, + ); + + return ResponseHelper::success('messages.success', [ + 'status' => 'recorded', + ]); + } catch (LockTimeoutException) { return ResponseHelper::success('messages.success', [ 'status' => 'ignored', - 'reason' => 'order_not_payable', + 'reason' => 'callback_in_progress', ]); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } - - // 결제 성공 콜백과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지 - // order_status=PENDING_ORDER 라 위 가드를 통과한다. payment_status 가 이미 PAID 면 결제가 - // 성공한 것이므로 실패 처리하지 않는다(failPayment 가 옵션을 취소로 덮어 주문/옵션 상태가 - // 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나 여기서 차단해 불필요한 - // 결제취소 이력 기록까지 미연에 방지한다. - if ($order->payment?->isPaid()) { - return ResponseHelper::success('messages.success', [ - 'status' => 'ignored', - 'reason' => 'payment_already_paid', - ]); - } - - $closeReason = trim((string) ($validated['reason'] ?? '')); - $this->markPaymentWindowClosed( - $this->orderService, - $order, - self::FAILURE_CODE, - self::FAILURE_MESSAGE, - $closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE, - ); - - return ResponseHelper::success('messages.success', [ - 'status' => 'recorded', - ]); } private function rateLimitKey(PaymentCloseReportRequest $request, string $oid): string diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php index bcc22976..d518eb16 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php @@ -156,8 +156,10 @@ class PaymentCloseReportControllerTest extends PluginTestCase public function test_close_report_ignores_order_when_payment_already_paid(): void { - // race 재현: 승인 콜백이 payment 를 먼저 PAID 로 갱신했으나 order_status 는 아직 PENDING_ORDER. - $order = $this->makeOrder('ORD-KCP-CLOSE-PAID-001', 10000); + // race 재현: 승인 콜백이 payment 를 PAID 로 갱신했고 결제 예정액은 이미 0원이 됐으나, + // order_status 관계가 아직 PENDING_ORDER 로 관측되는 순간에도 close-report 는 금액 불일치가 + // 아니라 결제 성공으로 판단해 무시해야 한다. + $order = $this->makeOrder('ORD-KCP-CLOSE-PAID-001', 0); $order->setRelation('payment', new OrderPayment([ 'payment_status' => PaymentStatusEnum::PAID, ]));