array(), 'rows'=>array(), 'active'=>''); return $runtime; } function shop_order_state_table() { global $g5; return isset($g5['g5_shop_order_access_table']) ? $g5['g5_shop_order_access_table'] : G5_SHOP_TABLE_PREFIX.'order_access'; } function shop_order_lock_name($kind, $id) { return substr('g5oa_'.hash('sha256', G5_MYSQL_DB.G5_SHOP_TABLE_PREFIX.$kind.$id), 0, 64); } function shop_order_state_lock($kind, $id) { $r =& shop_order_runtime(); $key = shop_order_lock_name($kind, $id); shop_order_state_check_locks(); if (isset($r['locks'][$key])) return; // MySQL 5.0은 연결당 이름 잠금 하나만 유지한다. 업무/포인트 연결과도 분리한다. $mysqli = function_exists('mysqli_connect') && G5_MYSQLI_USE; $host = G5_MYSQL_HOST; if (substr($host, 0, 2) === 'p:') $host = substr($host, 2); if ($mysqli) { try { $link = @mysqli_connect($host, G5_MYSQL_USER, G5_MYSQL_PASSWORD, G5_MYSQL_DB); } catch (Exception $e) { $link = false; } } else { $link = @mysql_connect($host, G5_MYSQL_USER, G5_MYSQL_PASSWORD, true); } if (!$link) shop_order_access_fail(); $lock = array('link'=>$link, 'mysqli'=>$mysqli); // 실패나 예외에서도 이 연결만 닫아 획득 중인 잠금까지 해제한다. if (empty($r['shutdown_registered'])) { register_shutdown_function('shop_order_state_unlock_all'); $r['shutdown_registered'] = true; } $r['locks'][$key] = $lock; if ($mysqli) { try { $result = @mysqli_query($link, "SELECT GET_LOCK('$key', 3) AS acquired"); } catch (Exception $e) { $result = false; } $row = $result ? mysqli_fetch_assoc($result) : false; } else { $result = @mysql_query("SELECT GET_LOCK('$key', 3) AS acquired", $link); $row = $result ? mysql_fetch_assoc($result) : false; } if (!$row || (int)$row['acquired'] !== 1) { shop_order_state_unlock($kind, $id); shop_order_access_fail(); } } // 잠금 연결이 끊겼거나 자동 재접속되면 기존 잠금을 보유한 것으로 취급하지 않는다. function shop_order_state_check_locks() { $r =& shop_order_runtime(); foreach ($r['locks'] as $key=>$lock) { $sql = "SELECT IS_USED_LOCK('$key') = CONNECTION_ID() AS owned"; if ($lock['mysqli']) { try { $result = @mysqli_query($lock['link'], $sql); } catch (Exception $e) { $result = false; } $row = $result ? mysqli_fetch_assoc($result) : false; } else { $result = @mysql_query($sql, $lock['link']); $row = $result ? mysql_fetch_assoc($result) : false; } if (!$row || (int)$row['owned'] !== 1) shop_order_access_fail(); } } function shop_order_state_unlock($kind, $id) { $r =& shop_order_runtime(); $key = shop_order_lock_name($kind, $id); if (!isset($r['locks'][$key])) return; $lock = $r['locks'][$key]; unset($r['locks'][$key]); // 비영속 연결 종료 자체가 해당 연결의 잠금을 해제한다. if ($lock['mysqli']) mysqli_close($lock['link']); else mysql_close($lock['link']); } function shop_order_state_unlock_all() { $r =& shop_order_runtime(); foreach ($r['locks'] as $lock) { if ($lock['mysqli']) mysqli_close($lock['link']); else mysql_close($lock['link']); } $r['locks'] = array(); } function shop_order_state_row($id) { if (!shop_order_access_id((string)$id)) shop_order_access_fail(); return sql_fetch("select * from ".shop_order_state_table()." where od_id='$id'", false); } function shop_order_state_meta($id) { if (!shop_order_access_id((string)$id)) shop_order_access_fail(); return sql_fetch("select od_id,token_hash,pg,status,expires from ".shop_order_state_table()." where od_id='$id'", false); } function shop_order_state_write($id, $fields) { shop_order_state_check_locks(); $allowed = array('status','payment_key','response_json','state_json','token_hash','expires','updated_at'); $set = array(); foreach ($fields as $key=>$value) { if (!in_array($key, $allowed, true)) shop_order_access_fail(); $set[] = "$key='".sql_escape_string((string)$value)."'"; } $set[] = 'updated_at='.G5_SERVER_TIME; if (!sql_query("update ".shop_order_state_table()." set ".implode(',', $set)." where od_id='$id'", false)) shop_order_access_fail(); } function shop_order_state_token($id) { $token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : ''; if ($token === '') { $states = get_session('ss_order_data_access'); $token = isset($states[$id]['token']) ? $states[$id]['token'] : ''; } if (!is_string($token) || !preg_match('/\A'.preg_quote((string)$id, '/').'\.[a-f0-9]{64}\z/D', $token)) return ''; return $token; } function shop_order_checkout_fields($id, $personal = false) { if (!shop_order_access_id((string)$id)) return ''; $map = get_session('ss_order_checkouts'); if (!is_array($map)) $map = array(); foreach ($map as $key=>$value) if ($value['expires'] <= G5_SERVER_TIME) unset($map[$key]); if (count($map) >= 30) array_shift($map); $nonce = shop_order_random_hex(24); $map[(string)$id] = array('nonce'=>$nonce, 'expires'=>G5_SERVER_TIME+7200, 'cart'=>(string)get_session(get_session('ss_direct') ? 'ss_cart_direct' : 'ss_cart_id'), 'direct'=>(bool)get_session('ss_direct'), 'personal'=>$personal, 'personal_hash'=>get_session('ss_personalpay_hash')); set_session('ss_order_checkouts', $map); return ''. ''; } function shop_order_checkout_restore() { if (!isset($_POST['g5_checkout_id'])) return; // 기존 사용자 스킨은 현재 세션 바인딩을 사용한다. $id = $_POST['g5_checkout_id']; $nonce = isset($_POST['g5_checkout_nonce']) ? $_POST['g5_checkout_nonce'] : ''; $map = get_session('ss_order_checkouts'); if (!shop_order_access_id($id) || !is_string($nonce) || !isset($map[$id]) || !shop_order_equals($map[$id]['nonce'], $nonce) || $map[$id]['expires'] <= G5_SERVER_TIME) shop_order_access_fail(); $c = $map[$id]; if ($c['personal'] !== !empty($_POST['pp_id']) || ($c['personal'] && (string)$_POST['pp_id'] !== $id)) shop_order_access_fail(); set_session('ss_order_id', $id); set_session('ss_direct', $c['direct']); set_session($c['direct'] ? 'ss_cart_direct' : 'ss_cart_id', $c['cart']); if ($c['personal']) { set_session('ss_personalpay_id', $id); set_session('ss_personalpay_hash', $c['personal_hash']); } } function shop_order_state_save($id, $state) { shop_order_state_lock('order', $id); $previous = shop_order_state_meta($id); if ($previous) { $token = shop_order_state_token($id); if (!$token || !shop_order_equals($previous['token_hash'], hash('sha256', $token)) || $previous['status'] !== 'pending') shop_order_access_fail(); } $token = $id.'.'.shop_order_random_hex(32); $state['version'] = 2; unset($state['token'], $state['payment_key']); $json = json_encode($state); if ($json === false) shop_order_access_fail(); $table = shop_order_state_table(); $fields = "token_hash='".hash('sha256', $token)."', pg='".sql_escape_string($state['pg'])."', ". "cart_id='".sql_escape_string($state['cart'])."', status='pending', expires=".(int)$state['expires']. ", state_json='".sql_escape_string($json)."', payment_key='', response_json='', updated_at=".G5_SERVER_TIME; $query = $previous ? "update $table set $fields where od_id='$id'" : "insert into $table set od_id='$id', $fields"; if (!sql_query($query, false)) shop_order_access_fail(); $state['token'] = $token; $states = get_session('ss_order_data_access'); if (!is_array($states)) $states = array(); $states[$id] = $state; set_session('ss_order_data_access', $states); header('Cache-Control: no-store, private'); header('Referrer-Policy: no-referrer'); header('X-G5-Order-State: '.$token); return $state; } function shop_order_state_can_save($id) { shop_order_state_lock('order', $id); $old = shop_order_state_meta($id); if (!$old) return; $token = shop_order_state_token($id); if ($old['status'] !== 'pending' || !$token || !shop_order_equals($old['token_hash'], hash('sha256', $token))) shop_order_access_fail(); } function shop_order_state_load($id, $pg) { global $g5, $member, $is_member, $is_guest; if (!shop_order_access_id($id)) shop_order_access_fail(); shop_order_state_lock('order', $id); $row = shop_order_state_meta($id); if (!$row) { // 1차 패치가 만든 세션/본문 해시가 검증되는 요청만 전환한다. // PAYREQ_MAP 또는 주문번호만 있는 과거 요청은 legacy 검증에서도 거부된다. $data = shop_order_access_load_legacy($id, $pg); $states = get_session('ss_order_data_access'); shop_order_state_save($id, $states[$id]); $row = shop_order_state_meta($id); } $token = shop_order_state_token($id); if (!$token || $row['pg'] !== $pg || !shop_order_equals($row['token_hash'], hash('sha256', $token))) shop_order_access_fail(); if (!in_array($row['status'], array('pending','approving','approved','finalizing'), true)) shop_order_access_fail(); if ((int)$row['expires'] <= G5_SERVER_TIME && $row['status'] === 'pending') shop_order_access_fail(); $row = shop_order_state_row($id); $state = json_decode($row['state_json'], true); if (!is_array($state) || !isset($state['version']) || $state['version'] !== 2) shop_order_access_fail(); $current = isset($member['mb_id']) ? (string)$member['mb_id'] : ''; if ($current !== '' && $current !== $state['member']) shop_order_access_fail(); // 결제 토큰은 해당 주문 처리에만 회원 컨텍스트를 제공한다. 로그인 세션을 발급하지 않는다. if ($current === '' && $state['member'] !== '') { $owner = get_member($state['member']); if (empty($owner['mb_id']) || !empty($owner['mb_leave_date']) || !empty($owner['mb_intercept_date'])) shop_order_access_fail(); $member = $owner; $is_member = true; $is_guest = false; } if ($state['personal']) { $done = sql_fetch("select pp_tno, pp_use, pp_price, pp_time from {$g5['g5_shop_personalpay_table']} where pp_id='$id'"); if (!$done || !$done['pp_use'] || $done['pp_tno'] || !shop_order_equals($state['personal_hash'], md5($id.$done['pp_price'].$done['pp_time']))) shop_order_access_fail(); } else { $done = sql_fetch("select od_id from {$g5['g5_shop_order_table']} where od_id='$id'"); if (!empty($done['od_id'])) shop_order_access_fail(); // 부분 저장도 자동 재승인하지 않는다. } if ($row['status'] === 'finalizing') { if ($state['personal']) shop_order_access_fail(); $cart_id = sql_escape_string($state['cart']); $remaining = sql_fetch("select count(*) as cnt from {$g5['g5_shop_cart_table']} where od_id='$cart_id' and ct_select='1' and ct_status='쇼핑'"); if (empty($remaining['cnt']) || $row['response_json'] === '') shop_order_access_fail(); shop_order_state_write($id, array('status'=>'approved')); $row['status'] = 'approved'; } $rows = sql_query("select cart_id,mb_id,dt_pg,dt_time from {$g5['g5_shop_order_data_table']} where od_id='$id'"); if (sql_num_rows($rows) !== 1) shop_order_access_fail(); $meta = sql_fetch_array($rows); if ((string)$meta['cart_id'] !== $state['cart'] || $meta['mb_id'] !== $state['member'] || $meta['dt_pg'] !== $pg || $meta['dt_time'] !== $state['time']) shop_order_access_fail(); $temp = sql_fetch("select dt_data from {$g5['g5_shop_order_data_table']} where od_id='$id'"); if (empty($temp['dt_data']) || !shop_order_equals($state['digest'], hash('sha256', $temp['dt_data']))) shop_order_access_fail(); $data = shop_order_decode_data($temp['dt_data']); if (!is_array($data) || !empty($data['pp_id']) !== $state['personal'] || ($state['personal'] && (string)$data['pp_id'] !== $id)) shop_order_access_fail(); unset($data['od_pwd']); $state['token'] = $token; if ($row['payment_key'] !== '') $state['payment_key'] = $row['payment_key']; $states = get_session('ss_order_data_access'); if (!is_array($states)) $states = array(); $states[$id] = $state; set_session('ss_order_data_access', $states); set_session('ss_order_id', $id); set_session('ss_direct', $state['direct']); set_session($state['direct'] ? 'ss_cart_direct' : 'ss_cart_id', $state['cart']); if ($state['personal']) { set_session('ss_personalpay_id', $id); set_session('ss_personalpay_hash', $state['personal_hash']); } $r =& shop_order_runtime(); $r['rows'][$id] = $row; $r['active'] = $id; $data['g5_order_state'] = $token; return $data; } function shop_order_state_prepare($personal) { global $default; $token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : ''; $id = (string)get_session($personal ? 'ss_personalpay_id' : 'ss_order_id'); if ($token !== '') { if (!is_string($token) || !preg_match('/\A([0-9]{1,20})\.[a-f0-9]{64}\z/D', $token, $m)) shop_order_access_fail(); $id = $m[1]; } $states = get_session('ss_order_data_access'); if ($token === '' && empty($states[$id])) return; // 임시 저장 없는 기존 PG/무통장 흐름 $row = shop_order_state_meta($id); $pg = $row ? $row['pg'] : (isset($states[$id]['pg']) ? $states[$id]['pg'] : ''); $data = shop_order_access_load($id, $pg); if (!empty($data['pp_id']) !== $personal) shop_order_access_fail(); $default['de_pg_service'] = $pg; // 결제 복귀의 업무 필드는 저장 당시 서버 데이터만 사용한다. $fields = array_merge(shop_order_data_fields($personal ? 1 : 0), array('pp_id','sw_direct','od_temp_point','od_coupon','od_send_coupon','cp_id','cp_price','it_id','od_cp_id','sc_cp_id','od_hope_date','ad_default','ad_subject','good_mny','amountValue','comm_tax_mny','comm_vat_mny','comm_free_mny')); foreach ($fields as $key) { $value = isset($data[$key]) ? $data[$key] : ''; $_POST[$key] = $value; $GLOBALS[$key] = $value; } if (!$personal) { $s = get_session('ss_order_data_access'); shop_order_state_lock('cart', $s[$id]['cart']); $cart = sql_escape_string($s[$id]['cart']); $busy = sql_fetch("select od_id from ".shop_order_state_table()." where cart_id='$cart' and od_id<>'$id' and status in ('approving','approved','finalizing','unknown','cancel_pending') limit 1"); if ($busy) shop_order_access_fail(); } } function shop_order_state_begin($id, $key, $expected) { shop_order_state_lock('order', $id); $row = shop_order_state_row($id); if (!$row || !in_array($row['status'], array('pending','approving','approved'), true)) shop_order_access_fail(); if ($row['payment_key'] !== '' && !shop_order_equals($row['payment_key'], $key)) shop_order_access_fail(); $state = json_decode($row['state_json'], true); if (!is_array($state) || (int)$expected <= 0 || (isset($state['expected_amount']) && (int)$state['expected_amount'] !== (int)$expected)) shop_order_access_fail(); if ($row['status'] === 'pending') { $state['expected_amount'] = (int)$expected; $state['approval_started'] = G5_SERVER_TIME; shop_order_state_write($id, array('status'=>'approving','payment_key'=>$key,'state_json'=>json_encode($state))); } return $row; } function shop_order_state_approved($id, $response) { $json = json_encode($response); if ($json === false) shop_order_access_fail(); shop_order_state_write($id, array('status'=>'approved','response_json'=>$json)); } function shop_order_state_finalizing() { $r =& shop_order_runtime(); if ($r['active'] === '') return; $row = shop_order_state_row($r['active']); if ($row && $row['status'] === 'approved') shop_order_state_write($r['active'], array('status'=>'finalizing')); } function shop_order_state_complete($id) { $row = shop_order_state_row($id); if (!$row) return; shop_order_state_write($id, array('status'=>'completed','state_json'=>'','response_json'=>'','token_hash'=>'','payment_key'=>'')); } function shop_order_state_cancel($status) { $r =& shop_order_runtime(); if ($r['active'] !== '') shop_order_state_write($r['active'], array('status'=>$status)); } function shop_order_toss_approve($toss, $id, $key, $expected) { $row = shop_order_state_begin($id, $key, $expected); $toss->headers[] = 'Idempotency-Key: g5-confirm-'.hash('sha256', (defined('G5_MYSQL_DB') ? G5_MYSQL_DB : '').G5_SHOP_TABLE_PREFIX.$id.$key); // 승인 응답을 놓쳤거나 저장 후 중단된 경우에도 먼저 PG에 현재 상태를 조회한다. if ($row['status'] !== 'pending') { if (!$toss->getPaymentByOrderId($id)) shop_order_access_fail(); $response = $toss->responseData; if (!isset($response['orderId'],$response['paymentKey'],$response['totalAmount'],$response['status']) || $response['orderId'] !== $id || $response['paymentKey'] !== $key || (int)$response['totalAmount'] !== (int)$expected) shop_order_access_fail(); if (in_array($response['status'], array('CANCELED','ABORTED','EXPIRED'), true)) { shop_order_state_write($id, array('status'=>$response['status'] === 'CANCELED' ? 'cancelled' : 'failed')); shop_order_access_fail(); } if ($response['status'] === 'IN_PROGRESS' && $row['status'] === 'approving') { $state = json_decode($row['state_json'], true); // PG가 미승인을 확인한 경우에만 동일 본문/멱등 키로 재시도한다(15일보다 짧게 제한). if (empty($state['approval_started']) || G5_SERVER_TIME - $state['approval_started'] > 14*86400 || !$toss->approvePayment()) shop_order_access_fail(); $response = $toss->responseData; } } else { if (!$toss->approvePayment()) { // 네트워크 오류를 결제 실패로 단정하지 않는다. 다음 시도는 조회부터 시작한다. shop_order_access_fail(); } $response = $toss->responseData; } if (!isset($response['orderId'],$response['paymentKey'],$response['totalAmount'],$response['status'],$response['method']) || $response['orderId'] !== $id || $response['paymentKey'] !== $key || (int)$response['totalAmount'] !== (int)$expected || !($response['status'] === 'DONE' || ($response['status'] === 'WAITING_FOR_DEPOSIT' && $response['method'] === '가상계좌'))) shop_order_access_fail(); shop_order_state_approved($id, $response); return true; } function shop_order_kcp_result_fields() { return explode(' ', 'tno amount pnt_issue card_cd card_name app_time app_no noinf quota partcanc_yn bankname bank_name bank_code depositor account pt_idno pnt_amount pnt_app_time pnt_app_no add_pnt use_pnt rsv_pnt commid mobile_no tk_van_code tk_app_no cash_authno cash_tr_code escw_yn res_cd res_msg app_kakaomny_time kakaomny_mny kcp_pay_method od_other_pay_type'); } function shop_order_state_abort_pending() { $token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : ''; if (!is_string($token) || !preg_match('/\A([0-9]{1,20})\.[a-f0-9]{64}\z/D', $token, $m)) shop_order_access_fail(); shop_order_state_lock('order', $m[1]); $row = shop_order_state_meta($m[1]); if (!$row || !shop_order_equals($row['token_hash'], hash('sha256', $token))) shop_order_access_fail(); // PG 승인 여부가 불명확한 상태에는 실패 복귀가 와도 데이터를 지우지 않는다. if ($row['status'] === 'pending') shop_order_state_write($m[1], array('status'=>'failed')); shop_order_access_fail(); }