fix(payment): NHN KCP close-report 경합 처리 보강

This commit is contained in:
thisgun
2026-07-07 14:11:16 +09:00
parent 48f877bdcd
commit cebe352e46
3 changed files with 76 additions and 55 deletions
@@ -10,6 +10,7 @@
- 비회원 NHN KCP 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다.
- 관리자 주문상세의 NHN KCP 결제 정보 패널이 원승인 정보와 현재 취소/환불 상태를 함께 표시하도록 보강했습니다.
- PC 결제창 close-report와 승인 콜백이 경쟁할 때 결제 성공 주문이 실패 처리로 덮이지 않도록 주문번호 단위 콜백 락과 최신 결제 상태 가드를 적용했습니다.
## [1.0.0-beta.5] - 2026-06-23
@@ -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
@@ -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,
]));