security: KVE-2026-2140 주문 보호와 5.6.39 보안 업데이트 배포

This commit is contained in:
whitedot committed 2026-09-14 06:53:14 +00:00
1 parent 2517343d51
commit f7aa440a80
43 files changed
+1510 -328

No files matched your search

+58 -7
View File
@@ -42,6 +42,7 @@ function g5_migration_replace_placeholders($sql)
'g5_shop_coupon_zone_table' => $shop_prefix . 'coupon_zone',
'g5_shop_inicis_log_table' => $shop_prefix . 'inicis_log',
'g5_shop_order_data_table' => $shop_prefix . 'order_data',
'g5_shop_order_access_table' => $shop_prefix . 'order_access',
'g5_shop_post_log_table' => $shop_prefix . 'order_post_log',
'g5_shop_order_cancel_log_table' => $shop_prefix . 'order_cancel_log',
'g5_shop_inicis_pay_table' => $shop_prefix . 'inicis_pay',
@@ -85,7 +86,7 @@ function g5_migration_parse_condition($directive, $file)
return $condition;
}
function g5_migration_parse_file($file)
function g5_migration_read_file($file)
{
$contents = @file_get_contents($file);
if ($contents === false) {
@@ -110,6 +111,17 @@ function g5_migration_parse_file($file)
$migration['description'] = trim($matches[1]);
}
$migration['contents'] = $contents;
return $migration;
}
function g5_migration_parse_file($file)
{
$migration = g5_migration_read_file($file);
if (isset($migration['error'])) return $migration;
$contents = $migration['contents'];
unset($migration['contents']);
if (preg_match('/^--[\t ]*@skip-if-column[\t ]+(\S+)[\t ]+(\S+)[\t ]*\r?$/mi', $contents, $matches)) {
$migration['skip_table'] = g5_migration_replace_placeholders(trim($matches[1], '`'));
$migration['skip_column'] = trim($matches[2], '`');
@@ -303,17 +315,31 @@ function g5_migration_execute_statement($statement)
return array('error' => '', 'executed' => $executed);
}
function g5_migration_get_records()
function g5_migration_get_records(&$error = null)
{
$table = g5_migration_table_name();
$records = array();
$error = '';
if (!g5_migration_table_exists($table)) {
$tables = sql_query("SHOW TABLES LIKE '" . sql_real_escape_string($table) . "'", false);
if (!$tables) {
$error = '마이그레이션 이력 테이블을 조회하지 못했습니다: ' . sql_error_info();
return $records;
}
$exists = false;
while ($row = sql_fetch_array($tables)) {
if (reset($row) === $table) $exists = true;
}
if (!$exists) {
return $records;
}
$error = g5_migration_validate_table();
if ($error !== '') return $records;
$result = sql_query("SELECT migration_id, checksum, status, error_message, applied_at FROM `{$table}` ORDER BY migration_id", false);
if (!$result) {
$error = '마이그레이션 이력을 읽지 못했습니다: ' . sql_error_info();
return $records;
}
@@ -399,7 +425,8 @@ function g5_migration_needs_no_execution($migration, &$cache)
function g5_migration_status()
{
$migrations = g5_migration_discover();
$records = g5_migration_get_records();
$records = g5_migration_get_records($history_error);
if ($history_error !== '') return array(array('error' => $history_error));
$status = array();
$inspection_cache = array();
@@ -470,6 +497,25 @@ function g5_migration_validate_target_dependencies($migrations, $records, $targe
return '';
}
// 조회에서는 구조만 검사하고, 복구를 위한 DDL은 자동 실행하지 않는다.
function g5_migration_validate_table($table = null)
{
if ($table === null) $table = g5_migration_table_name();
$columns = 'migration_id, description, checksum, status, error_message, execution_ms, applied_at';
if (!sql_query("SELECT {$columns} FROM `{$table}` LIMIT 0", false)) {
return "마이그레이션 이력 테이블 `{$table}`의 구조를 확인하지 못했습니다: " . sql_error_info()
. ' (docs/database-migrations.md의 이력 테이블 복구 절차를 확인해 주십시오.)';
}
$indexes = sql_query("SHOW INDEX FROM `{$table}` WHERE Key_name = 'PRIMARY'", false);
if (!$indexes) return '마이그레이션 이력 기본키를 확인하지 못했습니다: ' . sql_error_info();
$primary = array();
while ($row = sql_fetch_array($indexes)) $primary[] = $row['Column_name'];
if ($primary !== array('migration_id')) {
return "마이그레이션 이력 테이블 `{$table}`의 기본키가 migration_id 단일 컬럼이 아닙니다. docs/database-migrations.md의 이력 테이블 복구 절차를 확인해 주십시오.";
}
return '';
}
function g5_migration_ensure_table()
{
$table = g5_migration_table_name();
@@ -487,9 +533,9 @@ function g5_migration_ensure_table()
return (bool) sql_query($sql, false);
}
function g5_migration_save_record($migration, $status, $error_message, $execution_ms)
function g5_migration_save_record($migration, $status, $error_message, $execution_ms, $table = null)
{
$table = g5_migration_table_name();
if ($table === null) $table = g5_migration_table_name();
$id = sql_real_escape_string($migration['id']);
$description = sql_real_escape_string($migration['description']);
$checksum = sql_real_escape_string($migration['checksum']);
@@ -525,7 +571,12 @@ function g5_migration_run($target_id = '', $record_existing = false)
}
$migrations = g5_migration_discover();
$records = g5_migration_get_records();
$records = g5_migration_get_records($history_error);
if ($history_error !== '') {
$result['errors'][] = $history_error;
sql_query("SELECT RELEASE_LOCK('{$lock_name}')", false);
return $result;
}
$target_found = $target_id === '';
if ($record_existing) {
+3 -1
View File
@@ -2829,7 +2829,9 @@ function add_order_post_log($msg='', $code='error'){
if( empty($_POST) ) return;
$post_data = base64_encode(serialize($_POST));
$log_data = $_POST;
unset($log_data['od_pwd'], $log_data['g5_order_state'], $log_data['g5_checkout_nonce']);
$post_data = base64_encode(serialize($log_data));
$od_id = get_session('ss_order_id');
if( $code === 'delete' ){
+107
View File
@@ -0,0 +1,107 @@
<?php
if (!defined('_GNUBOARD_')) exit;
include_once(dirname(__FILE__).'/shop_order_compat.lib.php');
include_once(dirname(__FILE__).'/shop_order_state.lib.php');
include_once(dirname(__FILE__).'/shop_order_fields.lib.php');
// 결제 복귀는 주문번호가 아니라 임시 저장 시 발급한 서버 세션 상태로 인증한다.
function shop_order_access_fail()
{
header('Cache-Control: no-store, private');
header('Referrer-Policy: no-referrer');
alert('결제 요청을 확인할 수 없습니다. 결제 내역을 확인한 후 다시 시도해 주십시오.', G5_SHOP_URL);
exit;
}
function shop_order_access_id($value)
{
return is_string($value) && preg_match('/\A[0-9]{1,20}\z/D', $value);
}
function shop_order_access_load_legacy($order_id, $pg)
{
global $g5, $member;
header('Cache-Control: no-store, private');
header('Referrer-Policy: no-referrer');
if (!shop_order_access_id($order_id)) shop_order_access_fail();
$states = get_session('ss_order_data_access');
$state = is_array($states) && isset($states[$order_id]) ? $states[$order_id] : null;
$member_id = isset($member['mb_id']) ? (string)$member['mb_id'] : '';
if (!is_array($state) || $state['pg'] !== $pg || $state['member'] !== $member_id ||
$state['expires'] <= G5_SERVER_TIME) shop_order_access_fail();
if ($state['personal']) {
if ((string)get_session('ss_personalpay_id') !== $order_id ||
!$state['personal_hash'] ||
$state['personal_hash'] !== get_session('ss_personalpay_hash')) shop_order_access_fail();
$done = sql_fetch("select pp_tno, pp_use from {$g5['g5_shop_personalpay_table']} where pp_id = '$order_id'");
if (!$done || !$done['pp_use'] || $done['pp_tno']) shop_order_access_fail();
} else {
$cart = get_session($state['direct'] ? 'ss_cart_direct' : 'ss_cart_id');
if ((string)get_session('ss_order_id') !== $order_id ||
(bool)get_session('ss_direct') !== $state['direct'] ||
!$cart || (string)$cart !== $state['cart']) shop_order_access_fail();
$done = sql_fetch("select od_id from {$g5['g5_shop_order_table']} where od_id = '$order_id'");
if (!empty($done['od_id'])) shop_order_access_fail();
}
// 중복 행을 임의로 선택하지 않고, 최소 메타데이터부터 대조한다.
$rows = sql_query("select cart_id, mb_id, dt_pg, dt_time from {$g5['g5_shop_order_data_table']} where od_id = '$order_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();
$row = sql_fetch("select dt_data from {$g5['g5_shop_order_data_table']} where od_id = '$order_id' and dt_pg = '".sql_escape_string($pg)."'");
if (empty($row['dt_data']) || !shop_order_equals($state['digest'], hash('sha256', $row['dt_data']))) shop_order_access_fail();
$data = shop_order_decode_data($row['dt_data']);
if (!is_array($data) || (!empty($data['pp_id']) !== $state['personal']) ||
($state['personal'] && (string)$data['pp_id'] !== $order_id)) shop_order_access_fail();
return $data;
}
function shop_order_access_load($order_id, $pg)
{
header('Cache-Control: no-store, private');
header('Referrer-Policy: no-referrer');
return shop_order_state_load($order_id, $pg);
}
function shop_order_access_payment($order_id, $payment_key, $amount = null)
{
if (!is_string($payment_key) || !preg_match('/\A[A-Za-z0-9_-]{1,200}\z/D', $payment_key)) shop_order_access_fail();
if ($amount !== null && (!is_string($amount) || !preg_match('/\A[0-9]{1,12}\z/D', $amount))) shop_order_access_fail();
$data = shop_order_access_load($order_id, 'toss');
if ($amount !== null && (!is_string($amount) || !preg_match('/\A[0-9]{1,12}\z/D', $amount) ||
!isset($data['amountValue']) || (int)$amount <= 0 || (int)$amount !== (int)$data['amountValue'])) shop_order_access_fail();
$states = get_session('ss_order_data_access');
if (isset($states[$order_id]['payment_key']) && $states[$order_id]['payment_key'] !== $payment_key) shop_order_access_fail();
$states[$order_id]['payment_key'] = $payment_key;
shop_order_state_write($order_id, array('payment_key'=>$payment_key));
set_session('ss_order_data_access', $states);
$data['paymentKey'] = $payment_key;
$data['orderId'] = $order_id;
return $data;
}
// 클라이언트가 보낸 해시를 신뢰하지 않고 저장 당시 서버 상태만 사용한다.
function shop_order_access_password($order_id)
{
$states = get_session('ss_order_data_access');
if (!is_array($states) || !isset($states[$order_id])) return null;
$state = $states[$order_id];
shop_order_access_load($order_id, $state['pg']);
if ($state['personal'] || empty($state['password_hash'])) shop_order_access_fail();
return $state['password_hash'];
}
function shop_order_access_forget($order_id)
{
shop_order_state_complete($order_id);
$states = get_session('ss_order_data_access');
if (is_array($states)) {
unset($states[$order_id]);
set_session('ss_order_data_access', $states);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// PHP 5.2.17에서도 인증용 난수는 강한 난수원만 사용한다.
function shop_order_random_hex($length)
{
$bytes = false;
if (function_exists('random_bytes')) {
try { $bytes = random_bytes($length); } catch (Exception $e) { $bytes = false; }
}
if ($bytes === false && function_exists('openssl_random_pseudo_bytes')) {
$strong = false;
$candidate = openssl_random_pseudo_bytes($length, $strong);
if ($strong) $bytes = $candidate;
}
if ($bytes === false && DIRECTORY_SEPARATOR === '/') {
$stream = @fopen('/dev/urandom', 'rb');
if ($stream) {
$bytes = '';
while (strlen($bytes) < $length) {
$part = @fread($stream, $length - strlen($bytes));
if ($part === false || $part === '') break;
$bytes .= $part;
}
fclose($stream);
}
}
if ($bytes === false && function_exists('mcrypt_create_iv') && defined('MCRYPT_DEV_URANDOM')) {
$bytes = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);
}
if (!is_string($bytes) || strlen($bytes) !== $length) shop_order_access_fail();
return bin2hex($bytes);
}
// 토큰·다이제스트는 길이가 공개된 고정 길이 값이다. 같은 길이는 끝까지 비교한다.
function shop_order_equals($known, $input)
{
if (!is_string($known) || !is_string($input) || strlen($known) !== strlen($input)) return false;
$difference = 0;
for ($i = 0, $length = strlen($known); $i < $length; $i++) {
$difference |= ord($known[$i]) ^ ord($input[$i]);
}
return $difference === 0;
}
// PHP 버전과 무관하게 객체·참조를 생성하지 않는다. 기존 serialize/base64 행과 호환한다.
function shop_order_decode_data($encoded)
{
if (!is_string($encoded) || strlen($encoded) > 1398104) return false;
$serialized = base64_decode($encoded, true);
if ($serialized === false || strlen($serialized) > 1048576) return false;
$offset = 0;
$nodes = 0;
$valid = true;
$value = shop_order_decode_value($serialized, $offset, $nodes, $valid, 0);
return $valid && $offset === strlen($serialized) && is_array($value) ? $value : false;
}
function shop_order_decode_value($data, &$offset, &$nodes, &$valid, $depth)
{
if (!$valid || $depth > 32 || ++$nodes > 20000 || $offset >= strlen($data)) {
$valid = false;
return null;
}
$type = $data[$offset++];
if ($type === 'N' && substr($data, $offset++, 1) === ';') return null;
if (substr($data, $offset++, 1) !== ':') { $valid = false; return null; }
if ($type === 's' || $type === 'a') {
$end = strpos($data, ':', $offset);
if ($end === false) { $valid = false; return null; }
$number = substr($data, $offset, $end - $offset);
if (!preg_match('/\A(?:0|[1-9][0-9]{0,6})\z/D', $number)) { $valid = false; return null; }
$count = (int)$number;
$offset = $end + 1;
if ($type === 's') {
if (substr($data, $offset++, 1) !== '"' || $count > strlen($data) - $offset - 2) { $valid = false; return null; }
$value = substr($data, $offset, $count);
$offset += $count;
if (substr($data, $offset, 2) !== '";') $valid = false;
$offset += 2;
return $value;
}
if ($count > 10000 || substr($data, $offset++, 1) !== '{') { $valid = false; return null; }
$value = array();
for ($i = 0; $i < $count && $valid; $i++) {
$key = shop_order_decode_value($data, $offset, $nodes, $valid, $depth + 1);
if (!is_int($key) && !is_string($key)) { $valid = false; break; }
$item = shop_order_decode_value($data, $offset, $nodes, $valid, $depth + 1);
if ($valid) $value[$key] = $item;
}
if (substr($data, $offset++, 1) !== '}') $valid = false;
return $value;
}
if ($type === 'b' || $type === 'i' || $type === 'd') {
$end = strpos($data, ';', $offset);
if ($end === false) { $valid = false; return null; }
$number = substr($data, $offset, $end - $offset);
$offset = $end + 1;
if ($type === 'b' && ($number === '0' || $number === '1')) return $number === '1';
if ($type === 'i' && preg_match('/\A-?(?:0|[1-9][0-9]*)\z/D', $number) && (string)(int)$number === $number) return (int)$number;
if ($type === 'd' && preg_match('/\A-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?\z/D', $number) && is_finite((float)$number)) return (float)$number;
}
$valid = false;
return null;
}
+42
View File
@@ -0,0 +1,42 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// 기본 주문서와 PG별 폼 필드의 명시적 허용 목록. 비밀번호/상태 토큰은 별도 저장한다.
function shop_order_allowed_fields($pg)
{
$common = explode(' ', 'inicis_pro nhnkcp_pay_case ad_default ad_sel_addr ad_subject amountValue cp_id cp_price f_cp_id f_cp_prc it_id it_name it_notax it_price item_coupon max_temp_point naverpay_point_direct o_cp_id o_cp_prc od_addr1 od_addr2 od_addr3 od_addr_jibeon od_b_addr1 od_b_addr2 od_b_addr3 od_b_addr_jibeon od_b_hp od_b_name od_b_tel od_b_zip od_bank_account od_coupon od_cp_id od_deposit_name od_email od_goods_name od_hope_date od_hp od_ip od_memo od_name od_price od_send_cost od_send_cost2 od_send_coupon od_settle_case od_tel od_temp_point od_test od_zip org_od_price post_cart_id pp_email pp_hp pp_id pp_name pp_settle_case s_cp_id s_cp_prc sc_cp_id sw_direct');
$pg_fields = array(
'toss' => 'amountCurrency buyeremail buyertel cardUseAppCardOnly cardUseCardPoint cardUseEscrow cardeasyPay cardflowMode comm_free_mny comm_tax_mny comm_vat_mny customerEmail customerMobilePhone customerName escrowProducts good_mny id_info method od_id orderId orderName submitChecked taxFreeAmount tr_code tx windowTarget',
'kcp' => 'ActionResult KCP_PAY_MODULE Ret_URL amt_sup amt_svc amt_tax amt_tot applepay_direct approval_key bank_issu bank_name bask_cntx buyr_mail buyr_name buyr_tel1 buyr_tel2 cash_authno cash_id_info cash_tr_code cash_tsdtime cash_yn comm_free_mny comm_tax_mny comm_vat_mny complex_pnt_yn corp_type currency def_site_cd deli_term disp_tax_yn enc_data enc_info eng_flag epnt_issu escrow_foot escw_used fix_inst good_cd good_expr good_info good_mny good_name id_info ipgm_date kakaopay_direct kcp_noint kcp_noint_quota module_type naverpay_direct nhnkcp_pay_case not_used_card ordr_idxx param_opt_1 param_opt_2 param_opt_3 pay_method pay_mod payco_direct pt_memcorp_cd quotaopt rcvr_add1 rcvr_add2 rcvr_mail rcvr_name rcvr_tel1 rcvr_tel2 rcvr_zipx req_tx res_cd res_msg ret_pay_method save_ocb settle_method shop_name shop_user_id site_cd site_logo site_name skin_indx submitChecked tablet_size tar_opener tax_flag tk_shop_id tno tr_code trace_no trad_time tran_cd tx use_pay_method used_card used_card_CCXX used_card_YN vcnt_expire_term vcnt_expire_term_time wish_vbank_list',
'inicis' => 'gopaymethod DEF_RESERVED P_AMT P_APPL_NUM P_AUTH_DT P_AUTH_NO P_CARD_ISSUER P_EMAIL P_GOODS P_HASH P_HPP_CORP P_HPP_METHOD P_MID P_MOBILE P_NEXT_URL P_NOTI P_NOTI_URL P_OID P_QUOTABASE P_RESERVED P_RETURN_URL P_SKIP_TERMS P_TAX P_TAXFREE P_TYPE P_UNAME P_VACT_BANK P_VACT_NAME P_VACT_NUM acceptmethod buyeremail buyername buyertel charset closeUrl comm_free_mny comm_tax_mny comm_vat_mny currency good_mny goodname id_info ini_logoimage_url ini_menuarea_url mKey mid nointerest od_id oid parentemail payViewType popupUrl price quotabase recvaddr recvname recvpostnum recvtel res_cd returnUrl signature submitChecked tax taxfree timestamp tr_code tx version',
'lg' => 'CST_MID CST_PLATFORM LGD_AMOUNT LGD_BUYER LGD_BUYERADDRESS LGD_BUYEREMAIL LGD_BUYERID LGD_BUYERIP LGD_BUYERPHONE LGD_CASHRECEIPTYN LGD_CASNOTEURL LGD_CUSTOM_FIRSTPAY LGD_CUSTOM_PROCESSTYPE LGD_CUSTOM_SKIN LGD_CUSTOM_USABLEPAY LGD_EASYPAY_ONLY LGD_ENCODING LGD_ENCODING_RETURNURL LGD_ESCROW_ADDRESS1 LGD_ESCROW_ADDRESS2 LGD_ESCROW_BUYERPHONE LGD_ESCROW_ZIPCODE LGD_HASHDATA LGD_MID LGD_OID LGD_PAYKEY LGD_PRODUCTINFO LGD_RECEIVER LGD_RECEIVERPHONE LGD_RETURNURL LGD_TAXFREEAMOUNT LGD_TIMESTAMP LGD_VERSION LGD_WINDOW_VER comm_free_mny comm_tax_mny comm_vat_mny good_mny id_info od_id res_cd submitChecked tr_code tx',
'nicepay' => 'Amt BuyerEmail BuyerName BuyerTel CharSet DirectEasyPay DirectShowOpt EasyPayCardCode EasyPayMethod EasyPayQuota EdiDate GoodsCl GoodsName GoodsVat MID Moid MultiEasyPayQuota NicepayReserved NpLang PayMethod ReqReserved ReturnURL SelectCardCode SelectQuota ServiceAmt SignData SupplyAmt TaxFreeAmt TransType VbankExpDate buyeremail buyertel comm_free_mny comm_tax_mny comm_vat_mny good_mny id_info od_id submitChecked tr_code tx',
'samsungpay' => 'DEF_RESERVED P_AMT P_APPL_NUM P_AUTH_DT P_AUTH_NO P_CARD_ISSUER P_EMAIL P_GOODS P_HASH P_HPP_CORP P_HPP_METHOD P_MID P_MOBILE P_NEXT_URL P_NOTI P_NOTI_URL P_OID P_QUOTABASE P_RESERVED P_RETURN_URL P_SKIP_TERMS P_TAX P_TAXFREE P_TYPE P_UNAME P_VACT_BANK P_VACT_NAME P_VACT_NUM good_mny res_cd samsungpay_form',
);
return array_unique(array_merge($common, isset($pg_fields[$pg]) ? explode(' ', $pg_fields[$pg]) : array()));
}
function shop_order_filter_data($input, $pg)
{
$out = array();
foreach (shop_order_allowed_fields($pg) as $key) {
if (!array_key_exists($key, $input)) continue;
$value = $input[$key];
if (is_array($value)) {
if (count($value) > 1000) shop_order_access_fail();
foreach ($value as $k=>$v) {
if (!is_scalar($v) || strlen((string)$v) > 65536 || !preg_match('/\A[0-9A-Za-z_-]{1,100}\z/D', (string)$k)) shop_order_access_fail();
}
} elseif (!is_scalar($value) || strlen((string)$value) > 65536) shop_order_access_fail();
$out[$key] = $value;
}
if (strlen(serialize($out)) > 1048576) shop_order_access_fail();
return $out;
}
function shop_order_toss_providers()
{
$providers = array();
foreach (shop_easypay_catalog('toss') as $provider) $providers[] = $provider[1];
return $providers;
}
+75
View File
@@ -0,0 +1,75 @@
<?php
if (!defined('_GNUBOARD_')) exit;
function shop_order_cleanup($apply = false, $grace = 86400)
{
global $g5;
$cutoff = G5_SERVER_TIME - max(3600, (int)$grace);
$table = shop_order_state_table();
$rows = sql_query("select od_id from $table where (status in ('completed','failed','cancelled') and updated_at < $cutoff) or (status='pending' and pg in ('toss','kcp') and expires < $cutoff) order by od_id limit 500");
$result = array();
while ($candidate = sql_fetch_array($rows)) {
$id = $candidate['od_id']; shop_order_state_lock('order', $id);
$row = shop_order_state_row($id);
if (!$row) { shop_order_state_unlock('order', $id); continue; }
$safe = (in_array($row['status'], array('completed','failed','cancelled'), true) && (int)$row['updated_at'] < $cutoff) ||
($row['status'] === 'pending' && in_array($row['pg'], array('toss','kcp'), true) && (int)$row['expires'] < $cutoff);
if (!$safe) { shop_order_state_unlock('order', $id); continue; }
$result[] = array('order'=>$id, 'status'=>$row['status'], 'action'=>$apply ? 'purged' : 'would_purge');
if ($apply) {
$pg = sql_escape_string($row['pg']);
if (!sql_query("delete from {$g5['g5_shop_order_data_table']} where od_id='$id' and dt_pg='$pg'", false)) shop_order_access_fail();
shop_order_state_write($id, array('status'=>'purged','state_json'=>'','response_json'=>'','token_hash'=>'','payment_key'=>''));
}
shop_order_state_unlock('order', $id);
}
return $result;
}
function shop_order_quarantine_legacy($id)
{
global $g5;
shop_order_state_lock('order', $id);
if (shop_order_state_row($id)) throw new RuntimeException('이미 상태 기록이 있는 주문입니다.');
$res = sql_query("select * from {$g5['g5_shop_order_data_table']} where od_id='$id'");
if (sql_num_rows($res) !== 1) throw new RuntimeException('임시 주문을 하나로 확정할 수 없습니다.');
$row = sql_fetch_array($res);
$data = shop_order_decode_data($row['dt_data']);
if (!is_array($data)) throw new RuntimeException('임시 데이터 형식을 확인해 주십시오.');
$password_hash = isset($data['od_pwd']) && is_string($data['od_pwd']) ? get_encrypt_string($data['od_pwd']) : '';
unset($data['od_pwd']);
$data = shop_order_filter_data($data, $row['dt_pg']);
$encoded = base64_encode(serialize($data));
// 먼저 접근 불가능한 상태를 기록한다. 세션이 남아 있어도 자동 전환하지 않는다.
$state = sql_escape_string(json_encode(array('password_hash'=>$password_hash, 'legacy'=>true)));
$pg = sql_escape_string($row['dt_pg']);
if (!sql_query("insert into ".shop_order_state_table()." set od_id='$id',pg='$pg',status='legacy',state_json='$state',response_json='',updated_at=".G5_SERVER_TIME, false)) shop_order_access_fail();
if (!sql_query("update {$g5['g5_shop_order_data_table']} set dt_data='$encoded' where od_id='$id'", false)) shop_order_access_fail();
return array('order'=>$id, 'status'=>'legacy', 'plaintext_removed'=>true, 'token_issued'=>false);
}
function shop_order_reconcile_toss($id, $toss)
{
global $g5;
shop_order_state_lock('order', $id);
$row = shop_order_state_row($id);
$state = $row ? json_decode($row['state_json'], true) : null;
if (!$row || $row['pg'] !== 'toss' || empty($state['expected_amount']) || $row['payment_key'] === '' ||
!in_array($row['status'], array('approving','approved','finalizing','unknown','cancel_pending'), true)) throw new RuntimeException('승인 대조가 필요한 Toss 주문이 아닙니다.');
if (!$toss->getPaymentByOrderId($id)) throw new RuntimeException('PG 조회 실패: 상태를 변경하지 않았습니다.');
$p = $toss->responseData;
if (!isset($p['orderId'],$p['paymentKey'],$p['totalAmount'],$p['status']) || $p['orderId'] !== $id ||
$p['paymentKey'] !== $row['payment_key'] || (int)$p['totalAmount'] !== (int)$state['expected_amount']) throw new RuntimeException('PG 주문번호·키·금액이 일치하지 않습니다.');
if ($p['status'] === 'CANCELED') {
shop_order_state_write($id, array('status'=>'cancelled'));
return array('order'=>$id, 'status'=>'cancelled');
}
if (!($p['status'] === 'DONE' || ($p['status'] === 'WAITING_FOR_DEPOSIT' && isset($p['method']) && $p['method'] === '가상계좌'))) throw new RuntimeException('PG 거래를 재승인하지 말고 별도 확인해 주십시오.');
$saved = $state['personal'] ? sql_fetch("select pp_tno as tno from {$g5['g5_shop_personalpay_table']} where pp_id='$id'") : sql_fetch("select od_tno as tno from {$g5['g5_shop_order_table']} where od_id='$id'");
if (($state['personal'] && !empty($saved['tno'])) || (!$state['personal'] && $saved)) {
shop_order_state_write($id, array('status'=>'finalizing'));
return array('order'=>$id, 'status'=>'finalizing', 'action'=>'주문·장바구니·포인트·쿠폰의 부분 저장 대조 필요');
}
shop_order_state_approved($id, $p);
return array('order'=>$id, 'status'=>'approved', 'action'=>'원래 복귀 토큰으로 재시도 가능, 신규 승인 호출 없음');
}
+398
View File
@@ -0,0 +1,398 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// 주문별 복귀 인증과 승인 기록. DDL은 설치 SQL/명시적 마이그레이션에서만 실행한다.
function &shop_order_runtime()
{
static $runtime = array('locks'=>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 '<input type="hidden" name="g5_checkout_id" value="'.get_text($id).'">'.
'<input type="hidden" name="g5_checkout_nonce" value="'.$nonce.'">';
}
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();
}