fix(payment): KG 이니시스 close-report 동시성 보강
PC 승인 콜백 처리 중 close-report가 동시에 도착해 결제완료 주문의 옵션을 취소로 덮는 race를 막기 위해 같은 주문 락을 공유하고 최신 주문 상태를 다시 확인합니다. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
@@ -47,7 +47,7 @@
|
||||
- 활성 관리자 테마에 최신 시맨틱 CSS가 없어도 플러그인 설정 화면의 여백, 카드 경계, 하단 저장 영역이 깨지지 않도록 보강했습니다.
|
||||
- 회원 마이페이지 주문 상세에서 KG 이니시스로 결제한 주문의 영수증 조회 버튼이 표시되지 않던 문제를 수정했습니다. 주문 상세 주소의 주문 ID를 주문번호로 잘못 사용해 영수증 조회가 항상 실패하던 원인을 바로잡았습니다.
|
||||
- 주문 상세 화면에서 다른 결제수단으로 결제한 주문까지 영수증 조회를 짧은 간격으로 수십 회 반복 요청하던 문제를 수정했습니다. 이제 KG 이니시스 결제가 아닌 주문은 영수증 조회를 호출하지 않으며, 영수증이 없다는 확정 응답을 받으면 재시도를 멈춥니다.
|
||||
- 카드 결제가 정상 완료된 직후 결제창이 닫히면서 결제완료 주문의 상품이 취소 상태로 바뀌던 문제를 수정했습니다. 결제가 이미 완료된 주문은 결제창 닫힘 보고로 취소 처리되지 않습니다.
|
||||
- 카드 결제가 정상 완료된 직후 또는 승인 콜백 처리 중 결제창 닫힘 보고가 도착하면서 결제완료 주문의 상품이 취소 상태로 바뀌던 문제를 수정했습니다. 결제가 이미 완료됐거나 완료 처리 중인 주문은 결제창 닫힘 보고로 취소 처리되지 않습니다.
|
||||
- 기본 통화가 원(KRW)이 아닌 통화일 때 결제창에 전달되는 청구 금액이 결제 통화로 환산된 금액과 일치하도록 검증 기준을 맞췄습니다 — 그동안 결제 금액 불일치로 결제가 막히던 문제가 해소됩니다.
|
||||
- CBT 설정 저장 검증 실패 메시지가 번역 키 그대로 노출되지 않도록 백엔드 언어팩과 플러그인 번역 로딩 경로를 보강했습니다.
|
||||
- 운영 모드의 국내 표준/모바일 결제 설정이 미완료된 경우 결제창 호출 전에 명확히 중단하도록 클라이언트 설정 상태를 노출했습니다.
|
||||
|
||||
+85
-67
@@ -5,17 +5,20 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\PaymentCloseReportRequest;
|
||||
|
||||
class PaymentCloseReportController
|
||||
{
|
||||
use ResolvesEasyPaySelection;
|
||||
use SerializesPaymentCallbacks;
|
||||
use ValidatesCbtOrderContext;
|
||||
|
||||
private const FAILURE_CODE = 'USER_CANCEL';
|
||||
@@ -46,82 +49,97 @@ 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 (strtoupper((string) $order->currency) !== 'KRW') {
|
||||
return ResponseHelper::error('messages.failed', 422, [
|
||||
'message' => ['Standard KG Inicis close report is only available for KRW orders.'],
|
||||
]);
|
||||
}
|
||||
try {
|
||||
// PC close-report 는 PC authCallback 과 경쟁할 수 있으므로 같은 주문 락을 공유한다.
|
||||
// authCallback 이 먼저 결제완료를 확정했다면 아래 최신 주문 조회에서 close-report 를 무시한다.
|
||||
$callbackLock = $this->acquireOrderCallbackLock('authCallback', $oid);
|
||||
|
||||
if (! $this->requestMatchesOrderBuyer($request, $order)) {
|
||||
return ResponseHelper::error('messages.failed', 403, [
|
||||
'message' => ['Order buyer verification failed.'],
|
||||
]);
|
||||
}
|
||||
$order = $this->orderService->findByOrderNumber($oid);
|
||||
if (! $order) {
|
||||
return ResponseHelper::error('messages.failed', 404, [
|
||||
'message' => ['Order not found.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [
|
||||
'received_amount' => $price,
|
||||
]);
|
||||
if ($expectedPrice === null) {
|
||||
return ResponseHelper::error('messages.failed', 422, [
|
||||
'message' => ['Payment currency is not chargeable.'],
|
||||
]);
|
||||
}
|
||||
if (! $order->order_status->isBeforePayment()) {
|
||||
return ResponseHelper::success('messages.success', [
|
||||
'status' => 'ignored',
|
||||
'reason' => 'order_not_payable',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($price !== $expectedPrice) {
|
||||
return ResponseHelper::error('messages.failed', 422, [
|
||||
'message' => ['Payment amount does not match the order amount.'],
|
||||
]);
|
||||
}
|
||||
// 결제 성공 콜백(authorize)과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지
|
||||
// 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 (! $order->order_status->isBeforePayment()) {
|
||||
if (strtoupper((string) $order->currency) !== 'KRW') {
|
||||
return ResponseHelper::error('messages.failed', 422, [
|
||||
'message' => ['Standard KG Inicis close report is only available for KRW orders.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $this->requestMatchesOrderBuyer($request, $order)) {
|
||||
return ResponseHelper::error('messages.failed', 403, [
|
||||
'message' => ['Order buyer verification failed.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [
|
||||
'received_amount' => $price,
|
||||
]);
|
||||
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.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$selectedEasyPayMethod = $this->normalizeEasyPayMethod(
|
||||
is_string($validated['payment_method'] ?? null) ? $validated['payment_method'] : null,
|
||||
);
|
||||
|
||||
$failedOrder = $this->orderService->failPayment(
|
||||
$order,
|
||||
self::FAILURE_CODE,
|
||||
self::FAILURE_MESSAGE,
|
||||
);
|
||||
|
||||
$closeReason = trim((string) ($validated['reason'] ?? ''));
|
||||
|
||||
$this->persistEasyPayCloseContext($failedOrder, $selectedEasyPayMethod);
|
||||
|
||||
$this->orderService->recordPaymentCancellation(
|
||||
$failedOrder,
|
||||
self::FAILURE_CODE,
|
||||
$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);
|
||||
}
|
||||
|
||||
// 결제 성공 콜백(authorize)과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지
|
||||
// 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',
|
||||
]);
|
||||
}
|
||||
|
||||
$selectedEasyPayMethod = $this->normalizeEasyPayMethod(
|
||||
is_string($validated['payment_method'] ?? null) ? $validated['payment_method'] : null,
|
||||
);
|
||||
|
||||
$failedOrder = $this->orderService->failPayment(
|
||||
$order,
|
||||
self::FAILURE_CODE,
|
||||
self::FAILURE_MESSAGE,
|
||||
);
|
||||
|
||||
$closeReason = trim((string) ($validated['reason'] ?? ''));
|
||||
|
||||
$this->persistEasyPayCloseContext($failedOrder, $selectedEasyPayMethod);
|
||||
|
||||
$this->orderService->recordPaymentCancellation(
|
||||
$failedOrder,
|
||||
self::FAILURE_CODE,
|
||||
$closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE,
|
||||
);
|
||||
|
||||
return ResponseHelper::success('messages.success', [
|
||||
'status' => 'recorded',
|
||||
]);
|
||||
}
|
||||
|
||||
private function rateLimitKey(PaymentCloseReportRequest $request, string $oid): string
|
||||
|
||||
+4
-2
@@ -181,8 +181,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-CLOSE-PAID-001', 10000);
|
||||
// race 재현: 승인 콜백이 payment 를 PAID 로 갱신했고 결제 예정액은 이미 0원이 됐으나,
|
||||
// order_status 관계가 아직 PENDING_ORDER 로 관측되는 순간에도 close-report 는 금액 불일치가
|
||||
// 아니라 결제 성공으로 판단해 무시해야 한다.
|
||||
$order = $this->makeOrder('ORD-CLOSE-PAID-001', 0);
|
||||
$order->setRelation('shippingAddress', new OrderAddress([
|
||||
'address_type' => 'shipping',
|
||||
'orderer_email' => 'buyer@example.com',
|
||||
|
||||
Reference in New Issue
Block a user