security: KVE-2026-2346 KCP 통보 검증과 입금·망취소 재처리 보강

테스트·운영 발신지와 결제 당시 상점·거래·계좌·금액을 대조하여 위조 입금 처리를 차단한다. 개인결제와 연결 주문의 중복 가산을 막고 통보 이력과 목표값으로 MyISAM/InnoDB 중간 실패를 재처리한다.

DB 마이그레이션과 기존 거래 적용·복구 안내를 추가했다. 격리 HTTP/MySQL 검사 420개, PHP 문법 검사와 설치 스키마 대조가 통과했다. 실제 KCP 연동 확인과 배포·KISA 회신은 별도 진행이 필요하다.
This commit is contained in:
whitedot
2026-09-10 05:17:59 +00:00
parent 560aa0af47
commit 7a80ae913d
12 changed files with 650 additions and 267 deletions
+59
View File
@@ -0,0 +1,59 @@
# KVE-2026-2346 KCP 통보 검증 보강
KCP 공통 통보의 테스트 모드 발신 검증 누락으로 미결제 주문을 입금 처리할 수 있는 경로를 차단한다. `shop/settle_kcp_common.php`에서 요청을 검증한 뒤 `lib/kcp_notification.lib.php`가 주문·개인결제·장바구니와 처리 이력을 반영한다.
## 적용 절차
1. 소스와 DB를 백업하고 주문·결제 접수를 잠시 중지한다. 변경 파일 반영 후 최고관리자의 **환경설정 → DB업그레이드**에서 `20260910_001_kcp_notification`까지 실행한다. 신규 설치 SQL에도 같은 스키마를 포함했다. 일반 요청에서는 DDL을 실행하지 않는다. 업그레이드 전에는 새 주문 저장 및 KCP 통보가 실패할 수 있다.
2. DB 계정의 `LOCK TABLES`, 관련 테이블의 `SELECT`·`INSERT`·`UPDATE` 권한을 확인한다. 테스트 환경은 운영 주문과 분리한다.
3. 기존 KCP 미결제 가상계좌 주문과 개인결제를 KCP 거래 내역에 대조한다. 새 `od_kcp_site_cd`·`pp_kcp_site_cd`는 기존 행에 빈값으로 추가되며, 빈 상점코드인 거래의 통보는 실패 응답한다. **현재 설정을 전체 과거 거래에 일괄 복사하지 않는다.** 거래번호·주문번호·결제수단·청구액·계좌·테스트 여부·실제 상점코드를 확인한 건만 관리자가 별도 보정하고 KCP 재통보를 요청한다. 이미 입금된 기존 거래에는 새 이력을 임의로 만들지 말고 PG 기록과 대조하여 수동 정산한다.
4. PC·모바일에서 실제 KCP 테스트 가상계좌 발급과 모의 입금을 확인한 뒤 접수를 재개한다. 기존 연동의 통보 발신 IP 및 처리구분이 아래 공식 안내와 다른 경우 KCP에 해당 연동의 값을 확인한다. 검증을 통째로 생략하거나 임의 전달 헤더로 우회하지 않는다.
5. 실패 통보와 `shop_kcp_noti`의 미완료 이력을 점검하고 재통보 제한을 넘긴 건은 KCP 내역과 대조해 복구한다.
## 입력과 거래 검증
- 테스트는 `210.122.176.144`, 운영은 `103.215.144.173`, `103.215.144.174`, `210.122.72.173`의 POST만 받는다. 과거 코드의 다른 IP는 자동 승계하지 않는다. 기준은 [KCP 웹훅 가이드](https://developer.kcp.co.kr/guide/webhook), 확인일은 2026-09-10이다. 이 목록이 구형 계약의 모든 발신지를 포함하는지 KCP에 별도 확인한 상태는 아니다.
- `REMOTE_ADDR`만 사용한다. 리버스 프록시 환경은 신뢰하는 프록시와 원본 IP 복원 설정을 웹 서버에서 제한해야 한다. 요청 헤더·상점코드·관리자 로그인 자체를 발신 인증으로 취급하지 않는다.
- 필수값은 문자열 형식과 길이를 검사하고 배열·빈 거래번호·잘못된 날짜·0원·음수·소수·지수 표기·DB 정수 범위 초과를 거부한다. 가상계좌 입금은 `TX00`, `op_cd=50`, 망취소는 `op_cd=13`으로 구분한다. 다른 처리구분은 실패 응답한다.
- PC·모바일 주문과 개인결제 저장 시 서버의 `$g_conf_site_cd`를 보존한다. 테스트 `T0000`·`T0007`, 운영 `SR` 접두어를 포함한 실제 사용값을 비교하므로 이후 상점 설정 변경으로 과거 거래를 다른 상점에 연결하지 않는다.
- 저장된 PG·상점·거래번호·가상계좌 결제수단·계좌를 모두 대조한다. 연결 주문은 존재해야 하고 테스트 환경도 일치해야 한다. 개인결제와 일반 주문의 번호가 충돌하면 실패한다.
- 일반 입금은 서버 주문 합계에서 쿠폰·포인트 등을 반영한 청구액 전액과 일치하고, 입금액 0·주문 상태여야 한다. 개인결제는 `pp_price`와 대조하며 연결 주문 미수금을 초과할 수 없다. 연결 주문의 원래 PG·결제수단은 개인결제와 다를 수 있으므로 개인결제 자체의 KCP 거래를 검사한다. 연결 주문이 없는 독립 개인결제도 처리한다.
- 기존에 처리하지 않던 `TX01`~`TX07` 이벤트는 상점·거래를 확인하고 수신 확인만 한다. 환불·에스크로 상태 자동화 기능을 새로 추가한 것은 아니다.
## 중복과 중간 실패
이력 키는 상점·거래·주문·통보 ID·입금/망취소 구분을 포함한다. 같은 통보 ID의 입금과 망취소가 각각 기록되며, 이미 완료한 동일 통보는 주문이 완료 상태가 되었어도 성공 응답한다. 같은 통보의 금액·계좌가 바뀌면 거부한다. 활성 입금이 있을 때 다른 통보 ID로 다시 입금할 수 없고, 망취소 후 지연 도착한 입금 통보도 금액을 다시 올리지 않는다.
MyISAM과 InnoDB 모두 관련 네 테이블의 쓰기 잠금을 잡고 검증·반영한다. 변경 전 값과 목표값을 `kn_plan`에 먼저 기록하고, 개인결제 금액과 연결 주문 입금액을 목표값으로 저장하여 재시도에서 중복 가산하지 않는다. 주문·장바구니·개인결제 반영과 결과 재조회가 끝난 뒤 `kn_done=1`을 기록해야 `result=0000`을 응답한다. 입력 불일치·스키마/권한 누락·SQL 오류·미완료 복구 충돌은 `9999`로 응답한다.
**MyISAM의 여러 테이블 쓰기가 원자적 트랜잭션이 되는 것은 아니다.** 중간 실패 시 일부 변경이 남을 수 있으며 같은 통보의 재시도로 완료한다. 물리적 서버 장애·디스크 손상에 대한 롤백도 보장하지 않는다. 재시도 시 현재 값이 기록된 변경 전/후 값과 다르면 외부 변경으로 판단하여 덮어쓰지 않는다. 해당 주문의 미완료 통보가 있으면 다른 신규 통보도 막는다. 테이블 전체 잠금을 사용하므로 통보 처리 중 주문·관리자 쿼리가 잠시 대기할 수 있다.
망취소는 해당 입금액만 차감하고 미수금을 복원한다. 주문/장바구니의 `입금`은 `주문`으로 되돌린다. 이미 `준비`·`배송`·`완료`인 주문은 금액과 미수금을 수정하되 물류 상태를 되돌리지 않는다. 이런 건은 출고 중지·회수·콘텐츠 권한·지급 포인트 등을 운영자가 별도로 대조해야 한다. 이미 취소·반품된 주문 또는 이력 없이 입금액이 남아 있는 기존 거래는 자동 차감하지 않고 실패로 남긴다.
복구 점검 예시(실제 설치의 테이블 접두어로 바꾼다):
```sql
SELECT kn_key, kn_trade, kn_noti_id, kn_op_cd, od_id, kn_created_at
FROM g5_shop_kcp_noti WHERE kn_done = 0 ORDER BY kn_created_at;
```
미완료 행을 단순 삭제하거나 `kn_done=1`로 바꾸면 중복 가산 또는 미반영 통보 유실이 발생할 수 있다. 주문·개인결제·장바구니의 현재 값과 `kn_plan`, KCP 거래 기록을 대조하고 충돌 원인을 해결한 뒤 동일 통보를 재전송한다. 이력에는 금액·계좌 등 거래 정보가 포함되므로 DB 백업과 접근권한을 기존 주문 데이터와 같이 관리한다.
## 검증
```bash
python3 tests/kcp_notification_http.py 33443
php bin/check-migration-schema.php
```
첫 명령은 **격리 MySQL 전용**이다. 로컬 지정 포트에 root/빈 비밀번호로 접속하여 고유 임시 DB를 만들고 종료 시 삭제한다. PHP CLI·mysqli, MySQL CLI, Python 3이 필요하다. `bin`의 스키마 검사는 로컬 검증 도구로 배포본에 포함되지 않는다.
테스트는 실제 콜백 파일을 로컬 HTTP로 호출하고 MySQL 8의 MyISAM/InnoDB 테이블을 읽고 쓴다. 공통 초기화와 PG 발신 IP는 테스트 부트스트랩으로 대체한다. 주문·개인결제는 격리 DB에 준비하며 실제 KCP 승인·은행 입금·상점 로그인·전체 주문서 제출을 실행하지 않는다. 각 SQL 쓰기 직전 실패와 DB 반영 후 응답 유실은 테스트 SQL 래퍼에서 주입한다. 정상/거부 응답과 실제 DB 상태, 동시 통보, 중복·망취소·역순, 중간 실패 복구와 외부 수정 충돌, 실제 마이그레이션 적용·재실행을 확인한다.
2026-09-10 검증 결과: PHP 8.4.22, MySQL 8.0.45에서 HTTP·DB 검사 420개와 PHP 문법 검사, 신규 설치/마이그레이션 스키마 대조가 통과했다.
## 배포·회신 상태
코드 변경 및 격리 회귀 검증 단계다. 신고 대상은 5.6.35이며 검토 기준 5.6.37 개발 코드에도 원인이 있었다. 전체 영향 버전, 수정 배포 버전·배포일은 미확정이다. 실제 KCP 연동 시험, 구형 발신 범위 확인, 운영 반영, KISA 회신 완료를 의미하지 않는다. 원본 PDF는 저장소에 포함하지 않는다.
규격 참고: [가상계좌 입금·망취소](https://developer.kcp.co.kr/reference/account), [웹훅 응답](https://developer.kcp.co.kr/reference/webhook). 공개 규격에서 확인되지 않은 수신 서명 필드를 임의로 필수화하지 않았다. 인증서·서명을 사용하는 서버 간 거래조회 API의 도입은 사용 중인 계약/연동 지원 확인 후 별도로 검토한다.
+18
View File
@@ -683,6 +683,7 @@ CREATE TABLE IF NOT EXISTS `g5_shop_order` (
`od_test` tinyint(4) NOT NULL DEFAULT '0',
`od_mobile` tinyint(4) NOT NULL DEFAULT '0',
`od_pg` varchar(255) NOT NULL DEFAULT '',
`od_kcp_site_cd` varchar(5) NOT NULL DEFAULT '',
`od_tno` varchar(255) NOT NULL DEFAULT '',
`od_app_no` varchar(20) NOT NULL DEFAULT '',
`od_escrow` tinyint(4) NOT NULL DEFAULT '0',
@@ -781,6 +782,7 @@ CREATE TABLE IF NOT EXISTS `g5_shop_personalpay` (
`pp_use` TINYINT(4) NOT NULL DEFAULT '0',
`pp_price` INT(11) NOT NULL DEFAULT '0',
`pp_pg` varchar(255) NOT NULL DEFAULT '',
`pp_kcp_site_cd` varchar(5) NOT NULL DEFAULT '',
`pp_tno` VARCHAR(255) NOT NULL DEFAULT '',
`pp_app_no` VARCHAR(20) NOT NULL DEFAULT '',
`pp_casseqno` VARCHAR(255) NOT NULL DEFAULT '',
@@ -1037,3 +1039,19 @@ CREATE TABLE IF NOT EXISTS `g5_shop_inicis_pay_event` (
KEY `pe_status` (`pe_status`),
KEY `pe_created_at` (`pe_created_at`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
-- KCP 통보 처리 및 중간 실패 복구 이력
CREATE TABLE `g5_shop_kcp_noti` (
`kn_key` char(64) NOT NULL,
`kn_trade` char(64) NOT NULL,
`kn_noti_id` varchar(20) NOT NULL,
`kn_op_cd` char(2) NOT NULL,
`kn_payload` char(64) NOT NULL,
`od_id` bigint(20) unsigned NOT NULL DEFAULT '0',
`kn_plan` mediumtext NOT NULL,
`kn_done` tinyint(4) NOT NULL DEFAULT '0',
`kn_created_at` datetime NOT NULL,
PRIMARY KEY (`kn_key`),
KEY `kn_trade` (`kn_trade`),
KEY `od_id` (`od_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+1
View File
@@ -655,6 +655,7 @@ if($g5_shop_install) {
install_file_write($f, "\$g5['g5_shop_coupon_log_table'] = G5_SHOP_TABLE_PREFIX.'coupon_log'; // 쿠폰사용정보 테이블\n");
install_file_write($f, "\$g5['g5_shop_sendcost_table'] = G5_SHOP_TABLE_PREFIX.'sendcost'; // 추가배송비 테이블\n");
install_file_write($f, "\$g5['g5_shop_personalpay_table'] = G5_SHOP_TABLE_PREFIX.'personalpay'; // 개인결제 정보 테이블\n");
install_file_write($f, "\$g5['g5_shop_kcp_noti_table'] = G5_SHOP_TABLE_PREFIX.'kcp_noti'; // KCP 통보 처리 이력 테이블\n");
install_file_write($f, "\$g5['g5_shop_order_address_table'] = G5_SHOP_TABLE_PREFIX.'order_address'; // 배송지이력 정보 테이블\n");
install_file_write($f, "\$g5['g5_shop_item_stocksms_table'] = G5_SHOP_TABLE_PREFIX.'item_stocksms'; // 재입고SMS 알림 정보 테이블\n");
install_file_write($f, "\$g5['g5_shop_post_log_table'] = G5_SHOP_TABLE_PREFIX.'order_post_log'; // 주문요청 로그 테이블\n");
+263
View File
@@ -0,0 +1,263 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// KVE-2026-2346: 테스트 통보도 발신지와 저장된 거래를 모두 검증한다.
function kcp_noti_request($post, $server, $default)
{
if (!isset($server['REQUEST_METHOD']) || $server['REQUEST_METHOD'] !== 'POST') return false;
// https://developer.kcp.co.kr/guide/webhook (2026-09-10 확인)
$ips = !empty($default['de_card_test'])
? array('210.122.176.144')
: array('103.215.144.173', '103.215.144.174', '210.122.72.173');
if (!isset($server['REMOTE_ADDR']) || !in_array($server['REMOTE_ADDR'], $ips, true)) return false;
// 전달 헤더나 로그인 세션은 PG 발신 인증에 사용하지 않는다.
$patterns = array(
'site_cd' => '/\A[A-Z0-9]{5}\z/', 'tno' => '/\A[0-9]{14}\z/',
'order_no' => '/\A[1-9][0-9]{0,19}\z/', 'tx_cd' => '/\ATX0[0-7]\z/',
'tx_tm' => '/\A[0-9]{14}\z/'
);
if (isset($post['tx_cd']) && $post['tx_cd'] === 'TX00') {
$patterns += array('ipgm_mnyx' => '/\A[0-9]{1,12}\z/',
'account' => '/\A[T]?[0-9]{1,19}\z/', 'noti_id' => '/\A[0-9]{20}\z/',
'op_cd' => '/\A(?:50|13)\z/');
}
$data = array();
foreach ($patterns as $name => $pattern) {
if (!isset($post[$name]) || !is_string($post[$name]) || !preg_match($pattern, $post[$name])) return false;
$data[$name] = $post[$name];
}
$time = $data['tx_tm'];
if ((int)substr($time, 0, 4) < 1000 || !checkdate((int)substr($time, 4, 2), (int)substr($time, 6, 2), (int)substr($time, 0, 4))
|| substr($time, 8, 2) > 23 || substr($time, 10, 2) > 59 || substr($time, 12, 2) > 59) return false;
$test = in_array($data['site_cd'], array('T0000', 'T0007'), true);
if ($test !== !empty($default['de_card_test'])) return false;
if (!$test && substr($data['site_cd'], 0, 2) !== 'SR') return false;
if ($data['tx_cd'] === 'TX00') {
// 주문 금액 컬럼은 signed INT이다. 변환 전에 범위를 검사한다.
if ((float)$data['ipgm_mnyx'] < 1 || (float)$data['ipgm_mnyx'] > 2147483647) return false;
$data['ipgm_mnyx'] = (string)(int)$data['ipgm_mnyx'];
}
return $data;
}
function kcp_noti_query($sql)
{
$result = sql_query($sql, false);
if (!$result) throw new Exception('KCP notification database failure');
return $result;
}
function kcp_noti_rows($sql)
{
$result = kcp_noti_query($sql);
$rows = array();
while ($row = sql_fetch_array($result)) $rows[] = $row;
return $rows;
}
function kcp_noti_quote($value)
{
return "'".sql_escape_string((string)$value)."'";
}
function kcp_noti_tables()
{
global $g5;
return array('order' => $g5['g5_shop_order_table'], 'personal' => $g5['g5_shop_personalpay_table'],
'cart' => $g5['g5_shop_cart_table'],
'event' => isset($g5['g5_shop_kcp_noti_table']) ? $g5['g5_shop_kcp_noti_table'] : G5_SHOP_TABLE_PREFIX.'kcp_noti');
}
function kcp_noti_process($data)
{
$tables = kcp_noti_tables();
$locked = false;
try {
// 기본 설치의 MyISAM에서도 다른 통보 및 관리자 UPDATE와 경쟁하지 않게 한다.
// DDL은 관리자 DB 업그레이드에서만 실행하며 스키마/권한이 없으면 실패 응답한다.
$locks = array();
foreach ($tables as $table) $locks[] = '`'.$table.'` WRITE';
kcp_noti_query('LOCK TABLES '.implode(', ', $locks));
$locked = true;
$success = kcp_noti_locked($data, $tables);
} catch (Exception $e) {
error_log('KCP notification: processing failed; reconciliation/retry required');
$success = false;
} catch (Throwable $e) {
error_log('KCP notification: processing failed; reconciliation/retry required');
$success = false;
}
if ($locked) {
try {
kcp_noti_query('UNLOCK TABLES');
} catch (Exception $e) {
$success = false;
} catch (Throwable $e) {
$success = false;
}
}
return $success;
}
// 변경 전/후 값을 함께 기록한다. 재시도는 가산 SQL 대신 목표값을 대입한다.
function kcp_noti_step($table, $id_field, $row, $changes, $guards)
{
$before = array();
foreach (array_unique(array_merge(array($id_field), array_keys($changes), $guards)) as $field) {
if (!array_key_exists($field, $row)) throw new Exception('Missing notification schema');
$before[$field] = (string)$row[$field];
}
$after = $before;
foreach ($changes as $field => $value) $after[$field] = (string)$value;
return array('table' => $table, 'id_field' => $id_field, 'id' => (string)$row[$id_field],
'before' => $before, 'after' => $after);
}
function kcp_noti_matches($row, $expected)
{
foreach ($expected as $field => $value) {
if (!array_key_exists($field, $row) || (string)$row[$field] !== $value) return false;
}
return true;
}
function kcp_noti_apply($plan, $tables)
{
// 먼저 모든 행을 검사한다. 실패 후 외부에서 수정한 행을 과거 값으로 덮어쓰지 않는다.
foreach ($plan as $step) {
$rows = kcp_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id']));
if (count($rows) !== 1 || (!kcp_noti_matches($rows[0], $step['before']) && !kcp_noti_matches($rows[0], $step['after']))) return false;
}
foreach ($plan as $step) {
$sets = array();
foreach ($step['after'] as $field => $value) {
if ($value !== $step['before'][$field]) $sets[] = '`'.$field.'` = '.kcp_noti_quote($value);
}
if ($sets) kcp_noti_query('UPDATE `'.$tables[$step['table']].'` SET '.implode(', ', $sets)
.' WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id']));
}
foreach ($plan as $step) {
$rows = kcp_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id']));
if (count($rows) !== 1 || !kcp_noti_matches($rows[0], $step['after'])) return false;
}
return true;
}
function kcp_noti_locked($data, $tables)
{
$order_no = kcp_noti_quote($data['order_no']);
$personal = kcp_noti_rows("SELECT * FROM `{$tables['personal']}` WHERE pp_id = $order_no");
$orders = kcp_noti_rows("SELECT * FROM `{$tables['order']}` WHERE od_id = $order_no");
// 동일 번호가 두 종류에 존재하면 임의로 한쪽을 선택하지 않는다.
if ($personal && $orders) return false;
$pp = $personal ? $personal[0] : null;
if ($pp) {
$prefix = 'pp'; $payment = $pp;
$orders = $pp['od_id'] ? kcp_noti_rows("SELECT * FROM `{$tables['order']}` WHERE od_id = ".kcp_noti_quote($pp['od_id'])) : array();
if ($pp['od_id'] && !$orders) return false;
} else {
if (!$orders) return false;
$prefix = 'od'; $payment = $orders[0];
}
$od = $orders ? $orders[0] : null;
if ($payment[$prefix.'_pg'] !== 'kcp' || $payment[$prefix.'_tno'] !== $data['tno']
|| empty($payment[$prefix.'_kcp_site_cd']) || $payment[$prefix.'_kcp_site_cd'] !== $data['site_cd']) return false;
// 처리하지 않는 기존 에스크로 이벤트는 거래를 확인한 뒤 수신만 확인한다.
if ($data['tx_cd'] !== 'TX00') return true;
if ($payment[$prefix.'_settle_case'] !== '가상계좌') return false;
if ($od && (bool)$od['od_test'] !== in_array($data['site_cd'], array('T0000', 'T0007'), true)) return false;
$account_parts = preg_split('/\s+/', trim($payment[$prefix.'_bank_account']));
if (end($account_parts) !== $data['account']) return false;
$trade = hash('sha256', $data['site_cd'].'|'.$data['tno'].'|'.$data['order_no']);
$key = hash('sha256', $trade.'|'.$data['noti_id'].'|'.$data['op_cd']);
$payload = hash('sha256', $data['ipgm_mnyx'].'|'.$data['account']);
$events = kcp_noti_rows("SELECT * FROM `{$tables['event']}` WHERE kn_trade = '$trade'");
$deposit = null; $cancel = null; $active = false;
$by_id = array();
foreach ($events as $event) {
if ($event['kn_key'] === $key) {
if ($event['kn_payload'] !== $payload) return false;
if ($event['kn_done']) return true;
$plan = json_decode($event['kn_plan'], true);
if (!is_array($plan) || !kcp_noti_apply($plan, $tables)) return false;
kcp_noti_query("UPDATE `{$tables['event']}` SET kn_done = 1 WHERE kn_key = '$key'");
return true;
}
if (!$event['kn_done']) return false;
$by_id[$event['kn_noti_id']][$event['kn_op_cd']] = $event;
if ($event['kn_noti_id'] === $data['noti_id']) {
if ($event['kn_payload'] !== $payload) return false;
if ($event['kn_op_cd'] === '50') $deposit = $event;
else $cancel = $event;
}
}
foreach ($by_id as $pair) {
if (isset($pair['50']) && !isset($pair['13'])) $active = true;
}
// 망취소가 먼저 도착했거나 취소 뒤 입금 통보가 재전송되어도 재입금하지 않는다.
if ($data['op_cd'] === '50' && $cancel) return true;
$canceling = $data['op_cd'] === '13';
if (!$canceling && $active) return false;
if ($canceling && !$deposit && $active) return false;
if ($pp && !$pp['pp_use']) return false;
$amount = (int)$data['ipgm_mnyx'];
$receipt = (int)$payment[$prefix.'_receipt_price'];
if ($pp && ((int)$pp['pp_price'] !== $amount || (int)$pp['pp_price'] <= 0)) return false;
if (!$canceling && $receipt !== 0) return false;
if ($canceling && $deposit && $receipt < $amount) return false;
if ($canceling && !$deposit && $receipt !== 0) return false;
$od_id = $od ? $od['od_id'] : '0';
if ($od) {
$pending = kcp_noti_rows("SELECT kn_key FROM `{$tables['event']}` WHERE od_id = ".kcp_noti_quote($od_id)." AND kn_done = 0");
if ($pending) return false;
$due = (int)$od['od_cart_price'] + (int)$od['od_send_cost'] + (int)$od['od_send_cost2']
- (int)$od['od_cart_coupon'] - (int)$od['od_coupon'] - (int)$od['od_send_coupon']
- (int)$od['od_cancel_price'] - (int)$od['od_receipt_point'] + (int)$od['od_refund_price'];
$misu = $due - (int)$od['od_receipt_price'];
if ($misu !== (int)$od['od_misu'] || $due <= 0) return false;
if (!$pp && ($due !== $amount || (int)$od['od_cancel_price'] !== 0 || (int)$od['od_refund_price'] !== 0)) return false;
if (!$canceling && ($od['od_status'] !== '주문' || $misu < $amount)) return false;
if ($canceling && !in_array($od['od_status'], array('주문', '입금', '준비', '배송', '완료'), true)) return false;
if ($canceling && $deposit && (int)$od['od_receipt_price'] < $amount) return false;
}
$time = substr($data['tx_tm'], 0, 4).'-'.substr($data['tx_tm'], 4, 2).'-'.substr($data['tx_tm'], 6, 2)
.' '.substr($data['tx_tm'], 8, 2).':'.substr($data['tx_tm'], 10, 2).':'.substr($data['tx_tm'], 12, 2);
$delta = $canceling ? ($deposit ? -$amount : 0) : $amount;
$plan = array();
if ($pp && $delta) {
$plan[] = kcp_noti_step('personal', 'pp_id', $pp,
array('pp_receipt_price' => $receipt + $delta, 'pp_receipt_time' => $time),
array('od_id', 'pp_pg', 'pp_tno', 'pp_kcp_site_cd', 'pp_settle_case', 'pp_price', 'pp_use', 'pp_bank_account'));
}
if ($od && $delta) {
$new_receipt = (int)$od['od_receipt_price'] + $delta;
$new_misu = $due - $new_receipt;
if ($new_receipt < 0 || $new_receipt > 2147483647 || $new_misu > 2147483647) return false;
$status = $od['od_status'];
if (!$canceling && $new_misu === 0) $status = '입금';
if ($canceling && $status === '입금') $status = '주문';
$cart = kcp_noti_rows("SELECT ct_id, od_id, ct_status FROM `{$tables['cart']}` WHERE od_id = ".kcp_noti_quote($od_id));
if (!$cart) return false;
foreach ($cart as $item) {
if (!$canceling && $item['ct_status'] !== '주문') return false;
if ($status !== $od['od_status'] && $item['ct_status'] === $od['od_status']) {
$plan[] = kcp_noti_step('cart', 'ct_id', $item, array('ct_status' => $status), array('od_id'));
}
}
// 마지막에 주문 상태를 변경하여 중간 실패 시 출고 판정 노출을 줄인다.
$plan[] = kcp_noti_step('order', 'od_id', $od,
array('od_receipt_price' => $new_receipt, 'od_receipt_time' => $time, 'od_misu' => $new_misu, 'od_status' => $status),
array('od_pg', 'od_tno', 'od_kcp_site_cd', 'od_settle_case', 'od_test', 'od_cart_price', 'od_send_cost',
'od_send_cost2', 'od_cart_coupon', 'od_coupon', 'od_send_coupon', 'od_cancel_price', 'od_receipt_point', 'od_refund_price'));
}
$json = json_encode($plan);
if ($json === false) return false;
kcp_noti_query("INSERT INTO `{$tables['event']}` SET kn_key = '$key', kn_trade = '$trade', kn_noti_id = ".kcp_noti_quote($data['noti_id'])
.", kn_op_cd = ".kcp_noti_quote($data['op_cd']).", kn_payload = '$payload', od_id = ".kcp_noti_quote($od_id)
.", kn_plan = ".kcp_noti_quote($json).", kn_created_at = ".kcp_noti_quote($time));
if (!kcp_noti_apply($plan, $tables)) return false;
kcp_noti_query("UPDATE `{$tables['event']}` SET kn_done = 1 WHERE kn_key = '$key'");
return true;
}
+2
View File
@@ -34,6 +34,8 @@ function g5_migration_replace_placeholders($sql)
'g5_shop_cart_table' => $shop_prefix . 'cart',
'g5_shop_item_table' => $shop_prefix . 'item',
'g5_shop_order_table' => $shop_prefix . 'order',
'g5_shop_personalpay_table' => $shop_prefix . 'personalpay',
'g5_shop_kcp_noti_table' => $shop_prefix . 'kcp_noti',
'g5_shop_default_table' => $shop_prefix . 'default',
'g5_shop_coupon_table' => $shop_prefix . 'coupon',
'g5_shop_coupon_log_table' => $shop_prefix . 'coupon_log',
@@ -0,0 +1,23 @@
-- @description KCP 통보 거래 바인딩 및 재통보 복구 이력 추가
-- @if-table-exists {{g5_shop_order_table}}
-- @if-column-missing {{g5_shop_order_table}} od_kcp_site_cd
ALTER TABLE `{{g5_shop_order_table}}` ADD `od_kcp_site_cd` varchar(5) NOT NULL DEFAULT '';
-- @if-table-exists {{g5_shop_personalpay_table}}
-- @if-column-missing {{g5_shop_personalpay_table}} pp_kcp_site_cd
ALTER TABLE `{{g5_shop_personalpay_table}}` ADD `pp_kcp_site_cd` varchar(5) NOT NULL DEFAULT '';
-- @if-table-exists {{g5_shop_default_table}}
-- @if-table-missing {{g5_shop_kcp_noti_table}}
CREATE TABLE `{{g5_shop_kcp_noti_table}}` (
`kn_key` char(64) NOT NULL,
`kn_trade` char(64) NOT NULL,
`kn_noti_id` varchar(20) NOT NULL,
`kn_op_cd` char(2) NOT NULL,
`kn_payload` char(64) NOT NULL,
`od_id` bigint(20) unsigned NOT NULL DEFAULT '0',
`kn_plan` mediumtext NOT NULL,
`kn_done` tinyint(4) NOT NULL DEFAULT '0',
`kn_created_at` datetime NOT NULL,
PRIMARY KEY (`kn_key`),
KEY `kn_trade` (`kn_trade`),
KEY `od_id` (`od_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+4
View File
@@ -574,6 +574,9 @@ else
}
$od_pg = $default['de_pg_service'];
// KCP 통보는 결제 당시 사용한 상점코드와 대조한다.
$od_kcp_site_cd = ($od_pg === 'kcp' && isset($g_conf_site_cd))
? sql_escape_string($g_conf_site_cd) : '';
// 주문금액과 결제금액이 일치하는지 체크
if($tno) {
@@ -676,6 +679,7 @@ $sql = " insert {$g5['g5_shop_order_table']}
od_receipt_time = '$od_receipt_time',
od_misu = '$od_misu',
od_pg = '$od_pg',
od_kcp_site_cd = '$od_kcp_site_cd',
od_tno = '$od_tno',
od_app_no = '$od_app_no',
od_escrow = '$od_escrow',
+4
View File
@@ -213,10 +213,14 @@ if((int)$pp['pp_price'] !== (int)$pg_price) {
}
$pp_pg = $default['de_pg_service'];
// KCP 통보는 결제 당시 사용한 상점코드와 대조한다.
$pp_kcp_site_cd = ($pp_pg === 'kcp' && isset($g_conf_site_cd))
? sql_escape_string($g_conf_site_cd) : '';
// 결제정보 입력
$sql = " update {$g5['g5_shop_personalpay_table']}
set pp_pg = '$pp_pg',
pp_kcp_site_cd = '$pp_kcp_site_cd',
pp_tno = '$pp_tno',
pp_app_no = '$app_no',
pp_receipt_price = '$pp_receipt_price',
+4
View File
@@ -531,6 +531,9 @@ else
}
$od_pg = $default['de_pg_service'];
// KCP 통보는 결제 당시 사용한 상점코드와 대조한다.
$od_kcp_site_cd = ($od_pg === 'kcp' && isset($g_conf_site_cd))
? sql_escape_string($g_conf_site_cd) : '';
$tno = isset($tno) ? $tno : '';
$od_receipt_time = isset($od_receipt_time) ? $od_receipt_time : '';
@@ -630,6 +633,7 @@ $sql = " insert {$g5['g5_shop_order_table']}
od_receipt_time = '$od_receipt_time',
od_misu = '$od_misu',
od_pg = '$od_pg',
od_kcp_site_cd = '$od_kcp_site_cd',
od_tno = '$od_tno',
od_app_no = '$od_app_no',
od_escrow = '$od_escrow',
+4
View File
@@ -192,6 +192,9 @@ if((int)$pp['pp_price'] !== (int)$pg_price) {
}
$pp_pg = $default['de_pg_service'];
// KCP 통보는 결제 당시 사용한 상점코드와 대조한다.
$pp_kcp_site_cd = ($pp_pg === 'kcp' && isset($g_conf_site_cd))
? sql_escape_string($g_conf_site_cd) : '';
$pp_email = get_email_address($pp_email);
// 결제정보 입력
@@ -199,6 +202,7 @@ $sql = " update {$g5['g5_shop_personalpay_table']}
set pp_email = '$pp_email',
pp_hp = '$pp_hp',
pp_pg = '$pp_pg',
pp_kcp_site_cd = '$pp_kcp_site_cd',
pp_tno = '$pp_tno',
pp_app_no = '$app_no',
pp_receipt_price = '$pp_receipt_price',
+4 -267
View File
@@ -1,271 +1,8 @@
<?php
include_once('./_common.php');
include_once(G5_LIB_PATH.'/etc.lib.php');
include_once(G5_LIB_PATH.'/mailer.lib.php');
include_once(G5_LIB_PATH.'/kcp_notification.lib.php');
/*------------------------------------------------------------------------------
※ KCP 에서 가맹점의 결과처리 페이지로 데이터를 전송할 때에, 아래와 같은
IP 에서 전송을 합니다. 따라서 가맹점측께서 전송받는 데이터에 대해 KCP
에서 전송된 건이 맞는지 체크하는 부분을 구현할 때에, 아래의 IP 에 대해
REMOTE ADDRESS 체크를 하여, 아래의 IP 이외의 다른 경로를 통해서 전송된
데이터에 대해서는 결과처리를 하지 마시기 바랍니다.
------------------------------------------------------------------------------*/
if(!$default['de_card_test']) {
switch ($_SERVER['REMOTE_ADDR']) {
case '203.238.36.58' :
case '203.238.36.160' :
case '203.238.36.161' :
case '203.238.36.173' :
case '203.238.36.178' :
case '103.215.144.173' : //판교 IDC IP주소 2019년 4월 3일 추가
case '103.215.144.174' : //판교 IDC IP주소 2019년 4월 3일 추가
case '103.215.145.30' : //판교 IDC IP주소 2019년 4월 3일 추가
break;
default :
$super_admin = get_admin('super');
$egpcs_str = "ENV[" . serialize($_ENV) . "] "
. "GET[" . serialize($_GET) . "]"
. "POST[" . serialize($_POST) . "]"
. "COOKIE[" . serialize($_COOKIE) . "]"
. "SESSION[" . serialize($_SESSION) . "]";
mailer('경고', 'waring', $super_admin['mb_email'], '올바르지 않은 접속 보고', "{$_SERVER['SCRIPT_NAME']} 에 {$_SERVER['REMOTE_ADDR']} 이 ".G5_TIME_YMDHIS." 에 접속을 시도하였습니다.\n\n" . $egpcs_str, 2);
exit;
}
}
/* ============================================================================== */
/* = PAGE : 공통 통보 PAGE = */
/* = -------------------------------------------------------------------------- = */
/* = Copyright (c) 2006 KCP Inc. All Rights Reserverd. = */
/* ============================================================================== */
$data = kcp_noti_request($_POST, $_SERVER, $default);
$result = $data !== false && kcp_noti_process($data) ? '0000' : '9999';
?>
<?php
/* ============================================================================== */
/* = 01. 공통 통보 페이지 설명(필독!!) = */
/* = -------------------------------------------------------------------------- = */
/* = 에스크로 서비스의 경우, 가상계좌 입금 통보 데이터와 가상계좌 환불 = */
/* = 통보 데이터, 구매확인/구매취소 통보 데이터, 배송시작 통보 데이터 등을 = */
/* = KCP 를 통해 별도로 통보 받을 수 있습니다. 이러한 통보 데이터를 받기 = */
/* = 위해 가맹점측은 결과를 전송받는 페이지를 마련해 놓아야 합니다. = */
/* = 현재의 페이지를 업체에 맞게 수정하신 후, KCP 관리자 페이지에 등록해 = */
/* = 주시기 바랍니다. 등록 방법은 연동 매뉴얼을 참고하시기 바랍니다. = */
/* ============================================================================== */
//write_log("$g5[path]/data/log/kcp_common.log", print_r($_POST));
/* ============================================================================== */
/* = 02. 공통 통보 데이터 받기 = */
/* = -------------------------------------------------------------------------- = */
$site_cd = isset($_POST["site_cd"]) ? $_POST["site_cd"] : ''; // 사이트 코드
$tno = isset($_POST["tno"]) ? $_POST["tno"] : ''; // KCP 거래번호
$order_no = isset($_POST["order_no"]) ? $_POST["order_no"] : ''; // 주문번호
$tx_cd = isset($_POST["tx_cd"]) ? $_POST["tx_cd"] : ''; // 업무처리 구분 코드
$tx_tm = isset($_POST["tx_tm"]) ? $_POST["tx_tm"] : ''; // 업무처리 완료 시간
/* = -------------------------------------------------------------------------- = */
$ipgm_name = ""; // 주문자명
$remitter = ""; // 입금자명
$ipgm_mnyx = ""; // 입금 금액
$bank_code = ""; // 은행코드
$account = ""; // 가상계좌 입금계좌번호
$op_cd = ""; // 처리구분 코드
$noti_id = ""; // 통보 아이디
/* = -------------------------------------------------------------------------- = */
$refund_nm = ""; // 환불계좌주명
$refund_mny = ""; // 환불금액
$bank_code = ""; // 은행코드
/* = -------------------------------------------------------------------------- = */
$st_cd = ""; // 구매확인 코드
$can_msg = ""; // 구매취소 사유
/* = -------------------------------------------------------------------------- = */
$waybill_no = ""; // 운송장 번호
$waybill_corp = ""; // 택배 업체명
/* = -------------------------------------------------------------------------- = */
/* = 02-1. 가상계좌 입금 통보 데이터 받기 = */
/* = -------------------------------------------------------------------------- = */
if ( $tx_cd == "TX00" )
{
$ipgm_name = isset($_POST["ipgm_name"]) ? $_POST["ipgm_name"] : ''; // 주문자명
$remitter = isset($_POST["remitter"]) ? $_POST["remitter"] : ''; // 입금자명
$ipgm_mnyx = isset($_POST["ipgm_mnyx"]) ? $_POST["ipgm_mnyx"] : ''; // 입금 금액
$bank_code = isset($_POST["bank_code"]) ? $_POST["bank_code"] : ''; // 은행코드
$account = isset($_POST["account"]) ? $_POST["account"] : ''; // 가상계좌 입금계좌번호
$op_cd = isset($_POST["op_cd"]) ? $_POST["op_cd"] : ''; // 처리구분 코드
$noti_id = isset($_POST["noti_id"]) ? $_POST["noti_id"] : ''; // 통보 아이디
}
/* = -------------------------------------------------------------------------- = */
/* = 02-2. 가상계좌 환불 통보 데이터 받기 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX01" )
{
$refund_nm = isset($_POST["refund_nm"]) ? $_POST["refund_nm"] : ''; // 환불계좌주명
$refund_mny = isset($_POST["refund_mny"]) ? $_POST["refund_mny"] : ''; // 환불금액
$bank_code = isset($_POST["bank_code"]) ? $_POST["bank_code"] : ''; // 은행코드
}
/* = -------------------------------------------------------------------------- = */
/* = 02-3. 구매확인/구매취소 통보 데이터 받기 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX02" )
{
$st_cd = isset($_POST["st_cd"]) ? $_POST["st_cd"] : ''; // 구매확인 코드
if ( $st_cd == "N" ) // 구매확인 상태가 구매취소인 경우
{
$can_msg = isset($_POST["can_msg"]) ? $_POST["can_msg"] : ''; // 구매취소 사유
}
}
/* = -------------------------------------------------------------------------- = */
/* = 02-4. 배송시작 통보 데이터 받기 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX03" )
{
$waybill_no = isset($_POST["waybill_no"]) ? $_POST["waybill_no"] : ''; // 운송장 번호
$waybill_corp = isset($_POST["waybill_corp"]) ? $_POST["waybill_corp"] : ''; // 택배 업체명
}
/* ============================================================================== */
/* ============================================================================== */
/* = 03. 공통 통보 결과를 업체 자체적으로 DB 처리 작업하시는 부분입니다. = */
/* = -------------------------------------------------------------------------- = */
/* = 통보 결과를 DB 작업 하는 과정에서 정상적으로 통보된 건에 대해 DB 작업을 = */
/* = 실패하여 DB update 가 완료되지 않은 경우, 결과를 재통보 받을 수 있는 = */
/* = 프로세스가 구성되어 있습니다. 소스에서 result 라는 Form 값을 생성 하신 = */
/* = 후, DB 작업이 성공 한 경우, result 의 값을 "0000" 로 세팅해 주시고, = */
/* = DB 작업이 실패 한 경우, result 의 값을 "0000" 이외의 값으로 세팅해 주시 = */
/* = 기 바랍니다. result 값이 "0000" 이 아닌 경우에는 재통보를 받게 됩니다. = */
/* = -------------------------------------------------------------------------- = */
/* = -------------------------------------------------------------------------- = */
/* = 03-1. 가상계좌 입금 통보 데이터 DB 처리 작업 부분 = */
/* = -------------------------------------------------------------------------- = */
if ( $tx_cd == "TX00" )
{
$sql = " select pp_id, od_id from {$g5['g5_shop_personalpay_table']} where pp_id = '$order_no' and pp_tno = '$tno' ";
$row = sql_fetch($sql);
$result = false;
if($row['pp_id']) {
// 개인결제 UPDATE
$sql = " update {$g5['g5_shop_personalpay_table']}
set pp_receipt_price = '$ipgm_mnyx',
pp_receipt_time = '$tx_tm'
where pp_id = '$order_no'
and pp_tno = '$tno' ";
sql_query($sql, false);
if($row['od_id']) {
// 주문서 UPDATE
$receipt_time = preg_replace("/([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})/", "\\1-\\2-\\3 \\4:\\5:\\6", $tx_tm);
$sql = " update {$g5['g5_shop_order_table']}
set od_receipt_price = od_receipt_price + '$ipgm_mnyx',
od_receipt_time = '$tx_tm',
od_shop_memo = concat(od_shop_memo, \"\\n개인결제 ".$row['pp_id']." 로 결제완료 - ".$receipt_time."\")
where od_id = '{$row['od_id']}' ";
$result = sql_query($sql, FALSE);
}
} else {
// 주문서 UPDATE
$sql = " update {$g5['g5_shop_order_table']}
set od_receipt_price = '$ipgm_mnyx',
od_receipt_time = '$tx_tm'
where od_id = '$order_no'
and od_tno = '$tno' ";
$result = sql_query($sql, FALSE);
}
}
if($result) {
if($row['od_id'])
$od_id = $row['od_id'];
else
$od_id = $order_no;
// 주문정보 체크
$sql = " select count(od_id) as cnt
from {$g5['g5_shop_order_table']}
where od_id = '$od_id'
and od_status = '주문' ";
$row = sql_fetch($sql);
if($row['cnt'] == 1) {
// 미수금 정보 업데이트
$info = get_order_info($od_id);
$sql = " update {$g5['g5_shop_order_table']}
set od_misu = '{$info['od_misu']}' ";
if($info['od_misu'] == 0)
$sql .= " , od_status = '입금' ";
$sql .= " where od_id = '$od_id' ";
sql_query($sql, FALSE);
// 장바구니 상태변경
if($info['od_misu'] == 0) {
$sql = " update {$g5['g5_shop_cart_table']}
set ct_status = '입금'
where od_id = '$od_id' ";
sql_query($sql, FALSE);
}
}
}
/* = -------------------------------------------------------------------------- = */
/* = 03-2. 가상계좌 환불 통보 데이터 DB 처리 작업 부분 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX01" )
{
}
/* = -------------------------------------------------------------------------- = */
/* = 03-3. 구매확인/구매취소 통보 데이터 DB 처리 작업 부분 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX02" )
{
}
/* = -------------------------------------------------------------------------- = */
/* = 03-4. 배송시작 통보 데이터 DB 처리 작업 부분 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX03" )
{
}
/* = -------------------------------------------------------------------------- = */
/* = 03-5. 정산보류 통보 데이터 DB 처리 작업 부분 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX04" )
{
}
/* = -------------------------------------------------------------------------- = */
/* = 03-6. 즉시취소 통보 데이터 DB 처리 작업 부분 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX05" )
{
}
/* = -------------------------------------------------------------------------- = */
/* = 03-7. 취소 통보 데이터 DB 처리 작업 부분 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX06" )
{
}
/* = -------------------------------------------------------------------------- = */
/* = 03-7. 발급계좌해지 통보 데이터 DB 처리 작업 부분 = */
/* = -------------------------------------------------------------------------- = */
else if ( $tx_cd == "TX07" )
{
}
/* ============================================================================== */
/* ============================================================================== */
/* = 04. result 값 세팅 하기 = */
/* ============================================================================== */
?>
<html><body><form><input type="hidden" name="result" value="0000"></form></body></html>
<html><body><form><input type="hidden" name="result" value="<?php echo $result; ?>"></form></body></html>
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""격리 MySQL에서 KCP 실제 콜백과 MyISAM/InnoDB 재통보를 검증한다.
실행: python3 tests/kcp_notification_http.py 33443
localhost의 지정 포트에 root/빈 비밀번호로 접속하여 임시 DB만 만들고 삭제한다.
공통 초기화·발신 IP·SQL 실패만 대역이며 SQL 저장과 실제 콜백은 그대로 실행한다.
"""
import concurrent.futures
import json
import os
from pathlib import Path
import re
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.parse
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
PORT = int(sys.argv[1])
DB = 'kcp43_test_' + str(time.time_ns())
checks = 0
def mysql(sql, database=True):
command = ['mysql', '--no-defaults', '-h127.0.0.1', '-P' + str(PORT), '-uroot', '-N', '-B']
if database:
command.append(DB)
return subprocess.check_output(command, input="SET sql_mode='NO_ENGINE_SUBSTITUTION';" + sql, text=True).strip()
def check(value, message):
global checks
if not value:
raise AssertionError(message)
checks += 1
mysql('CREATE DATABASE `' + DB + '`', False)
server = None
try:
schema = (ROOT / 'install/gnuboard5shop.sql').read_text()
for table in ['order', 'personalpay', 'cart', 'default', 'kcp_noti']:
statement = re.search(r'CREATE TABLE(?: IF NOT EXISTS)? `g5_shop_' + table + r'` \(.*?;', schema, re.S).group()
mysql(statement)
with tempfile.TemporaryDirectory(prefix='kcp43-http-') as temp:
directory = Path(temp)
fixture = directory / 'fixture.json'
bootstrap = r'''<?php
error_reporting(E_ALL);
ini_set('display_errors', '0');
define('_GNUBOARD_', true);
define('G5_SHOP_TABLE_PREFIX', 'g5_shop_');
define('G5_TABLE_PREFIX', 'g5_');
define('G5_LIB_PATH', ROOT_PATH.'/lib');
mysqli_report(MYSQLI_REPORT_OFF);
$db = new mysqli('127.0.0.1', 'root', '', TEST_DB, TEST_PORT);
$db->set_charset('utf8mb4');
$db->query("SET sql_mode='NO_ENGINE_SUBSTITUTION'");
$fixture = json_decode(file_get_contents(__DIR__.'/fixture.json'), true);
$default = array('de_card_test' => $fixture['test'], 'de_pg_service' => 'kcp');
if (isset($fixture['ip'])) $_SERVER['REMOTE_ADDR'] = $fixture['ip'];
$g5 = array('g5_shop_order_table' => 'g5_shop_order', 'g5_shop_personalpay_table' => 'g5_shop_personalpay',
'g5_shop_cart_table' => 'g5_shop_cart', 'g5_shop_default_table' => 'g5_shop_default');
$writes = 0;
function sql_query($sql, $error = false) {
global $db, $writes, $fixture;
$write = preg_match('/^(UPDATE|INSERT) /', $sql);
if ($write) $writes++;
if ($write && isset($fixture['fail_before']) && $writes === $fixture['fail_before']) return false;
$result = $db->query($sql);
if ($write && isset($fixture['fail_after']) && $writes === $fixture['fail_after']) return false;
return $result;
}
function sql_fetch_array($result) { return $result->fetch_assoc(); }
function sql_num_rows($result) { return $result->num_rows; }
function sql_escape_string($value) { global $db; return $db->real_escape_string($value); }
function sql_real_escape_string($value) { return sql_escape_string($value); }
function sql_error_info() { global $db; return $db->error; }
'''.replace('ROOT_PATH', repr(str(ROOT))).replace('TEST_DB', repr(DB)).replace('TEST_PORT', str(PORT))
(directory / '_common.php').write_text(bootstrap)
(directory / 'router.php').write_text("<?php chdir(__DIR__); require " + repr(str(ROOT / 'shop/settle_kcp_common.php')) + ';')
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
http_port = sock.getsockname()[1]
env = dict(os.environ, PHP_CLI_SERVER_WORKERS='4')
log = open(directory / 'php.log', 'w+')
server = subprocess.Popen(['php', '-S', '127.0.0.1:' + str(http_port), str(directory / 'router.php')], env=env,
stdout=log, stderr=log, start_new_session=True)
for _ in range(50):
try:
with socket.create_connection(('127.0.0.1', http_port), timeout=.1):
break
except OSError:
time.sleep(.1)
def config(**changes):
fixture.write_text(json.dumps(dict(test=1, ip='210.122.176.144', **changes)))
def request(data=None, method='POST', headers=None):
payload = dict(site_cd='T0000', tno='20260910123456', order_no='1001', tx_cd='TX00',
tx_tm='20260910120000', ipgm_mnyx='10000', account='T123456789',
noti_id='20260910123456789012', op_cd='50')
if data:
for key, value in data.items():
if value is None:
payload.pop(key, None)
else:
payload[key] = value
req = urllib.request.Request('http://127.0.0.1:' + str(http_port),
data=urllib.parse.urlencode(payload).encode() if method == 'POST' else None,
method=method, headers=headers or {})
return 'value="0000"' in urllib.request.urlopen(req, timeout=30).read().decode()
def seed(personal=False, linked=True, mobile=0, site='T0000'):
mysql('TRUNCATE g5_shop_kcp_noti; TRUNCATE g5_shop_personalpay; TRUNCATE g5_shop_cart; TRUNCATE g5_shop_order;')
if linked or not personal:
mysql("INSERT INTO g5_shop_order (od_id,od_pg,od_tno,od_kcp_site_cd,od_settle_case,od_test,od_mobile,od_cart_price,od_misu,od_status,od_bank_account) "
"VALUES (1001,'kcp','20260910123456','" + site + "','가상계좌'," + ('1' if site.startswith('T') else '0') + "," + str(mobile) + ",10000,10000,'주문','은행 T123456789');"
"INSERT INTO g5_shop_cart (ct_id,od_id,ct_status) VALUES (1,1001,'주문'),(2,1001,'주문');")
if personal:
mysql("INSERT INTO g5_shop_personalpay (pp_id,od_id,pp_use,pp_pg,pp_tno,pp_kcp_site_cd,pp_settle_case,pp_price,pp_bank_account) "
"VALUES (2001," + ('1001' if linked else '0') + ",1,'kcp','20260910123456','" + site + "','가상계좌',4000,'은행 T123456789');")
def state():
return mysql('SELECT od_receipt_price,od_misu,od_status FROM g5_shop_order; SELECT pp_receipt_price FROM g5_shop_personalpay; SELECT ct_status FROM g5_shop_cart ORDER BY ct_id;')
def pp_request(extra=None):
return request(dict(order_no='2001', ipgm_mnyx='4000', **(extra or {})))
# 실제 마이그레이션 파서/실행기로 기존 스키마와 재실행을 검증한다.
mysql('ALTER TABLE g5_shop_order DROP od_kcp_site_cd; ALTER TABLE g5_shop_personalpay DROP pp_kcp_site_cd; DROP TABLE g5_shop_kcp_noti;')
config()
migration_php = "<?php require __DIR__.'/_common.php'; require G5_LIB_PATH.'/migration.lib.php'; $m=g5_migration_parse_file(" + repr(str(ROOT / 'migrations/20260910_001_kcp_notification.sql')) + "); if(isset($m['error'])) exit(1); foreach($m['statements'] as $s) { if(!g5_migration_should_run_statement($s['conditions'])) continue; $r=g5_migration_execute_statement($s); if($r['error']) {fwrite(STDERR,$r['error']); exit(1);} }"
(directory / 'migration.php').write_text(migration_php)
subprocess.check_call(['php', str(directory / 'migration.php')])
subprocess.check_call(['php', str(directory / 'migration.php')])
check(mysql("SHOW COLUMNS FROM g5_shop_order LIKE 'od_kcp_site_cd'") != '', '마이그레이션 실행')
for engine in ['MyISAM', 'InnoDB']:
for table in ['order', 'personalpay', 'cart', 'kcp_noti']:
mysql('ALTER TABLE g5_shop_' + table + ' ENGINE=' + engine)
seed()
before = state()
for test in [0, 1]:
fixture.write_text(json.dumps(dict(test=test, ip='192.0.2.43')))
check(not request(headers={'X-Forwarded-For': '210.122.176.144'}), '비허용 IP/전달 헤더 거부')
config()
check(not request(method='GET'), 'GET 거부')
for key in ['site_cd', 'tno', 'order_no', 'tx_cd', 'tx_tm', 'ipgm_mnyx', 'account', 'noti_id', 'op_cd']:
for value in ['', None, 'x\n']:
check(not request({key: value}), key + ' 형식 거부')
check(not request({key: None, key + '[]': '1'}), key + ' 배열 거부')
for amount in ['0', '-1', '1e4', '10000.0', '2147483648', '999999999999', '9999']:
check(not request({'ipgm_mnyx': amount}), '잘못된 금액 거부')
for data in [{'site_cd':'T0007'}, {'tno':'20260910123457'}, {'order_no':'9999'},
{'account':'T999'}, {'tx_tm':'20260229120000'}, {'tx_tm':'20260910240000'}, {'op_cd':'51'}]:
check(not request(data), '거래/날짜 불일치 거부')
check(state() == before, '거부 요청 DB 불변')
for field, value in [('od_pg','lg'),('od_settle_case','무통장'),('od_status','완료'),('od_status','취소'),('od_test','0'),('od_kcp_site_cd','')]:
seed()
mysql("UPDATE g5_shop_order SET " + field + "='" + value + "'")
before = state()
check(not request() and state() == before, field + ' 바인딩/상태 거부')
for mobile in [0, 1]:
seed(mobile=mobile)
check(request(), 'PC/모바일 정상 입금')
after = state()
check(after == '10000\t0\t입금\n입금\n입금', '입금 금액/미수금/장바구니')
check(request() and state() == after, '재전송 멱등성')
mysql("UPDATE g5_shop_order SET od_status='완료'")
check(request(), '완료 후 동일 재전송 성공')
seed()
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
check(all(pool.map(lambda _: request(), range(8))), '동시 입금 통보')
check(mysql('SELECT od_receipt_price FROM g5_shop_order') == '10000', '동시 입금 한 번 반영')
check(request({'op_cd':'13'}), '같은 noti_id 망취소')
after = state()
check(after == '0\t10000\t주문\n주문\n주문', '망취소 금액/상태 복원')
check(request({'op_cd':'13'}) and request() and state() == after, '망취소/입금 역순 재전송')
check(request({'noti_id':'20260910123456789013'}), '망취소 후 새 입금')
seed()
check(request({'op_cd':'13'}) and request(), '망취소 선도착 처리')
check(mysql('SELECT od_receipt_price FROM g5_shop_order') == '0', '선도착 망취소 재입금 방지')
seed(personal=True)
for field, value in [('pp_pg','lg'),('pp_settle_case','무통장'),('pp_use','0'),('pp_kcp_site_cd',''),('od_id','9999')]:
seed(personal=True)
mysql("UPDATE g5_shop_personalpay SET " + field + "='" + value + "'")
before = state()
check(not pp_request() and state() == before, '개인결제 ' + field + ' 거부')
seed(personal=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
check(all(pool.map(lambda _: pp_request(), range(8))), '개인결제 동시 통보')
check(state() == '4000\t6000\t주문\n4000\n주문\n주문', '개인결제 청구액만 가산')
check(not pp_request({'noti_id':'20260910123456789013'}), '다른 통보 ID 중복 입금 거부')
check(pp_request({'op_cd':'13'}), '개인결제 망취소')
check(state() == '0\t10000\t주문\n0\n주문\n주문', '개인결제 망취소 차감')
seed(personal=True, linked=False)
check(pp_request() and pp_request() and pp_request({'op_cd':'13'}), '독립 개인결제 입금/재전송/취소')
# 각 쓰기 직전 실패 및 DB 성공 후 응답 유실을 재현한다.
for personal in [False, True]:
for mode in ['fail_before', 'fail_after']:
for write_no in range(1, 7 if personal else 6):
seed(personal=personal)
if personal:
mysql('UPDATE g5_shop_personalpay SET pp_price=10000')
config(**{mode:write_no})
check(not request({'order_no':'2001'} if personal else {}), '중간 실패 시 실패 응답')
config()
check(request({'order_no':'2001'} if personal else {}), '재통보로 중간 실패 복구')
check(mysql('SELECT od_receipt_price FROM g5_shop_order') == '10000', '복구 중복 가산 방지')
# 망취소의 각 쓰기 단계도 실패 후 같은 이벤트로 복구한다.
for personal in [False, True]:
for write_no in range(1, 7 if personal else 6):
seed(personal=personal)
if personal:
mysql('UPDATE g5_shop_personalpay SET pp_price=10000')
config()
payload = {'order_no':'2001'} if personal else {}
check(request(payload), '망취소 실패 검증용 입금')
payload['op_cd'] = '13'
config(fail_after=write_no)
check(not request(payload), '망취소 중간 실패 응답')
config()
check(request(payload), '망취소 재통보 복구')
check(mysql('SELECT od_receipt_price FROM g5_shop_order') == '0', '망취소 중복 차감 방지')
seed()
config()
mysql("CREATE TRIGGER kcp43_fail BEFORE UPDATE ON g5_shop_order FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test failure'")
check(not request(), '실제 DB 오류 실패 응답')
mysql('DROP TRIGGER kcp43_fail')
check(request(), '실제 DB 오류 후 복구')
seed(personal=True)
mysql('UPDATE g5_shop_order SET od_test=0')
check(not pp_request(), '테스트 개인결제/운영 연결 주문 거부')
seed()
before = state()
mysql('RENAME TABLE g5_shop_kcp_noti TO kcp43_missing')
check(not request() and state() == before, '미적용 스키마 실패 시 DB 불변')
mysql('RENAME TABLE kcp43_missing TO g5_shop_kcp_noti')
seed()
check(request({'tx_cd':'TX02'}), '바인딩된 에스크로 통보 수신')
check(state() == '0\t10000\t주문\n주문\n주문', '에스크로 수신 금액 불변')
seed(personal=True)
config(fail_before=3) # 개인결제 금액 반영 뒤 연결 주문 변경 전
check(not pp_request(), '부분 처리 실패')
mysql('UPDATE g5_shop_order SET od_receipt_price=1')
before = state()
config()
check(not pp_request() and state() == before, '실패 후 외부 변경 충돌 보존')
# 운영/에스크로 상점코드와 발신지 분리
for site, ip, test in [('SR123','103.215.144.173',0),('SR123','103.215.144.174',0),('SR123','210.122.72.173',0),('T0007','210.122.176.144',1)]:
seed(site=site)
fixture.write_text(json.dumps(dict(test=test,ip=ip)))
check(request({'site_cd':site}), '환경별 정상 상점/발신지')
config()
log.flush()
log.seek(0)
output = log.read()
check('PHP Warning' not in output and 'PHP Fatal' not in output, 'PHP 경고/치명적 오류 없음')
print('PASS:', checks, 'checks (HTTP, MySQL 8, MyISAM/InnoDB)')
finally:
if server:
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
mysql('DROP DATABASE `' + DB + '`', False)