Compare commits

...
7 Commits
Author SHA1 Message Date
섭웨이bandGitHub 9aadcf3e99 security: KVE-2026-2424 LG 가상계좌 통보 보호와 5.6.40 배포 병합
LG 통보 인증·재처리·은행 취소 보호 및 필수 DB업그레이드 안내를 반영합니다. HTTP·DB 회귀 검사 715건과 설치 마이그레이션 이력 검사를 통과했습니다.
2026-09-21 08:44:55 +09:00
whitedot 0d247c542c security: KVE-2026-2424 LG 가상계좌 통보 보호와 5.6.40 배포 준비 2026-09-20 23:44:01 +00:00
섭웨이bandGitHub 148d748c2d fix: 그누보드 5.6.391 쇼핑몰 분류 표시 수정 배포 병합
fix: 그누보드 5.6.391 쇼핑몰 분류 표시 수정 배포
2026-09-15 15:20:13 +09:00
whitedot de7063192e chore: 5.6.391 버전과 쇼핑몰 분류 표시 수정 배포 안내 준비 2026-09-15 06:19:00 +00:00
whitedot e2d215c368 fix: 쇼핑몰 5단계 분류 경로와 서브메뉴 표시 일관성 보완
PC·basic 테마 breadcrumb를 5단계로 확장하고 기존 위치와 스타일을 유지한다. PC·모바일 서브메뉴는 5단계가 있으면 해당 목록만, 없으면 4단계 형제 목록만 표시한다.

검증: 8개 분류 경로와 네 스킨의 메뉴·선택 상태, Chromium 목록·상세 단계별 레이아웃 검사, PHP 5.2 및 현재 PHP 구문 검사 통과.
2026-09-15 06:19:00 +00:00
섭웨이bandGitHub c4cd61c916 security: KVE-2026-2140 그누보드 5.6.39 보안 업데이트 배포 병합
주문 보호와 설치·업그레이드 보완 및 배포 안내를 반영한다.
2026-09-14 15:54:57 +09:00
whitedot f7aa440a80 security: KVE-2026-2140 주문 보호와 5.6.39 보안 업데이트 배포 2026-09-14 06:53:14 +00:00
58 changed files with 2275 additions and 722 deletions
+2 -1
View File
@@ -73,7 +73,8 @@ cd gnuboard5
## 문서 및 지원
- [5.6.38 보안 업데이트 및 필수 DB업그레이드 안내](docs/release-5.6.38.md)
- [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 -1
View File
@@ -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(); 하기 전에
}
}
+21
View File
@@ -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에 실행하면 테이블이 삭제되므로 복구용으로 실행하지 않는다.
+69
View File
@@ -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 백업·실제 비밀번호·개인정보가 포함된 파일은 이 폴더에 보관하지 않는다.
+35
View File
@@ -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)를 참고하십시오.
+26
View File
@@ -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와 모든 사용자 스킨을 검증한 결과는 아닙니다.
+28
View File
@@ -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 통신·은행 입금, 전체 주문서 제출, 운영 서버와 모든 사용자 수정 환경의 검증을 포함하지 않습니다.
+110
View File
@@ -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 통신은 이 검사에 포함하지 않는다. 기존 주문 상태·최종 주문 회귀 검증도 별도 설치본에서 실행한다.
+87
View File
@@ -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를 임의로 가정하지 않았다.
+34
View File
@@ -684,6 +684,7 @@ CREATE TABLE IF NOT EXISTS `g5_shop_order` (
`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',
@@ -783,6 +784,7 @@ CREATE TABLE IF NOT EXISTS `g5_shop_personalpay` (
`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 '',
@@ -1055,3 +1057,35 @@ CREATE TABLE `g5_shop_kcp_noti` (
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;
+23
View File
@@ -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 '';
}
+24
View File
@@ -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;
@@ -656,10 +678,12 @@ if($g5_shop_install) {
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");
+26
View File
@@ -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);
}
+307
View File
@@ -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;
}
+59 -7
View File
@@ -36,12 +36,14 @@ function g5_migration_replace_placeholders($sql)
'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',
@@ -85,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) {
@@ -110,6 +112,17 @@ function g5_migration_parse_file($file)
$migration['description'] = trim($matches[1]);
}
$migration['contents'] = $contents;
return $migration;
}
function g5_migration_parse_file($file)
{
$migration = g5_migration_read_file($file);
if (isset($migration['error'])) return $migration;
$contents = $migration['contents'];
unset($migration['contents']);
if (preg_match('/^--[\t ]*@skip-if-column[\t ]+(\S+)[\t ]+(\S+)[\t ]*\r?$/mi', $contents, $matches)) {
$migration['skip_table'] = g5_migration_replace_placeholders(trim($matches[1], '`'));
$migration['skip_column'] = trim($matches[2], '`');
@@ -303,17 +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;
}
@@ -399,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();
@@ -470,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();
@@ -487,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']);
@@ -525,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) {
+41 -1
View File
@@ -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;
}
}
+3 -1
View File
@@ -2829,7 +2829,9 @@ function add_order_post_log($msg='', $code='error'){
if( empty($_POST) ) return;
$post_data = base64_encode(serialize($_POST));
$log_data = $_POST;
unset($log_data['od_pwd'], $log_data['g5_order_state'], $log_data['g5_checkout_nonce']);
$post_data = base64_encode(serialize($log_data));
$od_id = get_session('ss_order_id');
if( $code === 'delete' ){
+107
View File
@@ -0,0 +1,107 @@
<?php
if (!defined('_GNUBOARD_')) exit;
include_once(dirname(__FILE__).'/shop_order_compat.lib.php');
include_once(dirname(__FILE__).'/shop_order_state.lib.php');
include_once(dirname(__FILE__).'/shop_order_fields.lib.php');
// 결제 복귀는 주문번호가 아니라 임시 저장 시 발급한 서버 세션 상태로 인증한다.
function shop_order_access_fail()
{
header('Cache-Control: no-store, private');
header('Referrer-Policy: no-referrer');
alert('결제 요청을 확인할 수 없습니다. 결제 내역을 확인한 후 다시 시도해 주십시오.', G5_SHOP_URL);
exit;
}
function shop_order_access_id($value)
{
return is_string($value) && preg_match('/\A[0-9]{1,20}\z/D', $value);
}
function shop_order_access_load_legacy($order_id, $pg)
{
global $g5, $member;
header('Cache-Control: no-store, private');
header('Referrer-Policy: no-referrer');
if (!shop_order_access_id($order_id)) shop_order_access_fail();
$states = get_session('ss_order_data_access');
$state = is_array($states) && isset($states[$order_id]) ? $states[$order_id] : null;
$member_id = isset($member['mb_id']) ? (string)$member['mb_id'] : '';
if (!is_array($state) || $state['pg'] !== $pg || $state['member'] !== $member_id ||
$state['expires'] <= G5_SERVER_TIME) shop_order_access_fail();
if ($state['personal']) {
if ((string)get_session('ss_personalpay_id') !== $order_id ||
!$state['personal_hash'] ||
$state['personal_hash'] !== get_session('ss_personalpay_hash')) shop_order_access_fail();
$done = sql_fetch("select pp_tno, pp_use from {$g5['g5_shop_personalpay_table']} where pp_id = '$order_id'");
if (!$done || !$done['pp_use'] || $done['pp_tno']) shop_order_access_fail();
} else {
$cart = get_session($state['direct'] ? 'ss_cart_direct' : 'ss_cart_id');
if ((string)get_session('ss_order_id') !== $order_id ||
(bool)get_session('ss_direct') !== $state['direct'] ||
!$cart || (string)$cart !== $state['cart']) shop_order_access_fail();
$done = sql_fetch("select od_id from {$g5['g5_shop_order_table']} where od_id = '$order_id'");
if (!empty($done['od_id'])) shop_order_access_fail();
}
// 중복 행을 임의로 선택하지 않고, 최소 메타데이터부터 대조한다.
$rows = sql_query("select cart_id, mb_id, dt_pg, dt_time from {$g5['g5_shop_order_data_table']} where od_id = '$order_id'");
if (sql_num_rows($rows) !== 1) shop_order_access_fail();
$meta = sql_fetch_array($rows);
if ((string)$meta['cart_id'] !== $state['cart'] || $meta['mb_id'] !== $state['member'] ||
$meta['dt_pg'] !== $pg || $meta['dt_time'] !== $state['time']) shop_order_access_fail();
$row = sql_fetch("select dt_data from {$g5['g5_shop_order_data_table']} where od_id = '$order_id' and dt_pg = '".sql_escape_string($pg)."'");
if (empty($row['dt_data']) || !shop_order_equals($state['digest'], hash('sha256', $row['dt_data']))) shop_order_access_fail();
$data = shop_order_decode_data($row['dt_data']);
if (!is_array($data) || (!empty($data['pp_id']) !== $state['personal']) ||
($state['personal'] && (string)$data['pp_id'] !== $order_id)) shop_order_access_fail();
return $data;
}
function shop_order_access_load($order_id, $pg)
{
header('Cache-Control: no-store, private');
header('Referrer-Policy: no-referrer');
return shop_order_state_load($order_id, $pg);
}
function shop_order_access_payment($order_id, $payment_key, $amount = null)
{
if (!is_string($payment_key) || !preg_match('/\A[A-Za-z0-9_-]{1,200}\z/D', $payment_key)) shop_order_access_fail();
if ($amount !== null && (!is_string($amount) || !preg_match('/\A[0-9]{1,12}\z/D', $amount))) shop_order_access_fail();
$data = shop_order_access_load($order_id, 'toss');
if ($amount !== null && (!is_string($amount) || !preg_match('/\A[0-9]{1,12}\z/D', $amount) ||
!isset($data['amountValue']) || (int)$amount <= 0 || (int)$amount !== (int)$data['amountValue'])) shop_order_access_fail();
$states = get_session('ss_order_data_access');
if (isset($states[$order_id]['payment_key']) && $states[$order_id]['payment_key'] !== $payment_key) shop_order_access_fail();
$states[$order_id]['payment_key'] = $payment_key;
shop_order_state_write($order_id, array('payment_key'=>$payment_key));
set_session('ss_order_data_access', $states);
$data['paymentKey'] = $payment_key;
$data['orderId'] = $order_id;
return $data;
}
// 클라이언트가 보낸 해시를 신뢰하지 않고 저장 당시 서버 상태만 사용한다.
function shop_order_access_password($order_id)
{
$states = get_session('ss_order_data_access');
if (!is_array($states) || !isset($states[$order_id])) return null;
$state = $states[$order_id];
shop_order_access_load($order_id, $state['pg']);
if ($state['personal'] || empty($state['password_hash'])) shop_order_access_fail();
return $state['password_hash'];
}
function shop_order_access_forget($order_id)
{
shop_order_state_complete($order_id);
$states = get_session('ss_order_data_access');
if (is_array($states)) {
unset($states[$order_id]);
set_session('ss_order_data_access', $states);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// PHP 5.2.17에서도 인증용 난수는 강한 난수원만 사용한다.
function shop_order_random_hex($length)
{
$bytes = false;
if (function_exists('random_bytes')) {
try { $bytes = random_bytes($length); } catch (Exception $e) { $bytes = false; }
}
if ($bytes === false && function_exists('openssl_random_pseudo_bytes')) {
$strong = false;
$candidate = openssl_random_pseudo_bytes($length, $strong);
if ($strong) $bytes = $candidate;
}
if ($bytes === false && DIRECTORY_SEPARATOR === '/') {
$stream = @fopen('/dev/urandom', 'rb');
if ($stream) {
$bytes = '';
while (strlen($bytes) < $length) {
$part = @fread($stream, $length - strlen($bytes));
if ($part === false || $part === '') break;
$bytes .= $part;
}
fclose($stream);
}
}
if ($bytes === false && function_exists('mcrypt_create_iv') && defined('MCRYPT_DEV_URANDOM')) {
$bytes = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);
}
if (!is_string($bytes) || strlen($bytes) !== $length) shop_order_access_fail();
return bin2hex($bytes);
}
// 토큰·다이제스트는 길이가 공개된 고정 길이 값이다. 같은 길이는 끝까지 비교한다.
function shop_order_equals($known, $input)
{
if (!is_string($known) || !is_string($input) || strlen($known) !== strlen($input)) return false;
$difference = 0;
for ($i = 0, $length = strlen($known); $i < $length; $i++) {
$difference |= ord($known[$i]) ^ ord($input[$i]);
}
return $difference === 0;
}
// PHP 버전과 무관하게 객체·참조를 생성하지 않는다. 기존 serialize/base64 행과 호환한다.
function shop_order_decode_data($encoded)
{
if (!is_string($encoded) || strlen($encoded) > 1398104) return false;
$serialized = base64_decode($encoded, true);
if ($serialized === false || strlen($serialized) > 1048576) return false;
$offset = 0;
$nodes = 0;
$valid = true;
$value = shop_order_decode_value($serialized, $offset, $nodes, $valid, 0);
return $valid && $offset === strlen($serialized) && is_array($value) ? $value : false;
}
function shop_order_decode_value($data, &$offset, &$nodes, &$valid, $depth)
{
if (!$valid || $depth > 32 || ++$nodes > 20000 || $offset >= strlen($data)) {
$valid = false;
return null;
}
$type = $data[$offset++];
if ($type === 'N' && substr($data, $offset++, 1) === ';') return null;
if (substr($data, $offset++, 1) !== ':') { $valid = false; return null; }
if ($type === 's' || $type === 'a') {
$end = strpos($data, ':', $offset);
if ($end === false) { $valid = false; return null; }
$number = substr($data, $offset, $end - $offset);
if (!preg_match('/\A(?:0|[1-9][0-9]{0,6})\z/D', $number)) { $valid = false; return null; }
$count = (int)$number;
$offset = $end + 1;
if ($type === 's') {
if (substr($data, $offset++, 1) !== '"' || $count > strlen($data) - $offset - 2) { $valid = false; return null; }
$value = substr($data, $offset, $count);
$offset += $count;
if (substr($data, $offset, 2) !== '";') $valid = false;
$offset += 2;
return $value;
}
if ($count > 10000 || substr($data, $offset++, 1) !== '{') { $valid = false; return null; }
$value = array();
for ($i = 0; $i < $count && $valid; $i++) {
$key = shop_order_decode_value($data, $offset, $nodes, $valid, $depth + 1);
if (!is_int($key) && !is_string($key)) { $valid = false; break; }
$item = shop_order_decode_value($data, $offset, $nodes, $valid, $depth + 1);
if ($valid) $value[$key] = $item;
}
if (substr($data, $offset++, 1) !== '}') $valid = false;
return $value;
}
if ($type === 'b' || $type === 'i' || $type === 'd') {
$end = strpos($data, ';', $offset);
if ($end === false) { $valid = false; return null; }
$number = substr($data, $offset, $end - $offset);
$offset = $end + 1;
if ($type === 'b' && ($number === '0' || $number === '1')) return $number === '1';
if ($type === 'i' && preg_match('/\A-?(?:0|[1-9][0-9]*)\z/D', $number) && (string)(int)$number === $number) return (int)$number;
if ($type === 'd' && preg_match('/\A-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?\z/D', $number) && is_finite((float)$number)) return (float)$number;
}
$valid = false;
return null;
}
+42
View File
@@ -0,0 +1,42 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// 기본 주문서와 PG별 폼 필드의 명시적 허용 목록. 비밀번호/상태 토큰은 별도 저장한다.
function shop_order_allowed_fields($pg)
{
$common = explode(' ', 'inicis_pro nhnkcp_pay_case ad_default ad_sel_addr ad_subject amountValue cp_id cp_price f_cp_id f_cp_prc it_id it_name it_notax it_price item_coupon max_temp_point naverpay_point_direct o_cp_id o_cp_prc od_addr1 od_addr2 od_addr3 od_addr_jibeon od_b_addr1 od_b_addr2 od_b_addr3 od_b_addr_jibeon od_b_hp od_b_name od_b_tel od_b_zip od_bank_account od_coupon od_cp_id od_deposit_name od_email od_goods_name od_hope_date od_hp od_ip od_memo od_name od_price od_send_cost od_send_cost2 od_send_coupon od_settle_case od_tel od_temp_point od_test od_zip org_od_price post_cart_id pp_email pp_hp pp_id pp_name pp_settle_case s_cp_id s_cp_prc sc_cp_id sw_direct');
$pg_fields = array(
'toss' => 'amountCurrency buyeremail buyertel cardUseAppCardOnly cardUseCardPoint cardUseEscrow cardeasyPay cardflowMode comm_free_mny comm_tax_mny comm_vat_mny customerEmail customerMobilePhone customerName escrowProducts good_mny id_info method od_id orderId orderName submitChecked taxFreeAmount tr_code tx windowTarget',
'kcp' => 'ActionResult KCP_PAY_MODULE Ret_URL amt_sup amt_svc amt_tax amt_tot applepay_direct approval_key bank_issu bank_name bask_cntx buyr_mail buyr_name buyr_tel1 buyr_tel2 cash_authno cash_id_info cash_tr_code cash_tsdtime cash_yn comm_free_mny comm_tax_mny comm_vat_mny complex_pnt_yn corp_type currency def_site_cd deli_term disp_tax_yn enc_data enc_info eng_flag epnt_issu escrow_foot escw_used fix_inst good_cd good_expr good_info good_mny good_name id_info ipgm_date kakaopay_direct kcp_noint kcp_noint_quota module_type naverpay_direct nhnkcp_pay_case not_used_card ordr_idxx param_opt_1 param_opt_2 param_opt_3 pay_method pay_mod payco_direct pt_memcorp_cd quotaopt rcvr_add1 rcvr_add2 rcvr_mail rcvr_name rcvr_tel1 rcvr_tel2 rcvr_zipx req_tx res_cd res_msg ret_pay_method save_ocb settle_method shop_name shop_user_id site_cd site_logo site_name skin_indx submitChecked tablet_size tar_opener tax_flag tk_shop_id tno tr_code trace_no trad_time tran_cd tx use_pay_method used_card used_card_CCXX used_card_YN vcnt_expire_term vcnt_expire_term_time wish_vbank_list',
'inicis' => 'gopaymethod DEF_RESERVED P_AMT P_APPL_NUM P_AUTH_DT P_AUTH_NO P_CARD_ISSUER P_EMAIL P_GOODS P_HASH P_HPP_CORP P_HPP_METHOD P_MID P_MOBILE P_NEXT_URL P_NOTI P_NOTI_URL P_OID P_QUOTABASE P_RESERVED P_RETURN_URL P_SKIP_TERMS P_TAX P_TAXFREE P_TYPE P_UNAME P_VACT_BANK P_VACT_NAME P_VACT_NUM acceptmethod buyeremail buyername buyertel charset closeUrl comm_free_mny comm_tax_mny comm_vat_mny currency good_mny goodname id_info ini_logoimage_url ini_menuarea_url mKey mid nointerest od_id oid parentemail payViewType popupUrl price quotabase recvaddr recvname recvpostnum recvtel res_cd returnUrl signature submitChecked tax taxfree timestamp tr_code tx version',
'lg' => 'CST_MID CST_PLATFORM LGD_AMOUNT LGD_BUYER LGD_BUYERADDRESS LGD_BUYEREMAIL LGD_BUYERID LGD_BUYERIP LGD_BUYERPHONE LGD_CASHRECEIPTYN LGD_CASNOTEURL LGD_CUSTOM_FIRSTPAY LGD_CUSTOM_PROCESSTYPE LGD_CUSTOM_SKIN LGD_CUSTOM_USABLEPAY LGD_EASYPAY_ONLY LGD_ENCODING LGD_ENCODING_RETURNURL LGD_ESCROW_ADDRESS1 LGD_ESCROW_ADDRESS2 LGD_ESCROW_BUYERPHONE LGD_ESCROW_ZIPCODE LGD_HASHDATA LGD_MID LGD_OID LGD_PAYKEY LGD_PRODUCTINFO LGD_RECEIVER LGD_RECEIVERPHONE LGD_RETURNURL LGD_TAXFREEAMOUNT LGD_TIMESTAMP LGD_VERSION LGD_WINDOW_VER comm_free_mny comm_tax_mny comm_vat_mny good_mny id_info od_id res_cd submitChecked tr_code tx',
'nicepay' => 'Amt BuyerEmail BuyerName BuyerTel CharSet DirectEasyPay DirectShowOpt EasyPayCardCode EasyPayMethod EasyPayQuota EdiDate GoodsCl GoodsName GoodsVat MID Moid MultiEasyPayQuota NicepayReserved NpLang PayMethod ReqReserved ReturnURL SelectCardCode SelectQuota ServiceAmt SignData SupplyAmt TaxFreeAmt TransType VbankExpDate buyeremail buyertel comm_free_mny comm_tax_mny comm_vat_mny good_mny id_info od_id submitChecked tr_code tx',
'samsungpay' => 'DEF_RESERVED P_AMT P_APPL_NUM P_AUTH_DT P_AUTH_NO P_CARD_ISSUER P_EMAIL P_GOODS P_HASH P_HPP_CORP P_HPP_METHOD P_MID P_MOBILE P_NEXT_URL P_NOTI P_NOTI_URL P_OID P_QUOTABASE P_RESERVED P_RETURN_URL P_SKIP_TERMS P_TAX P_TAXFREE P_TYPE P_UNAME P_VACT_BANK P_VACT_NAME P_VACT_NUM good_mny res_cd samsungpay_form',
);
return array_unique(array_merge($common, isset($pg_fields[$pg]) ? explode(' ', $pg_fields[$pg]) : array()));
}
function shop_order_filter_data($input, $pg)
{
$out = array();
foreach (shop_order_allowed_fields($pg) as $key) {
if (!array_key_exists($key, $input)) continue;
$value = $input[$key];
if (is_array($value)) {
if (count($value) > 1000) shop_order_access_fail();
foreach ($value as $k=>$v) {
if (!is_scalar($v) || strlen((string)$v) > 65536 || !preg_match('/\A[0-9A-Za-z_-]{1,100}\z/D', (string)$k)) shop_order_access_fail();
}
} elseif (!is_scalar($value) || strlen((string)$value) > 65536) shop_order_access_fail();
$out[$key] = $value;
}
if (strlen(serialize($out)) > 1048576) shop_order_access_fail();
return $out;
}
function shop_order_toss_providers()
{
$providers = array();
foreach (shop_easypay_catalog('toss') as $provider) $providers[] = $provider[1];
return $providers;
}
+75
View File
@@ -0,0 +1,75 @@
<?php
if (!defined('_GNUBOARD_')) exit;
function shop_order_cleanup($apply = false, $grace = 86400)
{
global $g5;
$cutoff = G5_SERVER_TIME - max(3600, (int)$grace);
$table = shop_order_state_table();
$rows = sql_query("select od_id from $table where (status in ('completed','failed','cancelled') and updated_at < $cutoff) or (status='pending' and pg in ('toss','kcp') and expires < $cutoff) order by od_id limit 500");
$result = array();
while ($candidate = sql_fetch_array($rows)) {
$id = $candidate['od_id']; shop_order_state_lock('order', $id);
$row = shop_order_state_row($id);
if (!$row) { shop_order_state_unlock('order', $id); continue; }
$safe = (in_array($row['status'], array('completed','failed','cancelled'), true) && (int)$row['updated_at'] < $cutoff) ||
($row['status'] === 'pending' && in_array($row['pg'], array('toss','kcp'), true) && (int)$row['expires'] < $cutoff);
if (!$safe) { shop_order_state_unlock('order', $id); continue; }
$result[] = array('order'=>$id, 'status'=>$row['status'], 'action'=>$apply ? 'purged' : 'would_purge');
if ($apply) {
$pg = sql_escape_string($row['pg']);
if (!sql_query("delete from {$g5['g5_shop_order_data_table']} where od_id='$id' and dt_pg='$pg'", false)) shop_order_access_fail();
shop_order_state_write($id, array('status'=>'purged','state_json'=>'','response_json'=>'','token_hash'=>'','payment_key'=>''));
}
shop_order_state_unlock('order', $id);
}
return $result;
}
function shop_order_quarantine_legacy($id)
{
global $g5;
shop_order_state_lock('order', $id);
if (shop_order_state_row($id)) throw new RuntimeException('이미 상태 기록이 있는 주문입니다.');
$res = sql_query("select * from {$g5['g5_shop_order_data_table']} where od_id='$id'");
if (sql_num_rows($res) !== 1) throw new RuntimeException('임시 주문을 하나로 확정할 수 없습니다.');
$row = sql_fetch_array($res);
$data = shop_order_decode_data($row['dt_data']);
if (!is_array($data)) throw new RuntimeException('임시 데이터 형식을 확인해 주십시오.');
$password_hash = isset($data['od_pwd']) && is_string($data['od_pwd']) ? get_encrypt_string($data['od_pwd']) : '';
unset($data['od_pwd']);
$data = shop_order_filter_data($data, $row['dt_pg']);
$encoded = base64_encode(serialize($data));
// 먼저 접근 불가능한 상태를 기록한다. 세션이 남아 있어도 자동 전환하지 않는다.
$state = sql_escape_string(json_encode(array('password_hash'=>$password_hash, 'legacy'=>true)));
$pg = sql_escape_string($row['dt_pg']);
if (!sql_query("insert into ".shop_order_state_table()." set od_id='$id',pg='$pg',status='legacy',state_json='$state',response_json='',updated_at=".G5_SERVER_TIME, false)) shop_order_access_fail();
if (!sql_query("update {$g5['g5_shop_order_data_table']} set dt_data='$encoded' where od_id='$id'", false)) shop_order_access_fail();
return array('order'=>$id, 'status'=>'legacy', 'plaintext_removed'=>true, 'token_issued'=>false);
}
function shop_order_reconcile_toss($id, $toss)
{
global $g5;
shop_order_state_lock('order', $id);
$row = shop_order_state_row($id);
$state = $row ? json_decode($row['state_json'], true) : null;
if (!$row || $row['pg'] !== 'toss' || empty($state['expected_amount']) || $row['payment_key'] === '' ||
!in_array($row['status'], array('approving','approved','finalizing','unknown','cancel_pending'), true)) throw new RuntimeException('승인 대조가 필요한 Toss 주문이 아닙니다.');
if (!$toss->getPaymentByOrderId($id)) throw new RuntimeException('PG 조회 실패: 상태를 변경하지 않았습니다.');
$p = $toss->responseData;
if (!isset($p['orderId'],$p['paymentKey'],$p['totalAmount'],$p['status']) || $p['orderId'] !== $id ||
$p['paymentKey'] !== $row['payment_key'] || (int)$p['totalAmount'] !== (int)$state['expected_amount']) throw new RuntimeException('PG 주문번호·키·금액이 일치하지 않습니다.');
if ($p['status'] === 'CANCELED') {
shop_order_state_write($id, array('status'=>'cancelled'));
return array('order'=>$id, 'status'=>'cancelled');
}
if (!($p['status'] === 'DONE' || ($p['status'] === 'WAITING_FOR_DEPOSIT' && isset($p['method']) && $p['method'] === '가상계좌'))) throw new RuntimeException('PG 거래를 재승인하지 말고 별도 확인해 주십시오.');
$saved = $state['personal'] ? sql_fetch("select pp_tno as tno from {$g5['g5_shop_personalpay_table']} where pp_id='$id'") : sql_fetch("select od_tno as tno from {$g5['g5_shop_order_table']} where od_id='$id'");
if (($state['personal'] && !empty($saved['tno'])) || (!$state['personal'] && $saved)) {
shop_order_state_write($id, array('status'=>'finalizing'));
return array('order'=>$id, 'status'=>'finalizing', 'action'=>'주문·장바구니·포인트·쿠폰의 부분 저장 대조 필요');
}
shop_order_state_approved($id, $p);
return array('order'=>$id, 'status'=>'approved', 'action'=>'원래 복귀 토큰으로 재시도 가능, 신규 승인 호출 없음');
}
+398
View File
@@ -0,0 +1,398 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// 주문별 복귀 인증과 승인 기록. DDL은 설치 SQL/명시적 마이그레이션에서만 실행한다.
function &shop_order_runtime()
{
static $runtime = array('locks'=>array(), 'rows'=>array(), 'active'=>'');
return $runtime;
}
function shop_order_state_table()
{
global $g5;
return isset($g5['g5_shop_order_access_table']) ? $g5['g5_shop_order_access_table'] : G5_SHOP_TABLE_PREFIX.'order_access';
}
function shop_order_lock_name($kind, $id)
{
return substr('g5oa_'.hash('sha256', G5_MYSQL_DB.G5_SHOP_TABLE_PREFIX.$kind.$id), 0, 64);
}
function shop_order_state_lock($kind, $id)
{
$r =& shop_order_runtime();
$key = shop_order_lock_name($kind, $id);
shop_order_state_check_locks();
if (isset($r['locks'][$key])) return;
// MySQL 5.0은 연결당 이름 잠금 하나만 유지한다. 업무/포인트 연결과도 분리한다.
$mysqli = function_exists('mysqli_connect') && G5_MYSQLI_USE;
$host = G5_MYSQL_HOST;
if (substr($host, 0, 2) === 'p:') $host = substr($host, 2);
if ($mysqli) {
try { $link = @mysqli_connect($host, G5_MYSQL_USER, G5_MYSQL_PASSWORD, G5_MYSQL_DB); }
catch (Exception $e) { $link = false; }
} else {
$link = @mysql_connect($host, G5_MYSQL_USER, G5_MYSQL_PASSWORD, true);
}
if (!$link) shop_order_access_fail();
$lock = array('link'=>$link, 'mysqli'=>$mysqli);
// 실패나 예외에서도 이 연결만 닫아 획득 중인 잠금까지 해제한다.
if (empty($r['shutdown_registered'])) {
register_shutdown_function('shop_order_state_unlock_all');
$r['shutdown_registered'] = true;
}
$r['locks'][$key] = $lock;
if ($mysqli) {
try { $result = @mysqli_query($link, "SELECT GET_LOCK('$key', 3) AS acquired"); }
catch (Exception $e) { $result = false; }
$row = $result ? mysqli_fetch_assoc($result) : false;
} else {
$result = @mysql_query("SELECT GET_LOCK('$key', 3) AS acquired", $link);
$row = $result ? mysql_fetch_assoc($result) : false;
}
if (!$row || (int)$row['acquired'] !== 1) {
shop_order_state_unlock($kind, $id);
shop_order_access_fail();
}
}
// 잠금 연결이 끊겼거나 자동 재접속되면 기존 잠금을 보유한 것으로 취급하지 않는다.
function shop_order_state_check_locks()
{
$r =& shop_order_runtime();
foreach ($r['locks'] as $key=>$lock) {
$sql = "SELECT IS_USED_LOCK('$key') = CONNECTION_ID() AS owned";
if ($lock['mysqli']) {
try { $result = @mysqli_query($lock['link'], $sql); }
catch (Exception $e) { $result = false; }
$row = $result ? mysqli_fetch_assoc($result) : false;
} else {
$result = @mysql_query($sql, $lock['link']);
$row = $result ? mysql_fetch_assoc($result) : false;
}
if (!$row || (int)$row['owned'] !== 1) shop_order_access_fail();
}
}
function shop_order_state_unlock($kind, $id)
{
$r =& shop_order_runtime();
$key = shop_order_lock_name($kind, $id);
if (!isset($r['locks'][$key])) return;
$lock = $r['locks'][$key];
unset($r['locks'][$key]);
// 비영속 연결 종료 자체가 해당 연결의 잠금을 해제한다.
if ($lock['mysqli']) mysqli_close($lock['link']);
else mysql_close($lock['link']);
}
function shop_order_state_unlock_all()
{
$r =& shop_order_runtime();
foreach ($r['locks'] as $lock) {
if ($lock['mysqli']) mysqli_close($lock['link']);
else mysql_close($lock['link']);
}
$r['locks'] = array();
}
function shop_order_state_row($id)
{
if (!shop_order_access_id((string)$id)) shop_order_access_fail();
return sql_fetch("select * from ".shop_order_state_table()." where od_id='$id'", false);
}
function shop_order_state_meta($id)
{
if (!shop_order_access_id((string)$id)) shop_order_access_fail();
return sql_fetch("select od_id,token_hash,pg,status,expires from ".shop_order_state_table()." where od_id='$id'", false);
}
function shop_order_state_write($id, $fields)
{
shop_order_state_check_locks();
$allowed = array('status','payment_key','response_json','state_json','token_hash','expires','updated_at');
$set = array();
foreach ($fields as $key=>$value) {
if (!in_array($key, $allowed, true)) shop_order_access_fail();
$set[] = "$key='".sql_escape_string((string)$value)."'";
}
$set[] = 'updated_at='.G5_SERVER_TIME;
if (!sql_query("update ".shop_order_state_table()." set ".implode(',', $set)." where od_id='$id'", false)) shop_order_access_fail();
}
function shop_order_state_token($id)
{
$token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : '';
if ($token === '') {
$states = get_session('ss_order_data_access');
$token = isset($states[$id]['token']) ? $states[$id]['token'] : '';
}
if (!is_string($token) || !preg_match('/\A'.preg_quote((string)$id, '/').'\.[a-f0-9]{64}\z/D', $token)) return '';
return $token;
}
function shop_order_checkout_fields($id, $personal = false)
{
if (!shop_order_access_id((string)$id)) return '';
$map = get_session('ss_order_checkouts');
if (!is_array($map)) $map = array();
foreach ($map as $key=>$value) if ($value['expires'] <= G5_SERVER_TIME) unset($map[$key]);
if (count($map) >= 30) array_shift($map);
$nonce = shop_order_random_hex(24);
$map[(string)$id] = array('nonce'=>$nonce, 'expires'=>G5_SERVER_TIME+7200,
'cart'=>(string)get_session(get_session('ss_direct') ? 'ss_cart_direct' : 'ss_cart_id'),
'direct'=>(bool)get_session('ss_direct'), 'personal'=>$personal,
'personal_hash'=>get_session('ss_personalpay_hash'));
set_session('ss_order_checkouts', $map);
return '<input type="hidden" name="g5_checkout_id" value="'.get_text($id).'">'.
'<input type="hidden" name="g5_checkout_nonce" value="'.$nonce.'">';
}
function shop_order_checkout_restore()
{
if (!isset($_POST['g5_checkout_id'])) return; // 기존 사용자 스킨은 현재 세션 바인딩을 사용한다.
$id = $_POST['g5_checkout_id']; $nonce = isset($_POST['g5_checkout_nonce']) ? $_POST['g5_checkout_nonce'] : '';
$map = get_session('ss_order_checkouts');
if (!shop_order_access_id($id) || !is_string($nonce) || !isset($map[$id]) ||
!shop_order_equals($map[$id]['nonce'], $nonce) || $map[$id]['expires'] <= G5_SERVER_TIME) shop_order_access_fail();
$c = $map[$id];
if ($c['personal'] !== !empty($_POST['pp_id']) || ($c['personal'] && (string)$_POST['pp_id'] !== $id)) shop_order_access_fail();
set_session('ss_order_id', $id); set_session('ss_direct', $c['direct']);
set_session($c['direct'] ? 'ss_cart_direct' : 'ss_cart_id', $c['cart']);
if ($c['personal']) {
set_session('ss_personalpay_id', $id); set_session('ss_personalpay_hash', $c['personal_hash']);
}
}
function shop_order_state_save($id, $state)
{
shop_order_state_lock('order', $id);
$previous = shop_order_state_meta($id);
if ($previous) {
$token = shop_order_state_token($id);
if (!$token || !shop_order_equals($previous['token_hash'], hash('sha256', $token)) ||
$previous['status'] !== 'pending') shop_order_access_fail();
}
$token = $id.'.'.shop_order_random_hex(32);
$state['version'] = 2;
unset($state['token'], $state['payment_key']);
$json = json_encode($state);
if ($json === false) shop_order_access_fail();
$table = shop_order_state_table();
$fields = "token_hash='".hash('sha256', $token)."', pg='".sql_escape_string($state['pg'])."', ".
"cart_id='".sql_escape_string($state['cart'])."', status='pending', expires=".(int)$state['expires'].
", state_json='".sql_escape_string($json)."', payment_key='', response_json='', updated_at=".G5_SERVER_TIME;
$query = $previous ? "update $table set $fields where od_id='$id'" : "insert into $table set od_id='$id', $fields";
if (!sql_query($query, false)) shop_order_access_fail();
$state['token'] = $token;
$states = get_session('ss_order_data_access'); if (!is_array($states)) $states = array();
$states[$id] = $state; set_session('ss_order_data_access', $states);
header('Cache-Control: no-store, private'); header('Referrer-Policy: no-referrer');
header('X-G5-Order-State: '.$token);
return $state;
}
function shop_order_state_can_save($id)
{
shop_order_state_lock('order', $id);
$old = shop_order_state_meta($id);
if (!$old) return;
$token = shop_order_state_token($id);
if ($old['status'] !== 'pending' || !$token || !shop_order_equals($old['token_hash'], hash('sha256', $token))) shop_order_access_fail();
}
function shop_order_state_load($id, $pg)
{
global $g5, $member, $is_member, $is_guest;
if (!shop_order_access_id($id)) shop_order_access_fail();
shop_order_state_lock('order', $id);
$row = shop_order_state_meta($id);
if (!$row) {
// 1차 패치가 만든 세션/본문 해시가 검증되는 요청만 전환한다.
// PAYREQ_MAP 또는 주문번호만 있는 과거 요청은 legacy 검증에서도 거부된다.
$data = shop_order_access_load_legacy($id, $pg);
$states = get_session('ss_order_data_access');
shop_order_state_save($id, $states[$id]);
$row = shop_order_state_meta($id);
}
$token = shop_order_state_token($id);
if (!$token || $row['pg'] !== $pg || !shop_order_equals($row['token_hash'], hash('sha256', $token))) shop_order_access_fail();
if (!in_array($row['status'], array('pending','approving','approved','finalizing'), true)) shop_order_access_fail();
if ((int)$row['expires'] <= G5_SERVER_TIME && $row['status'] === 'pending') shop_order_access_fail();
$row = shop_order_state_row($id);
$state = json_decode($row['state_json'], true);
if (!is_array($state) || !isset($state['version']) || $state['version'] !== 2) shop_order_access_fail();
$current = isset($member['mb_id']) ? (string)$member['mb_id'] : '';
if ($current !== '' && $current !== $state['member']) shop_order_access_fail();
// 결제 토큰은 해당 주문 처리에만 회원 컨텍스트를 제공한다. 로그인 세션을 발급하지 않는다.
if ($current === '' && $state['member'] !== '') {
$owner = get_member($state['member']);
if (empty($owner['mb_id']) || !empty($owner['mb_leave_date']) || !empty($owner['mb_intercept_date'])) shop_order_access_fail();
$member = $owner; $is_member = true; $is_guest = false;
}
if ($state['personal']) {
$done = sql_fetch("select pp_tno, pp_use, pp_price, pp_time from {$g5['g5_shop_personalpay_table']} where pp_id='$id'");
if (!$done || !$done['pp_use'] || $done['pp_tno'] ||
!shop_order_equals($state['personal_hash'], md5($id.$done['pp_price'].$done['pp_time']))) shop_order_access_fail();
} else {
$done = sql_fetch("select od_id from {$g5['g5_shop_order_table']} where od_id='$id'");
if (!empty($done['od_id'])) shop_order_access_fail(); // 부분 저장도 자동 재승인하지 않는다.
}
if ($row['status'] === 'finalizing') {
if ($state['personal']) shop_order_access_fail();
$cart_id = sql_escape_string($state['cart']);
$remaining = sql_fetch("select count(*) as cnt from {$g5['g5_shop_cart_table']} where od_id='$cart_id' and ct_select='1' and ct_status='쇼핑'");
if (empty($remaining['cnt']) || $row['response_json'] === '') shop_order_access_fail();
shop_order_state_write($id, array('status'=>'approved'));
$row['status'] = 'approved';
}
$rows = sql_query("select cart_id,mb_id,dt_pg,dt_time from {$g5['g5_shop_order_data_table']} where od_id='$id'");
if (sql_num_rows($rows) !== 1) shop_order_access_fail();
$meta = sql_fetch_array($rows);
if ((string)$meta['cart_id'] !== $state['cart'] || $meta['mb_id'] !== $state['member'] ||
$meta['dt_pg'] !== $pg || $meta['dt_time'] !== $state['time']) shop_order_access_fail();
$temp = sql_fetch("select dt_data from {$g5['g5_shop_order_data_table']} where od_id='$id'");
if (empty($temp['dt_data']) || !shop_order_equals($state['digest'], hash('sha256', $temp['dt_data']))) shop_order_access_fail();
$data = shop_order_decode_data($temp['dt_data']);
if (!is_array($data) || !empty($data['pp_id']) !== $state['personal'] ||
($state['personal'] && (string)$data['pp_id'] !== $id)) shop_order_access_fail();
unset($data['od_pwd']);
$state['token'] = $token;
if ($row['payment_key'] !== '') $state['payment_key'] = $row['payment_key'];
$states = get_session('ss_order_data_access'); if (!is_array($states)) $states = array();
$states[$id] = $state; set_session('ss_order_data_access', $states);
set_session('ss_order_id', $id); set_session('ss_direct', $state['direct']);
set_session($state['direct'] ? 'ss_cart_direct' : 'ss_cart_id', $state['cart']);
if ($state['personal']) { set_session('ss_personalpay_id', $id); set_session('ss_personalpay_hash', $state['personal_hash']); }
$r =& shop_order_runtime(); $r['rows'][$id] = $row; $r['active'] = $id;
$data['g5_order_state'] = $token;
return $data;
}
function shop_order_state_prepare($personal)
{
global $default;
$token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : '';
$id = (string)get_session($personal ? 'ss_personalpay_id' : 'ss_order_id');
if ($token !== '') {
if (!is_string($token) || !preg_match('/\A([0-9]{1,20})\.[a-f0-9]{64}\z/D', $token, $m)) shop_order_access_fail();
$id = $m[1];
}
$states = get_session('ss_order_data_access');
if ($token === '' && empty($states[$id])) return; // 임시 저장 없는 기존 PG/무통장 흐름
$row = shop_order_state_meta($id);
$pg = $row ? $row['pg'] : (isset($states[$id]['pg']) ? $states[$id]['pg'] : '');
$data = shop_order_access_load($id, $pg);
if (!empty($data['pp_id']) !== $personal) shop_order_access_fail();
$default['de_pg_service'] = $pg;
// 결제 복귀의 업무 필드는 저장 당시 서버 데이터만 사용한다.
$fields = array_merge(shop_order_data_fields($personal ? 1 : 0), array('pp_id','sw_direct','od_temp_point','od_coupon','od_send_coupon','cp_id','cp_price','it_id','od_cp_id','sc_cp_id','od_hope_date','ad_default','ad_subject','good_mny','amountValue','comm_tax_mny','comm_vat_mny','comm_free_mny'));
foreach ($fields as $key) {
$value = isset($data[$key]) ? $data[$key] : '';
$_POST[$key] = $value; $GLOBALS[$key] = $value;
}
if (!$personal) {
$s = get_session('ss_order_data_access');
shop_order_state_lock('cart', $s[$id]['cart']);
$cart = sql_escape_string($s[$id]['cart']);
$busy = sql_fetch("select od_id from ".shop_order_state_table()." where cart_id='$cart' and od_id<>'$id' and status in ('approving','approved','finalizing','unknown','cancel_pending') limit 1");
if ($busy) shop_order_access_fail();
}
}
function shop_order_state_begin($id, $key, $expected)
{
shop_order_state_lock('order', $id);
$row = shop_order_state_row($id);
if (!$row || !in_array($row['status'], array('pending','approving','approved'), true)) shop_order_access_fail();
if ($row['payment_key'] !== '' && !shop_order_equals($row['payment_key'], $key)) shop_order_access_fail();
$state = json_decode($row['state_json'], true);
if (!is_array($state) || (int)$expected <= 0 || (isset($state['expected_amount']) && (int)$state['expected_amount'] !== (int)$expected)) shop_order_access_fail();
if ($row['status'] === 'pending') {
$state['expected_amount'] = (int)$expected;
$state['approval_started'] = G5_SERVER_TIME;
shop_order_state_write($id, array('status'=>'approving','payment_key'=>$key,'state_json'=>json_encode($state)));
}
return $row;
}
function shop_order_state_approved($id, $response)
{
$json = json_encode($response);
if ($json === false) shop_order_access_fail();
shop_order_state_write($id, array('status'=>'approved','response_json'=>$json));
}
function shop_order_state_finalizing()
{
$r =& shop_order_runtime();
if ($r['active'] === '') return;
$row = shop_order_state_row($r['active']);
if ($row && $row['status'] === 'approved') shop_order_state_write($r['active'], array('status'=>'finalizing'));
}
function shop_order_state_complete($id)
{
$row = shop_order_state_row($id);
if (!$row) return;
shop_order_state_write($id, array('status'=>'completed','state_json'=>'','response_json'=>'','token_hash'=>'','payment_key'=>''));
}
function shop_order_state_cancel($status)
{
$r =& shop_order_runtime();
if ($r['active'] !== '') shop_order_state_write($r['active'], array('status'=>$status));
}
function shop_order_toss_approve($toss, $id, $key, $expected)
{
$row = shop_order_state_begin($id, $key, $expected);
$toss->headers[] = 'Idempotency-Key: g5-confirm-'.hash('sha256', (defined('G5_MYSQL_DB') ? G5_MYSQL_DB : '').G5_SHOP_TABLE_PREFIX.$id.$key);
// 승인 응답을 놓쳤거나 저장 후 중단된 경우에도 먼저 PG에 현재 상태를 조회한다.
if ($row['status'] !== 'pending') {
if (!$toss->getPaymentByOrderId($id)) shop_order_access_fail();
$response = $toss->responseData;
if (!isset($response['orderId'],$response['paymentKey'],$response['totalAmount'],$response['status']) ||
$response['orderId'] !== $id || $response['paymentKey'] !== $key || (int)$response['totalAmount'] !== (int)$expected) shop_order_access_fail();
if (in_array($response['status'], array('CANCELED','ABORTED','EXPIRED'), true)) {
shop_order_state_write($id, array('status'=>$response['status'] === 'CANCELED' ? 'cancelled' : 'failed'));
shop_order_access_fail();
}
if ($response['status'] === 'IN_PROGRESS' && $row['status'] === 'approving') {
$state = json_decode($row['state_json'], true);
// PG가 미승인을 확인한 경우에만 동일 본문/멱등 키로 재시도한다(15일보다 짧게 제한).
if (empty($state['approval_started']) || G5_SERVER_TIME - $state['approval_started'] > 14*86400 || !$toss->approvePayment()) shop_order_access_fail();
$response = $toss->responseData;
}
} else {
if (!$toss->approvePayment()) {
// 네트워크 오류를 결제 실패로 단정하지 않는다. 다음 시도는 조회부터 시작한다.
shop_order_access_fail();
}
$response = $toss->responseData;
}
if (!isset($response['orderId'],$response['paymentKey'],$response['totalAmount'],$response['status'],$response['method']) ||
$response['orderId'] !== $id || $response['paymentKey'] !== $key || (int)$response['totalAmount'] !== (int)$expected ||
!($response['status'] === 'DONE' || ($response['status'] === 'WAITING_FOR_DEPOSIT' && $response['method'] === '가상계좌'))) shop_order_access_fail();
shop_order_state_approved($id, $response);
return true;
}
function shop_order_kcp_result_fields()
{
return explode(' ', 'tno amount pnt_issue card_cd card_name app_time app_no noinf quota partcanc_yn bankname bank_name bank_code depositor account pt_idno pnt_amount pnt_app_time pnt_app_no add_pnt use_pnt rsv_pnt commid mobile_no tk_van_code tk_app_no cash_authno cash_tr_code escw_yn res_cd res_msg app_kakaomny_time kakaomny_mny kcp_pay_method od_other_pay_type');
}
function shop_order_state_abort_pending()
{
$token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : '';
if (!is_string($token) || !preg_match('/\A([0-9]{1,20})\.[a-f0-9]{64}\z/D', $token, $m)) shop_order_access_fail();
shop_order_state_lock('order', $m[1]);
$row = shop_order_state_meta($m[1]);
if (!$row || !shop_order_equals($row['token_hash'], hash('sha256', $token))) shop_order_access_fail();
// PG 승인 여부가 불명확한 상태에는 실패 복귀가 와도 데이터를 지우지 않는다.
if ($row['status'] === 'pending') shop_order_state_write($m[1], array('status'=>'failed'));
shop_order_access_fail();
}
+3
View File
@@ -0,0 +1,3 @@
# Apache 2.4: 마이그레이션 파일의 HTTP 접근을 차단한다.
# 서버 내부에서 PHP가 SQL 파일을 읽는 동작에는 영향을 주지 않는다.
Require all denied
@@ -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;
+1 -1
View File
@@ -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 );
}
+6 -7
View File
@@ -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로 세팅 해주시기 바랍니다.-->
+34 -1
View File
@@ -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))));
+15 -6
View File
@@ -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);
}
});
+16 -5
View File
@@ -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') {
@@ -577,6 +582,9 @@ $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) {
@@ -592,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)));
}
// 주문번호를 얻는다.
@@ -680,6 +688,7 @@ $sql = " insert {$g5['g5_shop_order_table']}
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',
@@ -700,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 가 되었는지 한번더 체크한다.
@@ -717,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';
@@ -1053,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', '');
// 기존자료 세션에서 제거
+13 -4
View File
@@ -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);
}
});
+9
View File
@@ -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');
@@ -216,11 +218,15 @@ $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',
@@ -233,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);
// 결제정보 입력 오류시 결제 취소
@@ -343,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', '');
+8 -34
View File
@@ -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 {
+9 -62
View File
@@ -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,
+17 -36
View File
@@ -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
View File
@@ -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'])
//==============================================================================
// 쇼핑몰 필수 실행코드 모음 끝
//==============================================================================;
//==============================================================================;
+51 -14
View File
@@ -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('');
+4 -1
View File
@@ -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');
+14 -11
View File
@@ -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);
}
});
+15 -5
View File
@@ -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') {
@@ -534,6 +539,9 @@ $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 : '';
@@ -553,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');
@@ -634,6 +643,7 @@ $sql = " insert {$g5['g5_shop_order_table']}
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',
@@ -653,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 가 되었는지 한번더 체크한다.
@@ -984,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', '');
// 기존자료 세션에서 제거
+12 -9
View File
@@ -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);
}
});
+13
View File
@@ -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');
@@ -195,6 +197,9 @@ $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);
// 결제정보 입력
@@ -203,6 +208,7 @@ $sql = " update {$g5['g5_shop_personalpay_table']}
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',
@@ -215,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);
// 결제정보 입력 오류시 결제 취소
@@ -314,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', '');
+5 -175
View File
@@ -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';
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+3 -74
View File
@@ -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);
+18 -3
View File
@@ -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
View File
@@ -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'] : '';
+17 -36
View File
@@ -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 }
+10 -37
View File
@@ -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 } ?>
+44
View File
@@ -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; });
+79
View File
@@ -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 }
@@ -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 }
+10 -37
View File
@@ -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 } ?>
+86
View File
@@ -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
View File
@@ -2,7 +2,7 @@
if (!defined('_GNUBOARD_')) exit; // 개별 페이지 접근 불가
define('G5_VERSION', '그누보드5');
define('G5_GNUBOARD_VER', '5.6.38');
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의 버전은 의미가 없습니다.