diff --git a/README.md b/README.md
index f1ba3ef30..64840ef2d 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,8 @@ cd gnuboard5
## 문서 및 지원
+- [5.6.38 보안 업데이트 및 필수 DB업그레이드 안내](docs/release-5.6.38.md)
+
- [5.6.37 SIRK 전용 카카오페이 연동 종료 및 기존 거래 지원](docs/sirk-kakaopay-retirement.md)
- [그누보드5 공식 페이지](https://sir.kr/main/g5/)
- [그누보드5 공식 매뉴얼](https://sir.kr/manuals/g5/1)
diff --git a/docs/release-5.6.38.md b/docs/release-5.6.38.md
new file mode 100644
index 000000000..8831ad656
--- /dev/null
+++ b/docs/release-5.6.38.md
@@ -0,0 +1,37 @@
+# 그누보드(영카트) 5.6.38 배포 안내
+
+배포일: 2026-09-10. 변경 기준은 5.6.37입니다.
+
+이번 버전은 영카트 장바구니 옵션 검증과 KCP 가상계좌 통보 검증을 강화한 보안 업데이트입니다. **파일 반영 후 관리자 DB업그레이드가 필요합니다.**
+
+## 업데이트 절차
+
+1. 소스·설정·DB를 백업하고 주문·결제 접수를 잠시 중지합니다. 사용자 수정 파일과 커스텀 테마는 변경 내용을 비교·병합합니다.
+2. 5.6.37 설치본에 전체 소스 또는 `gnuboard5.6.38.patch.zip`의 변경 파일을 반영합니다. 이번 패치의 삭제 대상 파일은 없습니다. 이전 버전에서 업데이트하는 경우 [5.6.37 안내](release-5.6.37.md)의 설정 변경과 삭제 파일도 확인하십시오.
+3. 최고관리자로 **환경설정 → DB업그레이드**에서 `20260910_001_kcp_notification`까지 실행합니다. 파일 복사만으로 업데이트가 끝나지 않으며, DB업그레이드 전에는 주문 저장이나 KCP 통보가 실패할 수 있습니다. [DB업그레이드 안내](database-migrations.md)를 참고하십시오.
+4. KCP 이용 사이트는 DB 계정의 `LOCK TABLES` 및 관련 테이블의 조회·등록·수정 권한을 확인합니다. 기존 미결제 가상계좌 주문·개인결제에는 상점코드가 빈값으로 추가되므로, KCP 거래 기록과 대조해 개별 보정하고 재통보를 요청해야 합니다. 현재 상점 설정을 과거 거래 전체에 일괄 복사하지 마십시오. [KCP 적용·복구 절차](security-kve-2026-2346.md)를 반드시 확인하십시오.
+5. PC·모바일의 장바구니 추가·수정·주문과 사용하는 PG의 승인·취소를 확인합니다. KCP는 해당 계약의 통보 발신지, 실제 테스트 가상계좌 발급·입금·망취소·재통보를 확인한 뒤 접수를 재개합니다.
+
+## 보안 변경
+
+- **KVE-2026-2345:** 상품·옵션 종류와 구성 검증을 강화하여 상품대금이 주문 합계에서 누락되는 경로를 차단합니다. 공통 AJAX, 기본 테마 AJAX, 일반 장바구니 추가와 PC·모바일 주문 준비·확정에 적용합니다. 기존 장바구니의 잘못된 행과 가격 불일치도 거부하며, 정상 무료상품과 정상 0원 옵션은 유지합니다.
+- **KVE-2026-2346:** 테스트 모드를 포함한 KCP 통보 발신지·입력 검증과 저장된 상점·거래·결제수단·계좌·금액 대조를 적용합니다. 입금·망취소 이력을 기록하여 중복 반영을 방지하고, 중간 실패 시 재시도 및 충돌 검사를 수행합니다. 성공·실패에 맞는 통보 응답을 반환합니다.
+
+## 오류 수정 및 코드 정리
+
+- 모바일 이니시스 승인 전 장바구니 검증을 추가하고, 승인 후 검증 실패 시 서버 세션으로 확인한 거래의 승인 취소를 요청합니다. 취소 성공을 확인한 경우에만 완료를 안내합니다.
+- 기존 장바구니와 병합할 가격·누적 수량 등을 먼저 검사하여, 뒤쪽 옵션 검증 실패 전에 앞쪽 수량이 변경되는 문제를 수정합니다.
+- CRLF 및 줄 끝 공백이 있는 마이그레이션의 컬럼 생략 조건을 인식하여, 기존 컬럼 중복 추가 오류로 DB업그레이드가 중단되는 문제를 수정합니다.
+- KCP 처리 단계, 장바구니 조건문, 간편결제 HTML 조합의 가독성을 정리합니다.
+
+## 적용 후 점검
+
+- 기존 의심 주문과 장바구니는 금액·옵션·결제·출고 기록을 대조하십시오. 기존 주문을 자동 삭제·취소하는 업데이트는 아닙니다.
+- KCP 미완료 통보와 기존 미결제 거래를 점검하고 누락된 입금을 대조·복구하십시오. 이미 출고·완료된 주문의 망취소는 물류·콘텐츠 권한·포인트를 별도로 확인해야 합니다.
+- MyISAM의 여러 SQL 쓰기에 원자적 롤백을 보장하지 않습니다. 통보 처리 이력을 임의로 삭제하거나 완료 처리하지 말고 문서의 재시도·복구 절차를 따르십시오.
+
+## 검증 범위
+
+PHP 8.4.22와 격리 MySQL 8 환경에서 장바구니 공통 검사 46개, MyISAM·InnoDB 장바구니 HTTP/DB 검사 126개와 KCP HTTP/DB 검사 420개를 통과했습니다. 간편결제 PHP·JavaScript 회귀 검사 및 HTML 변경 전후 100개 조합 일치도 확인했습니다. 마이그레이션 줄바꿈 검사 13개, 변경 PHP 문법 검사와 신규 설치·마이그레이션 스키마 대조도 통과했습니다.
+
+HTTP/DB 회귀 검사는 초기화·PG 응답 등을 대체한 환경이며, 실제 외부 PG 승인·은행 입금이나 모든 운영 환경의 연동 검증을 의미하지 않습니다. 사용 중인 PG 계약·프록시·커스텀 테마와 기존 거래의 운영 확인이 필요합니다.
diff --git a/docs/security-kve-2026-2346.md b/docs/security-kve-2026-2346.md
new file mode 100644
index 000000000..cccab33b1
--- /dev/null
+++ b/docs/security-kve-2026-2346.md
@@ -0,0 +1,67 @@
+# 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 문법 검사, 신규 설치/마이그레이션 스키마 대조가 통과했다.
+
+### Windows CRLF 설치본 업그레이드 보완
+
+추가 설치 검증에서 기존 `@skip-if-column` 파서가 CRLF 줄 끝을 인식하지 못해, 이미 존재하는 `cf_email_certify_minutes` 컬럼을 다시 추가하려다 오류 1060으로 중단되는 문제가 재현됐다. 이 선행 오류 때문에 KCP 마이그레이션까지 실행되지 않았다. 파서가 CRLF 및 줄 끝 공백·탭을 허용하도록 수정했다. 인자 구분은 수평 공백으로 제한하여 불완전한 지시문이 다음 줄을 소비하지 않도록 했으며, SQL 파일과 체크섬 산정 방식은 변경하지 않았다.
+
+`php tests/migration_line_endings.php`로 LF·CRLF, 줄 끝 공백·탭, 파일 끝 지시문, 불완전한 지시문 및 원본 체크섬 보존을 포함한 13개 검사가 통과했다.
+
+PHP 8.4.25·MySQL 8.0.46의 별도 Windows 프로세스와 전용 DB에서 수정 전 소스를 실제 설치하고, 비회원 PC·회원 모바일 HTTP 주문 2건을 저장한 뒤 수정 소스를 적용했다. 원본 CRLF SQL을 유지한 상태에서 실제 관리자 로그인·CSRF 토큰을 사용한 전체 DB 업그레이드 및 KCP 마이그레이션 재실행이 통과했다. 기존 주문 금액·상태와 과거 상점코드 빈값이 유지됐고, 통보 테이블 컬럼이 신규 설치본과 일치했다. 결과는 `tests/reports/kve-2026-2346-migration-fixed.json`에 기록했다. VM·컨테이너 및 실제 KCP 발급·입금 연동 시험은 포함하지 않았다.
+
+## 배포·회신 상태
+
+수정 코드는 5.6.38 배포에 포함한다. 신고 대상은 5.6.35이며 검토 기준 5.6.37 개발 코드에도 원인이 있었다. 전체 영향 버전 범위는 추가 확인이 필요하다. 실제 KCP 연동 시험, 구형 발신 범위 확인, 운영 반영, KISA 회신 완료를 의미하지 않는다. 원본 PDF는 저장소에 포함하지 않는다.
+
+규격 참고: [가상계좌 입금·망취소](https://developer.kcp.co.kr/reference/account), [웹훅 응답](https://developer.kcp.co.kr/reference/webhook). 공개 규격에서 확인되지 않은 수신 서명 필드를 임의로 필수화하지 않았다. 인증서·서명을 사용하는 서버 간 거래조회 API의 도입은 사용 중인 계약/연동 지원 확인 후 별도로 검토한다.
diff --git a/install/gnuboard5shop.sql b/install/gnuboard5shop.sql
index 4fb827d5d..cecaf5579 100644
--- a/install/gnuboard5shop.sql
+++ b/install/gnuboard5shop.sql
@@ -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;
diff --git a/install/install_db.php b/install/install_db.php
index 2bba97a96..5a2ce5058 100644
--- a/install/install_db.php
+++ b/install/install_db.php
@@ -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");
diff --git a/lib/kcp_notification.lib.php b/lib/kcp_notification.lib.php
new file mode 100644
index 000000000..c77a4b31a
--- /dev/null
+++ b/lib/kcp_notification.lib.php
@@ -0,0 +1,391 @@
+ '/\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) {
+ $sql = 'UPDATE `'.$tables[$step['table']].'` SET '.implode(', ', $sets)
+ .' WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id']);
+ kcp_noti_query($sql);
+ }
+ }
+ 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;
+ }
+ return kcp_noti_deposit($data, $tables, $payment, $prefix, $pp, $od);
+}
+
+// 거래 검증과 테이블 잠금이 끝난 가상계좌 통보의 이력·금액·상태를 처리한다.
+function kcp_noti_deposit($data, $tables, $payment, $prefix, $pp, $od)
+{
+ // 기존 통보 이력과 미완료 처리를 먼저 확인한다.
+ $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;
+}
diff --git a/lib/migration.lib.php b/lib/migration.lib.php
index f0d3994bc..8299d2072 100644
--- a/lib/migration.lib.php
+++ b/lib/migration.lib.php
@@ -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',
@@ -108,7 +110,7 @@ function g5_migration_parse_file($file)
$migration['description'] = trim($matches[1]);
}
- if (preg_match('/^--\s*@skip-if-column\s+(\S+)\s+(\S+)$/mi', $contents, $matches)) {
+ 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], '`');
}
diff --git a/lib/shop.cartvalidate.lib.php b/lib/shop.cartvalidate.lib.php
new file mode 100644
index 000000000..9481db52f
--- /dev/null
+++ b/lib/shop.cartvalidate.lib.php
@@ -0,0 +1,230 @@
+ $error);
+ }
+
+ $option_map = array();
+ $has_selection = false;
+ foreach ($options as $option) {
+ if ((string) $option['it_id'] !== (string) $item['it_id']) {
+ return array('error' => $error);
+ }
+ if ((string) $option['io_type'] === '0') {
+ $has_selection = true;
+ }
+ $key = $option['io_type'].':'.$option['io_id'];
+ if (isset($option_map[$key])) {
+ return array('error' => $error);
+ }
+ $option_map[$key] = $option;
+ }
+
+ $validated = array();
+ $quantities = array();
+ $base_qty = 0;
+ $total = 0;
+ foreach ($rows as $row) {
+ // 요청값의 형식을 먼저 확인한다.
+ if (!isset($row['io_id'], $row['io_type'], $row['ct_qty']) ||
+ !is_string($row['io_id']) ||
+ !(is_string($row['io_type']) || is_int($row['io_type'])) ||
+ !in_array((string) $row['io_type'], array('0', '1'), true) ||
+ !(is_string($row['ct_qty']) || is_int($row['ct_qty'])) ||
+ !preg_match('/^[1-9][0-9]*$/D', (string) $row['ct_qty']) ||
+ (float) $row['ct_qty'] > 2147483647) {
+ return array('error' => $error);
+ }
+
+ // 서버 상품·옵션 정보로 종류와 가격을 결정한다.
+ $type = (int) $row['io_type'];
+ if (!$validated && $type !== 0) {
+ return array('error' => $error);
+ }
+ $id = $row['io_id'];
+ $key = $type.':'.$id;
+ if ($id === '') {
+ if ($type !== 0 || $has_selection) {
+ return array('error' => $error);
+ }
+ $price = 0;
+ $stock = (int) $item['it_stock_qty'];
+ } else {
+ if (!isset($option_map[$key]) || empty($option_map[$key]['io_use'])) {
+ return array('error' => $error);
+ }
+ $option = $option_map[$key];
+ $type = (int) $option['io_type'];
+ $price = (int) $option['io_price'];
+ $stock = (int) $option['io_stock_qty'];
+ }
+
+ // 누적 재고와 저장된 가격을 검사한다.
+ $qty = (int) $row['ct_qty'];
+ $quantities[$key] = isset($quantities[$key]) ? $quantities[$key] + $qty : $qty;
+ if ($quantities[$key] > $stock) {
+ return array('error' => '상품 또는 옵션의 재고수량이 부족합니다.');
+ }
+ $unit_price = $type === 1 ? $price : (int) $item['it_price'] + $price;
+ if ($unit_price < 0) {
+ return array('error' => $error);
+ }
+ if ($check_price && (!isset($row['ct_price'], $row['io_price']) ||
+ (int) $row['ct_price'] !== (int) $item['it_price'] || (int) $row['io_price'] !== $price)) {
+ return array('error' => '상품 또는 옵션 금액이 변경되었습니다. 장바구니를 다시 확인해 주십시오.');
+ }
+
+ if ($type === 0) {
+ $base_qty += $qty;
+ }
+ $row['io_type'] = $type;
+ $row['io_price'] = $price;
+ $row['ct_price'] = (int) $item['it_price'];
+ $row['ct_qty'] = $qty;
+ $validated[] = $row;
+ $total += $unit_price * $qty;
+ }
+
+ if (!$base_qty || (!empty($item['it_buy_min_qty']) && $base_qty < $item['it_buy_min_qty']) ||
+ (!empty($item['it_buy_max_qty']) && $base_qty > $item['it_buy_max_qty'])) {
+ return array('error' => '상품의 선택옵션과 최소/최대 구매수량을 확인해 주십시오.');
+ }
+
+ return array('error' => '', 'rows' => $validated, 'total' => $total);
+}
+
+function shop_cart_option_data($it_id)
+{
+ global $g5;
+ $id = sql_escape_string($it_id);
+ $item = sql_fetch(" select * from {$g5['g5_shop_item_table']} where it_id = '$id' ");
+ $result = sql_query(" select * from {$g5['g5_shop_item_option_table']} where it_id = '$id' ");
+ $options = array();
+ while ($row = sql_fetch_array($result)) {
+ $options[] = $row;
+ }
+ return array($item, $options);
+}
+
+// 모든 상품을 먼저 검증한다. 바로구매/옵션수정의 선삭제와 다중 상품의 부분 저장을 막는다.
+function shop_validate_cart_request($post, $multi = false)
+{
+ $error = '장바구니 요청 정보가 올바르지 않습니다.';
+ $products = array();
+ if (empty($post['it_id']) || !is_array($post['it_id']) ||
+ array_keys($post['it_id']) !== range(0, count($post['it_id']) - 1)) {
+ return array('error' => $error);
+ }
+ foreach ($post['it_id'] as $i => $id) {
+ if ($multi && empty($post['chk_it_id'][$i])) {
+ continue;
+ }
+ if (!is_string($id) || $id === '' || safe_replace_regex($id, 'it_id') !== $id || isset($products[$id])) {
+ return array('error' => $error);
+ }
+ foreach (array('io_id', 'io_type', 'ct_qty') as $field) {
+ if (!isset($post[$field][$id]) || !is_array($post[$field][$id]) || !$post[$field][$id]) {
+ return array('error' => $error);
+ }
+ }
+ $keys = range(0, count($post['io_id'][$id]) - 1);
+ foreach (array('io_id', 'io_type', 'ct_qty') as $field) {
+ if (array_keys($post[$field][$id]) !== $keys) {
+ return array('error' => $error);
+ }
+ }
+ $rows = array();
+ foreach ($keys as $k) {
+ $io_id = $post['io_id'][$id][$k];
+ if (!is_string($io_id) || preg_replace(G5_OPTION_ID_FILTER, '', $io_id) !== $io_id ||
+ (isset($post['io_value'][$id][$k]) && !is_string($post['io_value'][$id][$k]))) {
+ return array('error' => $error);
+ }
+ $rows[] = array('io_id' => $io_id, 'io_type' => $post['io_type'][$id][$k], 'ct_qty' => $post['ct_qty'][$id][$k]);
+ }
+ list($item, $options) = shop_cart_option_data($id);
+ $validated = shop_validate_cart_rows($item, $options, $rows);
+ if ($validated['error'] !== '') {
+ return $validated;
+ }
+ $validated['item'] = $item;
+ $products[$id] = $validated;
+ }
+ return array('error' => $products ? '' : $error, 'products' => $products);
+}
+
+// 기존 행과 합친 결과도 삭제/UPDATE 전에 모든 상품에 대해 확인한다.
+// 동시 요청과 SQL 실패의 원자성까지 보장하는 잠금/트랜잭션은 아니다.
+function shop_validate_cart_merge($cart_id, $products, $direct = false, $replace = false)
+{
+ global $g5;
+ $cart_id = sql_escape_string($cart_id);
+ foreach ($products as $it_id => $product) {
+ $id = sql_escape_string($it_id);
+ $rows = array();
+ if (!$replace) {
+ $exclude = $direct ? ' and ct_direct <> 1 ' : '';
+ $result = sql_query(" select * from {$g5['g5_shop_cart_table']} where od_id = '$cart_id' and it_id = '$id' and ct_status = '쇼핑' $exclude order by ct_id asc ");
+ while ($row = sql_fetch_array($result)) {
+ $rows[] = $row;
+ }
+ }
+ $rows = array_merge($rows, $product['rows']);
+ list($item, $options) = shop_cart_option_data($it_id);
+ $validated = shop_validate_cart_rows($item, $options, $rows, true);
+ if ($validated['error'] !== '') {
+ return $validated['error'];
+ }
+
+ // 바로구매에서는 다른 장바구니가 선택해 둔 수량도 선삭제 전에 검사한다.
+ if ($direct) {
+ $quantities = array();
+ foreach ($rows as $row) {
+ $key = $row['io_type'].':'.$row['io_id'];
+ $quantities[$key] = isset($quantities[$key]) ? $quantities[$key] + $row['ct_qty'] : $row['ct_qty'];
+ }
+ foreach ($product['rows'] as $row) {
+ $io_id = sql_escape_string($row['io_id']);
+ $type = (int) $row['io_type'];
+ $reserved = sql_fetch(" select SUM(ct_qty) as cnt from {$g5['g5_shop_cart_table']} where od_id <> '$cart_id' and it_id = '$id' and io_id = '$io_id' and io_type = '$type' and ct_stock_use = 0 and ct_status = '쇼핑' and ct_select = '1' ");
+ $stock = $row['io_id'] === '' ? get_it_stock_qty($it_id) : get_option_stock_qty($it_id, $row['io_id'], $type);
+ if ($quantities[$type.':'.$row['io_id']] + (int) $reserved['cnt'] > $stock) {
+ return '상품 또는 옵션의 재고수량이 부족합니다.';
+ }
+ }
+ }
+ }
+ return '';
+}
+
+// 주문 준비/확정 시에는 선택된 행만 검증하여 보조옵션 단독 선택과 과거 조작 행도 차단한다.
+function shop_validate_order_cart($cart_id)
+{
+ global $g5;
+ $id = sql_escape_string($cart_id);
+ $result = sql_query(" select * from {$g5['g5_shop_cart_table']} where od_id = '$id' and ct_select = '1' order by ct_id asc ");
+ $products = array();
+ while ($row = sql_fetch_array($result)) {
+ if ($row['ct_status'] !== '쇼핑') {
+ return '이미 처리되었거나 올바르지 않은 장바구니입니다.';
+ }
+ $products[$row['it_id']][] = $row;
+ }
+ if (!$products) {
+ return '주문하실 상품을 선택해 주십시오.';
+ }
+ foreach ($products as $it_id => $rows) {
+ list($item, $options) = shop_cart_option_data($it_id);
+ $validated = shop_validate_cart_rows($item, $options, $rows, true);
+ if ($validated['error'] !== '') {
+ return $validated['error'];
+ }
+ }
+ return '';
+}
diff --git a/lib/shop.easypay.lib.php b/lib/shop.easypay.lib.php
index ec97ed7dd..100cf1f3f 100644
--- a/lib/shop.easypay.lib.php
+++ b/lib/shop.easypay.lib.php
@@ -88,12 +88,22 @@ function shop_easypay_available($key, $mobile)
function shop_easypay_button($key, $provider, $mobile, $money = false)
{
+ $label = $provider[0];
+ $pay_code = $provider[1];
+ $css_class = $provider[2];
$value = isset($provider[3]) ? $provider[3] : '간편결제';
$legacy_ids = array('inicis_samsungpay' => 'samsungpay', 'inicis_lpay' => 'inicislpay');
$id = 'od_settle_'.(isset($legacy_ids[$key]) ? $legacy_ids[$key] : $key);
- $attrs = isset($provider[3]) ? ' data-case="'.$provider[1].'"' : '';
- if ($money) $attrs .= ' data-money="1"';
- $html = ' ';
+ $attrs = isset($provider[3]) ? ' data-case="'.$pay_code.'"' : '';
+ if ($money) {
+ $attrs .= ' data-money="1"';
+ }
+
+ $html = ' '
+ .'';
+
return $mobile ? '
'.$html.'' : $html;
}
diff --git a/lib/shop.lib.php b/lib/shop.lib.php
index a969c9d63..a48162752 100644
--- a/lib/shop.lib.php
+++ b/lib/shop.lib.php
@@ -1,4 +1,5 @@
$cart_cancel_method,
+ 'tid' => $cart_cancel_tid,
+ 'msg' => '장바구니 검증 실패',
+ 'audit' => false
+)) : '';
+$cart_cancel_result = json_decode($cart_cancel_response, true);
+$cart_cancel_success = is_array($cart_cancel_result) && isset($cart_cancel_result['resultCode']) && $cart_cancel_result['resultCode'] === '00';
+$cart_cancel_status = $cart_cancel_success ? 'cancel' : 'cancel_failed';
+
+// 성공을 확인하기 전에 취소 완료로 기록하지 않는다. 실패 거래는 관리자 확인용으로 남긴다.
+$cart_cancel_oid = sql_escape_string(get_session('ss_order_id'));
+$cart_cancel_log_tid = sql_escape_string($cart_cancel_tid);
+$cart_cancel_log_message = $cart_cancel_success ? '장바구니 검증 실패: 승인 취소 완료' : '장바구니 검증 실패: 승인 취소 결과 확인 필요';
+$cart_cancel_time = preg_replace('/[^0-9]/', '', G5_TIME_YMDHIS);
+$cart_cancel_mid = sql_escape_string($default['de_inicis_mid']);
+$cart_cancel_log_amount = (int) $cart_cancel_amount;
+$cart_cancel_old_log = sql_fetch(" select oid from {$g5['g5_shop_inicis_log_table']} where oid = '$cart_cancel_oid' ");
+if (!empty($cart_cancel_old_log['oid'])) {
+ sql_query(" update {$g5['g5_shop_inicis_log_table']} set P_STATUS = '$cart_cancel_status', P_RMESG1 = '$cart_cancel_log_message', P_AUTH_DT = '$cart_cancel_time' where oid = '$cart_cancel_oid' and P_TID = '$cart_cancel_log_tid' ", false);
+} else {
+ sql_query(" insert into {$g5['g5_shop_inicis_log_table']} (oid, P_TID, P_MID, P_AMT, P_STATUS, P_RMESG1, P_AUTH_DT, post_data) values ('$cart_cancel_oid', '$cart_cancel_log_tid', '$cart_cancel_mid', '$cart_cancel_log_amount', '$cart_cancel_status', '$cart_cancel_log_message', '$cart_cancel_time', '') ", false);
+}
+error_log('KVE-2026-2345: inicis '.$cart_cancel_status.' oid='.get_session('ss_order_id').' tid='.$cart_cancel_tid);
+if (function_exists('add_order_post_log')) add_order_post_log($cart_cancel_log_message);
+if ($cart_cancel_success) {
+ set_session('P_TID', '');
+ set_session('P_AMT', '');
+ set_session('P_HASH', '');
+ $cart_validation_error .= ' 결제 승인은 취소되었습니다.';
+} else {
+ $cart_validation_error .= ' 결제 취소 결과를 확인하지 못했습니다. 재결제 전에 상점에 문의해 주십시오.';
+}
diff --git a/mobile/shop/inicis/pay_approval.php b/mobile/shop/inicis/pay_approval.php
index 3dd76e7de..9eb4fc058 100644
--- a/mobile/shop/inicis/pay_approval.php
+++ b/mobile/shop/inicis/pay_approval.php
@@ -40,6 +40,11 @@ if(isset($data['pp_id']) && $data['pp_id']) {
if (get_cart_count($tmp_cart_id) == 0)// 장바구니에 담기
alert('세션을 잃거나 다른 브라우저에서 데이터가 변경된 경우입니다. 장바구니 상태를 확인후에 다시 시도해 주세요.', G5_SHOP_URL.'/cart.php');
+ // KVE-2026-2345: PG 승인 요청 전에 상품·옵션 구성과 금액을 확인한다.
+ $cart_validation_error = shop_validate_order_cart($tmp_cart_id);
+ if ($cart_validation_error !== '')
+ alert($cart_validation_error, G5_SHOP_URL.'/cart.php');
+
$error = "";
// 장바구니 상품 재고 검사
$sql = " select it_id,
diff --git a/mobile/shop/orderformupdate.php b/mobile/shop/orderformupdate.php
index 10da3f72a..8e77c1488 100644
--- a/mobile/shop/orderformupdate.php
+++ b/mobile/shop/orderformupdate.php
@@ -66,9 +66,14 @@ if (get_session('ss_direct'))
else
$tmp_cart_id = get_session('ss_cart_id');
-if (get_cart_count($tmp_cart_id) == 0) { // 장바구니에 담기
- if(function_exists('add_order_post_log')) add_order_post_log('장바구니가 비어 있습니다.');
- alert('장바구니가 비어 있습니다.\\n\\n이미 주문하셨거나 장바구니에 담긴 상품이 없는 경우입니다.', G5_SHOP_URL.'/cart.php');
+// KVE-2026-2345: 승인 이후 장바구니가 삭제된 경우도 취소 처리로 연결한다.
+$cart_validation_error = get_cart_count($tmp_cart_id) == 0
+ ? '장바구니가 비어 있습니다.'
+ : shop_validate_order_cart($tmp_cart_id);
+if ($cart_validation_error !== '') {
+ if ($default['de_pg_service'] === 'inicis')
+ include G5_MSHOP_PATH.'/inicis/cart_validation_cancel.php';
+ alert($cart_validation_error, G5_SHOP_URL.'/cart.php');
}
// 변수 초기화
@@ -569,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) {
@@ -671,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',
diff --git a/mobile/shop/personalpayformupdate.php b/mobile/shop/personalpayformupdate.php
index 45d39eff5..908dad56e 100644
--- a/mobile/shop/personalpayformupdate.php
+++ b/mobile/shop/personalpayformupdate.php
@@ -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',
diff --git a/shop/ajax.action.php b/shop/ajax.action.php
index 5c91f69c9..70384b269 100644
--- a/shop/ajax.action.php
+++ b/shop/ajax.action.php
@@ -78,35 +78,24 @@ switch ($action) {
if ($count < 1)
die(json_encode(array('error' => '장바구니에 담을 상품을 선택하여 주십시오.')));
+ $cart_validation = shop_validate_cart_request($_POST);
+ if ($cart_validation['error'] !== '')
+ die(json_encode(array('error' => $cart_validation['error'])));
+
+ $cart_merge_error = shop_validate_cart_merge($tmp_cart_id, $cart_validation['products'], !empty($sw_direct));
+ if ($cart_merge_error !== '')
+ die(json_encode(array('error' => $cart_merge_error)));
+
$ct_count = 0;
for($i=0; $i<$count; $i++) {
$it_id = isset($_POST['it_id'][$i]) ? safe_replace_regex($_POST['it_id'][$i], 'it_id') : '';
$opt_count = (isset($_POST['io_id'][$it_id]) && is_array($_POST['io_id'][$it_id])) ? count($_POST['io_id'][$it_id]) : 0;
// 상품정보
- $it = get_shop_item($it_id, false);
+ $it = $cart_validation['products'][$it_id]['item'];
if(!$it['it_id'])
die(json_encode(array('error' => '상품정보가 존재하지 않습니다.')));
- // 옵션정보를 얻어서 배열에 저장
- $opt_list = array();
- $sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '$it_id' order by io_no asc ";
- $result = sql_query($sql);
- $lst_count = 0;
- for($k=0; $row=sql_fetch_array($result); $k++) {
- $opt_list[$row['io_type']][$row['io_id']]['id'] = $row['io_id'];
- $opt_list[$row['io_type']][$row['io_id']]['use'] = $row['io_use'];
- $opt_list[$row['io_type']][$row['io_id']]['price'] = $row['io_price'];
- $opt_list[$row['io_type']][$row['io_id']]['stock'] = $row['io_stock_qty'];
-
- // 선택옵션 개수
- if(!$row['io_type'])
- $lst_count++;
- }
-
- if($lst_count > 0 && !trim($_POST['io_id'][$it_id][$i]) && $_POST['io_type'][$it_id][$i] == 0)
- die(json_encode(array('error' => '상품의 선택옵션을 선택해 주십시오.')));
-
for($k=0; $k<$opt_count; $k++) {
$post_ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0;
if ($post_ct_qty < 1)
@@ -155,25 +144,17 @@ switch ($action) {
// 장바구니에 Insert
$comma = '';
+ $ct_count = 0;
$sql = " INSERT INTO {$g5['g5_shop_cart_table']}
( od_id, mb_id, it_id, it_name, it_sc_type, it_sc_method, it_sc_price, it_sc_minimum, it_sc_qty, ct_status, ct_price, ct_point, ct_point_use, ct_stock_use, ct_option, ct_qty, ct_notax, io_id, io_type, io_price, ct_time, ct_ip, ct_send_cost, ct_direct, ct_select, ct_select_time )
VALUES ";
for($k=0; $k<$opt_count; $k++) {
- $io_id = isset($_POST['io_id'][$it_id][$k]) ? preg_replace(G5_OPTION_ID_FILTER, '', $_POST['io_id'][$it_id][$k]) : '';
- $io_type = isset($_POST['io_type'][$it_id][$k]) ? preg_replace('#[^01]#', '', $_POST['io_type'][$it_id][$k]) : '';
+ $io_id = $cart_validation['products'][$it_id]['rows'][$k]['io_id'];
+ $io_type = $cart_validation['products'][$it_id]['rows'][$k]['io_type'];
$io_value = isset($_POST['io_value'][$it_id][$k]) ? $_POST['io_value'][$it_id][$k] : '';
- // 선택옵션정보가 존재하는데 선택된 옵션이 없으면 건너뜀
- if($lst_count && $io_id == '')
- continue;
-
- $opt_list_type_id_use = isset($opt_list[$io_type][$io_id]['use']) ? $opt_list[$io_type][$io_id]['use'] : '';
- // 구매할 수 없는 옵션은 건너뜀
- if($io_id && ! $opt_list_type_id_use)
- continue;
-
- $io_price = isset($opt_list[$io_type][$io_id]['price']) ? $opt_list[$io_type][$io_id]['price'] : 0;
+ $io_price = $cart_validation['products'][$it_id]['rows'][$k]['io_price'];
$ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0;
// 구매가격이 음수인지 체크
@@ -186,14 +167,17 @@ switch ($action) {
}
// 동일옵션의 상품이 있으면 수량 더함
- $sql2 = " select ct_id, io_type, ct_qty
+ $sql2 = " select ct_id, io_type, ct_qty, ct_price, io_price
from {$g5['g5_shop_cart_table']}
where od_id = '$tmp_cart_id'
and it_id = '$it_id'
and io_id = '$io_id'
+ and io_type = '$io_type'
and ct_status = '쇼핑' ";
$row2 = sql_fetch($sql2);
if(isset($row2['ct_id']) && $row2['ct_id']) {
+ if ((int) $row2['ct_price'] !== (int) $it['it_price'] || (int) $row2['io_price'] !== $io_price)
+ die(json_encode(array('error' => '기존 장바구니의 상품 정보가 변경되었습니다. 삭제한 뒤 다시 담아 주십시오.')));
// 재고체크
$tmp_ct_qty = $row2['ct_qty'];
if(!$io_id)
diff --git a/shop/cartupdate.php b/shop/cartupdate.php
index dac248e9e..23a8d17a6 100644
--- a/shop/cartupdate.php
+++ b/shop/cartupdate.php
@@ -146,6 +146,22 @@ else // 장바구니에 담기
if ($count < 1)
alert('장바구니에 담을 상품을 선택하여 주십시오.');
+ $cart_validation = shop_validate_cart_request($_POST, $act === 'multi');
+ if ($cart_validation['error'] !== '')
+ alert($cart_validation['error']);
+
+ $cart_merge_error = shop_validate_cart_merge($tmp_cart_id, $cart_validation['products'], !empty($sw_direct), $act === 'optionmod');
+ if ($cart_merge_error !== '')
+ alert($cart_merge_error);
+
+ // 구매 자격 오류도 바로구매 삭제와 다중 상품 변경 전에 확인한다.
+ if (!$is_admin) {
+ foreach ($cart_validation['products'] as $cart_it_id => $cart_product) {
+ $msg = shop_member_cert_check($cart_it_id, 'item');
+ if ($msg) alert($msg, G5_SHOP_URL);
+ }
+ }
+
$ct_count = 0;
$post_chk_it_id = (isset($_POST['chk_it_id']) && is_array($_POST['chk_it_id'])) ? $_POST['chk_it_id'] : array();
$post_io_ids = (isset($_POST['io_id']) && is_array($_POST['io_id'])) ? $_POST['io_id'] : array();
@@ -176,15 +192,8 @@ else // 장바구니에 담기
alert('수량은 1 이상 입력해 주십시오.');
}
- // 본인인증, 성인인증체크
- if(!$is_admin) {
- $msg = shop_member_cert_check($it_id, 'item');
- if($msg)
- alert($msg, G5_SHOP_URL);
- }
-
// 상품정보
- $it = get_shop_item($it_id, false);
+ $it = $cart_validation['products'][$it_id]['item'];
if(!$it['it_id'])
alert('상품정보가 존재하지 않습니다.');
@@ -219,30 +228,14 @@ else // 장바구니에 담기
}
}
- // 옵션정보를 얻어서 배열에 저장
- $opt_list = array();
- $sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '$it_id' and io_use = 1 order by io_no asc ";
- $result = sql_query($sql);
- $lst_count = 0;
- for($k=0; $row=sql_fetch_array($result); $k++) {
- $opt_list[$row['io_type']][$row['io_id']]['id'] = $row['io_id'];
- $opt_list[$row['io_type']][$row['io_id']]['use'] = $row['io_use'];
- $opt_list[$row['io_type']][$row['io_id']]['price'] = $row['io_price'];
- $opt_list[$row['io_type']][$row['io_id']]['stock'] = $row['io_stock_qty'];
-
- // 선택옵션 개수
- if(!$row['io_type'])
- $lst_count++;
- }
-
//--------------------------------------------------------
// 재고 검사, 바로구매일 때만 체크
//--------------------------------------------------------
// 이미 주문폼에 있는 같은 상품의 수량합계를 구한다.
if($sw_direct) {
for($k=0; $k<$opt_count; $k++) {
- $io_id = isset($_POST['io_id'][$it_id][$k]) ? preg_replace(G5_OPTION_ID_FILTER, '', $_POST['io_id'][$it_id][$k]) : '';
- $io_type = isset($_POST['io_type'][$it_id][$k]) ? preg_replace('#[^01]#', '', $_POST['io_type'][$it_id][$k]) : '';
+ $io_id = $cart_validation['products'][$it_id]['rows'][$k]['io_id'];
+ $io_type = $cart_validation['products'][$it_id]['rows'][$k]['io_type'];
$io_value = isset($_POST['io_value'][$it_id][$k]) ? $_POST['io_value'][$it_id][$k] : '';
$sql = " select SUM(ct_qty) as cnt from {$g5['g5_shop_cart_table']}
@@ -287,24 +280,17 @@ else // 장바구니에 담기
// 장바구니에 Insert
$comma = '';
+ $ct_count = 0;
$sql = " INSERT INTO {$g5['g5_shop_cart_table']}
( od_id, mb_id, it_id, it_name, it_sc_type, it_sc_method, it_sc_price, it_sc_minimum, it_sc_qty, ct_status, ct_price, ct_point, ct_point_use, ct_stock_use, ct_option, ct_qty, ct_notax, io_id, io_type, io_price, ct_time, ct_ip, ct_send_cost, ct_direct, ct_select, ct_select_time )
VALUES ";
for($k=0; $k<$opt_count; $k++) {
- $io_id = isset($_POST['io_id'][$it_id][$k]) ? preg_replace(G5_OPTION_ID_FILTER, '', $_POST['io_id'][$it_id][$k]) : '';
- $io_type = isset($_POST['io_type'][$it_id][$k]) ? preg_replace('#[^01]#', '', $_POST['io_type'][$it_id][$k]) : '';
+ $io_id = $cart_validation['products'][$it_id]['rows'][$k]['io_id'];
+ $io_type = $cart_validation['products'][$it_id]['rows'][$k]['io_type'];
$io_value = isset($_POST['io_value'][$it_id][$k]) ? $_POST['io_value'][$it_id][$k] : '';
- // 선택옵션정보가 존재하는데 선택된 옵션이 없으면 건너뜀
- if($lst_count && $io_id == '')
- continue;
-
- // 구매할 수 없는 옵션은 건너뜀
- if($io_id && !$opt_list[$io_type][$io_id]['use'])
- continue;
-
- $io_price = isset($opt_list[$io_type][$io_id]['price']) ? $opt_list[$io_type][$io_id]['price'] : 0;
+ $io_price = $cart_validation['products'][$it_id]['rows'][$k]['io_price'];
$ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0;
// 구매가격이 음수인지 체크
@@ -317,14 +303,17 @@ else // 장바구니에 담기
}
// 동일옵션의 상품이 있으면 수량 더함
- $sql2 = " select ct_id, io_type, ct_qty
+ $sql2 = " select ct_id, io_type, ct_qty, ct_price, io_price
from {$g5['g5_shop_cart_table']}
where od_id = '$tmp_cart_id'
and it_id = '$it_id'
and io_id = '$io_id'
+ and io_type = '$io_type'
and ct_status = '쇼핑' ";
$row2 = sql_fetch($sql2);
if(isset($row2['ct_id']) && $row2['ct_id']) {
+ if ((int) $row2['ct_price'] !== (int) $it['it_price'] || (int) $row2['io_price'] !== $io_price)
+ alert('기존 장바구니의 상품 정보가 변경되었습니다. 삭제한 뒤 다시 담아 주십시오.');
// 재고체크
$tmp_ct_qty = $row2['ct_qty'];
if(!$io_id)
diff --git a/shop/orderform.php b/shop/orderform.php
index e7818a322..5eed7ec94 100644
--- a/shop/orderform.php
+++ b/shop/orderform.php
@@ -24,6 +24,12 @@ else {
if (get_cart_count($tmp_cart_id) == 0)
alert('장바구니가 비어 있습니다.', G5_SHOP_URL.'/cart.php');
+// KVE-2026-2345: 과거 장바구니의 옵션 종류와 가격도 주문 전에 다시 검증한다.
+$cart_validation_error = shop_validate_order_cart($tmp_cart_id);
+if ($cart_validation_error !== '') {
+ alert($cart_validation_error, G5_SHOP_URL.'/cart.php');
+}
+
if (function_exists('before_check_cart_price')) {
if(! before_check_cart_price($tmp_cart_id) ) alert('장바구니 금액에 변동사항이 있습니다.\n장바구니를 다시 확인해 주세요.', G5_SHOP_URL.'/cart.php');
}
diff --git a/shop/orderformupdate.php b/shop/orderformupdate.php
index bce7244da..51c2b8dd1 100644
--- a/shop/orderformupdate.php
+++ b/shop/orderformupdate.php
@@ -47,6 +47,12 @@ if (get_cart_count($tmp_cart_id) == 0) { // 장바구니에 담기
alert('장바구니가 비어 있습니다.\\n\\n이미 주문하셨거나 장바구니에 담긴 상품이 없는 경우입니다.', G5_SHOP_URL.'/cart.php');
}
+// KVE-2026-2345: 과거 장바구니의 옵션 종류와 가격도 주문 전에 다시 검증한다.
+$cart_validation_error = shop_validate_order_cart($tmp_cart_id);
+if ($cart_validation_error !== '') {
+ alert($cart_validation_error, G5_SHOP_URL.'/cart.php');
+}
+
// 변수 초기화
$od_other_pay_type = '';
@@ -525,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 : '';
@@ -624,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',
diff --git a/shop/personalpayformupdate.php b/shop/personalpayformupdate.php
index fe4183ea5..29a30e178 100644
--- a/shop/personalpayformupdate.php
+++ b/shop/personalpayformupdate.php
@@ -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',
diff --git a/shop/settle_kcp_common.php b/shop/settle_kcp_common.php
index 523a716dd..1763d3e20 100644
--- a/shop/settle_kcp_common.php
+++ b/shop/settle_kcp_common.php
@@ -1,271 +1,8 @@
-
-
\ No newline at end of file
+
diff --git a/tests/README.cart-validation.md b/tests/README.cart-validation.md
new file mode 100644
index 000000000..08b183a37
--- /dev/null
+++ b/tests/README.cart-validation.md
@@ -0,0 +1,54 @@
+# 장바구니 옵션 검증 테스트
+
+KVE-2026-2345 수정의 회귀 검증이다. 운영 DB·PG를 사용하지 않는다.
+
+## 공통 규칙
+
+```bash
+php tests/shop_cart_validation.php
+```
+
+DB 연결 없이 기본상품·선택옵션·추가옵션, 잘못된 종류·수량·재고, 정상 0원 상품, 과거 조작 행과 가격 변경을 검증한다.
+
+## HTTP 및 MySQL
+
+별도로 초기화한 테스트 전용 MySQL 서버의 Unix 소켓을 전달한다. PHP CLI의 `mysqli`, Python 3, `mysql` 클라이언트와 해당 테스트 서버의 비밀번호 없는 root 계정이 필요하다.
+
+```bash
+python3 tests/shop_cart_http.py /tmp/kve2345-mysql.sock
+```
+
+실행마다 고유한 `kve2345_test_*` DB를 생성하고 종료 시 삭제한다. 저장소 설치 SQL에서 상품·옵션·장바구니 테이블을 생성하며 MyISAM과 InnoDB에서 같은 검증을 수행한다. DB 엔진을 변환하는 대상은 생성된 테스트 테이블뿐이다.
+
+공통 AJAX, 기본 테마 AJAX, 일반 장바구니 처리 파일을 임시 디렉터리에 복사하여 로컬 HTTP 서버로 실행한다. 인증·세션·리다이렉트 등 주변 기능은 테스트 부트스트랩으로 대체하고, SQL은 실제 MySQL에서 실행한다. 잘못된 요청이 DB를 바꾸지 않는지와 정상 추가·수량 병합·옵션별 합계를 확인한다.
+
+PC·모바일 주문 처리 파일은 추가한 장바구니 검증 경계까지만 실행한다. 따라서 이 테스트는 실제 PG 승인·취소, 주문 저장 완료, 쿠폰·포인트·배송비 후속 정산, 실제 로그인·구매 권한, 브라우저 UI 또는 동시 요청 검증을 대신하지 않는다. 이 항목들은 배포 전 별도의 통합 환경에서 확인해야 한다.
+
+## 구현 범위
+
+두 AJAX 경로와 일반 추가 경로는 요청 전체의 상품·옵션 구성을 검증한 후 기존 변경 로직으로 진행한다. 옵션 종류·가격은 검증한 서버 데이터에서 가져온다. 주문 준비 및 PC·모바일 주문 제출은 선택된 기존 장바구니 행도 검증하여 과거 조작 행과 가격 불일치를 거부한다. 정상 무료상품은 허용한다.
+
+기존 테이블 엔진과 장바구니·주문 전체의 트랜잭션은 변경하지 않는다. 따라서 사전 검증 이후의 DB 오류·동시 변경까지 모든 처리가 원자적이라고 보장하는 변경은 아니다.
+
+## 후속 검토 회귀 검증
+
+```bash
+python3 tests/shop_cart_followup.py /path/to/php -n -d extension=pdo_sqlite
+```
+
+PHP의 확장 경로 설정이 필요한 환경에서는 `-d extension_dir=/path/to/ext`도 전달한다. Python 표준 라이브러리와 PHP PDO SQLite를 사용하며, 테스트 전용 임시 디렉터리와 SQLite DB는 종료 시 정리한다. `-n`은 실제 cURL 확장을 로드하지 않고 모의 승인 응답을 사용하기 위한 설정이다.
+
+25개 검증에서 세 추가 경로의 기존 가격 불일치·누적 재고·최대 수량·다중 상품 실패 시 행 보존과 정상 병합을 확인한다. 일반 추가의 바로구매 실패 시 선삭제 방지, 정상 옵션 교체, 뒤쪽 상품의 구매 자격 거부 시 행 보존도 확인한다. 실제 모바일 이니시스 승인 파일에서 모의 승인 응답을 거쳐 실제 모바일 주문 검증·취소 연결 파일을 실행하여 승인 전 차단, 승인 도중 가격 변경·장바구니 삭제, 취소 성공·실패, 기존 로그 갱신, 정상 검증 통과 및 위조 해시에 대한 취소 미실행을 확인한다.
+
+기존 행 병합 결과는 모든 상품에 대해 변경 전에 검사한다. 모바일 이니시스는 승인 전에 장바구니를 검사하고, 승인 후 주문 검증 실패 시 서버 세션과 해시로 확인한 거래번호를 기존 INIAPI 취소 함수에 전달한다. 취소 응답이 성공일 때만 취소 완료를 안내하며, 실패는 로그와 확인 필요 안내를 남긴다.
+
+이 검증의 SQL은 SQLite에서 실행하므로 MyISAM·InnoDB 테스트를 대체하지 않는다. PG 승인 응답과 취소 API는 모의 구현이며 실제 PG의 취소 성공이나 전체 주문 저장 완료를 검증한 결과가 아니다. 승인 이후 검사와 저장 사이의 동시 변경, 통신 실패 후 운영 복구, 실제 PG 통합 검증은 별도로 필요하다.
+
+## 실제 설치 환경 검증 기록
+
+2026-09-10에 PHP 8.4.25와 MySQL 8.0.46의 별도 로컬 프로세스로 수정 전·후 영카트를 실제 설치하여 HTTP 요청부터 무통장 주문 저장까지 확인했다. 공통 초기화·인증·주문 코드를 대체하거나 중간에서 종료하지 않았다. 이 실행은 위 단위/모의 테스트와 별도의 검증이다.
+
+- [검증 환경·46개 시나리오·판정과 한계](reports/KVE-2026-2345-installed-verification.md)
+- [시나리오별 결과 JSON](reports/KVE-2026-2345-integration-results.json)
+
+보고서는 해당 커밋과 환경에서 수행한 검증 기록이며 자동 재실행 도구가 아니다. DB 접속 설정, 비밀번호, 세션 파일과 테스트 DB는 저장소에 포함하지 않는다. 실제 PG 승인·취소와 브라우저 JavaScript, 동시성, 쿠폰·포인트 등 전체 정산은 이 검증에 포함되지 않는다.
diff --git a/tests/kcp_notification_http.py b/tests/kcp_notification_http.py
new file mode 100644
index 000000000..d9b3328ef
--- /dev/null
+++ b/tests/kcp_notification_http.py
@@ -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'''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(" 'g5_config');
+require dirname(__DIR__) . '/lib/migration.lib.php';
+
+$directory = sys_get_temp_dir() . '/g5-migration-' . uniqid('', true);
+mkdir($directory);
+$file = $directory . '/20260904_001_add_email_certify_minutes.sql';
+$count = 0;
+try {
+ foreach (array("\n", "\r\n") as $newline) {
+ foreach (array('', ' ', "\t") as $trailing) {
+ foreach (array(false, true) as $atEnd) {
+ $directive = '-- @skip-if-column {{config_table}} cf_email_certify_minutes' . $trailing;
+ $sql = 'ALTER TABLE `{{config_table}}` ADD `cf_email_certify_minutes` int;';
+ $contents = $atEnd ? $sql . $newline . $directive : $directive . $newline . $sql . $newline;
+ file_put_contents($file, $contents);
+ $parsed = g5_migration_parse_file($file);
+ if ($parsed['skip_table'] !== 'g5_config' || $parsed['skip_column'] !== 'cf_email_certify_minutes'
+ || $parsed['checksum'] !== hash('sha256', $contents) || count($parsed['statements']) !== 1) {
+ throw new RuntimeException('줄바꿈·공백·EOF 조건 또는 원본 체크섬 보존 실패');
+ }
+ $count++;
+ }
+ }
+ }
+ // 불완전한 지시문의 컬럼을 다음 줄에서 가져오면 안 된다.
+ file_put_contents($file, "-- @skip-if-column {{config_table}}\r\nSELECT 1;\r\n");
+ $parsed = g5_migration_parse_file($file);
+ if ($parsed['skip_table'] !== '' || $parsed['skip_column'] !== '') {
+ throw new RuntimeException('지시문이 다음 줄을 소비함');
+ }
+ $count++;
+ echo $count . "개 마이그레이션 줄바꿈 검사 통과\n";
+} finally {
+ unlink($file);
+ rmdir($directory);
+}
diff --git a/tests/reports/KVE-2026-2345-installed-verification.md b/tests/reports/KVE-2026-2345-installed-verification.md
new file mode 100644
index 000000000..203f5887d
--- /dev/null
+++ b/tests/reports/KVE-2026-2345-installed-verification.md
@@ -0,0 +1,55 @@
+# KVE-2026-2345 실제 설치 환경 검증
+
+검증일: 2026-09-10
+
+## 환경과 수행 방식
+
+Docker Desktop 시작을 시도했으나 WSL 2 커널이 없어 실패했다. 따라서 이번 환경은 가상머신/컨테이너가 아니라 Windows의 작업 전용 디렉터리와 로컬 전용 포트에 구성한 별도 프로세스 환경이다. 운영 시스템 수준의 샌드박스는 아니다.
+
+- PHP 8.4.25 내장 HTTP 서버, 실제 MySQL Community Server 8.0.46.
+- MySQL과 HTTP 모두 `127.0.0.1`에만 바인딩. 시스템 서비스 설치 없음.
+- 수정 전 `1c067f831`, 수정 후 `f7ba144b2e74e55106238c61c76367d2b84bea1a` 소스를 각각 별도 디렉터리에 전개.
+- 수정 전·후를 서로 다른 테스트 DB에 영카트의 실제 `install/install_db.php` HTTP 요청으로 설치. 전체 설치 테이블과 `data/dbconfig.php` 생성 확인.
+- 상품·테스트 회원 등 시험 데이터는 테스트 DB에 SQL로 준비. 회원 검증은 일반 구매자 레벨 2 계정으로 실제 `bbs/login_check.php` 로그인 수행.
+- HTTP 쿠키를 유지하면서 실제 `_common.php`, 인증·세션 처리, 상품·장바구니·주문 처리 코드를 실행. 처리 함수를 모의 함수로 교체하지 않았고 주문 코드를 중간에서 자르지 않았다.
+- PC·모바일 User-Agent를 사용해 서버의 분기와 각 주문 제출 주소를 실행. 브라우저의 JavaScript·화면 클릭 자동화는 수행하지 않았다.
+- 무통장 결제만 사용. 주문서 HTML에서 입력값을 가져와 주문 제출 후 실제 주문 테이블의 상태·가격·미수금·회원 ID를 확인.
+- 테스트 메일 발송 비활성화. 실계좌 송금, 외부 PG 승인·취소, 실제 운영 주문은 없음.
+- MyISAM·InnoDB 비교는 상품·옵션·장바구니·주문 4개 테이블의 엔진을 전환하여 수행. 전체 설치 테이블을 InnoDB로 전환한 시험은 아님.
+
+## 결과
+
+| 검증 | 조합 수 | 실제 DB/HTTP 결과 |
+|---|---:|---|
+| 수정 전 취약점 재현 | 8 | 유료 50,000원 상품의 장바구니가 `ct_price=50000, io_type=1, io_price=0, ct_qty=1`로 저장됨. 최종 무통장 주문은 `입금`, 상품대금·미수금·입금액 모두 0원으로 저장됨 |
+| 수정 후 조작 차단 및 정상 주문 | 24 | 조작 요청의 장바구니 저장 차단. 정상 요청은 50,000원, 미수금 50,000원, `주문` 상태로 최종 DB 저장. 회원 ID도 로그인 여부와 일치 |
+| 정상 무료상품 | 4 | 상품 가격 0원인 정상 주문을 0원·`입금`으로 저장 가능 |
+| 패치 전 조작 장바구니 | 4 | 테스트 DB에 기존 조작 상태를 구성한 후 주문 준비와 PC·모바일 주문 제출 모두 거부. 새 주문 없음 |
+| 옵션 가격 변경 후 병합 실패 | 6 | 기존 보조옵션 가격 1,000원을 현재 가격 1,500원으로 변경하고 재추가 시, 오류가 발생해도 모든 기존 행의 수량·가격이 그대로 유지됨 |
+
+총 46개 시나리오 조합이다. 수정 후 24개 조합은 각각 조작 요청과 정상 구매를 함께 검증했다. 기존 단위/모의 테스트 71개와 서로 다른 검증이며 수를 혼합하지 않는다.
+
+- 수정 전 8개: DB 엔진 2 × PC/모바일 2 × 공통/기본 테마 AJAX 2. 비회원 조건.
+- 수정 후 24개: DB 엔진 2 × 회원/비회원 2 × PC/모바일 2 × 공통 AJAX/기본 테마 AJAX/일반 추가 3.
+- 무료상품·기존 조작 장바구니: 각각 DB 엔진 2 × PC/모바일 2.
+- 병합 실패: DB 엔진 2 × 장바구니 추가 경로 3.
+
+HTTP 서버 로그에서 PHP Fatal error는 없었다. 최초 탐색 요청에 `sw_direct` 필드를 생략했을 때 수정 후 AJAX 파일에서 해당 변수 미정의 Warning 4건이 기록되었다. 이후 명시적으로 `sw_direct=0`을 전송한 시험 요청에서는 추가 Warning이 발생하지 않았다. 이 경고는 이번 작업에서 수정하지 않았다.
+
+## 판정과 한계
+
+**신고된 핵심 0원 주문 문제는 실제 설치·HTTP·MySQL·최종 주문 저장까지 수정 전 재현과 수정 후 차단을 확인했다.** 후속 P2인 장바구니 부분 병합 문제도 실제 MySQL 환경에서 기존 행 보존을 확인했다.
+
+이번 검증이 실제로 확장한 범위는 전체 애플리케이션 부트스트랩, 일반 회원 로그인, 세션, HTTP 요청, 실제 MySQL SQL, 무통장 주문 저장이다. 기존 CLI/SQLite/모의 PG 검증만 반복한 결과가 아니다.
+
+다음 항목은 여전히 미검증이다.
+
+- 실제 PG 테스트 상점을 이용한 승인·취소 및 실패 복구. 따라서 후속 P1의 실제 외부 취소 성공은 이전 모의 검증 수준이며, 이번 결과로 실 PG 검증 완료라고 표현하지 않는다.
+- 브라우저 JavaScript·버튼·렌더링, 커스텀 테마와 다른 PHP/OS/웹서버 환경.
+- 쿠폰·포인트·유료 배송비·과세/면세 조합의 전체 정산.
+- 동시 요청·상품 변경 경쟁·DB 장애·통신 장애와 재시도.
+- 전체 영향 버전, 배포 버전·일정, 운영 배포, KISA 제출.
+
+따라서 이슈 #42의 전체 완료 조건 충족이나 운영 배포 승인으로 간주하지 않는다. 실제 PG 검증은 테스트 상점 정보와 테스트 결제 절차가 필요하다.
+
+이번 검증에서는 저장소 코드를 변경하지 않았다. 테스트 서버는 검증 후 종료하고 시험 결과와 로컬 테스트 자료만 보존한다.
diff --git a/tests/reports/KVE-2026-2345-integration-results.json b/tests/reports/KVE-2026-2345-integration-results.json
new file mode 100644
index 000000000..97784c3d4
--- /dev/null
+++ b/tests/reports/KVE-2026-2345-integration-results.json
@@ -0,0 +1,374 @@
+{
+ "MyISAM": [
+ {
+ "engine": "MyISAM",
+ "member": false,
+ "mobile": false,
+ "route": "shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": false,
+ "mobile": false,
+ "route": "theme/basic/shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": false,
+ "mobile": false,
+ "route": "shop/cartupdate.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": false,
+ "mobile": true,
+ "route": "shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": false,
+ "mobile": true,
+ "route": "theme/basic/shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": false,
+ "mobile": true,
+ "route": "shop/cartupdate.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": true,
+ "mobile": false,
+ "route": "shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": true,
+ "mobile": false,
+ "route": "theme/basic/shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": true,
+ "mobile": false,
+ "route": "shop/cartupdate.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": true,
+ "mobile": true,
+ "route": "shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": true,
+ "mobile": true,
+ "route": "theme/basic/shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "MyISAM",
+ "member": true,
+ "mobile": true,
+ "route": "shop/cartupdate.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ }
+ ],
+ "InnoDB": [
+ {
+ "engine": "InnoDB",
+ "member": false,
+ "mobile": false,
+ "route": "shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": false,
+ "mobile": false,
+ "route": "theme/basic/shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": false,
+ "mobile": false,
+ "route": "shop/cartupdate.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": false,
+ "mobile": true,
+ "route": "shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": false,
+ "mobile": true,
+ "route": "theme/basic/shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": false,
+ "mobile": true,
+ "route": "shop/cartupdate.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": true,
+ "mobile": false,
+ "route": "shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": true,
+ "mobile": false,
+ "route": "theme/basic/shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": true,
+ "mobile": false,
+ "route": "shop/cartupdate.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": true,
+ "mobile": true,
+ "route": "shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": true,
+ "mobile": true,
+ "route": "theme/basic/shop/ajax.action.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ },
+ {
+ "engine": "InnoDB",
+ "member": true,
+ "mobile": true,
+ "route": "shop/cartupdate.php",
+ "attack": "blocked",
+ "normal_order": "saved",
+ "amount": 50000
+ }
+ ],
+ "before": [
+ {
+ "engine": "MyISAM",
+ "mobile": false,
+ "route": "shop/ajax.action.php",
+ "status": "입금",
+ "cart_price": 0,
+ "paid": 0
+ },
+ {
+ "engine": "MyISAM",
+ "mobile": false,
+ "route": "theme/basic/shop/ajax.action.php",
+ "status": "입금",
+ "cart_price": 0,
+ "paid": 0
+ },
+ {
+ "engine": "MyISAM",
+ "mobile": true,
+ "route": "shop/ajax.action.php",
+ "status": "입금",
+ "cart_price": 0,
+ "paid": 0
+ },
+ {
+ "engine": "MyISAM",
+ "mobile": true,
+ "route": "theme/basic/shop/ajax.action.php",
+ "status": "입금",
+ "cart_price": 0,
+ "paid": 0
+ },
+ {
+ "engine": "InnoDB",
+ "mobile": false,
+ "route": "shop/ajax.action.php",
+ "status": "입금",
+ "cart_price": 0,
+ "paid": 0
+ },
+ {
+ "engine": "InnoDB",
+ "mobile": false,
+ "route": "theme/basic/shop/ajax.action.php",
+ "status": "입금",
+ "cart_price": 0,
+ "paid": 0
+ },
+ {
+ "engine": "InnoDB",
+ "mobile": true,
+ "route": "shop/ajax.action.php",
+ "status": "입금",
+ "cart_price": 0,
+ "paid": 0
+ },
+ {
+ "engine": "InnoDB",
+ "mobile": true,
+ "route": "theme/basic/shop/ajax.action.php",
+ "status": "입금",
+ "cart_price": 0,
+ "paid": 0
+ }
+ ],
+ "extra": [
+ {
+ "engine": "MyISAM",
+ "mobile": false,
+ "case": "legitimate_free_order",
+ "result": "saved"
+ },
+ {
+ "engine": "MyISAM",
+ "mobile": false,
+ "case": "prepatch_corrupt_cart",
+ "result": "preparation_and_submission_blocked"
+ },
+ {
+ "engine": "MyISAM",
+ "mobile": true,
+ "case": "legitimate_free_order",
+ "result": "saved"
+ },
+ {
+ "engine": "MyISAM",
+ "mobile": true,
+ "case": "prepatch_corrupt_cart",
+ "result": "preparation_and_submission_blocked"
+ },
+ {
+ "engine": "MyISAM",
+ "route": "shop/ajax.action.php",
+ "case": "stale_addon_merge",
+ "result": "all_rows_unchanged"
+ },
+ {
+ "engine": "MyISAM",
+ "route": "theme/basic/shop/ajax.action.php",
+ "case": "stale_addon_merge",
+ "result": "all_rows_unchanged"
+ },
+ {
+ "engine": "MyISAM",
+ "route": "shop/cartupdate.php",
+ "case": "stale_addon_merge",
+ "result": "all_rows_unchanged"
+ },
+ {
+ "engine": "InnoDB",
+ "mobile": false,
+ "case": "legitimate_free_order",
+ "result": "saved"
+ },
+ {
+ "engine": "InnoDB",
+ "mobile": false,
+ "case": "prepatch_corrupt_cart",
+ "result": "preparation_and_submission_blocked"
+ },
+ {
+ "engine": "InnoDB",
+ "mobile": true,
+ "case": "legitimate_free_order",
+ "result": "saved"
+ },
+ {
+ "engine": "InnoDB",
+ "mobile": true,
+ "case": "prepatch_corrupt_cart",
+ "result": "preparation_and_submission_blocked"
+ },
+ {
+ "engine": "InnoDB",
+ "route": "shop/ajax.action.php",
+ "case": "stale_addon_merge",
+ "result": "all_rows_unchanged"
+ },
+ {
+ "engine": "InnoDB",
+ "route": "theme/basic/shop/ajax.action.php",
+ "case": "stale_addon_merge",
+ "result": "all_rows_unchanged"
+ },
+ {
+ "engine": "InnoDB",
+ "route": "shop/cartupdate.php",
+ "case": "stale_addon_merge",
+ "result": "all_rows_unchanged"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/reports/kve-2026-2346-migration-fixed.json b/tests/reports/kve-2026-2346-migration-fixed.json
new file mode 100644
index 000000000..beca3b013
--- /dev/null
+++ b/tests/reports/kve-2026-2346-migration-fixed.json
@@ -0,0 +1,10 @@
+{
+ "upgrade": "passed_with_original_CRLF_SQL",
+ "rerun": "passed",
+ "real_http_orders_preserved": 2,
+ "admin_login": "real",
+ "csrf_token": "real",
+ "historical_site_codes": "empty",
+ "notification_schema": "matches_fresh_install",
+ "sql_files_modified": false
+}
\ No newline at end of file
diff --git a/tests/shop_cart_followup.py b/tests/shop_cart_followup.py
new file mode 100644
index 000000000..64d769394
--- /dev/null
+++ b/tests/shop_cart_followup.py
@@ -0,0 +1,180 @@
+"""실제 처리 파일의 순차 SQL/승인 경로 회귀검증. 외부 PG는 모의한다.
+실행: python tests/shop_cart_followup.py /path/to/php
+PHP pdo_sqlite가 필요하며 임시 SQLite DB만 생성한다.
+"""
+import ast, json, re, sqlite3, subprocess, sys, tempfile, atexit
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+temporary = tempfile.TemporaryDirectory(prefix='cart-followup-')
+atexit.register(temporary.cleanup)
+OUT = Path(temporary.name)
+PHP = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path('php')
+PHP_ARGS = sys.argv[2:]
+tree = ast.parse((ROOT / 'tests/shop_cart_http.py').read_text(encoding='utf-8'))
+bootstrap = next(n.value.value for n in ast.walk(tree) if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'bootstrap' for t in n.targets))
+start = bootstrap.index("$link = new mysqli")
+end = bootstrap.index('function safe_replace_regex', start)
+bootstrap = bootstrap[:start] + '''$link = new PDO('sqlite:'.__DIR__.'/fixture.sqlite');
+$link->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+function sql_query($sql, $error=true) { global $link; return $link->query($sql); }
+function sql_fetch_array($result) { return $result->fetch(PDO::FETCH_ASSOC); }
+function sql_fetch($sql) { return sql_fetch_array(sql_query($sql)) ?: array(); }
+function sql_escape_string($s) { return str_replace("'", "''", (string)$s); }
+function sql_real_escape_string($s) { return sql_escape_string($s); }
+''' + bootstrap[end:]
+bootstrap = bootstrap.replace('require TEST_VALIDATOR;', 'require '+json.dumps(str(ROOT / 'lib/shop.cartvalidate.lib.php'))+';')
+bootstrap += '''
+$_SESSION['ss_cart_id'] = '123456789';
+define('G5_MSHOP_PATH', __DIR__);
+define('G5_SHOP_PATH', __DIR__);
+define('G5_HTTPS_MSHOP_URL', '/mobile/shop');
+$g5['g5_shop_inicis_log_table'] = 'g5_shop_inicis_log';
+$default['de_inicis_mid'] = 'test-mid';
+function get_shop_order_data($id) { return array(); }
+if (getenv('CASE') === 'approved') {
+ $default['de_pg_service'] = 'inicis';
+ $od_settle_case = '신용카드';
+ $_POST['P_HASH'] = 'simulated-verified-approval';
+ $_SESSION['P_TID'] = 'simulated-approved-tid';
+ $_SESSION['P_AMT'] = '50000';
+ $_SESSION['P_HASH'] = $_POST['P_HASH'];
+} else {
+ $_POST = array('action'=>'cart_update', 'it_id'=>array('item'),
+ 'io_id'=>array('item'=>array('', 'wrap')), 'io_type'=>array('item'=>array('0','1')),
+ 'ct_qty'=>array('item'=>array('1','1')));
+ $_REQUEST = $_POST;
+}
+'''
+bootstrap = bootstrap.replace("getenv('CASE') === 'approved'", "in_array(getenv('CASE'), array('approved', 'callback'))")
+bootstrap += '''
+if (getenv('SCENARIO') === 'direct') $sw_direct = $_POST['sw_direct'] = $_REQUEST['sw_direct'] = 1;
+if (getenv('SCENARIO') === 'replace') $_POST['act'] = 'optionmod';
+if (in_array(getenv('SCENARIO'), array('multi','cert'))) {
+ $_POST['it_id'][] = 'other';
+ $_POST['io_id']['other'] = array('');
+ $_POST['io_type']['other'] = array('0');
+ $_POST['ct_qty']['other'] = array('1');
+}
+if (getenv('SCENARIO') === 'cert') $is_admin = false;
+function shop_member_cert_check($id,$type) { return $id === 'other' ? '구매 자격 확인 필요' : ''; }
+if (getenv('CASE') === 'callback') {
+ $_SESSION['ss_order_id'] = '1001';
+ $g5['g5_shop_order_data_table'] = 'order_data';
+ $default['de_inicis_mid'] = 'test-mid';
+ $_REQUEST = array('P_NOTI'=>'test-order','P_REQ_URL'=>'https://example.invalid/approval','P_STATUS'=>'00','P_TID'=>'test-tid');
+ function is_inicis_url_return($s) { return $s; }
+ function iconv_utf8($s) { return $s; }
+ function get_search_string($s) { return $s; }
+ foreach (array('CURLOPT_PORT','CURLOPT_URL','CURLOPT_POST','CURLOPT_POSTFIELDS','CURLOPT_RETURNTRANSFER') as $i=>$c) define($c,$i);
+ function curl_init() { return true; }
+ function curl_setopt($h,$k,$v) { return true; }
+ function curl_exec($h) {
+ global $link;
+ $GLOBALS['review_pg_approved'] = true;
+ if (getenv('SCENARIO') === 'deleted')
+ $link->exec('DELETE FROM g5_shop_cart');
+ else if (getenv('SCENARIO') !== 'unchanged')
+ $link->exec('UPDATE g5_shop_item SET it_price=50000');
+ return 'P_STATUS=00&P_TID=test-tid&P_MID=test-mid&P_AMT=40000&P_TYPE=CARD';
+ }
+ $link->exec('DROP TABLE IF EXISTS order_data');
+ $link->exec('CREATE TABLE order_data (od_id TEXT, dt_data TEXT)');
+ $stmt = $link->prepare('INSERT INTO order_data VALUES (?,?)');
+ $stmt->execute(array('test-order',base64_encode(serialize(array('od_settle_case'=>'신용카드')))));
+}
+'''
+bootstrap = bootstrap.replace("array('error'=>$message)", "array('error'=>$message, 'simulated_pg_approved'=>!empty($GLOBALS['review_pg_approved']), 'canceled_tid'=>isset($GLOBALS['canceled_tid']) ? $GLOBALS['canceled_tid'] : '', 'session_tid'=>get_session('P_TID'))")
+(OUT/'bootstrap.php').write_text(bootstrap, encoding='utf-8')
+(OUT/'mailer.lib.php').write_text('getenv('SCENARIO') === 'cancel_fail' ? '99' : '00'));
+}
+''', encoding='utf-8')
+for name, source in [('ajax','shop/ajax.action.php'),('theme','theme/basic/shop/ajax.action.php'),('normal','shop/cartupdate.php'),('approved','mobile/shop/orderformupdate.php')]:
+ text = (ROOT/source).read_text(encoding='utf-8').replace("include_once('./_common.php');", "include_once('./bootstrap.php');", 1)
+ if name=='approved':
+ text=text.split('// 변수 초기화',1)[0]+"echo json_encode(array('error'=>'', 'checkpoint'=>'validated', 'simulated_pg_approved'=>!empty($GLOBALS['review_pg_approved']), 'canceled_tid'=>isset($GLOBALS['canceled_tid']) ? $GLOBALS['canceled_tid'] : '')); exit;"
+ (OUT/(name+'.php')).write_text(text, encoding='utf-8')
+(OUT/'orderformupdate.php').write_text((OUT/'approved.php').read_text(encoding='utf-8'), encoding='utf-8')
+callback=(ROOT/'mobile/shop/inicis/pay_approval.php').read_text(encoding='utf-8').replace("include_once('./_common.php');", "include_once('./bootstrap.php');",1).replace("include_once(G5_MSHOP_PATH.'/settle_inicis.inc.php');", "// PG settings supplied by isolated bootstrap")
+(OUT/'callback.php').write_text(callback,encoding='utf-8')
+
+def reset(approved=False):
+ db = sqlite3.connect(OUT/'fixture.sqlite')
+ schema = (ROOT/'install/gnuboard5shop.sql').read_text(encoding='utf-8')
+ for table in ['item','item_option','cart','inicis_log']:
+ db.execute('DROP TABLE IF EXISTS g5_shop_'+table)
+ block = re.search(r'CREATE TABLE IF NOT EXISTS `g5_shop_'+table+r'` \((.*?);',schema,re.S).group(1)
+ # Source column names/defaults, SQLite storage for deterministic sequential SQL.
+ cols=[]
+ for line in block.splitlines():
+ m = re.match(r'\s*`([^`]+)`\s+(.+)',line)
+ if not m: continue
+ col, decl = m.groups()
+ typ = 'INTEGER' if re.match(r'(tinyint|smallint|int|bigint)',decl) else 'TEXT'
+ default = re.search(r"DEFAULT ('[^']*'|\d+)",decl,re.I)
+ cols.append('"'+col+'" '+typ+' DEFAULT '+(default.group(1) if default else ("0" if typ=='INTEGER' else "''")))
+ db.execute('CREATE TABLE g5_shop_'+table+' ('+','.join(cols)+')')
+ db.execute("INSERT INTO g5_shop_item (it_id,it_name,it_use,it_price,it_stock_qty,it_sc_type) VALUES ('item','item',1,50000,100,1)")
+ db.execute("INSERT INTO g5_shop_cart (ct_id,od_id,it_id,ct_status,ct_price,io_id,io_type,io_price,ct_qty,ct_select) VALUES (1,'123456789','item','쇼핑',?,'',0,0,1,1)",(40000 if approved else 50000,))
+ if not approved:
+ db.execute("INSERT INTO g5_shop_item_option (it_id,io_id,io_type,io_use,io_price,io_stock_qty) VALUES ('item','wrap',1,1,1000,100)")
+ db.execute("INSERT INTO g5_shop_cart (ct_id,od_id,it_id,ct_status,ct_price,io_id,io_type,io_price,ct_qty,ct_select) VALUES (2,'123456789','item','쇼핑',50000,'wrap',1,900,1,1)")
+ db.commit()
+ return db
+
+import os
+results=[]
+cases=[(r,s) for r in ['ajax','theme','normal'] for s in ['stale','stock','maximum','multi','valid']]
+cases += [('normal','direct'),('normal','replace'),('normal','cert'),('approved','forged')]
+cases += [('callback',s) for s in ['before','during','cancel_fail','deleted','existing_log','unchanged']]
+for route, scenario in cases:
+ db=reset(route in ['approved','callback'])
+ atexit.register(db.close)
+ if scenario == 'valid' or scenario in ['stock','maximum','multi','direct','cert']:
+ db.execute('UPDATE g5_shop_cart SET io_price=1000 WHERE io_type=1')
+ if scenario == 'stock': db.execute('UPDATE g5_shop_item_option SET io_stock_qty=1')
+ if scenario == 'maximum': db.execute('UPDATE g5_shop_item SET it_buy_max_qty=1')
+ if scenario in ['multi','cert']:
+ db.execute("INSERT INTO g5_shop_item (it_id,it_name,it_use,it_price,it_stock_qty) VALUES ('other','other',1,30000,100)")
+ db.execute("INSERT INTO g5_shop_cart (ct_id,od_id,it_id,ct_status,ct_price,io_id,io_type,io_price,ct_qty) VALUES (3,'123456789','other','쇼핑',20000,'',0,0,1)")
+ if scenario=='cert': db.execute("UPDATE g5_shop_cart SET ct_price=30000 WHERE it_id='other'")
+ if scenario == 'direct':
+ db.execute('UPDATE g5_shop_cart SET ct_direct=1')
+ db.execute("INSERT INTO g5_shop_cart (ct_id,od_id,it_id,ct_status,ct_price,io_id,io_type,io_price,ct_qty,ct_select) VALUES (3,'999','item','쇼핑',50000,'',0,0,100,1)")
+ if route == 'callback' and scenario != 'before': db.execute('UPDATE g5_shop_item SET it_price=40000')
+ if scenario=='existing_log': db.execute("INSERT INTO g5_shop_inicis_log (oid,P_TID,P_STATUS) VALUES (1001,'test-tid','00')")
+ db.commit()
+ before=db.execute('SELECT ct_id,ct_qty FROM g5_shop_cart ORDER BY ct_id').fetchall()
+ p=subprocess.run([str(PHP)]+PHP_ARGS+[str(OUT/(route+'.php'))],cwd=OUT,env=dict(os.environ,CASE=route,SCENARIO=scenario),capture_output=True,text=True,encoding='utf-8')
+ after=db.execute('SELECT ct_id,ct_qty FROM g5_shop_cart ORDER BY ct_id').fetchall()
+ assert p.returncode == 0, p.stderr
+ response=json.loads(p.stdout)
+ assert 'PHP Warning' not in p.stderr and 'PHP Fatal' not in p.stderr, p.stderr
+ if scenario=='unchanged':
+ assert response['error']=='' and response['checkpoint']=='validated', response
+ elif scenario in ['valid','replace']:
+ assert response['error'] == '', (route,scenario,response)
+ quantities=db.execute('SELECT io_type,SUM(ct_qty) FROM g5_shop_cart GROUP BY io_type ORDER BY io_type').fetchall()
+ assert quantities == [(0,2),(1,2)] if scenario=='valid' else quantities==[(0,1),(1,1)], quantities
+ else:
+ assert response['error'], (route,scenario,response)
+ if scenario != 'deleted': assert before==after, (route,scenario,before,after)
+ if route == 'callback':
+ assert response['simulated_pg_approved'] == (scenario!='before'), response
+ assert response['canceled_tid'] == ('' if scenario in ['before','unchanged'] else 'test-tid'), response
+ if scenario not in ['before','unchanged']:
+ status=db.execute('SELECT P_STATUS FROM g5_shop_inicis_log').fetchone()[0]
+ assert status==('cancel_failed' if scenario=='cancel_fail' else 'cancel'), status
+ assert response['session_tid']==('test-tid' if scenario=='cancel_fail' else ''), response
+ if scenario=='forged': assert response['canceled_tid']=='', response
+ results.append(route+'/'+scenario)
+ db.close()
+print(str(len(results))+'개 후속 회귀 검증 통과 (SQLite, 모의 PG): '+', '.join(results))
diff --git a/tests/shop_cart_http.py b/tests/shop_cart_http.py
new file mode 100644
index 000000000..84e3172de
--- /dev/null
+++ b/tests/shop_cart_http.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""격리 MySQL 전용: python3 tests/shop_cart_http.py /path/to/test-mysql.sock
+
+고유 테스트 DB를 생성·삭제한다. 실제 장바구니 처리 파일을 HTTP로 실행하며,
+주문서는 PG/메일/주문 저장을 실행하기 전 검증 경계까지만 실행한다.
+인증·세션·메일·리다이렉트 등 주변 기능은 아래 테스트 부트스트랩으로 대체한다.
+"""
+import http.cookiejar
+import json
+from pathlib import Path
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.parse
+import urllib.request
+
+ROOT = Path(__file__).resolve().parents[1]
+SOCKET = sys.argv[1]
+DB = 'kve2345_test_' + str(time.time_ns())
+
+def mysql(sql, database=True):
+ command = ['mysql', '--no-defaults', '--socket=' + SOCKET, '-u', 'root', '-N', '-B']
+ if database:
+ command.append(DB)
+ return subprocess.check_output(command, input="SET sql_mode='';" + sql, text=True).strip()
+
+mysql('CREATE DATABASE `' + DB + '`', False)
+server = None
+try:
+ import re
+ schema = (ROOT / 'install/gnuboard5shop.sql').read_text()
+ for table in ['item', 'item_option', 'cart']:
+ statement = re.search(r'CREATE TABLE IF NOT EXISTS `g5_shop_' + table + r'` \(.*?;', schema, re.S).group()
+ mysql("SET sql_mode='';" + statement)
+ mysql("SET sql_mode=''; INSERT INTO g5_shop_item (it_id,it_name,it_use,it_price,it_stock_qty,it_sc_type) VALUES ('item','상품',1,50000,100,1),('other','다른 상품',1,30000,100,1)")
+ with tempfile.TemporaryDirectory(prefix='kve2345-http-') as temp:
+ directory = Path(temp)
+ bootstrap = r''''g5_shop_item', 'g5_shop_item_option_table'=>'g5_shop_item_option', 'g5_shop_cart_table'=>'g5_shop_cart');
+$member = array('mb_id'=>'', 'mb_level'=>1);
+$default = array('de_level_sell'=>1, 'de_pg_service'=>'kcp');
+$config = array('cf_use_point'=>0);
+$is_admin = true; $is_member = false;
+$sw_direct = !empty($_POST['sw_direct']) ? 1 : 0;
+$od_settle_case = '무통장';
+$link = new mysqli('localhost','root','',TEST_DB,0,TEST_SOCKET);
+$link->set_charset('utf8');
+$link->query("SET sql_mode=''");
+function sql_query($sql, $error=true) { global $link; return $link->query($sql); }
+function sql_fetch_array($result) { return $result->fetch_assoc(); }
+function sql_fetch($sql) { return sql_fetch_array(sql_query($sql)) ?: array(); }
+function sql_escape_string($s) { global $link; return $link->real_escape_string($s); }
+function sql_real_escape_string($s) { return sql_escape_string($s); }
+function safe_replace_regex($s,$type) { return preg_replace('/[^a-z0-9_\-]/i','',$s); }
+function clean_xss_tags($s) { return strip_tags($s); }
+function get_real_client_ip() { return '127.0.0.1'; }
+function set_cart_id($direct) { if(empty($_SESSION['ss_cart_id'])) $_SESSION['ss_cart_id'] = (string)random_int(100000000,999999999); $_SESSION['ss_cart_direct'] = $_SESSION['ss_cart_id']; }
+function get_session($key) { return isset($_SESSION[$key]) ? $_SESSION[$key] : ''; }
+function set_session($key,$value) { $_SESSION[$key]=$value; }
+function cart_item_clean() {}
+function is_inicis_order_pay($method) { return false; }
+function check_request_origin($url) {}
+function get_cart_count($id) { $r=sql_fetch("SELECT COUNT(*) n FROM g5_shop_cart WHERE od_id='".sql_escape_string($id)."'"); return $r['n']; }
+function get_it_stock_qty($id) { $r=sql_fetch("SELECT it_stock_qty FROM g5_shop_item WHERE it_id='".sql_escape_string($id)."'"); return $r['it_stock_qty']; }
+function get_option_stock_qty($id,$option,$type) { $r=sql_fetch("SELECT io_stock_qty FROM g5_shop_item_option WHERE it_id='".sql_escape_string($id)."' AND io_id='".sql_escape_string($option)."' AND io_type='".(int)$type."'"); return $r['io_stock_qty']; }
+function alert($message,$url='') { die(json_encode(array('error'=>$message))); }
+function goto_url($url) { die(json_encode(array('error'=>'','redirect'=>$url))); }
+require TEST_VALIDATOR;
+'''
+ constants = '\n'
+ (directory / 'bootstrap.php').write_text(constants + bootstrap)
+ (directory / 'mailer.lib.php').write_text(''','checkpoint'=>'validated')); exit;"
+ (directory / (route + '.php')).write_text(text)
+ with socket.socket() as sock:
+ sock.bind(('127.0.0.1', 0))
+ port = sock.getsockname()[1]
+ with (directory / 'server.log').open('w+') as log:
+ server = subprocess.Popen(['php', '-S', '127.0.0.1:' + str(port), '-t', temp], stdout=log, stderr=log, cwd=temp)
+ for attempt in range(100):
+ try:
+ with socket.create_connection(('127.0.0.1', port), timeout=.1):
+ break
+ except OSError:
+ time.sleep(.05)
+ client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
+ count = 0
+ def request(route, fields):
+ data = urllib.parse.urlencode(fields).encode()
+ try:
+ with client.open('http://127.0.0.1:' + str(port) + '/' + route + '.php', data) as response:
+ return json.loads(response.read())
+ except Exception:
+ print((directory / 'server.log').read_text(), file=sys.stderr)
+ raise
+ def payload(types=('0',), ids=('',)):
+ data = [('action','cart_update'),('it_id[0]','item')]
+ for i, (kind, identity) in enumerate(zip(types, ids)):
+ data += [('io_type[item]['+str(i)+']',kind),('io_id[item]['+str(i)+']',identity),('ct_qty[item]['+str(i)+']','1')]
+ return data
+ def expect(value, description):
+ if not value:
+ raise AssertionError(description)
+ for engine in ['MyISAM','InnoDB']:
+ for table in ['item','item_option','cart']:
+ mysql('ALTER TABLE g5_shop_' + table + ' ENGINE=' + engine)
+ for route in ['ajax','theme','normal']:
+ mysql('DELETE FROM g5_shop_cart; DELETE FROM g5_shop_item_option')
+ for bad in [payload(('1',)), payload(('0','1'),('','')), payload(('01',)), payload(('1x',)), payload(('0',),('missing',))]:
+ result = request(route,bad)
+ expect(result['error'] != '' and mysql('SELECT COUNT(*) FROM g5_shop_cart') == '0', route+' 잘못된 입력')
+ count += 1
+ malformed = [
+ [('action','cart_update'),('it_id[0]','item'),('io_id[item][0]',''),('io_type[item][0][nested]','0'),('ct_qty[item][0]','1')],
+ [('action','cart_update'),('it_id[0]','item'),('io_id[item][0][nested]',''),('io_type[item][0]','0'),('ct_qty[item][0]','1')],
+ [('action','cart_update'),('it_id[0]','item'),('io_id[item][0]',''),('io_type[item][0]','0'),('ct_qty[item][1]','1')],
+ [('action','cart_update'),('it_id[0]','item'),('io_id','bad'),('io_type[item][0]','0'),('ct_qty[item][0]','1')],
+ payload()+[('ct_qty[item][0]','1.5')],
+ payload()+[('io_value[item][0][nested]','bad')],
+ ]
+ for bad in malformed:
+ expect(request(route,bad)['error'] != '' and mysql('SELECT COUNT(*) FROM g5_shop_cart') == '0', '배열 구조 및 수량 검증')
+ count += 1
+ expect(request(route,payload())['error'] == '',route+' 정상 추가')
+ expect(mysql('SELECT SUM(IF(io_type=1,io_price*ct_qty,(ct_price+io_price)*ct_qty)) FROM g5_shop_cart') == '50000',route+' 정상 가격')
+ count += 1
+ # 옵션 변경/바로구매 실패가 기존 행을 삭제하면 안 된다.
+ before = mysql('SELECT ct_id,ct_qty FROM g5_shop_cart')
+ expect(request(route,payload(('1',))+[('sw_direct','1'),('act','optionmod')])['error'] != '', '잘못된 수정 거부')
+ expect(mysql('SELECT ct_id,ct_qty FROM g5_shop_cart') == before,'기존 장바구니 보존')
+ count += 1
+ bad_second = payload()+[('it_id[1]','other'),('io_type[other][0]','1'),('io_id[other][0]',''),('ct_qty[other][0]','1')]
+ expect(request(route,bad_second)['error'] != '', '다중 상품 검증')
+ expect(mysql('SELECT ct_id,ct_qty FROM g5_shop_cart') == before,'다중 요청 부분 변경 방지')
+ count += 1
+ expect(request(route,payload())['error'] == '', '정상 병합')
+ expect(mysql('SELECT COUNT(*),SUM(ct_qty) FROM g5_shop_cart') == '1\t2','수량 병합 결과')
+ count += 1
+ # 패치 전 변조 행이 정상이 되어 보이도록 병합되지 않는지 확인
+ mysql('UPDATE g5_shop_cart SET io_type=1,io_price=0,ct_select=1')
+ for order_route in ['pc_order','mobile_order']:
+ expect(request(order_route,[])['error'] != '', '과거 조작 주문 거부')
+ count += 1
+ mysql('UPDATE g5_shop_cart SET io_type=0')
+ for order_route in ['pc_order','mobile_order']:
+ expect(request(order_route,[]) .get('checkpoint') == 'validated','정상 주문 검증 진입')
+ count += 1
+ mysql("DELETE FROM g5_shop_cart; INSERT INTO g5_shop_item_option (it_id,io_id,io_type,io_use,io_price,io_stock_qty) VALUES ('item','wrap',1,1,1000,100)")
+ expect(request(route,payload(('0','1'),('','wrap')))['error'] == '', '정상 보조옵션')
+ expect(mysql('SELECT SUM(IF(io_type=1,io_price*ct_qty,(ct_price+io_price)*ct_qty)) FROM g5_shop_cart') == '51000','보조옵션 포함 합계')
+ count += 1
+ # 타입은 다르지만 ID가 같은 옵션을 서로 병합하지 않는다.
+ mysql("DELETE FROM g5_shop_cart; DELETE FROM g5_shop_item_option; INSERT INTO g5_shop_item_option (it_id,io_id,io_type,io_use,io_price,io_stock_qty) VALUES ('item','same',0,1,2000,100),('item','same',1,1,1000,100)")
+ pair = payload(('0','1'),('same','same'))
+ expect(request(route,pair)['error'] == '', '동일 ID 다른 종류 추가')
+ expect(request(route,pair)['error'] == '', '동일 ID 다른 종류 병합')
+ expect(mysql('SELECT COUNT(*),SUM(IF(io_type=1,io_price*ct_qty,(ct_price+io_price)*ct_qty)) FROM g5_shop_cart') == '2\t106000', '서로 다른 종류 병합 방지')
+ count += 1
+ server.terminate(); server.wait(); server = None
+ log.seek(0); errors = log.read()
+ expect('PHP Warning' not in errors and 'PHP Fatal' not in errors,errors)
+ print(str(count)+'개 HTTP/DB 검증 통과 (MyISAM·InnoDB, 주문은 검증 경계까지)')
+finally:
+ if server:
+ server.terminate(); server.wait()
+ mysql('DROP DATABASE `' + DB + '`', False)
diff --git a/tests/shop_cart_validation.php b/tests/shop_cart_validation.php
new file mode 100644
index 000000000..66fbc9467
--- /dev/null
+++ b/tests/shop_cart_validation.php
@@ -0,0 +1,75 @@
+ $id, 'io_type' => $type, 'ct_qty' => $qty);
+}
+$item = array('it_id' => 'item', 'it_use' => 1, 'it_tel_inq' => 0, 'it_price' => 50000, 'it_stock_qty' => 100, 'it_buy_min_qty' => 0, 'it_buy_max_qty' => 0);
+$base = test_row();
+$addon = array('it_id' => 'item', 'io_id' => 'wrap', 'io_type' => '1', 'io_use' => 1, 'io_price' => 1000, 'io_stock_qty' => 100);
+$selection = array('it_id' => 'item', 'io_id' => 'red', 'io_type' => '0', 'io_use' => 1, 'io_price' => 2000, 'io_stock_qty' => 100);
+check_cart('기본상품', $item, array(), array($base), 50000);
+check_cart('빈 보조옵션', $item, array(), array(test_row('', '1')), false);
+check_cart('정상 행 뒤 빈 보조옵션', $item, array(), array($base, test_row('', '1')), false);
+foreach (array('', '01', '11', '2', '-1', '1x', '1.0', true, array('1')) as $type)
+ check_cart('종류 형식', $item, array(), array(test_row('', $type)), false);
+foreach (array('0', '-1', '1.5', '1x', '2147483648', array(1), true) as $qty)
+ check_cart('수량 형식', $item, array(), array(test_row('', '0', $qty)), false);
+check_cart('수량 재고 초과', $item, array(), array(test_row('', '0', '101')), false);
+check_cart('동일 옵션 누적 재고', $item, array(), array(test_row('', '0', '60'), test_row('', '0', '60')), false);
+check_cart('정상 추가옵션', $item, array($addon), array($base, test_row('wrap', '1')), 51000);
+check_cart('첫 행 추가옵션', $item, array($addon), array(test_row('wrap', '1'), $base), false);
+check_cart('추가옵션 단독', $item, array($addon), array(test_row('wrap', '1')), false);
+check_cart('추가옵션 종류 불일치', $item, array($addon), array(test_row('wrap', '0')), false);
+check_cart('없는 옵션', $item, array(), array($base, test_row('wrap', '1')), false);
+$disabled = $addon; $disabled['io_use'] = 0;
+check_cart('비활성 옵션', $item, array($disabled), array($base, test_row('wrap', '1')), false);
+$foreign = $addon; $foreign['it_id'] = 'other';
+check_cart('타 상품 옵션', $item, array($foreign), array($base, test_row('wrap', '1')), false);
+check_cart('모호한 중복 옵션', $item, array($addon, $addon), array($base, test_row('wrap', '1')), false);
+check_cart('필수 선택옵션 누락', $item, array($selection), array($base), false);
+check_cart('정상 선택옵션', $item, array($selection), array(test_row('red')), 52000);
+$disabled = $selection; $disabled['io_use'] = 0;
+check_cart('모든 선택옵션 비활성', $item, array($disabled), array($base), false);
+$same_id = $addon; $same_id['io_id'] = 'red';
+check_cart('다른 종류의 동일 ID', $item, array($selection, $same_id), array(test_row('red'), test_row('red', '1')), 53000);
+$free = $item; $free['it_price'] = 0;
+check_cart('정상 무료상품', $free, array(), array($base), 0);
+$discount = $selection; $discount['io_price'] = -50000;
+check_cart('정상 0원 선택옵션', $item, array($discount), array(test_row('red')), 0);
+$discount['io_price'] = -50001;
+check_cart('음수 합계', $item, array($discount), array(test_row('red')), false);
+$negative = $addon; $negative['io_price'] = -1;
+check_cart('음수 추가옵션', $item, array($negative), array($base, test_row('wrap', '1')), false);
+$stored = $base; $stored['ct_price'] = '50000'; $stored['io_price'] = '0';
+check_cart('정상 저장 행', $item, array(), array($stored), 50000, true);
+$stored['io_type'] = '1';
+check_cart('과거 조작 행', $item, array(), array($stored), false, true);
+$stored['io_type'] = '0'; $stored['ct_price'] = 1;
+check_cart('상품가격 변경', $item, array(), array($stored), false, true);
+$stored['ct_price'] = 50000; $stored['io_price'] = 1;
+check_cart('옵션가격 불일치', $item, array(), array($stored), false, true);
+foreach (array('it_use' => 0, 'it_tel_inq' => 1, 'it_id' => '') as $key => $value) {
+ $invalid = $item; $invalid[$key] = $value;
+ check_cart('구매불가 상품', $invalid, array(), array($base), false);
+}
+$limited = $item; $limited['it_buy_min_qty'] = 2;
+check_cart('최소 수량', $limited, array(), array($base), false);
+$limited = $item; $limited['it_buy_max_qty'] = 1;
+check_cart('최대 수량', $limited, array(), array(test_row('', '0', '2')), false);
+echo $checks."개 검증 통과\n";
diff --git a/theme/basic/shop/ajax.action.php b/theme/basic/shop/ajax.action.php
index 0f5f37ff9..b287c6750 100644
--- a/theme/basic/shop/ajax.action.php
+++ b/theme/basic/shop/ajax.action.php
@@ -78,35 +78,24 @@ switch ($action) {
if ($count < 1)
die(json_encode(array('error' => '장바구니에 담을 상품을 선택하여 주십시오.')));
+ $cart_validation = shop_validate_cart_request($_POST);
+ if ($cart_validation['error'] !== '')
+ die(json_encode(array('error' => $cart_validation['error'])));
+
+ $cart_merge_error = shop_validate_cart_merge($tmp_cart_id, $cart_validation['products'], !empty($sw_direct));
+ if ($cart_merge_error !== '')
+ die(json_encode(array('error' => $cart_merge_error)));
+
$ct_count = 0;
for($i=0; $i<$count; $i++) {
$it_id = isset($_POST['it_id'][$i]) ? safe_replace_regex($_POST['it_id'][$i], 'it_id') : '';
$opt_count = (isset($_POST['io_id'][$it_id]) && is_array($_POST['io_id'][$it_id])) ? count($_POST['io_id'][$it_id]) : 0;
// 상품정보
- $it = get_shop_item($it_id, false);
+ $it = $cart_validation['products'][$it_id]['item'];
if(!$it['it_id'])
die(json_encode(array('error' => '상품정보가 존재하지 않습니다.')));
- // 옵션정보를 얻어서 배열에 저장
- $opt_list = array();
- $sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '$it_id' order by io_no asc ";
- $result = sql_query($sql);
- $lst_count = 0;
- for($k=0; $row=sql_fetch_array($result); $k++) {
- $opt_list[$row['io_type']][$row['io_id']]['id'] = $row['io_id'];
- $opt_list[$row['io_type']][$row['io_id']]['use'] = $row['io_use'];
- $opt_list[$row['io_type']][$row['io_id']]['price'] = $row['io_price'];
- $opt_list[$row['io_type']][$row['io_id']]['stock'] = $row['io_stock_qty'];
-
- // 선택옵션 개수
- if(!$row['io_type'])
- $lst_count++;
- }
-
- if($lst_count > 0 && !trim($_POST['io_id'][$it_id][$i]) && $_POST['io_type'][$it_id][$i] == 0)
- die(json_encode(array('error' => '상품의 선택옵션을 선택해 주십시오.')));
-
for($k=0; $k<$opt_count; $k++) {
$post_ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0;
if ($post_ct_qty < 1)
@@ -155,25 +144,17 @@ switch ($action) {
// 장바구니에 Insert
$comma = '';
+ $ct_count = 0;
$sql = " INSERT INTO {$g5['g5_shop_cart_table']}
( od_id, mb_id, it_id, it_name, it_sc_type, it_sc_method, it_sc_price, it_sc_minimum, it_sc_qty, ct_status, ct_price, ct_point, ct_point_use, ct_stock_use, ct_option, ct_qty, ct_notax, io_id, io_type, io_price, ct_time, ct_ip, ct_send_cost, ct_direct, ct_select, ct_select_time )
VALUES ";
for($k=0; $k<$opt_count; $k++) {
- $io_id = isset($_POST['io_id'][$it_id][$k]) ? preg_replace(G5_OPTION_ID_FILTER, '', $_POST['io_id'][$it_id][$k]) : '';
- $io_type = isset($_POST['io_type'][$it_id][$k]) ? preg_replace('#[^01]#', '', $_POST['io_type'][$it_id][$k]) : '';
+ $io_id = $cart_validation['products'][$it_id]['rows'][$k]['io_id'];
+ $io_type = $cart_validation['products'][$it_id]['rows'][$k]['io_type'];
$io_value = isset($_POST['io_value'][$it_id][$k]) ? $_POST['io_value'][$it_id][$k] : '';
- // 선택옵션정보가 존재하는데 선택된 옵션이 없으면 건너뜀
- if($lst_count && $io_id == '')
- continue;
-
- $opt_list_type_id_use = isset($opt_list[$io_type][$io_id]['use']) ? $opt_list[$io_type][$io_id]['use'] : '';
- // 구매할 수 없는 옵션은 건너뜀
- if($io_id && ! $opt_list_type_id_use)
- continue;
-
- $io_price = isset($opt_list[$io_type][$io_id]['price']) ? $opt_list[$io_type][$io_id]['price'] : 0;
+ $io_price = $cart_validation['products'][$it_id]['rows'][$k]['io_price'];
$ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0;
// 구매가격이 음수인지 체크
@@ -186,14 +167,17 @@ switch ($action) {
}
// 동일옵션의 상품이 있으면 수량 더함
- $sql2 = " select ct_id, io_type, ct_qty
+ $sql2 = " select ct_id, io_type, ct_qty, ct_price, io_price
from {$g5['g5_shop_cart_table']}
where od_id = '$tmp_cart_id'
and it_id = '$it_id'
and io_id = '$io_id'
+ and io_type = '$io_type'
and ct_status = '쇼핑' ";
$row2 = sql_fetch($sql2);
if(isset($row2['ct_id']) && $row2['ct_id']) {
+ if ((int) $row2['ct_price'] !== (int) $it['it_price'] || (int) $row2['io_price'] !== $io_price)
+ die(json_encode(array('error' => '기존 장바구니의 상품 정보가 변경되었습니다. 삭제한 뒤 다시 담아 주십시오.')));
// 재고체크
$tmp_ct_qty = $row2['ct_qty'];
if(!$io_id)
diff --git a/version.php b/version.php
index 59f29c5fc..a765648f0 100644
--- a/version.php
+++ b/version.php
@@ -2,7 +2,7 @@
if (!defined('_GNUBOARD_')) exit; // 개별 페이지 접근 불가
define('G5_VERSION', '그누보드5');
-define('G5_GNUBOARD_VER', '5.6.37');
+define('G5_GNUBOARD_VER', '5.6.38');
// 그누보드5.4.5.5 버전과 영카트5.4.5.5.1 버전을 합쳐서 그누보드5.4.6 버전에서 시작함 (kagla-210617)
// G5_YOUNGCART_VER 이 상수를 사용하는 곳이 있으므로 주석 처리 해제함
// 그누보드5.4.6 이상 버전 부터는 영카트를 그누보드에 포함하여 배포하므로 영카트5의 버전은 의미가 없습니다.