Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aadcf3e99 | ||
|
|
0d247c542c | ||
|
|
148d748c2d | ||
|
|
de7063192e | ||
|
|
e2d215c368 | ||
|
|
c4cd61c916 | ||
|
|
f7aa440a80 | ||
|
|
2517343d51 | ||
|
|
6dec001465 | ||
|
|
062b33dde3 | ||
|
|
97b5d7e122 | ||
|
|
40096220e2 | ||
|
|
7a80ae913d | ||
|
|
560aa0af47 | ||
|
|
6d4e75e1c9 |
@@ -73,6 +73,9 @@ cd gnuboard5
|
||||
|
||||
## 문서 및 지원
|
||||
|
||||
- [5.6.391 쇼핑몰 분류 표시 수정 안내](docs/release-5.6.391.md)
|
||||
- [5.6.39 보안 업데이트 및 필수 DB업그레이드 안내](docs/release-5.6.39.md)
|
||||
|
||||
- [5.6.37 SIRK 전용 카카오페이 연동 종료 및 기존 거래 지원](docs/sirk-kakaopay-retirement.md)
|
||||
- [그누보드5 공식 페이지](https://sir.kr/main/g5/)
|
||||
- [그누보드5 공식 매뉴얼](https://sir.kr/manuals/g5/1)
|
||||
|
||||
+5
-1
@@ -354,7 +354,11 @@ if( !function_exists('shop_check_is_pay_page') ){
|
||||
// PG 결제시에 세션이 없으면 내 호출페이지를 다시 호출하여 쿠키 PHPSESSID를 살려내어 세션값을 정상적으로 불러오게 합니다.
|
||||
// 위와 같이 코드를 전부 한페이지에 넣은 이유는 이전 버전 사용자들이 패치시 어려울수 있으므로 한페이지에 코드를 다 넣었습니다.
|
||||
if(XenoPostToForm::check()) {
|
||||
if ( shop_check_is_pay_page() ){ // PG 결제 리턴페이지에서만 사용
|
||||
// 토큰 복귀는 실제 엔드포인트에서 DB 해시를 검증한다. 여기서는 쿠키 재전송만 생략한다.
|
||||
$g5_order_token_return = isset($_REQUEST['g5_order_state']) && is_string($_REQUEST['g5_order_state']) &&
|
||||
preg_match('/\A[0-9]{1,20}\.[a-f0-9]{64}\z/D', $_REQUEST['g5_order_state']) &&
|
||||
preg_match('~/mobile/shop/kcp/order_approval_form\.php$~', str_replace('\\', '/', $_SERVER['SCRIPT_NAME']));
|
||||
if ( shop_check_is_pay_page() && !$g5_order_token_return ){ // PG 결제 리턴페이지에서만 사용
|
||||
XenoPostToForm::submit($_POST); // session_start(); 하기 전에
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
데이터베이스 스키마 변경은 설정·목록·상세 화면이나 공통 라이브러리의 일반 실행 경로에서 수행하지 않는다. 신규 설치의 최종 스키마는 `install/gnuboard5.sql`에 반영하고, 기존 설치 변경은 `migrations` 디렉터리의 버전형 SQL 파일로 관리한다.
|
||||
|
||||
## 신규 설치 이력
|
||||
|
||||
기존 테이블이 없는 신규 설치는 최신 스키마와 기본 데이터를 생성한 뒤, `data/dbconfig.php`를 만들기 전에 배포본의 모든 마이그레이션 ID·설명·체크섬을 성공 이력으로 등록한다. 적용 시각은 설치 시각이고 실행시간은 0이다. 과거 마이그레이션 SQL은 실행하지 않으며, 쇼핑몰 설치 선택 여부와 관계없이 해당 배포본을 기준선으로 기록한다. 설치 직후 DB 업그레이드에서 별도의 기존 상태 확인 이력 등록이 필요하지 않다.
|
||||
|
||||
마이그레이션 디렉터리가 비어 있거나 파일 읽기 실패 또는 이력 저장 실패 시 설치 완료를 중단한다. 해당 접두어의 기존 테이블이 있거나 기존 DB를 유지·재설치하는 경로는 일괄 완료로 기록하지 않는다. 남아 있는 쇼핑몰·SMS 등의 구버전 테이블을 완료로 오인하지 않도록 DB 업그레이드에서 실제 상태를 확인한다.
|
||||
|
||||
`php tests/migration_install_history.php /path/to/isolated/mysql.sock`은 root/빈 비밀번호인 격리 MySQL에 임시 DB를 생성하여 사용자 지정 접두어, 쇼핑몰 선택 여부, 설치 이력과 체크섬, 재실행 방지, 이력 저장 실패 및 잘못된 이력 테이블의 1054 오류와 실행 차단을 검증한다. 검사 종료 시 임시 DB를 삭제한다. `python3 tests/migration_install_flow.py /path/to/isolated/mysql.sock`은 임시 설치 디렉터리에서 실제 설치 코드를 실행하여 일반·쇼핑몰 신규 설치, 기존 테이블 보존과 마이그레이션 파일이 없을 때 설정 파일 생성 차단을 검증한다.
|
||||
|
||||
## 파일 규칙
|
||||
|
||||
- 파일명은 `YYYYMMDD_NNN_설명.sql` 형식을 사용한다.
|
||||
@@ -49,6 +57,8 @@
|
||||
|
||||
## 권한과 배포
|
||||
|
||||
`migrations/`는 업그레이드 완료 후에도 유지한다. Apache 2.4용 `migrations/.htaccess`를 함께 배포하며, Nginx는 별도의 서버 설정이 필요하다. 공유호스팅 적용 조건, 하위 경로 설치 예시와 개별 SQL 파일의 차단 확인 방법은 [마이그레이션 폴더 접근 차단](migration-directory-security.md)을 참고한다. HTTP 접근 차단 후에도 PHP의 서버 내부 파일 읽기는 허용해야 한다.
|
||||
|
||||
관리자 DB업그레이드 실행 시에는 DB 계정에 필요한 DDL 권한이 있어야 한다. 평상시 DDL 권한을 제거한 운영 환경에서는 업그레이드 시점에 필요한 권한을 부여하고, 완료 후 운영 권한으로 복원한다.
|
||||
|
||||
배포 전에는 백업과 테이블 크기, 예상 잠금 시간을 확인한다. MySQL 계열 DB의 DDL은 트랜잭션으로 완전히 되돌릴 수 없으므로 실패한 마이그레이션의 오류를 확인하고 수동 복구 후 재실행한다.
|
||||
@@ -56,3 +66,14 @@
|
||||
## 기존 업그레이드 코드
|
||||
|
||||
과거 설치본을 화면 접근 시점에 보정하던 누적 DDL은 일반 실행 파일에서 제거했다. `orderupgrade.php`와 `adm/dbupgrade.php`에 누적됐던 배포 후 변경은 실제 도입 시점별 마이그레이션으로 이전했다. 조사 근거와 대응 목록은 `database-migration-history.md`에 기록한다.
|
||||
|
||||
## 이력 테이블 구조 오류 복구
|
||||
|
||||
`Unknown column 'migration_id' in 'field list'`는 실행기가 사용하는 이력 테이블에 `migration_id` 컬럼이 없을 때 발생한다. 현재 설치 SQL에는 이 컬럼이 포함되어 있다. 기존의 `CREATE TABLE IF NOT EXISTS`는 이미 존재하는 테이블의 구조를 고치지 않으며, 과거 실행기는 이력 조회 실패를 빈 이력으로 취급하여 첫 이력 저장에서 오류가 드러날 수 있었다. 실제 발생 환경에서 구조가 달라진 경위는 테이블 정의와 설치 파일을 확인해야 한다.
|
||||
|
||||
조회와 실행 시 필수 컬럼 및 `migration_id` 단일 기본키를 확인한다. 구조 또는 이력 조회에 실패하면 오류를 표시하고 마이그레이션 실행과 이력 등록을 중단한다. 화면 조회에서는 테이블을 변경하지 않는다.
|
||||
|
||||
1. DB 백업 후 `SHOW CREATE TABLE g5_migrations`와 `SELECT COUNT(*) FROM g5_migrations`로 구조와 기존 이력을 확인한다. 사용자 지정 접두어를 사용했다면 실제 테이블명으로 바꾼다.
|
||||
2. **잘못된 구조이며 이력이 없는 테이블**은 다른 용도로 사용되는 테이블이 아닌지 확인하고, 사용하지 않는 이름으로 보존한다. 예: `RENAME TABLE g5_migrations TO g5_migrations_backup_20260914`. 이름이 중복되지 않는지 먼저 확인한다.
|
||||
3. 최신 파일을 배포한 뒤 DB 업그레이드의 전체 실행 또는 기존 상태 확인 이력 등록을 다시 수행한다. 정상 이력 테이블을 생성하고 실제 DB 상태에 따라 처리한다. 기존 상태 등록은 선행 실행 필요 항목에서 중단하므로 남은 작업은 전체 실행으로 처리한다.
|
||||
4. **기존 이력이 있는 테이블**은 컬럼 매핑과 체크섬을 검토하여 현재 정의로 복구한다. 정상 성공 이력을 잃으면 조건 없는 데이터 보정이 재실행될 수 있으므로, 이력을 임의 삭제하거나 빈 테이블로 대체하지 않는다. `install/gnuboard5.sql` 전체를 기존 DB에 실행하면 테이블이 삭제되므로 복구용으로 실행하지 않는다.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 마이그레이션 폴더 접근 차단
|
||||
|
||||
`migrations/`는 DB 업그레이드에 필요한 SQL 파일을 보관한다. 업그레이드 완료 후에도 적용 상태와 체크섬 확인, 후속 업데이트를 위해 폴더와 파일을 유지한다. HTTP 접근만 차단하고, 서버 내부에서 PHP가 파일을 읽을 수 있도록 한다.
|
||||
|
||||
## Apache 2.4 및 공유호스팅
|
||||
|
||||
배포본의 `migrations/.htaccess`에는 다음 설정이 포함된다.
|
||||
|
||||
```apache
|
||||
Require all denied
|
||||
```
|
||||
|
||||
FTP·파일 관리자로 업로드할 때 숨김 파일인 `.htaccess`도 포함해야 한다. 기존에 해당 파일을 수정했다면 설정을 병합한다. 이 규칙은 폴더와 개별 SQL 파일의 HTTP 접근을 차단하며 관리자 DB 업그레이드의 파일 읽기에는 영향을 주지 않는다.
|
||||
|
||||
호스팅에서 `.htaccess`와 인증·접근 제어 지시문을 허용해야 적용된다. `AllowOverride None`이면 무시되며, 지시문 사용이 제한된 환경에서는 HTTP 500이 발생할 수 있다. 차단 여부를 아래 방법으로 확인하고, 적용되지 않으면 호스팅 업체에 해당 폴더와 하위 파일의 HTTP 접근 차단을 요청한다. 웹서버 관리자는 `AllowOverride AuthConfig` 또는 필요한 지시문을 허용하는 설정을 검토할 수 있다. 자세한 조건은 [Apache 공식 안내](https://httpd.apache.org/docs/2.4/howto/htaccess.html)를 참고한다.
|
||||
|
||||
## Nginx
|
||||
|
||||
Nginx는 `.htaccess`를 적용하지 않는다. 사이트를 서비스하는 `server` 블록에 다음 설정을 추가한다. 도메인 루트에 그누보드를 설치한 경우의 예시다.
|
||||
|
||||
```nginx
|
||||
location = /migrations {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ^~ /migrations/ {
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
하위 경로에 설치했다면 실제 공개 URL에 맞춰 두 경로를 모두 바꾼다. 예를 들어 `/gnuboard5-dev/`에 설치한 경우 다음과 같다.
|
||||
|
||||
```nginx
|
||||
location = /gnuboard5-dev/migrations {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ^~ /gnuboard5-dev/migrations/ {
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
`^~`는 해당 접두어가 선택되었을 때 일반 정규식 location보다 우선하도록 한다. 기존의 더 구체적인 location, alias, 별도 호스트나 프록시 경로로 같은 파일을 제공한다면 해당 경로도 함께 차단한다. [Nginx location 공식 문서](https://nginx.org/en/docs/http/ngx_http_core_module.html#location)를 참고한다.
|
||||
|
||||
서버 관리자는 설정 파일을 저장한 뒤 문법 검사를 통과한 경우에만 반영한다.
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
공유호스팅에서 Nginx 설정 권한이 없다면 위 설치 경로와 설정 예시를 호스팅 업체에 전달한다. Apache 앞에서 Nginx가 정적 파일을 직접 제공하는 구성도 Nginx 측 차단이 필요하다. 이 작업을 위해 SQL 확장자나 파일명을 임의로 변경하지 않는다.
|
||||
|
||||
## 적용 확인
|
||||
|
||||
사이트 주소와 설치 경로를 바꿔 확인한다. 마지막 URL은 실제 존재하는 SQL 파일이어야 한다.
|
||||
|
||||
```bash
|
||||
curl -i 'https://example.com/migrations'
|
||||
curl -i 'https://example.com/migrations/'
|
||||
curl -i 'https://example.com/migrations/20260910_001_kcp_notification.sql'
|
||||
```
|
||||
|
||||
- 폴더와 개별 파일 요청 모두 HTTP 403 또는 404가 반환되고 SQL 본문이 노출되지 않아야 한다. 폴더 목록만 숨기는 `index.html`이나 디렉터리 목록 비활성화만으로는 개별 파일 다운로드를 막을 수 없다.
|
||||
- 사이트 전체 인증으로 HTTP 401이 반환되는 것만으로 폴더 전용 차단을 확인할 수는 없다. 사이트 인증을 통과한 요청에서도 SQL 파일이 차단되는지 확인한다.
|
||||
- HTTP 500은 정상 차단으로 간주하지 않는다. 호스팅의 허용 지시문과 오류 로그를 확인한다.
|
||||
- 관리자 **환경설정 → DB업그레이드**에서 기존 마이그레이션 목록과 상태가 정상적으로 조회되는지 확인한다. 검증 목적으로 마이그레이션을 재실행할 필요는 없다.
|
||||
|
||||
PHP 실행 계정에는 가능한 한 읽기 권한만 부여하고 파일 변경은 배포 계정으로 관리한다. 두 계정이 같은 공유호스팅에서는 계정별 권한 분리가 어려울 수 있으므로 `chmod` 값만으로 변조 방지가 보장된다고 안내하지 않는다. 폴더를 쓰기 가능하게 만들거나 `777`로 설정하지 않는다. DB 백업·실제 비밀번호·개인정보가 포함된 파일은 이 폴더에 보관하지 않는다.
|
||||
@@ -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 계약·프록시·커스텀 테마와 기존 거래의 운영 확인이 필요합니다.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 그누보드(영카트) 5.6.39 배포 안내
|
||||
|
||||
배포일: 2026-09-14. 변경 기준은 5.6.38입니다.
|
||||
|
||||
이번 버전은 임시 주문 정보 접근과 결제 복귀 보호를 강화하고, 마이그레이션 경로 보호와 설치·DB업그레이드를 보완한 보안 업데이트입니다. **파일 반영 후 최고관리자 DB업그레이드가 필요합니다.**
|
||||
|
||||
## 업데이트 절차
|
||||
|
||||
1. 신규 결제 접수를 잠시 중지하고 소스·설정·DB를 백업합니다. 진행 중인 결제와 PG에서 승인됐으나 주문이 완료되지 않은 거래를 먼저 대조합니다.
|
||||
2. 5.6.38 설치본에 전체 소스 또는 `gnuboard5.6.39.patch.zip`을 반영합니다. 이번 패치의 삭제 대상 파일은 없습니다. 사용자 수정 파일은 비교·병합하고 `migrations/.htaccess`도 업로드합니다. 더 오래된 버전은 [5.6.38 안내](release-5.6.38.md)와 선행 버전의 설정·삭제 파일·DB 변경을 함께 확인하십시오.
|
||||
3. 최고관리자로 **환경설정 → DB업그레이드**에서 선행 마이그레이션과 `20260914_001_order_access_state`를 실행합니다. 새 주문 상태 테이블은 InnoDB를 사용합니다. 주문 잠금과 장바구니 잠금은 별도 DB 연결을 사용하므로 일반 결제 요청당 최대 2개의 추가 연결을 고려하십시오. [DB업그레이드 안내](database-migrations.md)를 확인하십시오.
|
||||
4. 사용자 주문 스킨·테마에 주문별 체크아웃 필드와 토큰 헤더 수신·결제 복귀 전달 변경을 반영하고 PHP·JavaScript 파일 및 캐시를 함께 갱신합니다. 공통 임시 저장에서 허용하지 않은 사용자 추가 필드는 저장되지 않으므로 [주문 보호 문서](security-kve-2026-2140.md)의 저장 허용 목록을 확인하십시오.
|
||||
5. Apache는 `.htaccess` 적용 여부를 확인하고, Nginx는 별도의 접근 차단 설정을 반영합니다. `migrations/`와 실제 SQL 파일 URL 모두 403 또는 404가 반환되어야 합니다. 업그레이드 후에도 마이그레이션 파일을 유지하십시오. [서버별 설정 안내](migration-directory-security.md)를 확인하십시오.
|
||||
6. 결제 복귀 URL은 HTTPS를 사용하고 웹 서버·프록시·APM에서 주문 상태 토큰을 기록하지 않도록 마스킹합니다. 과거 임시 주문은 PG 원장과 대조한 후 제공된 CLI 도구로 정리·복구합니다. 승인 여부가 불명확한 거래를 임의로 삭제하거나 다시 승인하지 마십시오.
|
||||
7. 사용 중인 PG의 테스트 환경에서 PC·모바일, 회원·비회원, 결제 앱 복귀, 승인·취소·재시도, 비회원 주문 조회를 확인합니다. 주문·PG 금액과 상태가 일치하고 미완료 거래 처리 방침이 정해진 뒤 접수를 재개하십시오.
|
||||
|
||||
## 보안 변경
|
||||
|
||||
- **KVE-2026-2140:** PC·모바일 Toss와 모바일 KCP의 임시 주문 복원·승인·주문 저장에 주문별 복귀 토큰, 소유자·장바구니·PG·유효기간 검증을 적용합니다. 쿠키 없는 복귀와 다중 탭을 주문별로 처리하며, 토큰으로 일반 로그인 세션을 발급하지 않습니다.
|
||||
- 승인 상태와 결과를 기록하여 중복 승인과 응답 유실 후의 잘못된 재처리를 방지합니다. Toss는 거래 조회·멱등 처리로 복구하며, KCP의 불명확 거래와 주문 일부 저장은 운영자의 원장 대조가 필요합니다.
|
||||
- 공통 임시 주문 저장 항목을 허용 목록으로 제한하고 비회원 비밀번호·토큰을 복원용 데이터와 주문 POST 로그에서 제외합니다. 비밀번호는 서버 해시로 보호하며 완료된 임시 정보와 만료 데이터를 정리하는 CLI 도구를 제공합니다. 기존 데이터는 파일 교체만으로 모두 정리되지 않습니다.
|
||||
- `migrations/.htaccess`와 Nginx 설정 안내를 제공하여 마이그레이션 SQL 파일의 HTTP 접근을 차단할 수 있도록 합니다.
|
||||
|
||||
## 설치·호환성 보완
|
||||
|
||||
- 신규 설치 완료 전에 해당 배포본의 마이그레이션 이력을 등록합니다. 이력 등록에 실패하면 설치 완료로 진행하지 않습니다.
|
||||
- 기존 마이그레이션 이력 테이블의 구조 불일치와 조회 실패를 감지해 잘못된 업그레이드 실행을 차단합니다.
|
||||
- PHP 5.2.17 구문 호환을 유지하도록 주문 보호 코드와 Toss 클래스 등을 보완합니다. 안전한 난수원·SHA-256·JSON이 필요하며, PG 연결에는 해당 PG가 요구하는 cURL/TLS 환경이 별도로 필요합니다.
|
||||
- MySQL 5.0의 연결당 이름 잠금 제약을 고려하여 업무·주문·장바구니 잠금 연결을 분리합니다. 연결 실패 또는 잠금 유실 시 승인 상태 처리를 중단합니다.
|
||||
|
||||
## 검증 범위와 운영 확인
|
||||
|
||||
주문 접근·저장 10건, 토큰 전달 8건, PHP 5.2.17·7.4.33·8.4.22의 호환 함수 각 35건과 운영 PHP 33개 파일의 문법 검사, 안전한 난수원 부재 시 거부 검사를 통과했습니다. MySQL 8.0.45의 실제 연결 잠금 9건, 마이그레이션 이력·줄바꿈 13건, 신규 설치 4개 시나리오와 설치 스키마 대조를 확인했습니다. 격리된 PHP 8.4·MySQL·HTTP 환경에서 결제 상태 50건과 주문 확정 37건을 통과했으며, PG 통신은 대역을 사용했습니다.
|
||||
|
||||
실제 외부 PG 승인·취소·앱 복귀, MySQL 5.0 서버 자체 및 모든 사용자 스킨·운영 환경의 연동을 검증한 결과는 아닙니다. 다른 PG도 공통 저장 변경의 영향을 받으므로 정상 주문 확인이 필요합니다. 지원 버전의 구문 호환과 구형 TLS 환경에서의 실제 PG 연결 가능성은 구분해야 합니다. 상세 복구·정리 절차와 검증 한계는 [KVE-2026-2140 문서](security-kve-2026-2140.md)를 참고하십시오.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 그누보드(영카트) 5.6.391 배포 안내
|
||||
|
||||
배포일: 2026-09-15. 변경 기준은 5.6.39입니다.
|
||||
|
||||
쇼핑몰 4·5단계 분류의 현재 경로와 서브메뉴 표시를 보완하는 버그 수정 버전입니다.
|
||||
|
||||
## 변경 사항
|
||||
|
||||
- PC 기본 스킨과 basic 테마의 breadcrumb에 4·5단계 분류 경로를 표시합니다. 모든 단계에서 기존 위치와 스타일을 유지합니다.
|
||||
- PC·모바일 기본 스킨과 basic 테마의 서브메뉴는 선택한 4단계에 사용 중인 5단계가 있으면 5단계 목록만 표시하고, 없으면 4단계 형제 목록만 표시합니다.
|
||||
- 5단계에서는 같은 부모 아래의 5단계 형제 목록과 현재 선택을 표시합니다.
|
||||
- 공통 전체 분류 트리의 기존 3단계 구조는 유지하고 현재 경로에 필요한 깊은 단계만 추가 조회합니다.
|
||||
|
||||
## 업데이트 방법
|
||||
|
||||
1. 기존 소스와 사용자 수정 파일을 백업합니다.
|
||||
2. 5.6.39 설치본에 전체 소스 또는 `gnuboard5.6.391.patch.zip`을 반영합니다. 공통 라이브러리와 스킨 파일을 함께 업데이트합니다. 사용자 수정 스킨은 변경 내용을 비교하여 병합합니다.
|
||||
3. 4·5단계 분류의 breadcrumb, 하위 분류 유무에 따른 메뉴, 현재 선택 표시를 확인합니다.
|
||||
|
||||
이번 버전의 추가 DB 마이그레이션과 삭제 대상 파일은 없습니다. 5.6.39보다 오래된 설치본은 [5.6.39 보안 업데이트 및 DB업그레이드 안내](release-5.6.39.md)를 포함한 선행 업데이트를 적용해야 합니다.
|
||||
|
||||
## 검증
|
||||
|
||||
8개 분류 경로에서 PC·basic 테마의 목록/상세 breadcrumb와 네 스킨의 메뉴·선택 상태를 확인했습니다. Chromium에서 1·3·4·5단계 전환 시 breadcrumb 위치·높이와 메뉴·상품 정렬 영역 위치가 동일함을 확인했습니다. 변경된 운영 PHP 7개 파일은 현재 PHP와 PHP 5.2 구문 검사를 통과했습니다.
|
||||
|
||||
분류 데이터 대역을 사용한 회귀 검사이며 실제 운영 DB와 모든 사용자 스킨을 검증한 결과는 아닙니다.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 그누보드(영카트) 5.6.40 배포 안내
|
||||
|
||||
배포일: 2026-09-20. 변경 기준은 5.6.391입니다.
|
||||
|
||||
KVE-2026-2424 LG 가상계좌 입금 통보 위조를 차단하고 중복·역순 통보와 은행 취소 처리를 보완한 보안 업데이트입니다. **파일 반영 후 최고관리자 DB업그레이드가 필요합니다.**
|
||||
|
||||
## 변경 사항
|
||||
|
||||
- 통보 발신 IP·상점키·서명과 저장된 상점 MID·PG·거래번호·계좌·금액을 함께 검증합니다. 빈 키나 다른 PG 주문을 이용한 입금 처리를 차단합니다.
|
||||
- PC·모바일 일반 주문 및 개인결제에 결제 당시 LG MID를 저장합니다.
|
||||
- 통보 이력과 테이블 잠금으로 중복·동시·역순 통보를 처리하고, 중간 실패 시 같은 통보 재전송으로 복구할 수 있도록 합니다.
|
||||
- 은행 취소 시 확인된 입금액만 복원합니다. 개인결제 연결 주문이 입금 후 바뀌면 차감을 차단하며, 사용 중지된 개인결제도 기존 입금의 취소를 처리합니다.
|
||||
|
||||
## 업데이트 방법
|
||||
|
||||
1. 신규 주문·결제 접수를 잠시 중지하고 파일·DB를 백업합니다.
|
||||
2. 5.6.391 설치본에 전체 소스 또는 `gnuboard5.6.40.patch.zip`을 반영합니다. 사용자 수정 파일은 비교·병합합니다. 이번 패치의 삭제 대상 파일은 없습니다. 이전 버전은 선행 업데이트도 적용하십시오.
|
||||
3. 최고관리자의 **환경설정 → DB업그레이드**에서 `20260918_001_lg_notification`을 적용합니다. DB 계정에는 관련 테이블의 `LOCK TABLES`, `SELECT`, `INSERT`, `UPDATE` 권한이 필요합니다. 스키마 적용 전에는 주문 저장이나 통보 처리가 실패할 수 있습니다.
|
||||
4. 기존 미입금 LG 거래는 PG 원장과 대조한 뒤 확인된 건에만 `od_lg_mid`·`pp_lg_mid`를 보정하고 재통보를 요청합니다. 기존 행의 MID는 빈값으로 추가되며 이 상태에서는 통보를 거부합니다. 현재 설정을 과거 거래 전체에 일괄 복사하지 마십시오.
|
||||
5. HTTPS, 프록시의 원본 IP 복원과 실제 계약 PG 테스트 환경에서 PC·모바일 일반/개인결제의 계좌 발급·입금·은행 취소·재통보를 확인한 뒤 접수를 재개합니다.
|
||||
|
||||
일반형 가상계좌 전액 입금을 대상으로 합니다. 고정식 계좌·부분입금 및 계약별 취소 금액·순번 형식은 별도 대조가 필요합니다. 출고 후 은행 취소와 미완료 처리 이력은 운영자가 확인해야 합니다. 상세 적용·복구 절차와 검증 한계는 [KVE-2026-2424 안내](security-kve-2026-2424.md)를 참고하십시오.
|
||||
|
||||
## 검증
|
||||
|
||||
변경 PHP 8개 파일은 PHP 5.2.17·8.4.22 구문 검사를 통과했고, 마이그레이션 줄바꿈 13건을 확인했습니다. 격리된 MySQL의 MyISAM/InnoDB에서 실제 콜백과 SQL을 사용하는 HTTP·DB 회귀 검사 및 신규 설치 마이그레이션 이력 검사를 수행했습니다.
|
||||
|
||||
PG 발신 IP와 일부 실패 조건은 대역을 사용했습니다. 실제 외부 PG 통신·은행 입금, 전체 주문서 제출, 운영 서버와 모든 사용자 수정 환경의 검증을 포함하지 않습니다.
|
||||
@@ -0,0 +1,110 @@
|
||||
# KVE-2026-2140 주문 상태·복귀·복구
|
||||
|
||||
## 구현 범위
|
||||
|
||||
1차 세션 검사에 주문별 복귀 토큰, 영속 승인 기록, 데이터 정리와 운영 대조 도구를 추가했다. PC·모바일 Toss와 모바일 KCP의 반환·승인·주문 저장을 대상으로 한다. 공통 임시 저장의 필드 제한과 비밀번호 보호는 다른 PG에도 적용된다. 다른 PG의 쿠키 없는 복귀·승인 재시도를 이 구현이 대신 처리하지는 않는다.
|
||||
|
||||
- 임시 저장은 256비트 난수 토큰을 발급한다. DB에는 SHA-256 해시만 저장하고, 토큰 원문은 `X-G5-Order-State` 응답 헤더로 현재 탭에 전달한다.
|
||||
- 토큰은 주문번호·PG·회원/비회원·장바구니·일반/개인결제·저장 본문 해시·만료 시각에 연결된다. 원문 임시 데이터와 비밀번호 해시를 읽기 전에 토큰과 최소 메타데이터를 검증한다.
|
||||
- 새 테이블 `order_access`의 주문별 잠금과 최종 처리 시 장바구니별 잠금을 사용한다. 서로 다른 세션/프로세스에서도 승인부터 주문 저장까지 중복 처리를 막는다. 같은 장바구니의 다른 미완료 승인도 차단한다.
|
||||
- 폼을 표시할 때 주문별 체크아웃 nonce와 장바구니를 세션에 등록한다. 다른 탭이 현재 주문 세션을 바꾸더라도 원래 탭은 자기 주문을 저장·복원한다. 같은 상품 장바구니를 두 번 결제하는 것은 허용하지 않는다.
|
||||
- 쿠키 없는 복귀는 토큰으로 서버 상태를 복원한다. 회원 주문의 경우 해당 결제 요청 안에서만 원래 회원 컨텍스트를 적용하고 로그인 세션은 발급하지 않는다. 다른 회원으로 로그인한 브라우저의 토큰 사용은 거부한다. 결제 토큰은 해당 주문에 대한 bearer 권한이므로 로그·URL 보관에 주의한다.
|
||||
- 최종 주문의 이름·주소·금액·포인트·쿠폰·배송비 등 업무 필드는 서버가 저장한 데이터로 복원한다. 결제 키·PG 암호문 등 승인 응답 필드는 별도로 검증한다. 정상 결제 금액은 기존 서버 장바구니 계산값 및 개인결제 금액과 대조한다.
|
||||
- 공통 임시 저장은 `lib/shop_order_fields.lib.php`의 기본 주문서/PG별 허용 목록만 저장한다. 비밀번호와 토큰은 복원용 데이터에 보관하지 않는다. 사용자 스킨의 추가 업무 필드는 서버 허용 목록에 명시적으로 추가해야 한다.
|
||||
- 비회원 비밀번호는 서버 해시로 분리 보관하며, 최종 주문에서 재해싱하지 않는다. 완료 후 임시 행·토큰·해시·응답 상태를 정리한다. 주문 POST 로그에서 비밀번호와 상태/체크아웃 토큰을 제외한다.
|
||||
|
||||
## 승인 상태와 복구
|
||||
|
||||
| 상태 | 처리 |
|
||||
|---|---|
|
||||
| `pending` | 승인 미시작. 기본 복귀 수명 2시간. `G5_ORDER_DATA_ACCESS_TTL`로 조정 가능 |
|
||||
| `approving` | PG 요청 직전에 금액·요청 식별자를 영속 기록. 응답 유실 가능성이 있으므로 무조건 재승인하지 않음 |
|
||||
| `approved` | 검증된 PG 결과를 저장. 후속 주문 저장을 계속할 수 있음 |
|
||||
| `finalizing` | 주문 DB 반영을 시작함. 일부만 저장되었을 가능성이 있음 |
|
||||
| `completed` | 주문 처리가 끝남. 토큰·비밀번호 해시·PG 응답은 지워짐 |
|
||||
| `cancel_pending` | 취소 요청 시작. 결과 대조 전에는 승인 결과를 재사용하지 않음 |
|
||||
| `cancelled`, `failed` | 확인된 종료 상태. 유예 후 정리 가능 |
|
||||
| `legacy`, `unknown` | 운영자 대조 필요. 자동 삭제·재승인·토큰 발급 금지 |
|
||||
| `purged` | 임시 개인정보 및 인증 상태 제거. 주문번호 재사용 방지용 기록만 유지 |
|
||||
|
||||
Toss는 안정적인 `Idempotency-Key`로 승인한다. 응답을 놓친 요청과 승인 후 중단된 요청은 PG 조회를 먼저 수행하고 주문번호·결제 키·금액·상태를 대조한다. 취소·실패한 거래의 저장된 성공 응답을 재사용하지 않는다. PG가 같은 거래의 `IN_PROGRESS` 상태를 확인한 경우에만 동일 키로 승인 재시도하며, 멱등 키 유효기간보다 짧은 14일로 제한한다. 조회 자체가 실패하면 재승인하지 않는다. 취소에도 동일 거래의 고정 멱등 키와 거래번호 대조를 적용한다.
|
||||
|
||||
KCP는 검증된 승인 결과를 별도 기록해 같은 요청 재처리 시 결과를 복원한다. 통신 도중 중단되어 승인 결과가 없는 경우에는 KCP 거래 원장을 확인한 운영자가 복구 자료를 제공해야 한다. 자동 조회 API를 임의로 가정하지 않았다.
|
||||
|
||||
일반 주문이 `finalizing`에서 중단됐으나 주문 행이 없고 원래 선택 장바구니가 남아 있으면 승인된 거래를 계속 처리한다. 주문 행이 이미 생겼거나 개인결제가 일부 반영된 경우에는 자동 덮어쓰기/재승인하지 않는다. 포인트·쿠폰·주문·장바구니를 운영자가 대조한 뒤 완료 처리한다. 기존 주문/장바구니 테이블이 MyISAM인 환경에서 전체 업무 트랜잭션을 원자적으로 되돌릴 수 있다고 가정하지 않는다.
|
||||
|
||||
## 설치·배포
|
||||
|
||||
신규 설치 SQL과 `migrations/20260914_001_order_access_state.sql`을 함께 제공한다. 기존 설치는 최고관리자 DB업그레이드에서 선행 마이그레이션을 처리한 후 이 마이그레이션을 적용한다. 일반 페이지에서는 DDL을 실행하지 않는다. 새 테이블은 InnoDB를 사용한다. MySQL 5.0의 연결당 이름 잠금 하나 제약에 맞춰 주문 잠금과 장바구니 잠금은 각각 별도 비영속 DB 연결을 사용한다. 기존 업무·포인트 DB 연결의 잠금과 분리되며, 연결 종료 시 서버가 잠금을 해제한다. 이 처리에는 일반 결제 요청당 최대 2개의 추가 DB 연결이 필요하다. 연결·잠금 획득 실패나 잠금 연결 유실이 확인되면 승인 상태 처리를 중단한다. 정리 도구는 항목별로 잠금을 해제하여 처리 건수만큼 연결이 누적되지 않는다.
|
||||
|
||||
배포 순서:
|
||||
|
||||
1. 신규 결제 접수를 일시 중단하고 DB 및 웹 공개 경로 밖의 미완료 거래 백업을 확보한다.
|
||||
2. 미완료 주문·PG 승인 내역을 확인한다. 승인된 거래를 정리 대상으로 표시하지 않는다.
|
||||
3. 마이그레이션과 PHP/JS 변경을 함께 적용한다. 사용자 스킨에도 체크아웃 필드, 응답 헤더 수신 및 복귀 토큰 전달 변경을 반영한다. 새 PHP에 예전 JS가 캐시되지 않도록 배포 캐시를 갱신한다.
|
||||
4. 반환 URL의 HTTPS와 토큰 전달을 확인한다. Toss 성공/실패 URL과 KCP Ret_URL에 `g5_order_state`가 전달된다. KCP의 쿠키 재전송 우회는 토큰 형태를 인식하는 것뿐이며, 실제 인증은 엔드포인트의 DB 해시 검증으로 수행한다.
|
||||
5. 웹서버·프록시·APM에서 결제 복귀 URL의 쿼리와 Referer를 로그에 기록하지 않거나 `g5_order_state`를 마스킹한다. 예를 들어 Nginx 결제 반환 로그는 `$request_uri`/`$request` 대신 쿼리 없는 `$uri`를 사용한다. 응답은 `no-store` 및 `Referrer-Policy: no-referrer`를 설정한다. 응답 헤더와 POST 본문을 별도 수집하는 서비스에도 같은 마스킹을 적용한다.
|
||||
6. 계약된 PG 테스트 환경에서 외부 앱 복귀·성공·실패·취소·재통보를 확인한 후 접수를 재개한다. 코드 push와 운영 배포/PG 검증은 구분한다.
|
||||
|
||||
## 기존 요청과 정리 도구
|
||||
|
||||
`php tools/shop-order-maintenance.php`는 CLI 전용이며 기본값은 읽기 전용 목록이다. 웹에서는 실행되지 않는다. 목록과 오류에 토큰·비밀번호·전체 개인정보·PG 응답을 출력하지 않는다.
|
||||
|
||||
```sh
|
||||
php tools/shop-order-maintenance.php --action=list
|
||||
php tools/shop-order-maintenance.php --action=cleanup
|
||||
php tools/shop-order-maintenance.php --action=cleanup --apply
|
||||
php tools/shop-order-maintenance.php --action=reconcile-toss --order=주문번호 --apply
|
||||
php tools/shop-order-maintenance.php --action=quarantine-legacy --order=주문번호 --apply
|
||||
```
|
||||
|
||||
`cleanup`은 기본 24시간의 유예 후 완료·확인된 실패·취소 및 만료된 미승인 Toss/KCP 요청만 처리한다. 1회 최대 500건이다. `--grace=초`는 최소 1시간이다. `approving/approved/finalizing/unknown/cancel_pending`과 검토 전 과거 데이터는 자동 정리하지 않는다. 운영 환경에서 명시적으로 예약 실행할 수 있다. 일반 조회 요청에 정리 작업을 붙이지 않는다.
|
||||
|
||||
1차 패치의 주문·세션·본문 해시 검증을 통과하는 과거 요청은 새 상태로 전환할 수 있다. 그보다 오래된 요청은 주문번호 또는 PAYREQ_MAP만으로 신뢰하거나 토큰을 재발급하지 않는다. `quarantine-legacy`는 지정 주문의 평문 비밀번호를 제거하고 해시를 별도 보관하되 접근 불가능한 `legacy` 상태로 격리한다. 승인된 미완료 거래를 먼저 PG/주문 자료와 대조해야 한다.
|
||||
|
||||
KCP의 불명확한 승인 결과는 원장을 확인한 뒤 다음 명령으로 반영한다. JSON에는 `orderId`, `amount`, `tno`, `res_cd`와 해당 결제수단의 `app_time`, `app_no`, 은행/가상계좌 등의 실제 승인 결과를 넣는다. 자료는 웹 공개 경로 밖에 보관한다. 이 명령은 운영자의 원장 확인을 명시적으로 신뢰하며, 파일 자체를 PG 서명으로 간주하지 않는다.
|
||||
|
||||
```sh
|
||||
php tools/shop-order-maintenance.php --action=reconcile-kcp --order=주문번호 --receipt=/private/verified-kcp.json --verified --apply
|
||||
php tools/shop-order-maintenance.php --action=complete-reviewed --order=주문번호 --verified --apply
|
||||
php tools/shop-order-maintenance.php --action=purge-reviewed-legacy --order=주문번호 --receipt=/private/legacy-review.json --verified --apply
|
||||
```
|
||||
|
||||
`complete-reviewed`는 `finalizing` 거래의 주문·PG·장바구니·포인트·쿠폰 대조를 마친 경우에만 사용한다. 저장된 거래번호도 승인 기록과 비교한다. 기존 데이터 삭제 자료는 `orderId`, `resolution`(`unpaid/cancelled/completed`), `reference`(원장 대조 기록)를 포함한다. `completed`를 선택하면 실제 저장된 주문/개인결제의 거래번호가 있어야 한다. 이 도구는 불명확한 거래를 자동으로 미승인으로 판정하지 않는다.
|
||||
|
||||
## 검증 방법과 한계
|
||||
|
||||
- `php tests/shop_order_access_test.php`: 1차 세션 상태의 안전한 전환 경계와 저장 허용 목록.
|
||||
- `node tests/shop_order_state_browser_test.js`: 토큰 헤더·폼 전달·콜백 URL 인코딩. DOM 대역이며 실제 PG SDK 실행은 아니다.
|
||||
- `tests/shop_order_state_test.py`: 실제 PHP/DB/HTTP, 쿠키 없는 Toss/KCP 복귀, 위조 토큰, 주문/금액/키 변조, 다중 탭, 병렬 프로세스 승인, 응답 유실·부분 저장·취소 상태, 정리와 과거 요청, 신규/기존/쇼핑몰 미설치 마이그레이션.
|
||||
- `tests/shop_order_finalize_test.py`: 별도 테스트 설치본에서 PG 전송 클래스만 대체하고 실제 PC·모바일 주문 확정, 개인결제, 비회원 주문 조회, 완료 정리, 중복 POST를 실행한다. 테스트가 끝나면 원래 전송 클래스를 복원한다.
|
||||
|
||||
Python 검증에는 `G5_ORDER_TEST_CONFIG`로 별도 설치본의 JSON(`root`, `php`, `ini`, `port`)을 지정한다. 해당 설치본은 loopback DB 주소와 `issue48_` 접두어의 데이터베이스를 사용해야 하며 `data/.order-state-test`에 `KVE-2026-2140-local-test`를 적어 명시적으로 표시한다. fixture PHP는 운영 DB에서 실행을 거부한다. 테스트 파일은 Git에는 보관하지만 일반 배포 아카이브에서 제외한다.
|
||||
|
||||
실제 Toss/KCP 네트워크 승인·취소·재통보, PG 앱/브라우저별 쿠키 정책, 다른 PHP/DB 버전, 모든 사용자 스킨의 호환성은 이 로컬 대역 검증으로 확정하지 않는다. KCP 불명확 거래와 부분 주문 저장은 위 운영자 대조 경로로 처리하며 무조건 자동 복구한다고 안내하지 않는다.
|
||||
|
||||
Toss API 근거: [결제 API](https://docs.tosspayments.com/reference), [멱등성과 결제 후처리](https://docs.tosspayments.com/guides/v2/get-started/llms-quick-reference).
|
||||
|
||||
## 최소 버전 호환 처리
|
||||
|
||||
PHP 최소 버전은 기존 PHP 5.2.17을 유지한다. 공통 주문 라이브러리의 익명 함수와 `__DIR__`, PHP 7 전용 난수·역직렬화 옵션 의존을 제거했다. PC·모바일 주문서의 익명 콜백과 PHP 5.4 이후 JSON 출력 옵션, Toss 클래스의 스칼라·반환·프로퍼티 타입 선언도 구버전에서 읽을 수 있게 변경했다. 운영 도구는 구버전 HTTP 상태 헤더·JSON 출력·예외 처리를 사용한다.
|
||||
|
||||
`lib/shop_order_compat.lib.php`에서 다음을 공통 처리한다.
|
||||
|
||||
- 난수는 `random_bytes`, 강한 OpenSSL 난수, `/dev/urandom`, `MCRYPT_DEV_URANDOM` 순으로 사용 가능한 안전한 소스를 찾는다. 약한 난수로 대체하지 않으며 안전한 소스가 없으면 결제를 진행하지 않는다.
|
||||
- 토큰·해시는 길이와 자료형을 확인하고 같은 길이의 문자열은 끝까지 비교한다.
|
||||
- 기존 Base64/serialize 임시 데이터는 배열·문자열·정수·유한 실수·불리언·null만 읽는 전용 파서로 복원한다. 객체·참조·잘못된 길이·과도한 크기/깊이는 거부하며 PHP 객체 생성이나 `unserialize()`를 호출하지 않는다. 원문 길이와 바이너리 문자열을 유지하므로 기존 주문 필드와 KCP 응답을 읽을 수 있다.
|
||||
|
||||
인증 토큰 생성과 주문 상태 처리에는 hash(SHA-256)·JSON 및 안전한 난수원이 필요하다. PG 통신에는 해당 PG가 요구하는 cURL/TLS 환경이 별도로 필요하며, PHP 구문 호환이 구형 TLS 라이브러리로 실제 PG 연결까지 가능함을 보장하지 않는다. 사용자 주문 스킨의 토큰 전달 반영 요건은 그대로 유지한다.
|
||||
|
||||
검증 명령:
|
||||
|
||||
```sh
|
||||
php tests/shop_order_compat_test.php
|
||||
php -d disable_functions=random_bytes,openssl_random_pseudo_bytes,mcrypt_create_iv tests/shop_order_compat_test.php --no-rng
|
||||
php tests/shop_order_lock_test.php 127.0.0.1:포트 issue48_테스트DB
|
||||
```
|
||||
|
||||
설계 근거: [MySQL의 GET_LOCK 버전별 동작](https://dev.mysql.com/blog-archive/making-get_lock-behavior-more-predictable-cross-version-with-query-rewrite/)과 [mysql_connect의 new_link 옵션](https://www.php.net/manual/en/function.mysql-connect.php)을 기준으로 잠금 연결을 분리했다. PHP의 [random_bytes 지원 범위](https://www.php.net/manual/en/function.random-bytes.php)와 [PHP 7의 역직렬화 필터 도입](https://www.php.net/manual/en/migration70.new-features.php)을 확인하고 구버전용 처리를 추가했다. DB 파일 잠금은 여러 웹서버 간 보호를 보장하지 않으므로 사용하지 않는다.
|
||||
|
||||
호환 함수와 구형 mysql 드라이버의 단일 잠금 동작 대역은 PHP 5.2.17·7.4·8.4에서 검사한다. 실제 잠금 검사는 root/빈 비밀번호인 격리 MySQL에서 업무·주문·장바구니 연결 분리, 다른 프로세스의 경합, 종료·연결 단절 시 해제와 유실 감지를 확인한다. MySQL 5.0 서버 자체의 실행 검증과 실제 PG 통신은 이 검사에 포함하지 않는다. 기존 주문 상태·최종 주문 회귀 검증도 별도 설치본에서 실행한다.
|
||||
@@ -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의 도입은 사용 중인 계약/연동 지원 확인 후 별도로 검토한다.
|
||||
@@ -0,0 +1,87 @@
|
||||
# KVE-2026-2424 LG 가상계좌 입금 통보 위조 차단
|
||||
|
||||
`shop/settle_lg_common.php`가 빈 LG 상점키로 만든 통보를 받아 다른 PG의 미입금 주문을 입금 처리할 수 있던 경로를 차단한다. PC·모바일의 가상계좌 통보는 이 공통 URL을 사용한다. 주문 처리 로직은 `lib/lg_notification.lib.php`로 분리했다.
|
||||
|
||||
## 적용 절차
|
||||
|
||||
1. 파일·DB를 백업하고 신규 주문·결제 접수를 일시 중지한다. 변경 파일 반영과 함께 최고관리자의 **환경설정 → DB업그레이드**에서 `20260918_001_lg_notification`을 적용한다. 새 설치 SQL에도 같은 컬럼과 이력 테이블이 포함된다. 일반 요청에서 DDL을 실행하지 않으며, 스키마 적용 전에는 새 주문 저장이나 통보 처리가 실패할 수 있다.
|
||||
2. DB 계정의 `LOCK TABLES` 및 관련 주문·개인결제·장바구니·통보 이력 테이블의 `SELECT`·`INSERT`·`UPDATE` 권한을 확인한다.
|
||||
3. **기존 미입금 LG 거래를 원장과 대조한다.** `od_lg_mid`·`pp_lg_mid`는 기존 행에 빈값으로 추가되며, 이 상태에서는 통보를 거부한다. 현재 설정을 과거 거래 전체에 일괄 복사하지 않는다. PG 거래 내역의 주문번호·거래번호·상점 MID·운영/테스트 여부·계좌·청구액·입금 여부를 확인한 건만 담당자가 해당 MID를 보정하고 PG 재통보를 요청한다. 운영 MID는 `si_` 접두어, 테스트 MID는 `tsi_` 접두어까지 포함한 실제 값이다. 이미 입금된 기존 거래에는 처리 이력을 임의로 만들지 말고 수동 대조한다.
|
||||
4. 웹서버에서 HTTPS 및 통보 발신지 처리를 확인한다. 애플리케이션은 `REMOTE_ADDR`만 신뢰한다. 프록시를 사용하는 경우 관리자가 신뢰 프록시 범위를 제한하고 원본 IP를 웹서버에서 복원해야 한다. `X-Forwarded-For` 등을 애플리케이션에서 무조건 신뢰하거나 프록시 IP를 PG IP 목록에 추가해서는 안 된다.
|
||||
5. 실제 계약된 XPay 테스트 환경에서 PC·모바일·일반/개인결제의 계좌 발급, 입금, 은행 입금 취소 및 재통보를 확인한 뒤 접수를 재개한다. 특히 계약별 취소 통보의 금액·순번 형식은 아래 지원 범위와 대조한다.
|
||||
6. 실패 통보, 미완료 처리 이력과 출고 후 은행 취소를 감시한다. 현재 설정의 MID/키가 과거 계약과 달라진 거래는 자동 처리하지 않는다. PG 전환만 있었고 LG 인증정보와 저장 거래를 검증할 수 있는 경우에는 기존 LG 거래를 처리한다.
|
||||
|
||||
신고된 위조 통보가 실제로 있었는지는 별도 확인 사항이다. 과거 LG 통보 기록과 입금 원장·주문 상태를 대조하고, 코드 적용만으로 과거 주문 정합성까지 복구됐다고 판단하지 않는다.
|
||||
|
||||
## 인증과 거래 검증
|
||||
|
||||
- 공식 XPay 공통사항 6.2의 운영·테스트 공통 통보 IP 10개에서 온 POST만 받는다. 임의의 클라이언트 IP, 관리자 세션, 전달 헤더는 인증 근거가 아니다.
|
||||
- 설정 MID·상점키가 없으면 거부한다. 요청 MID는 설정에서 계산한 `si_`/`tsi_` MID 중 하나여야 한다. 계약에서 확인되지 않은 임의의 키 길이 조건은 추가하지 않는다.
|
||||
- XPay 응답 규격의 MD5를 동일 길이 상수 시간 비교로 검증한다. MD5 입력에는 입금 플래그·거래번호·통보 순번이 포함되지 않으므로 **해시 검사만으로 전체 요청의 무결성이 보장된다고 간주하지 않는다.** PG 발신지와 서버 저장 거래 검증을 함께 적용한다.
|
||||
- 필수 입력의 문자열 여부·길이·허용 문자, 실제 달력 날짜와 시각, 양의 청구액 및 DB signed INT 범위를 검사한다. 배열·빈 TID·지수 표기·음수·0원·소수·범위 초과는 거부한다.
|
||||
- 결제 당시 서버에서 구성한 `$LGD_MID`를 PC·모바일 일반 주문과 개인결제에 저장한다. 현재 활성 PG나 테스트 설정으로 과거 주문을 판단하지 않는다. 통보 MID와 저장 MID, PG=`lg`, 결제수단=`가상계좌`, 거래번호·계좌를 대조하며 연결 주문의 테스트 여부도 검사한다.
|
||||
- 일반/개인결제 번호가 충돌하거나 연결 주문이 없으면 거부한다. 개인결제에 연결된 주문의 원래 PG는 다를 수 있으므로 개인결제 자체의 LG 거래를 검사한다.
|
||||
- 일반 입금은 서버의 상품·배송비·쿠폰·포인트·취소·환불 계산과 저장 미수금이 일치해야 한다. 미입금 주문의 청구액 전액만 반영한다. 개인결제는 `pp_price` 전액을 반영하며 연결 주문 미수금을 초과할 수 없다.
|
||||
|
||||
## 지원하는 통보와 재처리
|
||||
|
||||
기본 통합결제창의 **일반형 가상계좌 전액 입금**을 대상으로 한다. 고정식 계좌나 부분입금 기능을 새로 지원하는 패치는 아니다.
|
||||
|
||||
| 플래그 | 금액 검증 및 동작 |
|
||||
| --- | --- |
|
||||
| `R` | 누적/현입금액 0. 저장된 거래와 대조한 뒤 수신만 확인하며 입금액을 바꾸지 않는다. |
|
||||
| `I` | `LGD_AMOUNT`, `LGD_CASTAMOUNT`, `LGD_CASCAMOUNT`가 청구액 전액과 같아야 한다. 해당 거래가 미입금 상태인 경우에만 반영한다. |
|
||||
| `C` | 누적액 0, 현입금액 0 또는 청구액 전액인 은행 취소를 처리한다. 확인된 입금 이력이 있으면 해당 금액만 차감한다. 입금보다 먼저 도착하면 취소 이력을 남겨 지연 입금 통보를 차단한다. |
|
||||
|
||||
통보 이력은 MID·TID·주문번호·숫자로 정규화한 `LGD_CASSEQNO`·플래그로 구별한다. 같은 이벤트의 금액·계좌 등 업무 값이 다르면 거부한다. 완료된 이벤트 재전송은 금액을 다시 반영하지 않고 `OK`로 응답한다. 타임스탬프의 임의 만료 시간으로 수동 재통보를 막지 않는다.
|
||||
|
||||
순번은 입금 순서를 나타내는 숫자(최대 6자리)로 취급한다. 같은 순번의 입금보다 취소가 우선하며, 더 낮은 순번의 지연 통보는 업무 데이터를 변경하지 않는다. 취소 후 새 입금은 증가한 순번에서만 허용한다. 계약 환경이 순번을 재사용하거나 다른 취소 금액 형식을 사용하면 원장과 대조하여 연동 정책을 확정해야 하며, 검증을 제거해 우회하지 않는다. 실제 PG 통보의 모든 변형을 확인한 것은 아니다.
|
||||
|
||||
은행 취소 시 입금액·미수금을 복원하고 `입금`·`준비` 상태를 `주문`으로 되돌린다. 이미 `배송`·`완료`인 경우 물류 이력은 유지하고 미수금 및 운영자 대조 필요 로그를 남긴다. 출고 중단·회수·디지털 상품 접근·포인트 회수는 운영자가 별도로 조치해야 한다. 이력 없이 입금액이 남아 있는 과거 거래, 취소·반품 주문, 부분입금과 불일치 금액은 자동으로 추정·차감하지 않는다.
|
||||
|
||||
개인결제의 은행 취소는 활성 입금 이력에 기록된 연결 주문과 현재 연결 주문을 대조한다. 입금 후 연결 주문 변경·해제·추가가 있으면 금액과 이력을 변경하지 않고 `FAIL` 및 연결 충돌 로그를 남긴다. 다른 주문에서 대신 차감하지 않는다. 담당자는 PG 원장과 입금 이력을 대조하고 원래 연결 관계를 복원한 뒤 동일 취소 통보를 재전송한다. 개인결제를 `사용 안 함`으로 바꿔도 기존 입금의 은행 취소는 처리하며, 사용 중지 상태의 신규 입금은 계속 거부한다.
|
||||
|
||||
## 중간 실패와 복구
|
||||
|
||||
MyISAM·InnoDB 모두 관련 네 테이블의 쓰기 잠금 안에서 검증·처리한다. `ln_plan`에 행별 변경 전 값과 목표값을 기록한 다음 개인결제·장바구니·주문을 갱신한다. 재시도에서는 가산 연산 대신 같은 목표값을 저장하므로 개인결제의 연결 주문 금액이 중복 가산되지 않는다. 모든 결과를 다시 읽어 확인하고 `ln_done=1`로 기록해야 `OK`로 응답한다. 실패 응답은 `FAIL`이다.
|
||||
|
||||
이 방식이 MyISAM 여러 테이블을 원자적 트랜잭션으로 바꾸지는 않는다. 중간 실패 시 일부 값이 반영될 수 있고, 같은 통보 재전송으로 나머지를 완료한다. 현재 행이 기록된 변경 전/후 어느 쪽과도 맞지 않으면 외부 변경으로 보고 복구를 중단한다. 미완료 이벤트가 남은 거래 및 연결 주문의 다른 신규 LG 통보도 차단한다. 다른 PG나 관리자 작업에서 발생한 충돌은 원장 대조가 필요하다. 테이블 전체 잠금 동안 주문 및 관리자 쿼리가 잠시 대기할 수 있다.
|
||||
|
||||
복구 점검 SQL(실제 설치의 테이블 접두어로 변경):
|
||||
|
||||
```sql
|
||||
SELECT ln_key, ln_trade, ln_seq, ln_flag, od_id, ln_created_at
|
||||
FROM g5_shop_lg_noti WHERE ln_done = 0 ORDER BY ln_created_at;
|
||||
```
|
||||
|
||||
미완료 이력을 삭제하거나 `ln_done`만 1로 변경하지 않는다. PG 원장과 주문·개인결제·장바구니·`ln_plan`을 대조하고 충돌 원인을 해결한 뒤 동일 통보를 재전송한다. 공식 가이드는 자동 재전송 후에도 처리되지 않은 거래를 구 상점관리자의 전송실패내역에서 재전송할 수 있다고 안내한다. 이력에는 계좌·금액 등 거래 정보가 포함되므로 주문 데이터와 같은 접근·백업 보호를 적용한다.
|
||||
|
||||
## 검증
|
||||
|
||||
```sh
|
||||
python3 tests/lg_notification_http.py 격리_MySQL_포트
|
||||
php tests/migration_line_endings.php
|
||||
php tests/migration_install_history.php 격리_MySQL_소켓
|
||||
```
|
||||
|
||||
HTTP 검사는 localhost 지정 포트의 root/빈 비밀번호인 **별도 테스트 MySQL**에 고유 임시 DB를 만들고 삭제한다. 실제 콜백 파일과 SQL을 실행하되 공통 초기화·PG 발신 IP·SQL 실패 주입만 대역으로 사용한다. Python 3, PHP CLI/mysqli, MySQL CLI가 필요하다. 테스트 파일은 일반 배포 아카이브에서 제외된다.
|
||||
|
||||
2026-09-18: PHP 8.4.22·MySQL 8.0.46의 MyISAM/InnoDB에서 HTTP·DB 645건 통과. 빈 키 제보 조건, 서명/IP/상점/거래/금액 거부, 일반·개인결제, 동시·중복·역순 통보, 은행 취소, 각 쓰기 전 실패·반영 후 응답 유실·실제 SQL 오류와 복구, 외부 수정 충돌, 스키마 누락, 새 설치/기존 업그레이드·재실행·쇼핑몰 미설치 분기를 확인했다. 마이그레이션 줄바꿈 13건 및 기존 신규 설치 이력·체크섬·재실행 검사가 통과했다. 변경 PHP 8개 파일의 구문 검사는 PHP 5.2.17·8.4.22에서 통과했다.
|
||||
|
||||
실제 PG 승인·은행 입금, 전체 주문서 제출, 프록시 설정, MySQL 5.0 실행, 실제 운영 배포는 이 격리 검증에 포함하지 않는다. 자동 거래조회 API는 이번 패치에 추가하지 않았다. 공식 IP·서명과 저장 거래를 함께 검증하고, 불명확한 기존 거래는 원장 대조로 처리한다.
|
||||
|
||||
2026-09-18 추가 보완 검증: PHP 8.4.25·별도 localhost MySQL 8.0.46에서 MyISAM/InnoDB 회귀 검사 715건과 실제 설치의 공통 초기화·DB 함수를 사용하는 검사 391건을 통과했다. 입금 후 개인결제 연결 주문 변경·해제·추가의 차단 및 원래 연결 복원 후 재통보, 사용 중지 후 동시 은행 취소·중복 차감 방지·신규 입금 거부를 추가했다. Windows 실행에 맞춰 테스트 사본의 경로·프로세스 종료를 조정하고 PHP 서버 4개로 동시 요청을 처리했다. 실제 초기화 검사에서도 PG 발신 IP는 로컬 라우터로 대입했으며 실제 PG 통신을 수행한 것은 아니다. 두 검사 집합의 시나리오는 중복되며, 이번 보완은 추가 DB 마이그레이션을 요구하지 않는다.
|
||||
|
||||
## 배포 및 인터넷진흥원 회신
|
||||
|
||||
현재는 코드 수정과 격리 검증을 완료한 상태이다. 공개 배포 버전·일정, 실제 계약 PG 연동 검사, 운영 반영과 KISA 제출은 별도 기록한다.
|
||||
|
||||
> KVE-2026-2424와 관련하여 미설정 LG 인증정보 차단, 통보 발신지·서명·저장 거래·결제금액 검증을 보완하였습니다. 일반 주문과 개인결제에 중복 통보 방지 및 은행 입금 취소 처리를 적용하고, DB 처리 중 실패한 건을 동일 통보로 복구하도록 수정하였습니다. 격리 환경에서 위조 요청 차단, 동시·역순 통보, 중간 실패 복구 및 신규/기존 DB 적용을 검증하였습니다. 기존 미입금 거래는 PG 원장 대조 후 결제 당시 MID를 확인해야 하며, 실제 PG 연동 검증과 공개 배포 버전·일정은 확정 후 별도로 통보하겠습니다.
|
||||
|
||||
## 규격 근거
|
||||
|
||||
2026-09-18에 공식 [구 모듈 가이드](https://docs.tosspayments.com/legacy/guide)에서 연결되는 다음 문서를 확인했다.
|
||||
|
||||
- [XPay 결제모듈 공통사항 v1.0.8](https://docs.google.com/document/d/1kyhcyXmaRuxZAlNJ_Oiuo2EoFVMlQbHZewZW40E6u2w/edit): 6.2의 가상계좌 입금 통보 발신지. [현행 방화벽 안내](https://docs.tosspayments.com/reference/using-api/security)의 인바운드 IP와도 일치한다.
|
||||
- [PC 통합결제창 가상계좌 v1.0.3](https://docs.google.com/document/d/1YZ5AuUVFlxRZriWkq3YlMrjPu6iEJSZFbI2KcNijdac/edit): 일반형 계좌의 일회성 입금, 발급/입금/은행 취소 구분, 통보 응답 및 재전송 안내.
|
||||
- 저장소의 `plugin/lgxpay/lgdacom/XPayClient.php::GetHashData2()`와 기존 콜백의 응답 해시 계산식도 대조했다. 키 길이·자동 조회 API를 임의로 가정하지 않았다.
|
||||
@@ -683,6 +683,8 @@ 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_lg_mid` varchar(104) 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 +783,8 @@ 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_lg_mid` varchar(104) 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 +1041,51 @@ 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;
|
||||
|
||||
CREATE TABLE `g5_shop_order_access` (
|
||||
od_id bigint(20) unsigned NOT NULL,
|
||||
token_hash char(64) NOT NULL DEFAULT '',
|
||||
pg varchar(20) NOT NULL DEFAULT '',
|
||||
cart_id bigint(20) unsigned NOT NULL DEFAULT '0',
|
||||
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||
expires bigint(20) NOT NULL DEFAULT '0',
|
||||
updated_at bigint(20) NOT NULL DEFAULT '0',
|
||||
state_json mediumtext NOT NULL,
|
||||
payment_key varchar(200) NOT NULL DEFAULT '',
|
||||
response_json mediumtext NOT NULL,
|
||||
PRIMARY KEY (od_id),
|
||||
KEY cart_status (cart_id,status),
|
||||
KEY state_expiry (status,expires)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
-- LG 입금 통보 및 중간 실패 복구 이력
|
||||
CREATE TABLE `g5_shop_lg_noti` (
|
||||
`ln_key` char(64) NOT NULL,
|
||||
`ln_trade` char(64) NOT NULL,
|
||||
`ln_seq` int(11) NOT NULL,
|
||||
`ln_flag` char(1) NOT NULL,
|
||||
`ln_payload` char(64) NOT NULL,
|
||||
`od_id` bigint(20) unsigned NOT NULL DEFAULT '0',
|
||||
`ln_plan` mediumtext NOT NULL,
|
||||
`ln_done` tinyint(4) NOT NULL DEFAULT '0',
|
||||
`ln_created_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`ln_key`),
|
||||
KEY `ln_trade` (`ln_trade`),
|
||||
KEY `od_id` (`od_id`)
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
|
||||
|
||||
@@ -451,3 +451,26 @@ if( ! function_exists('install_file_write') ){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 최신 설치 SQL을 새로 생성한 경우에만 호출한다. 과거 SQL은 실행하지 않는다.
|
||||
function install_record_migrations($table_prefix)
|
||||
{
|
||||
$files = glob(g5_migration_path() . '/*.sql');
|
||||
if (!$files) return '마이그레이션 파일을 찾을 수 없습니다. 배포본의 migrations 디렉터리를 확인해 주십시오.';
|
||||
sort($files, SORT_STRING);
|
||||
$migrations = array();
|
||||
foreach ($files as $file) {
|
||||
$migration = g5_migration_read_file($file);
|
||||
if (isset($migration['error'])) return $migration['error'];
|
||||
$migrations[] = $migration;
|
||||
}
|
||||
$table = $table_prefix . 'migrations';
|
||||
$error = g5_migration_validate_table($table);
|
||||
if ($error !== '') return $error;
|
||||
foreach ($migrations as $migration) {
|
||||
if (!g5_migration_save_record($migration, 'success', '', 0, $table)) {
|
||||
return $migration['id'] . ' 설치 이력을 저장하지 못했습니다: ' . sql_error_info();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -108,6 +108,18 @@ if ($table_check_error) {
|
||||
install_fail_page('기존 테이블 존재 여부를 확인하지 못했습니다. DB 계정 권한을 확인해 주십시오.', $dblink, $install_table_prefixes);
|
||||
}
|
||||
|
||||
// 기존 테이블을 보존하거나 재사용하는 설치는 DB 업그레이드에서 실제 상태를 검사한다.
|
||||
$install_record_baseline = $is_install === false;
|
||||
$existing_tables = install_query_or_fail('SHOW TABLES', $dblink, '신규 설치 여부를 확인하지 못했습니다.', $install_table_prefixes);
|
||||
while ($existing_table = sql_fetch_array($existing_tables)) {
|
||||
$existing_table_name = reset($existing_table);
|
||||
foreach (array($table_prefix, $g5_shop_prefix, $table_prefix . 'shop_') as $existing_prefix) {
|
||||
if (strpos($existing_table_name, $existing_prefix) === 0) {
|
||||
$install_record_baseline = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 그누보드5 재설치에 체크하였거나 그누보드5가 설치되어 있지 않다면
|
||||
if ($g5_install || $is_install === false) {
|
||||
// 테이블 생성 ------------------------------------
|
||||
@@ -580,6 +592,16 @@ if($g5_shop_install) {
|
||||
<?php
|
||||
//-------------------------------------------------------------------------------------------------
|
||||
|
||||
// 최신 기본 스키마를 생성한 설치에서만 배포 시점의 이력을 등록한다.
|
||||
// 실패하면 dbconfig.php를 만들지 않아 설치 완료로 취급하지 않는다.
|
||||
if ($install_record_baseline) {
|
||||
include_once('../lib/migration.lib.php');
|
||||
$migration_error = install_record_migrations($table_prefix);
|
||||
if ($migration_error !== '') {
|
||||
install_fail_page($migration_error, $dblink, $install_table_prefixes);
|
||||
}
|
||||
}
|
||||
|
||||
// DB 설정 파일 생성
|
||||
$file = '../'.G5_DATA_DIR.'/'.G5_DBCONFIG_FILE;
|
||||
$install_file_write_error = false;
|
||||
@@ -655,10 +677,13 @@ 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_lg_noti_table'] = G5_SHOP_TABLE_PREFIX.'lg_noti'; // LG 통보 처리 이력 테이블\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");
|
||||
install_file_write($f, "\$g5['g5_shop_order_data_table'] = G5_SHOP_TABLE_PREFIX.'order_data'; // 모바일 결제정보 임시저장 테이블\n");
|
||||
install_file_write($f, "\$g5['g5_shop_order_access_table'] = G5_SHOP_TABLE_PREFIX.'order_access'; // 주문 복귀 인증 및 승인 상태 테이블\n");
|
||||
install_file_write($f, "\$g5['g5_shop_inicis_log_table'] = G5_SHOP_TABLE_PREFIX.'inicis_log'; // 이니시스 모바일 계좌이체 로그 테이블\n");
|
||||
install_file_write($f, "\$g5['g5_shop_inicis_pay_table'] = G5_SHOP_TABLE_PREFIX.'inicis_pay'; // 이니시스 결제 처리 현황 테이블\n");
|
||||
install_file_write($f, "\$g5['g5_shop_inicis_pay_event_table'] = G5_SHOP_TABLE_PREFIX.'inicis_pay_event'; // 이니시스 결제 처리 이력 테이블\n");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* 주문 임시저장 응답의 토큰은 현재 탭의 결제 폼에만 전달한다. */
|
||||
function g5_order_state_accept(xhr) {
|
||||
var token = xhr.getResponseHeader('X-G5-Order-State');
|
||||
if (!token || !/^[0-9]{1,20}\.[a-f0-9]{64}$/.test(token)) {
|
||||
return '결제 요청을 저장하지 못했습니다. 다시 시도해 주십시오.';
|
||||
}
|
||||
['forderform', 'sm_form', 'nhnkcp_pay_form'].forEach(function(name) {
|
||||
var form = document.forms[name];
|
||||
if (!form) return;
|
||||
var input = form.elements.g5_order_state;
|
||||
if (!input) {
|
||||
input = document.createElement('input');
|
||||
input.type = 'hidden'; input.name = 'g5_order_state'; form.appendChild(input);
|
||||
}
|
||||
input.value = token;
|
||||
});
|
||||
return '';
|
||||
}
|
||||
|
||||
function g5_order_state_url(url, form) {
|
||||
var input = form.elements.g5_order_state;
|
||||
if (!input || !/^[0-9]{1,20}\.[a-f0-9]{64}$/.test(input.value)) {
|
||||
throw new Error('결제 요청을 다시 저장해 주십시오.');
|
||||
}
|
||||
return url + (url.indexOf('?') < 0 ? '?' : '&') + 'g5_order_state=' + encodeURIComponent(input.value);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
// KVE-2026-2346: 테스트 통보도 발신지와 저장된 거래를 모두 검증한다.
|
||||
function kcp_noti_request($post, $server, $default)
|
||||
{
|
||||
if (!isset($server['REQUEST_METHOD']) || $server['REQUEST_METHOD'] !== 'POST') {
|
||||
return false;
|
||||
}
|
||||
// https://developer.kcp.co.kr/guide/webhook (2026-09-10 확인)
|
||||
$ips = !empty($default['de_card_test'])
|
||||
? array('210.122.176.144')
|
||||
: array('103.215.144.173', '103.215.144.174', '210.122.72.173');
|
||||
if (!isset($server['REMOTE_ADDR']) || !in_array($server['REMOTE_ADDR'], $ips, true)) {
|
||||
return false;
|
||||
}
|
||||
// 전달 헤더나 로그인 세션은 PG 발신 인증에 사용하지 않는다.
|
||||
$patterns = array(
|
||||
'site_cd' => '/\A[A-Z0-9]{5}\z/',
|
||||
'tno' => '/\A[0-9]{14}\z/',
|
||||
'order_no' => '/\A[1-9][0-9]{0,19}\z/',
|
||||
'tx_cd' => '/\ATX0[0-7]\z/',
|
||||
'tx_tm' => '/\A[0-9]{14}\z/'
|
||||
);
|
||||
if (isset($post['tx_cd']) && $post['tx_cd'] === 'TX00') {
|
||||
$patterns += array(
|
||||
'ipgm_mnyx' => '/\A[0-9]{1,12}\z/',
|
||||
'account' => '/\A[T]?[0-9]{1,19}\z/',
|
||||
'noti_id' => '/\A[0-9]{20}\z/',
|
||||
'op_cd' => '/\A(?:50|13)\z/'
|
||||
);
|
||||
}
|
||||
$data = array();
|
||||
foreach ($patterns as $name => $pattern) {
|
||||
if (!isset($post[$name]) || !is_string($post[$name]) || !preg_match($pattern, $post[$name])) {
|
||||
return false;
|
||||
}
|
||||
$data[$name] = $post[$name];
|
||||
}
|
||||
$time = $data['tx_tm'];
|
||||
if ((int) substr($time, 0, 4) < 1000 || !checkdate((int) substr($time, 4, 2), (int) substr($time, 6, 2), (int) substr($time, 0, 4))
|
||||
|| substr($time, 8, 2) > 23 || substr($time, 10, 2) > 59 || substr($time, 12, 2) > 59) {
|
||||
return false;
|
||||
}
|
||||
$test = in_array($data['site_cd'], array('T0000', 'T0007'), true);
|
||||
if ($test !== !empty($default['de_card_test'])) {
|
||||
return false;
|
||||
}
|
||||
if (!$test && substr($data['site_cd'], 0, 2) !== 'SR') {
|
||||
return false;
|
||||
}
|
||||
if ($data['tx_cd'] === 'TX00') {
|
||||
// 주문 금액 컬럼은 signed INT이다. 변환 전에 범위를 검사한다.
|
||||
if ((float) $data['ipgm_mnyx'] < 1 || (float) $data['ipgm_mnyx'] > 2147483647) {
|
||||
return false;
|
||||
}
|
||||
$data['ipgm_mnyx'] = (string) (int) $data['ipgm_mnyx'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function kcp_noti_query($sql)
|
||||
{
|
||||
$result = sql_query($sql, false);
|
||||
if (!$result) {
|
||||
throw new Exception('KCP notification database failure');
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
function kcp_noti_rows($sql)
|
||||
{
|
||||
$result = kcp_noti_query($sql);
|
||||
$rows = array();
|
||||
while ($row = sql_fetch_array($result)) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function kcp_noti_quote($value)
|
||||
{
|
||||
return "'".sql_escape_string((string) $value)."'";
|
||||
}
|
||||
|
||||
function kcp_noti_tables()
|
||||
{
|
||||
global $g5;
|
||||
return array('order' => $g5['g5_shop_order_table'], 'personal' => $g5['g5_shop_personalpay_table'],
|
||||
'cart' => $g5['g5_shop_cart_table'],
|
||||
'event' => isset($g5['g5_shop_kcp_noti_table']) ? $g5['g5_shop_kcp_noti_table'] : G5_SHOP_TABLE_PREFIX.'kcp_noti');
|
||||
}
|
||||
|
||||
function kcp_noti_process($data)
|
||||
{
|
||||
$tables = kcp_noti_tables();
|
||||
$locked = false;
|
||||
try {
|
||||
// 기본 설치의 MyISAM에서도 다른 통보 및 관리자 UPDATE와 경쟁하지 않게 한다.
|
||||
// DDL은 관리자 DB 업그레이드에서만 실행하며 스키마/권한이 없으면 실패 응답한다.
|
||||
$locks = array();
|
||||
foreach ($tables as $table) {
|
||||
$locks[] = '`'.$table.'` WRITE';
|
||||
}
|
||||
kcp_noti_query('LOCK TABLES '.implode(', ', $locks));
|
||||
$locked = true;
|
||||
$success = kcp_noti_locked($data, $tables);
|
||||
} catch (Exception $e) {
|
||||
error_log('KCP notification: processing failed; reconciliation/retry required');
|
||||
$success = false;
|
||||
} catch (Throwable $e) {
|
||||
error_log('KCP notification: processing failed; reconciliation/retry required');
|
||||
$success = false;
|
||||
}
|
||||
if ($locked) {
|
||||
try {
|
||||
kcp_noti_query('UNLOCK TABLES');
|
||||
} catch (Exception $e) {
|
||||
$success = false;
|
||||
} catch (Throwable $e) {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
return $success;
|
||||
}
|
||||
|
||||
// 변경 전/후 값을 함께 기록한다. 재시도는 가산 SQL 대신 목표값을 대입한다.
|
||||
function kcp_noti_step($table, $id_field, $row, $changes, $guards)
|
||||
{
|
||||
$before = array();
|
||||
foreach (array_unique(array_merge(array($id_field), array_keys($changes), $guards)) as $field) {
|
||||
if (!array_key_exists($field, $row)) {
|
||||
throw new Exception('Missing notification schema');
|
||||
}
|
||||
$before[$field] = (string) $row[$field];
|
||||
}
|
||||
$after = $before;
|
||||
foreach ($changes as $field => $value) {
|
||||
$after[$field] = (string) $value;
|
||||
}
|
||||
return array('table' => $table, 'id_field' => $id_field, 'id' => (string) $row[$id_field],
|
||||
'before' => $before, 'after' => $after);
|
||||
}
|
||||
|
||||
function kcp_noti_matches($row, $expected)
|
||||
{
|
||||
foreach ($expected as $field => $value) {
|
||||
if (!array_key_exists($field, $row) || (string) $row[$field] !== $value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function kcp_noti_apply($plan, $tables)
|
||||
{
|
||||
// 먼저 모든 행을 검사한다. 실패 후 외부에서 수정한 행을 과거 값으로 덮어쓰지 않는다.
|
||||
foreach ($plan as $step) {
|
||||
$rows = kcp_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id']));
|
||||
if (count($rows) !== 1 || (!kcp_noti_matches($rows[0], $step['before']) && !kcp_noti_matches($rows[0], $step['after']))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach ($plan as $step) {
|
||||
$sets = array();
|
||||
foreach ($step['after'] as $field => $value) {
|
||||
if ($value !== $step['before'][$field]) {
|
||||
$sets[] = '`'.$field.'` = '.kcp_noti_quote($value);
|
||||
}
|
||||
}
|
||||
if ($sets) {
|
||||
$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;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
include_once(dirname(__FILE__).'/shop_order_compat.lib.php');
|
||||
|
||||
function lg_noti_query($sql)
|
||||
{
|
||||
$result = sql_query($sql, false);
|
||||
if (!$result) {
|
||||
throw new Exception('LG notification database failure');
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
function lg_noti_rows($sql)
|
||||
{
|
||||
$result = lg_noti_query($sql);
|
||||
$rows = array();
|
||||
while ($row = sql_fetch_array($result)) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function lg_noti_quote($value)
|
||||
{
|
||||
return "'".sql_escape_string((string) $value)."'";
|
||||
}
|
||||
|
||||
function lg_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_lg_noti_table']) ? $g5['g5_shop_lg_noti_table'] : G5_SHOP_TABLE_PREFIX.'lg_noti');
|
||||
}
|
||||
|
||||
function lg_noti_process($data)
|
||||
{
|
||||
$tables = lg_noti_tables();
|
||||
$locked = false;
|
||||
try {
|
||||
// 기본 설치의 MyISAM에서도 다른 통보 및 관리자 UPDATE와 경쟁하지 않게 한다.
|
||||
// DDL은 관리자 DB 업그레이드에서만 실행하며 스키마/권한이 없으면 실패 응답한다.
|
||||
$locks = array();
|
||||
foreach ($tables as $table) {
|
||||
$locks[] = '`'.$table.'` WRITE';
|
||||
}
|
||||
lg_noti_query('LOCK TABLES '.implode(', ', $locks));
|
||||
$locked = true;
|
||||
$success = lg_noti_locked($data, $tables);
|
||||
} catch (Exception $e) {
|
||||
error_log('LG notification: processing failed; reconciliation/retry required');
|
||||
$success = false;
|
||||
} catch (Throwable $e) {
|
||||
error_log('LG notification: processing failed; reconciliation/retry required');
|
||||
$success = false;
|
||||
}
|
||||
if ($locked) {
|
||||
try {
|
||||
lg_noti_query('UNLOCK TABLES');
|
||||
} catch (Exception $e) {
|
||||
$success = false;
|
||||
} catch (Throwable $e) {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
return $success;
|
||||
}
|
||||
|
||||
// 변경 전/후 값을 함께 기록한다. 재시도는 가산 SQL 대신 목표값을 대입한다.
|
||||
function lg_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 lg_noti_matches($row, $expected)
|
||||
{
|
||||
foreach ($expected as $field => $value) {
|
||||
if (!array_key_exists($field, $row) || (string) $row[$field] !== $value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function lg_noti_apply($plan, $tables)
|
||||
{
|
||||
// 먼저 모든 행을 검사한다. 실패 후 외부에서 수정한 행을 과거 값으로 덮어쓰지 않는다.
|
||||
foreach ($plan as $step) {
|
||||
$rows = lg_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.lg_noti_quote($step['id']));
|
||||
if (count($rows) !== 1 || (!lg_noti_matches($rows[0], $step['before']) && !lg_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.'` = '.lg_noti_quote($value);
|
||||
}
|
||||
}
|
||||
if ($sets) {
|
||||
$sql = 'UPDATE `'.$tables[$step['table']].'` SET '.implode(', ', $sets)
|
||||
.' WHERE `'.$step['id_field'].'` = '.lg_noti_quote($step['id']);
|
||||
lg_noti_query($sql);
|
||||
}
|
||||
}
|
||||
foreach ($plan as $step) {
|
||||
$rows = lg_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.lg_noti_quote($step['id']));
|
||||
if (count($rows) !== 1 || !lg_noti_matches($rows[0], $step['after'])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// XPay 공통사항 6.2 및 가상계좌 가이드(2026-09-18 확인).
|
||||
// MD5에는 CASFLAG/TID/순번이 포함되지 않으므로 발신지 검사도 반드시 적용한다.
|
||||
function lg_noti_request($post, $server, $config)
|
||||
{
|
||||
$ips = array('13.124.18.147', '13.124.108.35', '3.36.173.151', '3.38.81.32',
|
||||
'115.92.221.121', '115.92.221.122', '115.92.221.123', '115.92.221.125', '115.92.221.126', '115.92.221.127');
|
||||
if (!isset($server['REQUEST_METHOD'], $server['REMOTE_ADDR']) || $server['REQUEST_METHOD'] !== 'POST'
|
||||
|| !in_array($server['REMOTE_ADDR'], $ips, true)) return false;
|
||||
// 프록시 전달 헤더, 현재 로그인/활성 PG 설정은 통보 인증에 사용하지 않는다.
|
||||
if (!isset($config['cf_lg_mid'], $config['cf_lg_mert_key'])
|
||||
|| !is_string($config['cf_lg_mid']) || !preg_match('/\A[A-Za-z0-9_-]{1,100}\z/', $config['cf_lg_mid'])
|
||||
|| !is_string($config['cf_lg_mert_key']) || trim($config['cf_lg_mert_key']) === '') return false;
|
||||
$patterns = array(
|
||||
'LGD_MID' => '/\A[A-Za-z0-9_-]{1,104}\z/', 'LGD_OID' => '/\A[1-9][0-9]{0,19}\z/',
|
||||
'LGD_TID' => '/\A[A-Za-z0-9_-]{1,64}\z/', 'LGD_RESPCODE' => '/\A0000\z/',
|
||||
'LGD_PAYTYPE' => '/\ASC0040\z/', 'LGD_HASHDATA' => '/\A[a-fA-F0-9]{32}\z/',
|
||||
'LGD_TIMESTAMP' => '/\A[0-9]{14}\z/', 'LGD_PAYDATE' => '/\A[0-9]{14}\z/',
|
||||
'LGD_AMOUNT' => '/\A[0-9]{1,12}\z/', 'LGD_CASTAMOUNT' => '/\A[0-9]{1,12}\z/',
|
||||
'LGD_CASCAMOUNT' => '/\A[0-9]{1,12}\z/', 'LGD_CASFLAG' => '/\A[RIC]\z/',
|
||||
'LGD_CASSEQNO' => '/\A[0-9]{1,6}\z/', 'LGD_ACCOUNTNUM' => '/\A[A-Za-z0-9-]{1,30}\z/'
|
||||
);
|
||||
$data = array();
|
||||
foreach ($patterns as $field => $pattern) {
|
||||
if (!isset($post[$field]) || !is_string($post[$field]) || !preg_match($pattern, $post[$field])) return false;
|
||||
$data[$field] = $post[$field];
|
||||
}
|
||||
$mid = 'si_'.$config['cf_lg_mid'];
|
||||
if (!shop_order_equals($mid, $data['LGD_MID']) && !shop_order_equals('t'.$mid, $data['LGD_MID'])) return false;
|
||||
$hash = md5($data['LGD_MID'].$data['LGD_OID'].$data['LGD_AMOUNT'].$data['LGD_RESPCODE'].$data['LGD_TIMESTAMP'].$config['cf_lg_mert_key']);
|
||||
if (!shop_order_equals($hash, strtolower($data['LGD_HASHDATA']))) return false;
|
||||
foreach (array('LGD_TIMESTAMP', 'LGD_PAYDATE') as $field) {
|
||||
$time = $data[$field];
|
||||
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;
|
||||
}
|
||||
// 수동 재통보를 막는 임의의 timestamp 수명 제한 대신 거래별 처리 이력을 확인한다.
|
||||
foreach (array('LGD_AMOUNT', 'LGD_CASTAMOUNT', 'LGD_CASCAMOUNT') as $field) {
|
||||
if ((float)$data[$field] > 2147483647) return false;
|
||||
$data[$field] = (int)$data[$field];
|
||||
}
|
||||
if ($data['LGD_AMOUNT'] <= 0) return false;
|
||||
$flag = $data['LGD_CASFLAG'];
|
||||
if ($flag === 'R') {
|
||||
if ($data['LGD_CASTAMOUNT'] !== 0 || $data['LGD_CASCAMOUNT'] !== 0) return false;
|
||||
} else {
|
||||
if ((int)$data['LGD_CASSEQNO'] === 0) return false;
|
||||
// 기본 통합결제창의 일반형 가상계좌는 전액 일회성 입금이다.
|
||||
if ($flag === 'I' && ($data['LGD_CASTAMOUNT'] !== $data['LGD_AMOUNT'] || $data['LGD_CASCAMOUNT'] !== $data['LGD_AMOUNT'])) return false;
|
||||
if ($flag === 'C' && ($data['LGD_CASTAMOUNT'] !== 0 || !in_array($data['LGD_CASCAMOUNT'], array(0, $data['LGD_AMOUNT']), true))) return false;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function lg_noti_locked($data, $tables)
|
||||
{
|
||||
$id = lg_noti_quote($data['LGD_OID']);
|
||||
$personal = lg_noti_rows("SELECT * FROM `{$tables['personal']}` WHERE pp_id = $id");
|
||||
$orders = lg_noti_rows("SELECT * FROM `{$tables['order']}` WHERE od_id = $id");
|
||||
if (count($personal) > 1 || count($orders) > 1 || ($personal && $orders)) return false;
|
||||
$pp = $personal ? $personal[0] : null;
|
||||
if ($pp) {
|
||||
$prefix = 'pp'; $payment = $pp;
|
||||
$orders = $pp['od_id'] ? lg_noti_rows("SELECT * FROM `{$tables['order']}` WHERE od_id = ".lg_noti_quote($pp['od_id'])) : array();
|
||||
if ($pp['od_id'] && count($orders) !== 1) return false;
|
||||
} else {
|
||||
if (!$orders) return false;
|
||||
$prefix = 'od'; $payment = $orders[0];
|
||||
}
|
||||
$od = $orders ? $orders[0] : null;
|
||||
if ($payment[$prefix.'_pg'] !== 'lg' || $payment[$prefix.'_settle_case'] !== '가상계좌'
|
||||
|| $payment[$prefix.'_tno'] !== $data['LGD_TID'] || empty($payment[$prefix.'_lg_mid'])
|
||||
|| !shop_order_equals($payment[$prefix.'_lg_mid'], $data['LGD_MID'])) return false;
|
||||
if ($od && (bool)$od['od_test'] !== (substr($data['LGD_MID'], 0, 1) === 't')) return false;
|
||||
// 저장 형식: 은행명 계좌번호 예금주명. 부분 문자열 일치는 허용하지 않는다.
|
||||
if (!in_array($data['LGD_ACCOUNTNUM'], preg_split('/\s+/', trim($payment[$prefix.'_bank_account'])), true)) return false;
|
||||
if ($data['LGD_CASFLAG'] === 'R') return true; // 발급 통보로 입금 처리하지 않는다.
|
||||
return lg_noti_deposit($data, $tables, $payment, $prefix, $pp, $od);
|
||||
}
|
||||
|
||||
function lg_noti_deposit($data, $tables, $payment, $prefix, $pp, $od)
|
||||
{
|
||||
$trade = hash('sha256', $data['LGD_MID'].'|'.$data['LGD_TID'].'|'.$data['LGD_OID']);
|
||||
$seq = (int)$data['LGD_CASSEQNO'];
|
||||
$flag = $data['LGD_CASFLAG'];
|
||||
$key = hash('sha256', $trade.'|'.$seq.'|'.$flag);
|
||||
// 재전송 timestamp는 달라질 수 있다. 업무 값을 바꾼 동일 이벤트는 거부한다.
|
||||
$payload = hash('sha256', $data['LGD_AMOUNT'].'|'.$data['LGD_CASTAMOUNT'].'|'.$data['LGD_CASCAMOUNT'].'|'.$data['LGD_ACCOUNTNUM']);
|
||||
$events = lg_noti_rows("SELECT * FROM `{$tables['event']}` WHERE ln_trade = '$trade'");
|
||||
$same = null; $latest = null;
|
||||
foreach ($events as $event) {
|
||||
if ($event['ln_key'] === $key) $same = $event;
|
||||
if (!$latest || (int)$event['ln_seq'] > (int)$latest['ln_seq']
|
||||
|| ((int)$event['ln_seq'] === (int)$latest['ln_seq'] && $event['ln_flag'] === 'C')) $latest = $event;
|
||||
if (!$event['ln_done'] && $event['ln_key'] !== $key) return false;
|
||||
}
|
||||
if ($same) {
|
||||
if ($same['ln_payload'] !== $payload) return false;
|
||||
if ($same['ln_done']) return true;
|
||||
$plan = json_decode($same['ln_plan'], true);
|
||||
if (!is_array($plan) || !lg_noti_apply($plan, $tables)) return false;
|
||||
lg_noti_query("UPDATE `{$tables['event']}` SET ln_done = 1 WHERE ln_key = '$key'");
|
||||
return true;
|
||||
}
|
||||
// 은행 취소가 먼저 와도 같은/과거 입금 순번으로 다시 입금시키지 않는다.
|
||||
if ($latest && ($seq < (int)$latest['ln_seq'] || ($seq === (int)$latest['ln_seq'] && $latest['ln_flag'] === 'C'))) return true;
|
||||
$canceling = $flag === 'C';
|
||||
// 사용 중지는 신규 입금만 막는다. 이미 수납한 거래의 은행 취소는 반영해야 한다.
|
||||
if ($pp && !$pp['pp_use'] && !$canceling) return false;
|
||||
$amount = $data['LGD_AMOUNT'];
|
||||
$receipt = (int)$payment[$prefix.'_receipt_price'];
|
||||
$active = $latest && $latest['ln_flag'] === 'I';
|
||||
if ($pp && (int)$pp['pp_price'] !== $amount) return false;
|
||||
if (!$canceling && ($active || $receipt !== 0)) return false;
|
||||
// 이력 없는 기존 입금은 자동으로 취소하거나 기처리로 추정하지 않는다.
|
||||
if ($canceling && $receipt !== ($active ? $amount : 0)) return false;
|
||||
$delta = $canceling ? ($active ? -$amount : 0) : $amount;
|
||||
$od_id = $od ? $od['od_id'] : '0';
|
||||
// 관리자가 연결 주문을 바꿔도 이전 입금의 취소액을 새 주문에서 차감하지 않는다.
|
||||
// 연결 해제/추가도 충돌이며 원장 대조 후 원래 연결을 복원하고 재통보한다.
|
||||
if ($pp && $canceling && $active && (string)$latest['od_id'] !== (string)$od_id) {
|
||||
error_log('LG notification: personal payment order binding changed; reconciliation required');
|
||||
return false;
|
||||
}
|
||||
if ($od) {
|
||||
$pending = lg_noti_rows("SELECT ln_key FROM `{$tables['event']}` WHERE od_id = ".lg_noti_quote($od_id)." AND ln_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 ($due <= 0 || $due > 2147483647 || $misu !== (int)$od['od_misu']) 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)
|
||||
|| (int)$od['od_receipt_price'] + $delta < 0)) return false;
|
||||
}
|
||||
$raw_time = $data['LGD_PAYDATE'];
|
||||
$time = substr($raw_time, 0, 4).'-'.substr($raw_time, 4, 2).'-'.substr($raw_time, 6, 2)
|
||||
.' '.substr($raw_time, 8, 2).':'.substr($raw_time, 10, 2).':'.substr($raw_time, 12, 2);
|
||||
$plan = array();
|
||||
if ($pp && $delta) {
|
||||
$plan[] = lg_noti_step('personal', 'pp_id', $pp,
|
||||
array('pp_receipt_price'=>$receipt + $delta, 'pp_receipt_time'=>$time, 'pp_casseqno'=>$data['LGD_CASSEQNO']),
|
||||
array('od_id', 'pp_pg', 'pp_tno', 'pp_lg_mid', '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 < 0 || $new_misu > 2147483647) return false;
|
||||
$status = $od['od_status'];
|
||||
if (!$canceling && $new_misu === 0) $status = '입금';
|
||||
if ($canceling && in_array($status, array('입금', '준비'), true)) $status = '주문';
|
||||
$cart = lg_noti_rows("SELECT ct_id, od_id, ct_status FROM `{$tables['cart']}` WHERE od_id = ".lg_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']
|
||||
|| ($canceling && in_array($item['ct_status'], array('입금', '준비'), true)))) {
|
||||
$plan[] = lg_noti_step('cart', 'ct_id', $item, array('ct_status'=>$status), array('od_id'));
|
||||
}
|
||||
}
|
||||
// 출고에 사용하는 주문 상태는 장바구니 반영 뒤 마지막에 바꾼다.
|
||||
$plan[] = lg_noti_step('order', 'od_id', $od,
|
||||
array('od_receipt_price'=>$new_receipt, 'od_receipt_time'=>$time, 'od_misu'=>$new_misu,
|
||||
'od_status'=>$status, 'od_casseqno'=>$data['LGD_CASSEQNO']),
|
||||
array('od_pg', 'od_tno', 'od_lg_mid', 'od_settle_case', 'od_test', 'od_bank_account', '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;
|
||||
lg_noti_query("INSERT INTO `{$tables['event']}` SET ln_key='$key', ln_trade='$trade', ln_seq='$seq', ln_flag='$flag', ln_payload='$payload', od_id=".lg_noti_quote($od_id)
|
||||
.", ln_plan=".lg_noti_quote($json).", ln_created_at=".lg_noti_quote($time));
|
||||
if (!lg_noti_apply($plan, $tables)) return false;
|
||||
lg_noti_query("UPDATE `{$tables['event']}` SET ln_done = 1 WHERE ln_key = '$key'");
|
||||
if ($canceling && $od && in_array($od['od_status'], array('배송', '완료'), true)) {
|
||||
error_log('LG notification: bank cancellation after shipment; operator reconciliation required');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+62
-8
@@ -34,12 +34,16 @@ 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_lg_noti_table' => $shop_prefix . 'lg_noti',
|
||||
'g5_shop_default_table' => $shop_prefix . 'default',
|
||||
'g5_shop_coupon_table' => $shop_prefix . 'coupon',
|
||||
'g5_shop_coupon_log_table' => $shop_prefix . 'coupon_log',
|
||||
'g5_shop_coupon_zone_table' => $shop_prefix . 'coupon_zone',
|
||||
'g5_shop_inicis_log_table' => $shop_prefix . 'inicis_log',
|
||||
'g5_shop_order_data_table' => $shop_prefix . 'order_data',
|
||||
'g5_shop_order_access_table' => $shop_prefix . 'order_access',
|
||||
'g5_shop_post_log_table' => $shop_prefix . 'order_post_log',
|
||||
'g5_shop_order_cancel_log_table' => $shop_prefix . 'order_cancel_log',
|
||||
'g5_shop_inicis_pay_table' => $shop_prefix . 'inicis_pay',
|
||||
@@ -83,7 +87,7 @@ function g5_migration_parse_condition($directive, $file)
|
||||
return $condition;
|
||||
}
|
||||
|
||||
function g5_migration_parse_file($file)
|
||||
function g5_migration_read_file($file)
|
||||
{
|
||||
$contents = @file_get_contents($file);
|
||||
if ($contents === false) {
|
||||
@@ -108,7 +112,18 @@ 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)) {
|
||||
$migration['contents'] = $contents;
|
||||
return $migration;
|
||||
}
|
||||
|
||||
function g5_migration_parse_file($file)
|
||||
{
|
||||
$migration = g5_migration_read_file($file);
|
||||
if (isset($migration['error'])) return $migration;
|
||||
$contents = $migration['contents'];
|
||||
unset($migration['contents']);
|
||||
|
||||
if (preg_match('/^--[\t ]*@skip-if-column[\t ]+(\S+)[\t ]+(\S+)[\t ]*\r?$/mi', $contents, $matches)) {
|
||||
$migration['skip_table'] = g5_migration_replace_placeholders(trim($matches[1], '`'));
|
||||
$migration['skip_column'] = trim($matches[2], '`');
|
||||
}
|
||||
@@ -301,17 +316,31 @@ function g5_migration_execute_statement($statement)
|
||||
return array('error' => '', 'executed' => $executed);
|
||||
}
|
||||
|
||||
function g5_migration_get_records()
|
||||
function g5_migration_get_records(&$error = null)
|
||||
{
|
||||
$table = g5_migration_table_name();
|
||||
$records = array();
|
||||
$error = '';
|
||||
|
||||
if (!g5_migration_table_exists($table)) {
|
||||
$tables = sql_query("SHOW TABLES LIKE '" . sql_real_escape_string($table) . "'", false);
|
||||
if (!$tables) {
|
||||
$error = '마이그레이션 이력 테이블을 조회하지 못했습니다: ' . sql_error_info();
|
||||
return $records;
|
||||
}
|
||||
$exists = false;
|
||||
while ($row = sql_fetch_array($tables)) {
|
||||
if (reset($row) === $table) $exists = true;
|
||||
}
|
||||
if (!$exists) {
|
||||
return $records;
|
||||
}
|
||||
|
||||
$error = g5_migration_validate_table();
|
||||
if ($error !== '') return $records;
|
||||
|
||||
$result = sql_query("SELECT migration_id, checksum, status, error_message, applied_at FROM `{$table}` ORDER BY migration_id", false);
|
||||
if (!$result) {
|
||||
$error = '마이그레이션 이력을 읽지 못했습니다: ' . sql_error_info();
|
||||
return $records;
|
||||
}
|
||||
|
||||
@@ -397,7 +426,8 @@ function g5_migration_needs_no_execution($migration, &$cache)
|
||||
function g5_migration_status()
|
||||
{
|
||||
$migrations = g5_migration_discover();
|
||||
$records = g5_migration_get_records();
|
||||
$records = g5_migration_get_records($history_error);
|
||||
if ($history_error !== '') return array(array('error' => $history_error));
|
||||
$status = array();
|
||||
$inspection_cache = array();
|
||||
|
||||
@@ -468,6 +498,25 @@ function g5_migration_validate_target_dependencies($migrations, $records, $targe
|
||||
return '';
|
||||
}
|
||||
|
||||
// 조회에서는 구조만 검사하고, 복구를 위한 DDL은 자동 실행하지 않는다.
|
||||
function g5_migration_validate_table($table = null)
|
||||
{
|
||||
if ($table === null) $table = g5_migration_table_name();
|
||||
$columns = 'migration_id, description, checksum, status, error_message, execution_ms, applied_at';
|
||||
if (!sql_query("SELECT {$columns} FROM `{$table}` LIMIT 0", false)) {
|
||||
return "마이그레이션 이력 테이블 `{$table}`의 구조를 확인하지 못했습니다: " . sql_error_info()
|
||||
. ' (docs/database-migrations.md의 이력 테이블 복구 절차를 확인해 주십시오.)';
|
||||
}
|
||||
$indexes = sql_query("SHOW INDEX FROM `{$table}` WHERE Key_name = 'PRIMARY'", false);
|
||||
if (!$indexes) return '마이그레이션 이력 기본키를 확인하지 못했습니다: ' . sql_error_info();
|
||||
$primary = array();
|
||||
while ($row = sql_fetch_array($indexes)) $primary[] = $row['Column_name'];
|
||||
if ($primary !== array('migration_id')) {
|
||||
return "마이그레이션 이력 테이블 `{$table}`의 기본키가 migration_id 단일 컬럼이 아닙니다. docs/database-migrations.md의 이력 테이블 복구 절차를 확인해 주십시오.";
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function g5_migration_ensure_table()
|
||||
{
|
||||
$table = g5_migration_table_name();
|
||||
@@ -485,9 +534,9 @@ function g5_migration_ensure_table()
|
||||
return (bool) sql_query($sql, false);
|
||||
}
|
||||
|
||||
function g5_migration_save_record($migration, $status, $error_message, $execution_ms)
|
||||
function g5_migration_save_record($migration, $status, $error_message, $execution_ms, $table = null)
|
||||
{
|
||||
$table = g5_migration_table_name();
|
||||
if ($table === null) $table = g5_migration_table_name();
|
||||
$id = sql_real_escape_string($migration['id']);
|
||||
$description = sql_real_escape_string($migration['description']);
|
||||
$checksum = sql_real_escape_string($migration['checksum']);
|
||||
@@ -523,7 +572,12 @@ function g5_migration_run($target_id = '', $record_existing = false)
|
||||
}
|
||||
|
||||
$migrations = g5_migration_discover();
|
||||
$records = g5_migration_get_records();
|
||||
$records = g5_migration_get_records($history_error);
|
||||
if ($history_error !== '') {
|
||||
$result['errors'][] = $history_error;
|
||||
sql_query("SELECT RELEASE_LOCK('{$lock_name}')", false);
|
||||
return $result;
|
||||
}
|
||||
$target_found = $target_id === '';
|
||||
|
||||
if ($record_existing) {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
// KVE-2026-2345: 요청의 옵션 종류는 가격 계산의 근거가 될 수 없다.
|
||||
// DB 조회와 분리하여 신규 요청과 기존 장바구니에 같은 규칙을 적용한다.
|
||||
function shop_validate_cart_rows($item, $options, $rows, $check_price = false)
|
||||
{
|
||||
$error = '상품 또는 옵션 정보가 올바르지 않습니다. 장바구니에서 삭제한 뒤 다시 담아 주십시오.';
|
||||
if (empty($item['it_id']) || empty($item['it_use']) || !empty($item['it_tel_inq']) || !$rows) {
|
||||
return array('error' => $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 '';
|
||||
}
|
||||
+41
-1
@@ -82,9 +82,49 @@ function get_shop_navigation_data($is_cache, $ca_id, $ca_id2='', $ca_id3=''){
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 전체 분류 트리는 기존 3단계 구조를 유지하고 현재 경로의 깊은 단계만 조회한다.
|
||||
for ($depth = 4; $depth <= 5 && strlen($ca_id) >= $depth * 2; $depth++) {
|
||||
$parent = substr($ca_id, 0, ($depth - 1) * 2);
|
||||
$result = sql_query(get_shop_category_sql($parent, $depth * 2));
|
||||
while ($row = sql_fetch_array($result)) {
|
||||
$row['url'] = shop_category_url($row['ca_id']);
|
||||
$datas[$depth - 1][] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $datas;
|
||||
}
|
||||
|
||||
// 4·5단계에서는 선택 경로의 5단계를 우선 표시하고, 없으면 4단계 형제를 표시한다.
|
||||
function get_shop_category_menu_groups($ca_id)
|
||||
{
|
||||
$length = strlen($ca_id);
|
||||
$groups = array();
|
||||
if ($length >= 8) {
|
||||
$queries = array(
|
||||
array('5단계 분류', substr($ca_id, 0, 8), 10),
|
||||
array('4단계 분류', substr($ca_id, 0, 6), 8),
|
||||
);
|
||||
} else {
|
||||
$queries = array(array('하위 분류', $ca_id, $length + 2));
|
||||
}
|
||||
foreach ($queries as $query) {
|
||||
$rows = array();
|
||||
$result = sql_query(get_shop_category_sql($query[1], $query[2]));
|
||||
while ($row = sql_fetch_array($result)) $rows[] = $row;
|
||||
if (!$rows && $length < 8) {
|
||||
$query[0] = '현재 단계 분류';
|
||||
$result = sql_query(get_shop_category_sql(substr($ca_id, 0, -2), $length));
|
||||
while ($row = sql_fetch_array($result)) $rows[] = $row;
|
||||
}
|
||||
if ($rows) {
|
||||
$groups[] = array('label' => $query[0], 'categories' => $rows);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
function get_shop_category_by($is_cache, $case, $value){
|
||||
|
||||
if( $case === 'ca_id' ){
|
||||
@@ -268,4 +308,4 @@ function get_shop_item_options($it_id, $subject, $no)
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '<input type="radio" id="'.$id.'" name="od_settle_case" data-pay="'.$provider[1].'" value="'.$value.'"'.$attrs.'> <label for="'.$id.'" class="'.$provider[2].' '.$key.' lb_icon" title="'.$provider[0].'">'.$provider[0].'</label>';
|
||||
$attrs = isset($provider[3]) ? ' data-case="'.$pay_code.'"' : '';
|
||||
if ($money) {
|
||||
$attrs .= ' data-money="1"';
|
||||
}
|
||||
|
||||
$html = '<input type="radio" id="'.$id.'" name="od_settle_case"'
|
||||
.' data-pay="'.$pay_code.'" value="'.$value.'"'.$attrs.'> '
|
||||
.'<label for="'.$id.'" class="'.$css_class.' '.$key.' lb_icon"'
|
||||
.' title="'.$label.'">'.$label.'</label>';
|
||||
|
||||
return $mobile ? '<li>'.$html.'</li>' : $html;
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
include_once(dirname(__FILE__).'/shop.cartvalidate.lib.php');
|
||||
//==============================================================================
|
||||
// 쇼핑몰 라이브러리 모음 시작
|
||||
//==============================================================================
|
||||
@@ -2694,7 +2695,7 @@ function before_check_cart_price($s_cart_id, $is_ct_select_condition=false, $is_
|
||||
}
|
||||
|
||||
if( $row['io_id'] ){
|
||||
$io_sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '{$it['it_id']}' and io_id = '{$row['io_id']}' ";
|
||||
$io_sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '{$it['it_id']}' and io_id = '{$row['io_id']}' and io_type = '{$row['io_type']}' and io_use = '1' ";
|
||||
$io_infos = sql_fetch( $io_sql );
|
||||
|
||||
if( $io_infos['io_type'] ){
|
||||
@@ -2828,7 +2829,9 @@ function add_order_post_log($msg='', $code='error'){
|
||||
|
||||
if( empty($_POST) ) return;
|
||||
|
||||
$post_data = base64_encode(serialize($_POST));
|
||||
$log_data = $_POST;
|
||||
unset($log_data['od_pwd'], $log_data['g5_order_state'], $log_data['g5_checkout_nonce']);
|
||||
$post_data = base64_encode(serialize($log_data));
|
||||
$od_id = get_session('ss_order_id');
|
||||
|
||||
if( $code === 'delete' ){
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
include_once(dirname(__FILE__).'/shop_order_compat.lib.php');
|
||||
include_once(dirname(__FILE__).'/shop_order_state.lib.php');
|
||||
include_once(dirname(__FILE__).'/shop_order_fields.lib.php');
|
||||
|
||||
// 결제 복귀는 주문번호가 아니라 임시 저장 시 발급한 서버 세션 상태로 인증한다.
|
||||
function shop_order_access_fail()
|
||||
{
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
alert('결제 요청을 확인할 수 없습니다. 결제 내역을 확인한 후 다시 시도해 주십시오.', G5_SHOP_URL);
|
||||
exit;
|
||||
}
|
||||
|
||||
function shop_order_access_id($value)
|
||||
{
|
||||
return is_string($value) && preg_match('/\A[0-9]{1,20}\z/D', $value);
|
||||
}
|
||||
|
||||
function shop_order_access_load_legacy($order_id, $pg)
|
||||
{
|
||||
global $g5, $member;
|
||||
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
if (!shop_order_access_id($order_id)) shop_order_access_fail();
|
||||
$states = get_session('ss_order_data_access');
|
||||
$state = is_array($states) && isset($states[$order_id]) ? $states[$order_id] : null;
|
||||
$member_id = isset($member['mb_id']) ? (string)$member['mb_id'] : '';
|
||||
if (!is_array($state) || $state['pg'] !== $pg || $state['member'] !== $member_id ||
|
||||
$state['expires'] <= G5_SERVER_TIME) shop_order_access_fail();
|
||||
|
||||
if ($state['personal']) {
|
||||
if ((string)get_session('ss_personalpay_id') !== $order_id ||
|
||||
!$state['personal_hash'] ||
|
||||
$state['personal_hash'] !== get_session('ss_personalpay_hash')) shop_order_access_fail();
|
||||
$done = sql_fetch("select pp_tno, pp_use from {$g5['g5_shop_personalpay_table']} where pp_id = '$order_id'");
|
||||
if (!$done || !$done['pp_use'] || $done['pp_tno']) shop_order_access_fail();
|
||||
} else {
|
||||
$cart = get_session($state['direct'] ? 'ss_cart_direct' : 'ss_cart_id');
|
||||
if ((string)get_session('ss_order_id') !== $order_id ||
|
||||
(bool)get_session('ss_direct') !== $state['direct'] ||
|
||||
!$cart || (string)$cart !== $state['cart']) shop_order_access_fail();
|
||||
$done = sql_fetch("select od_id from {$g5['g5_shop_order_table']} where od_id = '$order_id'");
|
||||
if (!empty($done['od_id'])) shop_order_access_fail();
|
||||
}
|
||||
|
||||
// 중복 행을 임의로 선택하지 않고, 최소 메타데이터부터 대조한다.
|
||||
$rows = sql_query("select cart_id, mb_id, dt_pg, dt_time from {$g5['g5_shop_order_data_table']} where od_id = '$order_id'");
|
||||
if (sql_num_rows($rows) !== 1) shop_order_access_fail();
|
||||
$meta = sql_fetch_array($rows);
|
||||
if ((string)$meta['cart_id'] !== $state['cart'] || $meta['mb_id'] !== $state['member'] ||
|
||||
$meta['dt_pg'] !== $pg || $meta['dt_time'] !== $state['time']) shop_order_access_fail();
|
||||
|
||||
$row = sql_fetch("select dt_data from {$g5['g5_shop_order_data_table']} where od_id = '$order_id' and dt_pg = '".sql_escape_string($pg)."'");
|
||||
if (empty($row['dt_data']) || !shop_order_equals($state['digest'], hash('sha256', $row['dt_data']))) shop_order_access_fail();
|
||||
$data = shop_order_decode_data($row['dt_data']);
|
||||
if (!is_array($data) || (!empty($data['pp_id']) !== $state['personal']) ||
|
||||
($state['personal'] && (string)$data['pp_id'] !== $order_id)) shop_order_access_fail();
|
||||
return $data;
|
||||
}
|
||||
|
||||
function shop_order_access_load($order_id, $pg)
|
||||
{
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
return shop_order_state_load($order_id, $pg);
|
||||
}
|
||||
|
||||
function shop_order_access_payment($order_id, $payment_key, $amount = null)
|
||||
{
|
||||
if (!is_string($payment_key) || !preg_match('/\A[A-Za-z0-9_-]{1,200}\z/D', $payment_key)) shop_order_access_fail();
|
||||
if ($amount !== null && (!is_string($amount) || !preg_match('/\A[0-9]{1,12}\z/D', $amount))) shop_order_access_fail();
|
||||
$data = shop_order_access_load($order_id, 'toss');
|
||||
if ($amount !== null && (!is_string($amount) || !preg_match('/\A[0-9]{1,12}\z/D', $amount) ||
|
||||
!isset($data['amountValue']) || (int)$amount <= 0 || (int)$amount !== (int)$data['amountValue'])) shop_order_access_fail();
|
||||
$states = get_session('ss_order_data_access');
|
||||
if (isset($states[$order_id]['payment_key']) && $states[$order_id]['payment_key'] !== $payment_key) shop_order_access_fail();
|
||||
$states[$order_id]['payment_key'] = $payment_key;
|
||||
shop_order_state_write($order_id, array('payment_key'=>$payment_key));
|
||||
set_session('ss_order_data_access', $states);
|
||||
$data['paymentKey'] = $payment_key;
|
||||
$data['orderId'] = $order_id;
|
||||
return $data;
|
||||
}
|
||||
|
||||
// 클라이언트가 보낸 해시를 신뢰하지 않고 저장 당시 서버 상태만 사용한다.
|
||||
function shop_order_access_password($order_id)
|
||||
{
|
||||
$states = get_session('ss_order_data_access');
|
||||
if (!is_array($states) || !isset($states[$order_id])) return null;
|
||||
$state = $states[$order_id];
|
||||
shop_order_access_load($order_id, $state['pg']);
|
||||
if ($state['personal'] || empty($state['password_hash'])) shop_order_access_fail();
|
||||
return $state['password_hash'];
|
||||
}
|
||||
|
||||
function shop_order_access_forget($order_id)
|
||||
{
|
||||
shop_order_state_complete($order_id);
|
||||
$states = get_session('ss_order_data_access');
|
||||
if (is_array($states)) {
|
||||
unset($states[$order_id]);
|
||||
set_session('ss_order_data_access', $states);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
// PHP 5.2.17에서도 인증용 난수는 강한 난수원만 사용한다.
|
||||
function shop_order_random_hex($length)
|
||||
{
|
||||
$bytes = false;
|
||||
if (function_exists('random_bytes')) {
|
||||
try { $bytes = random_bytes($length); } catch (Exception $e) { $bytes = false; }
|
||||
}
|
||||
if ($bytes === false && function_exists('openssl_random_pseudo_bytes')) {
|
||||
$strong = false;
|
||||
$candidate = openssl_random_pseudo_bytes($length, $strong);
|
||||
if ($strong) $bytes = $candidate;
|
||||
}
|
||||
if ($bytes === false && DIRECTORY_SEPARATOR === '/') {
|
||||
$stream = @fopen('/dev/urandom', 'rb');
|
||||
if ($stream) {
|
||||
$bytes = '';
|
||||
while (strlen($bytes) < $length) {
|
||||
$part = @fread($stream, $length - strlen($bytes));
|
||||
if ($part === false || $part === '') break;
|
||||
$bytes .= $part;
|
||||
}
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
if ($bytes === false && function_exists('mcrypt_create_iv') && defined('MCRYPT_DEV_URANDOM')) {
|
||||
$bytes = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);
|
||||
}
|
||||
if (!is_string($bytes) || strlen($bytes) !== $length) shop_order_access_fail();
|
||||
return bin2hex($bytes);
|
||||
}
|
||||
|
||||
// 토큰·다이제스트는 길이가 공개된 고정 길이 값이다. 같은 길이는 끝까지 비교한다.
|
||||
function shop_order_equals($known, $input)
|
||||
{
|
||||
if (!is_string($known) || !is_string($input) || strlen($known) !== strlen($input)) return false;
|
||||
$difference = 0;
|
||||
for ($i = 0, $length = strlen($known); $i < $length; $i++) {
|
||||
$difference |= ord($known[$i]) ^ ord($input[$i]);
|
||||
}
|
||||
return $difference === 0;
|
||||
}
|
||||
|
||||
// PHP 버전과 무관하게 객체·참조를 생성하지 않는다. 기존 serialize/base64 행과 호환한다.
|
||||
function shop_order_decode_data($encoded)
|
||||
{
|
||||
if (!is_string($encoded) || strlen($encoded) > 1398104) return false;
|
||||
$serialized = base64_decode($encoded, true);
|
||||
if ($serialized === false || strlen($serialized) > 1048576) return false;
|
||||
$offset = 0;
|
||||
$nodes = 0;
|
||||
$valid = true;
|
||||
$value = shop_order_decode_value($serialized, $offset, $nodes, $valid, 0);
|
||||
return $valid && $offset === strlen($serialized) && is_array($value) ? $value : false;
|
||||
}
|
||||
|
||||
function shop_order_decode_value($data, &$offset, &$nodes, &$valid, $depth)
|
||||
{
|
||||
if (!$valid || $depth > 32 || ++$nodes > 20000 || $offset >= strlen($data)) {
|
||||
$valid = false;
|
||||
return null;
|
||||
}
|
||||
$type = $data[$offset++];
|
||||
if ($type === 'N' && substr($data, $offset++, 1) === ';') return null;
|
||||
if (substr($data, $offset++, 1) !== ':') { $valid = false; return null; }
|
||||
if ($type === 's' || $type === 'a') {
|
||||
$end = strpos($data, ':', $offset);
|
||||
if ($end === false) { $valid = false; return null; }
|
||||
$number = substr($data, $offset, $end - $offset);
|
||||
if (!preg_match('/\A(?:0|[1-9][0-9]{0,6})\z/D', $number)) { $valid = false; return null; }
|
||||
$count = (int)$number;
|
||||
$offset = $end + 1;
|
||||
if ($type === 's') {
|
||||
if (substr($data, $offset++, 1) !== '"' || $count > strlen($data) - $offset - 2) { $valid = false; return null; }
|
||||
$value = substr($data, $offset, $count);
|
||||
$offset += $count;
|
||||
if (substr($data, $offset, 2) !== '";') $valid = false;
|
||||
$offset += 2;
|
||||
return $value;
|
||||
}
|
||||
if ($count > 10000 || substr($data, $offset++, 1) !== '{') { $valid = false; return null; }
|
||||
$value = array();
|
||||
for ($i = 0; $i < $count && $valid; $i++) {
|
||||
$key = shop_order_decode_value($data, $offset, $nodes, $valid, $depth + 1);
|
||||
if (!is_int($key) && !is_string($key)) { $valid = false; break; }
|
||||
$item = shop_order_decode_value($data, $offset, $nodes, $valid, $depth + 1);
|
||||
if ($valid) $value[$key] = $item;
|
||||
}
|
||||
if (substr($data, $offset++, 1) !== '}') $valid = false;
|
||||
return $value;
|
||||
}
|
||||
if ($type === 'b' || $type === 'i' || $type === 'd') {
|
||||
$end = strpos($data, ';', $offset);
|
||||
if ($end === false) { $valid = false; return null; }
|
||||
$number = substr($data, $offset, $end - $offset);
|
||||
$offset = $end + 1;
|
||||
if ($type === 'b' && ($number === '0' || $number === '1')) return $number === '1';
|
||||
if ($type === 'i' && preg_match('/\A-?(?:0|[1-9][0-9]*)\z/D', $number) && (string)(int)$number === $number) return (int)$number;
|
||||
if ($type === 'd' && preg_match('/\A-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?\z/D', $number) && is_finite((float)$number)) return (float)$number;
|
||||
}
|
||||
$valid = false;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
// 기본 주문서와 PG별 폼 필드의 명시적 허용 목록. 비밀번호/상태 토큰은 별도 저장한다.
|
||||
function shop_order_allowed_fields($pg)
|
||||
{
|
||||
$common = explode(' ', 'inicis_pro nhnkcp_pay_case ad_default ad_sel_addr ad_subject amountValue cp_id cp_price f_cp_id f_cp_prc it_id it_name it_notax it_price item_coupon max_temp_point naverpay_point_direct o_cp_id o_cp_prc od_addr1 od_addr2 od_addr3 od_addr_jibeon od_b_addr1 od_b_addr2 od_b_addr3 od_b_addr_jibeon od_b_hp od_b_name od_b_tel od_b_zip od_bank_account od_coupon od_cp_id od_deposit_name od_email od_goods_name od_hope_date od_hp od_ip od_memo od_name od_price od_send_cost od_send_cost2 od_send_coupon od_settle_case od_tel od_temp_point od_test od_zip org_od_price post_cart_id pp_email pp_hp pp_id pp_name pp_settle_case s_cp_id s_cp_prc sc_cp_id sw_direct');
|
||||
$pg_fields = array(
|
||||
'toss' => 'amountCurrency buyeremail buyertel cardUseAppCardOnly cardUseCardPoint cardUseEscrow cardeasyPay cardflowMode comm_free_mny comm_tax_mny comm_vat_mny customerEmail customerMobilePhone customerName escrowProducts good_mny id_info method od_id orderId orderName submitChecked taxFreeAmount tr_code tx windowTarget',
|
||||
'kcp' => 'ActionResult KCP_PAY_MODULE Ret_URL amt_sup amt_svc amt_tax amt_tot applepay_direct approval_key bank_issu bank_name bask_cntx buyr_mail buyr_name buyr_tel1 buyr_tel2 cash_authno cash_id_info cash_tr_code cash_tsdtime cash_yn comm_free_mny comm_tax_mny comm_vat_mny complex_pnt_yn corp_type currency def_site_cd deli_term disp_tax_yn enc_data enc_info eng_flag epnt_issu escrow_foot escw_used fix_inst good_cd good_expr good_info good_mny good_name id_info ipgm_date kakaopay_direct kcp_noint kcp_noint_quota module_type naverpay_direct nhnkcp_pay_case not_used_card ordr_idxx param_opt_1 param_opt_2 param_opt_3 pay_method pay_mod payco_direct pt_memcorp_cd quotaopt rcvr_add1 rcvr_add2 rcvr_mail rcvr_name rcvr_tel1 rcvr_tel2 rcvr_zipx req_tx res_cd res_msg ret_pay_method save_ocb settle_method shop_name shop_user_id site_cd site_logo site_name skin_indx submitChecked tablet_size tar_opener tax_flag tk_shop_id tno tr_code trace_no trad_time tran_cd tx use_pay_method used_card used_card_CCXX used_card_YN vcnt_expire_term vcnt_expire_term_time wish_vbank_list',
|
||||
'inicis' => 'gopaymethod DEF_RESERVED P_AMT P_APPL_NUM P_AUTH_DT P_AUTH_NO P_CARD_ISSUER P_EMAIL P_GOODS P_HASH P_HPP_CORP P_HPP_METHOD P_MID P_MOBILE P_NEXT_URL P_NOTI P_NOTI_URL P_OID P_QUOTABASE P_RESERVED P_RETURN_URL P_SKIP_TERMS P_TAX P_TAXFREE P_TYPE P_UNAME P_VACT_BANK P_VACT_NAME P_VACT_NUM acceptmethod buyeremail buyername buyertel charset closeUrl comm_free_mny comm_tax_mny comm_vat_mny currency good_mny goodname id_info ini_logoimage_url ini_menuarea_url mKey mid nointerest od_id oid parentemail payViewType popupUrl price quotabase recvaddr recvname recvpostnum recvtel res_cd returnUrl signature submitChecked tax taxfree timestamp tr_code tx version',
|
||||
'lg' => 'CST_MID CST_PLATFORM LGD_AMOUNT LGD_BUYER LGD_BUYERADDRESS LGD_BUYEREMAIL LGD_BUYERID LGD_BUYERIP LGD_BUYERPHONE LGD_CASHRECEIPTYN LGD_CASNOTEURL LGD_CUSTOM_FIRSTPAY LGD_CUSTOM_PROCESSTYPE LGD_CUSTOM_SKIN LGD_CUSTOM_USABLEPAY LGD_EASYPAY_ONLY LGD_ENCODING LGD_ENCODING_RETURNURL LGD_ESCROW_ADDRESS1 LGD_ESCROW_ADDRESS2 LGD_ESCROW_BUYERPHONE LGD_ESCROW_ZIPCODE LGD_HASHDATA LGD_MID LGD_OID LGD_PAYKEY LGD_PRODUCTINFO LGD_RECEIVER LGD_RECEIVERPHONE LGD_RETURNURL LGD_TAXFREEAMOUNT LGD_TIMESTAMP LGD_VERSION LGD_WINDOW_VER comm_free_mny comm_tax_mny comm_vat_mny good_mny id_info od_id res_cd submitChecked tr_code tx',
|
||||
'nicepay' => 'Amt BuyerEmail BuyerName BuyerTel CharSet DirectEasyPay DirectShowOpt EasyPayCardCode EasyPayMethod EasyPayQuota EdiDate GoodsCl GoodsName GoodsVat MID Moid MultiEasyPayQuota NicepayReserved NpLang PayMethod ReqReserved ReturnURL SelectCardCode SelectQuota ServiceAmt SignData SupplyAmt TaxFreeAmt TransType VbankExpDate buyeremail buyertel comm_free_mny comm_tax_mny comm_vat_mny good_mny id_info od_id submitChecked tr_code tx',
|
||||
'samsungpay' => 'DEF_RESERVED P_AMT P_APPL_NUM P_AUTH_DT P_AUTH_NO P_CARD_ISSUER P_EMAIL P_GOODS P_HASH P_HPP_CORP P_HPP_METHOD P_MID P_MOBILE P_NEXT_URL P_NOTI P_NOTI_URL P_OID P_QUOTABASE P_RESERVED P_RETURN_URL P_SKIP_TERMS P_TAX P_TAXFREE P_TYPE P_UNAME P_VACT_BANK P_VACT_NAME P_VACT_NUM good_mny res_cd samsungpay_form',
|
||||
);
|
||||
return array_unique(array_merge($common, isset($pg_fields[$pg]) ? explode(' ', $pg_fields[$pg]) : array()));
|
||||
}
|
||||
|
||||
function shop_order_filter_data($input, $pg)
|
||||
{
|
||||
$out = array();
|
||||
foreach (shop_order_allowed_fields($pg) as $key) {
|
||||
if (!array_key_exists($key, $input)) continue;
|
||||
$value = $input[$key];
|
||||
if (is_array($value)) {
|
||||
if (count($value) > 1000) shop_order_access_fail();
|
||||
foreach ($value as $k=>$v) {
|
||||
if (!is_scalar($v) || strlen((string)$v) > 65536 || !preg_match('/\A[0-9A-Za-z_-]{1,100}\z/D', (string)$k)) shop_order_access_fail();
|
||||
}
|
||||
} elseif (!is_scalar($value) || strlen((string)$value) > 65536) shop_order_access_fail();
|
||||
$out[$key] = $value;
|
||||
}
|
||||
if (strlen(serialize($out)) > 1048576) shop_order_access_fail();
|
||||
return $out;
|
||||
}
|
||||
|
||||
function shop_order_toss_providers()
|
||||
{
|
||||
$providers = array();
|
||||
foreach (shop_easypay_catalog('toss') as $provider) $providers[] = $provider[1];
|
||||
return $providers;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
function shop_order_cleanup($apply = false, $grace = 86400)
|
||||
{
|
||||
global $g5;
|
||||
$cutoff = G5_SERVER_TIME - max(3600, (int)$grace);
|
||||
$table = shop_order_state_table();
|
||||
$rows = sql_query("select od_id from $table where (status in ('completed','failed','cancelled') and updated_at < $cutoff) or (status='pending' and pg in ('toss','kcp') and expires < $cutoff) order by od_id limit 500");
|
||||
$result = array();
|
||||
while ($candidate = sql_fetch_array($rows)) {
|
||||
$id = $candidate['od_id']; shop_order_state_lock('order', $id);
|
||||
$row = shop_order_state_row($id);
|
||||
if (!$row) { shop_order_state_unlock('order', $id); continue; }
|
||||
$safe = (in_array($row['status'], array('completed','failed','cancelled'), true) && (int)$row['updated_at'] < $cutoff) ||
|
||||
($row['status'] === 'pending' && in_array($row['pg'], array('toss','kcp'), true) && (int)$row['expires'] < $cutoff);
|
||||
if (!$safe) { shop_order_state_unlock('order', $id); continue; }
|
||||
$result[] = array('order'=>$id, 'status'=>$row['status'], 'action'=>$apply ? 'purged' : 'would_purge');
|
||||
if ($apply) {
|
||||
$pg = sql_escape_string($row['pg']);
|
||||
if (!sql_query("delete from {$g5['g5_shop_order_data_table']} where od_id='$id' and dt_pg='$pg'", false)) shop_order_access_fail();
|
||||
shop_order_state_write($id, array('status'=>'purged','state_json'=>'','response_json'=>'','token_hash'=>'','payment_key'=>''));
|
||||
}
|
||||
shop_order_state_unlock('order', $id);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
function shop_order_quarantine_legacy($id)
|
||||
{
|
||||
global $g5;
|
||||
shop_order_state_lock('order', $id);
|
||||
if (shop_order_state_row($id)) throw new RuntimeException('이미 상태 기록이 있는 주문입니다.');
|
||||
$res = sql_query("select * from {$g5['g5_shop_order_data_table']} where od_id='$id'");
|
||||
if (sql_num_rows($res) !== 1) throw new RuntimeException('임시 주문을 하나로 확정할 수 없습니다.');
|
||||
$row = sql_fetch_array($res);
|
||||
$data = shop_order_decode_data($row['dt_data']);
|
||||
if (!is_array($data)) throw new RuntimeException('임시 데이터 형식을 확인해 주십시오.');
|
||||
$password_hash = isset($data['od_pwd']) && is_string($data['od_pwd']) ? get_encrypt_string($data['od_pwd']) : '';
|
||||
unset($data['od_pwd']);
|
||||
$data = shop_order_filter_data($data, $row['dt_pg']);
|
||||
$encoded = base64_encode(serialize($data));
|
||||
// 먼저 접근 불가능한 상태를 기록한다. 세션이 남아 있어도 자동 전환하지 않는다.
|
||||
$state = sql_escape_string(json_encode(array('password_hash'=>$password_hash, 'legacy'=>true)));
|
||||
$pg = sql_escape_string($row['dt_pg']);
|
||||
if (!sql_query("insert into ".shop_order_state_table()." set od_id='$id',pg='$pg',status='legacy',state_json='$state',response_json='',updated_at=".G5_SERVER_TIME, false)) shop_order_access_fail();
|
||||
if (!sql_query("update {$g5['g5_shop_order_data_table']} set dt_data='$encoded' where od_id='$id'", false)) shop_order_access_fail();
|
||||
return array('order'=>$id, 'status'=>'legacy', 'plaintext_removed'=>true, 'token_issued'=>false);
|
||||
}
|
||||
|
||||
function shop_order_reconcile_toss($id, $toss)
|
||||
{
|
||||
global $g5;
|
||||
shop_order_state_lock('order', $id);
|
||||
$row = shop_order_state_row($id);
|
||||
$state = $row ? json_decode($row['state_json'], true) : null;
|
||||
if (!$row || $row['pg'] !== 'toss' || empty($state['expected_amount']) || $row['payment_key'] === '' ||
|
||||
!in_array($row['status'], array('approving','approved','finalizing','unknown','cancel_pending'), true)) throw new RuntimeException('승인 대조가 필요한 Toss 주문이 아닙니다.');
|
||||
if (!$toss->getPaymentByOrderId($id)) throw new RuntimeException('PG 조회 실패: 상태를 변경하지 않았습니다.');
|
||||
$p = $toss->responseData;
|
||||
if (!isset($p['orderId'],$p['paymentKey'],$p['totalAmount'],$p['status']) || $p['orderId'] !== $id ||
|
||||
$p['paymentKey'] !== $row['payment_key'] || (int)$p['totalAmount'] !== (int)$state['expected_amount']) throw new RuntimeException('PG 주문번호·키·금액이 일치하지 않습니다.');
|
||||
if ($p['status'] === 'CANCELED') {
|
||||
shop_order_state_write($id, array('status'=>'cancelled'));
|
||||
return array('order'=>$id, 'status'=>'cancelled');
|
||||
}
|
||||
if (!($p['status'] === 'DONE' || ($p['status'] === 'WAITING_FOR_DEPOSIT' && isset($p['method']) && $p['method'] === '가상계좌'))) throw new RuntimeException('PG 거래를 재승인하지 말고 별도 확인해 주십시오.');
|
||||
$saved = $state['personal'] ? sql_fetch("select pp_tno as tno from {$g5['g5_shop_personalpay_table']} where pp_id='$id'") : sql_fetch("select od_tno as tno from {$g5['g5_shop_order_table']} where od_id='$id'");
|
||||
if (($state['personal'] && !empty($saved['tno'])) || (!$state['personal'] && $saved)) {
|
||||
shop_order_state_write($id, array('status'=>'finalizing'));
|
||||
return array('order'=>$id, 'status'=>'finalizing', 'action'=>'주문·장바구니·포인트·쿠폰의 부분 저장 대조 필요');
|
||||
}
|
||||
shop_order_state_approved($id, $p);
|
||||
return array('order'=>$id, 'status'=>'approved', 'action'=>'원래 복귀 토큰으로 재시도 가능, 신규 승인 호출 없음');
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
// 주문별 복귀 인증과 승인 기록. DDL은 설치 SQL/명시적 마이그레이션에서만 실행한다.
|
||||
function &shop_order_runtime()
|
||||
{
|
||||
static $runtime = array('locks'=>array(), 'rows'=>array(), 'active'=>'');
|
||||
return $runtime;
|
||||
}
|
||||
|
||||
function shop_order_state_table()
|
||||
{
|
||||
global $g5;
|
||||
return isset($g5['g5_shop_order_access_table']) ? $g5['g5_shop_order_access_table'] : G5_SHOP_TABLE_PREFIX.'order_access';
|
||||
}
|
||||
|
||||
function shop_order_lock_name($kind, $id)
|
||||
{
|
||||
return substr('g5oa_'.hash('sha256', G5_MYSQL_DB.G5_SHOP_TABLE_PREFIX.$kind.$id), 0, 64);
|
||||
}
|
||||
|
||||
function shop_order_state_lock($kind, $id)
|
||||
{
|
||||
$r =& shop_order_runtime();
|
||||
$key = shop_order_lock_name($kind, $id);
|
||||
shop_order_state_check_locks();
|
||||
if (isset($r['locks'][$key])) return;
|
||||
// MySQL 5.0은 연결당 이름 잠금 하나만 유지한다. 업무/포인트 연결과도 분리한다.
|
||||
$mysqli = function_exists('mysqli_connect') && G5_MYSQLI_USE;
|
||||
$host = G5_MYSQL_HOST;
|
||||
if (substr($host, 0, 2) === 'p:') $host = substr($host, 2);
|
||||
if ($mysqli) {
|
||||
try { $link = @mysqli_connect($host, G5_MYSQL_USER, G5_MYSQL_PASSWORD, G5_MYSQL_DB); }
|
||||
catch (Exception $e) { $link = false; }
|
||||
} else {
|
||||
$link = @mysql_connect($host, G5_MYSQL_USER, G5_MYSQL_PASSWORD, true);
|
||||
}
|
||||
if (!$link) shop_order_access_fail();
|
||||
$lock = array('link'=>$link, 'mysqli'=>$mysqli);
|
||||
// 실패나 예외에서도 이 연결만 닫아 획득 중인 잠금까지 해제한다.
|
||||
if (empty($r['shutdown_registered'])) {
|
||||
register_shutdown_function('shop_order_state_unlock_all');
|
||||
$r['shutdown_registered'] = true;
|
||||
}
|
||||
$r['locks'][$key] = $lock;
|
||||
if ($mysqli) {
|
||||
try { $result = @mysqli_query($link, "SELECT GET_LOCK('$key', 3) AS acquired"); }
|
||||
catch (Exception $e) { $result = false; }
|
||||
$row = $result ? mysqli_fetch_assoc($result) : false;
|
||||
} else {
|
||||
$result = @mysql_query("SELECT GET_LOCK('$key', 3) AS acquired", $link);
|
||||
$row = $result ? mysql_fetch_assoc($result) : false;
|
||||
}
|
||||
if (!$row || (int)$row['acquired'] !== 1) {
|
||||
shop_order_state_unlock($kind, $id);
|
||||
shop_order_access_fail();
|
||||
}
|
||||
}
|
||||
|
||||
// 잠금 연결이 끊겼거나 자동 재접속되면 기존 잠금을 보유한 것으로 취급하지 않는다.
|
||||
function shop_order_state_check_locks()
|
||||
{
|
||||
$r =& shop_order_runtime();
|
||||
foreach ($r['locks'] as $key=>$lock) {
|
||||
$sql = "SELECT IS_USED_LOCK('$key') = CONNECTION_ID() AS owned";
|
||||
if ($lock['mysqli']) {
|
||||
try { $result = @mysqli_query($lock['link'], $sql); }
|
||||
catch (Exception $e) { $result = false; }
|
||||
$row = $result ? mysqli_fetch_assoc($result) : false;
|
||||
} else {
|
||||
$result = @mysql_query($sql, $lock['link']);
|
||||
$row = $result ? mysql_fetch_assoc($result) : false;
|
||||
}
|
||||
if (!$row || (int)$row['owned'] !== 1) shop_order_access_fail();
|
||||
}
|
||||
}
|
||||
|
||||
function shop_order_state_unlock($kind, $id)
|
||||
{
|
||||
$r =& shop_order_runtime();
|
||||
$key = shop_order_lock_name($kind, $id);
|
||||
if (!isset($r['locks'][$key])) return;
|
||||
$lock = $r['locks'][$key];
|
||||
unset($r['locks'][$key]);
|
||||
// 비영속 연결 종료 자체가 해당 연결의 잠금을 해제한다.
|
||||
if ($lock['mysqli']) mysqli_close($lock['link']);
|
||||
else mysql_close($lock['link']);
|
||||
}
|
||||
|
||||
function shop_order_state_unlock_all()
|
||||
{
|
||||
$r =& shop_order_runtime();
|
||||
foreach ($r['locks'] as $lock) {
|
||||
if ($lock['mysqli']) mysqli_close($lock['link']);
|
||||
else mysql_close($lock['link']);
|
||||
}
|
||||
$r['locks'] = array();
|
||||
}
|
||||
|
||||
function shop_order_state_row($id)
|
||||
{
|
||||
if (!shop_order_access_id((string)$id)) shop_order_access_fail();
|
||||
return sql_fetch("select * from ".shop_order_state_table()." where od_id='$id'", false);
|
||||
}
|
||||
|
||||
function shop_order_state_meta($id)
|
||||
{
|
||||
if (!shop_order_access_id((string)$id)) shop_order_access_fail();
|
||||
return sql_fetch("select od_id,token_hash,pg,status,expires from ".shop_order_state_table()." where od_id='$id'", false);
|
||||
}
|
||||
|
||||
function shop_order_state_write($id, $fields)
|
||||
{
|
||||
shop_order_state_check_locks();
|
||||
$allowed = array('status','payment_key','response_json','state_json','token_hash','expires','updated_at');
|
||||
$set = array();
|
||||
foreach ($fields as $key=>$value) {
|
||||
if (!in_array($key, $allowed, true)) shop_order_access_fail();
|
||||
$set[] = "$key='".sql_escape_string((string)$value)."'";
|
||||
}
|
||||
$set[] = 'updated_at='.G5_SERVER_TIME;
|
||||
if (!sql_query("update ".shop_order_state_table()." set ".implode(',', $set)." where od_id='$id'", false)) shop_order_access_fail();
|
||||
}
|
||||
|
||||
function shop_order_state_token($id)
|
||||
{
|
||||
$token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : '';
|
||||
if ($token === '') {
|
||||
$states = get_session('ss_order_data_access');
|
||||
$token = isset($states[$id]['token']) ? $states[$id]['token'] : '';
|
||||
}
|
||||
if (!is_string($token) || !preg_match('/\A'.preg_quote((string)$id, '/').'\.[a-f0-9]{64}\z/D', $token)) return '';
|
||||
return $token;
|
||||
}
|
||||
|
||||
function shop_order_checkout_fields($id, $personal = false)
|
||||
{
|
||||
if (!shop_order_access_id((string)$id)) return '';
|
||||
$map = get_session('ss_order_checkouts');
|
||||
if (!is_array($map)) $map = array();
|
||||
foreach ($map as $key=>$value) if ($value['expires'] <= G5_SERVER_TIME) unset($map[$key]);
|
||||
if (count($map) >= 30) array_shift($map);
|
||||
$nonce = shop_order_random_hex(24);
|
||||
$map[(string)$id] = array('nonce'=>$nonce, 'expires'=>G5_SERVER_TIME+7200,
|
||||
'cart'=>(string)get_session(get_session('ss_direct') ? 'ss_cart_direct' : 'ss_cart_id'),
|
||||
'direct'=>(bool)get_session('ss_direct'), 'personal'=>$personal,
|
||||
'personal_hash'=>get_session('ss_personalpay_hash'));
|
||||
set_session('ss_order_checkouts', $map);
|
||||
return '<input type="hidden" name="g5_checkout_id" value="'.get_text($id).'">'.
|
||||
'<input type="hidden" name="g5_checkout_nonce" value="'.$nonce.'">';
|
||||
}
|
||||
|
||||
function shop_order_checkout_restore()
|
||||
{
|
||||
if (!isset($_POST['g5_checkout_id'])) return; // 기존 사용자 스킨은 현재 세션 바인딩을 사용한다.
|
||||
$id = $_POST['g5_checkout_id']; $nonce = isset($_POST['g5_checkout_nonce']) ? $_POST['g5_checkout_nonce'] : '';
|
||||
$map = get_session('ss_order_checkouts');
|
||||
if (!shop_order_access_id($id) || !is_string($nonce) || !isset($map[$id]) ||
|
||||
!shop_order_equals($map[$id]['nonce'], $nonce) || $map[$id]['expires'] <= G5_SERVER_TIME) shop_order_access_fail();
|
||||
$c = $map[$id];
|
||||
if ($c['personal'] !== !empty($_POST['pp_id']) || ($c['personal'] && (string)$_POST['pp_id'] !== $id)) shop_order_access_fail();
|
||||
set_session('ss_order_id', $id); set_session('ss_direct', $c['direct']);
|
||||
set_session($c['direct'] ? 'ss_cart_direct' : 'ss_cart_id', $c['cart']);
|
||||
if ($c['personal']) {
|
||||
set_session('ss_personalpay_id', $id); set_session('ss_personalpay_hash', $c['personal_hash']);
|
||||
}
|
||||
}
|
||||
|
||||
function shop_order_state_save($id, $state)
|
||||
{
|
||||
shop_order_state_lock('order', $id);
|
||||
$previous = shop_order_state_meta($id);
|
||||
if ($previous) {
|
||||
$token = shop_order_state_token($id);
|
||||
if (!$token || !shop_order_equals($previous['token_hash'], hash('sha256', $token)) ||
|
||||
$previous['status'] !== 'pending') shop_order_access_fail();
|
||||
}
|
||||
$token = $id.'.'.shop_order_random_hex(32);
|
||||
$state['version'] = 2;
|
||||
unset($state['token'], $state['payment_key']);
|
||||
$json = json_encode($state);
|
||||
if ($json === false) shop_order_access_fail();
|
||||
$table = shop_order_state_table();
|
||||
$fields = "token_hash='".hash('sha256', $token)."', pg='".sql_escape_string($state['pg'])."', ".
|
||||
"cart_id='".sql_escape_string($state['cart'])."', status='pending', expires=".(int)$state['expires'].
|
||||
", state_json='".sql_escape_string($json)."', payment_key='', response_json='', updated_at=".G5_SERVER_TIME;
|
||||
$query = $previous ? "update $table set $fields where od_id='$id'" : "insert into $table set od_id='$id', $fields";
|
||||
if (!sql_query($query, false)) shop_order_access_fail();
|
||||
$state['token'] = $token;
|
||||
$states = get_session('ss_order_data_access'); if (!is_array($states)) $states = array();
|
||||
$states[$id] = $state; set_session('ss_order_data_access', $states);
|
||||
header('Cache-Control: no-store, private'); header('Referrer-Policy: no-referrer');
|
||||
header('X-G5-Order-State: '.$token);
|
||||
return $state;
|
||||
}
|
||||
|
||||
function shop_order_state_can_save($id)
|
||||
{
|
||||
shop_order_state_lock('order', $id);
|
||||
$old = shop_order_state_meta($id);
|
||||
if (!$old) return;
|
||||
$token = shop_order_state_token($id);
|
||||
if ($old['status'] !== 'pending' || !$token || !shop_order_equals($old['token_hash'], hash('sha256', $token))) shop_order_access_fail();
|
||||
}
|
||||
|
||||
function shop_order_state_load($id, $pg)
|
||||
{
|
||||
global $g5, $member, $is_member, $is_guest;
|
||||
if (!shop_order_access_id($id)) shop_order_access_fail();
|
||||
shop_order_state_lock('order', $id);
|
||||
$row = shop_order_state_meta($id);
|
||||
if (!$row) {
|
||||
// 1차 패치가 만든 세션/본문 해시가 검증되는 요청만 전환한다.
|
||||
// PAYREQ_MAP 또는 주문번호만 있는 과거 요청은 legacy 검증에서도 거부된다.
|
||||
$data = shop_order_access_load_legacy($id, $pg);
|
||||
$states = get_session('ss_order_data_access');
|
||||
shop_order_state_save($id, $states[$id]);
|
||||
$row = shop_order_state_meta($id);
|
||||
}
|
||||
$token = shop_order_state_token($id);
|
||||
if (!$token || $row['pg'] !== $pg || !shop_order_equals($row['token_hash'], hash('sha256', $token))) shop_order_access_fail();
|
||||
if (!in_array($row['status'], array('pending','approving','approved','finalizing'), true)) shop_order_access_fail();
|
||||
if ((int)$row['expires'] <= G5_SERVER_TIME && $row['status'] === 'pending') shop_order_access_fail();
|
||||
$row = shop_order_state_row($id);
|
||||
$state = json_decode($row['state_json'], true);
|
||||
if (!is_array($state) || !isset($state['version']) || $state['version'] !== 2) shop_order_access_fail();
|
||||
$current = isset($member['mb_id']) ? (string)$member['mb_id'] : '';
|
||||
if ($current !== '' && $current !== $state['member']) shop_order_access_fail();
|
||||
// 결제 토큰은 해당 주문 처리에만 회원 컨텍스트를 제공한다. 로그인 세션을 발급하지 않는다.
|
||||
if ($current === '' && $state['member'] !== '') {
|
||||
$owner = get_member($state['member']);
|
||||
if (empty($owner['mb_id']) || !empty($owner['mb_leave_date']) || !empty($owner['mb_intercept_date'])) shop_order_access_fail();
|
||||
$member = $owner; $is_member = true; $is_guest = false;
|
||||
}
|
||||
if ($state['personal']) {
|
||||
$done = sql_fetch("select pp_tno, pp_use, pp_price, pp_time from {$g5['g5_shop_personalpay_table']} where pp_id='$id'");
|
||||
if (!$done || !$done['pp_use'] || $done['pp_tno'] ||
|
||||
!shop_order_equals($state['personal_hash'], md5($id.$done['pp_price'].$done['pp_time']))) shop_order_access_fail();
|
||||
} else {
|
||||
$done = sql_fetch("select od_id from {$g5['g5_shop_order_table']} where od_id='$id'");
|
||||
if (!empty($done['od_id'])) shop_order_access_fail(); // 부분 저장도 자동 재승인하지 않는다.
|
||||
}
|
||||
if ($row['status'] === 'finalizing') {
|
||||
if ($state['personal']) shop_order_access_fail();
|
||||
$cart_id = sql_escape_string($state['cart']);
|
||||
$remaining = sql_fetch("select count(*) as cnt from {$g5['g5_shop_cart_table']} where od_id='$cart_id' and ct_select='1' and ct_status='쇼핑'");
|
||||
if (empty($remaining['cnt']) || $row['response_json'] === '') shop_order_access_fail();
|
||||
shop_order_state_write($id, array('status'=>'approved'));
|
||||
$row['status'] = 'approved';
|
||||
}
|
||||
$rows = sql_query("select cart_id,mb_id,dt_pg,dt_time from {$g5['g5_shop_order_data_table']} where od_id='$id'");
|
||||
if (sql_num_rows($rows) !== 1) shop_order_access_fail();
|
||||
$meta = sql_fetch_array($rows);
|
||||
if ((string)$meta['cart_id'] !== $state['cart'] || $meta['mb_id'] !== $state['member'] ||
|
||||
$meta['dt_pg'] !== $pg || $meta['dt_time'] !== $state['time']) shop_order_access_fail();
|
||||
$temp = sql_fetch("select dt_data from {$g5['g5_shop_order_data_table']} where od_id='$id'");
|
||||
if (empty($temp['dt_data']) || !shop_order_equals($state['digest'], hash('sha256', $temp['dt_data']))) shop_order_access_fail();
|
||||
$data = shop_order_decode_data($temp['dt_data']);
|
||||
if (!is_array($data) || !empty($data['pp_id']) !== $state['personal'] ||
|
||||
($state['personal'] && (string)$data['pp_id'] !== $id)) shop_order_access_fail();
|
||||
unset($data['od_pwd']);
|
||||
$state['token'] = $token;
|
||||
if ($row['payment_key'] !== '') $state['payment_key'] = $row['payment_key'];
|
||||
$states = get_session('ss_order_data_access'); if (!is_array($states)) $states = array();
|
||||
$states[$id] = $state; set_session('ss_order_data_access', $states);
|
||||
set_session('ss_order_id', $id); set_session('ss_direct', $state['direct']);
|
||||
set_session($state['direct'] ? 'ss_cart_direct' : 'ss_cart_id', $state['cart']);
|
||||
if ($state['personal']) { set_session('ss_personalpay_id', $id); set_session('ss_personalpay_hash', $state['personal_hash']); }
|
||||
$r =& shop_order_runtime(); $r['rows'][$id] = $row; $r['active'] = $id;
|
||||
$data['g5_order_state'] = $token;
|
||||
return $data;
|
||||
}
|
||||
|
||||
function shop_order_state_prepare($personal)
|
||||
{
|
||||
global $default;
|
||||
$token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : '';
|
||||
$id = (string)get_session($personal ? 'ss_personalpay_id' : 'ss_order_id');
|
||||
if ($token !== '') {
|
||||
if (!is_string($token) || !preg_match('/\A([0-9]{1,20})\.[a-f0-9]{64}\z/D', $token, $m)) shop_order_access_fail();
|
||||
$id = $m[1];
|
||||
}
|
||||
$states = get_session('ss_order_data_access');
|
||||
if ($token === '' && empty($states[$id])) return; // 임시 저장 없는 기존 PG/무통장 흐름
|
||||
$row = shop_order_state_meta($id);
|
||||
$pg = $row ? $row['pg'] : (isset($states[$id]['pg']) ? $states[$id]['pg'] : '');
|
||||
$data = shop_order_access_load($id, $pg);
|
||||
if (!empty($data['pp_id']) !== $personal) shop_order_access_fail();
|
||||
$default['de_pg_service'] = $pg;
|
||||
// 결제 복귀의 업무 필드는 저장 당시 서버 데이터만 사용한다.
|
||||
$fields = array_merge(shop_order_data_fields($personal ? 1 : 0), array('pp_id','sw_direct','od_temp_point','od_coupon','od_send_coupon','cp_id','cp_price','it_id','od_cp_id','sc_cp_id','od_hope_date','ad_default','ad_subject','good_mny','amountValue','comm_tax_mny','comm_vat_mny','comm_free_mny'));
|
||||
foreach ($fields as $key) {
|
||||
$value = isset($data[$key]) ? $data[$key] : '';
|
||||
$_POST[$key] = $value; $GLOBALS[$key] = $value;
|
||||
}
|
||||
if (!$personal) {
|
||||
$s = get_session('ss_order_data_access');
|
||||
shop_order_state_lock('cart', $s[$id]['cart']);
|
||||
$cart = sql_escape_string($s[$id]['cart']);
|
||||
$busy = sql_fetch("select od_id from ".shop_order_state_table()." where cart_id='$cart' and od_id<>'$id' and status in ('approving','approved','finalizing','unknown','cancel_pending') limit 1");
|
||||
if ($busy) shop_order_access_fail();
|
||||
}
|
||||
}
|
||||
|
||||
function shop_order_state_begin($id, $key, $expected)
|
||||
{
|
||||
shop_order_state_lock('order', $id);
|
||||
$row = shop_order_state_row($id);
|
||||
if (!$row || !in_array($row['status'], array('pending','approving','approved'), true)) shop_order_access_fail();
|
||||
if ($row['payment_key'] !== '' && !shop_order_equals($row['payment_key'], $key)) shop_order_access_fail();
|
||||
$state = json_decode($row['state_json'], true);
|
||||
if (!is_array($state) || (int)$expected <= 0 || (isset($state['expected_amount']) && (int)$state['expected_amount'] !== (int)$expected)) shop_order_access_fail();
|
||||
if ($row['status'] === 'pending') {
|
||||
$state['expected_amount'] = (int)$expected;
|
||||
$state['approval_started'] = G5_SERVER_TIME;
|
||||
shop_order_state_write($id, array('status'=>'approving','payment_key'=>$key,'state_json'=>json_encode($state)));
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
function shop_order_state_approved($id, $response)
|
||||
{
|
||||
$json = json_encode($response);
|
||||
if ($json === false) shop_order_access_fail();
|
||||
shop_order_state_write($id, array('status'=>'approved','response_json'=>$json));
|
||||
}
|
||||
|
||||
function shop_order_state_finalizing()
|
||||
{
|
||||
$r =& shop_order_runtime();
|
||||
if ($r['active'] === '') return;
|
||||
$row = shop_order_state_row($r['active']);
|
||||
if ($row && $row['status'] === 'approved') shop_order_state_write($r['active'], array('status'=>'finalizing'));
|
||||
}
|
||||
|
||||
function shop_order_state_complete($id)
|
||||
{
|
||||
$row = shop_order_state_row($id);
|
||||
if (!$row) return;
|
||||
shop_order_state_write($id, array('status'=>'completed','state_json'=>'','response_json'=>'','token_hash'=>'','payment_key'=>''));
|
||||
}
|
||||
|
||||
function shop_order_state_cancel($status)
|
||||
{
|
||||
$r =& shop_order_runtime();
|
||||
if ($r['active'] !== '') shop_order_state_write($r['active'], array('status'=>$status));
|
||||
}
|
||||
|
||||
function shop_order_toss_approve($toss, $id, $key, $expected)
|
||||
{
|
||||
$row = shop_order_state_begin($id, $key, $expected);
|
||||
$toss->headers[] = 'Idempotency-Key: g5-confirm-'.hash('sha256', (defined('G5_MYSQL_DB') ? G5_MYSQL_DB : '').G5_SHOP_TABLE_PREFIX.$id.$key);
|
||||
// 승인 응답을 놓쳤거나 저장 후 중단된 경우에도 먼저 PG에 현재 상태를 조회한다.
|
||||
if ($row['status'] !== 'pending') {
|
||||
if (!$toss->getPaymentByOrderId($id)) shop_order_access_fail();
|
||||
$response = $toss->responseData;
|
||||
if (!isset($response['orderId'],$response['paymentKey'],$response['totalAmount'],$response['status']) ||
|
||||
$response['orderId'] !== $id || $response['paymentKey'] !== $key || (int)$response['totalAmount'] !== (int)$expected) shop_order_access_fail();
|
||||
if (in_array($response['status'], array('CANCELED','ABORTED','EXPIRED'), true)) {
|
||||
shop_order_state_write($id, array('status'=>$response['status'] === 'CANCELED' ? 'cancelled' : 'failed'));
|
||||
shop_order_access_fail();
|
||||
}
|
||||
if ($response['status'] === 'IN_PROGRESS' && $row['status'] === 'approving') {
|
||||
$state = json_decode($row['state_json'], true);
|
||||
// PG가 미승인을 확인한 경우에만 동일 본문/멱등 키로 재시도한다(15일보다 짧게 제한).
|
||||
if (empty($state['approval_started']) || G5_SERVER_TIME - $state['approval_started'] > 14*86400 || !$toss->approvePayment()) shop_order_access_fail();
|
||||
$response = $toss->responseData;
|
||||
}
|
||||
} else {
|
||||
if (!$toss->approvePayment()) {
|
||||
// 네트워크 오류를 결제 실패로 단정하지 않는다. 다음 시도는 조회부터 시작한다.
|
||||
shop_order_access_fail();
|
||||
}
|
||||
$response = $toss->responseData;
|
||||
}
|
||||
if (!isset($response['orderId'],$response['paymentKey'],$response['totalAmount'],$response['status'],$response['method']) ||
|
||||
$response['orderId'] !== $id || $response['paymentKey'] !== $key || (int)$response['totalAmount'] !== (int)$expected ||
|
||||
!($response['status'] === 'DONE' || ($response['status'] === 'WAITING_FOR_DEPOSIT' && $response['method'] === '가상계좌'))) shop_order_access_fail();
|
||||
shop_order_state_approved($id, $response);
|
||||
return true;
|
||||
}
|
||||
|
||||
function shop_order_kcp_result_fields()
|
||||
{
|
||||
return explode(' ', 'tno amount pnt_issue card_cd card_name app_time app_no noinf quota partcanc_yn bankname bank_name bank_code depositor account pt_idno pnt_amount pnt_app_time pnt_app_no add_pnt use_pnt rsv_pnt commid mobile_no tk_van_code tk_app_no cash_authno cash_tr_code escw_yn res_cd res_msg app_kakaomny_time kakaomny_mny kcp_pay_method od_other_pay_type');
|
||||
}
|
||||
|
||||
function shop_order_state_abort_pending()
|
||||
{
|
||||
$token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : '';
|
||||
if (!is_string($token) || !preg_match('/\A([0-9]{1,20})\.[a-f0-9]{64}\z/D', $token, $m)) shop_order_access_fail();
|
||||
shop_order_state_lock('order', $m[1]);
|
||||
$row = shop_order_state_meta($m[1]);
|
||||
if (!$row || !shop_order_equals($row['token_hash'], hash('sha256', $token))) shop_order_access_fail();
|
||||
// PG 승인 여부가 불명확한 상태에는 실패 복귀가 와도 데이터를 지우지 않는다.
|
||||
if ($row['status'] === 'pending') shop_order_state_write($m[1], array('status'=>'failed'));
|
||||
shop_order_access_fail();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Apache 2.4: 마이그레이션 파일의 HTTP 접근을 차단한다.
|
||||
# 서버 내부에서 PHP가 SQL 파일을 읽는 동작에는 영향을 주지 않는다.
|
||||
Require all denied
|
||||
@@ -0,0 +1,23 @@
|
||||
-- @description KCP 통보 거래 바인딩 및 재통보 복구 이력 추가
|
||||
-- @if-table-exists {{g5_shop_order_table}}
|
||||
-- @if-column-missing {{g5_shop_order_table}} od_kcp_site_cd
|
||||
ALTER TABLE `{{g5_shop_order_table}}` ADD `od_kcp_site_cd` varchar(5) NOT NULL DEFAULT '';
|
||||
-- @if-table-exists {{g5_shop_personalpay_table}}
|
||||
-- @if-column-missing {{g5_shop_personalpay_table}} pp_kcp_site_cd
|
||||
ALTER TABLE `{{g5_shop_personalpay_table}}` ADD `pp_kcp_site_cd` varchar(5) NOT NULL DEFAULT '';
|
||||
-- @if-table-exists {{g5_shop_default_table}}
|
||||
-- @if-table-missing {{g5_shop_kcp_noti_table}}
|
||||
CREATE TABLE `{{g5_shop_kcp_noti_table}}` (
|
||||
`kn_key` char(64) NOT NULL,
|
||||
`kn_trade` char(64) NOT NULL,
|
||||
`kn_noti_id` varchar(20) NOT NULL,
|
||||
`kn_op_cd` char(2) NOT NULL,
|
||||
`kn_payload` char(64) NOT NULL,
|
||||
`od_id` bigint(20) unsigned NOT NULL DEFAULT '0',
|
||||
`kn_plan` mediumtext NOT NULL,
|
||||
`kn_done` tinyint(4) NOT NULL DEFAULT '0',
|
||||
`kn_created_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`kn_key`),
|
||||
KEY `kn_trade` (`kn_trade`),
|
||||
KEY `od_id` (`od_id`)
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- @description KVE-2026-2140 주문별 복귀 인증과 영속 승인 상태
|
||||
-- @if-table-exists {{g5_shop_order_data_table}}
|
||||
-- @if-table-missing {{g5_shop_order_access_table}}
|
||||
CREATE TABLE {{g5_shop_order_access_table}} (
|
||||
od_id bigint(20) unsigned NOT NULL,
|
||||
token_hash char(64) NOT NULL DEFAULT '',
|
||||
pg varchar(20) NOT NULL DEFAULT '',
|
||||
cart_id bigint(20) unsigned NOT NULL DEFAULT '0',
|
||||
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||
expires bigint(20) NOT NULL DEFAULT '0',
|
||||
updated_at bigint(20) NOT NULL DEFAULT '0',
|
||||
state_json mediumtext NOT NULL,
|
||||
payment_key varchar(200) NOT NULL DEFAULT '',
|
||||
response_json mediumtext NOT NULL,
|
||||
PRIMARY KEY (od_id),
|
||||
KEY cart_status (cart_id,status),
|
||||
KEY state_expiry (status,expires)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- @description LG 가상계좌 통보 거래 바인딩 및 재통보 복구 이력 추가
|
||||
-- @if-table-exists {{g5_shop_order_table}}
|
||||
-- @if-column-missing {{g5_shop_order_table}} od_lg_mid
|
||||
ALTER TABLE `{{g5_shop_order_table}}` ADD `od_lg_mid` varchar(104) NOT NULL DEFAULT '';
|
||||
-- @if-table-exists {{g5_shop_personalpay_table}}
|
||||
-- @if-column-missing {{g5_shop_personalpay_table}} pp_lg_mid
|
||||
ALTER TABLE `{{g5_shop_personalpay_table}}` ADD `pp_lg_mid` varchar(104) NOT NULL DEFAULT '';
|
||||
-- @if-table-exists {{g5_shop_default_table}}
|
||||
-- @if-table-missing {{g5_shop_lg_noti_table}}
|
||||
CREATE TABLE `{{g5_shop_lg_noti_table}}` (
|
||||
`ln_key` char(64) NOT NULL,
|
||||
`ln_trade` char(64) NOT NULL,
|
||||
`ln_seq` int(11) NOT NULL,
|
||||
`ln_flag` char(1) NOT NULL,
|
||||
`ln_payload` char(64) NOT NULL,
|
||||
`od_id` bigint(20) unsigned NOT NULL DEFAULT '0',
|
||||
`ln_plan` mediumtext NOT NULL,
|
||||
`ln_done` tinyint(4) NOT NULL DEFAULT '0',
|
||||
`ln_created_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`ln_key`),
|
||||
KEY `ln_trade` (`ln_trade`),
|
||||
KEY `od_id` (`od_id`)
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
// 레거시 모바일 승인/통보/복귀 경로는 주문 검증 전에 이미 승인되었을 수 있다.
|
||||
// POST의 거래번호를 사용하지 않고 기존 pay_result.php와 같은 세션 검증을 적용한다.
|
||||
include_once(G5_MSHOP_PATH.'/settle_inicis.inc.php');
|
||||
$cart_cancel_tid = get_session('P_TID');
|
||||
$cart_cancel_amount = get_session('P_AMT');
|
||||
$cart_cancel_hash = isset($_POST['P_HASH']) && is_string($_POST['P_HASH']) ? $_POST['P_HASH'] : '';
|
||||
if (!$cart_cancel_tid || (int) $cart_cancel_amount <= 0 ||
|
||||
$cart_cancel_hash !== md5($cart_cancel_tid.$default['de_inicis_mid'].$cart_cancel_amount))
|
||||
return;
|
||||
|
||||
include_once(G5_SHOP_PATH.'/inicis/libs/inicis_youngcart_fn.php');
|
||||
$cart_cancel_method = get_type_inicis_paymethod($od_settle_case);
|
||||
$cart_cancel_response = $cart_cancel_method ? inicis_tid_cancel(array(
|
||||
'paymethod' => $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 .= ' 결제 취소 결과를 확인하지 못했습니다. 재결제 전에 상점에 문의해 주십시오.';
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
+ "&pay_method=" + form.pay_method.value
|
||||
+ "&escw_used=" + form.escw_used.value
|
||||
+ "&good_name=" + form.good_name.value
|
||||
+ "&Ret_URL=" + form.Ret_URL.value;
|
||||
+ "&Ret_URL=" + encodeURIComponent(form.Ret_URL.value);
|
||||
sendRequest( url + params );
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
$access_order_id = isset($_POST['ordr_idxx']) ? $_POST['ordr_idxx'] : '';
|
||||
$data = shop_order_access_load($access_order_id, 'kcp');
|
||||
|
||||
@header('Cache-Control: no-cache, no-store, must-revalidate'); // HTTP 1.1.
|
||||
@header('Pragma: no-cache'); // HTTP 1.0.
|
||||
@@ -235,12 +238,7 @@ include_once('./_common.php');
|
||||
<?php
|
||||
if($enc_data != '' && $enc_info != '' && $tran_cd != '') {
|
||||
// 제외할 필드
|
||||
$exclude = array('req_tx', 'res_cd', 'tran_cd', 'ordr_idxx', 'good_mny', 'good_name', 'buyr_name', 'buyr_tel1', 'buyr_tel2', 'buyr_mail', 'enc_info', 'enc_data', 'use_pay_method', 'rcvr_name', 'rcvr_tel1', 'rcvr_tel2', 'rcvr_mail', 'rcvr_zipx', 'rcvr_add1', 'rcvr_add2', 'param_opt_1', 'param_opt_2', 'param_opt_3');
|
||||
|
||||
$sql = " select * from {$g5['g5_shop_order_data_table']} where od_id = '$ordr_idxx' ";
|
||||
$row = sql_fetch($sql);
|
||||
|
||||
$data = isset($row['dt_data']) ? unserialize(base64_decode($row['dt_data'])) : array();
|
||||
$exclude = array('req_tx', 'res_cd', 'tran_cd', 'ordr_idxx', 'good_mny', 'good_name', 'buyr_name', 'buyr_tel1', 'buyr_tel2', 'buyr_mail', 'enc_info', 'enc_data', 'use_pay_method', 'rcvr_name', 'rcvr_tel1', 'rcvr_tel2', 'rcvr_mail', 'rcvr_zipx', 'rcvr_add1', 'rcvr_add2', 'param_opt_1', 'param_opt_2', 'param_opt_3', 'site_cd');
|
||||
|
||||
if(isset($data['pp_id']) && $data['pp_id']) {
|
||||
$order_action_url = G5_HTTPS_MSHOP_URL.'/personalpayformupdate.php';
|
||||
@@ -253,6 +251,7 @@ if($enc_data != '' && $enc_info != '' && $tran_cd != '') {
|
||||
echo make_order_field($data, $exclude);
|
||||
|
||||
foreach($_POST as $key=>$value) {
|
||||
if (!in_array($key, $exclude, true) || !is_string($value)) continue;
|
||||
echo '<input type="hidden" name="'.get_text($key).'" value="'.get_text($value).'">'.PHP_EOL;
|
||||
}
|
||||
|
||||
@@ -309,7 +308,7 @@ if($enc_data != '' && $enc_info != '' && $tran_cd != '') {
|
||||
<input type="hidden" name="approval_key" id="approval">
|
||||
<!-- 리턴 URL (kcp와 통신후 결제를 요청할 수 있는 암호화 데이터를 전송 받을 가맹점의 주문페이지 URL) -->
|
||||
<!-- 반드시 가맹점 주문페이지의 URL을 입력 해주시기 바랍니다. -->
|
||||
<input type="hidden" name="Ret_URL" value="<?php echo G5_MSHOP_URL; ?>/kcp/order_approval_form.php">
|
||||
<input type="hidden" name="Ret_URL" value="<?php echo G5_MSHOP_URL; ?>/kcp/order_approval_form.php?g5_order_state=<?php echo get_text($data['g5_order_state']); ?>">
|
||||
<!-- 인증시 필요한 파라미터(변경불가)-->
|
||||
<input type="hidden" name="ActionResult" value="<?php echo get_text($ActionResult); ?>">
|
||||
<!-- 에스크로 사용유무 에스크로 사용 업체(가상계좌만 해당)는 Y로 세팅 해주시기 바랍니다.-->
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
$access_order_id = isset($_POST['ordr_idxx']) ? $_POST['ordr_idxx'] : '';
|
||||
$access_data = shop_order_access_load($access_order_id, 'kcp');
|
||||
$access_personal = !empty($access_data['pp_id']);
|
||||
if ($access_personal !== !empty($_POST['pp_id']) ||
|
||||
($access_personal && (string)$_POST['pp_id'] !== $access_order_id)) shop_order_access_fail();
|
||||
|
||||
// 최종 계산 금액을 검증한 뒤 승인 기록을 원자적으로 확보한다.
|
||||
$kcp_expected = $access_personal ? (int)$pp['pp_price'] : (int)$order_price;
|
||||
if ($kcp_expected <= 0 || !isset($_POST['req_tx'], $_POST['good_mny'], $_POST['enc_data'], $_POST['enc_info']) ||
|
||||
$_POST['req_tx'] !== 'pay' || !is_string($_POST['good_mny']) || !ctype_digit($_POST['good_mny']) ||
|
||||
(int)$_POST['good_mny'] !== $kcp_expected || !is_string($_POST['enc_data']) || !is_string($_POST['enc_info']) ||
|
||||
$_POST['enc_data'] === '' || $_POST['enc_info'] === '') shop_order_access_fail();
|
||||
$kcp_request_key = 'kcp:'.hash('sha256', $_POST['enc_data']."\0".$_POST['enc_info']);
|
||||
$kcp_state = shop_order_state_begin($access_order_id, $kcp_request_key, $kcp_expected);
|
||||
if ($kcp_state['status'] === 'approved') {
|
||||
$kcp_cached = json_decode($kcp_state['response_json'], true);
|
||||
$kcp_cached = isset($kcp_cached['payload']) ? shop_order_decode_data($kcp_cached['payload']) : false;
|
||||
if (!is_array($kcp_cached) || (int)$kcp_cached['amount'] !== $kcp_expected) shop_order_access_fail();
|
||||
foreach (shop_order_kcp_result_fields() as $kcp_field) {
|
||||
if (isset($kcp_cached[$kcp_field])) $$kcp_field = $kcp_cached[$kcp_field];
|
||||
}
|
||||
return;
|
||||
}
|
||||
// KCP 통신 도중 프로세스가 끊긴 거래는 운영 대조 전 재승인하지 않는다.
|
||||
if ($kcp_state['status'] !== 'pending') shop_order_access_fail();
|
||||
|
||||
/* ============================================================================== */
|
||||
/* = PAGE : 지불 요청 및 결과 처리 PAGE = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
@@ -304,4 +331,10 @@
|
||||
}
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 05. 승인 결과 처리 END = */
|
||||
/* ============================================================================== */;
|
||||
/* ============================================================================== */;
|
||||
if (empty($tno) || (int)$amount !== $kcp_expected) shop_order_access_fail();
|
||||
$kcp_snapshot = array();
|
||||
foreach (shop_order_kcp_result_fields() as $kcp_field) {
|
||||
if (isset($$kcp_field)) $kcp_snapshot[$kcp_field] = $$kcp_field;
|
||||
}
|
||||
shop_order_state_approved($access_order_id, array('payload'=>base64_encode(serialize($kcp_snapshot))));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit; // 개별 페이지 접근 불가
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
|
||||
require_once(G5_MSHOP_PATH.'/settle_'.$default['de_pg_service'].'.inc.php');
|
||||
|
||||
@@ -277,7 +278,9 @@ if(function_exists('is_use_easypay') && is_use_easypay('global_nhnkcp')){ //
|
||||
</div>
|
||||
|
||||
<div id="sod_frm" class="sod_frm_mobile">
|
||||
<script src="<?php echo G5_JS_URL; ?>/shop.order-state.js"></script>
|
||||
<form name="forderform" method="post" action="<?php echo $order_action_url; ?>" autocomplete="off">
|
||||
<?php echo shop_order_checkout_fields((string)$od_id, false); ?>
|
||||
<input type="hidden" name="od_price" value="<?php echo $tot_sell_price; ?>">
|
||||
<input type="hidden" name="org_od_price" value="<?php echo $tot_sell_price; ?>">
|
||||
<input type="hidden" name="od_send_cost" value="<?php echo $send_cost; ?>">
|
||||
@@ -1333,14 +1336,14 @@ function pay_approval()
|
||||
|
||||
<?php if($default['de_escrow_use']) { ?>
|
||||
f.cardUseEscrow.value = 'true';
|
||||
f.escrowProducts.value = JSON.stringify(<?php echo json_encode($escrow_products, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>);
|
||||
f.escrowProducts.value = JSON.stringify(<?php echo json_encode($escrow_products); ?>);
|
||||
<?php } ?>
|
||||
|
||||
f.cardflowMode.value = 'DEFAULT';
|
||||
f.cardeasyPay.value = '';
|
||||
if(settle_method == "간편결제") {
|
||||
var provider = $("input[name=od_settle_case]:checked").attr("data-pay");
|
||||
var providers = <?php echo json_encode(array_values(array_map(function ($provider) { return $provider[1]; }, shop_easypay_catalog('toss')))); ?>;
|
||||
var providers = <?php echo json_encode(shop_order_toss_providers()); ?>;
|
||||
if (providers.indexOf(provider) === -1) {
|
||||
alert('간편결제 수단을 다시 선택해 주세요.');
|
||||
return false;
|
||||
@@ -1500,16 +1503,22 @@ function pay_approval()
|
||||
<?php } ?>
|
||||
|
||||
// 주문 정보 임시저장
|
||||
var order_data = $(pf).serialize();
|
||||
var save_result = "";
|
||||
<?php if ($default['de_pg_service'] == 'toss') { ?>
|
||||
// 복귀 페이지에서 요청값으로 덮어쓰지 않도록 PG 필드를 임시 저장 전에 동기화한다.
|
||||
$(f).serializeArray().forEach(function(field) {
|
||||
if (pf.elements[field.name]) pf.elements[field.name].value = field.value;
|
||||
});
|
||||
<?php } ?>
|
||||
var order_data = $(pf).serialize();
|
||||
var save_result = "결제 요청을 저장하지 못했습니다.";
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
data: order_data,
|
||||
url: g5_url+"/shop/ajax.orderdatasave.php",
|
||||
cache: false,
|
||||
async: false,
|
||||
success: function(data) {
|
||||
save_result = data;
|
||||
success: function(data, textStatus, xhr) {
|
||||
save_result = data || g5_order_state_accept(xhr);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
shop_order_state_prepare(false);
|
||||
// PG 호출 전에 저장된 비회원 비밀번호 해시의 주문 소유권을 확인한다.
|
||||
$saved_order_password = ($is_member || $od_settle_case === '무통장') ? null : shop_order_access_password((string)get_session('ss_order_id'));
|
||||
|
||||
|
||||
// 구 주문서나 직접 POST로도 SIRK 전용 결제를 다시 시작하지 않는다.
|
||||
if (isset($od_settle_case) && $od_settle_case === 'KAKAOPAY') {
|
||||
@@ -66,9 +71,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 +579,12 @@ 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) : '';
|
||||
// LG 통보는 결제 당시 서버에서 구성한 운영/테스트 MID와 대조한다.
|
||||
$od_lg_mid = ($od_pg === 'lg' && isset($LGD_MID))
|
||||
? sql_escape_string($LGD_MID) : '';
|
||||
|
||||
// 주문금액과 결제금액이 일치하는지 체크
|
||||
if($tno) {
|
||||
@@ -584,8 +600,8 @@ if($tno) {
|
||||
if ($is_member) {
|
||||
$od_pwd = $member['mb_password'];
|
||||
} else {
|
||||
$post_od_pwd = isset($_POST['od_pwd']) ? $_POST['od_pwd'] : sha1(rand());
|
||||
$od_pwd = get_encrypt_string($_POST['od_pwd']);
|
||||
$od_pwd = $saved_order_password !== null ? $saved_order_password :
|
||||
get_encrypt_string(isset($_POST['od_pwd']) ? $_POST['od_pwd'] : bin2hex(random_bytes(16)));
|
||||
}
|
||||
|
||||
// 주문번호를 얻는다.
|
||||
@@ -671,6 +687,8 @@ $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_lg_mid = '$od_lg_mid',
|
||||
od_tno = '$od_tno',
|
||||
od_app_no = '$od_app_no',
|
||||
od_escrow = '$od_escrow',
|
||||
@@ -691,6 +709,7 @@ $sql = " insert {$g5['g5_shop_order_table']}
|
||||
od_cash_info = '{$pg_receipt_infos['od_cash_info']}',
|
||||
od_test = '{$default['de_card_test']}'
|
||||
";
|
||||
shop_order_state_finalizing();
|
||||
$result = sql_query($sql, false);
|
||||
|
||||
// 정말로 insert 가 되었는지 한번더 체크한다.
|
||||
@@ -708,9 +727,9 @@ if(! $result && (isset($exists_order['od_id']) && $od_id && $exists_order['od_id
|
||||
// 주문정보 입력 오류시 결제 취소
|
||||
if(! $result || ! (isset($exists_order['od_id']) && $od_id && $exists_order['od_id'] === $od_id)) {
|
||||
if($tno) {
|
||||
$cancel_msg = '주문정보 입력 오류';
|
||||
include G5_SHOP_PATH.'/cancel_pg.inc.php';
|
||||
}
|
||||
$cancel_msg = '주문정보 입력 오류';
|
||||
include G5_SHOP_PATH.'/cancel_pg.inc.php';
|
||||
}
|
||||
|
||||
// 관리자에게 오류 알림 메일발송
|
||||
$error = 'order';
|
||||
@@ -1044,6 +1063,7 @@ if( $od_pg == 'inicis' && $od_tno && empty($_POST['inicis_pro']) ){
|
||||
if(function_exists('add_order_post_log')) add_order_post_log('', 'delete');
|
||||
|
||||
// 주문번호제거
|
||||
shop_order_access_forget((string)$od_id);
|
||||
set_session('ss_order_id', '');
|
||||
|
||||
// 기존자료 세션에서 제거
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit; // 개별 페이지 접근 불가
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
|
||||
require_once(G5_MSHOP_PATH.'/settle_'.$default['de_pg_service'].'.inc.php');
|
||||
|
||||
@@ -14,7 +15,9 @@ $tablet_size = "1.0"; // 화면 사이즈 조정 - 기기화면에 맞게 수정
|
||||
</div>
|
||||
|
||||
<div id="m_pv_sod_frm">
|
||||
<script src="<?php echo G5_JS_URL; ?>/shop.order-state.js"></script>
|
||||
<form name="forderform" method="post" action="<?php echo $order_action_url; ?>" autocomplete="off">
|
||||
<?php echo shop_order_checkout_fields((string)$pp['pp_id'], true); ?>
|
||||
<input type="hidden" name="pp_id" value="<?php echo $pp['pp_id']; ?>">
|
||||
<section id="m_sod_frm_orderer">
|
||||
<h2>개인결제정보</h2>
|
||||
@@ -311,16 +314,22 @@ function pay_approval()
|
||||
//f.target = "tar_opener";
|
||||
|
||||
// 주문 정보 임시저장
|
||||
var order_data = $(pf).serialize();
|
||||
var save_result = "";
|
||||
<?php if ($default['de_pg_service'] == 'toss') { ?>
|
||||
// 복귀 페이지에서 요청값으로 덮어쓰지 않도록 PG 필드를 임시 저장 전에 동기화한다.
|
||||
$(f).serializeArray().forEach(function(field) {
|
||||
if (pf.elements[field.name]) pf.elements[field.name].value = field.value;
|
||||
});
|
||||
<?php } ?>
|
||||
var order_data = $(pf).serialize();
|
||||
var save_result = "결제 요청을 저장하지 못했습니다.";
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
data: order_data,
|
||||
url: g5_url+"/shop/ajax.orderdatasave.php",
|
||||
cache: false,
|
||||
async: false,
|
||||
success: function(data) {
|
||||
save_result = data;
|
||||
success: function(data, textStatus, xhr) {
|
||||
save_result = data || g5_order_state_accept(xhr);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
shop_order_state_prepare(true);
|
||||
include_once(G5_LIB_PATH.'/mailer.lib.php');
|
||||
|
||||
$page_return_url = G5_SHOP_URL.'/personalpayform.php?pp_id='.get_session('ss_personalpay_id');
|
||||
@@ -213,10 +215,18 @@ 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) : '';
|
||||
// LG 통보는 결제 당시 서버에서 구성한 운영/테스트 MID와 대조한다.
|
||||
$pp_lg_mid = ($pp_pg === 'lg' && isset($LGD_MID))
|
||||
? sql_escape_string($LGD_MID) : '';
|
||||
|
||||
// 결제정보 입력
|
||||
$sql = " update {$g5['g5_shop_personalpay_table']}
|
||||
set pp_pg = '$pp_pg',
|
||||
pp_kcp_site_cd = '$pp_kcp_site_cd',
|
||||
pp_lg_mid = '$pp_lg_mid',
|
||||
pp_tno = '$pp_tno',
|
||||
pp_app_no = '$app_no',
|
||||
pp_receipt_price = '$pp_receipt_price',
|
||||
@@ -229,6 +239,7 @@ $sql = " update {$g5['g5_shop_personalpay_table']}
|
||||
pp_cash_no = '{$pg_receipt_infos['od_cash_no']}',
|
||||
pp_cash_info = '{$pg_receipt_infos['od_cash_info']}'
|
||||
where pp_id = '{$pp['pp_id']}' ";
|
||||
shop_order_state_finalizing();
|
||||
$result = sql_query($sql, false);
|
||||
|
||||
// 결제정보 입력 오류시 결제 취소
|
||||
@@ -339,6 +350,8 @@ $sql = " delete from {$g5['g5_shop_order_data_table']} where od_id = '{$pp['pp_i
|
||||
sql_query($sql);
|
||||
|
||||
// 개인결제번호제거
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
shop_order_access_forget((string)$pp['pp_id']);
|
||||
set_session('ss_personalpay_id', '');
|
||||
set_session('ss_personalpay_hash', '');
|
||||
|
||||
|
||||
@@ -1,46 +1,20 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
|
||||
// 결제 실패 처리인 경우
|
||||
if (isset($_REQUEST['mode']) && $_REQUEST['mode'] === 'fail') {
|
||||
$code = isset($_REQUEST['code']) ? trim($_REQUEST['code']) : '';
|
||||
$message = isset($_REQUEST['message']) ? trim($_REQUEST['message']) : '';
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
|
||||
alert('결제에 실패하였습니다.\\n\\n[' . $code . '] ' . $message, G5_SHOP_URL . '/orderform.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if(!isset($_SESSION['PAYREQ_MAP'])){
|
||||
alert('세션이 만료 되었거나 유효하지 않은 요청 입니다.', G5_MSHOP_URL);
|
||||
}
|
||||
|
||||
$payReqMap = $_SESSION['PAYREQ_MAP']; //결제 요청시, Session에 저장했던 파라미터 MAP
|
||||
if (isset($_REQUEST['mode']) && $_REQUEST['mode'] === 'fail') shop_order_state_abort_pending();
|
||||
$orderId = isset($_REQUEST['orderId']) ? $_REQUEST['orderId'] : '';
|
||||
$paymentKey = isset($_REQUEST['paymentKey']) ? $_REQUEST['paymentKey'] : '';
|
||||
$amount = isset($_REQUEST['amount']) ? $_REQUEST['amount'] : '';
|
||||
$data = shop_order_access_payment($orderId, $paymentKey, $amount);
|
||||
|
||||
$g5['title'] = '토스페이먼츠 결제인증 완료처리';
|
||||
$g5['body_script'] = ' onload="setTossResult();"';
|
||||
include_once(G5_PATH.'/head.sub.php');
|
||||
|
||||
// 토스페이먼츠 결제인증 성공시 인증키 주문 임시데이터에 업데이트
|
||||
$paymentKey = isset($_REQUEST['paymentKey']) ? trim($_REQUEST['paymentKey']) : '';
|
||||
$orderId = isset($_REQUEST['orderId']) ? trim($_REQUEST['orderId']) : '';
|
||||
$amount = isset($_REQUEST['amount']) ? trim($_REQUEST['amount']) : '';
|
||||
|
||||
if (empty($paymentKey) || empty($orderId)) {
|
||||
alert('결제정보가 올바르지 않습니다.', G5_MSHOP_URL);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = " select * from {$g5['g5_shop_order_data_table']} where od_id = '$orderId' ";
|
||||
$row = sql_fetch($sql);
|
||||
|
||||
$data = isset($row['dt_data']) ? unserialize(base64_decode($row['dt_data'])) : array();
|
||||
|
||||
// 주문 임시데이터에 paymentKey 업데이트
|
||||
$data['paymentKey'] = $paymentKey;
|
||||
$data_new = base64_encode(serialize($data));
|
||||
$sql = " update {$g5['g5_shop_order_data_table']} set dt_data = '$data_new' where od_id = '$orderId' limit 1 ";
|
||||
sql_query($sql);
|
||||
|
||||
if(isset($data['pp_id']) && $data['pp_id']) {
|
||||
$order_action_url = G5_HTTPS_MSHOP_URL.'/personalpayformupdate.php';
|
||||
} else {
|
||||
|
||||
@@ -1,53 +1,9 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
|
||||
// 토스페이먼츠 class
|
||||
require_once(G5_SHOP_PATH.'/toss/toss.inc.php');
|
||||
|
||||
// 개인결제 ID와 주문 ID 설정
|
||||
$ss_order_id = isset($_REQUEST['orderId']) ? $_REQUEST['orderId'] : '';
|
||||
$ss_personalpay_id = get_session('ss_personalpay_id');
|
||||
|
||||
// 장바구니 ID 설정 (바로구매 여부 확인)
|
||||
$ss_cart_id = get_session('ss_direct') ? get_session('ss_cart_direct') : get_session('ss_cart_id');
|
||||
|
||||
// WHERE 조건 추가용 변수
|
||||
$addQuery = "";
|
||||
if (!empty($ss_order_id)) {
|
||||
$addQuery .= " AND od_id = '{$ss_order_id}'";
|
||||
}
|
||||
if (isset($member['mb_id']) && $member['mb_id'] !== '') {
|
||||
$addQuery .= " AND mb_id = '{$member['mb_id']}'";
|
||||
}
|
||||
|
||||
// 개인결제가 아닌 경우 장바구니 ID 조건 추가
|
||||
if (empty($ss_personalpay_id)) {
|
||||
if (!empty($ss_cart_id)) {
|
||||
$addQuery .= " AND cart_id = '{$ss_cart_id}'";
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 검증 (원래 로직 유지)
|
||||
if (empty($ss_order_id) || (empty($ss_personalpay_id) && empty($ss_cart_id))) {
|
||||
alert('주문정보가 올바르지 않습니다.');
|
||||
exit;
|
||||
}
|
||||
|
||||
// 기존 dt_data 가져오기
|
||||
$sql = "
|
||||
SELECT * FROM {$g5['g5_shop_order_data_table']}
|
||||
WHERE 1=1
|
||||
{$addQuery}
|
||||
";
|
||||
$res = sql_fetch($sql);
|
||||
$dt_data = array();
|
||||
if (isset($res['dt_data'])) {
|
||||
$dt_data = unserialize(base64_decode($res['dt_data']));
|
||||
}
|
||||
|
||||
$payReqMap = $dt_data;
|
||||
|
||||
$_SESSION['PAYREQ_MAP'] = $payReqMap;
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
$ss_order_id = isset($_REQUEST['orderId']) ? $_REQUEST['orderId'] : '';
|
||||
$payReqMap = shop_order_access_load($ss_order_id, 'toss');
|
||||
|
||||
if(isset($payReqMap['pp_id']) && $payReqMap['pp_id']) {
|
||||
$page_return_url = G5_SHOP_URL.'/personalpayform.php?pp_id='.$payReqMap['pp_id'];
|
||||
@@ -65,23 +21,14 @@ include_once(G5_PATH.'/head.sub.php');
|
||||
|
||||
<form name="forderform">
|
||||
<?php
|
||||
foreach($payReqMap as $key => $value) {
|
||||
if (isset($_REQUEST[$key]) && $_REQUEST[$key]) {
|
||||
$value = $_REQUEST[$key];
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$value = implode(',', $value);
|
||||
}
|
||||
if ($key === 'escrowProducts') {
|
||||
$value = str_replace("\\", "", $value);
|
||||
echo '<input type="hidden" name="'.$key.'" value=\''.$value.'\'>'.PHP_EOL;
|
||||
} else {
|
||||
echo '<input type="hidden" name="'.$key.'" value="'.$value.'">'.PHP_EOL;
|
||||
}
|
||||
if (isset($payReqMap['escrowProducts']) && is_string($payReqMap['escrowProducts'])) {
|
||||
$payReqMap['escrowProducts'] = stripslashes($payReqMap['escrowProducts']);
|
||||
}
|
||||
echo make_order_field($payReqMap, array());
|
||||
?>
|
||||
</form>
|
||||
|
||||
<script src="<?php echo G5_JS_URL; ?>/shop.order-state.js"></script>
|
||||
<script language="javascript" src="https://js.tosspayments.com/v2/standard"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -124,8 +71,8 @@ async function launchCrossPlatform(frm) {
|
||||
taxFreeAmount: parseInt(frm.taxFreeAmount.value),
|
||||
orderId: frm.orderId.value, // 고유 주문번호
|
||||
orderName: frm.orderName.value,
|
||||
successUrl: "<?php echo G5_MSHOP_URL;?>/toss/returnurl.php", // 결제 요청이 성공하면 리다이렉트되는 URL
|
||||
failUrl: "<?php echo G5_MSHOP_URL;?>/toss/returnurl.php?mode=fail", // 결제 요청이 실패하면 리다이렉트되는 URL
|
||||
successUrl: g5_order_state_url("<?php echo G5_MSHOP_URL;?>/toss/returnurl.php", frm), // 결제 요청이 성공하면 리다이렉트되는 URL
|
||||
failUrl: g5_order_state_url("<?php echo G5_MSHOP_URL;?>/toss/returnurl.php?mode=fail", frm), // 결제 요청이 실패하면 리다이렉트되는 URL
|
||||
customerEmail: frm.customerEmail.value,
|
||||
customerName: frm.customerName.value,
|
||||
customerMobilePhone: frm.customerMobilePhone.value,
|
||||
|
||||
@@ -1,44 +1,25 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
$str = '';
|
||||
$exists = false;
|
||||
|
||||
$ca_id_len = strlen($ca_id);
|
||||
$len2 = $ca_id_len + 2;
|
||||
|
||||
$sql = " select ca_id, ca_name from {$g5['g5_shop_category_table']} where ca_id like '$ca_id%' and length(ca_id) = $len2 and ca_use = '1' order by ca_order, ca_id ";
|
||||
$result = sql_query($sql);
|
||||
|
||||
// 하위 분류가 없으면 현재 분류를 포함한 같은 단계의 사용 중인 분류를 표시한다.
|
||||
if (!sql_num_rows($result)) {
|
||||
$parent_ca_id = substr($ca_id, 0, -2);
|
||||
$sql = " select ca_id, ca_name from {$g5['g5_shop_category_table']} where ca_id like '$parent_ca_id%' and length(ca_id) = $ca_id_len and ca_use = '1' order by ca_order, ca_id ";
|
||||
$result = sql_query($sql);
|
||||
}
|
||||
|
||||
while ($row=sql_fetch_array($result)) {
|
||||
|
||||
$row2 = sql_fetch(" select count(*) as cnt from {$g5['g5_shop_item_table']} where (ca_id like '{$row['ca_id']}%' or ca_id2 like '{$row['ca_id']}%' or ca_id3 like '{$row['ca_id']}%') and it_use = '1' ");
|
||||
|
||||
$current_attr = ($row['ca_id'] === $ca_id) ? ' class="sct_ct_here" aria-current="page"' : '';
|
||||
$str .= '<li><a'.$current_attr.' href="'.shop_category_url($row['ca_id']).'">'.$row['ca_name'].' <span class="prd_cnt">'.$row2['cnt'].'</span></a></li>';
|
||||
$exists = true;
|
||||
}
|
||||
|
||||
if ($exists) {
|
||||
|
||||
// add_stylesheet('css 구문', 출력순서); 숫자가 작을 수록 먼저 출력됨
|
||||
$category_groups = get_shop_category_menu_groups($ca_id);
|
||||
if ($category_groups) {
|
||||
add_stylesheet('<link rel="stylesheet" href="'.G5_SHOP_CSS_URL.'/style.css">', 0);
|
||||
?>
|
||||
|
||||
<!-- 상품분류 1 시작 { -->
|
||||
<aside id="sct_ct_1" class="sct_ct">
|
||||
<h2>현재 상품 분류와 관련된 분류</h2>
|
||||
<ul>
|
||||
<?php echo $str; ?>
|
||||
<?php foreach ($category_groups as $category_group) { ?>
|
||||
<ul aria-label="<?php echo $category_group['label']; ?>">
|
||||
<?php foreach ($category_group['categories'] as $row) {
|
||||
$row2 = sql_fetch(" select count(*) as cnt from {$g5['g5_shop_item_table']} where (ca_id like '{$row['ca_id']}%' or ca_id2 like '{$row['ca_id']}%' or ca_id3 like '{$row['ca_id']}%') and it_use = '1' ");
|
||||
$current_attr = '';
|
||||
if ($row['ca_id'] === $ca_id) {
|
||||
$current_attr = ' class="sct_ct_here" aria-current="page"';
|
||||
} elseif (strpos($ca_id, $row['ca_id']) === 0) {
|
||||
$current_attr = ' class="sct_ct_here sct_ct_parent"';
|
||||
}
|
||||
echo '<li><a'.$current_attr.' href="'.shop_category_url($row['ca_id']).'">'.get_text($row['ca_name']).' <span class="prd_cnt">'.$row2['cnt'].'</span></a></li>';
|
||||
} ?>
|
||||
</ul>
|
||||
<?php } ?>
|
||||
</aside>
|
||||
<!-- } 상품분류 1 끝 -->
|
||||
|
||||
<?php }
|
||||
<?php }
|
||||
|
||||
+5
-1
@@ -81,6 +81,10 @@ if( !isset($g5['g5_shop_post_log_table']) || !$g5['g5_shop_post_log_table'] ){
|
||||
$g5['g5_shop_post_log_table'] = G5_SHOP_TABLE_PREFIX.'order_post_log'; // 주문요청 로그 테이블
|
||||
}
|
||||
|
||||
if (empty($g5['g5_shop_order_access_table'])) {
|
||||
$g5['g5_shop_order_access_table'] = G5_SHOP_TABLE_PREFIX.'order_access';
|
||||
}
|
||||
|
||||
// 옵션 ID 특수문자 필터링 패턴
|
||||
define('G5_OPTION_ID_FILTER', '/[\'\"\\\'\\\"]/');
|
||||
|
||||
@@ -146,4 +150,4 @@ if (!empty($default['de_inicis_pro_use'])
|
||||
|
||||
//==============================================================================
|
||||
// 쇼핑몰 필수 실행코드 모음 끝
|
||||
//==============================================================================;
|
||||
//==============================================================================;
|
||||
|
||||
+17
-33
@@ -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)
|
||||
|
||||
+51
-14
@@ -7,12 +7,8 @@ if (function_exists('check_request_origin')) check_request_origin(G5_SHOP_URL);
|
||||
if(empty($_POST))
|
||||
die('정보가 넘어오지 않았습니다.');
|
||||
|
||||
// 일정 기간이 경과된 임시 데이터 삭제
|
||||
/*
|
||||
$limit_time = date("Y-m-d H:i:s", (G5_SERVER_TIME - 86400 * 1));
|
||||
$sql = " delete from {$g5['g5_shop_order_data_table']} where dt_type = '1' and dt_time < '$limit_time' ";
|
||||
sql_query($sql);
|
||||
*/
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
shop_order_checkout_restore();
|
||||
|
||||
$od_settle_case = isset($_POST['od_settle_case']) ? clean_xss_tags($_POST['od_settle_case'], 1, 1) : '';
|
||||
if ($od_settle_case === 'KAKAOPAY') {
|
||||
@@ -29,10 +25,14 @@ $_POST['od_ip'] = function_exists('get_real_client_ip') ? get_real_client_ip()
|
||||
if(isset($_POST['pp_id']) && $_POST['pp_id']) {
|
||||
$od_id = get_session('ss_personalpay_id');
|
||||
$cart_id = 0;
|
||||
if (!is_string($_POST['pp_id']) || $_POST['pp_id'] !== (string)$od_id ||
|
||||
!shop_order_access_id((string)$od_id) || !get_session('ss_personalpay_hash')) shop_order_access_fail();
|
||||
|
||||
$sql = "select pp_use, pp_tno from {$g5['g5_shop_personalpay_table']} where pp_id = '$od_id' ";
|
||||
$sql = "select pp_use, pp_tno, pp_price, pp_time from {$g5['g5_shop_personalpay_table']} where pp_id = '$od_id' ";
|
||||
$pp_row = sql_fetch($sql);
|
||||
|
||||
if (!$pp_row || md5($od_id.$pp_row['pp_price'].$pp_row['pp_time']) !== get_session('ss_personalpay_hash')) shop_order_access_fail();
|
||||
|
||||
if( $pp_row['pp_tno'] ){
|
||||
die('해당 개인결제는 이미 결제되었습니다.');
|
||||
} else if( ! $pp_row['pp_use'] ){
|
||||
@@ -55,6 +55,29 @@ if(isset($_POST['pp_id']) && $_POST['pp_id']) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!shop_order_access_id((string)$od_id) || (empty($_POST['pp_id']) && !$cart_id)) shop_order_access_fail();
|
||||
|
||||
// 비회원 비밀번호는 HTML 복원용 데이터와 분리하고 서버 세션에 해시만 보관한다.
|
||||
$order_password_hash = '';
|
||||
if (empty($member['mb_id']) && empty($_POST['pp_id'])) {
|
||||
if (isset($_POST['od_pwd']) && !is_string($_POST['od_pwd'])) shop_order_access_fail();
|
||||
$order_password_hash = get_encrypt_string(isset($_POST['od_pwd']) ? $_POST['od_pwd'] : shop_order_random_hex(16));
|
||||
}
|
||||
unset($_POST['od_pwd']);
|
||||
|
||||
|
||||
$default_pg = $default['de_pg_service'];
|
||||
if (function_exists('is_use_easypay') && is_use_easypay('global_nhnkcp') &&
|
||||
isset($_POST['nhnkcp_pay_case']) && $_POST['nhnkcp_pay_case'] === 'naverpay') {
|
||||
$default_pg = 'kcp';
|
||||
}
|
||||
|
||||
if( $od_settle_case == '삼성페이' ){ //현재 삼성페이인 경우에는 pg를 inicis로 처리
|
||||
$default_pg = 'inicis';
|
||||
}
|
||||
|
||||
shop_order_state_can_save((string)$od_id);
|
||||
$_POST = shop_order_filter_data($_POST, $default_pg);
|
||||
$dt_data = base64_encode(serialize($_POST));
|
||||
|
||||
// 동일한 주문번호가 있는지 체크
|
||||
@@ -63,12 +86,6 @@ $row = sql_fetch($sql);
|
||||
if($row['cnt'])
|
||||
sql_query(" delete from {$g5['g5_shop_order_data_table']} where od_id = '$od_id' ");
|
||||
|
||||
$default_pg = $default['de_pg_service'];
|
||||
|
||||
if( $od_settle_case == '삼성페이' ){ //현재 삼성페이인 경우에는 pg를 inicis로 처리
|
||||
$default_pg = 'inicis';
|
||||
}
|
||||
|
||||
$sql = " insert into {$g5['g5_shop_order_data_table']}
|
||||
set od_id = '$od_id',
|
||||
cart_id = '$cart_id',
|
||||
@@ -76,6 +93,26 @@ $sql = " insert into {$g5['g5_shop_order_data_table']}
|
||||
dt_pg = '$default_pg',
|
||||
dt_data = '$dt_data',
|
||||
dt_time = '".G5_TIME_YMDHIS."' ";
|
||||
sql_query($sql);
|
||||
if (!sql_query($sql, false)) shop_order_access_fail();
|
||||
|
||||
// 요청 본문을 그대로 인증 상태로 사용하지 않는다. 세션은 응답 HTML에 노출하지 않는다.
|
||||
$states = get_session('ss_order_data_access');
|
||||
if (!is_array($states)) $states = array();
|
||||
foreach ($states as $key => $state) {
|
||||
if ($state['expires'] < G5_SERVER_TIME) unset($states[$key]);
|
||||
}
|
||||
$states[(string)$od_id] = array(
|
||||
'pg' => $default_pg,
|
||||
'member' => isset($member['mb_id']) ? (string)$member['mb_id'] : '',
|
||||
'cart' => (string)$cart_id,
|
||||
'personal' => !empty($_POST['pp_id']),
|
||||
'personal_hash' => get_session('ss_personalpay_hash'),
|
||||
'direct' => (bool)get_session('ss_direct'),
|
||||
'time' => G5_TIME_YMDHIS,
|
||||
'expires' => G5_SERVER_TIME + (defined('G5_ORDER_DATA_ACCESS_TTL') ? G5_ORDER_DATA_ACCESS_TTL : 7200),
|
||||
'digest' => hash('sha256', $dt_data),
|
||||
'password_hash' => $order_password_hash,
|
||||
);
|
||||
$states[(string)$od_id] = shop_order_state_save((string)$od_id, $states[(string)$od_id]);
|
||||
|
||||
die('');
|
||||
+27
-38
@@ -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)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
|
||||
if (function_exists('shop_order_state_cancel')) shop_order_state_cancel('cancel_pending');
|
||||
|
||||
// locale ko_KR.euc-kr 로 설정
|
||||
setlocale(LC_CTYPE, 'ko_KR.euc-kr');
|
||||
|
||||
@@ -75,4 +77,5 @@ if ( $req_tx == "pay" )
|
||||
/* ============================================================================== */
|
||||
|
||||
// locale 설정 초기화
|
||||
setlocale(LC_CTYPE, '');
|
||||
setlocale(LC_CTYPE, '');
|
||||
if (isset($res_cd) && $res_cd === '0000' && function_exists('shop_order_state_cancel')) shop_order_state_cancel('cancelled');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+14
-11
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit; // 개별 페이지 접근 불가
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
|
||||
require_once(G5_SHOP_PATH.'/settle_'.$default['de_pg_service'].'.inc.php');
|
||||
|
||||
@@ -24,7 +25,9 @@ if(function_exists('is_use_easypay') && is_use_easypay('global_nhnkcp')){ //
|
||||
|
||||
?>
|
||||
|
||||
<script src="<?php echo G5_JS_URL; ?>/shop.order-state.js"></script>
|
||||
<form name="forderform" id="forderform" method="post" action="<?php echo $order_action_url; ?>" autocomplete="off">
|
||||
<?php echo shop_order_checkout_fields((string)$od_id, false); ?>
|
||||
<div id="sod_frm" class="sod_frm_pc">
|
||||
<!-- 주문상품 확인 시작 { -->
|
||||
<div class="tbl_head03 tbl_wrap od_prd_list">
|
||||
@@ -1622,14 +1625,14 @@ function forderform_check(f)
|
||||
|
||||
<?php if($default['de_escrow_use']) { ?>
|
||||
f.cardUseEscrow.value = 'true';
|
||||
f.escrowProducts.value = JSON.stringify(<?php echo json_encode($escrow_products, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>);
|
||||
f.escrowProducts.value = JSON.stringify(<?php echo json_encode($escrow_products); ?>);
|
||||
<?php } ?>
|
||||
|
||||
f.cardflowMode.value = 'DEFAULT';
|
||||
f.cardeasyPay.value = '';
|
||||
if(settle_method == "간편결제") {
|
||||
var provider = $("input[name=od_settle_case]:checked").attr("data-pay");
|
||||
var providers = <?php echo json_encode(array_values(array_map(function ($provider) { return $provider[1]; }, shop_easypay_catalog('toss')))); ?>;
|
||||
var providers = <?php echo json_encode(shop_order_toss_providers()); ?>;
|
||||
if (providers.indexOf(provider) === -1) {
|
||||
alert('간편결제 수단을 다시 선택해 주세요.');
|
||||
return false;
|
||||
@@ -1648,15 +1651,15 @@ function forderform_check(f)
|
||||
if(f.method.value != "무통장") {
|
||||
// 주문정보 임시저장
|
||||
var order_data = $(f).serialize();
|
||||
var save_result = "";
|
||||
var save_result = "결제 요청을 저장하지 못했습니다.";
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
data: order_data,
|
||||
url: g5_url+"/shop/ajax.orderdatasave.php",
|
||||
cache: false,
|
||||
async: false,
|
||||
success: function(data) {
|
||||
save_result = data;
|
||||
success: function(data, textStatus, xhr) {
|
||||
save_result = data || g5_order_state_accept(xhr);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1687,15 +1690,15 @@ function forderform_check(f)
|
||||
if(f.gopaymethod.value != "무통장") {
|
||||
// 주문정보 임시저장
|
||||
var order_data = $(f).serialize();
|
||||
var save_result = "";
|
||||
var save_result = "결제 요청을 저장하지 못했습니다.";
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
data: order_data,
|
||||
url: g5_url+"/shop/ajax.orderdatasave.php",
|
||||
cache: false,
|
||||
async: false,
|
||||
success: function(data) {
|
||||
save_result = data;
|
||||
success: function(data, textStatus, xhr) {
|
||||
save_result = data || g5_order_state_accept(xhr);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1730,15 +1733,15 @@ function forderform_check(f)
|
||||
if(f.PayMethod.value != "무통장") {
|
||||
// 주문정보 임시저장
|
||||
var order_data = $(f).serialize();
|
||||
var save_result = "";
|
||||
var save_result = "결제 요청을 저장하지 못했습니다.";
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
data: order_data,
|
||||
url: g5_url+"/shop/ajax.orderdatasave.php",
|
||||
cache: false,
|
||||
async: false,
|
||||
success: function(data) {
|
||||
save_result = data;
|
||||
success: function(data, textStatus, xhr) {
|
||||
save_result = data || g5_order_state_accept(xhr);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
shop_order_state_prepare(false);
|
||||
// PG 호출 전에 저장된 비회원 비밀번호 해시의 주문 소유권을 확인한다.
|
||||
$saved_order_password = ($is_member || $od_settle_case === '무통장') ? null : shop_order_access_password((string)get_session('ss_order_id'));
|
||||
|
||||
|
||||
// 구 주문서나 직접 POST로도 SIRK 전용 결제를 다시 시작하지 않는다.
|
||||
if (isset($od_settle_case) && $od_settle_case === 'KAKAOPAY') {
|
||||
@@ -47,6 +52,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 +536,12 @@ 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) : '';
|
||||
// LG 통보는 결제 당시 서버에서 구성한 운영/테스트 MID와 대조한다.
|
||||
$od_lg_mid = ($od_pg === 'lg' && isset($LGD_MID))
|
||||
? sql_escape_string($LGD_MID) : '';
|
||||
|
||||
$tno = isset($tno) ? $tno : '';
|
||||
$od_receipt_time = isset($od_receipt_time) ? $od_receipt_time : '';
|
||||
@@ -544,7 +561,8 @@ if($tno) {
|
||||
if ($is_member)
|
||||
$od_pwd = $member['mb_password'];
|
||||
else
|
||||
$od_pwd = isset($_POST['od_pwd']) ? get_encrypt_string($_POST['od_pwd']) : get_encrypt_string(mt_rand());
|
||||
$od_pwd = $saved_order_password !== null ? $saved_order_password :
|
||||
(isset($_POST['od_pwd']) ? get_encrypt_string($_POST['od_pwd']) : get_encrypt_string(mt_rand()));
|
||||
|
||||
// 주문번호를 얻는다.
|
||||
$od_id = get_session('ss_order_id');
|
||||
@@ -624,6 +642,8 @@ $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_lg_mid = '$od_lg_mid',
|
||||
od_tno = '$od_tno',
|
||||
od_app_no = '$od_app_no',
|
||||
od_escrow = '$od_escrow',
|
||||
@@ -643,6 +663,7 @@ $sql = " insert {$g5['g5_shop_order_table']}
|
||||
od_cash_info = '{$pg_receipt_infos['od_cash_info']}',
|
||||
od_test = '{$default['de_card_test']}'
|
||||
";
|
||||
shop_order_state_finalizing();
|
||||
$result = sql_query($sql, false);
|
||||
|
||||
// 정말로 insert 가 되었는지 한번더 체크한다.
|
||||
@@ -974,14 +995,13 @@ $uid = function_exists('get_shop_uid') ? get_shop_uid('order', $od_id, G5_TIME_Y
|
||||
set_session('ss_orderview_uid', $uid);
|
||||
|
||||
// 주문 정보 임시 데이터 삭제
|
||||
if($od_pg == 'inicis') {
|
||||
$sql = " delete from {$g5['g5_shop_order_data_table']} where od_id = '$od_id' and dt_pg = '$od_pg' ";
|
||||
sql_query($sql);
|
||||
}
|
||||
$sql = " delete from {$g5['g5_shop_order_data_table']} where od_id = '$od_id' and dt_pg = '$od_pg' ";
|
||||
sql_query($sql);
|
||||
|
||||
if(function_exists('add_order_post_log')) add_order_post_log('', 'delete');
|
||||
|
||||
// 주문번호제거
|
||||
shop_order_access_forget((string)$od_id);
|
||||
set_session('ss_order_id', '');
|
||||
|
||||
// 기존자료 세션에서 제거
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
if (!defined('_GNUBOARD_')) exit; // 개별 페이지 접근 불가
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
|
||||
require_once(G5_SHOP_PATH.'/settle_'.$default['de_pg_service'].'.inc.php');
|
||||
|
||||
@@ -7,7 +8,9 @@ require_once(G5_SHOP_PATH.'/settle_'.$default['de_pg_service'].'.inc.php');
|
||||
require_once(G5_SHOP_PATH.'/'.$default['de_pg_service'].'/orderform.1.php');
|
||||
?>
|
||||
|
||||
<script src="<?php echo G5_JS_URL; ?>/shop.order-state.js"></script>
|
||||
<form name="forderform" id="forderform" method="post" action="<?php echo $order_action_url; ?>" autocomplete="off">
|
||||
<?php echo shop_order_checkout_fields((string)$pp['pp_id'], true); ?>
|
||||
<input type="hidden" name="pp_id" value="<?php echo $pp['pp_id']; ?>">
|
||||
|
||||
<?php
|
||||
@@ -373,15 +376,15 @@ function forderform_check(f)
|
||||
if(f.method.value != "무통장") {
|
||||
// 주문정보 임시저장
|
||||
var order_data = $(f).serialize();
|
||||
var save_result = "";
|
||||
var save_result = "결제 요청을 저장하지 못했습니다.";
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
data: order_data,
|
||||
url: g5_url+"/shop/ajax.orderdatasave.php",
|
||||
cache: false,
|
||||
async: false,
|
||||
success: function(data) {
|
||||
save_result = data;
|
||||
success: function(data, textStatus, xhr) {
|
||||
save_result = data || g5_order_state_accept(xhr);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -404,15 +407,15 @@ function forderform_check(f)
|
||||
if(f.gopaymethod.value != "무통장") {
|
||||
// 주문정보 임시저장
|
||||
var order_data = $(f).serialize();
|
||||
var save_result = "";
|
||||
var save_result = "결제 요청을 저장하지 못했습니다.";
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
data: order_data,
|
||||
url: g5_url+"/shop/ajax.orderdatasave.php",
|
||||
cache: false,
|
||||
async: false,
|
||||
success: function(data) {
|
||||
save_result = data;
|
||||
success: function(data, textStatus, xhr) {
|
||||
save_result = data || g5_order_state_accept(xhr);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -447,15 +450,15 @@ function forderform_check(f)
|
||||
if(f.PayMethod.value != "무통장") {
|
||||
// 주문정보 임시저장
|
||||
var order_data = $(f).serialize();
|
||||
var save_result = "";
|
||||
var save_result = "결제 요청을 저장하지 못했습니다.";
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
data: order_data,
|
||||
url: g5_url+"/shop/ajax.orderdatasave.php",
|
||||
cache: false,
|
||||
async: false,
|
||||
success: function(data) {
|
||||
save_result = data;
|
||||
success: function(data, textStatus, xhr) {
|
||||
save_result = data || g5_order_state_accept(xhr);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
shop_order_state_prepare(true);
|
||||
include_once(G5_LIB_PATH.'/mailer.lib.php');
|
||||
|
||||
$page_return_url = G5_SHOP_URL.'/personalpayform.php?pp_id='.get_session('ss_personalpay_id');
|
||||
@@ -192,6 +194,12 @@ 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) : '';
|
||||
// LG 통보는 결제 당시 서버에서 구성한 운영/테스트 MID와 대조한다.
|
||||
$pp_lg_mid = ($pp_pg === 'lg' && isset($LGD_MID))
|
||||
? sql_escape_string($LGD_MID) : '';
|
||||
$pp_email = get_email_address($pp_email);
|
||||
|
||||
// 결제정보 입력
|
||||
@@ -199,6 +207,8 @@ $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_lg_mid = '$pp_lg_mid',
|
||||
pp_tno = '$pp_tno',
|
||||
pp_app_no = '$app_no',
|
||||
pp_receipt_price = '$pp_receipt_price',
|
||||
@@ -211,6 +221,7 @@ $sql = " update {$g5['g5_shop_personalpay_table']}
|
||||
pp_cash_no = '{$pg_receipt_infos['od_cash_no']}',
|
||||
pp_cash_info = '{$pg_receipt_infos['od_cash_info']}'
|
||||
where pp_id = '{$pp['pp_id']}' ";
|
||||
shop_order_state_finalizing();
|
||||
$result = sql_query($sql, false);
|
||||
|
||||
// 결제정보 입력 오류시 결제 취소
|
||||
@@ -310,10 +321,16 @@ if($pp_receipt_price > 0 && $pp['pp_id'] && $pp['od_id']) {
|
||||
}
|
||||
}
|
||||
|
||||
// 완료한 개인결제의 임시 데이터 삭제
|
||||
$sql = " delete from {$g5['g5_shop_order_data_table']} where od_id = '{$pp['pp_id']}' and dt_pg = '$pp_pg' ";
|
||||
sql_query($sql);
|
||||
|
||||
// 개인결제번호제거
|
||||
if (!empty($_POST['inicis_pro']) && function_exists('inicis_pro_audit_order_saved'))
|
||||
inicis_pro_audit_order_saved($pp['pp_id'], $pp_tno, 'personal', 'web');
|
||||
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
shop_order_access_forget((string)$pp['pp_id']);
|
||||
set_session('ss_personalpay_id', '');
|
||||
set_session('ss_personalpay_hash', '');
|
||||
|
||||
|
||||
+4
-267
@@ -1,271 +1,8 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
include_once(G5_LIB_PATH.'/etc.lib.php');
|
||||
include_once(G5_LIB_PATH.'/mailer.lib.php');
|
||||
include_once(G5_LIB_PATH.'/kcp_notification.lib.php');
|
||||
|
||||
/*------------------------------------------------------------------------------
|
||||
※ KCP 에서 가맹점의 결과처리 페이지로 데이터를 전송할 때에, 아래와 같은
|
||||
IP 에서 전송을 합니다. 따라서 가맹점측께서 전송받는 데이터에 대해 KCP
|
||||
에서 전송된 건이 맞는지 체크하는 부분을 구현할 때에, 아래의 IP 에 대해
|
||||
REMOTE ADDRESS 체크를 하여, 아래의 IP 이외의 다른 경로를 통해서 전송된
|
||||
데이터에 대해서는 결과처리를 하지 마시기 바랍니다.
|
||||
------------------------------------------------------------------------------*/
|
||||
if(!$default['de_card_test']) {
|
||||
switch ($_SERVER['REMOTE_ADDR']) {
|
||||
case '203.238.36.58' :
|
||||
case '203.238.36.160' :
|
||||
case '203.238.36.161' :
|
||||
case '203.238.36.173' :
|
||||
case '203.238.36.178' :
|
||||
case '103.215.144.173' : //판교 IDC IP주소 2019년 4월 3일 추가
|
||||
case '103.215.144.174' : //판교 IDC IP주소 2019년 4월 3일 추가
|
||||
case '103.215.145.30' : //판교 IDC IP주소 2019년 4월 3일 추가
|
||||
break;
|
||||
default :
|
||||
$super_admin = get_admin('super');
|
||||
$egpcs_str = "ENV[" . serialize($_ENV) . "] "
|
||||
. "GET[" . serialize($_GET) . "]"
|
||||
. "POST[" . serialize($_POST) . "]"
|
||||
. "COOKIE[" . serialize($_COOKIE) . "]"
|
||||
. "SESSION[" . serialize($_SESSION) . "]";
|
||||
mailer('경고', 'waring', $super_admin['mb_email'], '올바르지 않은 접속 보고', "{$_SERVER['SCRIPT_NAME']} 에 {$_SERVER['REMOTE_ADDR']} 이 ".G5_TIME_YMDHIS." 에 접속을 시도하였습니다.\n\n" . $egpcs_str, 2);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================== */
|
||||
/* = PAGE : 공통 통보 PAGE = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = Copyright (c) 2006 KCP Inc. All Rights Reserverd. = */
|
||||
/* ============================================================================== */
|
||||
$data = kcp_noti_request($_POST, $_SERVER, $default);
|
||||
$result = $data !== false && kcp_noti_process($data) ? '0000' : '9999';
|
||||
?>
|
||||
<?php
|
||||
/* ============================================================================== */
|
||||
/* = 01. 공통 통보 페이지 설명(필독!!) = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 에스크로 서비스의 경우, 가상계좌 입금 통보 데이터와 가상계좌 환불 = */
|
||||
/* = 통보 데이터, 구매확인/구매취소 통보 데이터, 배송시작 통보 데이터 등을 = */
|
||||
/* = KCP 를 통해 별도로 통보 받을 수 있습니다. 이러한 통보 데이터를 받기 = */
|
||||
/* = 위해 가맹점측은 결과를 전송받는 페이지를 마련해 놓아야 합니다. = */
|
||||
/* = 현재의 페이지를 업체에 맞게 수정하신 후, KCP 관리자 페이지에 등록해 = */
|
||||
/* = 주시기 바랍니다. 등록 방법은 연동 매뉴얼을 참고하시기 바랍니다. = */
|
||||
/* ============================================================================== */
|
||||
|
||||
//write_log("$g5[path]/data/log/kcp_common.log", print_r($_POST));
|
||||
|
||||
/* ============================================================================== */
|
||||
/* = 02. 공통 통보 데이터 받기 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
$site_cd = isset($_POST["site_cd"]) ? $_POST["site_cd"] : ''; // 사이트 코드
|
||||
$tno = isset($_POST["tno"]) ? $_POST["tno"] : ''; // KCP 거래번호
|
||||
$order_no = isset($_POST["order_no"]) ? $_POST["order_no"] : ''; // 주문번호
|
||||
$tx_cd = isset($_POST["tx_cd"]) ? $_POST["tx_cd"] : ''; // 업무처리 구분 코드
|
||||
$tx_tm = isset($_POST["tx_tm"]) ? $_POST["tx_tm"] : ''; // 업무처리 완료 시간
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
$ipgm_name = ""; // 주문자명
|
||||
$remitter = ""; // 입금자명
|
||||
$ipgm_mnyx = ""; // 입금 금액
|
||||
$bank_code = ""; // 은행코드
|
||||
$account = ""; // 가상계좌 입금계좌번호
|
||||
$op_cd = ""; // 처리구분 코드
|
||||
$noti_id = ""; // 통보 아이디
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
$refund_nm = ""; // 환불계좌주명
|
||||
$refund_mny = ""; // 환불금액
|
||||
$bank_code = ""; // 은행코드
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
$st_cd = ""; // 구매확인 코드
|
||||
$can_msg = ""; // 구매취소 사유
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
$waybill_no = ""; // 운송장 번호
|
||||
$waybill_corp = ""; // 택배 업체명
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 02-1. 가상계좌 입금 통보 데이터 받기 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
if ( $tx_cd == "TX00" )
|
||||
{
|
||||
$ipgm_name = isset($_POST["ipgm_name"]) ? $_POST["ipgm_name"] : ''; // 주문자명
|
||||
$remitter = isset($_POST["remitter"]) ? $_POST["remitter"] : ''; // 입금자명
|
||||
$ipgm_mnyx = isset($_POST["ipgm_mnyx"]) ? $_POST["ipgm_mnyx"] : ''; // 입금 금액
|
||||
$bank_code = isset($_POST["bank_code"]) ? $_POST["bank_code"] : ''; // 은행코드
|
||||
$account = isset($_POST["account"]) ? $_POST["account"] : ''; // 가상계좌 입금계좌번호
|
||||
$op_cd = isset($_POST["op_cd"]) ? $_POST["op_cd"] : ''; // 처리구분 코드
|
||||
$noti_id = isset($_POST["noti_id"]) ? $_POST["noti_id"] : ''; // 통보 아이디
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 02-2. 가상계좌 환불 통보 데이터 받기 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX01" )
|
||||
{
|
||||
$refund_nm = isset($_POST["refund_nm"]) ? $_POST["refund_nm"] : ''; // 환불계좌주명
|
||||
$refund_mny = isset($_POST["refund_mny"]) ? $_POST["refund_mny"] : ''; // 환불금액
|
||||
$bank_code = isset($_POST["bank_code"]) ? $_POST["bank_code"] : ''; // 은행코드
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 02-3. 구매확인/구매취소 통보 데이터 받기 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX02" )
|
||||
{
|
||||
$st_cd = isset($_POST["st_cd"]) ? $_POST["st_cd"] : ''; // 구매확인 코드
|
||||
|
||||
if ( $st_cd == "N" ) // 구매확인 상태가 구매취소인 경우
|
||||
{
|
||||
$can_msg = isset($_POST["can_msg"]) ? $_POST["can_msg"] : ''; // 구매취소 사유
|
||||
}
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 02-4. 배송시작 통보 데이터 받기 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX03" )
|
||||
{
|
||||
$waybill_no = isset($_POST["waybill_no"]) ? $_POST["waybill_no"] : ''; // 운송장 번호
|
||||
$waybill_corp = isset($_POST["waybill_corp"]) ? $_POST["waybill_corp"] : ''; // 택배 업체명
|
||||
}
|
||||
/* ============================================================================== */
|
||||
|
||||
|
||||
/* ============================================================================== */
|
||||
/* = 03. 공통 통보 결과를 업체 자체적으로 DB 처리 작업하시는 부분입니다. = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 통보 결과를 DB 작업 하는 과정에서 정상적으로 통보된 건에 대해 DB 작업을 = */
|
||||
/* = 실패하여 DB update 가 완료되지 않은 경우, 결과를 재통보 받을 수 있는 = */
|
||||
/* = 프로세스가 구성되어 있습니다. 소스에서 result 라는 Form 값을 생성 하신 = */
|
||||
/* = 후, DB 작업이 성공 한 경우, result 의 값을 "0000" 로 세팅해 주시고, = */
|
||||
/* = DB 작업이 실패 한 경우, result 의 값을 "0000" 이외의 값으로 세팅해 주시 = */
|
||||
/* = 기 바랍니다. result 값이 "0000" 이 아닌 경우에는 재통보를 받게 됩니다. = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 03-1. 가상계좌 입금 통보 데이터 DB 처리 작업 부분 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
if ( $tx_cd == "TX00" )
|
||||
{
|
||||
$sql = " select pp_id, od_id from {$g5['g5_shop_personalpay_table']} where pp_id = '$order_no' and pp_tno = '$tno' ";
|
||||
$row = sql_fetch($sql);
|
||||
|
||||
$result = false;
|
||||
|
||||
if($row['pp_id']) {
|
||||
// 개인결제 UPDATE
|
||||
$sql = " update {$g5['g5_shop_personalpay_table']}
|
||||
set pp_receipt_price = '$ipgm_mnyx',
|
||||
pp_receipt_time = '$tx_tm'
|
||||
where pp_id = '$order_no'
|
||||
and pp_tno = '$tno' ";
|
||||
sql_query($sql, false);
|
||||
|
||||
if($row['od_id']) {
|
||||
// 주문서 UPDATE
|
||||
$receipt_time = preg_replace("/([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})/", "\\1-\\2-\\3 \\4:\\5:\\6", $tx_tm);
|
||||
$sql = " update {$g5['g5_shop_order_table']}
|
||||
set od_receipt_price = od_receipt_price + '$ipgm_mnyx',
|
||||
od_receipt_time = '$tx_tm',
|
||||
od_shop_memo = concat(od_shop_memo, \"\\n개인결제 ".$row['pp_id']." 로 결제완료 - ".$receipt_time."\")
|
||||
where od_id = '{$row['od_id']}' ";
|
||||
$result = sql_query($sql, FALSE);
|
||||
}
|
||||
} else {
|
||||
// 주문서 UPDATE
|
||||
$sql = " update {$g5['g5_shop_order_table']}
|
||||
set od_receipt_price = '$ipgm_mnyx',
|
||||
od_receipt_time = '$tx_tm'
|
||||
where od_id = '$order_no'
|
||||
and od_tno = '$tno' ";
|
||||
$result = sql_query($sql, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
if($result) {
|
||||
if($row['od_id'])
|
||||
$od_id = $row['od_id'];
|
||||
else
|
||||
$od_id = $order_no;
|
||||
|
||||
// 주문정보 체크
|
||||
$sql = " select count(od_id) as cnt
|
||||
from {$g5['g5_shop_order_table']}
|
||||
where od_id = '$od_id'
|
||||
and od_status = '주문' ";
|
||||
$row = sql_fetch($sql);
|
||||
|
||||
if($row['cnt'] == 1) {
|
||||
// 미수금 정보 업데이트
|
||||
$info = get_order_info($od_id);
|
||||
|
||||
$sql = " update {$g5['g5_shop_order_table']}
|
||||
set od_misu = '{$info['od_misu']}' ";
|
||||
if($info['od_misu'] == 0)
|
||||
$sql .= " , od_status = '입금' ";
|
||||
$sql .= " where od_id = '$od_id' ";
|
||||
sql_query($sql, FALSE);
|
||||
|
||||
// 장바구니 상태변경
|
||||
if($info['od_misu'] == 0) {
|
||||
$sql = " update {$g5['g5_shop_cart_table']}
|
||||
set ct_status = '입금'
|
||||
where od_id = '$od_id' ";
|
||||
sql_query($sql, FALSE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 03-2. 가상계좌 환불 통보 데이터 DB 처리 작업 부분 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX01" )
|
||||
{
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 03-3. 구매확인/구매취소 통보 데이터 DB 처리 작업 부분 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX02" )
|
||||
{
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 03-4. 배송시작 통보 데이터 DB 처리 작업 부분 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX03" )
|
||||
{
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 03-5. 정산보류 통보 데이터 DB 처리 작업 부분 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX04" )
|
||||
{
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 03-6. 즉시취소 통보 데이터 DB 처리 작업 부분 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX05" )
|
||||
{
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 03-7. 취소 통보 데이터 DB 처리 작업 부분 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX06" )
|
||||
{
|
||||
}
|
||||
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
/* = 03-7. 발급계좌해지 통보 데이터 DB 처리 작업 부분 = */
|
||||
/* = -------------------------------------------------------------------------- = */
|
||||
else if ( $tx_cd == "TX07" )
|
||||
{
|
||||
}
|
||||
/* ============================================================================== */
|
||||
|
||||
|
||||
/* ============================================================================== */
|
||||
/* = 04. result 값 세팅 하기 = */
|
||||
/* ============================================================================== */
|
||||
?>
|
||||
<html><body><form><input type="hidden" name="result" value="0000"></form></body></html>
|
||||
<html><body><form><input type="hidden" name="result" value="<?php echo $result; ?>"></form></body></html>
|
||||
|
||||
+5
-175
@@ -1,178 +1,8 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
include_once(G5_LIB_PATH.'/lg_notification.lib.php');
|
||||
|
||||
|
||||
/*
|
||||
* [상점 결제결과처리(DB) 페이지]
|
||||
*
|
||||
* 1) 위변조 방지를 위한 hashdata값 검증은 반드시 적용하셔야 합니다.
|
||||
*
|
||||
*/
|
||||
$LGD_RESPCODE = isset($_POST["LGD_RESPCODE"]) ? $_POST["LGD_RESPCODE"] : ''; // 응답코드: 0000(성공) 그외 실패
|
||||
$LGD_RESPMSG = isset($_POST["LGD_RESPMSG"]) ? $_POST["LGD_RESPMSG"] : ''; // 응답메세지
|
||||
$LGD_MID = isset($_POST["LGD_MID"]) ? $_POST["LGD_MID"] : ''; // 상점아이디
|
||||
$LGD_OID = isset($_POST["LGD_OID"]) ? $_POST["LGD_OID"] : ''; // 주문번호
|
||||
$LGD_AMOUNT = isset($_POST["LGD_AMOUNT"]) ? $_POST["LGD_AMOUNT"] : ''; // 거래금액
|
||||
$LGD_TID = isset($_POST["LGD_TID"]) ? $_POST["LGD_TID"] : ''; // LG유플러스에서 부여한 거래번호
|
||||
$LGD_PAYTYPE = isset($_POST["LGD_PAYTYPE"]) ? $_POST["LGD_PAYTYPE"] : ''; // 결제수단코드
|
||||
$LGD_PAYDATE = isset($_POST["LGD_PAYDATE"]) ? $_POST["LGD_PAYDATE"] : ''; // 거래일시(승인일시/이체일시)
|
||||
$LGD_HASHDATA = isset($_POST["LGD_HASHDATA"]) ? $_POST["LGD_HASHDATA"] : ''; // 해쉬값
|
||||
$LGD_FINANCECODE = isset($_POST["LGD_FINANCECODE"]) ? $_POST["LGD_FINANCECODE"] : ''; // 결제기관코드(은행코드)
|
||||
$LGD_FINANCENAME = isset($_POST["LGD_FINANCENAME"]) ? $_POST["LGD_FINANCENAME"] : ''; // 결제기관이름(은행이름)
|
||||
$LGD_ESCROWYN = isset($_POST["LGD_ESCROWYN"]) ? $_POST["LGD_ESCROWYN"] : ''; // 에스크로 적용여부
|
||||
$LGD_TIMESTAMP = isset($_POST["LGD_TIMESTAMP"]) ? $_POST["LGD_TIMESTAMP"] : ''; // 타임스탬프
|
||||
$LGD_ACCOUNTNUM = isset($_POST["LGD_ACCOUNTNUM"]) ? $_POST["LGD_ACCOUNTNUM"] : ''; // 계좌번호(무통장입금)
|
||||
$LGD_CASTAMOUNT = isset($_POST["LGD_CASTAMOUNT"]) ? $_POST["LGD_CASTAMOUNT"] : ''; // 입금총액(무통장입금)
|
||||
$LGD_CASCAMOUNT = isset($_POST["LGD_CASCAMOUNT"]) ? $_POST["LGD_CASCAMOUNT"] : ''; // 현입금액(무통장입금)
|
||||
$LGD_CASFLAG = isset($_POST["LGD_CASFLAG"]) ? $_POST["LGD_CASFLAG"] : ''; // 무통장입금 플래그(무통장입금) - 'R':계좌할당, 'I':입금, 'C':입금취소
|
||||
$LGD_CASSEQNO = isset($_POST["LGD_CASSEQNO"]) ? $_POST["LGD_CASSEQNO"] : ''; // 입금순서(무통장입금)
|
||||
$LGD_CASHRECEIPTNUM = isset($_POST["LGD_CASHRECEIPTNUM"]) ? $_POST["LGD_CASHRECEIPTNUM"] : ''; // 현금영수증 승인번호
|
||||
$LGD_CASHRECEIPTSELFYN = isset($_POST["LGD_CASHRECEIPTSELFYN"]) ? $_POST["LGD_CASHRECEIPTSELFYN"] : ''; // 현금영수증자진발급제유무 Y: 자진발급제 적용, 그외 : 미적용
|
||||
$LGD_CASHRECEIPTKIND = isset($_POST["LGD_CASHRECEIPTKIND"]) ? $_POST["LGD_CASHRECEIPTKIND"] : ''; // 현금영수증 종류 0: 소득공제용 , 1: 지출증빙용
|
||||
$LGD_PAYER = isset($_POST["LGD_PAYER"]) ? $_POST["LGD_PAYER"] : ''; // 입금자명
|
||||
|
||||
/*
|
||||
* 구매정보
|
||||
*/
|
||||
$LGD_BUYER = isset($_POST["LGD_BUYER"]) ? $_POST["LGD_BUYER"] : ''; // 구매자
|
||||
$LGD_PRODUCTINFO = isset($_POST["LGD_PRODUCTINFO"]) ? $_POST["LGD_PRODUCTINFO"] : ''; // 상품명
|
||||
$LGD_BUYERID = isset($_POST["LGD_BUYERID"]) ? $_POST["LGD_BUYERID"] : ''; // 구매자 ID
|
||||
$LGD_BUYERADDRESS = isset($_POST["LGD_BUYERADDRESS"]) ? $_POST["LGD_BUYERADDRESS"] : ''; // 구매자 주소
|
||||
$LGD_BUYERPHONE = isset($_POST["LGD_BUYERPHONE"]) ? $_POST["LGD_BUYERPHONE"] : ''; // 구매자 전화번호
|
||||
$LGD_BUYEREMAIL = isset($_POST["LGD_BUYEREMAIL"]) ? $_POST["LGD_BUYEREMAIL"] : ''; // 구매자 이메일
|
||||
$LGD_BUYERSSN = isset($_POST["LGD_BUYERSSN"]) ? $_POST["LGD_BUYERSSN"] : ''; // 구매자 주민번호
|
||||
$LGD_PRODUCTCODE = isset($_POST["LGD_PRODUCTCODE"]) ? $_POST["LGD_PRODUCTCODE"] : ''; // 상품코드
|
||||
$LGD_RECEIVER = isset($_POST["LGD_RECEIVER"]) ? $_POST["LGD_RECEIVER"] : ''; // 수취인
|
||||
$LGD_RECEIVERPHONE = isset($_POST["LGD_RECEIVERPHONE"]) ? $_POST["LGD_RECEIVERPHONE"] : ''; // 수취인 전화번호
|
||||
$LGD_DELIVERYINFO = isset($_POST["LGD_DELIVERYINFO"]) ? $_POST["LGD_DELIVERYINFO"] : ''; // 배송지
|
||||
|
||||
$LGD_MERTKEY = $config['cf_lg_mert_key']; //LG유플러스에서 발급한 상점키로 변경해 주시기 바랍니다.
|
||||
|
||||
$LGD_HASHDATA2 = md5($LGD_MID.$LGD_OID.$LGD_AMOUNT.$LGD_RESPCODE.$LGD_TIMESTAMP.$LGD_MERTKEY);
|
||||
|
||||
/*
|
||||
* 상점 처리결과 리턴메세지
|
||||
*
|
||||
* OK : 상점 처리결과 성공
|
||||
* 그외 : 상점 처리결과 실패
|
||||
*
|
||||
* ※ 주의사항 : 성공시 'OK' 문자이외의 다른문자열이 포함되면 실패처리 되오니 주의하시기 바랍니다.
|
||||
*/
|
||||
$resultMSG = "결제결과 상점 DB처리(LGD_CASNOTEURL) 결과값을 입력해 주시기 바랍니다.";
|
||||
|
||||
|
||||
if ( $LGD_HASHDATA2 == $LGD_HASHDATA ) { //해쉬값 검증이 성공이면
|
||||
if ( "0000" == $LGD_RESPCODE ){ //결제가 성공이면
|
||||
if( "R" == $LGD_CASFLAG ) {
|
||||
/*
|
||||
* 무통장 할당 성공 결과 상점 처리(DB) 부분
|
||||
* 상점 결과 처리가 정상이면 "OK"
|
||||
*/
|
||||
//if( 무통장 할당 성공 상점처리결과 성공 )
|
||||
$resultMSG = "OK";
|
||||
}else if( "I" == $LGD_CASFLAG ) {
|
||||
/*
|
||||
* 무통장 입금 성공 결과 상점 처리(DB) 부분
|
||||
* 상점 결과 처리가 정상이면 "OK"
|
||||
*/
|
||||
|
||||
$sql = " select pp_id, od_id from {$g5['g5_shop_personalpay_table']} where pp_id = '$LGD_OID' and pp_tno = '$LGD_TID' ";
|
||||
$row = sql_fetch($sql);
|
||||
|
||||
$result = false;
|
||||
|
||||
if(isset($row['pp_id']) && $row['pp_id']) {
|
||||
// 개인결제 UPDATE
|
||||
$sql = " update {$g5['g5_shop_personalpay_table']}
|
||||
set pp_receipt_price = '$LGD_AMOUNT',
|
||||
pp_receipt_time = '$LGD_PAYDATE',
|
||||
pp_casseqno = '$LGD_CASSEQNO'
|
||||
where pp_id = '$LGD_OID'
|
||||
and pp_tno = '$LGD_TID' ";
|
||||
$result = sql_query($sql, false);
|
||||
|
||||
if($row['od_id']) {
|
||||
// 주문서 UPDATE
|
||||
$receipt_time = preg_replace("/([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})/", "\\1-\\2-\\3 \\4:\\5:\\6", $LGD_PAYDATE);
|
||||
$sql = " update {$g5['g5_shop_order_table']}
|
||||
set od_receipt_price = od_receipt_price + '$LGD_AMOUNT',
|
||||
od_receipt_time = '$LGD_PAYDATE',
|
||||
od_casseqno = '$LGD_CASSEQNO',
|
||||
od_shop_memo = concat(od_shop_memo, \"\\n개인결제 ".$row['pp_id']." 로 결제완료 - ".$receipt_time."\")
|
||||
where od_id = '{$row['od_id']}' ";
|
||||
$result = sql_query($sql, FALSE);
|
||||
}
|
||||
} else {
|
||||
// 주문서 UPDATE
|
||||
$sql = " update {$g5['g5_shop_order_table']}
|
||||
set od_receipt_price = '$LGD_AMOUNT',
|
||||
od_receipt_time = '$LGD_PAYDATE',
|
||||
od_casseqno = '$LGD_CASSEQNO'
|
||||
where od_id = '$LGD_OID'
|
||||
and od_tno = '$LGD_TID' ";
|
||||
$result = sql_query($sql, FALSE);
|
||||
}
|
||||
|
||||
if($result) {
|
||||
if(isset($row['od_id']) && $row['od_id'])
|
||||
$od_id = $row['od_id'];
|
||||
else
|
||||
$od_id = $LGD_OID;
|
||||
|
||||
// 주문정보 체크
|
||||
$sql = " select count(od_id) as cnt
|
||||
from {$g5['g5_shop_order_table']}
|
||||
where od_id = '$od_id'
|
||||
and od_status = '주문' ";
|
||||
$row = sql_fetch($sql);
|
||||
|
||||
if($row['cnt'] == 1) {
|
||||
// 미수금 정보 업데이트
|
||||
$info = get_order_info($od_id);
|
||||
|
||||
$sql = " update {$g5['g5_shop_order_table']}
|
||||
set od_misu = '{$info['od_misu']}' ";
|
||||
if($info['od_misu'] == 0)
|
||||
$sql .= " , od_status = '입금' ";
|
||||
$sql .= " where od_id = '$od_id' ";
|
||||
$result = sql_query($sql, FALSE);
|
||||
|
||||
// 장바구니 상태변경
|
||||
if($info['od_misu'] == 0) {
|
||||
$sql = " update {$g5['g5_shop_cart_table']}
|
||||
set ct_status = '입금'
|
||||
where od_id = '$od_id' ";
|
||||
sql_query($sql, FALSE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//if( 무통장 입금 성공 상점처리결과 성공 )
|
||||
if ($result)
|
||||
$resultMSG = "OK";
|
||||
else
|
||||
$resultMSG = "DB Error";
|
||||
}else if( "C" == $LGD_CASFLAG ) {
|
||||
/*
|
||||
* 무통장 입금취소 성공 결과 상점 처리(DB) 부분
|
||||
* 상점 결과 처리가 정상이면 "OK"
|
||||
*/
|
||||
//if( 무통장 입금취소 성공 상점처리결과 성공 )
|
||||
$resultMSG = "OK";
|
||||
}
|
||||
} else { //결제가 실패이면
|
||||
/*
|
||||
* 거래실패 결과 상점 처리(DB) 부분
|
||||
* 상점결과 처리가 정상이면 "OK"
|
||||
*/
|
||||
//if( 결제실패 상점처리결과 성공 )
|
||||
$resultMSG = "OK";
|
||||
}
|
||||
} else { //해쉬값이 검증이 실패이면
|
||||
/*
|
||||
* hashdata검증 실패 로그를 처리하시기 바랍니다.
|
||||
*/
|
||||
$resultMSG = "결제결과 상점 DB처리(LGD_CASNOTEURL) 해쉬값 검증이 실패하였습니다.";
|
||||
}
|
||||
|
||||
echo $resultMSG;
|
||||
$data = lg_noti_request($_POST, $_SERVER, $config);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo $data !== false && lg_noti_process($data) ? 'OK' : 'FAIL';
|
||||
|
||||
@@ -45,8 +45,8 @@ async function launchCrossPlatform(frm) {
|
||||
taxFreeAmount: parseInt(frm.taxFreeAmount.value),
|
||||
orderId: frm.orderId.value, // 고유 주문번호
|
||||
orderName: frm.orderName.value,
|
||||
successUrl: "<?php echo G5_SHOP_URL;?>/toss/returnurl.php", // 결제 요청이 성공하면 리다이렉트되는 URL
|
||||
failUrl: "<?php echo G5_SHOP_URL;?>/toss/returnurl.php?mode=fail", // 결제 요청이 실패하면 리다이렉트되는 URL
|
||||
successUrl: g5_order_state_url("<?php echo G5_SHOP_URL;?>/toss/returnurl.php", frm), // 결제 요청이 성공하면 리다이렉트되는 URL
|
||||
failUrl: g5_order_state_url("<?php echo G5_SHOP_URL;?>/toss/returnurl.php?mode=fail", frm), // 결제 요청이 실패하면 리다이렉트되는 URL
|
||||
customerEmail: frm.customerEmail.value,
|
||||
customerName: frm.customerName.value,
|
||||
customerMobilePhone: frm.customerMobilePhone.value,
|
||||
|
||||
+8
-29
@@ -1,41 +1,20 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
|
||||
// 결제 실패 처리인 경우
|
||||
if (isset($_REQUEST['mode']) && $_REQUEST['mode'] === 'fail') {
|
||||
$code = isset($_REQUEST['code']) ? trim($_REQUEST['code']) : '';
|
||||
$message = isset($_REQUEST['message']) ? trim($_REQUEST['message']) : '';
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
|
||||
alert('결제에 실패하였습니다.\\n\\n[' . $code . '] ' . $message, G5_SHOP_URL . '/orderform.php');
|
||||
exit;
|
||||
}
|
||||
if (isset($_REQUEST['mode']) && $_REQUEST['mode'] === 'fail') shop_order_state_abort_pending();
|
||||
$orderId = isset($_REQUEST['orderId']) ? $_REQUEST['orderId'] : '';
|
||||
$paymentKey = isset($_REQUEST['paymentKey']) ? $_REQUEST['paymentKey'] : '';
|
||||
$amount = isset($_REQUEST['amount']) ? $_REQUEST['amount'] : '';
|
||||
$data = shop_order_access_payment($orderId, $paymentKey, $amount);
|
||||
|
||||
$g5['title'] = '토스페이먼츠 결제인증 완료처리';
|
||||
$g5['body_script'] = ' onload="setTossResult();"';
|
||||
include_once(G5_PATH.'/head.sub.php');
|
||||
|
||||
// 토스페이먼츠 결제인증 성공시 인증키 주문 임시데이터에 업데이트
|
||||
$paymentKey = isset($_REQUEST['paymentKey']) ? trim($_REQUEST['paymentKey']) : '';
|
||||
$orderId = isset($_REQUEST['orderId']) ? trim($_REQUEST['orderId']) : '';
|
||||
$amount = isset($_REQUEST['amount']) ? trim($_REQUEST['amount']) : '';
|
||||
|
||||
if (empty($paymentKey) || empty($orderId)) {
|
||||
alert('결제정보가 올바르지 않습니다.', G5_SHOP_URL);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = " select * from {$g5['g5_shop_order_data_table']} where od_id = '$orderId' ";
|
||||
$row = sql_fetch($sql);
|
||||
|
||||
$data = isset($row['dt_data']) ? unserialize(base64_decode($row['dt_data'])) : array();
|
||||
|
||||
|
||||
// 주문 임시데이터에 paymentKey 업데이트
|
||||
$data['paymentKey'] = $paymentKey;
|
||||
$data_new = base64_encode(serialize($data));
|
||||
$sql = " update {$g5['g5_shop_order_data_table']} set dt_data = '$data_new' where od_id = '$orderId' limit 1 ";
|
||||
sql_query($sql);
|
||||
|
||||
if(isset($data['pp_id']) && $data['pp_id']) {
|
||||
$order_action_url = G5_HTTPS_SHOP_URL.'/personalpayformupdate.php';
|
||||
} else {
|
||||
|
||||
+34
-34
@@ -2,24 +2,24 @@
|
||||
class TossPayments {
|
||||
|
||||
// 클라이언트, 시크릿 키, 상점아이디
|
||||
public string $clientKey;
|
||||
public string $secretKey;
|
||||
public string $mId;
|
||||
public string $headerSecretKey = "";
|
||||
public $clientKey;
|
||||
public $secretKey;
|
||||
public $mId;
|
||||
public $headerSecretKey = "";
|
||||
|
||||
public string $paymentUrl = "https://api.tosspayments.com/v1/payments/orders/{orderId}";
|
||||
public string $acceptUrl = "https://api.tosspayments.com/v1/payments/confirm";
|
||||
public string $cancelUrl = "https://api.tosspayments.com/v1/payments/{paymentKey}/cancel";
|
||||
public string $cashReceiptsUrl = "https://api.tosspayments.com/v1/cash-receipts";
|
||||
public $paymentUrl = "https://api.tosspayments.com/v1/payments/orders/{orderId}";
|
||||
public $acceptUrl = "https://api.tosspayments.com/v1/payments/confirm";
|
||||
public $cancelUrl = "https://api.tosspayments.com/v1/payments/{paymentKey}/cancel";
|
||||
public $cashReceiptsUrl = "https://api.tosspayments.com/v1/cash-receipts";
|
||||
|
||||
// 결제데이터
|
||||
public array $headers = array();
|
||||
public array $paymentData = array();
|
||||
public array $cancelData = array();
|
||||
public array $cashReceiptsData = array();
|
||||
public array $responseData = array();
|
||||
public $headers = array();
|
||||
public $paymentData = array();
|
||||
public $cancelData = array();
|
||||
public $cashReceiptsData = array();
|
||||
public $responseData = array();
|
||||
|
||||
public array $bankCode = array(
|
||||
public $bankCode = array(
|
||||
// 은행
|
||||
'02' => '한국산업은행',
|
||||
'03' => 'IBK기업은행',
|
||||
@@ -75,7 +75,7 @@ class TossPayments {
|
||||
'ST' => '토스증권'
|
||||
);
|
||||
|
||||
public array $cardCode = array(
|
||||
public $cardCode = array(
|
||||
'3K' => '기업 BC',
|
||||
'46' => '광주은행',
|
||||
'71' => '롯데카드',
|
||||
@@ -105,7 +105,7 @@ class TossPayments {
|
||||
);
|
||||
|
||||
// 간편결제 제공업체 코드
|
||||
public array $easyPayCode = array(
|
||||
public $easyPayCode = array(
|
||||
'TOSSPAY' => '토스페이',
|
||||
'NAVERPAY' => '네이버페이',
|
||||
'SAMSUNGPAY' => '삼성페이',
|
||||
@@ -117,7 +117,7 @@ class TossPayments {
|
||||
'SSG' => 'SSG페이'
|
||||
);
|
||||
|
||||
public function __construct(string $clientKey, string $secretKey, string $mId) {
|
||||
public function __construct($clientKey, $secretKey, $mId) {
|
||||
$this->clientKey = $clientKey;
|
||||
$this->secretKey = $secretKey;
|
||||
$this->mId = $mId;
|
||||
@@ -127,7 +127,7 @@ class TossPayments {
|
||||
* 헤더 시크릿 키 설정
|
||||
* @return void
|
||||
*/
|
||||
private function setHeaderSecretKey(): void
|
||||
private function setHeaderSecretKey()
|
||||
{
|
||||
$this->headerSecretKey = base64_encode($this->secretKey . ':');
|
||||
}
|
||||
@@ -136,7 +136,7 @@ class TossPayments {
|
||||
* 헤더 설정
|
||||
* @return void
|
||||
*/
|
||||
public function setPaymentHeader(): void
|
||||
public function setPaymentHeader()
|
||||
{
|
||||
$this->setHeaderSecretKey();
|
||||
|
||||
@@ -152,7 +152,7 @@ class TossPayments {
|
||||
* @param array $request
|
||||
* @return void
|
||||
*/
|
||||
public function setPaymentData(array $request): void
|
||||
public function setPaymentData(array $request)
|
||||
{
|
||||
$this->paymentData = array(
|
||||
'amount' => $request['amount'],
|
||||
@@ -164,10 +164,10 @@ class TossPayments {
|
||||
/**
|
||||
* 주문번호로 결제정보 조회
|
||||
*
|
||||
* @param string $orderId
|
||||
* @param $orderId
|
||||
* @return bool
|
||||
*/
|
||||
public function getPaymentByOrderId(string $orderId): bool
|
||||
public function getPaymentByOrderId($orderId)
|
||||
{
|
||||
if (empty($orderId)) {
|
||||
return false;
|
||||
@@ -183,7 +183,7 @@ class TossPayments {
|
||||
$response = curl_exec($curl);
|
||||
|
||||
$return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
$this->responseData = json_decode($response, true);
|
||||
$this->responseData = (array) json_decode((string)$response, true);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
@@ -200,7 +200,7 @@ class TossPayments {
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function approvePayment(): bool {
|
||||
public function approvePayment() {
|
||||
$curl = curl_init();
|
||||
curl_setopt($curl, CURLOPT_URL, $this->acceptUrl);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, $this->headers);
|
||||
@@ -213,12 +213,12 @@ class TossPayments {
|
||||
$response = curl_exec($curl);
|
||||
|
||||
$return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
$this->responseData = json_decode($response, true);
|
||||
$this->responseData = (array) json_decode((string)$response, true);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
// 결제 실패 상황인 경우
|
||||
if ($return_status != 200 || ($this->responseData['status'] != 'DONE' && $this->responseData['status'] != 'WAITING_FOR_DEPOSIT')) {
|
||||
if ($return_status != 200 || !isset($this->responseData['status']) || ($this->responseData['status'] != 'DONE' && $this->responseData['status'] != 'WAITING_FOR_DEPOSIT')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ class TossPayments {
|
||||
* @param array $request
|
||||
* @return void
|
||||
*/
|
||||
public function setCancelData(array $request): void
|
||||
public function setCancelData(array $request)
|
||||
{
|
||||
$this->cancelData = array(
|
||||
'paymentKey' => $request['paymentKey'],
|
||||
@@ -263,7 +263,7 @@ class TossPayments {
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function cancelPayment(): bool
|
||||
public function cancelPayment()
|
||||
{
|
||||
// 취소에 필요한 결제 키가 있는지 여부
|
||||
if (empty($this->cancelData['paymentKey'])) {
|
||||
@@ -282,7 +282,7 @@ class TossPayments {
|
||||
$response = curl_exec($curl);
|
||||
|
||||
$return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
$this->responseData = json_decode($response, true);
|
||||
$this->responseData = (array) json_decode((string)$response, true);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
@@ -297,7 +297,7 @@ class TossPayments {
|
||||
/**
|
||||
* 현금영수증 발급 데이터 설정
|
||||
*/
|
||||
public function setCashReceiptsData(array $request): void
|
||||
public function setCashReceiptsData(array $request)
|
||||
{
|
||||
$this->cashReceiptsData = array(
|
||||
'amount' => $request['amount'],
|
||||
@@ -311,7 +311,7 @@ class TossPayments {
|
||||
/**
|
||||
* 현금영수증 발급
|
||||
*/
|
||||
public function issueCashReceipt(): bool
|
||||
public function issueCashReceipt()
|
||||
{
|
||||
$curl = curl_init();
|
||||
curl_setopt($curl, CURLOPT_URL, $this->cashReceiptsUrl);
|
||||
@@ -328,7 +328,7 @@ class TossPayments {
|
||||
$response = curl_exec($curl);
|
||||
|
||||
$return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
$this->responseData = json_decode($response, true);
|
||||
$this->responseData = (array) json_decode((string)$response, true);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
@@ -343,7 +343,7 @@ class TossPayments {
|
||||
/**
|
||||
* 현금영수증 발급 취소
|
||||
*/
|
||||
public function cancelCashReceipt($receiptKey): bool
|
||||
public function cancelCashReceipt($receiptKey)
|
||||
{
|
||||
$curl = curl_init();
|
||||
curl_setopt($curl, CURLOPT_URL, $this->cashReceiptsUrl."/".$receiptKey."/cancel");
|
||||
@@ -359,7 +359,7 @@ class TossPayments {
|
||||
$response = curl_exec($curl);
|
||||
|
||||
$return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
$this->responseData = json_decode($response, true);
|
||||
$this->responseData = (array) json_decode((string)$response, true);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
|
||||
@@ -1,79 +1,8 @@
|
||||
<?php
|
||||
include_once('./_common.php');
|
||||
|
||||
// 토스페이먼츠 class
|
||||
require_once(G5_SHOP_PATH.'/toss/toss.inc.php');
|
||||
|
||||
$toss = new TossPayments(
|
||||
$config['cf_toss_client_key'],
|
||||
$config['cf_toss_secret_key'],
|
||||
$config['cf_lg_mid']
|
||||
);
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
|
||||
$orderId = isset($_REQUEST['orderId']) ? $_REQUEST['orderId'] : '';
|
||||
$amount = isset($_REQUEST['amount']) ? $_REQUEST['amount'] : '';
|
||||
$paymentKey = isset($_REQUEST['paymentKey']) ? $_REQUEST['paymentKey'] : '';
|
||||
|
||||
$toss->setPaymentData(array(
|
||||
'orderId' => $orderId,
|
||||
'amount' => $amount,
|
||||
'paymentKey' => $paymentKey,
|
||||
));
|
||||
|
||||
// 장바구니 ID 설정 (바로구매 여부 확인)
|
||||
$ss_cart_id = get_session('ss_direct') ? get_session('ss_cart_direct') : get_session('ss_cart_id');
|
||||
|
||||
// 임시데이터에 결제 데이터 저장
|
||||
$addQuery = "";
|
||||
if (isset($orderId)) {
|
||||
$addQuery .= " AND od_id = '$orderId'";
|
||||
}
|
||||
if (isset($ss_cart_id)) {
|
||||
$addQuery .= " AND cart_id = '$ss_cart_id'";
|
||||
}
|
||||
if (isset($member['mb_id'])) {
|
||||
$addQuery .= " AND mb_id = '{$member['mb_id']}'";
|
||||
}
|
||||
|
||||
if (empty($orderId) && empty($ss_cart_id)) {
|
||||
alert('주문정보가 올바르지 않습니다.');
|
||||
exit;
|
||||
}
|
||||
|
||||
// 기존 dt_data 가져오기
|
||||
$sql = "
|
||||
SELECT * FROM {$g5['g5_shop_order_data_table']}
|
||||
WHERE 1=1
|
||||
{$addQuery}
|
||||
LIMIT 1
|
||||
";
|
||||
$res = sql_fetch($sql);
|
||||
$dt_data = array();
|
||||
if (isset($res['dt_data'])) {
|
||||
$dt_data = unserialize(base64_decode($res['dt_data']));
|
||||
}
|
||||
|
||||
// dt_data 에 결제 키 추가
|
||||
if (isset($paymentKey)) {
|
||||
$dt_data['paymentKey'] = $paymentKey;
|
||||
$dt_data_new = base64_encode(serialize($dt_data));
|
||||
|
||||
// 업데이트
|
||||
$sql = "
|
||||
UPDATE {$g5['g5_shop_order_data_table']} SET
|
||||
dt_data = '".$dt_data_new."'
|
||||
WHERE od_id = '$orderId'
|
||||
{$addQuery}
|
||||
";
|
||||
sql_query($sql);
|
||||
}
|
||||
|
||||
if(isset($payReqMap['pp_id']) && $payReqMap['pp_id']) {
|
||||
$page_return_url = G5_SHOP_URL.'/personalpayform.php?pp_id='.$payReqMap['pp_id'];
|
||||
} else {
|
||||
$page_return_url = G5_SHOP_URL.'/orderform.php';
|
||||
if ($_SESSION['ss_direct']) {
|
||||
$page_return_url .= '?sw_direct=1';
|
||||
}
|
||||
}
|
||||
?>
|
||||
$amount = isset($_REQUEST['amount']) ? $_REQUEST['amount'] : '';
|
||||
shop_order_access_payment($orderId, $paymentKey, $amount);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
|
||||
if (function_exists('shop_order_state_cancel')) shop_order_state_cancel('cancel_pending');
|
||||
|
||||
// 토스 공통 설정
|
||||
require_once(G5_SHOP_PATH.'/toss/toss.inc.php');
|
||||
|
||||
@@ -16,11 +18,23 @@ $toss = new TossPayments(
|
||||
|
||||
$toss->setPaymentHeader();
|
||||
|
||||
$od_id = isset($od['od_id']) ? $od['od_id'] : (isset($pp['pp_id']) ? $pp['pp_id'] : '');
|
||||
$cancel_runtime = function_exists('shop_order_runtime') ? shop_order_runtime() : array('active'=>'');
|
||||
$cancel_order_id = $cancel_runtime['active'] !== '' ? $cancel_runtime['active'] :
|
||||
(isset($od['od_id']) ? (string)$od['od_id'] : (isset($pp['pp_id']) ? (string)$pp['pp_id'] : (string)get_session('ss_order_id')));
|
||||
|
||||
if (!$toss->getPaymentByOrderId($od_id)) {
|
||||
if (!$toss->getPaymentByOrderId($cancel_order_id)) {
|
||||
alert('결제정보를 가져올 수 없습니다.');
|
||||
}
|
||||
if ($cancel_runtime['active'] !== '') {
|
||||
$cancel_state = shop_order_state_row($cancel_order_id);
|
||||
if (!isset($toss->responseData['orderId'],$toss->responseData['paymentKey']) ||
|
||||
$toss->responseData['orderId'] !== $cancel_order_id || $toss->responseData['paymentKey'] !== $cancel_state['payment_key']) shop_order_access_fail();
|
||||
}
|
||||
if (isset($toss->responseData['status']) && $toss->responseData['status'] === 'CANCELED') {
|
||||
if (function_exists('shop_order_state_cancel')) shop_order_state_cancel('cancelled');
|
||||
return;
|
||||
}
|
||||
$toss->headers[] = 'Idempotency-Key: g5-cancel-'.hash('sha256', $cancel_order_id.$toss->responseData['paymentKey']);
|
||||
|
||||
$toss->setCancelData(array(
|
||||
'paymentKey' => $toss->responseData['paymentKey'],
|
||||
@@ -35,4 +49,5 @@ if (!$toss->cancelPayment()) {
|
||||
$msg .= '코드 : ' . $toss->responseData['code'];
|
||||
}
|
||||
alert($msg);
|
||||
}
|
||||
}
|
||||
if (function_exists('shop_order_state_cancel')) shop_order_state_cancel('cancelled');
|
||||
|
||||
+11
-14
@@ -7,20 +7,14 @@ require_once(G5_SHOP_PATH.'/toss/toss.inc.php');
|
||||
$orderId = isset($_REQUEST['orderId']) ? $_REQUEST['orderId'] : '';
|
||||
$paymentKey = isset($_POST['paymentKey']) ? $_POST['paymentKey'] : '';
|
||||
|
||||
if (empty($orderId) || empty($paymentKey)) {
|
||||
alert('주문정보가 올바르지 않습니다.', G5_SHOP_URL);
|
||||
}
|
||||
|
||||
$sql = " select * from {$g5['g5_shop_order_data_table']} where od_id = '$orderId' limit 1 ";
|
||||
$row = sql_fetch($sql);
|
||||
|
||||
$data = isset($row['dt_data']) ? unserialize(base64_decode($row['dt_data'])) : array();
|
||||
|
||||
include_once(G5_LIB_PATH.'/shop_order_access.lib.php');
|
||||
$data = shop_order_access_payment($orderId, $paymentKey);
|
||||
$is_personal = !empty($data['pp_id']);
|
||||
if ($is_personal !== !empty($_POST['pp_id']) ||
|
||||
($is_personal && (string)$_POST['pp_id'] !== $orderId)) shop_order_access_fail();
|
||||
$amount = isset($data['amountValue']) ? (int)$data['amountValue'] : 0;
|
||||
|
||||
if ($amount <= 0 || $amount !== (int)$order_price) {
|
||||
alert('결제금액이 올바르지 않습니다.', G5_SHOP_URL);
|
||||
}
|
||||
$expected_amount = $is_personal ? (int)$pp['pp_price'] : (int)$order_price;
|
||||
if ($amount <= 0 || $amount !== $expected_amount) shop_order_access_fail();
|
||||
|
||||
$toss = new TossPayments(
|
||||
$config['cf_toss_client_key'],
|
||||
@@ -37,9 +31,12 @@ $toss->setPaymentData(array(
|
||||
$toss->setPaymentHeader();
|
||||
|
||||
// 결제승인 요청
|
||||
$result = $toss->approvePayment();
|
||||
$result = shop_order_toss_approve($toss, $orderId, $paymentKey, $expected_amount);
|
||||
|
||||
if ($result) {
|
||||
if (!isset($toss->responseData['orderId'], $toss->responseData['paymentKey'], $toss->responseData['totalAmount']) ||
|
||||
$toss->responseData['orderId'] !== $orderId || $toss->responseData['paymentKey'] !== $paymentKey ||
|
||||
(int)$toss->responseData['totalAmount'] !== $expected_amount) shop_order_access_fail();
|
||||
// 결제승인 성공시 처리
|
||||
$status = isset($toss->responseData['status']) ? $toss->responseData['status'] : '';
|
||||
$method = isset($toss->responseData['method']) ? $toss->responseData['method'] : '';
|
||||
|
||||
@@ -1,44 +1,25 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
$str = '';
|
||||
$exists = false;
|
||||
|
||||
$ca_id_len = strlen($ca_id);
|
||||
$len2 = $ca_id_len + 2;
|
||||
|
||||
$sql = " select ca_id, ca_name from {$g5['g5_shop_category_table']} where ca_id like '$ca_id%' and length(ca_id) = $len2 and ca_use = '1' order by ca_order, ca_id ";
|
||||
$result = sql_query($sql);
|
||||
|
||||
// 하위 분류가 없으면 현재 분류를 포함한 같은 단계의 사용 중인 분류를 표시한다.
|
||||
if (!sql_num_rows($result)) {
|
||||
$parent_ca_id = substr($ca_id, 0, -2);
|
||||
$sql = " select ca_id, ca_name from {$g5['g5_shop_category_table']} where ca_id like '$parent_ca_id%' and length(ca_id) = $ca_id_len and ca_use = '1' order by ca_order, ca_id ";
|
||||
$result = sql_query($sql);
|
||||
}
|
||||
|
||||
while ($row=sql_fetch_array($result)) {
|
||||
|
||||
$row2 = sql_fetch(" select count(*) as cnt from {$g5['g5_shop_item_table']} where (ca_id like '{$row['ca_id']}%' or ca_id2 like '{$row['ca_id']}%' or ca_id3 like '{$row['ca_id']}%') and it_use = '1' ");
|
||||
|
||||
$current_attr = ($row['ca_id'] === $ca_id) ? ' class="sct_ct_here" aria-current="page"' : '';
|
||||
$str .= '<li><a'.$current_attr.' href="'.shop_category_url($row['ca_id']).'">'.$row['ca_name'].' ('.$row2['cnt'].')</a></li>';
|
||||
$exists = true;
|
||||
}
|
||||
|
||||
if ($exists) {
|
||||
|
||||
// add_stylesheet('css 구문', 출력순서); 숫자가 작을 수록 먼저 출력됨
|
||||
$category_groups = get_shop_category_menu_groups($ca_id);
|
||||
if ($category_groups) {
|
||||
add_stylesheet('<link rel="stylesheet" href="'.G5_SHOP_SKIN_URL.'/style.css">', 0);
|
||||
?>
|
||||
|
||||
<!-- 상품분류 1 시작 { -->
|
||||
<aside id="sct_ct_1" class="sct_ct">
|
||||
<h2>현재 상품 분류와 관련된 분류</h2>
|
||||
<ul>
|
||||
<?php echo $str; ?>
|
||||
<?php foreach ($category_groups as $category_group) { ?>
|
||||
<ul aria-label="<?php echo $category_group['label']; ?>">
|
||||
<?php foreach ($category_group['categories'] as $row) {
|
||||
$row2 = sql_fetch(" select count(*) as cnt from {$g5['g5_shop_item_table']} where (ca_id like '{$row['ca_id']}%' or ca_id2 like '{$row['ca_id']}%' or ca_id3 like '{$row['ca_id']}%') and it_use = '1' ");
|
||||
$current_attr = '';
|
||||
if ($row['ca_id'] === $ca_id) {
|
||||
$current_attr = ' class="sct_ct_here" aria-current="page"';
|
||||
} elseif (strpos($ca_id, $row['ca_id']) === 0) {
|
||||
$current_attr = ' class="sct_ct_here sct_ct_parent"';
|
||||
}
|
||||
echo '<li><a'.$current_attr.' href="'.shop_category_url($row['ca_id']).'">'.get_text($row['ca_name']).' ('.$row2['cnt'].')</a></li>';
|
||||
} ?>
|
||||
</ul>
|
||||
<?php } ?>
|
||||
</aside>
|
||||
<!-- } 상품분류 1 끝 -->
|
||||
|
||||
<?php }
|
||||
<?php }
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
|
||||
$navi_datas = $ca_ids = array();
|
||||
$is_item_view = (isset($it_id) && isset($it) && isset($it['it_id']) && $it_id === $it['it_id']) ? true : false;
|
||||
|
||||
if( !$is_item_view && $ca_id ){
|
||||
$navi_datas = get_shop_navigation_data(true, $ca_id);
|
||||
$ca_ids = array(
|
||||
'ca_id' => substr($ca_id,0,2),
|
||||
'ca_id2' => substr($ca_id,0,4),
|
||||
'ca_id3' => substr($ca_id,0,6),
|
||||
);
|
||||
} else if( $is_item_view && isset($it) && is_array($it) ) {
|
||||
$navi_datas = get_shop_navigation_data(true, $it['ca_id']);
|
||||
$ca_ids = array(
|
||||
'ca_id' => substr($it['ca_id'],0,2),
|
||||
'ca_id2' => substr($it['ca_id'],0,4),
|
||||
'ca_id3' => substr($it['ca_id'],0,6)
|
||||
);
|
||||
}
|
||||
$is_item_view = (isset($it_id, $it['it_id']) && $it_id === $it['it_id']);
|
||||
$navigation_ca_id = $is_item_view ? $it['ca_id'] : $ca_id;
|
||||
$navi_datas = $navigation_ca_id ? get_shop_navigation_data(true, $navigation_ca_id) : array();
|
||||
|
||||
$location_class = array();
|
||||
if($is_item_view){
|
||||
@@ -27,7 +12,7 @@ if($is_item_view){
|
||||
$location_class[] = 'is_list is_right'; // view_location는 리스트 말고 상품보기에서만 표시
|
||||
}
|
||||
|
||||
// add_stylesheet('css 구문', 출력순서); 숫자가 작을 수록 먼저 출력됨
|
||||
// add_stylesheet('css 구문', 출력순서);
|
||||
add_stylesheet('<link rel="stylesheet" href="'.G5_SHOP_CSS_URL.'/style.css">', 0);
|
||||
add_javascript('<script src="'.G5_JS_URL.'/shop.category.navigation.js"></script>', 10);
|
||||
?>
|
||||
@@ -37,26 +22,14 @@ add_javascript('<script src="'.G5_JS_URL.'/shop.category.navigation.js"></script
|
||||
<i class="dividing-line fa fa-angle-right" aria-hidden="true"></i>
|
||||
<?php if ( is_array($navi_datas) && $navi_datas ){ ?>
|
||||
|
||||
<?php if( isset($navi_datas[0]) && $navi_datas[0] ){ ?>
|
||||
<select class="shop_hover_selectbox category1">
|
||||
<?php foreach((array) $navi_datas[0] as $data ){ ?>
|
||||
<option value="<?php echo $data['ca_id']; ?>" data-url="<?php echo $data['url']; ?>" <?php if($ca_ids['ca_id'] === $data['ca_id']) echo 'selected'; ?>><?php echo $data['ca_name']; ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
<?php } ?>
|
||||
<?php if( isset($navi_datas[1]) && $navi_datas[1] ){ ?>
|
||||
<?php foreach ($navi_datas as $depth => $categories) {
|
||||
$selected_ca_id = substr($navigation_ca_id, 0, ($depth + 1) * 2);
|
||||
if ($depth > 0) { ?>
|
||||
<i class="dividing-line fa fa-angle-right" aria-hidden="true"></i>
|
||||
<select class="shop_hover_selectbox category2">
|
||||
<?php foreach((array) $navi_datas[1] as $data ){ ?>
|
||||
<option value="<?php echo $data['ca_id']; ?>" data-url="<?php echo $data['url']; ?>" <?php if($ca_ids['ca_id2'] === $data['ca_id']) echo 'selected'; ?>><?php echo $data['ca_name']; ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
<?php } ?>
|
||||
<?php if( isset($navi_datas[2]) && $navi_datas[2] ){ ?>
|
||||
<i class="dividing-line fa fa-angle-right" aria-hidden="true"></i>
|
||||
<select class="shop_hover_selectbox category3">
|
||||
<?php foreach((array) $navi_datas[2] as $data ){ ?>
|
||||
<option value="<?php echo $data['ca_id']; ?>" data-url="<?php echo $data['url']; ?>" <?php if($ca_ids['ca_id3'] === $data['ca_id']) echo 'selected'; ?>><?php echo $data['ca_name']; ?></option>
|
||||
<select class="shop_hover_selectbox category<?php echo $depth + 1; ?>" aria-label="<?php echo $depth + 1; ?>단계 분류">
|
||||
<?php foreach ($categories as $data) { ?>
|
||||
<option value="<?php echo $data['ca_id']; ?>" data-url="<?php echo $data['url']; ?>"<?php if ($selected_ca_id === $data['ca_id']) echo ' selected'; ?>><?php echo get_text($data['ca_name']); ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
<?php } ?>
|
||||
|
||||
@@ -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, 동시성, 쿠폰·포인트 등 전체 정산은 이 검증에 포함되지 않는다.
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""격리 MySQL에서 KCP 실제 콜백과 MyISAM/InnoDB 재통보를 검증한다.
|
||||
실행: python3 tests/kcp_notification_http.py 33443
|
||||
localhost의 지정 포트에 root/빈 비밀번호로 접속하여 임시 DB만 만들고 삭제한다.
|
||||
공통 초기화·발신 IP·SQL 실패만 대역이며 SQL 저장과 실제 콜백은 그대로 실행한다.
|
||||
"""
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PORT = int(sys.argv[1])
|
||||
DB = 'kcp43_test_' + str(time.time_ns())
|
||||
checks = 0
|
||||
|
||||
def mysql(sql, database=True):
|
||||
command = ['mysql', '--no-defaults', '-h127.0.0.1', '-P' + str(PORT), '-uroot', '-N', '-B']
|
||||
if database:
|
||||
command.append(DB)
|
||||
return subprocess.check_output(command, input="SET sql_mode='NO_ENGINE_SUBSTITUTION';" + sql, text=True).strip()
|
||||
|
||||
def check(value, message):
|
||||
global checks
|
||||
if not value:
|
||||
raise AssertionError(message)
|
||||
checks += 1
|
||||
|
||||
mysql('CREATE DATABASE `' + DB + '`', False)
|
||||
server = None
|
||||
try:
|
||||
schema = (ROOT / 'install/gnuboard5shop.sql').read_text()
|
||||
for table in ['order', 'personalpay', 'cart', 'default', 'kcp_noti']:
|
||||
statement = re.search(r'CREATE TABLE(?: IF NOT EXISTS)? `g5_shop_' + table + r'` \(.*?;', schema, re.S).group()
|
||||
mysql(statement)
|
||||
with tempfile.TemporaryDirectory(prefix='kcp43-http-') as temp:
|
||||
directory = Path(temp)
|
||||
fixture = directory / 'fixture.json'
|
||||
bootstrap = r'''<?php
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
define('_GNUBOARD_', true);
|
||||
define('G5_SHOP_TABLE_PREFIX', 'g5_shop_');
|
||||
define('G5_TABLE_PREFIX', 'g5_');
|
||||
define('G5_LIB_PATH', ROOT_PATH.'/lib');
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
$db = new mysqli('127.0.0.1', 'root', '', TEST_DB, TEST_PORT);
|
||||
$db->set_charset('utf8mb4');
|
||||
$db->query("SET sql_mode='NO_ENGINE_SUBSTITUTION'");
|
||||
$fixture = json_decode(file_get_contents(__DIR__.'/fixture.json'), true);
|
||||
$default = array('de_card_test' => $fixture['test'], 'de_pg_service' => 'kcp');
|
||||
if (isset($fixture['ip'])) $_SERVER['REMOTE_ADDR'] = $fixture['ip'];
|
||||
$g5 = array('g5_shop_order_table' => 'g5_shop_order', 'g5_shop_personalpay_table' => 'g5_shop_personalpay',
|
||||
'g5_shop_cart_table' => 'g5_shop_cart', 'g5_shop_default_table' => 'g5_shop_default');
|
||||
$writes = 0;
|
||||
function sql_query($sql, $error = false) {
|
||||
global $db, $writes, $fixture;
|
||||
$write = preg_match('/^(UPDATE|INSERT) /', $sql);
|
||||
if ($write) $writes++;
|
||||
if ($write && isset($fixture['fail_before']) && $writes === $fixture['fail_before']) return false;
|
||||
$result = $db->query($sql);
|
||||
if ($write && isset($fixture['fail_after']) && $writes === $fixture['fail_after']) return false;
|
||||
return $result;
|
||||
}
|
||||
function sql_fetch_array($result) { return $result->fetch_assoc(); }
|
||||
function sql_num_rows($result) { return $result->num_rows; }
|
||||
function sql_escape_string($value) { global $db; return $db->real_escape_string($value); }
|
||||
function sql_real_escape_string($value) { return sql_escape_string($value); }
|
||||
function sql_error_info() { global $db; return $db->error; }
|
||||
'''.replace('ROOT_PATH', repr(str(ROOT))).replace('TEST_DB', repr(DB)).replace('TEST_PORT', str(PORT))
|
||||
(directory / '_common.php').write_text(bootstrap)
|
||||
(directory / 'router.php').write_text("<?php chdir(__DIR__); require " + repr(str(ROOT / 'shop/settle_kcp_common.php')) + ';')
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
http_port = sock.getsockname()[1]
|
||||
env = dict(os.environ, PHP_CLI_SERVER_WORKERS='4')
|
||||
log = open(directory / 'php.log', 'w+')
|
||||
server = subprocess.Popen(['php', '-S', '127.0.0.1:' + str(http_port), str(directory / 'router.php')], env=env,
|
||||
stdout=log, stderr=log, start_new_session=True)
|
||||
for _ in range(50):
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', http_port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.1)
|
||||
|
||||
def config(**changes):
|
||||
fixture.write_text(json.dumps(dict(test=1, ip='210.122.176.144', **changes)))
|
||||
|
||||
def request(data=None, method='POST', headers=None):
|
||||
payload = dict(site_cd='T0000', tno='20260910123456', order_no='1001', tx_cd='TX00',
|
||||
tx_tm='20260910120000', ipgm_mnyx='10000', account='T123456789',
|
||||
noti_id='20260910123456789012', op_cd='50')
|
||||
if data:
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
payload.pop(key, None)
|
||||
else:
|
||||
payload[key] = value
|
||||
req = urllib.request.Request('http://127.0.0.1:' + str(http_port),
|
||||
data=urllib.parse.urlencode(payload).encode() if method == 'POST' else None,
|
||||
method=method, headers=headers or {})
|
||||
return 'value="0000"' in urllib.request.urlopen(req, timeout=30).read().decode()
|
||||
|
||||
def seed(personal=False, linked=True, mobile=0, site='T0000'):
|
||||
mysql('TRUNCATE g5_shop_kcp_noti; TRUNCATE g5_shop_personalpay; TRUNCATE g5_shop_cart; TRUNCATE g5_shop_order;')
|
||||
if linked or not personal:
|
||||
mysql("INSERT INTO g5_shop_order (od_id,od_pg,od_tno,od_kcp_site_cd,od_settle_case,od_test,od_mobile,od_cart_price,od_misu,od_status,od_bank_account) "
|
||||
"VALUES (1001,'kcp','20260910123456','" + site + "','가상계좌'," + ('1' if site.startswith('T') else '0') + "," + str(mobile) + ",10000,10000,'주문','은행 T123456789');"
|
||||
"INSERT INTO g5_shop_cart (ct_id,od_id,ct_status) VALUES (1,1001,'주문'),(2,1001,'주문');")
|
||||
if personal:
|
||||
mysql("INSERT INTO g5_shop_personalpay (pp_id,od_id,pp_use,pp_pg,pp_tno,pp_kcp_site_cd,pp_settle_case,pp_price,pp_bank_account) "
|
||||
"VALUES (2001," + ('1001' if linked else '0') + ",1,'kcp','20260910123456','" + site + "','가상계좌',4000,'은행 T123456789');")
|
||||
|
||||
def state():
|
||||
return mysql('SELECT od_receipt_price,od_misu,od_status FROM g5_shop_order; SELECT pp_receipt_price FROM g5_shop_personalpay; SELECT ct_status FROM g5_shop_cart ORDER BY ct_id;')
|
||||
|
||||
def pp_request(extra=None):
|
||||
return request(dict(order_no='2001', ipgm_mnyx='4000', **(extra or {})))
|
||||
|
||||
# 실제 마이그레이션 파서/실행기로 기존 스키마와 재실행을 검증한다.
|
||||
mysql('ALTER TABLE g5_shop_order DROP od_kcp_site_cd; ALTER TABLE g5_shop_personalpay DROP pp_kcp_site_cd; DROP TABLE g5_shop_kcp_noti;')
|
||||
config()
|
||||
migration_php = "<?php require __DIR__.'/_common.php'; require G5_LIB_PATH.'/migration.lib.php'; $m=g5_migration_parse_file(" + repr(str(ROOT / 'migrations/20260910_001_kcp_notification.sql')) + "); if(isset($m['error'])) exit(1); foreach($m['statements'] as $s) { if(!g5_migration_should_run_statement($s['conditions'])) continue; $r=g5_migration_execute_statement($s); if($r['error']) {fwrite(STDERR,$r['error']); exit(1);} }"
|
||||
(directory / 'migration.php').write_text(migration_php)
|
||||
subprocess.check_call(['php', str(directory / 'migration.php')])
|
||||
subprocess.check_call(['php', str(directory / 'migration.php')])
|
||||
check(mysql("SHOW COLUMNS FROM g5_shop_order LIKE 'od_kcp_site_cd'") != '', '마이그레이션 실행')
|
||||
for engine in ['MyISAM', 'InnoDB']:
|
||||
for table in ['order', 'personalpay', 'cart', 'kcp_noti']:
|
||||
mysql('ALTER TABLE g5_shop_' + table + ' ENGINE=' + engine)
|
||||
seed()
|
||||
before = state()
|
||||
for test in [0, 1]:
|
||||
fixture.write_text(json.dumps(dict(test=test, ip='192.0.2.43')))
|
||||
check(not request(headers={'X-Forwarded-For': '210.122.176.144'}), '비허용 IP/전달 헤더 거부')
|
||||
config()
|
||||
check(not request(method='GET'), 'GET 거부')
|
||||
for key in ['site_cd', 'tno', 'order_no', 'tx_cd', 'tx_tm', 'ipgm_mnyx', 'account', 'noti_id', 'op_cd']:
|
||||
for value in ['', None, 'x\n']:
|
||||
check(not request({key: value}), key + ' 형식 거부')
|
||||
check(not request({key: None, key + '[]': '1'}), key + ' 배열 거부')
|
||||
for amount in ['0', '-1', '1e4', '10000.0', '2147483648', '999999999999', '9999']:
|
||||
check(not request({'ipgm_mnyx': amount}), '잘못된 금액 거부')
|
||||
for data in [{'site_cd':'T0007'}, {'tno':'20260910123457'}, {'order_no':'9999'},
|
||||
{'account':'T999'}, {'tx_tm':'20260229120000'}, {'tx_tm':'20260910240000'}, {'op_cd':'51'}]:
|
||||
check(not request(data), '거래/날짜 불일치 거부')
|
||||
check(state() == before, '거부 요청 DB 불변')
|
||||
for field, value in [('od_pg','lg'),('od_settle_case','무통장'),('od_status','완료'),('od_status','취소'),('od_test','0'),('od_kcp_site_cd','')]:
|
||||
seed()
|
||||
mysql("UPDATE g5_shop_order SET " + field + "='" + value + "'")
|
||||
before = state()
|
||||
check(not request() and state() == before, field + ' 바인딩/상태 거부')
|
||||
for mobile in [0, 1]:
|
||||
seed(mobile=mobile)
|
||||
check(request(), 'PC/모바일 정상 입금')
|
||||
after = state()
|
||||
check(after == '10000\t0\t입금\n입금\n입금', '입금 금액/미수금/장바구니')
|
||||
check(request() and state() == after, '재전송 멱등성')
|
||||
mysql("UPDATE g5_shop_order SET od_status='완료'")
|
||||
check(request(), '완료 후 동일 재전송 성공')
|
||||
seed()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
check(all(pool.map(lambda _: request(), range(8))), '동시 입금 통보')
|
||||
check(mysql('SELECT od_receipt_price FROM g5_shop_order') == '10000', '동시 입금 한 번 반영')
|
||||
check(request({'op_cd':'13'}), '같은 noti_id 망취소')
|
||||
after = state()
|
||||
check(after == '0\t10000\t주문\n주문\n주문', '망취소 금액/상태 복원')
|
||||
check(request({'op_cd':'13'}) and request() and state() == after, '망취소/입금 역순 재전송')
|
||||
check(request({'noti_id':'20260910123456789013'}), '망취소 후 새 입금')
|
||||
seed()
|
||||
check(request({'op_cd':'13'}) and request(), '망취소 선도착 처리')
|
||||
check(mysql('SELECT od_receipt_price FROM g5_shop_order') == '0', '선도착 망취소 재입금 방지')
|
||||
seed(personal=True)
|
||||
for field, value in [('pp_pg','lg'),('pp_settle_case','무통장'),('pp_use','0'),('pp_kcp_site_cd',''),('od_id','9999')]:
|
||||
seed(personal=True)
|
||||
mysql("UPDATE g5_shop_personalpay SET " + field + "='" + value + "'")
|
||||
before = state()
|
||||
check(not pp_request() and state() == before, '개인결제 ' + field + ' 거부')
|
||||
seed(personal=True)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
check(all(pool.map(lambda _: pp_request(), range(8))), '개인결제 동시 통보')
|
||||
check(state() == '4000\t6000\t주문\n4000\n주문\n주문', '개인결제 청구액만 가산')
|
||||
check(not pp_request({'noti_id':'20260910123456789013'}), '다른 통보 ID 중복 입금 거부')
|
||||
check(pp_request({'op_cd':'13'}), '개인결제 망취소')
|
||||
check(state() == '0\t10000\t주문\n0\n주문\n주문', '개인결제 망취소 차감')
|
||||
seed(personal=True, linked=False)
|
||||
check(pp_request() and pp_request() and pp_request({'op_cd':'13'}), '독립 개인결제 입금/재전송/취소')
|
||||
# 각 쓰기 직전 실패 및 DB 성공 후 응답 유실을 재현한다.
|
||||
for personal in [False, True]:
|
||||
for mode in ['fail_before', 'fail_after']:
|
||||
for write_no in range(1, 7 if personal else 6):
|
||||
seed(personal=personal)
|
||||
if personal:
|
||||
mysql('UPDATE g5_shop_personalpay SET pp_price=10000')
|
||||
config(**{mode:write_no})
|
||||
check(not request({'order_no':'2001'} if personal else {}), '중간 실패 시 실패 응답')
|
||||
config()
|
||||
check(request({'order_no':'2001'} if personal else {}), '재통보로 중간 실패 복구')
|
||||
check(mysql('SELECT od_receipt_price FROM g5_shop_order') == '10000', '복구 중복 가산 방지')
|
||||
# 망취소의 각 쓰기 단계도 실패 후 같은 이벤트로 복구한다.
|
||||
for personal in [False, True]:
|
||||
for write_no in range(1, 7 if personal else 6):
|
||||
seed(personal=personal)
|
||||
if personal:
|
||||
mysql('UPDATE g5_shop_personalpay SET pp_price=10000')
|
||||
config()
|
||||
payload = {'order_no':'2001'} if personal else {}
|
||||
check(request(payload), '망취소 실패 검증용 입금')
|
||||
payload['op_cd'] = '13'
|
||||
config(fail_after=write_no)
|
||||
check(not request(payload), '망취소 중간 실패 응답')
|
||||
config()
|
||||
check(request(payload), '망취소 재통보 복구')
|
||||
check(mysql('SELECT od_receipt_price FROM g5_shop_order') == '0', '망취소 중복 차감 방지')
|
||||
seed()
|
||||
config()
|
||||
mysql("CREATE TRIGGER kcp43_fail BEFORE UPDATE ON g5_shop_order FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test failure'")
|
||||
check(not request(), '실제 DB 오류 실패 응답')
|
||||
mysql('DROP TRIGGER kcp43_fail')
|
||||
check(request(), '실제 DB 오류 후 복구')
|
||||
seed(personal=True)
|
||||
mysql('UPDATE g5_shop_order SET od_test=0')
|
||||
check(not pp_request(), '테스트 개인결제/운영 연결 주문 거부')
|
||||
seed()
|
||||
before = state()
|
||||
mysql('RENAME TABLE g5_shop_kcp_noti TO kcp43_missing')
|
||||
check(not request() and state() == before, '미적용 스키마 실패 시 DB 불변')
|
||||
mysql('RENAME TABLE kcp43_missing TO g5_shop_kcp_noti')
|
||||
seed()
|
||||
check(request({'tx_cd':'TX02'}), '바인딩된 에스크로 통보 수신')
|
||||
check(state() == '0\t10000\t주문\n주문\n주문', '에스크로 수신 금액 불변')
|
||||
seed(personal=True)
|
||||
config(fail_before=3) # 개인결제 금액 반영 뒤 연결 주문 변경 전
|
||||
check(not pp_request(), '부분 처리 실패')
|
||||
mysql('UPDATE g5_shop_order SET od_receipt_price=1')
|
||||
before = state()
|
||||
config()
|
||||
check(not pp_request() and state() == before, '실패 후 외부 변경 충돌 보존')
|
||||
# 운영/에스크로 상점코드와 발신지 분리
|
||||
for site, ip, test in [('SR123','103.215.144.173',0),('SR123','103.215.144.174',0),('SR123','210.122.72.173',0),('T0007','210.122.176.144',1)]:
|
||||
seed(site=site)
|
||||
fixture.write_text(json.dumps(dict(test=test,ip=ip)))
|
||||
check(request({'site_cd':site}), '환경별 정상 상점/발신지')
|
||||
config()
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
output = log.read()
|
||||
check('PHP Warning' not in output and 'PHP Fatal' not in output, 'PHP 경고/치명적 오류 없음')
|
||||
print('PASS:', checks, 'checks (HTTP, MySQL 8, MyISAM/InnoDB)')
|
||||
finally:
|
||||
if server:
|
||||
os.killpg(server.pid, signal.SIGTERM)
|
||||
server.wait(timeout=10)
|
||||
mysql('DROP DATABASE `' + DB + '`', False)
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
// 실행: php tests/migration_line_endings.php (DB 연결 불필요)
|
||||
define('_GNUBOARD_', true);
|
||||
define('G5_TABLE_PREFIX', 'g5_');
|
||||
$g5 = array('config_table' => '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);
|
||||
}
|
||||
@@ -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 검증은 테스트 상점 정보와 테스트 결제 절차가 필요하다.
|
||||
|
||||
이번 검증에서는 저장소 코드를 변경하지 않았다. 테스트 서버는 검증 후 종료하고 시험 결과와 로컬 테스트 자료만 보존한다.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('<?php', encoding='utf-8')
|
||||
(OUT/'settle_inicis.inc.php').write_text('<?php', encoding='utf-8')
|
||||
(OUT/'inicis/libs').mkdir(parents=True)
|
||||
(OUT/'inicis/cart_validation_cancel.php').write_bytes((ROOT/'mobile/shop/inicis/cart_validation_cancel.php').read_bytes())
|
||||
(OUT/'inicis/libs/inicis_youngcart_fn.php').write_text('''<?php
|
||||
function get_type_inicis_paymethod($method) { return 'Card'; }
|
||||
function inicis_tid_cancel($args) {
|
||||
$GLOBALS['canceled_tid'] = $args['tid'];
|
||||
return json_encode(array('resultCode'=>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))
|
||||
@@ -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'''<?php
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
session_start();
|
||||
define('_GNUBOARD_', true);
|
||||
define('G5_SHOP_URL', '/shop');
|
||||
define('G5_BBS_URL', '/bbs');
|
||||
define('G5_TIME_YMDHIS', date('Y-m-d H:i:s'));
|
||||
define('G5_OPTION_ID_FILTER', '/[\'"\\\\]/');
|
||||
define('G5_LIB_PATH', __DIR__);
|
||||
$g5 = array('g5_shop_item_table'=>'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 = '<?php\ndefine("TEST_DB",' + json.dumps(DB) + ');\ndefine("TEST_SOCKET",' + json.dumps(SOCKET) + ');\ndefine("TEST_VALIDATOR",' + json.dumps(str(ROOT / 'lib/shop.cartvalidate.lib.php')) + ');\n?>\n'
|
||||
(directory / 'bootstrap.php').write_text(constants + bootstrap)
|
||||
(directory / 'mailer.lib.php').write_text('<?php')
|
||||
routes = {'ajax': 'shop/ajax.action.php', 'theme': 'theme/basic/shop/ajax.action.php', 'normal': 'shop/cartupdate.php', 'pc_order': 'shop/orderformupdate.php', 'mobile_order': 'mobile/shop/orderformupdate.php'}
|
||||
for route, source in routes.items():
|
||||
text = (ROOT / source).read_text().replace("include_once('./_common.php');", "include_once('./bootstrap.php');", 1)
|
||||
if route.endswith('_order'):
|
||||
text = text.split('// 변수 초기화', 1)[0] + "echo json_encode(array('error'=>'','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)
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
// 실행: php tests/shop_cart_validation.php (DB 연결 없이 공통 규칙 검증)
|
||||
if (PHP_SAPI !== 'cli') exit;
|
||||
define('_GNUBOARD_', true);
|
||||
require dirname(__FILE__).'/../lib/shop.cartvalidate.lib.php';
|
||||
$checks = 0;
|
||||
function check_cart($name, $item, $options, $rows, $expected, $stored = false)
|
||||
{
|
||||
global $checks;
|
||||
$result = shop_validate_cart_rows($item, $options, $rows, $stored);
|
||||
$actual = $result['error'] === '' ? $result['total'] : false;
|
||||
if ($actual !== $expected) {
|
||||
fwrite(STDERR, $name.': '.json_encode($result)."\n");
|
||||
exit(1);
|
||||
}
|
||||
$checks++;
|
||||
}
|
||||
function test_row($id = '', $type = '0', $qty = '1')
|
||||
{
|
||||
return array('io_id' => $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";
|
||||
@@ -0,0 +1,44 @@
|
||||
// 실행: NODE_PATH=<Playwright 경로>/node_modules CHROME_PATH=<Chromium 실행 파일> node tests/shop_category_layout.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
const {execFileSync} = require('child_process');
|
||||
const {chromium} = require('playwright');
|
||||
const root = path.dirname(__dirname);
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const fixtures = JSON.parse(execFileSync('php', [path.join(__dirname, 'shop_category_navigation.php'), '--json'], {encoding:'utf8'}));
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true, ...(process.env.CHROME_PATH ? {executablePath:process.env.CHROME_PATH} : {}), args:['--no-sandbox']});
|
||||
try {
|
||||
for (const skin of ['skin/shop/basic', 'theme/basic/skin/shop/basic']) {
|
||||
for (const view of ['list', 'item']) {
|
||||
const page = await browser.newPage({viewport:{width:1920, height:1080}});
|
||||
let baseline;
|
||||
for (const id of ['10', '101010', '10101010', '1010101010']) {
|
||||
let html = fixtures[skin][id].replace(/<script>[\s\S]*?<\/script>/g, '');
|
||||
if (view === 'list') html = html.replace('view_location', 'is_list is_right');
|
||||
await page.setContent('<style>'+read(skin.startsWith('theme/') ? 'theme/basic/css/default_shop.css' : 'css/default_shop.css')+'\n'+read(skin+'/style.css')+'</style><div id="container"><div class="shop-content"><div id="wrapper_title">상품리스트</div><div class="sct_admin"><a>분류 관리</a></div><div id="sct">'+html+'<div id="sct_sortlst">상품 정렬</div></div></div></div>');
|
||||
await page.addScriptTag({content:read('js/jquery-1.12.4.min.js')});
|
||||
await page.addScriptTag({content:read('js/shop.category.navigation.js')});
|
||||
await page.evaluate(() => new Promise(resolve => jQuery(() => {
|
||||
jQuery('select.shop_hover_selectbox').shop_select_to_html();
|
||||
resolve();
|
||||
})));
|
||||
const layout = await page.evaluate(() => {
|
||||
const nav = document.getElementById('sct_location');
|
||||
const rect = nav.getBoundingClientRect();
|
||||
return {top:rect.top, height:rect.height, position:getComputedStyle(nav).position,
|
||||
menuTop:document.getElementById('sct_ct_1').getBoundingClientRect().top,
|
||||
sortTop:document.getElementById('sct_sortlst').getBoundingClientRect().top};
|
||||
});
|
||||
if (!baseline) baseline = layout;
|
||||
assert.deepStrictEqual(layout, baseline, skin+' '+view+' '+id+' 단계 변경 시 레이아웃 유지');
|
||||
assert.strictEqual(await page.locator('.shop_select_to_html').count(), id.length / 2);
|
||||
assert.strictEqual(await page.locator('#sct_ct_1 ul').count(), 1);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
console.log('PC·basic 테마 목록/상세: 1·3·4·5단계 breadcrumb 위치·높이와 메뉴·정렬 영역 위치 동일');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
// 실행: php tests/shop_category_navigation.php [--json]
|
||||
// DB 대신 분류 조회 대역을 사용하여 실제 공통 함수와 스킨을 실행한다.
|
||||
define('_GNUBOARD_', true);
|
||||
define('G5_SHOP_CSS_URL', '/css');
|
||||
define('G5_SHOP_SKIN_URL', '/skin');
|
||||
define('G5_SHOP_URL', '/shop');
|
||||
define('G5_JS_URL', '/js');
|
||||
$g5 = array('g5_shop_category_table'=>'categories', 'g5_shop_item_table'=>'items', 'title'=>'분류');
|
||||
$fixture_ids = array('10','20','1010','1020','101010','101020','10101010','10101020','1010101010','1010101020');
|
||||
function run_replace($name, $data, $cache) { return $data; }
|
||||
function shop_category_url($id) { return '/shop/list.php?ca_id='.$id; }
|
||||
function get_text($text) { return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); }
|
||||
function add_stylesheet($html, $priority) {}
|
||||
function add_javascript($html, $priority) {}
|
||||
function sql_query($sql) {
|
||||
global $fixture_ids;
|
||||
preg_match("/ca_id like '([^']*)%'/", $sql, $prefix);
|
||||
preg_match("/length\(ca_id\) = '?([0-9]+)/", $sql, $length);
|
||||
$result = (object)array('rows'=>array(), 'index'=>0);
|
||||
foreach ($fixture_ids as $id) {
|
||||
if ((!isset($prefix[1]) || strpos($id, $prefix[1]) === 0) && strlen($id) == $length[1]) {
|
||||
$result->rows[] = array('ca_id'=>$id, 'ca_name'=>'분류 '.$id);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
function sql_fetch_array($result) { return isset($result->rows[$result->index]) ? $result->rows[$result->index++] : false; }
|
||||
function sql_fetch($sql) { return array('cnt'=>3); }
|
||||
function expect_category($condition, $message) { if (!$condition) throw new RuntimeException($message); }
|
||||
require dirname(__DIR__).'/lib/shop.data.lib.php';
|
||||
$artifacts = array();
|
||||
$cases = array(
|
||||
'10'=>array(array('1010','1020')),
|
||||
'20'=>array(array('10','20')),
|
||||
'1010'=>array(array('101010','101020')),
|
||||
'101010'=>array(array('10101010','10101020')),
|
||||
'101020'=>array(array('101010','101020')),
|
||||
'10101010'=>array(array('1010101010','1010101020')),
|
||||
'10101020'=>array(array('10101010','10101020')),
|
||||
'1010101010'=>array(array('1010101010','1010101020')),
|
||||
);
|
||||
foreach ($cases as $code=>$expected) {
|
||||
$ca_id = (string)$code;
|
||||
$groups = get_shop_category_menu_groups($ca_id);
|
||||
$actual = array();
|
||||
foreach ($groups as $group) {
|
||||
$ids = array();
|
||||
foreach ($group['categories'] as $row) $ids[] = $row['ca_id'];
|
||||
$actual[] = $ids;
|
||||
}
|
||||
expect_category($actual === $expected, $ca_id.' 메뉴 단계');
|
||||
foreach (array('skin/shop/basic', 'theme/basic/skin/shop/basic') as $skin) {
|
||||
foreach (array(false, true) as $item_view) {
|
||||
$it_id = $item_view ? 'item' : '';
|
||||
$it = $item_view ? array('it_id'=>'item', 'ca_id'=>$ca_id) : array();
|
||||
ob_start(); include dirname(__DIR__).'/'.$skin.'/navigation.skin.php'; $nav = ob_get_clean();
|
||||
preg_match_all('/value="([0-9]+)"[^>]* selected/', $nav, $selected);
|
||||
$path = array();
|
||||
for ($depth=1; $depth<=strlen($ca_id)/2; $depth++) $path[] = substr($ca_id, 0, $depth*2);
|
||||
expect_category($selected[1] === $path, $skin.' '.$ca_id.' breadcrumb 현재 경로');
|
||||
$artifacts[$skin][$ca_id] = $nav;
|
||||
}
|
||||
}
|
||||
foreach (array('skin/shop/basic', 'theme/basic/skin/shop/basic', 'mobile/skin/shop/basic', 'theme/basic/mobile/skin/shop/basic') as $skin) {
|
||||
ob_start(); include dirname(__DIR__).'/'.$skin.'/listcategory.skin.php'; $menu = ob_get_clean();
|
||||
preg_match_all('/href="\/shop\/list.php\?ca_id=([0-9]+)"/', $menu, $links);
|
||||
$flattened = array(); foreach ($expected as $group) $flattened = array_merge($flattened, $group);
|
||||
expect_category($links[1] === $flattened, $skin.' '.$ca_id.' 메뉴 링크');
|
||||
preg_match_all('/aria-current="page" href="\/shop\/list.php\?ca_id=([0-9]+)"/', $menu, $selected);
|
||||
expect_category($selected[1] === (in_array($ca_id, $flattened, true) ? array($ca_id) : array()), $skin.' 현재 메뉴 표시');
|
||||
$artifacts[$skin][$ca_id] = (isset($artifacts[$skin][$ca_id]) ? $artifacts[$skin][$ca_id] : '').$menu;
|
||||
}
|
||||
}
|
||||
// 5단계 전체 트리를 전역 메뉴에 추가하지 않았는지 확인한다.
|
||||
$tree = get_shop_category_array(true);
|
||||
expect_category(!isset($tree['10']['1010']['101010']['10101010']), '공통 분류 트리 호환성');
|
||||
if (isset($argv[1]) && $argv[1] === '--json') echo json_encode($artifacts);
|
||||
else echo "8개 분류 경로: PC·테마 breadcrumb 목록/상세 및 4개 스킨 메뉴·선택 상태 통과\n";
|
||||
@@ -1,44 +1,25 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
$str = '';
|
||||
$exists = false;
|
||||
|
||||
$ca_id_len = strlen($ca_id);
|
||||
$len2 = $ca_id_len + 2;
|
||||
|
||||
$sql = " select ca_id, ca_name from {$g5['g5_shop_category_table']} where ca_id like '$ca_id%' and length(ca_id) = $len2 and ca_use = '1' order by ca_order, ca_id ";
|
||||
$result = sql_query($sql);
|
||||
|
||||
// 하위 분류가 없으면 현재 분류를 포함한 같은 단계의 사용 중인 분류를 표시한다.
|
||||
if (!sql_num_rows($result)) {
|
||||
$parent_ca_id = substr($ca_id, 0, -2);
|
||||
$sql = " select ca_id, ca_name from {$g5['g5_shop_category_table']} where ca_id like '$parent_ca_id%' and length(ca_id) = $ca_id_len and ca_use = '1' order by ca_order, ca_id ";
|
||||
$result = sql_query($sql);
|
||||
}
|
||||
|
||||
while ($row=sql_fetch_array($result)) {
|
||||
|
||||
$row2 = sql_fetch(" select count(*) as cnt from {$g5['g5_shop_item_table']} where (ca_id like '{$row['ca_id']}%' or ca_id2 like '{$row['ca_id']}%' or ca_id3 like '{$row['ca_id']}%') and it_use = '1' ");
|
||||
|
||||
$current_attr = ($row['ca_id'] === $ca_id) ? ' class="sct_ct_here" aria-current="page"' : '';
|
||||
$str .= '<li><a'.$current_attr.' href="'.shop_category_url($row['ca_id']).'">'.$row['ca_name'].' <span class="prd_cnt">'.$row2['cnt'].'</span></a></li>';
|
||||
$exists = true;
|
||||
}
|
||||
|
||||
if ($exists) {
|
||||
|
||||
// add_stylesheet('css 구문', 출력순서); 숫자가 작을 수록 먼저 출력됨
|
||||
$category_groups = get_shop_category_menu_groups($ca_id);
|
||||
if ($category_groups) {
|
||||
add_stylesheet('<link rel="stylesheet" href="'.G5_SHOP_CSS_URL.'/style.css">', 0);
|
||||
?>
|
||||
|
||||
<!-- 상품분류 1 시작 { -->
|
||||
<aside id="sct_ct_1" class="sct_ct">
|
||||
<h2>현재 상품 분류와 관련된 분류</h2>
|
||||
<ul>
|
||||
<?php echo $str; ?>
|
||||
<?php foreach ($category_groups as $category_group) { ?>
|
||||
<ul aria-label="<?php echo $category_group['label']; ?>">
|
||||
<?php foreach ($category_group['categories'] as $row) {
|
||||
$row2 = sql_fetch(" select count(*) as cnt from {$g5['g5_shop_item_table']} where (ca_id like '{$row['ca_id']}%' or ca_id2 like '{$row['ca_id']}%' or ca_id3 like '{$row['ca_id']}%') and it_use = '1' ");
|
||||
$current_attr = '';
|
||||
if ($row['ca_id'] === $ca_id) {
|
||||
$current_attr = ' class="sct_ct_here" aria-current="page"';
|
||||
} elseif (strpos($ca_id, $row['ca_id']) === 0) {
|
||||
$current_attr = ' class="sct_ct_here sct_ct_parent"';
|
||||
}
|
||||
echo '<li><a'.$current_attr.' href="'.shop_category_url($row['ca_id']).'">'.get_text($row['ca_name']).' <span class="prd_cnt">'.$row2['cnt'].'</span></a></li>';
|
||||
} ?>
|
||||
</ul>
|
||||
<?php } ?>
|
||||
</aside>
|
||||
<!-- } 상품분류 1 끝 -->
|
||||
|
||||
<?php }
|
||||
<?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)
|
||||
|
||||
@@ -1,44 +1,25 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
$str = '';
|
||||
$exists = false;
|
||||
|
||||
$ca_id_len = strlen($ca_id);
|
||||
$len2 = $ca_id_len + 2;
|
||||
|
||||
$sql = " select ca_id, ca_name from {$g5['g5_shop_category_table']} where ca_id like '$ca_id%' and length(ca_id) = $len2 and ca_use = '1' order by ca_order, ca_id ";
|
||||
$result = sql_query($sql);
|
||||
|
||||
// 하위 분류가 없으면 현재 분류를 포함한 같은 단계의 사용 중인 분류를 표시한다.
|
||||
if (!sql_num_rows($result)) {
|
||||
$parent_ca_id = substr($ca_id, 0, -2);
|
||||
$sql = " select ca_id, ca_name from {$g5['g5_shop_category_table']} where ca_id like '$parent_ca_id%' and length(ca_id) = $ca_id_len and ca_use = '1' order by ca_order, ca_id ";
|
||||
$result = sql_query($sql);
|
||||
}
|
||||
|
||||
while ($row=sql_fetch_array($result)) {
|
||||
|
||||
$row2 = sql_fetch(" select count(*) as cnt from {$g5['g5_shop_item_table']} where (ca_id like '{$row['ca_id']}%' or ca_id2 like '{$row['ca_id']}%' or ca_id3 like '{$row['ca_id']}%') and it_use = '1' ");
|
||||
|
||||
$current_attr = ($row['ca_id'] === $ca_id) ? ' class="sct_ct_here" aria-current="page"' : '';
|
||||
$str .= '<li><a'.$current_attr.' href="'.shop_category_url($row['ca_id']).'">'.$row['ca_name'].' ('.$row2['cnt'].')</a></li>';
|
||||
$exists = true;
|
||||
}
|
||||
|
||||
if ($exists) {
|
||||
|
||||
// add_stylesheet('css 구문', 출력순서); 숫자가 작을 수록 먼저 출력됨
|
||||
$category_groups = get_shop_category_menu_groups($ca_id);
|
||||
if ($category_groups) {
|
||||
add_stylesheet('<link rel="stylesheet" href="'.G5_SHOP_SKIN_URL.'/style.css">', 0);
|
||||
?>
|
||||
|
||||
<!-- 상품분류 1 시작 { -->
|
||||
<aside id="sct_ct_1" class="sct_ct">
|
||||
<h2>현재 상품 분류와 관련된 분류</h2>
|
||||
<ul>
|
||||
<?php echo $str; ?>
|
||||
<?php foreach ($category_groups as $category_group) { ?>
|
||||
<ul aria-label="<?php echo $category_group['label']; ?>">
|
||||
<?php foreach ($category_group['categories'] as $row) {
|
||||
$row2 = sql_fetch(" select count(*) as cnt from {$g5['g5_shop_item_table']} where (ca_id like '{$row['ca_id']}%' or ca_id2 like '{$row['ca_id']}%' or ca_id3 like '{$row['ca_id']}%') and it_use = '1' ");
|
||||
$current_attr = '';
|
||||
if ($row['ca_id'] === $ca_id) {
|
||||
$current_attr = ' class="sct_ct_here" aria-current="page"';
|
||||
} elseif (strpos($ca_id, $row['ca_id']) === 0) {
|
||||
$current_attr = ' class="sct_ct_here sct_ct_parent"';
|
||||
}
|
||||
echo '<li><a'.$current_attr.' href="'.shop_category_url($row['ca_id']).'">'.get_text($row['ca_name']).' ('.$row2['cnt'].')</a></li>';
|
||||
} ?>
|
||||
</ul>
|
||||
<?php } ?>
|
||||
</aside>
|
||||
<!-- } 상품분류 1 끝 -->
|
||||
|
||||
<?php }
|
||||
<?php }
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
<?php
|
||||
if (!defined("_GNUBOARD_")) exit; // 개별 페이지 접근 불가
|
||||
|
||||
$navi_datas = $ca_ids = array();
|
||||
$is_item_view = (isset($it_id) && isset($it) && isset($it['it_id']) && $it_id === $it['it_id']) ? true : false;
|
||||
|
||||
if( !$is_item_view && $ca_id ){
|
||||
$navi_datas = get_shop_navigation_data(true, $ca_id);
|
||||
$ca_ids = array(
|
||||
'ca_id' => substr($ca_id,0,2),
|
||||
'ca_id2' => substr($ca_id,0,4),
|
||||
'ca_id3' => substr($ca_id,0,6),
|
||||
);
|
||||
} else if( $is_item_view && isset($it) && is_array($it) ) {
|
||||
$navi_datas = get_shop_navigation_data(true, $it['ca_id']);
|
||||
$ca_ids = array(
|
||||
'ca_id' => substr($it['ca_id'],0,2),
|
||||
'ca_id2' => substr($it['ca_id'],0,4),
|
||||
'ca_id3' => substr($it['ca_id'],0,6)
|
||||
);
|
||||
}
|
||||
$is_item_view = (isset($it_id, $it['it_id']) && $it_id === $it['it_id']);
|
||||
$navigation_ca_id = $is_item_view ? $it['ca_id'] : $ca_id;
|
||||
$navi_datas = $navigation_ca_id ? get_shop_navigation_data(true, $navigation_ca_id) : array();
|
||||
|
||||
$location_class = array();
|
||||
if($is_item_view){
|
||||
@@ -27,7 +12,7 @@ if($is_item_view){
|
||||
$location_class[] = 'is_list is_right'; // view_location는 리스트 말고 상품보기에서만 표시
|
||||
}
|
||||
|
||||
// add_stylesheet('css 구문', 출력순서); 숫자가 작을 수록 먼저 출력됨
|
||||
// add_stylesheet('css 구문', 출력순서);
|
||||
add_stylesheet('<link rel="stylesheet" href="'.G5_SHOP_CSS_URL.'/style.css">', 0);
|
||||
add_javascript('<script src="'.G5_JS_URL.'/shop.category.navigation.js"></script>', 10);
|
||||
?>
|
||||
@@ -37,26 +22,14 @@ add_javascript('<script src="'.G5_JS_URL.'/shop.category.navigation.js"></script
|
||||
<i class="dividing-line fa fa-angle-right" aria-hidden="true"></i>
|
||||
<?php if ( is_array($navi_datas) && $navi_datas ){ ?>
|
||||
|
||||
<?php if( isset($navi_datas[0]) && $navi_datas[0] ){ ?>
|
||||
<select class="shop_hover_selectbox category1">
|
||||
<?php foreach((array) $navi_datas[0] as $data ){ ?>
|
||||
<option value="<?php echo $data['ca_id']; ?>" data-url="<?php echo $data['url']; ?>" <?php if($ca_ids['ca_id'] === $data['ca_id']) echo 'selected'; ?>><?php echo $data['ca_name']; ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
<?php } ?>
|
||||
<?php if( isset($navi_datas[1]) && $navi_datas[1] ){ ?>
|
||||
<?php foreach ($navi_datas as $depth => $categories) {
|
||||
$selected_ca_id = substr($navigation_ca_id, 0, ($depth + 1) * 2);
|
||||
if ($depth > 0) { ?>
|
||||
<i class="dividing-line fa fa-angle-right" aria-hidden="true"></i>
|
||||
<select class="shop_hover_selectbox category2">
|
||||
<?php foreach((array) $navi_datas[1] as $data ){ ?>
|
||||
<option value="<?php echo $data['ca_id']; ?>" data-url="<?php echo $data['url']; ?>" <?php if($ca_ids['ca_id2'] === $data['ca_id']) echo 'selected'; ?>><?php echo $data['ca_name']; ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
<?php } ?>
|
||||
<?php if( isset($navi_datas[2]) && $navi_datas[2] ){ ?>
|
||||
<i class="dividing-line fa fa-angle-right" aria-hidden="true"></i>
|
||||
<select class="shop_hover_selectbox category3">
|
||||
<?php foreach((array) $navi_datas[2] as $data ){ ?>
|
||||
<option value="<?php echo $data['ca_id']; ?>" data-url="<?php echo $data['url']; ?>" <?php if($ca_ids['ca_id3'] === $data['ca_id']) echo 'selected'; ?>><?php echo $data['ca_name']; ?></option>
|
||||
<select class="shop_hover_selectbox category<?php echo $depth + 1; ?>" aria-label="<?php echo $depth + 1; ?>단계 분류">
|
||||
<?php foreach ($categories as $data) { ?>
|
||||
<option value="<?php echo $data['ca_id']; ?>" data-url="<?php echo $data['url']; ?>"<?php if ($selected_ca_id === $data['ca_id']) echo ' selected'; ?>><?php echo get_text($data['ca_name']); ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
<?php } ?>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
// 운영자 전용. HTTP 요청으로 실행되지 않으며 토큰/평문/PG 응답을 출력하지 않는다.
|
||||
if (PHP_SAPI !== 'cli') { header('HTTP/1.0 404 Not Found'); exit; }
|
||||
$options = getopt('', array('action:', 'order:', 'apply', 'grace:', 'receipt:', 'verified'));
|
||||
$root = dirname(dirname(__FILE__));
|
||||
chdir($root);
|
||||
$_SERVER['DOCUMENT_ROOT'] = $root;
|
||||
$_SERVER['SCRIPT_FILENAME'] = __FILE__;
|
||||
$_SERVER['SCRIPT_NAME'] = '/tools/shop-order-maintenance.php';
|
||||
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
|
||||
$_SERVER['HTTP_HOST'] = 'localhost';
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
$_SERVER['REQUEST_URI'] = $_SERVER['SCRIPT_NAME'];
|
||||
require './common.php';
|
||||
require_once G5_LIB_PATH.'/shop_order_access.lib.php';
|
||||
require_once G5_LIB_PATH.'/shop_order_maintenance.lib.php';
|
||||
try {
|
||||
$action = isset($options['action']) ? $options['action'] : 'list';
|
||||
$id = isset($options['order']) ? $options['order'] : '';
|
||||
$apply = isset($options['apply']);
|
||||
if (!in_array($action, array('list','cleanup'), true) && !shop_order_access_id($id)) throw new RuntimeException('--order에 주문번호를 지정해 주십시오.');
|
||||
$out = array();
|
||||
if ($action === 'list') {
|
||||
$rows = sql_query("select od_id,pg,status,expires,updated_at from ".shop_order_state_table()." order by updated_at limit 500");
|
||||
while ($row = sql_fetch_array($rows)) $out[] = $row;
|
||||
$rows = sql_query("select d.od_id,d.dt_pg as pg,d.dt_time from {$g5['g5_shop_order_data_table']} d left join ".shop_order_state_table()." a on d.od_id=a.od_id where a.od_id is null limit 500");
|
||||
while ($row = sql_fetch_array($rows)) { $row['status']='legacy_unreviewed'; $out[]=$row; }
|
||||
} elseif ($action === 'cleanup') {
|
||||
$out = shop_order_cleanup($apply, isset($options['grace']) ? (int)$options['grace'] : 86400);
|
||||
} elseif ($action === 'quarantine-legacy') {
|
||||
if (!$apply) $out = array('order'=>$id,'action'=>'--apply로 평문 제거·해시 별도 보관·복귀 차단을 실행합니다. 백업과 PG 대조를 먼저 수행하십시오.');
|
||||
else $out = shop_order_quarantine_legacy($id);
|
||||
} elseif ($action === 'reconcile-toss') {
|
||||
if (!$apply) throw new RuntimeException('PG 조회와 상태 갱신을 실행하려면 --apply를 지정해 주십시오.');
|
||||
require_once G5_SHOP_PATH.'/toss/toss.inc.php';
|
||||
$toss = new TossPayments($config['cf_toss_client_key'],$config['cf_toss_secret_key'],$config['cf_lg_mid']);
|
||||
$toss->setPaymentHeader(); $out = shop_order_reconcile_toss($id, $toss);
|
||||
} elseif ($action === 'reconcile-kcp') {
|
||||
if (!$apply || !isset($options['verified'],$options['receipt'])) throw new RuntimeException('KCP 거래 원장 대조 후 --apply --verified --receipt=파일을 지정해 주십시오.');
|
||||
shop_order_state_lock('order', $id); $row = shop_order_state_row($id);
|
||||
$state = $row ? json_decode($row['state_json'],true) : null;
|
||||
$receipt = json_decode(file_get_contents($options['receipt']),true);
|
||||
if (!$row || $row['pg'] !== 'kcp' || !in_array($row['status'],array('approving','unknown'),true) ||
|
||||
!is_array($receipt) || !isset($receipt['orderId'],$receipt['amount'],$receipt['tno'],$receipt['res_cd']) ||
|
||||
(string)$receipt['orderId'] !== $id || empty($state['expected_amount']) || (int)$receipt['amount'] !== (int)$state['expected_amount'] ||
|
||||
!is_string($receipt['tno']) || !preg_match('/\A[A-Za-z0-9_-]{6,100}\z/D',$receipt['tno']) ||
|
||||
!in_array($receipt['res_cd'],array('0000','V000'),true)) throw new RuntimeException('KCP 대조 자료가 주문·금액·승인 상태와 일치하지 않습니다.');
|
||||
$payload = array_intersect_key($receipt,array_flip(shop_order_kcp_result_fields()));
|
||||
foreach ($payload as $value) if (!is_scalar($value)) throw new RuntimeException('승인 필드는 단일 값이어야 합니다.');
|
||||
shop_order_state_approved($id,array('payload'=>base64_encode(serialize($payload))));
|
||||
$out = array('order'=>$id,'status'=>'approved','action'=>'운영자 확인 자료 반영. 원래 토큰으로 재시도하십시오.');
|
||||
} elseif ($action === 'purge-reviewed-legacy') {
|
||||
if (!$apply || !isset($options['verified'],$options['receipt'])) throw new RuntimeException('기존 거래 원장 대조 후 --apply --verified --receipt=파일을 지정하십시오.');
|
||||
shop_order_state_lock('order',$id);$row=shop_order_state_row($id);
|
||||
$receipt=json_decode(file_get_contents($options['receipt']),true);
|
||||
if (!$row || $row['status'] !== 'legacy' || !is_array($receipt) || !isset($receipt['orderId'],$receipt['resolution'],$receipt['reference']) ||
|
||||
(string)$receipt['orderId'] !== $id || !in_array($receipt['resolution'],array('unpaid','cancelled','completed'),true) ||
|
||||
!is_string($receipt['reference']) || trim($receipt['reference'])==='') throw new RuntimeException('격리된 과거 주문과 대조 자료를 확인하십시오.');
|
||||
if ($receipt['resolution']==='completed') {
|
||||
$order=sql_fetch("select od_tno from {$g5['g5_shop_order_table']} where od_id='$id'");
|
||||
$personal=sql_fetch("select pp_tno from {$g5['g5_shop_personalpay_table']} where pp_id='$id'");
|
||||
if (empty($order['od_tno']) && empty($personal['pp_tno'])) throw new RuntimeException('완료 거래의 주문 저장을 먼저 복구하십시오.');
|
||||
}
|
||||
$pg=sql_escape_string($row['pg']);
|
||||
if (!sql_query("delete from {$g5['g5_shop_order_data_table']} where od_id='$id' and dt_pg='$pg'",false)) shop_order_access_fail();
|
||||
shop_order_state_write($id,array('status'=>'purged','state_json'=>json_encode(array('review_sha256'=>hash_file('sha256',$options['receipt']))),'response_json'=>'','token_hash'=>'','payment_key'=>''));
|
||||
$out=array('order'=>$id,'status'=>'purged');
|
||||
} elseif ($action === 'complete-reviewed') {
|
||||
if (!$apply || !isset($options['verified'])) throw new RuntimeException('주문·PG·장바구니·포인트·쿠폰 대조를 마친 후 --apply --verified를 지정하십시오.');
|
||||
shop_order_state_lock('order',$id);$row=shop_order_state_row($id);
|
||||
if (!$row || $row['status'] !== 'finalizing') throw new RuntimeException('부분 저장 대조 대상이 아닙니다.');
|
||||
$state=json_decode($row['state_json'],true);
|
||||
$saved=$state['personal'] ? sql_fetch("select pp_tno as tno from {$g5['g5_shop_personalpay_table']} where pp_id='$id'") : sql_fetch("select od_tno as tno from {$g5['g5_shop_order_table']} where od_id='$id'");
|
||||
if (empty($saved['tno'])) throw new RuntimeException('저장된 주문의 거래번호가 없습니다.');
|
||||
if ($row['pg']==='toss' && $saved['tno']!==$row['payment_key']) throw new RuntimeException('저장 거래번호와 승인 기록이 다릅니다.');
|
||||
if ($row['pg']==='kcp') {
|
||||
$response=json_decode($row['response_json'],true);
|
||||
$response=isset($response['payload'])?shop_order_decode_data($response['payload']):false;
|
||||
if (!is_array($response) || empty($response['tno']) || $saved['tno']!==$response['tno']) throw new RuntimeException('KCP 저장 거래번호와 승인 기록이 다릅니다.');
|
||||
}
|
||||
shop_order_state_complete($id);$out=array('order'=>$id,'status'=>'completed');
|
||||
} else throw new RuntimeException('지원하지 않는 action입니다.');
|
||||
echo json_encode($out).PHP_EOL;
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR,$e->getMessage().PHP_EOL);exit(1);
|
||||
}
|
||||
+1
-1
@@ -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.40');
|
||||
// 그누보드5.4.5.5 버전과 영카트5.4.5.5.1 버전을 합쳐서 그누보드5.4.6 버전에서 시작함 (kagla-210617)
|
||||
// G5_YOUNGCART_VER 이 상수를 사용하는 곳이 있으므로 주석 처리 해제함
|
||||
// 그누보드5.4.6 이상 버전 부터는 영카트를 그누보드에 포함하여 배포하므로 영카트5의 버전은 의미가 없습니다.
|
||||
|
||||
Reference in New Issue
Block a user