fix(ecommerce,board,tosspayments,pay_kginicis,pay_nhnkcp,pay_nicepayments,core,basic): 공개 이슈 잔여 결함 5건 해소 — 상점 주소 SSoT·게시판 계약/안내/상한/색인
공개 이슈(gnuboard/g7) 17건 전수 대조에서 "진짜 누락"으로 판정된 5건을 해소한다.
— 서버가 만들어 내보내는 상점 주소가 각 지점에서 `/shop` 리터럴이나
`"/{$routePath}/..."` 조립으로 흩어져 있었다. 기본값이 아닌 상점에서는 그 주소가
존재하지 않는 화면을 가리키는데, 서버는 문자열을 만들어 내려보냈을 뿐이라 예외도
404 로그도 남지 않는다 — 링크만 조용히 죽는다. `ShopPathResolver` 를 단일 해석
지점으로 두고 주문 완료 이동·비회원 조회 안내·알림 메일·통합검색·상품문의·SEO
캐시·사이트맵 전 지점을 경유시켰다. 같은 값에 종속된 PG 4종 리다이렉트는
`{shopBase}` 자리표시자로, 편집기 상태 scope 는 선택 세그먼트 토큰 `/*?`
(engine-v1.58.0)로 해소했다. 기설치본은 업그레이드 스텝이 백필한다.
M-02 — 답변 깊이 상한이 config 는 1~10 인데 컬럼 comment 만 1~5 로 남아,
comment 에서 자동 생성되는 계약 문서 4곳이 실제보다 좁은 상한을 공개하고 있었다.
L-01 — 관리자 게시글 폼에 첨부 안내가 아예 없었고, 대신 사장된 고정 문구
("최대 5개 파일, 각 10MB")가 남아 있었다. 게시판마다 다른 실제 설정값을 렌더한다.
— 목록 1페이지가 공지 전량을 상한 없이 읽던 것에 안전상한을 두고(구조 무변경,
limit 부가만), 조회수 정렬 목록 술어를 덮는 복합 색인을 신설했다.
브라우저 실측 검수에서 발견한 사전 결함 1건(사용자 게시판 목록이 URL 정렬
파라미터를 데이터소스로 전달하지 않던 문제)도 함께 고쳤다. 폴백을 빈 문자열로 둬
URL 에 정렬이 없을 때 게시판 설정을 덮어쓰지 않게 했다.
This commit is contained in:
1 parent
3715be5e51
commit
f9c4c025cb
138 files changed
+3907
-361
No files matched your search
@@ -22,6 +22,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 결제를 마치거나 결제에 실패한 구매자가 존재하지 않는 페이지로 이동하던 문제를 수정했습니다. 이동 주소가 기본 주소로 고정돼 있었기 때문이며, 이제 상점 주소 설정을 따라갑니다. 이미 운영 중인 사이트도 업데이트 시 자동으로 반영되며, 이동 주소를 직접 입력해 두었다면 그 값은 그대로 유지됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 안내 메시지가 번역되지 않고 내부 코드 그대로 표시되던 문제를 수정했습니다. 일본 편의점결제(CVS) 입금 통보 테스트·입금기한 만료 처리뿐 아니라 에스크로 배송 등록·구매확인 거부, 거래 조회, 정산 마감 등 이 플러그인의 모든 처리 결과에서 사용 중인 언어에 맞는 안내가 표시됩니다.
|
||||
- 일본(CBT) 설정을 운영 모드로 전환할 때 상점 정보나 서명키가 비어 있어도 저장되던 문제를 수정했습니다. 필수값이 비었거나 샘플값이 그대로면 저장이 중단되고 안내 메시지가 표시됩니다.
|
||||
- 이 플러그인이 설치된 사이트에서 관리자 주문 목록의 '일부취소' 표시와 적립예정 포인트·마일리지 사용 금액, 주문상품의 추가옵션 요약이 보이지 않던 문제를 수정했습니다. 결제 정보 칸에 테스트결제 표시를 덧붙이는 과정에서 목록 표 구성 전체를 옛 버전으로 되돌려 놓고 있었습니다.
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"japan_contact_email": "support@example.com",
|
||||
"japan_contact_phone": "0120-123-456",
|
||||
"japan_contact_opening_hours": "10:00-18:00",
|
||||
"redirect_success_url": "/shop/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "/shop/checkout",
|
||||
"redirect_success_url": "{shopBase}/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "{shopBase}/checkout",
|
||||
"easy_pay_allow_with_other_pg": false,
|
||||
"easy_pay_samsung_pay": false,
|
||||
"easy_pay_naverpay": false,
|
||||
|
||||
@@ -125,7 +125,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| product_id | integer | `114` | product 식별자 (연관 리소스 참조) |
|
||||
| product_code | string | `CBT-TEST-20260712085816` | 생성된 테스트 상품의 상품코드. `CBT-TEST-` 접두사에 생성 시각(YmdHis)을 붙여 자동 부여되며, SKU는 여기에 `KGINICIS-` 접두사를 더해 만들어진다. |
|
||||
| admin_url | string | `/admin/ecommerce/products/114/edit` | admin URL |
|
||||
| shop_url | string | `/shop/products/114?locale=ja` | shop URL |
|
||||
| shop_url | string | `/shop/products/114?locale=ja` | 생성된 테스트 상품의 상점 화면 주소. 앞부분은 상점 주소 설정을 따른다 — 기본은 `/shop`, 이커머스 모듈 설정 `basic_info.route_path` 를 바꾸면 그 값(`/store/...`), `basic_info.no_route` 를 켜면 세그먼트 없이 루트에 붙는다(`/products/114?locale=ja`). 예시는 기본 설정 기준. |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -392,8 +392,8 @@ _이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저
|
||||
|
||||
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
|
||||
| --- | --- | --- |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`invalid_params` \| `missing_fields` \| `auth_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (PG 결과 메시지), `orderId` |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `{shopBase}/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `{shopBase}/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`invalid_params` \| `missing_fields` \| `auth_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (PG 결과 메시지), `orderId` |
|
||||
| 실패 URL (쿼리 없음) | 사용자가 결제창을 닫은 취소(`2001` / `0021` / `0022` / 빈값, 또는 결과 메시지에 '취소'·'사용자' 포함) | 없음 (조용한 복귀) |
|
||||
|
||||
**응답 예시**
|
||||
@@ -461,8 +461,8 @@ _이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저
|
||||
|
||||
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
|
||||
| --- | --- | --- |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | CBT 서버 승인 성공(결제완료 또는 편의점 입금대기 등록), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·MID 불일치·검증 실패·승인 실패 | `error` (`invalid_params` \| `mid_mismatch` \| `order_not_found` \| `cbt_failed` 등), `message` (PG 결과 메시지), `orderId` |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `{shopBase}/orders/{orderId}/complete`) | CBT 서버 승인 성공(결제완료 또는 편의점 입금대기 등록), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `{shopBase}/checkout`) | 인증 실패·MID 불일치·검증 실패·승인 실패 | `error` (`invalid_params` \| `mid_mismatch` \| `order_not_found` \| `cbt_failed` 등), `message` (PG 결과 메시지), `orderId` |
|
||||
| 실패 URL (쿼리 없음) | 사용자 취소(취소 코드 또는 일본어·한국어 취소 문구) | 없음 (조용한 복귀) |
|
||||
|
||||
**응답 예시**
|
||||
@@ -543,8 +543,8 @@ _이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저
|
||||
|
||||
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
|
||||
| --- | --- | --- |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | CBT 서버 승인 성공(결제완료 또는 편의점 입금대기 등록), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·MID 불일치·검증 실패·승인 실패 | `error` (`invalid_params` \| `mid_mismatch` \| `order_not_found` \| `cbt_failed` 등), `message` (PG 결과 메시지), `orderId` |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `{shopBase}/orders/{orderId}/complete`) | CBT 서버 승인 성공(결제완료 또는 편의점 입금대기 등록), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `{shopBase}/checkout`) | 인증 실패·MID 불일치·검증 실패·승인 실패 | `error` (`invalid_params` \| `mid_mismatch` \| `order_not_found` \| `cbt_failed` 등), `message` (PG 결과 메시지), `orderId` |
|
||||
| 실패 URL (쿼리 없음) | 사용자 취소(취소 코드 또는 일본어·한국어 취소 문구) | 없음 (조용한 복귀) |
|
||||
|
||||
**응답 예시**
|
||||
@@ -968,8 +968,8 @@ _이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저
|
||||
|
||||
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
|
||||
| --- | --- | --- |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`order_id_missing` \| `missing_fields` \| `req_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (`P_RMESG1` 결과 메시지), `orderId` |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `{shopBase}/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `{shopBase}/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`order_id_missing` \| `missing_fields` \| `req_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (`P_RMESG1` 결과 메시지), `orderId` |
|
||||
| 실패 URL (쿼리 없음) | 사용자가 결제창을 닫은 취소(`P_RMESG1` 에 '사용자가 결제를 취소' 등 취소 문구 포함) | 없음 (조용한 복귀) |
|
||||
|
||||
**응답 예시**
|
||||
@@ -1046,8 +1046,8 @@ _이 엔드포인트는 JSON `data` 를 반환하지 않습니다. 브라우저
|
||||
|
||||
| 리다이렉트 대상 | 조건 | 쿼리 파라미터 |
|
||||
| --- | --- | --- |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `/shop/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `/shop/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`order_id_missing` \| `missing_fields` \| `req_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (`P_RMESG1` 결과 메시지), `orderId` |
|
||||
| 성공 URL (`redirect_success_url`, 기본 `{shopBase}/orders/{orderId}/complete`) | 서버 승인 성공(결제완료 또는 가상계좌 발급), 이미 결제완료된 거래(재전송) | 없음 (주문번호는 경로에 치환) |
|
||||
| 실패 URL (`redirect_fail_url`, 기본 `{shopBase}/checkout`) | 인증 실패·검증 실패·승인 실패 | `error` (`order_id_missing` \| `missing_fields` \| `req_url_invalid` \| `order_not_found` \| `amount_mismatch` 등), `message` (`P_RMESG1` 결과 메시지), `orderId` |
|
||||
| 실패 URL (쿼리 없음) | 사용자가 결제창을 닫은 취소(`P_RMESG1` 에 취소 문구 포함) | 없음 (조용한 복귀) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -204,16 +204,16 @@ class Plugin extends AbstractPlugin
|
||||
],
|
||||
'redirect_success_url' => [
|
||||
'type' => 'string',
|
||||
'default' => '/shop/orders/{orderId}/complete',
|
||||
'default' => '{shopBase}/orders/{orderId}/complete',
|
||||
'label' => ['ko' => '결제 성공 리다이렉트 URL', 'en' => 'Payment Success Redirect URL'],
|
||||
'hint' => [
|
||||
'ko' => '상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.',
|
||||
'en' => 'Supports relative paths or full URLs. {orderId} will be replaced with the actual order number.',
|
||||
'ko' => '{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL(https://...)을 직접 넣어도 됩니다. {orderId}는 주문번호로 자동 치환됩니다.',
|
||||
'en' => '{shopBase} is filled in from the storefront address setting. You may also enter a full URL (https://...). {orderId} will be replaced with the actual order number.',
|
||||
],
|
||||
],
|
||||
'redirect_fail_url' => [
|
||||
'type' => 'string',
|
||||
'default' => '/shop/checkout',
|
||||
'default' => '{shopBase}/checkout',
|
||||
'label' => ['ko' => '결제 실패 리다이렉트 URL', 'en' => 'Payment Failure Redirect URL'],
|
||||
'hint' => [
|
||||
'ko' => '상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.',
|
||||
@@ -290,8 +290,8 @@ class Plugin extends AbstractPlugin
|
||||
'japan_contact_email' => 'support@example.com',
|
||||
'japan_contact_phone' => '0120-123-456',
|
||||
'japan_contact_opening_hours' => '10:00-18:00',
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
'redirect_success_url' => '{shopBase}/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '{shopBase}/checkout',
|
||||
'easy_pay_allow_with_other_pg' => false,
|
||||
'easy_pay_samsung_pay' => false,
|
||||
'easy_pay_naverpay' => false,
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
"use_escrow_hint": "When enabled, PC payments append useescrow to acceptmethod and mobile payments append useescrow=Y to P_RESERVED.",
|
||||
"section_redirect": "Redirect URLs",
|
||||
"redirect_success_url": "Payment Success Redirect URL",
|
||||
"redirect_success_url_hint": "Supports relative paths or full URLs. {orderId} will be replaced with the actual order number.",
|
||||
"redirect_success_url_hint": "{shopBase} is filled in from the storefront address setting. You may also enter a full URL (https://...). {orderId} will be replaced with the actual order number.",
|
||||
"redirect_fail_url": "Payment Failure Redirect URL",
|
||||
"redirect_fail_url_hint": "Supports relative paths or full URLs. Error details are appended as query parameters.",
|
||||
"section_vbank_notify": "Virtual Account Deposit Notification URL (PC)",
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
"use_escrow_hint": "활성화 시 PC는 acceptmethod에 useescrow가 추가되고, 모바일은 P_RESERVED에 useescrow=Y가 추가됩니다.",
|
||||
"section_redirect": "리다이렉트 URL",
|
||||
"redirect_success_url": "결제 성공 리다이렉트 URL",
|
||||
"redirect_success_url_hint": "상대 경로(/shop/...) 또는 전체 URL 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.",
|
||||
"redirect_success_url_hint": "{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL(https://...)을 직접 넣어도 됩니다. {orderId}는 주문번호로 자동 치환됩니다.",
|
||||
"redirect_fail_url": "결제 실패 리다이렉트 URL",
|
||||
"redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.",
|
||||
"section_vbank_notify": "가상계좌 입금통보 URL (PC)",
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@ use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* KG 이니시스 CBT (일본 결제) 테스트용 상품 자동 생성 컨트롤러
|
||||
@@ -80,7 +81,8 @@ class AdminCbtTestProductController extends AdminBaseController
|
||||
'product_id' => $product->id,
|
||||
'product_code' => $product->product_code,
|
||||
'admin_url' => '/admin/ecommerce/products/'.$product->id.'/edit',
|
||||
'shop_url' => '/shop/products/'.$product->id.'?locale=ja',
|
||||
// 상점 주소는 운영자 설정이라 리터럴로 두면 주소를 바꾼 상점에서 없는 페이지를 가리킨다.
|
||||
'shop_url' => ShopPathResolver::path('products/'.$product->id).'?locale=ja',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('KG Inicis CBT: test product creation failed', [
|
||||
|
||||
@@ -14,10 +14,11 @@ use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\CbtCallbackRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\CbtCallbackRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\CbtReconciliationService;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
use Plugins\Sirsoft\PayKginicis\Support\ShopRedirectUrl;
|
||||
|
||||
/**
|
||||
* KG 이니시스 CBT (Cross Border Trade) 일본 결제 콜백 컨트롤러
|
||||
@@ -693,15 +694,15 @@ class CbtCallbackController
|
||||
private function resolveSuccessUrl(string $orderId): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? ShopRedirectUrl::DEFAULT_SUCCESS_URL;
|
||||
|
||||
return $this->absolutize(str_replace('{orderId}', $orderId, $urlTemplate));
|
||||
return $this->absolutize(ShopRedirectUrl::resolve($urlTemplate, ['{orderId}' => $orderId]));
|
||||
}
|
||||
|
||||
private function resolveFailUrl(array $queryParams = []): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$baseUrl = $this->absolutize($settings['redirect_fail_url'] ?? '/shop/checkout');
|
||||
$baseUrl = $this->absolutize(ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
|
||||
if (empty($queryParams)) {
|
||||
return $baseUrl;
|
||||
|
||||
+73
-71
@@ -6,6 +6,7 @@ namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
|
||||
@@ -19,6 +20,7 @@ use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\MobileCallbackRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
use Plugins\Sirsoft\PayKginicis\Support\ShopRedirectUrl;
|
||||
|
||||
/**
|
||||
* KG 이니시스 모바일 결제 콜백 컨트롤러
|
||||
@@ -90,23 +92,23 @@ class MobileCallbackController
|
||||
* handle
|
||||
*
|
||||
* @param MobileCallbackRequest $request
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
public function handle(MobileCallbackRequest $request): \Illuminate\Http\RedirectResponse
|
||||
public function handle(MobileCallbackRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$selectedEasyPayMethod = $this->resolveSelectedEasyPayMethod($request);
|
||||
|
||||
$pStatus = $validated['P_STATUS'];
|
||||
$pStatus = $validated['P_STATUS'];
|
||||
// KG 이니시스 모바일 메뉴얼(STEP 2) 표준 응답에는 P_OID 가 없으므로 P_NEXT_URL 쿼리스트링의
|
||||
// orderId 를 fallback 으로 사용한다. 일부 PG 환경에서 P_OID 를 echo 하면 우선 채택.
|
||||
$moid = $validated['P_OID'] ?? $request->query('orderId') ?? null;
|
||||
$pAmt = $validated['P_AMT'] ?? null;
|
||||
$moid = $validated['P_OID'] ?? $request->query('orderId') ?? null;
|
||||
$pAmt = $validated['P_AMT'] ?? null;
|
||||
|
||||
Log::info('KG Inicis mobile: callback received', [
|
||||
'order_id' => $moid,
|
||||
'P_STATUS' => $pStatus,
|
||||
'idc_name' => $validated['idc_name'] ?? null,
|
||||
'order_id' => $moid,
|
||||
'P_STATUS' => $pStatus,
|
||||
'idc_name' => $validated['idc_name'] ?? null,
|
||||
'P_REQ_URL' => $validated['P_REQ_URL'] ?? null,
|
||||
'input_keys' => array_keys($request->all()),
|
||||
'query_keys' => array_keys($request->query()),
|
||||
@@ -127,9 +129,9 @@ class MobileCallbackController
|
||||
$isUserCancel = $this->isUserCancelMessage($pMesg);
|
||||
|
||||
Log::info('KG Inicis mobile: auth not success', [
|
||||
'P_OID' => $moid,
|
||||
'P_STATUS' => $pStatus,
|
||||
'P_RMESG1' => $pMesg,
|
||||
'P_OID' => $moid,
|
||||
'P_STATUS' => $pStatus,
|
||||
'P_RMESG1' => $pMesg,
|
||||
'is_user_cancel' => $isUserCancel,
|
||||
]);
|
||||
|
||||
@@ -139,21 +141,21 @@ class MobileCallbackController
|
||||
}
|
||||
|
||||
return redirect($this->resolveFailUrl([
|
||||
'error' => $pStatus,
|
||||
'error' => $pStatus,
|
||||
'message' => $pMesg,
|
||||
'orderId' => $moid,
|
||||
]));
|
||||
}
|
||||
|
||||
$pTid = $validated['P_TID'] ?? null;
|
||||
$pTid = $validated['P_TID'] ?? null;
|
||||
$idcName = $validated['idc_name'] ?? null;
|
||||
$reqUrl = $validated['P_REQ_URL'] ?? null;
|
||||
$reqUrl = $validated['P_REQ_URL'] ?? null;
|
||||
|
||||
if (! $pTid || ! $idcName || ! $reqUrl) {
|
||||
Log::error('KG Inicis mobile: missing required fields', [
|
||||
'P_OID' => $moid,
|
||||
'P_OID' => $moid,
|
||||
'idc_name' => $idcName,
|
||||
'P_TID' => $pTid,
|
||||
'P_TID' => $pTid,
|
||||
'P_REQ_URL' => $reqUrl,
|
||||
]);
|
||||
|
||||
@@ -163,7 +165,7 @@ class MobileCallbackController
|
||||
// P_REQ_URL 화이트리스트 검증 (모바일 IDC URL, SSRF 방어)
|
||||
if (! $this->apiService->isValidIdcAuthUrl($idcName, $reqUrl)) {
|
||||
Log::error('KG Inicis mobile: P_REQ_URL not in whitelist (possible SSRF attempt)', [
|
||||
'P_OID' => $moid,
|
||||
'P_OID' => $moid,
|
||||
'idc_name' => $idcName,
|
||||
'received' => $reqUrl,
|
||||
]);
|
||||
@@ -196,7 +198,7 @@ class MobileCallbackController
|
||||
|
||||
if ($resultStatus !== '00') {
|
||||
Log::warning('KG Inicis mobile: server approve failed', [
|
||||
'P_OID' => $moid,
|
||||
'P_OID' => $moid,
|
||||
'P_STATUS' => $resultStatus,
|
||||
'P_RMESG1' => $result['P_RMESG1'] ?? '',
|
||||
]);
|
||||
@@ -204,15 +206,15 @@ class MobileCallbackController
|
||||
$this->orderService->failPayment($order, $resultStatus, $result['P_RMESG1'] ?? '');
|
||||
|
||||
return redirect($this->resolveFailUrl([
|
||||
'error' => $resultStatus,
|
||||
'error' => $resultStatus,
|
||||
'message' => $result['P_RMESG1'] ?? '',
|
||||
'orderId' => $moid,
|
||||
]));
|
||||
}
|
||||
|
||||
$tid = $result['P_TID'] ?? $pTid;
|
||||
$tid = $result['P_TID'] ?? $pTid;
|
||||
$totPrice = (int) ($result['P_AMT'] ?? $pAmt ?? 0);
|
||||
$payType = (string) ($result['P_TYPE'] ?? '');
|
||||
$payType = (string) ($result['P_TYPE'] ?? '');
|
||||
|
||||
// 가상계좌: completePayment 없이 발급 정보만 저장 (입금 통보 시점에 completePayment)
|
||||
if (strcasecmp($payType, 'VBank') === 0) {
|
||||
@@ -258,24 +260,24 @@ class MobileCallbackController
|
||||
]);
|
||||
|
||||
$this->orderService->completePayment($order, [
|
||||
'transaction_id' => $tid,
|
||||
'card_approval_number' => $result['P_APPL_NUM'] ?? null,
|
||||
'card_number_masked' => $result['P_CARD_NUM'] ?? null,
|
||||
'card_name' => $result['P_CARD_ISSUER_NAME'] ?? null,
|
||||
'transaction_id' => $tid,
|
||||
'card_approval_number' => $result['P_APPL_NUM'] ?? null,
|
||||
'card_number_masked' => $result['P_CARD_NUM'] ?? null,
|
||||
'card_name' => $result['P_CARD_ISSUER_NAME'] ?? null,
|
||||
'card_installment_months' => (int) ($result['P_CARD_QUOTA'] ?? 0),
|
||||
'is_interest_free' => false,
|
||||
'embedded_pg_provider' => $embeddedPgProvider,
|
||||
'receipt_url' => null,
|
||||
'payment_meta' => array_merge([
|
||||
'result_code' => $resultStatus,
|
||||
'pay_method' => $payType ?: null,
|
||||
'auth_date' => $result['P_AUTH_DT'] ?? null,
|
||||
'mid' => $this->apiService->getMid(),
|
||||
'is_test_mode' => $this->apiService->isTestMode(),
|
||||
'is_interest_free' => false,
|
||||
'embedded_pg_provider' => $embeddedPgProvider,
|
||||
'receipt_url' => null,
|
||||
'payment_meta' => array_merge([
|
||||
'result_code' => $resultStatus,
|
||||
'pay_method' => $payType ?: null,
|
||||
'auth_date' => $result['P_AUTH_DT'] ?? null,
|
||||
'mid' => $this->apiService->getMid(),
|
||||
'is_test_mode' => $this->apiService->isTestMode(),
|
||||
'pg_response_sanitized' => true,
|
||||
'pg_raw_response' => $this->sanitizePgResponse($result, self::MOBILE_APPROVE_RESPONSE_KEYS),
|
||||
], $this->buildEasyPayPaymentMeta($selectedEasyPayMethod)),
|
||||
'payment_device' => 'mobile',
|
||||
'payment_device' => 'mobile',
|
||||
], $totPrice);
|
||||
|
||||
$order->payment()->update(['pg_provider' => 'kginicis']);
|
||||
@@ -285,9 +287,9 @@ class MobileCallbackController
|
||||
|
||||
} catch (PaymentAmountMismatchException $e) {
|
||||
Log::error('KG Inicis mobile: amount mismatch', [
|
||||
'P_OID' => $moid,
|
||||
'P_OID' => $moid,
|
||||
'expected' => $e->getExpectedAmount(),
|
||||
'actual' => $e->getActualAmount(),
|
||||
'actual' => $e->getActualAmount(),
|
||||
]);
|
||||
|
||||
$this->autoCancelIfApproved($approvedTid, $moid, $approvedTotPrice, 'amount_mismatch');
|
||||
@@ -316,7 +318,7 @@ class MobileCallbackController
|
||||
$this->autoCancelIfApproved($approvedTid, $moid, $approvedTotPrice, 'approve_failed');
|
||||
|
||||
return redirect($this->resolveFailUrl([
|
||||
'error' => 'approve_failed',
|
||||
'error' => 'approve_failed',
|
||||
'message' => $e->getMessage(),
|
||||
'orderId' => $moid,
|
||||
]));
|
||||
@@ -350,24 +352,24 @@ class MobileCallbackController
|
||||
$tid,
|
||||
'Card',
|
||||
null,
|
||||
'auto-cancel: ' . $reason,
|
||||
'auto-cancel: '.$reason,
|
||||
$totPrice > 0 ? $totPrice : null,
|
||||
);
|
||||
|
||||
Log::warning('KG Inicis mobile: auto-cancel after post-approve failure', [
|
||||
'tid' => $tid,
|
||||
'P_OID' => $moid,
|
||||
'amount' => $totPrice,
|
||||
'reason' => $reason,
|
||||
'tid' => $tid,
|
||||
'P_OID' => $moid,
|
||||
'amount' => $totPrice,
|
||||
'reason' => $reason,
|
||||
'pg_result' => $result,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('KG Inicis mobile: auto-cancel FAILED — manual reconciliation required', [
|
||||
'tid' => $tid,
|
||||
'P_OID' => $moid,
|
||||
'tid' => $tid,
|
||||
'P_OID' => $moid,
|
||||
'amount' => $totPrice,
|
||||
'reason' => $reason,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -387,38 +389,38 @@ class MobileCallbackController
|
||||
|
||||
if ($vactDate && strlen((string) $vactDate) === 8) {
|
||||
try {
|
||||
$vbankDueAt = Carbon::createFromFormat('YmdHis', $vactDate . $vactTime);
|
||||
$vbankDueAt = Carbon::createFromFormat('YmdHis', $vactDate.$vactTime);
|
||||
} catch (\Exception) {
|
||||
$vbankDueAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
$order->payment()->update(array_filter([
|
||||
'pg_provider' => 'kginicis',
|
||||
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
|
||||
'transaction_id' => $tid ?: null,
|
||||
'vbank_code' => $result['P_VACT_BANK_CODE'] ?? $result['P_FN_CD1'] ?? null,
|
||||
'vbank_name' => $result['P_VACT_BANK_NAME'] ?? $result['P_FN_NM'] ?? null,
|
||||
'vbank_number' => $result['P_VACT_NUM'] ?? null,
|
||||
'vbank_holder' => $result['P_VACT_NAME'] ?? $result['P_RVACTNM'] ?? null,
|
||||
'vbank_due_at' => $vbankDueAt,
|
||||
'pg_provider' => 'kginicis',
|
||||
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
|
||||
'transaction_id' => $tid ?: null,
|
||||
'vbank_code' => $result['P_VACT_BANK_CODE'] ?? $result['P_FN_CD1'] ?? null,
|
||||
'vbank_name' => $result['P_VACT_BANK_NAME'] ?? $result['P_FN_NM'] ?? null,
|
||||
'vbank_number' => $result['P_VACT_NUM'] ?? null,
|
||||
'vbank_holder' => $result['P_VACT_NAME'] ?? $result['P_RVACTNM'] ?? null,
|
||||
'vbank_due_at' => $vbankDueAt,
|
||||
'vbank_issued_at' => now(),
|
||||
'payment_device' => 'mobile',
|
||||
'payment_meta' => [
|
||||
'result_code' => $result['P_STATUS'] ?? '00',
|
||||
'pay_method' => 'VBank',
|
||||
'auth_date' => $result['P_AUTH_DT'] ?? null,
|
||||
'mid' => $this->apiService->getMid(),
|
||||
'is_test_mode' => $this->apiService->isTestMode(),
|
||||
'payment_device' => 'mobile',
|
||||
'payment_meta' => [
|
||||
'result_code' => $result['P_STATUS'] ?? '00',
|
||||
'pay_method' => 'VBank',
|
||||
'auth_date' => $result['P_AUTH_DT'] ?? null,
|
||||
'mid' => $this->apiService->getMid(),
|
||||
'is_test_mode' => $this->apiService->isTestMode(),
|
||||
'pg_response_sanitized' => true,
|
||||
'pg_raw_response' => $this->sanitizePgResponse($result, self::MOBILE_VBANK_ISSUE_RESPONSE_KEYS),
|
||||
],
|
||||
], fn ($v) => $v !== null));
|
||||
|
||||
Log::info('KG Inicis mobile: vbank account issued', [
|
||||
'P_OID' => $order->order_number,
|
||||
'P_TID' => $tid,
|
||||
'vbank_name' => $result['P_VACT_BANK_NAME'] ?? $result['P_FN_NM'] ?? null,
|
||||
'P_OID' => $order->order_number,
|
||||
'P_TID' => $tid,
|
||||
'vbank_name' => $result['P_VACT_BANK_NAME'] ?? $result['P_FN_NM'] ?? null,
|
||||
'vbank_number' => $result['P_VACT_NUM'] ?? null,
|
||||
'vbank_due_at' => $vbankDueAt?->toDateTimeString(),
|
||||
]);
|
||||
@@ -427,15 +429,15 @@ class MobileCallbackController
|
||||
private function resolveSuccessUrl(string $orderId): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? ShopRedirectUrl::DEFAULT_SUCCESS_URL;
|
||||
|
||||
return $this->absolutize(str_replace('{orderId}', $orderId, $urlTemplate));
|
||||
return $this->absolutize(ShopRedirectUrl::resolve($urlTemplate, ['{orderId}' => $orderId]));
|
||||
}
|
||||
|
||||
private function resolveFailUrl(array $queryParams = []): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$baseUrl = $this->absolutize($settings['redirect_fail_url'] ?? '/shop/checkout');
|
||||
$baseUrl = $this->absolutize(ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
|
||||
if (empty($queryParams)) {
|
||||
return $baseUrl;
|
||||
@@ -444,7 +446,7 @@ class MobileCallbackController
|
||||
$query = http_build_query(array_filter($queryParams, fn ($v) => $v !== null && $v !== ''));
|
||||
$separator = str_contains($baseUrl, '?') ? '&' : '?';
|
||||
|
||||
return $baseUrl . $separator . $query;
|
||||
return $baseUrl.$separator.$query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,8 +485,8 @@ class MobileCallbackController
|
||||
}
|
||||
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
$path = $url === '' ? '/' : ($url[0] === '/' ? $url : '/' . $url);
|
||||
$path = $url === '' ? '/' : ($url[0] === '/' ? $url : '/'.$url);
|
||||
|
||||
return $base . $path;
|
||||
return $base.$path;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use Plugins\Sirsoft\PayKginicis\Http\Requests\AuthCallbackRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\MobileVbankNotifyRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\VbankNotifyRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
use Plugins\Sirsoft\PayKginicis\Support\ShopRedirectUrl;
|
||||
|
||||
/**
|
||||
* KG 이니시스 결제 콜백 컨트롤러
|
||||
@@ -691,15 +692,15 @@ class PaymentCallbackController
|
||||
private function resolveSuccessUrl(string $orderId): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? ShopRedirectUrl::DEFAULT_SUCCESS_URL;
|
||||
|
||||
return $this->absolutize(str_replace('{orderId}', $orderId, $urlTemplate));
|
||||
return $this->absolutize(ShopRedirectUrl::resolve($urlTemplate, ['{orderId}' => $orderId]));
|
||||
}
|
||||
|
||||
private function resolveFailUrl(array $queryParams = []): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$baseUrl = $this->absolutize($settings['redirect_fail_url'] ?? '/shop/checkout');
|
||||
$baseUrl = $this->absolutize(ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
|
||||
if (empty($queryParams)) {
|
||||
return $baseUrl;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Support;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 결제 완료·실패 후 돌아갈 상점 화면 주소를 만듭니다.
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다 — 쇼핑몰 모듈의 `basic_info.route_path` 로 바꿀 수 있고
|
||||
* `basic_info.no_route` 를 켜면 세그먼트 없이 루트에 붙는다. 그런데 이 플러그인의
|
||||
* 리다이렉트 기본값은 `/shop/...` 리터럴이라, 주소를 바꾼 상점에서는 결제를 마친
|
||||
* 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 로그도 남지 않는다.
|
||||
*
|
||||
* 그래서 기본값에 `{shopBase}` 자리표시자를 두고, 실제 이동 직전에 쇼핑몰 모듈의
|
||||
* 해석기(SSoT)가 계산한 기준 경로로 바꾼다. 운영자가 절대 URL(`https://...`)이나
|
||||
* 자기 경로를 직접 넣었다면 자리표시자가 없으므로 입력값이 그대로 쓰인다.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
final class ShopRedirectUrl
|
||||
{
|
||||
/** 결제 성공 후 이동할 기본 주소 */
|
||||
public const DEFAULT_SUCCESS_URL = '{shopBase}/orders/{orderId}/complete';
|
||||
|
||||
/** 결제 실패 후 이동할 기본 주소 */
|
||||
public const DEFAULT_FAIL_URL = '{shopBase}/checkout';
|
||||
|
||||
/**
|
||||
* 자리표시자를 실제 값으로 바꾼 주소를 반환합니다.
|
||||
*
|
||||
* `{shopBase}` 는 항상 치환한다 — 주소 없이 운영하는 상점(`no_route`)에서는 빈
|
||||
* 문자열이 되어 `{shopBase}/checkout` 이 `/checkout` 으로 접힌다.
|
||||
*
|
||||
* 슬래시 중복 정규화는 하지 않는다. 운영자가 절대 URL 을 넣었을 때 `https://` 의
|
||||
* 이중 슬래시까지 접어 버리기 때문이다.
|
||||
*
|
||||
* @param string $template 설정에 저장된 주소 템플릿
|
||||
* @param array<string, string> $tokens 추가 치환 토큰 (예: `['{orderId}' => '20260807-0001']`)
|
||||
* @return string 치환이 끝난 주소
|
||||
*/
|
||||
public static function resolve(string $template, array $tokens = []): string
|
||||
{
|
||||
return strtr($template, ['{shopBase}' => ShopPathResolver::base()] + $tokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Plugins\Sirsoft\PayKginicis\Support\ShopRedirectUrl;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 결제 리다이렉트 주소가 상점 주소 설정을 따르는지 검증한다 (공개 #85).
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다. 기본값을 `/shop/...` 리터럴로 두면 주소를 바꾼 상점에서
|
||||
* 결제를 마친 구매자가 존재하지 않는 페이지로 떨어지는데, 리다이렉트라 예외도 로그도
|
||||
* 남지 않아 증상이 드러나지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects payment_redirect_follows_route_path, payment_redirect_drops_segment_when_no_route,
|
||||
* operator_supplied_redirect_url_is_preserved
|
||||
*
|
||||
* @group payment
|
||||
*/
|
||||
class ShopRedirectUrlTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 상점 주소 설정을 주입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $basicInfo `basic_info` 하위 설정
|
||||
*/
|
||||
private function setShopSettings(array $basicInfo): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
}
|
||||
|
||||
public function test_설정이_없으면_기본_상점_주소를_쓴다(): void
|
||||
{
|
||||
$this->setShopSettings([]);
|
||||
|
||||
$this->assertSame(
|
||||
'/shop/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/shop/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_운영자가_바꾼_상점_주소를_따른다(): void
|
||||
{
|
||||
$this->setShopSettings(['route_path' => 'store']);
|
||||
|
||||
$this->assertSame(
|
||||
'/store/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/store/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_주소_없이_운영하면_루트에_붙는다(): void
|
||||
{
|
||||
$this->setShopSettings(['no_route' => true]);
|
||||
|
||||
$this->assertSame(
|
||||
'/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_운영자가_직접_넣은_주소는_그대로_쓴다(): void
|
||||
{
|
||||
$this->setShopSettings(['route_path' => 'store']);
|
||||
|
||||
// 자리표시자가 없으면 손대지 않는다 — 절대 URL 의 '//' 도 보존되어야 한다.
|
||||
$this->assertSame(
|
||||
'https://pay.example.com/done/20260807-0001',
|
||||
ShopRedirectUrl::resolve('https://pay.example.com/done/{orderId}', ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/my/custom/checkout', ShopRedirectUrl::resolve('/my/custom/checkout'));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Upgrade;
|
||||
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_1_0\Migrations\BackfillShopBaseRedirectUrls;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 기설치본의 결제 리다이렉트 주소 백필 규칙 (공개 #85).
|
||||
*
|
||||
* 코드 기본값이 `{shopBase}` 자리표시자로 바뀌어도, 이미 설치를 마친 사이트는 예전
|
||||
* 리터럴이 설정 파일에 저장돼 있어 그대로 깨진 채로 남는다. 백필이 그 저장값만 바꾸되
|
||||
* **운영자가 직접 넣은 주소는 건드리지 않아야** 한다.
|
||||
*
|
||||
* 판단 규칙만 순수 함수로 검증한다 — 실제 설정 파일(storage/app/plugins/...)은 개발
|
||||
* 환경의 실데이터이므로 테스트가 건드리지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects legacy_default_redirect_is_backfilled, operator_custom_redirect_survives_backfill,
|
||||
* redirect_backfill_is_idempotent
|
||||
*
|
||||
* @group payment
|
||||
* @group upgrade
|
||||
*/
|
||||
class BackfillShopBaseRedirectUrlsTest extends PluginTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// DataMigration 은 AbstractUpgradeStep 이 동적으로 require 하므로 일반 autoload 대상이 아니다.
|
||||
require_once base_path('plugins/_bundled/sirsoft-pay_kginicis/upgrades/data/1.1.0/migrations/BackfillShopBaseRedirectUrls.php');
|
||||
}
|
||||
|
||||
public function test_예전_기본값이_저장돼_있으면_자리표시자로_바꾼다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
]);
|
||||
|
||||
$this->assertSame('{shopBase}/orders/{orderId}/complete', $settings['redirect_success_url']);
|
||||
$this->assertSame('{shopBase}/checkout', $settings['redirect_fail_url']);
|
||||
$this->assertSame(['redirect_success_url', 'redirect_fail_url'], $updated);
|
||||
}
|
||||
|
||||
public function test_운영자가_직접_넣은_주소는_건드리지_않는다(): void
|
||||
{
|
||||
$custom = [
|
||||
'redirect_success_url' => 'https://pay.example.com/done/{orderId}',
|
||||
'redirect_fail_url' => '/my/custom/checkout',
|
||||
];
|
||||
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply($custom);
|
||||
|
||||
$this->assertSame($custom, $settings);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
|
||||
public function test_한쪽만_예전_기본값이면_그쪽만_바꾼다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => 'https://pay.example.com/fail',
|
||||
]);
|
||||
|
||||
$this->assertSame('{shopBase}/orders/{orderId}/complete', $settings['redirect_success_url']);
|
||||
$this->assertSame('https://pay.example.com/fail', $settings['redirect_fail_url']);
|
||||
$this->assertSame(['redirect_success_url'], $updated);
|
||||
}
|
||||
|
||||
public function test_재실행해도_변화가_없다(): void
|
||||
{
|
||||
[$once] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
]);
|
||||
|
||||
[$twice, $updated] = BackfillShopBaseRedirectUrls::apply($once);
|
||||
|
||||
$this->assertSame($once, $twice);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
|
||||
public function test_키가_없으면_추가하지_않는다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply(['is_test_mode' => true]);
|
||||
|
||||
$this->assertSame(['is_test_mode' => true], $settings);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* v1.1.0 업그레이드 스텝
|
||||
*
|
||||
* 저장된 결제 리다이렉트 주소를 상점 주소 설정(route_path / no_route)을 따르도록 백필한다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.1.0/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path B
|
||||
*/
|
||||
class Upgrade_1_1_0 extends AbstractUpgradeStep {}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_1_0\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 저장된 결제 리다이렉트 주소를 상점 주소 설정을 따르도록 백필한다.
|
||||
*
|
||||
* 배경 (공개 #85):
|
||||
*
|
||||
* 결제 완료·실패 후 돌아갈 주소의 기본값이 `/shop/...` 리터럴이었다. 상점 주소는
|
||||
* 운영자 설정(`basic_info.route_path` / `no_route`)이라 주소를 바꾼 상점에서는
|
||||
* 결제를 마친 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 로그도
|
||||
* 남지 않아 증상이 드러나지 않는다.
|
||||
*
|
||||
* 코드 기본값은 `{shopBase}` 자리표시자로 바뀌었지만, 이미 설치를 마친 사이트는
|
||||
* 예전 리터럴이 설정 파일에 저장돼 있어 그대로 깨진 채로 남는다. 본 마이그레이션이
|
||||
* 그 저장값을 자리표시자 형태로 바꾼다.
|
||||
*
|
||||
* 운영자 입력 보존: 저장값이 **예전 기본값과 정확히 같을 때만** 바꾼다. 운영자가
|
||||
* 직접 넣은 주소(절대 URL·자기 경로)는 의도된 값이므로 건드리지 않는다.
|
||||
*
|
||||
* 멱등: 이미 자리표시자로 바뀐 값은 예전 기본값과 다르므로 재실행해도 변화가 없다.
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이 플러그인의 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-pay_kginicis/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 예전 기본값 => 새 기본값. 저장값이 좌변과 정확히 같을 때만 우변으로 바꾼다.
|
||||
*
|
||||
* @var array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
private const REPLACEMENTS = [
|
||||
'redirect_success_url' => ['/shop/orders/{orderId}/complete', '{shopBase}/orders/{orderId}/complete'],
|
||||
'redirect_fail_url' => ['/shop/checkout', '{shopBase}/checkout'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillShopBaseRedirectUrls';
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 리다이렉트 주소를 자리표시자 형태로 백필한다. idempotent.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[kginicis] 설정 파일 없음 — 새 기본값으로 동작하므로 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = json_decode(File::get($path), true);
|
||||
if (! is_array($settings)) {
|
||||
$context->logger->warning('[kginicis] 설정 JSON 형식 비정상 — 리다이렉트 주소 백필 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$settings, $updated] = self::apply($settings);
|
||||
|
||||
if ($updated === []) {
|
||||
$context->logger->info('[kginicis] 리다이렉트 주소가 예전 기본값이 아님 — 운영자 설정 보존, 변경 없음');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[kginicis] 결제 리다이렉트 주소를 상점 주소 설정 기준으로 백필 완료', [
|
||||
'updated_keys' => $updated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 설정 배열에 백필을 적용한 결과를 반환합니다.
|
||||
*
|
||||
* 파일 I/O 와 분리한 순수 함수다 — 실제 설정 파일을 건드리지 않고 판단 규칙
|
||||
* (예전 기본값과 **정확히 같을 때만** 교체)을 검증할 수 있다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 저장된 설정
|
||||
* @return array{0: array<string, mixed>, 1: array<int, string>} [갱신된 설정, 바뀐 키 목록]
|
||||
*/
|
||||
public static function apply(array $settings): array
|
||||
{
|
||||
$updated = [];
|
||||
|
||||
foreach (self::REPLACEMENTS as $key => [$legacy, $replacement]) {
|
||||
if (($settings[$key] ?? null) === $legacy) {
|
||||
$settings[$key] = $replacement;
|
||||
$updated[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return [$settings, $updated];
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,13 @@
|
||||
|
||||
## [1.0.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- 이커머스 모듈 최소 버전을 1.1.0으로 상향했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 결제를 마치거나 결제에 실패한 구매자가 존재하지 않는 페이지로 이동하던 문제를 수정했습니다. 이동 주소가 기본 주소로 고정돼 있었기 때문이며, 이제 상점 주소 설정을 따라갑니다. 이미 운영 중인 사이트도 업데이트 시 자동으로 반영되며, 이동 주소를 직접 입력해 두었다면 그 값은 그대로 유지됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 이 플러그인이 설치된 사이트에서 관리자 주문 목록의 '일부취소' 표시와 적립예정 포인트·마일리지 사용 금액, 주문상품의 추가옵션 요약이 보이지 않던 문제를 수정했습니다. 결제 정보 칸에 테스트결제 표시를 덧붙이는 과정에서 목록 표 구성 전체를 옛 버전으로 되돌려 놓고 있었습니다.
|
||||
- 관리자 결제수단 설정에서 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제를 수정했습니다. 해당 결제수단의 PG사 목록이 항상 비어 있어 기본 PG사만 사용할 수 있었고, 결제수단별로 다른 PG사를 지정하는 설정이 화면에서 불가능했습니다.
|
||||
- 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"test_site_key": "",
|
||||
"live_site_cd": "",
|
||||
"live_site_key": "",
|
||||
"redirect_success_url": "/shop/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "/shop/checkout"
|
||||
"redirect_success_url": "{shopBase}/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "{shopBase}/checkout"
|
||||
},
|
||||
"frontend_schema": {
|
||||
"is_test_mode": { "expose": false },
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"g7_version": ">=7.0.5",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.0.0-beta.5"
|
||||
"sirsoft-ecommerce": ">=1.1.0"
|
||||
},
|
||||
"plugins": {}
|
||||
},
|
||||
|
||||
@@ -74,16 +74,16 @@ class Plugin extends AbstractPlugin
|
||||
],
|
||||
'redirect_success_url' => [
|
||||
'type' => 'string',
|
||||
'default' => '/shop/orders/{orderId}/complete',
|
||||
'default' => '{shopBase}/orders/{orderId}/complete',
|
||||
'label' => ['ko' => '결제 성공 리다이렉트 URL', 'en' => 'Payment Success Redirect URL'],
|
||||
'hint' => [
|
||||
'ko' => '상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.',
|
||||
'en' => 'Supports relative paths (/shop/...) or full URLs (https://...). {orderId} will be replaced with the actual order number.',
|
||||
'ko' => '{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL(https://...)을 직접 넣어도 됩니다. {orderId}는 주문번호로 자동 치환됩니다.',
|
||||
'en' => '{shopBase} is filled in from the storefront address setting. You may also enter a full URL (https://...). {orderId} will be replaced with the actual order number.',
|
||||
],
|
||||
],
|
||||
'redirect_fail_url' => [
|
||||
'type' => 'string',
|
||||
'default' => '/shop/checkout',
|
||||
'default' => '{shopBase}/checkout',
|
||||
'label' => ['ko' => '결제 실패 리다이렉트 URL', 'en' => 'Payment Failure Redirect URL'],
|
||||
'hint' => [
|
||||
'ko' => '상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.',
|
||||
@@ -136,8 +136,8 @@ class Plugin extends AbstractPlugin
|
||||
'test_site_key' => '',
|
||||
'live_site_cd' => '',
|
||||
'live_site_key' => '',
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
'redirect_success_url' => '{shopBase}/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '{shopBase}/checkout',
|
||||
'use_escrow' => false,
|
||||
'escrow_test_site_cd' => '',
|
||||
'vbank_expire_days' => 3,
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"easy_pay_applepay_hint": "Available on iOS Safari only",
|
||||
"section_redirect": "Redirect URLs",
|
||||
"redirect_success_url": "Payment Success Redirect URL",
|
||||
"redirect_success_url_hint": "Supports relative paths or full URLs. {orderId} will be replaced with the actual order number.",
|
||||
"redirect_success_url_hint": "{shopBase} is filled in from the storefront address setting. You may also enter a full URL (https://...). {orderId} will be replaced with the actual order number.",
|
||||
"redirect_fail_url": "Payment Failure Redirect URL",
|
||||
"redirect_fail_url_hint": "Supports relative paths or full URLs. Error details are appended as query parameters.",
|
||||
"section_vbank": "Virtual Account Settings",
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"easy_pay_applepay_hint": "iOS Safari에서만 사용 가능",
|
||||
"section_redirect": "리다이렉트 URL",
|
||||
"redirect_success_url": "결제 성공 리다이렉트 URL",
|
||||
"redirect_success_url_hint": "상대 경로(/shop/...) 또는 전체 URL 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.",
|
||||
"redirect_success_url_hint": "{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL(https://...)을 직접 넣어도 됩니다. {orderId}는 주문번호로 자동 치환됩니다.",
|
||||
"redirect_fail_url": "결제 실패 리다이렉트 URL",
|
||||
"redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.",
|
||||
"section_vbank": "가상계좌 설정",
|
||||
|
||||
@@ -27,6 +27,7 @@ use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\AuthCallbackRequest;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\VbankNotifyRequest;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Support\ShopRedirectUrl;
|
||||
|
||||
/**
|
||||
* KCP 결제 콜백 컨트롤러
|
||||
@@ -834,9 +835,9 @@ class PaymentCallbackController
|
||||
private function resolveSuccessUrl(string $orderId): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? ShopRedirectUrl::DEFAULT_SUCCESS_URL;
|
||||
|
||||
return $this->absolutize(str_replace('{orderId}', $orderId, $urlTemplate));
|
||||
return $this->absolutize(ShopRedirectUrl::resolve($urlTemplate, ['{orderId}' => $orderId]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -889,7 +890,7 @@ class PaymentCallbackController
|
||||
private function resolveFailUrl(array $queryParams = []): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$baseUrl = $this->absolutize($settings['redirect_fail_url'] ?? '/shop/checkout');
|
||||
$baseUrl = $this->absolutize(ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
|
||||
if (empty($queryParams)) {
|
||||
return $baseUrl;
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Support\ShopRedirectUrl;
|
||||
|
||||
/**
|
||||
* KCP 결제 승인 콜백 요청 검증
|
||||
@@ -68,11 +69,11 @@ class AuthCallbackRequest extends FormRequest
|
||||
]);
|
||||
|
||||
$settings = plugin_settings(self::PLUGIN_IDENTIFIER);
|
||||
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
|
||||
$baseUrl = ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL);
|
||||
$separator = str_contains($baseUrl, '?') ? '&' : '?';
|
||||
|
||||
throw new HttpResponseException(
|
||||
redirect($baseUrl . $separator . http_build_query(['error' => 'invalid_params']))
|
||||
redirect($baseUrl.$separator.http_build_query(['error' => 'invalid_params']))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Support;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 결제 완료·실패 후 돌아갈 상점 화면 주소를 만듭니다.
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다 — 쇼핑몰 모듈의 `basic_info.route_path` 로 바꿀 수 있고
|
||||
* `basic_info.no_route` 를 켜면 세그먼트 없이 루트에 붙는다. 그런데 이 플러그인의
|
||||
* 리다이렉트 기본값은 `/shop/...` 리터럴이라, 주소를 바꾼 상점에서는 결제를 마친
|
||||
* 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 로그도 남지 않는다.
|
||||
*
|
||||
* 그래서 기본값에 `{shopBase}` 자리표시자를 두고, 실제 이동 직전에 쇼핑몰 모듈의
|
||||
* 해석기(SSoT)가 계산한 기준 경로로 바꾼다. 운영자가 절대 URL(`https://...`)이나
|
||||
* 자기 경로를 직접 넣었다면 자리표시자가 없으므로 입력값이 그대로 쓰인다.
|
||||
*
|
||||
* @since 1.0.1
|
||||
*/
|
||||
final class ShopRedirectUrl
|
||||
{
|
||||
/** 결제 성공 후 이동할 기본 주소 */
|
||||
public const DEFAULT_SUCCESS_URL = '{shopBase}/orders/{orderId}/complete';
|
||||
|
||||
/** 결제 실패 후 이동할 기본 주소 */
|
||||
public const DEFAULT_FAIL_URL = '{shopBase}/checkout';
|
||||
|
||||
/**
|
||||
* 자리표시자를 실제 값으로 바꾼 주소를 반환합니다.
|
||||
*
|
||||
* `{shopBase}` 는 항상 치환한다 — 주소 없이 운영하는 상점(`no_route`)에서는 빈
|
||||
* 문자열이 되어 `{shopBase}/checkout` 이 `/checkout` 으로 접힌다.
|
||||
*
|
||||
* 슬래시 중복 정규화는 하지 않는다. 운영자가 절대 URL 을 넣었을 때 `https://` 의
|
||||
* 이중 슬래시까지 접어 버리기 때문이다.
|
||||
*
|
||||
* @param string $template 설정에 저장된 주소 템플릿
|
||||
* @param array<string, string> $tokens 추가 치환 토큰 (예: `['{orderId}' => '20260807-0001']`)
|
||||
* @return string 치환이 끝난 주소
|
||||
*/
|
||||
public static function resolve(string $template, array $tokens = []): string
|
||||
{
|
||||
return strtr($template, ['{shopBase}' => ShopPathResolver::base()] + $tokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Support\ShopRedirectUrl;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 결제 리다이렉트 주소가 상점 주소 설정을 따르는지 검증한다 (공개 #85).
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다. 기본값을 `/shop/...` 리터럴로 두면 주소를 바꾼 상점에서
|
||||
* 결제를 마친 구매자가 존재하지 않는 페이지로 떨어지는데, 리다이렉트라 예외도 로그도
|
||||
* 남지 않아 증상이 드러나지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects payment_redirect_follows_route_path, payment_redirect_drops_segment_when_no_route,
|
||||
* operator_supplied_redirect_url_is_preserved
|
||||
*
|
||||
* @group payment
|
||||
*/
|
||||
class ShopRedirectUrlTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 상점 주소 설정을 주입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $basicInfo `basic_info` 하위 설정
|
||||
*/
|
||||
private function setShopSettings(array $basicInfo): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
}
|
||||
|
||||
public function test_설정이_없으면_기본_상점_주소를_쓴다(): void
|
||||
{
|
||||
$this->setShopSettings([]);
|
||||
|
||||
$this->assertSame(
|
||||
'/shop/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/shop/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_운영자가_바꾼_상점_주소를_따른다(): void
|
||||
{
|
||||
$this->setShopSettings(['route_path' => 'store']);
|
||||
|
||||
$this->assertSame(
|
||||
'/store/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/store/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_주소_없이_운영하면_루트에_붙는다(): void
|
||||
{
|
||||
$this->setShopSettings(['no_route' => true]);
|
||||
|
||||
$this->assertSame(
|
||||
'/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_운영자가_직접_넣은_주소는_그대로_쓴다(): void
|
||||
{
|
||||
$this->setShopSettings(['route_path' => 'store']);
|
||||
|
||||
// 자리표시자가 없으면 손대지 않는다 — 절대 URL 의 '//' 도 보존되어야 한다.
|
||||
$this->assertSame(
|
||||
'https://pay.example.com/done/20260807-0001',
|
||||
ShopRedirectUrl::resolve('https://pay.example.com/done/{orderId}', ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/my/custom/checkout', ShopRedirectUrl::resolve('/my/custom/checkout'));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Upgrade;
|
||||
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_1\Migrations\BackfillShopBaseRedirectUrls;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 기설치본의 결제 리다이렉트 주소 백필 규칙 (공개 #85).
|
||||
*
|
||||
* 코드 기본값이 `{shopBase}` 자리표시자로 바뀌어도, 이미 설치를 마친 사이트는 예전
|
||||
* 리터럴이 설정 파일에 저장돼 있어 그대로 깨진 채로 남는다. 백필이 그 저장값만 바꾸되
|
||||
* **운영자가 직접 넣은 주소는 건드리지 않아야** 한다.
|
||||
*
|
||||
* 판단 규칙만 순수 함수로 검증한다 — 실제 설정 파일(storage/app/plugins/...)은 개발
|
||||
* 환경의 실데이터이므로 테스트가 건드리지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects legacy_default_redirect_is_backfilled, operator_custom_redirect_survives_backfill,
|
||||
* redirect_backfill_is_idempotent
|
||||
*
|
||||
* @group payment
|
||||
* @group upgrade
|
||||
*/
|
||||
class BackfillShopBaseRedirectUrlsTest extends PluginTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// DataMigration 은 AbstractUpgradeStep 이 동적으로 require 하므로 일반 autoload 대상이 아니다.
|
||||
require_once base_path('plugins/_bundled/sirsoft-pay_nhnkcp/upgrades/data/1.0.1/migrations/BackfillShopBaseRedirectUrls.php');
|
||||
}
|
||||
|
||||
public function test_예전_기본값이_저장돼_있으면_자리표시자로_바꾼다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
]);
|
||||
|
||||
$this->assertSame('{shopBase}/orders/{orderId}/complete', $settings['redirect_success_url']);
|
||||
$this->assertSame('{shopBase}/checkout', $settings['redirect_fail_url']);
|
||||
$this->assertSame(['redirect_success_url', 'redirect_fail_url'], $updated);
|
||||
}
|
||||
|
||||
public function test_운영자가_직접_넣은_주소는_건드리지_않는다(): void
|
||||
{
|
||||
$custom = [
|
||||
'redirect_success_url' => 'https://pay.example.com/done/{orderId}',
|
||||
'redirect_fail_url' => '/my/custom/checkout',
|
||||
];
|
||||
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply($custom);
|
||||
|
||||
$this->assertSame($custom, $settings);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
|
||||
public function test_한쪽만_예전_기본값이면_그쪽만_바꾼다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => 'https://pay.example.com/fail',
|
||||
]);
|
||||
|
||||
$this->assertSame('{shopBase}/orders/{orderId}/complete', $settings['redirect_success_url']);
|
||||
$this->assertSame('https://pay.example.com/fail', $settings['redirect_fail_url']);
|
||||
$this->assertSame(['redirect_success_url'], $updated);
|
||||
}
|
||||
|
||||
public function test_재실행해도_변화가_없다(): void
|
||||
{
|
||||
[$once] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
]);
|
||||
|
||||
[$twice, $updated] = BackfillShopBaseRedirectUrls::apply($once);
|
||||
|
||||
$this->assertSame($once, $twice);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
|
||||
public function test_키가_없으면_추가하지_않는다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply(['is_test_mode' => true]);
|
||||
|
||||
$this->assertSame(['is_test_mode' => true], $settings);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* v1.0.1 업그레이드 스텝
|
||||
*
|
||||
* 저장된 결제 리다이렉트 주소를 상점 주소 설정(route_path / no_route)을 따르도록 백필한다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.1/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path B
|
||||
*/
|
||||
class Upgrade_1_0_1 extends AbstractUpgradeStep {}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_1\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 저장된 결제 리다이렉트 주소를 상점 주소 설정을 따르도록 백필한다.
|
||||
*
|
||||
* 배경 (공개 #85):
|
||||
*
|
||||
* 결제 완료·실패 후 돌아갈 주소의 기본값이 `/shop/...` 리터럴이었다. 상점 주소는
|
||||
* 운영자 설정(`basic_info.route_path` / `no_route`)이라 주소를 바꾼 상점에서는
|
||||
* 결제를 마친 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 로그도
|
||||
* 남지 않아 증상이 드러나지 않는다.
|
||||
*
|
||||
* 코드 기본값은 `{shopBase}` 자리표시자로 바뀌었지만, 이미 설치를 마친 사이트는
|
||||
* 예전 리터럴이 설정 파일에 저장돼 있어 그대로 깨진 채로 남는다. 본 마이그레이션이
|
||||
* 그 저장값을 자리표시자 형태로 바꾼다.
|
||||
*
|
||||
* 운영자 입력 보존: 저장값이 **예전 기본값과 정확히 같을 때만** 바꾼다. 운영자가
|
||||
* 직접 넣은 주소(절대 URL·자기 경로)는 의도된 값이므로 건드리지 않는다.
|
||||
*
|
||||
* 멱등: 이미 자리표시자로 바뀐 값은 예전 기본값과 다르므로 재실행해도 변화가 없다.
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이 플러그인의 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-pay_nhnkcp/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 예전 기본값 => 새 기본값. 저장값이 좌변과 정확히 같을 때만 우변으로 바꾼다.
|
||||
*
|
||||
* @var array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
private const REPLACEMENTS = [
|
||||
'redirect_success_url' => ['/shop/orders/{orderId}/complete', '{shopBase}/orders/{orderId}/complete'],
|
||||
'redirect_fail_url' => ['/shop/checkout', '{shopBase}/checkout'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillShopBaseRedirectUrls';
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 리다이렉트 주소를 자리표시자 형태로 백필한다. idempotent.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[nhnkcp] 설정 파일 없음 — 새 기본값으로 동작하므로 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = json_decode(File::get($path), true);
|
||||
if (! is_array($settings)) {
|
||||
$context->logger->warning('[nhnkcp] 설정 JSON 형식 비정상 — 리다이렉트 주소 백필 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$settings, $updated] = self::apply($settings);
|
||||
|
||||
if ($updated === []) {
|
||||
$context->logger->info('[nhnkcp] 리다이렉트 주소가 예전 기본값이 아님 — 운영자 설정 보존, 변경 없음');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[nhnkcp] 결제 리다이렉트 주소를 상점 주소 설정 기준으로 백필 완료', [
|
||||
'updated_keys' => $updated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 설정 배열에 백필을 적용한 결과를 반환합니다.
|
||||
*
|
||||
* 파일 I/O 와 분리한 순수 함수다 — 실제 설정 파일을 건드리지 않고 판단 규칙
|
||||
* (예전 기본값과 **정확히 같을 때만** 교체)을 검증할 수 있다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 저장된 설정
|
||||
* @return array{0: array<string, mixed>, 1: array<int, string>} [갱신된 설정, 바뀐 키 목록]
|
||||
*/
|
||||
public static function apply(array $settings): array
|
||||
{
|
||||
$updated = [];
|
||||
|
||||
foreach (self::REPLACEMENTS as $key => [$legacy, $replacement]) {
|
||||
if (($settings[$key] ?? null) === $legacy) {
|
||||
$settings[$key] = $replacement;
|
||||
$updated[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return [$settings, $updated];
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,13 @@
|
||||
|
||||
## [1.0.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- 이커머스 모듈 최소 버전을 1.1.0으로 상향했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 결제를 마치거나 결제에 실패한 구매자가 존재하지 않는 페이지로 이동하던 문제를 수정했습니다. 이동 주소가 기본 주소로 고정돼 있었기 때문이며, 이제 상점 주소 설정을 따라갑니다. 이미 운영 중인 사이트도 업데이트 시 자동으로 반영되며, 이동 주소를 직접 입력해 두었다면 그 값은 그대로 유지됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 이 플러그인이 설치된 사이트에서 관리자 주문 목록의 '일부취소' 표시와 적립예정 포인트·마일리지 사용 금액, 주문상품의 추가옵션 요약이 보이지 않던 문제를 수정했습니다. 결제 정보 칸에 테스트결제 표시를 덧붙이는 과정에서 목록 표 구성 전체를 옛 버전으로 되돌려 놓고 있었습니다.
|
||||
- 관리자 결제수단 설정에서 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제를 수정했습니다. 해당 결제수단의 PG사 목록이 항상 비어 있어 기본 PG사만 사용할 수 있었고, 결제수단별로 다른 PG사를 지정하는 설정이 화면에서 불가능했습니다.
|
||||
- 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"test_merchant_key": "EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==",
|
||||
"live_mid": "",
|
||||
"live_merchant_key": "",
|
||||
"redirect_success_url": "/shop/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "/shop/checkout"
|
||||
"redirect_success_url": "{shopBase}/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "{shopBase}/checkout"
|
||||
},
|
||||
"frontend_schema": {
|
||||
"is_test_mode": { "expose": false },
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"g7_version": ">=7.0.5",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.0.0-beta.5"
|
||||
"sirsoft-ecommerce": ">=1.1.0"
|
||||
},
|
||||
"plugins": {}
|
||||
},
|
||||
|
||||
@@ -70,16 +70,16 @@ class Plugin extends AbstractPlugin
|
||||
],
|
||||
'redirect_success_url' => [
|
||||
'type' => 'string',
|
||||
'default' => '/shop/orders/{orderId}/complete',
|
||||
'default' => '{shopBase}/orders/{orderId}/complete',
|
||||
'label' => ['ko' => '결제 성공 리다이렉트 URL', 'en' => 'Payment Success Redirect URL'],
|
||||
'hint' => [
|
||||
'ko' => '상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.',
|
||||
'en' => 'Supports relative paths (/shop/...) or full URLs (https://...). {orderId} will be replaced with the actual order number.',
|
||||
'ko' => '{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL(https://...)을 직접 넣어도 됩니다. {orderId}는 주문번호로 자동 치환됩니다.',
|
||||
'en' => '{shopBase} is filled in from the storefront address setting. You may also enter a full URL (https://...). {orderId} will be replaced with the actual order number.',
|
||||
],
|
||||
],
|
||||
'redirect_fail_url' => [
|
||||
'type' => 'string',
|
||||
'default' => '/shop/checkout',
|
||||
'default' => '{shopBase}/checkout',
|
||||
'label' => ['ko' => '결제 실패 리다이렉트 URL', 'en' => 'Payment Failure Redirect URL'],
|
||||
'hint' => [
|
||||
'ko' => '상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.',
|
||||
@@ -163,8 +163,8 @@ class Plugin extends AbstractPlugin
|
||||
'test_merchant_key' => 'EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==',
|
||||
'live_mid' => '',
|
||||
'live_merchant_key' => '',
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
'redirect_success_url' => '{shopBase}/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '{shopBase}/checkout',
|
||||
'use_escrow' => false,
|
||||
'easy_pay_allow_with_other_pg' => false,
|
||||
'easy_pay_naverpay' => false,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"copy": "Copy",
|
||||
"section_redirect": "Redirect URLs",
|
||||
"redirect_success_url": "Payment Success Redirect URL",
|
||||
"redirect_success_url_hint": "Supports relative paths (/shop/...) or full URLs (https://...). {orderId} will be replaced with the actual order number.",
|
||||
"redirect_success_url_hint": "{shopBase} is filled in from the storefront address setting. You may also enter a full URL (https://...). {orderId} will be replaced with the actual order number.",
|
||||
"redirect_fail_url": "Payment Failure Redirect URL",
|
||||
"redirect_fail_url_hint": "Supports relative paths or full URLs. Error details are appended as query parameters.",
|
||||
"use_escrow": "Enable Escrow Payment",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"copy": "복사",
|
||||
"section_redirect": "리다이렉트 URL",
|
||||
"redirect_success_url": "결제 성공 리다이렉트 URL",
|
||||
"redirect_success_url_hint": "상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.",
|
||||
"redirect_success_url_hint": "{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL(https://...)을 직접 넣어도 됩니다. {orderId}는 주문번호로 자동 치환됩니다.",
|
||||
"redirect_fail_url": "결제 실패 리다이렉트 URL",
|
||||
"redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.",
|
||||
"use_escrow": "에스크로 결제 사용",
|
||||
|
||||
+4
-3
@@ -31,6 +31,7 @@ use Plugins\Sirsoft\PayNicepayments\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayNicepayments\Http\Requests\AuthCallbackRequest;
|
||||
use Plugins\Sirsoft\PayNicepayments\Http\Requests\VbankNotifyRequest;
|
||||
use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService;
|
||||
use Plugins\Sirsoft\PayNicepayments\Support\ShopRedirectUrl;
|
||||
use Plugins\Sirsoft\PayNicepayments\Support\UrlHelper;
|
||||
|
||||
/**
|
||||
@@ -995,8 +996,8 @@ class PaymentCallbackController
|
||||
private function resolveSuccessUrl(string $orderId): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
|
||||
$url = str_replace('{orderId}', $orderId, $urlTemplate);
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? ShopRedirectUrl::DEFAULT_SUCCESS_URL;
|
||||
$url = ShopRedirectUrl::resolve($urlTemplate, ['{orderId}' => $orderId]);
|
||||
|
||||
return UrlHelper::toAbsolute($url);
|
||||
}
|
||||
@@ -1004,7 +1005,7 @@ class PaymentCallbackController
|
||||
private function resolveFailUrl(array $queryParams = []): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
|
||||
$baseUrl = ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL);
|
||||
|
||||
if (! empty($queryParams)) {
|
||||
$query = http_build_query(array_filter($queryParams));
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Plugins\Sirsoft\PayNicepayments\Http\Requests;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Plugins\Sirsoft\PayNicepayments\Support\ShopRedirectUrl;
|
||||
use Plugins\Sirsoft\PayNicepayments\Support\UrlHelper;
|
||||
|
||||
/**
|
||||
@@ -78,9 +79,9 @@ class AuthCallbackRequest extends FormRequest
|
||||
protected function failedValidation(Validator $validator): void
|
||||
{
|
||||
$settings = plugin_settings(self::PLUGIN_IDENTIFIER);
|
||||
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
|
||||
$baseUrl = ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL);
|
||||
$separator = str_contains($baseUrl, '?') ? '&' : '?';
|
||||
$url = $baseUrl . $separator . http_build_query(['error' => 'invalid_params']);
|
||||
$url = $baseUrl.$separator.http_build_query(['error' => 'invalid_params']);
|
||||
|
||||
throw new HttpResponseException(
|
||||
redirect(UrlHelper::toAbsolute($url))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Support;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 결제 완료·실패 후 돌아갈 상점 화면 주소를 만듭니다.
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다 — 쇼핑몰 모듈의 `basic_info.route_path` 로 바꿀 수 있고
|
||||
* `basic_info.no_route` 를 켜면 세그먼트 없이 루트에 붙는다. 그런데 이 플러그인의
|
||||
* 리다이렉트 기본값은 `/shop/...` 리터럴이라, 주소를 바꾼 상점에서는 결제를 마친
|
||||
* 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 로그도 남지 않는다.
|
||||
*
|
||||
* 그래서 기본값에 `{shopBase}` 자리표시자를 두고, 실제 이동 직전에 쇼핑몰 모듈의
|
||||
* 해석기(SSoT)가 계산한 기준 경로로 바꾼다. 운영자가 절대 URL(`https://...`)이나
|
||||
* 자기 경로를 직접 넣었다면 자리표시자가 없으므로 입력값이 그대로 쓰인다.
|
||||
*
|
||||
* @since 1.0.1
|
||||
*/
|
||||
final class ShopRedirectUrl
|
||||
{
|
||||
/** 결제 성공 후 이동할 기본 주소 */
|
||||
public const DEFAULT_SUCCESS_URL = '{shopBase}/orders/{orderId}/complete';
|
||||
|
||||
/** 결제 실패 후 이동할 기본 주소 */
|
||||
public const DEFAULT_FAIL_URL = '{shopBase}/checkout';
|
||||
|
||||
/**
|
||||
* 자리표시자를 실제 값으로 바꾼 주소를 반환합니다.
|
||||
*
|
||||
* `{shopBase}` 는 항상 치환한다 — 주소 없이 운영하는 상점(`no_route`)에서는 빈
|
||||
* 문자열이 되어 `{shopBase}/checkout` 이 `/checkout` 으로 접힌다.
|
||||
*
|
||||
* 슬래시 중복 정규화는 하지 않는다. 운영자가 절대 URL 을 넣었을 때 `https://` 의
|
||||
* 이중 슬래시까지 접어 버리기 때문이다.
|
||||
*
|
||||
* @param string $template 설정에 저장된 주소 템플릿
|
||||
* @param array<string, string> $tokens 추가 치환 토큰 (예: `['{orderId}' => '20260807-0001']`)
|
||||
* @return string 치환이 끝난 주소
|
||||
*/
|
||||
public static function resolve(string $template, array $tokens = []): string
|
||||
{
|
||||
return strtr($template, ['{shopBase}' => ShopPathResolver::base()] + $tokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Tests\Unit\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Plugins\Sirsoft\PayNicepayments\Support\ShopRedirectUrl;
|
||||
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 결제 리다이렉트 주소가 상점 주소 설정을 따르는지 검증한다 (공개 #85).
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다. 기본값을 `/shop/...` 리터럴로 두면 주소를 바꾼 상점에서
|
||||
* 결제를 마친 구매자가 존재하지 않는 페이지로 떨어지는데, 리다이렉트라 예외도 로그도
|
||||
* 남지 않아 증상이 드러나지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects payment_redirect_follows_route_path, payment_redirect_drops_segment_when_no_route,
|
||||
* operator_supplied_redirect_url_is_preserved
|
||||
*
|
||||
* @group payment
|
||||
*/
|
||||
class ShopRedirectUrlTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 상점 주소 설정을 주입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $basicInfo `basic_info` 하위 설정
|
||||
*/
|
||||
private function setShopSettings(array $basicInfo): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
}
|
||||
|
||||
public function test_설정이_없으면_기본_상점_주소를_쓴다(): void
|
||||
{
|
||||
$this->setShopSettings([]);
|
||||
|
||||
$this->assertSame(
|
||||
'/shop/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/shop/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_운영자가_바꾼_상점_주소를_따른다(): void
|
||||
{
|
||||
$this->setShopSettings(['route_path' => 'store']);
|
||||
|
||||
$this->assertSame(
|
||||
'/store/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/store/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_주소_없이_운영하면_루트에_붙는다(): void
|
||||
{
|
||||
$this->setShopSettings(['no_route' => true]);
|
||||
|
||||
$this->assertSame(
|
||||
'/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_운영자가_직접_넣은_주소는_그대로_쓴다(): void
|
||||
{
|
||||
$this->setShopSettings(['route_path' => 'store']);
|
||||
|
||||
// 자리표시자가 없으면 손대지 않는다 — 절대 URL 의 '//' 도 보존되어야 한다.
|
||||
$this->assertSame(
|
||||
'https://pay.example.com/done/20260807-0001',
|
||||
ShopRedirectUrl::resolve('https://pay.example.com/done/{orderId}', ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/my/custom/checkout', ShopRedirectUrl::resolve('/my/custom/checkout'));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Tests\Unit\Upgrade;
|
||||
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_1\Migrations\BackfillShopBaseRedirectUrls;
|
||||
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 기설치본의 결제 리다이렉트 주소 백필 규칙 (공개 #85).
|
||||
*
|
||||
* 코드 기본값이 `{shopBase}` 자리표시자로 바뀌어도, 이미 설치를 마친 사이트는 예전
|
||||
* 리터럴이 설정 파일에 저장돼 있어 그대로 깨진 채로 남는다. 백필이 그 저장값만 바꾸되
|
||||
* **운영자가 직접 넣은 주소는 건드리지 않아야** 한다.
|
||||
*
|
||||
* 판단 규칙만 순수 함수로 검증한다 — 실제 설정 파일(storage/app/plugins/...)은 개발
|
||||
* 환경의 실데이터이므로 테스트가 건드리지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects legacy_default_redirect_is_backfilled, operator_custom_redirect_survives_backfill,
|
||||
* redirect_backfill_is_idempotent
|
||||
*
|
||||
* @group payment
|
||||
* @group upgrade
|
||||
*/
|
||||
class BackfillShopBaseRedirectUrlsTest extends PluginTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// DataMigration 은 AbstractUpgradeStep 이 동적으로 require 하므로 일반 autoload 대상이 아니다.
|
||||
require_once base_path('plugins/_bundled/sirsoft-pay_nicepayments/upgrades/data/1.0.1/migrations/BackfillShopBaseRedirectUrls.php');
|
||||
}
|
||||
|
||||
public function test_예전_기본값이_저장돼_있으면_자리표시자로_바꾼다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
]);
|
||||
|
||||
$this->assertSame('{shopBase}/orders/{orderId}/complete', $settings['redirect_success_url']);
|
||||
$this->assertSame('{shopBase}/checkout', $settings['redirect_fail_url']);
|
||||
$this->assertSame(['redirect_success_url', 'redirect_fail_url'], $updated);
|
||||
}
|
||||
|
||||
public function test_운영자가_직접_넣은_주소는_건드리지_않는다(): void
|
||||
{
|
||||
$custom = [
|
||||
'redirect_success_url' => 'https://pay.example.com/done/{orderId}',
|
||||
'redirect_fail_url' => '/my/custom/checkout',
|
||||
];
|
||||
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply($custom);
|
||||
|
||||
$this->assertSame($custom, $settings);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
|
||||
public function test_한쪽만_예전_기본값이면_그쪽만_바꾼다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => 'https://pay.example.com/fail',
|
||||
]);
|
||||
|
||||
$this->assertSame('{shopBase}/orders/{orderId}/complete', $settings['redirect_success_url']);
|
||||
$this->assertSame('https://pay.example.com/fail', $settings['redirect_fail_url']);
|
||||
$this->assertSame(['redirect_success_url'], $updated);
|
||||
}
|
||||
|
||||
public function test_재실행해도_변화가_없다(): void
|
||||
{
|
||||
[$once] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
]);
|
||||
|
||||
[$twice, $updated] = BackfillShopBaseRedirectUrls::apply($once);
|
||||
|
||||
$this->assertSame($once, $twice);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
|
||||
public function test_키가_없으면_추가하지_않는다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply(['is_test_mode' => true]);
|
||||
|
||||
$this->assertSame(['is_test_mode' => true], $settings);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* v1.0.1 업그레이드 스텝
|
||||
*
|
||||
* 저장된 결제 리다이렉트 주소를 상점 주소 설정(route_path / no_route)을 따르도록 백필한다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.1/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path B
|
||||
*/
|
||||
class Upgrade_1_0_1 extends AbstractUpgradeStep {}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_1\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 저장된 결제 리다이렉트 주소를 상점 주소 설정을 따르도록 백필한다.
|
||||
*
|
||||
* 배경 (공개 #85):
|
||||
*
|
||||
* 결제 완료·실패 후 돌아갈 주소의 기본값이 `/shop/...` 리터럴이었다. 상점 주소는
|
||||
* 운영자 설정(`basic_info.route_path` / `no_route`)이라 주소를 바꾼 상점에서는
|
||||
* 결제를 마친 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 로그도
|
||||
* 남지 않아 증상이 드러나지 않는다.
|
||||
*
|
||||
* 코드 기본값은 `{shopBase}` 자리표시자로 바뀌었지만, 이미 설치를 마친 사이트는
|
||||
* 예전 리터럴이 설정 파일에 저장돼 있어 그대로 깨진 채로 남는다. 본 마이그레이션이
|
||||
* 그 저장값을 자리표시자 형태로 바꾼다.
|
||||
*
|
||||
* 운영자 입력 보존: 저장값이 **예전 기본값과 정확히 같을 때만** 바꾼다. 운영자가
|
||||
* 직접 넣은 주소(절대 URL·자기 경로)는 의도된 값이므로 건드리지 않는다.
|
||||
*
|
||||
* 멱등: 이미 자리표시자로 바뀐 값은 예전 기본값과 다르므로 재실행해도 변화가 없다.
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이 플러그인의 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-pay_nicepayments/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 예전 기본값 => 새 기본값. 저장값이 좌변과 정확히 같을 때만 우변으로 바꾼다.
|
||||
*
|
||||
* @var array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
private const REPLACEMENTS = [
|
||||
'redirect_success_url' => ['/shop/orders/{orderId}/complete', '{shopBase}/orders/{orderId}/complete'],
|
||||
'redirect_fail_url' => ['/shop/checkout', '{shopBase}/checkout'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillShopBaseRedirectUrls';
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 리다이렉트 주소를 자리표시자 형태로 백필한다. idempotent.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[nicepayments] 설정 파일 없음 — 새 기본값으로 동작하므로 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = json_decode(File::get($path), true);
|
||||
if (! is_array($settings)) {
|
||||
$context->logger->warning('[nicepayments] 설정 JSON 형식 비정상 — 리다이렉트 주소 백필 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$settings, $updated] = self::apply($settings);
|
||||
|
||||
if ($updated === []) {
|
||||
$context->logger->info('[nicepayments] 리다이렉트 주소가 예전 기본값이 아님 — 운영자 설정 보존, 변경 없음');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[nicepayments] 결제 리다이렉트 주소를 상점 주소 설정 기준으로 백필 완료', [
|
||||
'updated_keys' => $updated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 설정 배열에 백필을 적용한 결과를 반환합니다.
|
||||
*
|
||||
* 파일 I/O 와 분리한 순수 함수다 — 실제 설정 파일을 건드리지 않고 판단 규칙
|
||||
* (예전 기본값과 **정확히 같을 때만** 교체)을 검증할 수 있다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 저장된 설정
|
||||
* @return array{0: array<string, mixed>, 1: array<int, string>} [갱신된 설정, 바뀐 키 목록]
|
||||
*/
|
||||
public static function apply(array $settings): array
|
||||
{
|
||||
$updated = [];
|
||||
|
||||
foreach (self::REPLACEMENTS as $key => [$legacy, $replacement]) {
|
||||
if (($settings[$key] ?? null) === $legacy) {
|
||||
$settings[$key] = $replacement;
|
||||
$updated[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return [$settings, $updated];
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 결제를 마치거나 결제에 실패한 구매자가 존재하지 않는 페이지로 이동하던 문제를 수정했습니다. 이동 주소가 기본 주소로 고정돼 있었기 때문이며, 이제 상점 주소 설정을 따라갑니다. 이미 운영 중인 사이트도 업데이트 시 자동으로 반영되며, 이동 주소를 직접 입력해 두었다면 그 값은 그대로 유지됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 전액 환불이 부분취소로 잘못 요청되던 문제를 수정했습니다. 이미 부분취소한 이력이 있는 주문의 잔액 취소도 정확한 금액으로 처리됩니다.
|
||||
- 면세 상품이 포함된 주문을 환불할 때 면세 금액이 함께 전달되지 않아 부가세가 잘못 계산되던 문제를 수정했습니다.
|
||||
- 가상계좌 결제를 취소할 때 환불받을 계좌 정보가 전달되지 않아 취소가 실패하던 문제를 수정했습니다. 계좌 정보가 없으면 취소 전에 안내합니다.
|
||||
@@ -119,11 +121,13 @@
|
||||
## [0.1.1] - 2026-02-24
|
||||
|
||||
### Changed
|
||||
|
||||
- 버전 체계 조정 (정식 출시 전 0.x 체계로 변경)
|
||||
|
||||
## [0.1.0] - 2026-02-23
|
||||
|
||||
### Added
|
||||
|
||||
- 토스페이먼츠 결제 플러그인 초기 구현
|
||||
- 카드/계좌이체/가상계좌/무통장입금 결제 지원
|
||||
- 결제 위젯 (TossPayments SDK) 통합
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"test_secret_key": "",
|
||||
"live_client_key": "",
|
||||
"live_secret_key": "",
|
||||
"redirect_success_url": "/shop/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "/shop/checkout",
|
||||
"redirect_success_url": "{shopBase}/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "{shopBase}/checkout",
|
||||
"order_sheet_mode": false,
|
||||
"method_card": true,
|
||||
"method_virtual_account": false,
|
||||
|
||||
@@ -80,20 +80,20 @@ class Plugin extends AbstractPlugin
|
||||
],
|
||||
'redirect_success_url' => [
|
||||
'type' => 'string',
|
||||
'default' => '/shop/orders/{orderId}/complete',
|
||||
'default' => '{shopBase}/orders/{orderId}/complete',
|
||||
'label' => ['ko' => '결제 성공 리다이렉트 URL', 'en' => 'Payment Success Redirect URL'],
|
||||
'hint' => [
|
||||
'ko' => '상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.',
|
||||
'en' => 'Supports relative paths (/shop/...) or full URLs (https://...). {orderId} will be replaced with the actual order number.',
|
||||
'ko' => '{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL(https://...)을 직접 넣어도 됩니다. {orderId}는 주문번호로 자동 치환됩니다.',
|
||||
'en' => '{shopBase} is filled in from the storefront address setting. You may also enter a full URL (https://...). {orderId} will be replaced with the actual order number.',
|
||||
],
|
||||
],
|
||||
'redirect_fail_url' => [
|
||||
'type' => 'string',
|
||||
'default' => '/shop/checkout',
|
||||
'default' => '{shopBase}/checkout',
|
||||
'label' => ['ko' => '결제 실패 리다이렉트 URL', 'en' => 'Payment Failure Redirect URL'],
|
||||
'hint' => [
|
||||
'ko' => '상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.',
|
||||
'en' => 'Supports relative paths or full URLs. Error details are appended as query parameters.',
|
||||
'ko' => '{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL 을 직접 넣어도 됩니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.',
|
||||
'en' => '{shopBase} is filled in from the storefront address setting. You may also enter a full URL. Error details are appended as query parameters.',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -212,8 +212,8 @@ class Plugin extends AbstractPlugin
|
||||
'test_secret_key' => '',
|
||||
'live_client_key' => '',
|
||||
'live_secret_key' => '',
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
'redirect_success_url' => '{shopBase}/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '{shopBase}/checkout',
|
||||
'order_sheet_mode' => false,
|
||||
'method_card' => true,
|
||||
'method_virtual_account' => false,
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
"section_live_keys": "Live API Keys",
|
||||
"section_redirect": "Redirect Settings",
|
||||
"redirect_success_url": "Payment Success Redirect URL",
|
||||
"redirect_success_url_hint": "Supports relative paths (/shop/...) or full URLs (https://...). {orderId} will be replaced with the actual order number.",
|
||||
"redirect_success_url_hint": "{shopBase} is filled in from the storefront address setting. You may also enter a full URL (https://...). {orderId} will be replaced with the actual order number.",
|
||||
"redirect_fail_url": "Payment Failure Redirect URL",
|
||||
"redirect_fail_url_hint": "Supports relative paths or full URLs. Error details (error, message, orderId) are appended as query parameters.",
|
||||
"redirect_fail_url_hint": "{shopBase} is filled in from the storefront address setting. You may also enter a full URL. Error details (error, message, orderId) are appended as query parameters.",
|
||||
"save": "Save",
|
||||
"saved": "Settings saved.",
|
||||
"section_payment_methods": "Payment Methods",
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
"section_live_keys": "라이브 API 키",
|
||||
"section_redirect": "리다이렉트 설정",
|
||||
"redirect_success_url": "결제 성공 리다이렉트 URL",
|
||||
"redirect_success_url_hint": "상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 실제 주문번호로 자동 치환됩니다.",
|
||||
"redirect_success_url_hint": "{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL(https://...)을 직접 넣어도 됩니다. {orderId}는 실제 주문번호로 자동 치환됩니다.",
|
||||
"redirect_fail_url": "결제 실패 리다이렉트 URL",
|
||||
"redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보(error, message, orderId)는 쿼리 파라미터로 자동 추가됩니다.",
|
||||
"redirect_fail_url_hint": "{shopBase}는 상점 주소 설정을 따라 자동으로 채워집니다. 전체 URL 을 직접 넣어도 됩니다. 오류 정보(error, message, orderId)는 쿼리 파라미터로 자동 추가됩니다.",
|
||||
"save": "저장",
|
||||
"saved": "설정이 저장되었습니다.",
|
||||
"section_payment_methods": "결제 방식",
|
||||
|
||||
@@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\Tosspayments\Http\Requests\FailCallbackRequest;
|
||||
use Plugins\Sirsoft\Tosspayments\Http\Requests\SuccessCallbackRequest;
|
||||
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
|
||||
use Plugins\Sirsoft\Tosspayments\Support\ShopRedirectUrl;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 콜백 컨트롤러
|
||||
@@ -273,9 +274,9 @@ class PaymentCallbackController
|
||||
private function resolveSuccessUrl(string $orderId): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? ShopRedirectUrl::DEFAULT_SUCCESS_URL;
|
||||
|
||||
return str_replace('{orderId}', $orderId, $urlTemplate);
|
||||
return ShopRedirectUrl::resolve($urlTemplate, ['{orderId}' => $orderId]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,7 +288,7 @@ class PaymentCallbackController
|
||||
private function resolveFailUrl(array $queryParams = []): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
|
||||
$baseUrl = ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL);
|
||||
|
||||
if (empty($queryParams)) {
|
||||
return $baseUrl;
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\Tosspayments\Http\Requests;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Plugins\Sirsoft\Tosspayments\Support\ShopRedirectUrl;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 성공 콜백 요청 검증
|
||||
@@ -45,14 +46,15 @@ class SuccessCallbackRequest extends FormRequest
|
||||
*
|
||||
* PG 콜백은 브라우저 리다이렉트이므로 422 대신 실패 페이지로 이동합니다.
|
||||
*
|
||||
* @param Validator $validator 검증기 인스턴스
|
||||
* @param Validator $validator 검증기 인스턴스
|
||||
* @return void
|
||||
*
|
||||
* @throws HttpResponseException
|
||||
*/
|
||||
protected function failedValidation(Validator $validator): void
|
||||
{
|
||||
$settings = plugin_settings(self::PLUGIN_IDENTIFIER);
|
||||
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
|
||||
$baseUrl = ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL);
|
||||
$separator = str_contains($baseUrl, '?') ? '&' : '?';
|
||||
|
||||
throw new HttpResponseException(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Support;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 결제 완료·실패 후 돌아갈 상점 화면 주소를 만듭니다.
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다 — 쇼핑몰 모듈의 `basic_info.route_path` 로 바꿀 수 있고
|
||||
* `basic_info.no_route` 를 켜면 세그먼트 없이 루트에 붙는다. 그런데 이 플러그인의
|
||||
* 리다이렉트 기본값은 `/shop/...` 리터럴이라, 주소를 바꾼 상점에서는 결제를 마친
|
||||
* 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 로그도 남지 않는다.
|
||||
*
|
||||
* 그래서 기본값에 `{shopBase}` 자리표시자를 두고, 실제 이동 직전에 쇼핑몰 모듈의
|
||||
* 해석기(SSoT)가 계산한 기준 경로로 바꾼다. 운영자가 절대 URL(`https://...`)이나
|
||||
* 자기 경로를 직접 넣었다면 자리표시자가 없으므로 입력값이 그대로 쓰인다.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
final class ShopRedirectUrl
|
||||
{
|
||||
/** 결제 성공 후 이동할 기본 주소 */
|
||||
public const DEFAULT_SUCCESS_URL = '{shopBase}/orders/{orderId}/complete';
|
||||
|
||||
/** 결제 실패 후 이동할 기본 주소 */
|
||||
public const DEFAULT_FAIL_URL = '{shopBase}/checkout';
|
||||
|
||||
/**
|
||||
* 자리표시자를 실제 값으로 바꾼 주소를 반환합니다.
|
||||
*
|
||||
* `{shopBase}` 는 항상 치환한다 — 주소 없이 운영하는 상점(`no_route`)에서는 빈
|
||||
* 문자열이 되어 `{shopBase}/checkout` 이 `/checkout` 으로 접힌다.
|
||||
*
|
||||
* 슬래시 중복 정규화는 하지 않는다. 운영자가 절대 URL 을 넣었을 때 `https://` 의
|
||||
* 이중 슬래시까지 접어 버리기 때문이다.
|
||||
*
|
||||
* @param string $template 설정에 저장된 주소 템플릿
|
||||
* @param array<string, string> $tokens 추가 치환 토큰 (예: `['{orderId}' => '20260807-0001']`)
|
||||
* @return string 치환이 끝난 주소
|
||||
*/
|
||||
public static function resolve(string $template, array $tokens = []): string
|
||||
{
|
||||
return strtr($template, ['{shopBase}' => ShopPathResolver::base()] + $tokens);
|
||||
}
|
||||
}
|
||||
+81
-2
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
@@ -11,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Plugins\Sirsoft\Tosspayments\Support\ShopRedirectUrl;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,25 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
*/
|
||||
private static int $orderSequence = 0;
|
||||
|
||||
/**
|
||||
* 주문 통화 고정값.
|
||||
*
|
||||
* 팩토리 기본값은 이커머스 통화 설정에서 읽으므로 앞서 실행된 다른 스위트가 남긴
|
||||
* 설정 상태에 따라 통화가 달라진다. 더 중요한 것은 **소수 자릿수**다 —
|
||||
* `resolveSnapshotPaymentCharge` 는 스냅샷에 `decimal_places` 가 없으면 상점 통화
|
||||
* 설정에서 조회하는데, 그 설정이 비어 있으면 KRW(0자리)가 2자리로 해석되어 청구액이
|
||||
* 최소단위 환산으로 100배가 된다. 그러면 콜백 금액 검증이 `amount_mismatch` 로 갈라져
|
||||
* 이 파일의 리다이렉트 단언이 성공 분기가 아닌 실패 분기를 본다.
|
||||
* (실측: 게시판 스위트 직후 expected 5,000,000 vs 콜백 amount 50,000)
|
||||
*
|
||||
* 이 파일은 리다이렉트 주소를 검증하는 것이지 통화 환산을 검증하지 않으므로,
|
||||
* 통화와 자릿수를 스냅샷에 못박아 선행 스위트와 무관하게 결정적으로 동작하게 한다.
|
||||
*/
|
||||
private const TEST_CURRENCY = 'KRW';
|
||||
|
||||
/** 원화 소수 자릿수 — 스냅샷에 명시해 상점 설정 조회로 떨어지지 않게 한다. */
|
||||
private const TEST_CURRENCY_DECIMALS = 0;
|
||||
|
||||
private function createTestOrder(int $totalAmount = 50000): Order
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
@@ -78,6 +99,24 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'user_id' => $user->id,
|
||||
'order_number' => 'ORD-TEST-'.(++self::$orderSequence),
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'currency' => self::TEST_CURRENCY,
|
||||
'currency_snapshot' => [
|
||||
'base_currency' => self::TEST_CURRENCY,
|
||||
'order_currency' => self::TEST_CURRENCY,
|
||||
'exchange_rate' => 1.0,
|
||||
// 환율을 단순 float 로 두면 `resolveSnapshotPaymentCharge` 가 자릿수를
|
||||
// 상점 설정에서 조회한다. 배열 형태로 `decimal_places` 를 명시해야
|
||||
// 설정 상태와 무관하게 원화 0 자리로 고정된다.
|
||||
'exchange_rates' => [
|
||||
self::TEST_CURRENCY => [
|
||||
'rate' => 1.0,
|
||||
'decimal_places' => self::TEST_CURRENCY_DECIMALS,
|
||||
'rounding_unit' => '1',
|
||||
'rounding_method' => 'round',
|
||||
],
|
||||
],
|
||||
'snapshot_at' => now()->toIso8601String(),
|
||||
],
|
||||
'subtotal_amount' => $totalAmount,
|
||||
'total_discount_amount' => 0,
|
||||
'total_coupon_discount_amount' => 0,
|
||||
@@ -100,6 +139,8 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'payment_status' => PaymentStatusEnum::READY,
|
||||
'payment_method' => PaymentMethodEnum::CARD,
|
||||
'pg_provider' => 'tosspayments',
|
||||
'currency' => self::TEST_CURRENCY,
|
||||
'currency_snapshot' => [self::TEST_CURRENCY => 1.0],
|
||||
'paid_amount_local' => 0,
|
||||
'paid_at' => null,
|
||||
'transaction_id' => null,
|
||||
@@ -120,8 +161,8 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'is_test_mode' => true,
|
||||
'test_secret_key' => 'test_sk_mock_key',
|
||||
'test_client_key' => 'test_ck_mock_key',
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
'redirect_success_url' => ShopRedirectUrl::DEFAULT_SUCCESS_URL,
|
||||
'redirect_fail_url' => ShopRedirectUrl::DEFAULT_FAIL_URL,
|
||||
];
|
||||
|
||||
$settingsMock = $this->createMock(PluginSettingsService::class);
|
||||
@@ -131,6 +172,44 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
$this->app->instance(PluginSettingsService::class, $settingsMock);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상점 주소를 바꾼 상점에서도 결제 완료 이동 주소가 그 주소를 따르는지 확인 (공개 #85)
|
||||
*
|
||||
* 기본값이 `/shop/...` 리터럴이던 시절에는 주소를 바꾼 상점의 구매자가 결제를 마치고
|
||||
* 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 404 로그도 남지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects payment_redirect_follows_route_path
|
||||
*/
|
||||
public function test_success_redirect_follows_shop_route_path_setting(): void
|
||||
{
|
||||
Config::set(
|
||||
'g7_settings.modules.sirsoft-ecommerce.basic_info',
|
||||
['route_path' => 'store']
|
||||
);
|
||||
|
||||
$order = $this->createTestOrder(50000);
|
||||
$paymentKey = 'pk_test_route_path';
|
||||
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/payments/confirm' => Http::response(
|
||||
$this->makeMockConfirmResponse($paymentKey, $order->order_number, 50000),
|
||||
200
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
]));
|
||||
|
||||
$response->assertRedirect("/store/orders/{$order->order_number}/complete");
|
||||
}
|
||||
|
||||
// ===== Success 콜백 테스트 =====
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Plugins\Sirsoft\Tosspayments\Support\ShopRedirectUrl;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 결제 리다이렉트 주소가 상점 주소 설정을 따르는지 검증한다 (공개 #85).
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다. 기본값을 `/shop/...` 리터럴로 두면 주소를 바꾼 상점에서
|
||||
* 결제를 마친 구매자가 존재하지 않는 페이지로 떨어지는데, 리다이렉트라 예외도 로그도
|
||||
* 남지 않아 증상이 드러나지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects payment_redirect_follows_route_path, payment_redirect_drops_segment_when_no_route,
|
||||
* operator_supplied_redirect_url_is_preserved
|
||||
*
|
||||
* @group payment
|
||||
*/
|
||||
class ShopRedirectUrlTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 상점 주소 설정을 주입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $basicInfo `basic_info` 하위 설정
|
||||
*/
|
||||
private function setShopSettings(array $basicInfo): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
}
|
||||
|
||||
public function test_설정이_없으면_기본_상점_주소를_쓴다(): void
|
||||
{
|
||||
$this->setShopSettings([]);
|
||||
|
||||
$this->assertSame(
|
||||
'/shop/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/shop/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_운영자가_바꾼_상점_주소를_따른다(): void
|
||||
{
|
||||
$this->setShopSettings(['route_path' => 'store']);
|
||||
|
||||
$this->assertSame(
|
||||
'/store/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/store/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_주소_없이_운영하면_루트에_붙는다(): void
|
||||
{
|
||||
$this->setShopSettings(['no_route' => true]);
|
||||
|
||||
$this->assertSame(
|
||||
'/orders/20260807-0001/complete',
|
||||
ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_SUCCESS_URL, ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/checkout', ShopRedirectUrl::resolve(ShopRedirectUrl::DEFAULT_FAIL_URL));
|
||||
}
|
||||
|
||||
public function test_운영자가_직접_넣은_주소는_그대로_쓴다(): void
|
||||
{
|
||||
$this->setShopSettings(['route_path' => 'store']);
|
||||
|
||||
// 자리표시자가 없으면 손대지 않는다 — 절대 URL 의 '//' 도 보존되어야 한다.
|
||||
$this->assertSame(
|
||||
'https://pay.example.com/done/20260807-0001',
|
||||
ShopRedirectUrl::resolve('https://pay.example.com/done/{orderId}', ['{orderId}' => '20260807-0001'])
|
||||
);
|
||||
$this->assertSame('/my/custom/checkout', ShopRedirectUrl::resolve('/my/custom/checkout'));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Upgrade;
|
||||
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftTosspayments\V1_0_0\Migrations\BackfillShopBaseRedirectUrls;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 기설치본의 결제 리다이렉트 주소 백필 규칙 (공개 #85).
|
||||
*
|
||||
* 코드 기본값이 `{shopBase}` 자리표시자로 바뀌어도, 이미 설치를 마친 사이트는 예전
|
||||
* 리터럴이 설정 파일에 저장돼 있어 그대로 깨진 채로 남는다. 백필이 그 저장값만 바꾸되
|
||||
* **운영자가 직접 넣은 주소는 건드리지 않아야** 한다.
|
||||
*
|
||||
* 판단 규칙만 순수 함수로 검증한다 — 실제 설정 파일(storage/app/plugins/...)은 개발
|
||||
* 환경의 실데이터이므로 테스트가 건드리지 않는다.
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects legacy_default_redirect_is_backfilled, operator_custom_redirect_survives_backfill,
|
||||
* redirect_backfill_is_idempotent
|
||||
*
|
||||
* @group payment
|
||||
* @group upgrade
|
||||
*/
|
||||
class BackfillShopBaseRedirectUrlsTest extends PluginTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// DataMigration 은 AbstractUpgradeStep 이 동적으로 require 하므로 일반 autoload 대상이 아니다.
|
||||
require_once base_path('plugins/_bundled/sirsoft-tosspayments/upgrades/data/1.0.0/migrations/BackfillShopBaseRedirectUrls.php');
|
||||
}
|
||||
|
||||
public function test_예전_기본값이_저장돼_있으면_자리표시자로_바꾼다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
]);
|
||||
|
||||
$this->assertSame('{shopBase}/orders/{orderId}/complete', $settings['redirect_success_url']);
|
||||
$this->assertSame('{shopBase}/checkout', $settings['redirect_fail_url']);
|
||||
$this->assertSame(['redirect_success_url', 'redirect_fail_url'], $updated);
|
||||
}
|
||||
|
||||
public function test_운영자가_직접_넣은_주소는_건드리지_않는다(): void
|
||||
{
|
||||
$custom = [
|
||||
'redirect_success_url' => 'https://pay.example.com/done/{orderId}',
|
||||
'redirect_fail_url' => '/my/custom/checkout',
|
||||
];
|
||||
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply($custom);
|
||||
|
||||
$this->assertSame($custom, $settings);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
|
||||
public function test_한쪽만_예전_기본값이면_그쪽만_바꾼다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => 'https://pay.example.com/fail',
|
||||
]);
|
||||
|
||||
$this->assertSame('{shopBase}/orders/{orderId}/complete', $settings['redirect_success_url']);
|
||||
$this->assertSame('https://pay.example.com/fail', $settings['redirect_fail_url']);
|
||||
$this->assertSame(['redirect_success_url'], $updated);
|
||||
}
|
||||
|
||||
public function test_재실행해도_변화가_없다(): void
|
||||
{
|
||||
[$once] = BackfillShopBaseRedirectUrls::apply([
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
]);
|
||||
|
||||
[$twice, $updated] = BackfillShopBaseRedirectUrls::apply($once);
|
||||
|
||||
$this->assertSame($once, $twice);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
|
||||
public function test_키가_없으면_추가하지_않는다(): void
|
||||
{
|
||||
[$settings, $updated] = BackfillShopBaseRedirectUrls::apply(['is_test_mode' => true]);
|
||||
|
||||
$this->assertSame(['is_test_mode' => true], $settings);
|
||||
$this->assertSame([], $updated);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
# audit:allow test-scenario-coverage reason: 축이 서로 다른 계층에 격리 검증된다 — route_setting 은
|
||||
# 해석기 단위 테스트(플러그인 4종 ShopRedirectUrlTest + 모듈 ShopPathResolverTest)가, stored_value 는
|
||||
# 업그레이드 백필 단위 테스트(플러그인 4종 BackfillShopBaseRedirectUrlsTest)가, redirect_kind 는 양쪽이
|
||||
# 성공·실패 키를 각각 단언한다. 한 메서드가 3축 조합을 동시에 재현하지 않으므로 cross product 18조합을
|
||||
# docblock @scenario 로 나열하면 거짓 매핑이 된다(같은 플러그인의 S4·S5 매니페스트 선례 동일).
|
||||
# effects 는 전부 실제 검증 메서드에 @effects 로 매핑되어 있으며 그것이 실질 커버리지다.
|
||||
# 시나리오 매니페스트 — 결제 리다이렉트 주소가 상점 주소 설정을 따른다 (공개 #85)
|
||||
# 참조: docs/testing-guide.md "기능 단위 시나리오 매트릭스"
|
||||
|
||||
feature: payment_redirect_follows_shop_route_path
|
||||
|
||||
description: >-
|
||||
상점 주소는 운영자 설정이다 — 쇼핑몰 모듈의 `basic_info.route_path` 로 바꿀 수 있고
|
||||
`basic_info.no_route` 를 켜면 세그먼트 없이 루트에 붙는다. 그런데 결제 플러그인 4종의
|
||||
결제 완료·실패 리다이렉트 기본값은 `/shop/...` 리터럴이라, 주소를 바꾼 상점에서는 결제를
|
||||
마친 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 404 로그도 남지 않아
|
||||
증상이 드러나지 않는다.
|
||||
|
||||
기본값에 `{shopBase}` 자리표시자를 두고 이동 직전에 쇼핑몰 모듈의 해석기(SSoT)가 계산한
|
||||
기준 경로로 바꾼다. 운영자가 직접 넣은 주소는 자리표시자가 없으므로 그대로 쓰인다.
|
||||
이미 설치를 마친 사이트는 예전 리터럴이 설정 파일에 저장돼 있으므로, 저장값이 예전
|
||||
기본값과 정확히 같을 때만 업그레이드 시점에 자리표시자 형태로 백필한다.
|
||||
|
||||
test_files:
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Unit/Support/ShopRedirectUrlTest.php
|
||||
- plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Support/ShopRedirectUrlTest.php
|
||||
- plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Support/ShopRedirectUrlTest.php
|
||||
- plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Support/ShopRedirectUrlTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php
|
||||
# 기설치본 백필 판단 규칙 (stored_value 축)
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Unit/Upgrade/BackfillShopBaseRedirectUrlsTest.php
|
||||
- plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Upgrade/BackfillShopBaseRedirectUrlsTest.php
|
||||
- plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Upgrade/BackfillShopBaseRedirectUrlsTest.php
|
||||
- plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Upgrade/BackfillShopBaseRedirectUrlsTest.php
|
||||
# 상점 주소 해석기 자체(SSoT)의 축 검증
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/ShopPathResolverTest.php
|
||||
|
||||
axes:
|
||||
route_setting:
|
||||
- default # route_path 미설정/기본값 'shop'
|
||||
- custom # 운영자가 바꾼 주소 (예: 'store')
|
||||
- no_route # 주소 없이 루트 운영
|
||||
|
||||
redirect_kind:
|
||||
- success # 결제 완료 후 이동
|
||||
- fail # 결제 실패 후 이동
|
||||
|
||||
stored_value:
|
||||
- legacy_default # 예전 기본값 리터럴이 저장돼 있음 (기설치본)
|
||||
- placeholder # 새 기본값(자리표시자)
|
||||
- operator_custom # 운영자가 직접 넣은 절대 URL/자기 경로
|
||||
|
||||
exclusions:
|
||||
- { stored_value: operator_custom, route_setting: no_route, reason: "운영자 지정 주소는 상점 주소 설정과 무관하게 그대로 쓰인다 — route_setting 축이 결과에 영향을 주지 않음" }
|
||||
|
||||
effects:
|
||||
# 해석 규칙
|
||||
- payment_redirect_follows_route_path # 변경 주소 반영 (성공·실패 양쪽)
|
||||
- payment_redirect_drops_segment_when_no_route # no_route → 루트 경로
|
||||
- operator_supplied_redirect_url_is_preserved # 자리표시자 없는 입력은 무변경 (절대 URL 의 '//' 보존)
|
||||
# 기설치본 백필
|
||||
- legacy_default_redirect_is_backfilled # 예전 기본값 == 저장값일 때만 자리표시자로 갱신
|
||||
- operator_custom_redirect_survives_backfill # 운영자 입력값은 백필이 건드리지 않음
|
||||
- redirect_backfill_is_idempotent # 재실행해도 변화 없음
|
||||
# 회귀
|
||||
- default_shop_redirect_unchanged # 기본 주소 상점의 이동 주소는 종전과 동일
|
||||
|
||||
validation:
|
||||
audit_rule: test-scenario-coverage
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* v1.0.0 업그레이드 스텝
|
||||
*
|
||||
* 저장된 결제 리다이렉트 주소를 상점 주소 설정(route_path / no_route)을 따르도록 백필한다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.0/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path B
|
||||
*/
|
||||
class Upgrade_1_0_0 extends AbstractUpgradeStep {}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftTosspayments\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 저장된 결제 리다이렉트 주소를 상점 주소 설정을 따르도록 백필한다.
|
||||
*
|
||||
* 배경 (공개 #85):
|
||||
*
|
||||
* 결제 완료·실패 후 돌아갈 주소의 기본값이 `/shop/...` 리터럴이었다. 상점 주소는
|
||||
* 운영자 설정(`basic_info.route_path` / `no_route`)이라 주소를 바꾼 상점에서는
|
||||
* 결제를 마친 구매자가 존재하지 않는 페이지로 떨어졌다. 리다이렉트라 예외도 로그도
|
||||
* 남지 않아 증상이 드러나지 않는다.
|
||||
*
|
||||
* 코드 기본값은 `{shopBase}` 자리표시자로 바뀌었지만, 이미 설치를 마친 사이트는
|
||||
* 예전 리터럴이 설정 파일에 저장돼 있어 그대로 깨진 채로 남는다. 본 마이그레이션이
|
||||
* 그 저장값을 자리표시자 형태로 바꾼다.
|
||||
*
|
||||
* 운영자 입력 보존: 저장값이 **예전 기본값과 정확히 같을 때만** 바꾼다. 운영자가
|
||||
* 직접 넣은 주소(절대 URL·자기 경로)는 의도된 값이므로 건드리지 않는다.
|
||||
*
|
||||
* 멱등: 이미 자리표시자로 바뀐 값은 예전 기본값과 다르므로 재실행해도 변화가 없다.
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이 플러그인의 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-tosspayments/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 예전 기본값 => 새 기본값. 저장값이 좌변과 정확히 같을 때만 우변으로 바꾼다.
|
||||
*
|
||||
* @var array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
private const REPLACEMENTS = [
|
||||
'redirect_success_url' => ['/shop/orders/{orderId}/complete', '{shopBase}/orders/{orderId}/complete'],
|
||||
'redirect_fail_url' => ['/shop/checkout', '{shopBase}/checkout'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillShopBaseRedirectUrls';
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 리다이렉트 주소를 자리표시자 형태로 백필한다. idempotent.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[tosspayments] 설정 파일 없음 — 새 기본값으로 동작하므로 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = json_decode(File::get($path), true);
|
||||
if (! is_array($settings)) {
|
||||
$context->logger->warning('[tosspayments] 설정 JSON 형식 비정상 — 리다이렉트 주소 백필 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$settings, $updated] = self::apply($settings);
|
||||
|
||||
if ($updated === []) {
|
||||
$context->logger->info('[tosspayments] 리다이렉트 주소가 예전 기본값이 아님 — 운영자 설정 보존, 변경 없음');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[tosspayments] 결제 리다이렉트 주소를 상점 주소 설정 기준으로 백필 완료', [
|
||||
'updated_keys' => $updated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 설정 배열에 백필을 적용한 결과를 반환합니다.
|
||||
*
|
||||
* 파일 I/O 와 분리한 순수 함수다 — 실제 설정 파일을 건드리지 않고 판단 규칙
|
||||
* (예전 기본값과 **정확히 같을 때만** 교체)을 검증할 수 있다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 저장된 설정
|
||||
* @return array{0: array<string, mixed>, 1: array<int, string>} [갱신된 설정, 바뀐 키 목록]
|
||||
*/
|
||||
public static function apply(array $settings): array
|
||||
{
|
||||
$updated = [];
|
||||
|
||||
foreach (self::REPLACEMENTS as $key => [$legacy, $replacement]) {
|
||||
if (($settings[$key] ?? null) === $legacy) {
|
||||
$settings[$key] = $replacement;
|
||||
$updated[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return [$settings, $updated];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user