변경 파일 목록
총 3개
변경 파일 코드
추가삭제
변경 전PgNoti.php
| private $pgClass = ''; | |
| private $orderNo = ''; | |
| private $pgMobileCode = ''; | |
| /** | |
| * 생성자 | |
| $this->orderNo = $requestValue['partner_order_id']; | |
| // 노티 중복 수신 방지 — 중계서버는 나중 응답만 보고 카카오 결제 취소 여부를 판단하므로, | |
| // 락 대기 후 실제 확정된 결과로 응답한다(즉시 낙관적 OK 금지). 대기해도 못 얻으면 안전하게 FAIL. | |
| $lockGuard = new PaymentNotiLockGuard(App::getInstance('DB'), 'kakaopay'); | |
| if ($lockGuard->lock($this->orderNo, self::NOTI_LOCK_WAIT_SECONDS) === false) { | |
| Logger::channel('pgKakaopayResult')->error('Noti Lock Failed :' . $this->pgMobileCode, [$this->orderNo]); | |
| echo '{"result":"FAIL"}'; | |
| exit; | |
| } | |
| register_shutdown_function(function () use ($lockGuard) { | |
| $lockGuard->unlock($this->orderNo); | |
| }); | |
| // 결과 정보 로그 | |
| Logger::channel('pgKakaopayResult')->info('PG Settle Data :' . $this->pgMobileCode, [$this->orderNo . ' : Settle Success']); | |
| // 결제 결과 저장 | |
| $saveResult = $this->pgClass->setPgData($this->orderNo, $getPg, null); | |
| // 결과 DB 처리 | |
| if ($saveResult === true) { | |
| Logger::channel('pgKakaopayResult')->info('Set Pg Data :' . $this->pgMobileCode, ['OK']); | |
| echo '{"result":"OK"}'; | |
| exit; | |
| $errorData = $this->pgClass->setPgResultCode($e->getMessage()); | |
| Logger::channel('pgKakaopayResult')->info('Noti Exception Data :' . $errorData['message'], ['FAIL']); | |
| if ($e->getMessage() == 'PG_STOP_ORDER_DATA') { | |
| Logger::channel('pgKakaopayResult')->info('Stop Pg Data : ' . $errorData['message'], ['FAIL']); | |
| } | |
| } | |
| /** | |
| * 결제 결과 정보 데이터 설정 | |
| * | |
변경 후PgNoti.php
| private $pgClass = ''; | |
| private $orderNo = ''; | |
| private $pgMobileCode = ''; | |
| private bool $accepted = false; | |
| private bool $saved = false; | |
| private ?\Exception $saveException = null; | |
| /** | |
| * 생성자 | |
| $this->orderNo = $requestValue['partner_order_id']; | |
| // 노티 중복 수신 방지 — 중계서버는 나중 응답만 보고 카카오 결제 취소 여부를 판단하므로, | |
| // 락 대기 후 실제 확정된 결과로 응답한다(즉시 낙관적 OK 금지). | |
| $lockGuard = new PaymentNotiLockGuard(App::getInstance('DB'), 'kakaopay'); | |
| if ($lockGuard->lock($this->orderNo, self::NOTI_LOCK_WAIT_SECONDS) === false) { | |
| // 앞선 노티가 검증을 통과해 저장 중이면 FAIL 응답 시 정상 결제가 취소되므로 PAYED로 응답한다 | |
| if ($lockGuard->isAccepted($this->orderNo)) { | |
| Logger::channel('pgKakaopayResult')->warning('Noti Lock Wait Timeout, Accepted :' . $this->pgMobileCode, [$this->orderNo]); | |
| echo '{"result":"PAYED"}'; | |
| exit; | |
| } | |
| // 대기가 끝나는 사이 앞선 처리가 끝났을 수 있어 한 번 더 시도한다 | |
| if ($lockGuard->lock($this->orderNo) === false) { | |
| Logger::channel('pgKakaopayResult')->error('Noti Lock Failed :' . $this->pgMobileCode, [$this->orderNo]); | |
| echo '{"result":"FAIL"}'; | |
| exit; | |
| } | |
| } | |
| register_shutdown_function(function () use ($lockGuard) { | |
| $this->logAcceptedSaveFailed(); | |
| $lockGuard->unlock($this->orderNo); | |
| }); | |
| // 결과 정보 로그 | |
| Logger::channel('pgKakaopayResult')->info('PG Settle Data :' . $this->pgMobileCode, [$this->orderNo . ' : Settle Success']); | |
| // 저장이 늦어져 재시도 노티가 들어와도 결제가 취소되지 않도록 승인 확정을 표시한다 | |
| $this->accepted = $lockGuard->markAccepted($this->orderNo); | |
| if (!$this->accepted) { | |
| Logger::channel('pgKakaopayResult')->warning('Noti Accepted Mark Failed :' . $this->pgMobileCode, [$this->orderNo]); | |
| } | |
| // 결제 결과 저장 | |
| $saveResult = $this->pgClass->setPgData($this->orderNo, $getPg, null); | |
| // 결과 DB 처리 | |
| if ($saveResult === true) { | |
| $this->saved = true; | |
| Logger::channel('pgKakaopayResult')->info('Set Pg Data :' . $this->pgMobileCode, ['OK']); | |
| echo '{"result":"OK"}'; | |
| exit; | |
| $errorData = $this->pgClass->setPgResultCode($e->getMessage()); | |
| Logger::channel('pgKakaopayResult')->info('Noti Exception Data :' . $errorData['message'], ['FAIL']); | |
| $this->saveException = $e; | |
| if ($e->getMessage() == 'PG_STOP_ORDER_DATA') { | |
| Logger::channel('pgKakaopayResult')->info('Stop Pg Data : ' . $errorData['message'], ['FAIL']); | |
| } | |
| } | |
| /** | |
| * 승인 확정 표시 후 저장되지 않고 끝나면(실패 응답·예외·치명 오류) 재시도 노티가 PAYED로 응답해 | |
| * 카카오 결제가 유지됐을 수 있어 대사용으로 남긴다. 종료 시점 한 곳에서 판정한다. | |
| * | |
| * @return void | |
| */ | |
| private function logAcceptedSaveFailed(): void | |
| { | |
| if (!$this->accepted || $this->saved) { | |
| return; | |
| } | |
| $context = ['orderNo' => $this->orderNo]; | |
| if ($this->saveException !== null) { | |
| $context['exception'] = $this->saveException; | |
| $context['errorMessage'] = $this->saveException->getMessage(); | |
| } | |
| Logger::channel('pgKakaopayResult')->error('Noti Accepted But Save Failed :' . $this->pgMobileCode, $context); | |
| } | |
| /** | |
| * 결제 결과 정보 데이터 설정 | |
| * | |
변경 전PgReturn.php
| namespace Bundle\Component\Payment\Kakaopay; | |
| use Request; | |
| use Logger; | |
| use Origin\Traits\Payment\PaymentProcessingSessionTrait; | |
| /** | |
| */ | |
| const KAKAOPAY_PG_DB_ERROR = '99'; | |
| /** | |
| * 생성자 | |
| * | |
| $getPg['pgFailReason'] = $setPayData['retmsg']; | |
| $this->pgClass->setPgErrorData($this->orderNo, $getPg, null); | |
| \Logger::channel('pgKakaopayResult')->info(__METHOD__ . " ORDER NO : {$this->orderNo} ", $setPayData); | |
| } else { | |
| $script .= "location.href='../../order/order_end.php?orderNo=" . $this->orderNo . "';"; | |
| } | |
| <?php | |
| } | |
| /** | |
| * 인증 정보 데이터 설정 | |
| * | |
변경 후PgReturn.php
| namespace Bundle\Component\Payment\Kakaopay; | |
| use Component\Member\Util\MemberUtil; | |
| use Framework\Database\DBManager; | |
| use Request; | |
| use Logger; | |
| use Origin\Enum\Order\OrderStatusCode; | |
| use Origin\Traits\Payment\PaymentProcessingSessionTrait; | |
| /** | |
| */ | |
| const KAKAOPAY_PG_DB_ERROR = '99'; | |
| // 결제 성공 복귀 시 노티의 결제완료 저장을 기다리는 최대 시간(초)과 주문상태 확인 간격(마이크로초) | |
| private int $notiWaitSeconds = 25; | |
| private int $notiPollMicroseconds = 500000; | |
| /** | |
| * 생성자 | |
| * | |
| $getPg['pgFailReason'] = $setPayData['retmsg']; | |
| $this->pgClass->setPgErrorData($this->orderNo, $getPg, null); | |
| \Logger::channel('pgKakaopayResult')->info(__METHOD__ . " ORDER NO : {$this->orderNo} ", $setPayData); | |
| } elseif ($this->isNotiProcessing()) { | |
| // 재시도 노티의 PAYED 응답으로 결제가 유지됐는데 앞선 노티가 아직 저장 중이면, 주문완료 화면은 결제시도 상태라 | |
| // '다시 결제' 안내가 나와 이중결제로 이어질 수 있어 안내 후 주문내역으로 보낸다(비회원은 로그인·비회원 주문조회로 이동) | |
| $script = $this->getOrderListGuideScript($script); | |
| } else { | |
| $script .= "location.href='../../order/order_end.php?orderNo=" . $this->orderNo . "';"; | |
| } | |
| <?php | |
| } | |
| /** | |
| * 결제는 됐는데 주문 확인이 필요한 경우 안내 후 주문내역으로 보내는 스크립트 | |
| * 비회원은 주문내역 대신 로그인 화면으로 이동(PageAccess)해 비회원 주문조회에 쓸 주문번호를 함께 안내한다. | |
| * | |
| * @param string $script 창 위치 접두(opener.parent. 등) | |
| * @return string | |
| */ | |
| private function getOrderListGuideScript(string $script): string | |
| { | |
| if (MemberUtil::isLogin()) { | |
| $message = __('결제는 완료되었으며 주문 정보를 확인하고 있습니다. 잠시 후 주문내역에서 확인해 주세요.'); | |
| } else { | |
| $message = sprintf(__('결제는 완료되었으며 주문 정보를 확인하고 있습니다. 잠시 후 주문내역에서 확인해 주세요. (주문번호: %s)'), $this->orderNo); | |
| } | |
| return 'alert(' . json_encode($message) . ');' . $script . "location.href='../../mypage/order_list.php';"; | |
| } | |
| /** | |
| * 결제 성공으로 돌아왔는데 주문이 아직 결제시도(f1)면 노티가 결제완료를 저장할 때까지 주문상태를 짧은 간격으로 다시 확인한다. | |
| * 노티 락은 메일·SMS 등 후처리까지 끝나야 풀려서, 락 대신 주문상태로 판정해 저장 즉시 빠져나온다. | |
| * | |
| * @return bool true = 최대 대기 후에도 결제시도 상태(또는 확인 실패) | |
| */ | |
| private function isNotiProcessing(): bool | |
| { | |
| try { | |
| $db = \App::getInstance('DB'); | |
| // 분산서버(read replica) 상점에서 복제 지연으로 방금 저장된 결제완료를 놓치지 않도록 primary에서 조회한다 | |
| if (method_exists($db, 'stickyPrimary')) { | |
| $db->stickyPrimary(); | |
| } | |
| $deadline = microtime(true) + $this->notiWaitSeconds; | |
| while ($this->getOrderStatus($db) === OrderStatusCode::PAYMENT_ATTEMPT->value) { | |
| if (microtime(true) >= $deadline) { | |
| return true; | |
| } | |
| usleep($this->notiPollMicroseconds); | |
| } | |
| return false; | |
| } catch (\Throwable $e) { | |
| // 확인할 수 없으면 결제시도로 보고 주문내역 안내로 보낸다(주문완료 화면의 '다시 결제' 안내 방지) | |
| \Logger::channel('pgKakaopayResult')->warning(__METHOD__ . ' 주문상태 확인 실패 : ' . $this->orderNo, ['exception' => $e]); | |
| return true; | |
| } | |
| } | |
| /** | |
| * @param DBManager $db | |
| * @return string | |
| */ | |
| private function getOrderStatus(DBManager $db): string | |
| { | |
| $arrBind = []; | |
| $db->bind_param_push($arrBind, 's', $this->orderNo); | |
| $orderData = $db->query_fetch('SELECT orderStatus FROM ' . DB_ORDER . ' WHERE orderNo = ?', $arrBind, false); | |
| return (string)($orderData['orderStatus'] ?? ''); | |
| } | |
| /** | |
| * 인증 정보 데이터 설정 | |
| * | |
변경 전PaymentNotiLockGuard.php
| * | |
| * 주의: GET_LOCK은 커넥션 단위 스코프라, lock()/unlock()에는 노티 처리에 쓰는 DB 커넥션과 동일한 | |
| * 인스턴스(mysqli 싱글턴, \App::getInstance('DB'))를 넘겨야 한다. 판정 결과만 반환하며 로깅은 호출부 책임이다. | |
| */ | |
| class PaymentNotiLockGuard | |
| { | |
| const LOCK_PREFIX = 'pgNoti:'; | |
| private DBManager $db; | |
| private string $pgName; | |
| /** | |
| * @param DBManager $db 노티 처리에 사용하는 DB 커넥션과 동일한 인스턴스 | |
| { | |
| $this->db = $db; | |
| $this->pgName = $pgName; | |
| } | |
| /** | |
| * @throws DatabaseException | |
| */ | |
| public function unlock(string $orderNo): void | |
| { | |
| $arrBind = []; | |
| $this->db->bind_param_push($arrBind, 's', $this->lockName($orderNo)); | |
| $this->db->query_fetch('SELECT RELEASE_LOCK(?) AS released', $arrBind, false); | |
| } | |
| /** | |
| * @param string $orderNo | |
| * @return string | |
| */ | |
| private function lockName(string $orderNo): string | |
| { | |
| return self::LOCK_PREFIX . $this->pgName . ':' . $orderNo; | |
| } | |
| } | |
변경 후PaymentNotiLockGuard.php
| * | |
| * 주의: GET_LOCK은 커넥션 단위 스코프라, lock()/unlock()에는 노티 처리에 쓰는 DB 커넥션과 동일한 | |
| * 인스턴스(mysqli 싱글턴, \App::getInstance('DB'))를 넘겨야 한다. 판정 결과만 반환하며 로깅은 호출부 책임이다. | |
| * | |
| * 승인 확정 표시(markAccepted/isAccepted): 처리 중인 요청이 주문·금액 검증을 통과했다는 표시를 별도 락으로 남긴다. | |
| * 웹서버가 여러 대여도 같은 DB에서 보이고, 요청이 비정상 종료되면 커넥션과 함께 풀린다. | |
| * | |
| * 락 이름은 상점 단위로 분리한다 — lockName() 참고. | |
| */ | |
| class PaymentNotiLockGuard | |
| { | |
| const LOCK_PREFIX = 'pgNoti:'; | |
| const ACCEPTED_LOCK_PREFIX = 'pgNotiAccepted:'; | |
| private DBManager $db; | |
| private string $pgName; | |
| /** @var string 락 이름에 쓰는 상점 식별자 — lock()/unlock() 이 같은 이름을 쓰도록 생성 시점에 한 번만 읽는다 */ | |
| private string $shopNo; | |
| /** | |
| * @param DBManager $db 노티 처리에 사용하는 DB 커넥션과 동일한 인스턴스 | |
| { | |
| $this->db = $db; | |
| $this->pgName = $pgName; | |
| $this->shopNo = (string)\Globals::get('gLicense.godosno'); | |
| } | |
| /** | |
| * @throws DatabaseException | |
| */ | |
| public function unlock(string $orderNo): void | |
| { | |
| // 주문 락을 먼저 푼다 — 표시를 먼저 풀면 그 사이 대기가 끝난 재시도가 표시 없음·락 점유로 판정해 FAIL 응답할 수 있다 | |
| foreach ([$this->lockName($orderNo), $this->acceptedLockName($orderNo)] as $lockName) { | |
| $arrBind = []; | |
| $this->db->bind_param_push($arrBind, 's', $lockName); | |
| $this->db->query_fetch('SELECT RELEASE_LOCK(?) AS released', $arrBind, false); | |
| } | |
| } | |
| /** | |
| * 주문·금액 검증을 통과해 결제를 확정 처리 중이라는 표시를 남긴다. lock() 획득 후에만 호출한다. | |
| * | |
| * @param string $orderNo | |
| * @return bool | |
| * @throws DatabaseException | |
| */ | |
| public function markAccepted(string $orderNo): bool | |
| { | |
| $arrBind = []; | |
| $this->db->bind_param_push($arrBind, 's', $this->acceptedLockName($orderNo)); | |
| $result = $this->db->query_fetch('SELECT GET_LOCK(?, 0) AS acquired', $arrBind, false); | |
| return !empty($result) && (int)$result['acquired'] === 1; | |
| } | |
| /** | |
| * 다른 요청이 승인 확정 표시를 들고 처리 중인지 확인한다. | |
| * | |
| * @param string $orderNo | |
| * @return bool | |
| * @throws DatabaseException | |
| */ | |
| public function isAccepted(string $orderNo): bool | |
| { | |
| if (method_exists($this->db, 'stickyPrimary')) { | |
| $this->db->stickyPrimary(); | |
| } | |
| $arrBind = []; | |
| $this->db->bind_param_push($arrBind, 's', $this->acceptedLockName($orderNo)); | |
| $result = $this->db->query_fetch('SELECT IS_USED_LOCK(?) AS holder', $arrBind, false); | |
| return !empty($result) && $result['holder'] !== null; | |
| } | |
| /** | |
| * GET_LOCK 이름은 스키마가 아니라 MySQL 인스턴스 전역이라, 여러 상점 DB가 한 서버를 공유하면 | |
| * 주문번호만으로는 다른 상점의 같은 주문번호와 겹친다(주문번호에 상점 식별자 없음). 예치금 표준 | |
| * 락(SimpleCache Redis executeWithLock)과 동일하게 상점 식별자(godosno)를 이름에 포함한다. | |
| * | |
| * @param string $orderNo | |
| * @return string | |
| */ | |
| private function lockName(string $orderNo): string | |
| { | |
| return self::LOCK_PREFIX . $this->shopNo . ':' . $this->pgName . ':' . $orderNo; | |
| } | |
| /** | |
| * @param string $orderNo | |
| * @return string | |
| */ | |
| private function acceptedLockName(string $orderNo): string | |
| { | |
| return self::ACCEPTED_LOCK_PREFIX . $this->shopNo . ':' . $this->pgName . ':' . $orderNo; | |
| } | |
| } | |