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:
@@ -111,6 +111,10 @@
|
||||
|
||||
- 레이아웃 코드 편집 화면이 파일 목록을 불러올 때 편집하지 않는 레이아웃의 본문까지 전부 내려받던 문제를 수정했습니다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB가 브라우저에 쌓여 화면이 크게 느려지거나, 디버그 모드에서는 탭이 메모리 부족으로 종료됐습니다. 이제 목록에는 이름·설명·크기·수정일만 실리고 본문은 선택한 파일만 불러옵니다.
|
||||
|
||||
#### 화면 편집기
|
||||
|
||||
- 화면 편집기가 주소 일부를 설정으로 바꿀 수 있는 화면도 알아보도록 화면 인식 방식을 넓혔습니다. 예를 들어 쇼핑몰은 주소를 바꾸거나 주소 없이 운영할 수 있는데, 편집기는 주소가 한 토막 있는 경우만 인식해 그 외의 사이트에서는 해당 화면의 상태 미리보기 전환이 표시되지 않았습니다. 이제 확장이 "이 토막은 있을 수도 없을 수도 있다"고 알려 줄 수 있으며, 함께 배포되는 쇼핑몰에 이미 적용되어 있습니다.
|
||||
|
||||
#### 설정 화면 입력
|
||||
|
||||
- 설정 화면에서 여러 입력칸을 고친 뒤 다른 탭에 갔다 돌아오면, 먼저 고친 칸에 입력했던 값이 그대로 남던 문제를 수정했습니다. 저장된 값은 원래 값이라 화면에 보이는 값과 실제 값이 어긋났고, 새로고침해야 드러났습니다. 이제 탭을 오갈 때 모든 입력칸이 저장된 값으로 함께 돌아옵니다.
|
||||
|
||||
@@ -191,6 +191,17 @@ interface UserRepositoryInterface
|
||||
*/
|
||||
public function getIdsByUuids(array $uuids): array;
|
||||
|
||||
/**
|
||||
* 사용자 ID 목록에 해당하는 이름을 ID 로 색인해 반환합니다.
|
||||
*
|
||||
* 목록 화면이 작성자 이름을 행마다 조회하면 N+1 이 되므로, 표시에 필요한
|
||||
* 이름만 한 번에 모아 오기 위한 배치 조회입니다.
|
||||
*
|
||||
* @param array $ids 사용자 ID 배열
|
||||
* @return array<int, string> 사용자 ID => 이름
|
||||
*/
|
||||
public function getNamesByIds(array $ids): array;
|
||||
|
||||
/**
|
||||
* 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다.
|
||||
*
|
||||
|
||||
@@ -406,6 +406,24 @@ class UserRepository implements UserRepositoryInterface
|
||||
return User::whereIn('uuid', $uuids)->pluck('id')->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 ID 목록에 해당하는 이름을 ID 로 색인해 반환합니다.
|
||||
*
|
||||
* 목록 화면이 작성자 이름을 행마다 조회하면 N+1 이 되므로, 표시에 필요한
|
||||
* 이름만 한 번에 모아 옵니다.
|
||||
*
|
||||
* @param array $ids 사용자 ID 배열
|
||||
* @return array<int, string> 사용자 ID => 이름
|
||||
*/
|
||||
public function getNamesByIds(array $ids): array
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return User::whereIn('id', $ids)->pluck('name', 'id')->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.5] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- 관리자 게시글 작성·수정 화면의 첨부 안내 문구 일본어 번역 추가 — 게시판에 설정된 첨부 개수·용량이 일본어 로케일에서도 그대로 표시됩니다.
|
||||
|
||||
### Removed
|
||||
|
||||
- 화면에서 쓰이지 않던 첨부 안내(고정 5개·10MB)와 답변글 깊이 안내(1~5) 번역을 제거했습니다. 실제 상한과 다른 값이라 남겨 둘 이유가 없습니다.
|
||||
|
||||
## [1.0.4] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -717,12 +717,11 @@
|
||||
"html_preview_warning": "HTMLレンダリングの注意",
|
||||
"no_content_preview": "内容を入力するとプレビューが表示されます",
|
||||
"attachments": "添付ファイル",
|
||||
"attachments_desc": "最大{{maxFiles}}個、各{{maxSize}}MB以下",
|
||||
"attached_files": "添付されたファイル",
|
||||
"confirm_remove_attachment_title": "添付ファイルを削除",
|
||||
"confirm_remove_attachment_message": "ファイルはすぐに削除されます。投稿の保存に関係なく、復元することはできません。",
|
||||
"attachments_limit": "最大5個のファイル、各10MB以下 (jpg、jpeg、png、gif、pdf、zip)",
|
||||
"drop_files_or_click": "クリックしてアップロード、またはドラッグ&ドロップ",
|
||||
"max_10mb": "最大10MB",
|
||||
"file_upload_component_needed": "FileUploadコンポーネント開発が必要です",
|
||||
"uploading": "アップロード中...",
|
||||
"reply_to": "以下の投稿に対する回答です",
|
||||
@@ -902,7 +901,6 @@
|
||||
"max_content_length": "最大投稿文字数",
|
||||
"use_reply": "返信機能",
|
||||
"max_reply_depth": "返信の最大深さ",
|
||||
"max_reply_depth_hint": "1~5の間の値を入力してください",
|
||||
"use_comment": "コメント機能",
|
||||
"comment_order": "コメント並べ替え順序",
|
||||
"min_comment_length": "最小コメント文字数",
|
||||
@@ -974,7 +972,6 @@
|
||||
"min_content_length": "投稿本文の最小文字数を設定します",
|
||||
"max_content_length": "投稿本文の最大文字数を設定します",
|
||||
"use_reply": "ONに設定する必要があります新しい掲示板に返信機能が適用されます。",
|
||||
"max_reply_depth": "返信の最大ネスト深さを設定します (1~5)",
|
||||
"blocked_keywords": "登録されたキーワードを含む投稿/コメントは作成がブロックされます",
|
||||
"use_comment": "ONに設定する必要があります新しい掲示板にコメント機能が適用されます。以下のコメント詳細設定も同時に適用されます。",
|
||||
"comment_order": "コメントの基本並べ替え方向を設定します",
|
||||
|
||||
@@ -174,12 +174,11 @@
|
||||
"html_preview_warning": "HTMLレンダリング注意",
|
||||
"no_content_preview": "内容を入力するとプレビューが表示されます",
|
||||
"attachments": "添付ファイル",
|
||||
"attachments_desc": "最大{{maxFiles}}個、各{{maxSize}}MB以下",
|
||||
"attached_files": "添付ファイル",
|
||||
"confirm_remove_attachment_title": "添付ファイル削除",
|
||||
"confirm_remove_attachment_message": "ファイルは即座に削除されます。投稿の保存とは関係なく、復元できません。",
|
||||
"attachments_limit": "最大5個のファイル、各10MB以下 (jpg、jpeg、png、gif、pdf、zip)",
|
||||
"drop_files_or_click": "クリックしてアップロード、またはドラッグアンドドロップ",
|
||||
"max_10mb": "最大10MB",
|
||||
"file_upload_component_needed": "FileUploadコンポーネント開発が必要です",
|
||||
"uploading": "アップロード中...",
|
||||
"reply_to": "次の投稿への返信です",
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
"max_content_length": "最大投稿文字数",
|
||||
"use_reply": "回答投稿の使用",
|
||||
"max_reply_depth": "回答投稿の最大深さ",
|
||||
"max_reply_depth_hint": "1~5の間の値を入力してください",
|
||||
"use_comment": "コメントの使用",
|
||||
"comment_order": "コメント並べ替え順序",
|
||||
"min_comment_length": "最小コメント文字数",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-board) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-board",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- 결제 성공·실패 리다이렉트 주소 안내 문구를 갱신했습니다 — 상점 주소 설정을 따라 자동으로 채워지는 `{shopBase}` 자리표시자 설명으로 바뀌었습니다.
|
||||
|
||||
## [1.1.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -316,9 +316,9 @@
|
||||
"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両方が可能です。エラー情報はクエリパラメーターとして自動追加されます。",
|
||||
"redirect_fail_url_hint": "{shopBase}はストア住所設定に従って自動的に入力されます。完全URLを直接入力することもできます。エラー情報はクエリパラメーターとして自動追加されます。",
|
||||
"section_vbank_notify": "仮想口座入金通知 URL (PC)",
|
||||
"vbank_notify_hint": "KGイニシス加盟店管理画面でこのURLをPC仮想口座入金通知URLとして登録する必要があります。",
|
||||
"vbank_notify_copied": "URLがクリップボードにコピーされました。",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_kginicis",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- 결제 성공·실패 리다이렉트 주소 안내 문구를 갱신했습니다 — 상점 주소 설정을 따라 자동으로 채워지는 `{shopBase}` 자리표시자 설명으로 바뀌었습니다.
|
||||
|
||||
## [1.0.1] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -113,9 +113,9 @@
|
||||
"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の両方が可能です。エラー情報はクエリパラメータとして自動追加されます。",
|
||||
"redirect_fail_url_hint": "{shopBase}はストア住所設定に従って自動的に入力されます。完全URLを直接入力することもできます。エラー情報はクエリパラメータとして自動追加されます。",
|
||||
"section_vbank": "仮想口座設定",
|
||||
"section_vbank_hint": "仮想口座決済に関する設定です。",
|
||||
"vbank_expire_days": "入金有効期限",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nhnkcp",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- 결제 성공·실패 리다이렉트 주소 안내 문구를 갱신했습니다 — 상점 주소 설정을 따라 자동으로 채워지는 `{shopBase}` 자리표시자 설명으로 바뀌었습니다.
|
||||
|
||||
## [1.0.1] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
"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両方が可能です。エラー情報はクエリパラメーターとして自動追加されます。",
|
||||
"redirect_fail_url_hint": "{shopBase}はストア住所設定に従って自動的に入力されます。完全URLを直接入力することもできます。エラー情報はクエリパラメーターとして自動追加されます。",
|
||||
"use_escrow": "エスクロー決済使用",
|
||||
"use_escrow_hint": "実物商品販売時に購入者保護のためのエスクロー決済を有効化します。エスクロー決済後の配送登録が必要です。",
|
||||
"section_easy_pay": "簡易決済設定",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nicepayments",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- 결제 성공·실패 리다이렉트 주소 안내 문구를 갱신했습니다 — 상점 주소 설정을 따라 자동으로 채워지는 `{shopBase}` 자리표시자 설명으로 바뀌었습니다.
|
||||
|
||||
## [1.0.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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": "決済方法",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-tosspayments) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-tosspayments",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- 화면 편집기 상품 상세의 상태 미리보기 라벨(`editor.state.out_of_stock`, `editor.state.sold_out`)을 서로 구분되도록 정정했습니다. 두 상태 모두 "品切れ" 로 표시되어 목록에서 어느 쪽인지 구별할 수 없었습니다.
|
||||
|
||||
## [1.1.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -887,7 +887,7 @@
|
||||
"with_data": "データあり",
|
||||
"empty_data": "データなし",
|
||||
"in_stock": "在庫あり",
|
||||
"out_of_stock": "品切れ",
|
||||
"out_of_stock": "在庫切れ (販売中)",
|
||||
"cart_with_items": "商品が追加されました",
|
||||
"cart_empty": "カートが空です",
|
||||
"read_normal": "一般投稿",
|
||||
@@ -924,7 +924,7 @@
|
||||
"board_create": "新規投稿",
|
||||
"board_edit": "投稿編集",
|
||||
"shop_access_denied": "アクセス制限",
|
||||
"sold_out": "品切れ",
|
||||
"sold_out": "品切れ設定",
|
||||
"suspended": "販売停止",
|
||||
"coming_soon": "販売予定",
|
||||
"cart_ordering": "注文処理中",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-basic",
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
- 게시글 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 게시글의 본문 앞부분까지 함께 읽어 느려졌지만, 이제 현재 페이지의 게시글만 본문 미리보기를 읽습니다. 공지 노출·답글 표시·다음 페이지 버튼은 이전과 동일하게 동작합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 신고 관리 목록도 같은 방식으로 개선했습니다. 신고 건수·대상 글 상태처럼 목록에 함께 표시되는 정보를 예전에는 건너뛰는 신고까지 모두 계산했지만, 이제 현재 페이지의 신고에 대해서만 계산합니다. 신고가 많이 쌓인 사이트에서 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 마이페이지의 「내가 쓴 댓글」·「내가 댓글 단 글」 목록도 같은 방식으로 개선했습니다. 각 글의 최근 댓글을 예전에는 건너뛰는 글까지 모두 찾아봤지만, 이제 현재 페이지의 글에 대해서만 찾습니다. 댓글 활동이 많은 회원의 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 게시글 목록을 조회순으로 볼 때도 뒤쪽 페이지가 빠르게 열리도록 색인을 정비했습니다. 게시판 설정의 기본 정렬을 조회순으로 두었거나 목록에서 조회순으로 바꿔 보는 경우가 해당됩니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 게시글이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 글쓰기가 잠시 대기할 수 있습니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 목록 첫 페이지의 공지를 개수 제한 없이 모두 읽던 것에 안전 상한을 두었습니다. 공지는 관리자가 등록하는 글이라 보통 문제가 되지 않지만, 어떤 이유로든 공지가 많이 쌓인 게시판에서 목록을 여는 것만으로 부담이 커지던 여지를 없앴습니다. 상한에 걸리면 최근 공지가 우선 표시됩니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 답변글 최대 깊이의 안내 상한 표기를 실제 설정 가능 범위(1~10)에 맞게 정정했습니다. 실제로는 10까지 설정할 수 있는데 일부 안내에만 5까지로 적혀 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 게시글이 많은 게시판에서 목록 정렬이 더 빨라지도록 색인을 정비했습니다. 신고 관리 목록에도 같은 정비를 적용했습니다. 같은 시각에 등록된 게시글이 많은 구간에서 순서를 정하느라 생기던 추가 작업이 사라집니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 게시글이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 글쓰기가 잠시 대기할 수 있습니다.
|
||||
- 관리자 게시판 목록이 게시판마다 매니저·스텝 역할과 그 역할에 속한 회원 명단(이름·이메일)을 함께 내려주지 않습니다. 목록 화면이 쓰지 않는 값이라 게시판 수만큼 늘어나던 조회와 전송량이 사라집니다. 역할 지정은 종전대로 게시판 상세·설정 화면에서 하며, 목록에 보이는 항목은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 한 글의 댓글이 아주 많아 목록이 일정 수에서 끊긴 경우, 끊겼다는 사실과 전체 건수를 함께 알려 줍니다. 예전에는 조용히 잘려 "댓글이 그만큼뿐" 으로 보였습니다.
|
||||
@@ -43,6 +46,7 @@
|
||||
### Fixed
|
||||
|
||||
- 게시판 알림 설정 화면에서 모든 알림의 제목·수신자·활성 여부가 비어 보이고 「이 채널에 대한 템플릿이 없습니다」로만 표시되던 문제를 수정했습니다. 편집 창을 열어도 내용이 채워지지 않았습니다. 채널을 전환하거나 새로고침해도 마찬가지였습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 관리자 게시글 작성·수정 화면의 첨부 영역에 첨부 가능한 개수와 파일당 용량 안내가 표시됩니다. 안내는 그 게시판에 설정된 값을 그대로 보여 주므로, 게시판마다 다른 제한이 화면에 정확히 나타납니다. 이전에는 안내가 아예 없어 저장을 시도한 뒤에야 제한을 알 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 게시판 설정의 '새 글 표시 시간'이 저장 직후에는 숫자가 아닌 형태로 다뤄져, 조회 시점에 따라 값의 형태가 달라지던 문제를 수정했습니다. 새 글 표시 여부 판정에 쓰이는 값이므로 항상 숫자로 처리합니다.
|
||||
- 게시글이 아주 많은 게시판에서 목록의 글 번호가 0 이나 음수로 표시되던 문제를 수정했습니다. 총 건수를 끝까지 세지 못하는 경우에는 번호를 지어내지 않고 「-」로 표시합니다.
|
||||
- 통합 검색 결과에서 작성자 자리에 번역되지 않은 내부 문구가 그대로 보이던 문제를 수정했습니다. 이제 「비회원」으로 표시됩니다.
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ return new class extends Migration
|
||||
$table->enum('secret_mode', ['disabled', 'enabled', 'always'])->default('disabled')->comment('비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정)');
|
||||
$table->boolean('use_comment')->default(true)->comment('댓글 기능 사용');
|
||||
$table->boolean('use_reply')->default(true)->comment('게시글 답변 기능 사용 (댓글에 대한 답글 아님)');
|
||||
$table->unsignedSmallInteger('max_reply_depth')->default(5)->comment('답변글 최대 깊이 (1~5)');
|
||||
$table->unsignedSmallInteger('max_reply_depth')->default(5)->comment('답변글 최대 깊이 (1~10)');
|
||||
$table->boolean('use_report')->default(false)->comment('게시글/댓글 신고 기능 사용');
|
||||
$table->unsignedInteger('new_display_hours')->default(24)->comment('신규 게시글 표시 기간 (시간 단위)');
|
||||
$table->unsignedInteger('min_title_length')->default(2)->comment('최소 제목 글자 수');
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* boards.max_reply_depth 컬럼 comment 의 상한 표기를 실제 상한과 맞춥니다.
|
||||
*
|
||||
* 실제 허용 범위의 SSoT 는 `config/board.php` 의 `max_reply_depth_min=1` /
|
||||
* `max_reply_depth_max=10` 이며, 설정 화면과 검증 규칙이 이 값을 쓴다. 그런데 컬럼
|
||||
* comment 만 `(1~5)` 로 남아 있었고, API 레퍼런스 문서가 컬럼 comment 에서 자동
|
||||
* 생성되므로(`ColumnCommentResolver`) 계약 문서 네 곳이 실제보다 좁은 상한을
|
||||
* 공개하고 있었다 — 6~10 을 넣어도 저장되는데 문서는 5 까지라고 말한다.
|
||||
*
|
||||
* 데이터는 바꾸지 않는다. 타입·기본값·NULL 허용은 원 정의와 동일하게 유지하고
|
||||
* comment 만 교체한다.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('boards')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
DB::statement(
|
||||
"ALTER TABLE {$prefix}boards MODIFY COLUMN max_reply_depth "
|
||||
.'SMALLINT UNSIGNED NOT NULL DEFAULT 5 '
|
||||
."COMMENT '답변글 최대 깊이 (1~10)'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* comment 표기만 원복한다 (데이터·타입·기본값 무변경).
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('boards')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
DB::statement(
|
||||
"ALTER TABLE {$prefix}boards MODIFY COLUMN max_reply_depth "
|
||||
.'SMALLINT UNSIGNED NOT NULL DEFAULT 5 '
|
||||
."COMMENT '답변글 최대 깊이 (1~5)'"
|
||||
);
|
||||
}
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 게시글 목록의 조회수 정렬 색인을 추가합니다.
|
||||
*
|
||||
* 조회수 정렬은 화면에서 실제로 도달 가능한 경로다 — 게시판 설정의 기본 정렬
|
||||
* (`order_by = 'view_count'`)과 목록 URL(`?sort_by=view_count`) 둘 다 있고, 저장소
|
||||
* 정렬 화이트리스트에도 들어 있다. 그런데 목록 술어(`board_id` + `is_notice=0`
|
||||
* + `parent_id IS NULL` + `deleted_at IS NULL`)를 등치 사슬로 덮으면서 `view_count`
|
||||
* 로 끝나는 색인이 없어, 뒤쪽 페이지가 테이블 전체를 훑고 정렬했다.
|
||||
*
|
||||
* 필요한 색인은 계측 프로파일 선언(filters / order / soft_delete)에서 도출한 것이며,
|
||||
* `ListIndexCoverageTest` 가 같은 규칙으로 전 목록을 검사한다.
|
||||
*
|
||||
* 기존 `idx_board_posts_board_view_count (board_id, view_count)` 는 지우지 않는다 —
|
||||
* 인기글 조회(`getPopularPosts`)가 쓰는 색인이며, 이 목록 색인은 앞쪽에 등치 컬럼이
|
||||
* 더 붙어 있어 `(board_id, view_count)` 만 필요한 그 쿼리를 대신하지 못한다.
|
||||
* (`2026_04_17_000004` 의 주석이 `USE INDEX` 힌트를 언급하나 현재 코드에 힌트는 없다.)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TABLE = 'board_posts';
|
||||
|
||||
private const INDEX_NAME = 'idx_board_posts_list_views';
|
||||
|
||||
/** @var array<int, string> */
|
||||
private const INDEX_COLUMNS = ['board_id', 'is_notice', 'parent_id', 'deleted_at', 'view_count', 'id'];
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable(self::TABLE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = array_column(Schema::getIndexes(self::TABLE), 'name');
|
||||
|
||||
Schema::table(self::TABLE, function (Blueprint $table) use ($existing) {
|
||||
if (! in_array(self::INDEX_NAME, $existing, true)) {
|
||||
$table->index(self::INDEX_COLUMNS, self::INDEX_NAME);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable(self::TABLE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = array_column(Schema::getIndexes(self::TABLE), 'name');
|
||||
|
||||
Schema::table(self::TABLE, function (Blueprint $table) use ($existing) {
|
||||
if (in_array(self::INDEX_NAME, $existing, true)) {
|
||||
$table->dropIndex(self::INDEX_NAME);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -389,7 +389,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) |
|
||||
| use_comment | boolean | `true` | 댓글 기능 사용 |
|
||||
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
|
||||
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
|
||||
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
|
||||
| show_view_count | boolean | `true` | 조회수 노출 |
|
||||
@@ -754,7 +754,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) |
|
||||
| use_comment | boolean | `true` | 댓글 기능 사용 |
|
||||
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
|
||||
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
|
||||
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
|
||||
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
|
||||
@@ -972,7 +972,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) |
|
||||
| use_comment | boolean | `true` | 댓글 기능 사용 |
|
||||
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
|
||||
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
|
||||
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
|
||||
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
|
||||
@@ -1979,7 +1979,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) |
|
||||
| use_comment | boolean | `true` | 댓글 기능 사용 |
|
||||
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
|
||||
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
|
||||
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
|
||||
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
|
||||
|
||||
@@ -880,6 +880,26 @@ class Module extends AbstractModule
|
||||
'seed_overrides' => ['board_id' => 1, 'is_notice' => 0, 'parent_id' => null],
|
||||
'soft_delete' => true,
|
||||
],
|
||||
'board_posts_by_view_count' => [
|
||||
'type' => 'list',
|
||||
'label' => '게시글 목록 (조회순)',
|
||||
'table' => 'board_posts',
|
||||
'columns' => [
|
||||
'id', 'board_id', 'user_id', 'parent_id', 'category',
|
||||
'title', 'author_name', 'content_mode',
|
||||
'is_notice', 'is_secret', 'status', 'depth',
|
||||
'view_count', 'comments_count', 'replies_count', 'attachments_count',
|
||||
'trigger_type', 'ip_address', 'created_at', 'updated_at', 'deleted_at',
|
||||
],
|
||||
// 조회수 정렬은 화면에서 실제로 도달 가능한 경로다 — 게시판 설정
|
||||
// `order_by='view_count'` 와 목록 URL `?sort_by=view_count` 둘 다 있고,
|
||||
// 저장소 정렬 화이트리스트에도 들어 있다. 작성일 정렬과 같은 술어에
|
||||
// 정렬 축만 다르므로 별도 프로파일로 재야 인덱스 커버리지가 판정된다.
|
||||
'order' => [['view_count', 'desc'], ['id', 'desc']],
|
||||
'filters' => ['board_id' => 1, 'is_notice' => 0, 'parent_id' => null],
|
||||
'seed_overrides' => ['board_id' => 1, 'is_notice' => 0, 'parent_id' => null],
|
||||
'soft_delete' => true,
|
||||
],
|
||||
// 댓글은 계측 프로파일을 두지 않는다. 페이지네이션되는 댓글 목록은 회원 본인 댓글
|
||||
// 목록뿐이고(CommentRepository), 그 쿼리에는 회원 스코프 · 삭제 게시글 제외
|
||||
// (whereExists 서브쿼리) · 비활성 게시판 제외가 무조건 붙는다. 선언형 필터로
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file admin-board-post-form-attachments-desc.test.tsx
|
||||
* @description 관리자 게시글 폼 첨부 안내가 게시판 설정값으로 렌더되는지 검증 (공개 #81 L-01)
|
||||
*
|
||||
* 결함: 관리자 게시글 폼의 첨부 영역에는 안내 문구가 아예 없었고, 대신 사장된 lang 키
|
||||
* ("최대 5개 파일, 각 10MB 이하")가 남아 있었다. 그 문구는 어디에도 렌더되지 않는데,
|
||||
* 설령 렌더되었어도 게시판마다 다른 실제 제한과 무관한 고정 숫자였다.
|
||||
*
|
||||
* 운영자는 게시판 설정에서 첨부 개수·용량을 바꿀 수 있으므로, 안내는 반드시
|
||||
* `form_meta.data.board.max_file_count/max_file_size` 를 파라미터로 받아야 한다.
|
||||
* 유저 폼(`_post_form.json` 의 `board.form.attachments_desc`)이 이미 그 형태다.
|
||||
*
|
||||
* @scenario surface=admin_post_form, source=board_settings
|
||||
* @effects admin_attachment_hint_renders_board_limits
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
createLayoutTest,
|
||||
createMockComponentRegistryWithBasics,
|
||||
screen,
|
||||
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
|
||||
import attachmentsPartial from '../../../layouts/admin/partials/admin_board_post_form/_attachments.json';
|
||||
import koPosts from '../../../lang/partial/ko/admin/posts.json';
|
||||
|
||||
/** 안내 노드 id (레이아웃 JSON) */
|
||||
const DESC_ID = 'attachments_desc';
|
||||
|
||||
/**
|
||||
* partial 트리에서 id 로 노드를 찾습니다.
|
||||
*
|
||||
* @param node 순회 시작 노드
|
||||
* @param id 찾을 노드 id
|
||||
* @returns 찾은 노드 (없으면 null)
|
||||
*/
|
||||
function findById(node: any, id: string): any | null {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findById(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('관리자 첨부 안내 — 선언 (#81 L-01)', () => {
|
||||
it('안내 노드가 존재하고 게시판 설정값을 파라미터로 넘긴다', () => {
|
||||
const node = findById(attachmentsPartial, DESC_ID);
|
||||
|
||||
expect(node, '첨부 안내 노드가 없다').not.toBeNull();
|
||||
expect(node.text).toContain('$t:sirsoft-board.admin.posts.form.attachments_desc');
|
||||
expect(node.text).toContain('maxFiles={{form_meta?.data?.board?.max_file_count');
|
||||
expect(node.text).toContain('maxSize={{form_meta?.data?.board?.max_file_size');
|
||||
});
|
||||
|
||||
it('다크 모드 variant 를 함께 지정한다', () => {
|
||||
const node = findById(attachmentsPartial, DESC_ID);
|
||||
|
||||
expect(node.props.className).toMatch(/text-gray-\d+/);
|
||||
expect(node.props.className).toMatch(/dark:text-gray-\d+/);
|
||||
});
|
||||
|
||||
it('lang 키가 ko 에 정의되어 있고 파라미터를 포함한다', () => {
|
||||
const value = (koPosts as any).form?.attachments_desc;
|
||||
|
||||
expect(value, 'attachments_desc 키가 없다').toBeTruthy();
|
||||
expect(value).toContain('{{maxFiles}}');
|
||||
expect(value).toContain('{{maxSize}}');
|
||||
});
|
||||
|
||||
it('사장된 고정 숫자 안내 키가 남아 있지 않다', () => {
|
||||
expect((koPosts as any).form?.attachments_limit).toBeUndefined();
|
||||
expect((koPosts as any).form?.max_10mb).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('관리자 첨부 안내 — 렌더 결과 (#81 L-01)', () => {
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
/**
|
||||
* 안내 노드만 떼어내 게시판 설정값과 함께 렌더합니다.
|
||||
*
|
||||
* FileUploader 는 composite 라 mock 레지스트리에 없으므로, 검증 대상인 안내 노드만
|
||||
* 실제 레이아웃 JSON 에서 가져와 렌더한다 (문구 자체는 실 JSON 선언 그대로다).
|
||||
*
|
||||
* @param board 게시판 설정 (max_file_count / max_file_size)
|
||||
* @returns 렌더 유틸
|
||||
*/
|
||||
async function renderDesc(board: { max_file_count: number; max_file_size: number }) {
|
||||
const node = findById(attachmentsPartial, DESC_ID);
|
||||
|
||||
const utils = createLayoutTest(
|
||||
{
|
||||
version: '1.0.0',
|
||||
layout_name: 'admin_attachments_desc_probe',
|
||||
data_sources: [],
|
||||
components: [{ ...node, props: { ...node.props, 'data-testid': DESC_ID } }],
|
||||
} as never,
|
||||
{
|
||||
componentRegistry: createMockComponentRegistryWithBasics() as never,
|
||||
locale: 'ko',
|
||||
templateId: 'test-template',
|
||||
translations: {
|
||||
'sirsoft-board': { admin: { posts: { form: { attachments_desc: (koPosts as any).form.attachments_desc } } } },
|
||||
},
|
||||
initialData: { form_meta: { data: { board } } },
|
||||
}
|
||||
);
|
||||
|
||||
await utils.render();
|
||||
|
||||
return utils;
|
||||
}
|
||||
|
||||
it('게시판이 3개·50MB 로 설정되어 있으면 그 값이 안내에 나온다', async () => {
|
||||
const utils = await renderDesc({ max_file_count: 3, max_file_size: 50 });
|
||||
|
||||
// 존재를 먼저 확정한 뒤에야 문구를 단언한다 (부재 단언은 렌더 전에도 통과한다)
|
||||
const el = await screen.findByTestId(DESC_ID);
|
||||
expect(document.body.contains(el)).toBe(true);
|
||||
expect(el.textContent).toBe('최대 3개, 각 50MB 이하');
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('게시판 설정이 바뀌면 안내도 따라 바뀐다 (고정 문구가 아니다)', async () => {
|
||||
const utils = await renderDesc({ max_file_count: 10, max_file_size: 2 });
|
||||
|
||||
const el = await screen.findByTestId(DESC_ID);
|
||||
expect(el.textContent).toBe('최대 10개, 각 2MB 이하');
|
||||
// 종전 사장 키의 고정 숫자가 다시 새어 나오지 않는다
|
||||
expect(el.textContent).not.toContain('5개');
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -716,12 +716,11 @@
|
||||
"html_preview_warning": "HTML Rendering Warning",
|
||||
"no_content_preview": "Preview will appear when you enter content",
|
||||
"attachments": "Attachments",
|
||||
"attachments_desc": "Max {{maxFiles}} files, {{maxSize}}MB each",
|
||||
"attached_files": "Attached Files",
|
||||
"confirm_remove_attachment_title": "Delete Attachment",
|
||||
"confirm_remove_attachment_message": "The file will be deleted immediately. This action cannot be undone regardless of saving the post.",
|
||||
"attachments_limit": "Max 5 files, 10MB each (jpg, jpeg, png, gif, pdf, zip)",
|
||||
"drop_files_or_click": "Click to upload or drag and drop",
|
||||
"max_10mb": "Max 10MB",
|
||||
"file_upload_component_needed": "FileUpload component needed",
|
||||
"uploading": "Uploading...",
|
||||
"reply_to": "Reply to the following post",
|
||||
@@ -902,7 +901,6 @@
|
||||
"max_content_length": "Maximum Content Length",
|
||||
"use_reply": "Enable Reply Posts",
|
||||
"max_reply_depth": "Max Reply Depth",
|
||||
"max_reply_depth_hint": "Enter a value between 1 and 5",
|
||||
"use_comment": "Enable Comments",
|
||||
"comment_order": "Comment Sort Order",
|
||||
"min_comment_length": "Min Comment Length",
|
||||
@@ -974,7 +972,6 @@
|
||||
"min_content_length": "Set the minimum character count for post content",
|
||||
"max_content_length": "Set the maximum character count for post content",
|
||||
"use_reply": "Must be ON to apply reply post feature to new boards.",
|
||||
"max_reply_depth": "Set the maximum nesting depth for replies (1-5)",
|
||||
"blocked_keywords": "Posts or comments containing registered keywords will be blocked",
|
||||
"use_comment": "Must be ON to apply comment feature to new boards. Comment detail settings below will also apply.",
|
||||
"comment_order": "Set the default sort direction for comments",
|
||||
|
||||
@@ -173,12 +173,11 @@
|
||||
"html_preview_warning": "HTML Rendering Warning",
|
||||
"no_content_preview": "Preview will appear when you enter content",
|
||||
"attachments": "Attachments",
|
||||
"attachments_desc": "Max {{maxFiles}} files, {{maxSize}}MB each",
|
||||
"attached_files": "Attached Files",
|
||||
"confirm_remove_attachment_title": "Delete Attachment",
|
||||
"confirm_remove_attachment_message": "The file will be deleted immediately. This action cannot be undone regardless of saving the post.",
|
||||
"attachments_limit": "Max 5 files, 10MB each (jpg, jpeg, png, gif, pdf, zip)",
|
||||
"drop_files_or_click": "Click to upload or drag and drop",
|
||||
"max_10mb": "Max 10MB",
|
||||
"file_upload_component_needed": "FileUpload component needed",
|
||||
"uploading": "Uploading...",
|
||||
"reply_to": "Reply to the following post",
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
"max_content_length": "Maximum Content Length",
|
||||
"use_reply": "Enable Reply Posts",
|
||||
"max_reply_depth": "Max Reply Depth",
|
||||
"max_reply_depth_hint": "Enter a value between 1 and 5",
|
||||
"use_comment": "Enable Comments",
|
||||
"comment_order": "Comment Sort Order",
|
||||
"min_comment_length": "Min Comment Length",
|
||||
|
||||
@@ -717,12 +717,11 @@
|
||||
"html_preview_warning": "HTML 렌더링 주의",
|
||||
"no_content_preview": "내용을 입력하면 미리보기가 표시됩니다",
|
||||
"attachments": "첨부파일",
|
||||
"attachments_desc": "최대 {{maxFiles}}개, 각 {{maxSize}}MB 이하",
|
||||
"attached_files": "첨부된 파일",
|
||||
"confirm_remove_attachment_title": "첨부파일 삭제",
|
||||
"confirm_remove_attachment_message": "파일이 즉시 삭제됩니다. 게시글 저장과 관계없이 되돌릴 수 없습니다.",
|
||||
"attachments_limit": "최대 5개 파일, 각 10MB 이하 (jpg, jpeg, png, gif, pdf, zip)",
|
||||
"drop_files_or_click": "클릭하여 업로드 또는 드래그 앤 드롭",
|
||||
"max_10mb": "최대 10MB",
|
||||
"file_upload_component_needed": "FileUpload 컴포넌트 개발 필요",
|
||||
"uploading": "업로드 중...",
|
||||
"reply_to": "다음 글에 대한 답변입니다",
|
||||
@@ -902,7 +901,6 @@
|
||||
"max_content_length": "최대 게시글 글자 수",
|
||||
"use_reply": "답변글 사용",
|
||||
"max_reply_depth": "답변글 최대 깊이",
|
||||
"max_reply_depth_hint": "1~5 사이의 값을 입력하세요",
|
||||
"use_comment": "댓글 사용",
|
||||
"comment_order": "댓글 정렬 순서",
|
||||
"min_comment_length": "최소 댓글 글자 수",
|
||||
@@ -974,7 +972,6 @@
|
||||
"min_content_length": "게시글 본문의 최소 글자 수를 설정합니다",
|
||||
"max_content_length": "게시글 본문의 최대 글자 수를 설정합니다",
|
||||
"use_reply": "ON으로 설정해야 새 게시판에 답변글 기능이 적용됩니다.",
|
||||
"max_reply_depth": "답변글의 최대 중첩 깊이를 설정합니다 (1~5)",
|
||||
"blocked_keywords": "등록된 키워드가 포함된 게시글/댓글은 작성이 차단됩니다",
|
||||
"use_comment": "ON으로 설정해야 새 게시판에 댓글 기능이 적용됩니다. 아래 댓글 세부 설정도 함께 적용됩니다.",
|
||||
"comment_order": "댓글의 기본 정렬 방향을 설정합니다",
|
||||
|
||||
@@ -174,12 +174,11 @@
|
||||
"html_preview_warning": "HTML 렌더링 주의",
|
||||
"no_content_preview": "내용을 입력하면 미리보기가 표시됩니다",
|
||||
"attachments": "첨부파일",
|
||||
"attachments_desc": "최대 {{maxFiles}}개, 각 {{maxSize}}MB 이하",
|
||||
"attached_files": "첨부된 파일",
|
||||
"confirm_remove_attachment_title": "첨부파일 삭제",
|
||||
"confirm_remove_attachment_message": "파일이 즉시 삭제됩니다. 게시글 저장과 관계없이 되돌릴 수 없습니다.",
|
||||
"attachments_limit": "최대 5개 파일, 각 10MB 이하 (jpg, jpeg, png, gif, pdf, zip)",
|
||||
"drop_files_or_click": "클릭하여 업로드 또는 드래그 앤 드롭",
|
||||
"max_10mb": "최대 10MB",
|
||||
"file_upload_component_needed": "FileUpload 컴포넌트 개발 필요",
|
||||
"uploading": "업로드 중...",
|
||||
"reply_to": "다음 글에 대한 답변입니다",
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
"max_content_length": "최대 게시글 글자 수",
|
||||
"use_reply": "답변글 사용",
|
||||
"max_reply_depth": "답변글 최대 깊이",
|
||||
"max_reply_depth_hint": "1~5 사이의 값을 입력하세요",
|
||||
"use_comment": "댓글 사용",
|
||||
"comment_order": "댓글 정렬 순서",
|
||||
"min_comment_length": "최소 댓글 글자 수",
|
||||
|
||||
+9
@@ -47,6 +47,15 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"id": "attachments_desc",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-500 dark:text-gray-400 mb-2"
|
||||
},
|
||||
"text": "$t:sirsoft-board.admin.posts.form.attachments_desc|maxFiles={{form_meta?.data?.board?.max_file_count ?? 5}}|maxSize={{form_meta?.data?.board?.max_file_size ?? 10}}"
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "FileUploader",
|
||||
|
||||
@@ -803,6 +803,16 @@ class PostRepository implements PostRepositoryInterface
|
||||
$noticeQuery->with($relations);
|
||||
}
|
||||
|
||||
// 공지는 운영자가 등록하는 데이터라 통상 소수지만, 개수를 막는 장치가 없으면
|
||||
// 잘못 늘어난 게시판에서 한 페이지를 여는 것만으로 전량이 메모리에 올라온다.
|
||||
// `created_at desc` 정렬이므로 상한에 걸릴 때 최신 공지가 우선 보존된다 —
|
||||
// 잘린 공지는 후속 페이지에 노출될 경로가 없어 이 순서가 유일한 안전판이다.
|
||||
// 조정은 코어 필터 훅 `core.pagination.filter_result_cap` (context: board.notices).
|
||||
$noticeCap = PaginationLimits::resultCap('board.notices');
|
||||
if ($noticeCap !== null) {
|
||||
$noticeQuery->limit($noticeCap);
|
||||
}
|
||||
|
||||
$notices = $noticeQuery->get($columns);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Support\Query\PaginationLimits;
|
||||
use Modules\Sirsoft\Board\Models\Post;
|
||||
use Modules\Sirsoft\Board\Repositories\PostRepository;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
|
||||
/**
|
||||
* 첫 페이지 공지 조회의 안전상한 회귀 테스트 (공개 #74)
|
||||
*
|
||||
* 목록 1페이지는 그 게시판의 공지 전량을 상한 없이 `get()` 으로 읽어 왔다. 공지는
|
||||
* 운영자가 등록하는 데이터라 통상 소수지만, 개수를 제한하는 장치가 어디에도 없어
|
||||
* 잘못 늘어난 게시판에서는 한 페이지를 여는 것만으로 그 전량이 메모리에 올라온다.
|
||||
*
|
||||
* 상한은 코어 `PaginationLimits::resultCap('board.notices')` 가 해석하며 확장은
|
||||
* `core.pagination.filter_result_cap` 필터 훅으로 조정한다. 상한을 리터럴로 다시
|
||||
* 적으면 코어 설정과 어긋나므로 여기서도 훅으로만 검증한다.
|
||||
*
|
||||
* 5단계 병합 구조(공지 → 원글 → 답글 → 정렬 → 병합)는 손대지 않는다 — 이 테스트는
|
||||
* 상한 부가가 일반 게시글 목록과 총 건수에 영향을 주지 않는 것까지 함께 고정한다.
|
||||
*
|
||||
* @scenario case=notice_result_cap
|
||||
*
|
||||
* @effects notice_query_respects_result_cap,
|
||||
* notice_cap_keeps_newest_first,
|
||||
* notice_cap_does_not_affect_normal_posts,
|
||||
* other_contexts_keep_their_own_cap
|
||||
*/
|
||||
class NoticeResultCapTest extends BoardTestCase
|
||||
{
|
||||
/** 공지 상한 컨텍스트 키 */
|
||||
private const CONTEXT = 'board.notices';
|
||||
|
||||
/** 훅 해제를 위해 등록한 콜백 */
|
||||
private $capFilter = null;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->capFilter !== null) {
|
||||
HookManager::removeFilter('core.pagination.filter_result_cap', $this->capFilter);
|
||||
$this->capFilter = null;
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 공지 컨텍스트에만 적용되는 상한 필터를 등록합니다.
|
||||
*
|
||||
* 다른 컨텍스트(예: 답글 트리)는 원값을 그대로 통과시켜, 상한이 컨텍스트별로
|
||||
* 분리되어 해석되는지도 함께 확인한다.
|
||||
*
|
||||
* @param int $cap 공지 컨텍스트에 적용할 상한
|
||||
*/
|
||||
private function capNoticesAt(int $cap): void
|
||||
{
|
||||
$this->capFilter = function ($value, $context = null) use ($cap) {
|
||||
return $context === self::CONTEXT ? $cap : $value;
|
||||
};
|
||||
|
||||
HookManager::addFilter('core.pagination.filter_result_cap', $this->capFilter, priority: 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글을 생성합니다.
|
||||
*
|
||||
* @param string $title 제목
|
||||
* @param bool $isNotice 공지 여부
|
||||
* @param int $minutesAgo 작성 시각 (분 단위 과거)
|
||||
*/
|
||||
private function makePost(string $title, bool $isNotice, int $minutesAgo): Post
|
||||
{
|
||||
$post = Post::create([
|
||||
'board_id' => $this->board->id,
|
||||
'user_id' => null,
|
||||
'parent_id' => null,
|
||||
'title' => $title,
|
||||
'content' => $title.' 본문',
|
||||
'author_name' => '작성자',
|
||||
'is_notice' => $isNotice,
|
||||
'status' => 'published',
|
||||
'depth' => 0,
|
||||
'ip_address' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
// created_at 은 $fillable 이 아니라 create() 로는 들어가지 않는다.
|
||||
// 전부 같은 초로 저장되면 정렬 축이 사라져 "최신 우선 보존" 을 검증할 수 없다.
|
||||
$post->forceFill([
|
||||
'created_at' => now()->subMinutes($minutesAgo),
|
||||
'updated_at' => now()->subMinutes($minutesAgo),
|
||||
])->saveQuietly();
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function test_공지_조회가_설정된_안전상한을_넘지_않는다(): void
|
||||
{
|
||||
// 공지 5건 (오래된 것부터 1..5 — 5번이 가장 최신)
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$this->makePost("공지 {$i}", true, 100 - $i);
|
||||
}
|
||||
|
||||
$this->capNoticesAt(3);
|
||||
|
||||
$result = app(PostRepository::class)->paginate($this->board->slug, ['page' => 1], 15, false, $this->board);
|
||||
$notices = collect($result->items())->filter(fn ($p) => (bool) $p->is_notice);
|
||||
|
||||
$this->assertCount(3, $notices, '공지 상한이 조회에 반영되지 않았다');
|
||||
}
|
||||
|
||||
public function test_상한에_걸리면_최신_공지가_남는다(): void
|
||||
{
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$this->makePost("공지 {$i}", true, 100 - $i);
|
||||
}
|
||||
|
||||
$this->capNoticesAt(2);
|
||||
|
||||
$result = app(PostRepository::class)->paginate($this->board->slug, ['page' => 1], 15, false, $this->board);
|
||||
$titles = collect($result->items())
|
||||
->filter(fn ($p) => (bool) $p->is_notice)
|
||||
->pluck('title')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// created_at desc 정렬이므로 최신 2건(공지 5, 공지 4)이 남는다.
|
||||
// 잘린 공지는 후속 페이지에 노출될 경로가 없으므로 최신 우선 보존이 유일한 안전판이다.
|
||||
$this->assertSame(['공지 5', '공지 4'], $titles);
|
||||
}
|
||||
|
||||
public function test_공지_상한이_일반_게시글_목록과_총건수를_바꾸지_않는다(): void
|
||||
{
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$this->makePost("공지 {$i}", true, 100 - $i);
|
||||
}
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$this->makePost("일반글 {$i}", false, 50 - $i);
|
||||
}
|
||||
|
||||
$repository = app(PostRepository::class);
|
||||
|
||||
$uncapped = $repository->paginate($this->board->slug, ['page' => 1], 15, false, $this->board);
|
||||
$normalBefore = collect($uncapped->items())->reject(fn ($p) => (bool) $p->is_notice)->pluck('title')->values()->all();
|
||||
$totalBefore = $repository->countNormalPosts($this->board->slug);
|
||||
|
||||
$this->capNoticesAt(2);
|
||||
|
||||
$capped = $repository->paginate($this->board->slug, ['page' => 1], 15, false, $this->board);
|
||||
$normalAfter = collect($capped->items())->reject(fn ($p) => (bool) $p->is_notice)->pluck('title')->values()->all();
|
||||
$totalAfter = $repository->countNormalPosts($this->board->slug);
|
||||
|
||||
$this->assertSame($normalBefore, $normalAfter, '공지 상한이 일반 게시글 목록을 바꿨다');
|
||||
$this->assertCount(4, $normalAfter);
|
||||
|
||||
// 화면이 보여주는 총 건수는 이 목록과 별개 술어(countNormalPosts)로 세어진다.
|
||||
// 공지 상한이 그 경로까지 새면 페이저 끝이 줄어 뒤쪽 게시글에 도달할 수 없게 된다.
|
||||
// 잘린 값과 정확한 값을 구분해야 하므로 건수뿐 아니라 정확도까지 비교한다.
|
||||
$this->assertSame($totalBefore->total(), $totalAfter->total(), '공지 상한이 목록 총 건수를 바꿨다');
|
||||
$this->assertSame($totalBefore->totalRelation(), $totalAfter->totalRelation(), '공지 상한이 총 건수의 정확도를 바꿨다');
|
||||
|
||||
// 다음 페이지 존재 여부도 공지 상한과 무관해야 한다 (per_page + 1 실측 경로).
|
||||
$this->assertSame($uncapped->hasMorePages(), $capped->hasMorePages(), '공지 상한이 페이지 이동 범위를 바꿨다');
|
||||
}
|
||||
|
||||
public function test_다른_컨텍스트는_자기_상한을_유지한다(): void
|
||||
{
|
||||
$this->capNoticesAt(2);
|
||||
|
||||
$this->assertSame(2, PaginationLimits::resultCap(self::CONTEXT));
|
||||
$this->assertNotSame(2, PaginationLimits::resultCap('board.reply_tree'));
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Upgrade;
|
||||
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
use Modules\Sirsoft\Board\Upgrades\Upgrade_1_0_3;
|
||||
|
||||
/**
|
||||
* 1.0.3 게시글 목록 조회수 정렬 색인 업그레이드 스텝 테스트
|
||||
*
|
||||
* 신규 설치는 마이그레이션(`2026_08_06_000002`)이 색인을 만들지만, 이미 설치를 마친
|
||||
* 사이트에도 확실히 도달하도록 업그레이드 데이터 스텝을 함께 둔다(같은 1.0.3 의
|
||||
* `04_AddTiebreakToPostListIndex` · `05_AddReportListSortIndex` 와 동일 규약).
|
||||
*
|
||||
* 검증 목적:
|
||||
* - 색인이 없는 상태(기설치본 모사)에서 스텝이 색인을 만든다
|
||||
* - 재실행해도 예외 없이 결과가 같다 (멱등)
|
||||
* - 인기글 조회가 쓰는 기존 색인을 건드리지 않는다
|
||||
*
|
||||
* @group board
|
||||
* @group upgrade
|
||||
*/
|
||||
class PostListViewCountIndexUpgradeTest extends BoardTestCase
|
||||
{
|
||||
private const TABLE = 'board_posts';
|
||||
|
||||
private const INDEX_NAME = 'idx_board_posts_list_views';
|
||||
|
||||
/** 인기글 조회가 이름으로 의존하는 기존 색인 — 보존되어야 한다. */
|
||||
private const LEGACY_INDEX_NAME = 'idx_board_posts_board_view_count';
|
||||
|
||||
protected function getTestBoardSlug(): string
|
||||
{
|
||||
return 'post-list-view-count-index';
|
||||
}
|
||||
|
||||
protected function getDefaultBoardAttributes(string $slug): array
|
||||
{
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'name' => ['ko' => '조회수 색인 게시판', 'en' => 'View Count Index Board'],
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 색인이 없는 기설치본에서 업그레이드 스텝이 색인을 만든다
|
||||
*
|
||||
* @scenario case=post_list_view_count_index_upgrade
|
||||
*
|
||||
* @effects view_count_list_index_created_on_existing_site
|
||||
*/
|
||||
public function test_색인이_없으면_업그레이드_스텝이_생성한다(): void
|
||||
{
|
||||
$this->dropIndexIfExists();
|
||||
$this->assertFalse($this->hasIndex(self::INDEX_NAME), '사전 조건: 색인이 없어야 한다');
|
||||
|
||||
$this->runUpgrade();
|
||||
|
||||
$this->assertTrue(
|
||||
$this->hasIndex(self::INDEX_NAME),
|
||||
'업그레이드 스텝이 조회수 정렬 색인을 만들어야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재실행해도 예외 없이 결과가 같다 (멱등)
|
||||
*
|
||||
* @scenario case=post_list_view_count_index_upgrade
|
||||
*
|
||||
* @effects view_count_list_index_upgrade_is_idempotent
|
||||
*/
|
||||
public function test_이미_있으면_재실행해도_결과가_같다(): void
|
||||
{
|
||||
$this->dropIndexIfExists();
|
||||
$this->runUpgrade();
|
||||
$this->assertTrue($this->hasIndex(self::INDEX_NAME));
|
||||
|
||||
// 색인이 이미 있는 상태에서 한 번 더 — 중복 생성 예외가 나면 실패한다.
|
||||
$this->runUpgrade();
|
||||
|
||||
$this->assertTrue(
|
||||
$this->hasIndex(self::INDEX_NAME),
|
||||
'재실행 후에도 색인이 그대로 있어야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기글 조회가 쓰는 기존 색인을 건드리지 않는다
|
||||
*
|
||||
* @scenario case=post_list_view_count_index_upgrade
|
||||
*
|
||||
* @effects legacy_popular_posts_index_preserved
|
||||
*/
|
||||
public function test_기존_인기글_색인을_보존한다(): void
|
||||
{
|
||||
$legacyBefore = $this->hasIndex(self::LEGACY_INDEX_NAME);
|
||||
|
||||
$this->dropIndexIfExists();
|
||||
$this->runUpgrade();
|
||||
|
||||
$this->assertSame(
|
||||
$legacyBefore,
|
||||
$this->hasIndex(self::LEGACY_INDEX_NAME),
|
||||
'조회수 목록 색인 추가가 인기글 색인의 존재 여부를 바꾸면 안 된다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.0.3 업그레이드 스텝을 실행합니다.
|
||||
*/
|
||||
private function runUpgrade(): void
|
||||
{
|
||||
$context = new UpgradeContext('1.0.2', '1.0.3', '1.0.3', 'extension-upgrade');
|
||||
|
||||
(new Upgrade_1_0_3)->run($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 색인이 존재하면 제거합니다 (기설치본 모사).
|
||||
*/
|
||||
private function dropIndexIfExists(): void
|
||||
{
|
||||
if (! $this->hasIndex(self::INDEX_NAME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table(self::TABLE, function ($table) {
|
||||
$table->dropIndex(self::INDEX_NAME);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 색인 존재 여부를 조회합니다.
|
||||
*
|
||||
* @param string $name 색인 이름
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
private function hasIndex(string $name): bool
|
||||
{
|
||||
if (! Schema::hasTable(self::TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($name, array_column(Schema::getIndexes(self::TABLE), 'name'), true);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 관리자 게시글 폼 — 첨부 안내가 게시판 설정값으로 렌더 (공개 #81 L-01).
|
||||
*
|
||||
* 관리자 게시글 폼의 첨부 영역에는 안내 문구가 없었고, 대신 사장된 lang 키
|
||||
* ("최대 5개 파일, 각 10MB 이하")만 남아 있었다. 그 문구는 어디에도 렌더되지 않는데
|
||||
* 설령 렌더되었어도 게시판마다 다른 실제 제한과 무관한 고정 숫자였다.
|
||||
*
|
||||
* 안내는 `form_meta.data.board.max_file_count/max_file_size` 를 파라미터로 받아야 한다.
|
||||
* 단위(Vitest admin-board-post-form-attachments-desc) 는 안내 노드만 떼어 렌더하므로,
|
||||
* 실제 폼에서 form_meta 데이터소스가 도착한 뒤의 치환 결과는 브라우저로만 확인 가능하다
|
||||
* (위지윅 발행 회귀 #238 교훈).
|
||||
*
|
||||
* @scenario board-post-form-attachments-desc
|
||||
* @effects admin_attachment_hint_renders_board_limits,
|
||||
* admin_attachment_hint_placeholders_not_raw
|
||||
*
|
||||
* 활성화 절차: PlaywrightIssueToken 발급이 가능한 환경에서 test.describe.skip → test.describe.
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
|
||||
|
||||
/** 첨부 기능이 켜진 게시판의 관리자 글쓰기 화면 */
|
||||
const WRITE_URL = '/admin/board/free/write';
|
||||
|
||||
test.describe.skip('관리자 게시글 폼 — 첨부 안내 (#81 L-01)', () => {
|
||||
test('첨부 안내가 게시판 설정의 개수·용량으로 렌더된다', async ({ page, settingsToken }) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
await page.goto(WRITE_URL);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// form_meta 도착 후 안내가 채워질 때까지 대기 — 숫자는 게시판 설정에서 온다
|
||||
const hint = page.getByText(/^최대 \d+개, 각 \d+MB 이하$/);
|
||||
await expect(hint).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('raw 플레이스홀더({{maxFiles}}/{{maxSize}})가 노출되지 않는다', async ({
|
||||
page,
|
||||
settingsToken,
|
||||
}) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
await page.goto(WRITE_URL);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await expect(page.getByText(/^최대 \d+개, 각 \d+MB 이하$/)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(page.getByText('{{maxFiles}}', { exact: false })).toHaveCount(0);
|
||||
await expect(page.getByText('{{maxSize}}', { exact: false })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('사장된 고정 숫자 안내가 다시 나타나지 않는다', async ({ page, settingsToken }) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
await page.goto(WRITE_URL);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await expect(page.getByText(/^최대 \d+개, 각 \d+MB 이하$/)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 종전 사장 키의 고정 문구 (jpg, jpeg, ... 확장자 나열 포함) 는 제거되었다
|
||||
await expect(
|
||||
page.getByText('최대 5개 파일, 각 10MB 이하', { exact: false }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 게시글 목록의 조회수 정렬 색인을 추가합니다.
|
||||
*
|
||||
* 조회수 정렬은 화면에서 실제로 도달 가능한 경로인데(게시판 설정의 기본 정렬과 목록
|
||||
* URL 둘 다 있습니다), 목록 술어를 등치 사슬로 덮으면서 `view_count` 로 끝나는 색인이
|
||||
* 없어 뒤쪽 페이지가 테이블 전체를 훑고 정렬했습니다.
|
||||
*
|
||||
* 신규 설치는 마이그레이션이 처리하지만 기존 사이트에는 반영되지 않으므로 업그레이드
|
||||
* 시점에 같은 색인을 추가합니다.
|
||||
*
|
||||
* 게시글이 많이 쌓인 사이트에서는 수 분 걸릴 수 있고 그동안 글쓰기가 대기합니다.
|
||||
*
|
||||
* 기존 `idx_board_posts_board_view_count` 는 건드리지 않습니다 — 인기글 조회가 쓰는
|
||||
* 색인이며 이 목록 색인이 대신하지 못합니다.
|
||||
*
|
||||
* idempotent: 이미 존재하는 색인은 건너뜁니다. V-1 안전: Facades\Schema 만 사용합니다.
|
||||
*/
|
||||
class AddPostListViewCountIndex implements DataMigration
|
||||
{
|
||||
private const TABLE = 'board_posts';
|
||||
|
||||
private const INDEX_NAME = 'idx_board_posts_list_views';
|
||||
|
||||
/** @var array<int, string> */
|
||||
private const INDEX_COLUMNS = ['board_id', 'is_notice', 'parent_id', 'deleted_at', 'view_count', 'id'];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자를 반환합니다.
|
||||
*
|
||||
* @return string 마이그레이션 이름
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'AddPostListViewCountIndex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 목록 조회수 정렬 색인을 추가합니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! Schema::hasTable(self::TABLE)) {
|
||||
$context->logger->info('[board:1.0.3] 게시글 테이블 부재 — 조회수 정렬 색인 추가 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = array_column(Schema::getIndexes(self::TABLE), 'name');
|
||||
|
||||
if (in_array(self::INDEX_NAME, $existing, true)) {
|
||||
$context->logger->info('[board:1.0.3] 이미 존재하는 색인 — 스킵: '.self::INDEX_NAME);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$context->logger->info('[board:1.0.3] 게시글 목록 조회수 정렬 색인 추가 시작 (게시글이 많으면 수 분 걸릴 수 있고 그동안 글쓰기가 대기합니다)');
|
||||
|
||||
Schema::table(self::TABLE, function ($table) {
|
||||
$table->index(self::INDEX_COLUMNS, self::INDEX_NAME);
|
||||
});
|
||||
|
||||
$context->logger->info('[board:1.0.3] 게시글 목록 조회수 정렬 색인 추가 완료: '.self::INDEX_NAME);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 서버가 만들어 보내는 상점 주소가 기본 주소로 잘못 만들어지던 문제를 수정했습니다. 주문 완료 후 이동, 비회원 주문 조회 안내, 알림 메일의 주문 조회 주소, 통합검색·상품문의 목록의 상품 링크, 검색엔진용 주소(사이트맵·봇 화면 캐시)가 모두 해당됐습니다. 링크가 만들어지기는 해서 오류로 드러나지 않고 없는 페이지로만 이어졌습니다. 기본 주소를 쓰는 상점은 이전과 동일합니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 쇼핑몰 주소를 바꾸거나 주소 없이 운영하는 상점에서 화면 편집기의 상품·장바구니·주문서 등 상점 화면을 편집할 때 상태 미리보기 전환(품절/재고 있음 등)이 표시되지 않던 문제를 수정했습니다. 편집기가 기본 주소인 상점만 알아보던 탓이며, 이제 어떤 주소로 바꾸어도 — 주소 없이 운영하는 상점까지 — 동일하게 표시됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 상품 상세 화면에서만 상태 미리보기 전환(재고 있음/품절/판매중지/판매예정)이 어떤 쇼핑몰 주소에서도 표시되지 않던 문제를 수정했습니다. 편집기가 찾는 화면 주소와 실제 상품 상세 주소의 표기가 서로 달라 끝내 연결되지 않았습니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- "최근 본 상품" 과 찜 목록의 상품 카드가 리뷰가 달린 상품도 별점 0 개 · 리뷰 0건으로 표시하던 문제를 수정했습니다. 두 조회만 리뷰를 세지 않아 값이 비어 있었고, 빈 값이 0 으로 표기되어 실제 평점과 다르게 보였습니다. 이제 다른 상품 목록과 동일하게 실제 평점·리뷰 수가 표시됩니다.
|
||||
- 리뷰를 세지 않는 조회 경로(관리자 상품 목록 등)에서는 평점·리뷰 수 항목을 아예 싣지 않도록 바꿨습니다. 종전에는 세지 않았을 뿐인데 0 이 실려 "리뷰 없음" 과 구분되지 않았습니다. 리뷰가 실제로 0건이면 종전대로 0 으로 표시됩니다.
|
||||
- 모바일·태블릿 화면에서 쿠폰 목록의 카드 보기가 내용 없이 나오던 문제를 수정했습니다. 목록의 각 쿠폰 정보가 카드에 전달되지 않아, 쿠폰이 있어도 빈 카드만 표시됐습니다.
|
||||
|
||||
@@ -1382,18 +1382,18 @@ _목록 응답: `data.data[]` 배열 항목의 필드._
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `27789` | 기본 키 (내부 식별자) |
|
||||
| log_type | string | `admin` | <!-- TODO: 설명 --> |
|
||||
| log_type | string | `admin` | 로그 분류 (`ActivityLogType` Enum 값 — 관리자/사용자/시스템 등 활동 주체 구분) |
|
||||
| log_type_label | string | `관리자` | `log_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
|
||||
| loggable_type | string | `Modules\Sirsoft\Ecommerce\Models\Orde…` | <!-- TODO: 설명 --> |
|
||||
| loggable_type_display | string | `OrderOption` | <!-- TODO: 설명 --> |
|
||||
| loggable_type | string | `Modules\Sirsoft\Ecommerce\Models\Orde…` | 로그 대상 모델의 FQCN (다형 관계 타입 — 표시용 짧은 이름은 `loggable_type_display`) |
|
||||
| loggable_type_display | string | `OrderOption` | `loggable_type` 의 표시용 짧은 이름 (네임스페이스를 제외한 클래스명) |
|
||||
| loggable_id | integer | `1264` | loggable 식별자 (연관 리소스 참조) |
|
||||
| action | string | `order_option.partial_cancel` | <!-- TODO: 설명 --> |
|
||||
| action | string | `order_option.partial_cancel` | 수행된 활동의 식별 키 (`{대상}.{행위}` 형식 — 라벨은 `action_label`) |
|
||||
| action_label | string | `부분 취소` | `action` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
|
||||
| localized_description | string | `주문 옵션 부분 취소 (옵션 ID: 1264)` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
|
||||
| description_key | string | `sirsoft-ecommerce::activity_log.descr…` | <!-- TODO: 설명 --> |
|
||||
| properties | object | `{"order_id":1316,"product_name":{"ko":"신제품 출시 예정 #21","en…` | <!-- TODO: 설명 --> |
|
||||
| changes | null | `null` | <!-- TODO: 설명 --> |
|
||||
| bulk_changes | null | `null` | <!-- TODO: 설명 --> |
|
||||
| description_key | string | `sirsoft-ecommerce::activity_log.descr…` | 설명 문구의 다국어 키 (`localized_description` 은 이 키를 현재 로케일로 해석한 값) |
|
||||
| properties | object | `{"order_id":1316,"product_name":{"ko":"신제품 출시 예정 #21","en…` | 활동 시점의 부가 정보 (설명 문구의 치환 파라미터 및 참조 식별자 — 활동 종류마다 키가 다름) |
|
||||
| changes | null | `null` | 단건 수정의 변경 내역 `[{field, old, new}, …]`. 일괄 수정이거나 변경 추적 대상이 아니면 `null` |
|
||||
| bulk_changes | null | `null` | 일괄 수정의 변경 내역 `[{model_id, changes[]}, …]`. 단건 수정이면 `null` (`changes` 와 동시에 채워지지 않음) |
|
||||
| has_changes | boolean | `false` | changes 여부 |
|
||||
| actor_name | string | `시스템` | 행위를 수행한 주체(사용자/시스템)의 이름 |
|
||||
| user | object | `{"name":"시스템"}` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) |
|
||||
@@ -2073,7 +2073,7 @@ _단건 응답: `data` 객체의 필드 (HTTP 201). 회원은 `order` 에 `Order
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| order | object | `{"id":181,"order_number":"20260711-0210001234", …}` | 생성된 주문 (회원: `OrderResource` / 비회원: `GuestOrderResource`) |
|
||||
| redirect_url | string | `/shop/orders/20260711-0210001234/complete` | 주문완료 페이지 경로 (프론트가 이동할 URL) |
|
||||
| redirect_url | string | `/shop/orders/20260711-0210001234/complete` | 주문완료 페이지 경로 (프론트가 이동할 URL). 앞의 상점 경로는 상점 주소 설정을 반영한다 — `basic_info.route_path` 를 바꾸면 그 값이(`/store/orders/…`), `basic_info.no_route` 를 켜면 세그먼트 없이(`/orders/…`) 내려간다 |
|
||||
| requires_pg_payment | boolean | `true` | PG 결제창 호출이 필요한지 여부 (무통장·전액 마일리지 등 non-PG 는 `false`) |
|
||||
| pg_provider | string | `sirsoft-tosspayments` | PG 플러그인 식별자 (`requires_pg_payment=true` 일 때만 포함) |
|
||||
| pg_payment_handler | string | `sirsoft-tosspayments.requestPayment` | 프론트가 dispatch 할 결제 진입 핸들러 풀네임 (provider 가 선언한 경우에만 포함) |
|
||||
@@ -3221,7 +3221,7 @@ HTTP/1.1 200
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 404 | Not Found | 주문이 없거나 접근 권한이 없는 경우 — 회원: 본인 주문 아님(`errors.redirect_to = /mypage/orders`), 비회원: `X-Guest-Order-Token` 부재·만료·위조(`errors.redirect_to = /shop/guest/orders`). 정보 노출 방지를 위해 모든 실패를 동일 404 로 처리 |
|
||||
| 404 | Not Found | 주문이 없거나 접근 권한이 없는 경우 — 회원: 본인 주문 아님(`errors.redirect_to = /mypage/orders`), 비회원: `X-Guest-Order-Token` 부재·만료·위조(`errors.redirect_to = /shop/guest/orders` — 상점 주소 설정 `basic_info.route_path`/`no_route` 반영). 정보 노출 방지를 위해 모든 실패를 동일 404 로 처리 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
|
||||
@@ -4729,13 +4729,13 @@
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"comment": "이커머스 도메인 변종. 사용자 shop/* 라우트(템플릿 sirsoft-basic 소속이나 코어 concat 으로 일원화)와 관리자 라우트(*/admin/ecommerce/*) 변종을 모두 포함한다. shop/* scope.match 는 편집기가 동적 path 를 'shop' 으로 평가한 selectedRoute.path 와 일치한다(Chrome MCP 실측).",
|
||||
"comment": "이커머스 도메인 변종. 사용자 상점 라우트(템플릿 sirsoft-basic 소속이나 코어 concat 으로 일원화)와 관리자 라우트(*/admin/ecommerce/*) 변종을 모두 포함한다. 상점 주소는 운영자 설정이라 편집기가 평가한 selectedRoute.path 의 첫 세그먼트가 사이트마다 다르다 — 기본 'shop', basic_info.route_path 로 바꾸면 그 값, basic_info.no_route 를 켜면 세그먼트가 아예 없다(`/products`). 그래서 scope.match 는 선택 세그먼트 토큰 '/*?'(0개 또는 1개, engine-v1.58.0)를 써 세 경우를 모두 매칭한다. '/*' 로 쓰면 no_route 상점에서만 매칭이 실패해 상태 토글이 조용히 사라진다. 파라미터 토큰(:id 등)은 리터럴로 대조되므로 routes.json 이 쓴 이름을 그대로 적어야 한다 — 이름이 다르면 세그먼트 토큰과 무관하게 세 축 모두에서 매칭되지 않는다.",
|
||||
"groups": [
|
||||
{
|
||||
"comment": "shop/show — product.data.sales_status 값(on_sale/sold_out/suspended/coming_soon)으로 판매 상태 배지·버튼을 if 분기한다(본문 line 388/406). 재고는 stock 으로 분기.",
|
||||
"comment": "shop/show — product.data.sales_status 값(on_sale/sold_out/suspended/coming_soon)으로 판매 상태 배지·버튼을 if 분기한다(본문 line 388/406). 재고는 stock 으로 분기. 파라미터 토큰은 routes.json 의 ':product_code' 를 그대로 쓴다 — ':id' 로 적으면 리터럴 대조라 어떤 상점 주소에서도 매칭되지 않는다.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/products/:id"
|
||||
"match": "/*?/products/:product_code"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7099,7 +7099,7 @@
|
||||
"comment": "shop/index — 목록 유무 + 접근 제한. _local.productAccessDenied=true 시 접근 제한 화면을 if 분기한다(본문 line 192).",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/products"
|
||||
"match": "/*?/products"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7140,7 +7140,7 @@
|
||||
"comment": "shop/cart — 담긴 상품 유무 + 주문 처리 중(_local.isOrdering 시 오버레이 잠금, 본문 line 115).",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/cart"
|
||||
"match": "/*?/cart"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7222,7 +7222,7 @@
|
||||
"comment": "shop/checkout — 정상/결제오류(_local.orderError·query.error, 본문 line 45/194)/구매불가(_local.unavailableModal, line 102)/입력검증실패 분기. 검증 실패는 주문자/배송지 입력칸별 인라인 에러를 _local.errors 의 점 포함 flat 키(예 'orderer.name')로 읽는다(_checkout_orderer.json line 99/101, _checkout_shipping.json) — formErrors 키를 대괄호 표기로 지정한다.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/checkout"
|
||||
"match": "/*?/checkout"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7291,7 +7291,7 @@
|
||||
"comment": "shop/order_complete — orderData.data.payment.payment_method 값(card/vbank/dbank)으로 결제수단별 안내를 if 분기한다(본문 line 52/95/138/229). card 는 base sampleData(=card)가 흐르므로 오버라이드 없음, vbank/dbank 는 byDataSourceId.orderData 통째 교체.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/orders/:id/complete"
|
||||
"match": "/*?/orders/:id/complete"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7747,7 +7747,7 @@
|
||||
"comment": "shop/reorder — _local.status 값(pending/success/error)으로 재주문 진행 화면을 if 분기한다(본문 line 72/99/225).",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/reorder/:id"
|
||||
"match": "/*?/reorder/:id"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -8394,7 +8394,7 @@
|
||||
"comment": "shop/guest_order_form — 비회원 주문 조회 폼. 본문이 _global.currentUser.uuid 유무로 양분된다(slots.content[0] 회원→마이페이지 리다이렉트 안내 / content[1] 비회원→조회 폼). 편집기 sampleGlobal 은 로그인 상태(currentUser 시드)라 비회원 조회 폼이 기본 미표시이므로, currentUser 를 null 로 패치한 비회원 상태를 기본으로 노출해 폼을 편집 가능하게 한다.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/guest/orders"
|
||||
"match": "/*?/guest/orders"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ const spec = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'modules/_bundled/sirsoft-ecommerce/editor-spec.json'), 'utf-8'),
|
||||
);
|
||||
const checkoutGroup = spec.states.groups.find(
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/checkout',
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/checkout',
|
||||
);
|
||||
const normal = checkoutGroup?.items.find((i: any) => i.id === 'normal');
|
||||
|
||||
|
||||
+18
-3
@@ -8,7 +8,7 @@
|
||||
* 그룹이 없으면 비회원 조회 폼이 캔버스에 영영 미표시되어 편집 불가였다.
|
||||
*
|
||||
* 본 테스트는 develop 신규 화면에 대응해 추가한 상태 그룹의 계약을 가드한다:
|
||||
* - scope.match 가 정규화 라우트 path(`/shop/guest/orders`)와 일치
|
||||
* - scope.match 가 상점 주소 설정과 무관하게 정규화 라우트 path 와 일치 (공개 #85)
|
||||
* - 기본(guest) 상태가 `global.currentUser: null` 패치로 비회원 폼을 노출
|
||||
* - 회원(member) 상태가 `global.currentUser.uuid` 명시 시드로 마이페이지 안내 분기를 노출
|
||||
* - 두 상태 라벨이 `$t:` 친화 키
|
||||
@@ -25,6 +25,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { matchStateItems } from '../../../../../../../resources/js/core/template-engine/layout-editor/utils/matchStateScope';
|
||||
|
||||
function findProjectRoot(startDir: string): string {
|
||||
let dir = startDir;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
@@ -39,18 +41,31 @@ const spec = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'modules/_bundled/sirsoft-ecommerce/editor-spec.json'), 'utf-8'),
|
||||
);
|
||||
const group = spec.states.groups.find(
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/guest/orders',
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/guest/orders',
|
||||
);
|
||||
const guest = group?.items.find((i: any) => i.id === 'guest');
|
||||
const member = group?.items.find((i: any) => i.id === 'member');
|
||||
|
||||
describe('guest_order_form 상태 그룹', () => {
|
||||
it('정규화 라우트 path(`/shop/guest/orders`)에 상태 그룹이 존재한다', () => {
|
||||
it('정규화 라우트 path 에 상태 그룹이 존재한다', () => {
|
||||
expect(group).toBeTruthy();
|
||||
// items 2개 이상이어야 편집기 캔버스에 상태 드롭다운(PageStateSwitcher)이 표시된다.
|
||||
expect(group.items.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// 공개 #85 — 상점 주소는 운영자 설정이라 편집기가 평가한 라우트 path 의 첫 세그먼트가
|
||||
// 사이트마다 다르다. scope.match 를 `/shop/...` 리터럴로 두면 주소를 바꾼 상점에서
|
||||
// 상태 그룹이 매칭되지 않아 상태 토글이 조용히 사라진다(예외·경고 없음).
|
||||
// 실제 소비자(matchStateItems)로 판정해 정규식 의미까지 고정한다.
|
||||
it.each([
|
||||
['기본 상점 주소', '/shop/guest/orders'],
|
||||
['운영자가 바꾼 주소', '/store/guest/orders'],
|
||||
['주소 없이 운영하는 상점(no_route)', '/guest/orders'],
|
||||
])('%s 라우트에서 상태 그룹이 매칭된다', (_label, routePath) => {
|
||||
const items = matchStateItems(spec.states.groups, { kind: 'route', match: routePath });
|
||||
expect(items.map((i) => i.id)).toEqual(expect.arrayContaining(['guest', 'member']));
|
||||
});
|
||||
|
||||
it('기본(guest) 상태가 currentUser 를 null 로 패치해 비회원 조회 폼을 노출한다', () => {
|
||||
expect(guest?.default).toBe(true);
|
||||
// global.currentUser === null → `_global.currentUser?.uuid` undefined → content[1] 폼 활성.
|
||||
|
||||
+2
-2
@@ -238,13 +238,13 @@ describe('상품 도메인 편집기 샘플 — sirsoft-ecommerce admin', () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe('/shop/products/:id 상태 override — 통째 교체이므로 충실 shape 유지', () => {
|
||||
describe('상품 상세 상태 override — 통째 교체이므로 충실 shape 유지', () => {
|
||||
// Chrome MCP 실측 회귀(2026-06-03): 비-기본 상태 override 가 빈약 stub(name:"샘플 상품 1",
|
||||
// price, stock_status, images:[], options:[])이라 캔버스가 붕괴(277자)했다. override 는
|
||||
// base sampleData 를 "통째 교체"(pageStateSimulator.resolveSampleOverride)하므로
|
||||
// 누락 필드는 undefined → 렌더 붕괴. base 와 동일 충실 shape 를 유지해야 한다.
|
||||
const group = spec.states.groups.find(
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/products/:id',
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/products/:product_code',
|
||||
);
|
||||
const PRODUCT_EP = '/api/modules/sirsoft-ecommerce/products/*';
|
||||
const stateOf = (id: string) =>
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// e2e:allow 편집기 상태 스코프 ↔ 라우트 토큰 정합만 검사하는 정적 계약 테스트 — 런타임 동작 무변경, 해당 화면 E2E 는 기존 spec 으로 커버됨
|
||||
/**
|
||||
* 상점 상태 그룹 scope ↔ routes.json 라우트 패리티 계약 테스트
|
||||
*
|
||||
* 편집기는 템플릿 routes.json 의 동적 path 표현식을 평가해 `selectedRoute.path` 를
|
||||
* 만들고, 그 값을 editor-spec 의 `scope.match` 와 대조해 상태 토글을 띄운다. 따라서
|
||||
* scope 는 **routes.json 이 실제로 만들어 내는 path 문자열**과 일치해야 한다.
|
||||
*
|
||||
* 이 테스트가 막는 회귀는 두 축이다:
|
||||
*
|
||||
* 1. 세그먼트 축 — 상점 주소는 운영자 설정이라 첫 세그먼트가 사이트마다 다르다
|
||||
* (기본 `shop` / `route_path` 로 바꾼 값 / `no_route` 면 세그먼트 없음).
|
||||
* `/*` 로 쓰면 no_route 상점에서만 매칭이 깨진다 (공개 #85, engine-v1.58.0 `/*?`).
|
||||
*
|
||||
* 2. 파라미터 토큰 축 — `matchRoutePattern` 은 `:param` 을 리터럴로 취급한다.
|
||||
* scope 에 `:id` 라 적었는데 routes.json 이 `:product_code` 를 쓰면 세그먼트
|
||||
* 토큰을 아무리 고쳐도 **세 축 모두에서** 영영 매칭되지 않는다. 예외도 경고도
|
||||
* 없이 그 화면의 상태 토글만 사라지므로 정적 대조가 유일한 발견 경로다.
|
||||
*
|
||||
* scope 목록을 손으로 열거하지 않고 editor-spec 과 routes.json 양쪽에서 도출한다 —
|
||||
* 나중에 상점 화면이 늘어도 이 테스트가 그 화면까지 자동으로 검사한다.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { matchStateItems } from '../../../../../../../resources/js/core/template-engine/layout-editor/utils/matchStateScope';
|
||||
|
||||
function findProjectRoot(startDir: string): string {
|
||||
let dir = startDir;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
if (fs.existsSync(path.join(dir, 'artisan'))) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return path.resolve(startDir, '../../../../../../..');
|
||||
}
|
||||
|
||||
const REPO_ROOT = findProjectRoot(__dirname);
|
||||
|
||||
const spec = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'modules/_bundled/sirsoft-ecommerce/editor-spec.json'), 'utf-8'),
|
||||
);
|
||||
const routesJson = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'templates/_bundled/sirsoft-basic/routes.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
/** routes.json 의 상점 주소 prefix 표현식 (`no_route ? '' : route_path ?? 'shop'`) */
|
||||
const SHOP_PREFIX_EXPR = /^\/\{\{[^}]*?no_route[\s\S]*?\}\}/;
|
||||
|
||||
/** 편집기가 상점 주소 설정을 평가했을 때 나올 수 있는 세 가지 첫 세그먼트 */
|
||||
const AXES: ReadonlyArray<[label: string, base: string]> = [
|
||||
['기본 상점 주소(shop)', '/shop'],
|
||||
['운영자가 바꾼 주소(store)', '/store'],
|
||||
['주소 없이 운영하는 상점(no_route)', ''],
|
||||
];
|
||||
|
||||
/** 상점 주소에 종속된 라우트를 축별로 평가한 path 목록 */
|
||||
function shopRoutePaths(base: string): string[] {
|
||||
return (routesJson.routes ?? [])
|
||||
.map((r: { path?: string }) => r.path)
|
||||
.filter((p: unknown): p is string => typeof p === 'string' && SHOP_PREFIX_EXPR.test(p))
|
||||
.map((p: string) => p.replace(SHOP_PREFIX_EXPR, base));
|
||||
}
|
||||
|
||||
/**
|
||||
* editor-spec 의 상점 route scope 목록 (그룹의 첫 상태 id 를 식별자로 함께 들고 간다).
|
||||
* 관리자 라우트(`*/admin/...`)는 상점 주소와 무관하므로 제외한다.
|
||||
*/
|
||||
const shopScopes: Array<{ match: string; probeItemId: string }> = (spec.states?.groups ?? [])
|
||||
.filter(
|
||||
(g: { scope?: { kind?: string; match?: unknown }; items?: unknown[] }) =>
|
||||
g.scope?.kind === 'route' &&
|
||||
typeof g.scope?.match === 'string' &&
|
||||
!g.scope.match.includes('/admin/') &&
|
||||
Array.isArray(g.items) &&
|
||||
g.items.length > 0,
|
||||
)
|
||||
.map((g: { scope: { match: string }; items: Array<{ id: string }> }) => ({
|
||||
match: g.scope.match,
|
||||
probeItemId: g.items[0].id,
|
||||
}));
|
||||
|
||||
describe('상점 상태 그룹 scope ↔ routes.json 라우트 패리티', () => {
|
||||
it('검사 모집단이 비어 있지 않다 (빈 모집단으로 통과하는 것을 막는다)', () => {
|
||||
expect(shopScopes.length).toBeGreaterThan(0);
|
||||
expect(shopRoutePaths('/shop').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe.each(AXES)('%s', (_label, base) => {
|
||||
it.each(shopScopes.map((s) => [s.match, s.probeItemId]))(
|
||||
'scope %s 의 상태가 실제 라우트에서 노출된다',
|
||||
(scopeMatch: string, probeItemId: string) => {
|
||||
const candidates = shopRoutePaths(base);
|
||||
|
||||
// 실제 소비자(matchStateItems)로 판정해 정규식 의미까지 함께 고정한다.
|
||||
const hit = candidates.find((routePath) =>
|
||||
matchStateItems(spec.states.groups, { kind: 'route', match: routePath }).some(
|
||||
(i) => i.id === probeItemId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
hit,
|
||||
`scope "${scopeMatch}" 가 이 축의 어떤 라우트와도 매칭되지 않는다.\n` +
|
||||
` 후보 라우트: ${candidates.join(', ')}`,
|
||||
).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ use Modules\Sirsoft\Ecommerce\Services\OrderService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\StockService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\TempOrderService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\UserAddressService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 주문 공유 컨트롤러 (회원/비회원 공용 + 비회원 토큰 후속 액션)
|
||||
@@ -193,7 +194,9 @@ class OrderController extends PublicBaseController
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-ecommerce::exceptions.order_not_found',
|
||||
404,
|
||||
['redirect_to' => '/shop/guest/orders']
|
||||
// 상점 주소 설정(route_path/no_route)을 반영해야 주소를 바꾼 상점에서도
|
||||
// 실제 존재하는 비회원 조회 화면으로 안내된다 (공개 #85).
|
||||
['redirect_to' => ShopPathResolver::path('guest/orders')]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -19,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 주문 생성 공통 흐름 Trait
|
||||
@@ -218,7 +219,9 @@ trait HandlesOrderCreation
|
||||
{
|
||||
$responseData = [
|
||||
'order' => $orderResource,
|
||||
'redirect_url' => "/shop/orders/{$order->order_number}/complete",
|
||||
// 상점 주소는 운영자 설정이다 — 기본값 리터럴을 내려보내면 주소를 바꾼 상점에서
|
||||
// 결제를 마친 손님이 존재하지 않는 화면으로 이동한다 (공개 #85).
|
||||
'redirect_url' => ShopPathResolver::path("orders/{$order->order_number}/complete"),
|
||||
'requires_pg_payment' => $requiresPg,
|
||||
];
|
||||
|
||||
|
||||
+6
-3
@@ -11,6 +11,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Module;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCancelRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 이커머스 알림 데이터 필터 리스너
|
||||
@@ -418,16 +419,18 @@ class EcommerceNotificationDataListener implements HookListenerInterface
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 비회원 주문 조회 화면 경로
|
||||
* 비회원 주문 조회 화면의 기준 경로 (상점 주소 설정 하위)
|
||||
*
|
||||
* 후속 이메일 발송 이슈가 비회원 주문 조회 URL을 만들 때 사용하는 경로 기준이다.
|
||||
* 비회원 본인 확인은 주문번호 + 전화번호 + 조회 비밀번호 입력을 전제로 하므로
|
||||
* 주문번호를 쿼리에 노출하지 않고 순수 경로만 제공한다.
|
||||
*
|
||||
* 앞의 상점 주소는 운영자 설정이므로 ShopPathResolver 가 붙인다 — 상수로 `/shop` 을
|
||||
* 박아 두면 주소를 바꾼 상점의 안내 메일이 존재하지 않는 화면을 가리킨다 (공개 #85).
|
||||
* 경로는 비회원 주문 조회 라우트(templates/sirsoft-basic/routes.json) 및
|
||||
* OrderController::showByOrderNumber 의 비회원 redirect_to 와 동일하게 유지한다.
|
||||
*/
|
||||
private const GUEST_ORDER_LOOKUP_PATH = '/shop/guest/orders';
|
||||
private const GUEST_ORDER_LOOKUP_SUFFIX = 'guest/orders';
|
||||
|
||||
/**
|
||||
* 주문 알림 데이터 배열을 구성합니다.
|
||||
@@ -445,7 +448,7 @@ class EcommerceNotificationDataListener implements HookListenerInterface
|
||||
|
||||
if ($order->isGuestOrder()) {
|
||||
$name = $order->getOrdererName() ?? '';
|
||||
$orderUrl = "{$baseUrl}".self::GUEST_ORDER_LOOKUP_PATH;
|
||||
$orderUrl = "{$baseUrl}".ShopPathResolver::path(self::GUEST_ORDER_LOOKUP_SUFFIX);
|
||||
} else {
|
||||
$name = $order->user?->name ?? '';
|
||||
$orderUrl = "{$baseUrl}/mypage/orders/{$order->order_number}";
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Support\Query\BoundedCount;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 통합 검색에 상품 검색 결과를 제공하는 리스너
|
||||
@@ -268,7 +269,8 @@ class SearchProductsListener implements HookListenerInterface
|
||||
'multi_currency_selling_price' => $this->buildMultiCurrencyPrices($product->selling_price),
|
||||
'multi_currency_list_price' => $this->buildMultiCurrencyPrices($product->list_price),
|
||||
'labels' => $labels,
|
||||
'url' => '/shop/products/'.$product->product_code,
|
||||
// 상점 주소는 운영자 설정 — 기본값 리터럴은 주소를 바꾼 상점에서 죽은 링크가 된다 (공개 #85)
|
||||
'url' => ShopPathResolver::path('products/'.$product->product_code),
|
||||
'review_count' => (int) ($product->review_count ?? 0),
|
||||
'rating_avg' => $product->rating_avg !== null ? round((float) $product->rating_avg, 1) : 0.0,
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 카테고리 변경 시 SEO 캐시 무효화 리스너
|
||||
@@ -142,9 +143,8 @@ class SeoCategoryCacheListener implements HookListenerInterface
|
||||
&& (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true);
|
||||
|
||||
if ($visible) {
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
|
||||
$indexer->indexResource('category', $category->id, 'sirsoft-ecommerce', [[
|
||||
'url' => "/{$routePath}/category/{$category->slug}",
|
||||
'url' => ShopPathResolver::path("category/{$category->slug}"),
|
||||
'lastmod' => $category->updated_at?->toW3cString(),
|
||||
'changefreq' => SitemapChangeFreq::Weekly->value,
|
||||
'priority' => 0.6,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Seo\SeoCacheRegenerator;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 상품 변경 시 SEO 캐시 무효화 리스너
|
||||
@@ -160,9 +161,8 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
&& (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true);
|
||||
|
||||
if ($visible) {
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
|
||||
$indexer->indexResource('product', $product->id, 'sirsoft-ecommerce', [[
|
||||
'url' => "/{$routePath}/products/{$product->id}",
|
||||
'url' => ShopPathResolver::path("products/{$product->id}"),
|
||||
'lastmod' => $product->updated_at?->toW3cString(),
|
||||
'changefreq' => SitemapChangeFreq::Weekly->value,
|
||||
'priority' => 0.8,
|
||||
@@ -184,8 +184,8 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
/**
|
||||
* 상품 상세 페이지의 SEO 캐시를 즉시 재생성합니다.
|
||||
*
|
||||
* URL 구성: /{route_path}/products/{id}
|
||||
* route_path는 이커머스 모듈 설정에서 조회합니다.
|
||||
* URL 구성: {상점 기준 경로}/products/{id}
|
||||
* 기준 경로는 상점 주소 설정(route_path / no_route)을 ShopPathResolver 가 해석한 값입니다.
|
||||
*
|
||||
* @param array $args 훅 인자 배열
|
||||
*/
|
||||
@@ -199,8 +199,7 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
|
||||
try {
|
||||
$regenerator = app(SeoCacheRegenerator::class);
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
|
||||
$url = "/{$routePath}/products/{$product->id}";
|
||||
$url = ShopPathResolver::path("products/{$product->id}");
|
||||
$regenerator->renderAndCache($url);
|
||||
|
||||
Log::debug('[SEO] Product detail cache regenerated', [
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Seo\AbstractSitemapContributor;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* Ecommerce 모듈 Sitemap 기여자
|
||||
@@ -74,12 +75,12 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
*/
|
||||
private function collectUrls(): iterable
|
||||
{
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop';
|
||||
|
||||
// 상점 주소 설정(route_path / no_route)을 반영한다 — 주소를 바꾼 상점의 사이트맵이
|
||||
// 기본값 주소를 싣고 나가면 검색엔진이 존재하지 않는 URL 을 수집한다 (공개 #85).
|
||||
// 상품 목록 페이지 — 'SEO 제공 페이지' 토글 OFF 시 제외
|
||||
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) {
|
||||
yield [
|
||||
'url' => "/{$routePath}/products",
|
||||
'url' => ShopPathResolver::path('products'),
|
||||
'changefreq' => SitemapChangeFreq::Daily->value,
|
||||
'priority' => 0.7,
|
||||
'resource_type' => 'shop_index',
|
||||
@@ -91,7 +92,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) {
|
||||
foreach ($this->categoryRepository->streamActiveForSitemap() as $category) {
|
||||
yield [
|
||||
'url' => "/{$routePath}/category/{$category->slug}",
|
||||
'url' => ShopPathResolver::path("category/{$category->slug}"),
|
||||
'lastmod' => $category->updated_at?->toW3cString(),
|
||||
'changefreq' => SitemapChangeFreq::Weekly->value,
|
||||
'priority' => 0.6,
|
||||
@@ -105,7 +106,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) {
|
||||
foreach ($this->productRepository->streamVisibleForSitemap() as $product) {
|
||||
yield [
|
||||
'url' => "/{$routePath}/products/{$product->id}",
|
||||
'url' => ShopPathResolver::path("products/{$product->id}"),
|
||||
'lastmod' => $product->updated_at?->toW3cString(),
|
||||
'changefreq' => SitemapChangeFreq::Weekly->value,
|
||||
'priority' => 0.8,
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductInquiryRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 상품 1:1 문의 서비스
|
||||
@@ -26,11 +28,13 @@ class ProductInquiryService
|
||||
* @param ProductInquiryRepositoryInterface $repository 문의 리포지토리
|
||||
* @param ProductRepositoryInterface $productRepository 상품 리포지토리
|
||||
* @param EcommerceSettingsService $settingsService 이커머스 설정 서비스
|
||||
* @param UserRepositoryInterface $userRepository 사용자 리포지토리 (작성자 이름 배치 조회)
|
||||
*/
|
||||
public function __construct(
|
||||
protected ProductInquiryRepositoryInterface $repository,
|
||||
protected ProductRepositoryInterface $productRepository,
|
||||
protected EcommerceSettingsService $settingsService
|
||||
protected EcommerceSettingsService $settingsService,
|
||||
protected UserRepositoryInterface $userRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -63,13 +67,13 @@ class ProductInquiryService
|
||||
if (! $boardSlug) {
|
||||
return [
|
||||
'items' => [],
|
||||
'meta' => [
|
||||
'board_settings' => $this->defaultBoardSettings(),
|
||||
'meta' => [
|
||||
'board_settings' => $this->defaultBoardSettings(),
|
||||
'inquiry_available' => false,
|
||||
'total' => 0,
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'last_page' => 1,
|
||||
'total' => 0,
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'last_page' => 1,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -118,7 +122,7 @@ class ProductInquiryService
|
||||
// user_id 일괄 조회 (N+1 방지)
|
||||
$userIds = $pagePivots->map(fn ($pivot) => $posts[$pivot->inquirable_id]['user_id'] ?? null)
|
||||
->filter()->unique()->values()->all();
|
||||
$userMap = User::whereIn('id', $userIds)->pluck('name', 'id');
|
||||
$userMap = $this->userRepository->getNamesByIds($userIds);
|
||||
|
||||
$items = $pagePivots->map(function ($pivot) use ($posts, $currentUserId, $userMap) {
|
||||
$post = $posts[$pivot->inquirable_id] ?? null;
|
||||
@@ -128,19 +132,19 @@ class ProductInquiryService
|
||||
$name = $userId ? ($userMap[$userId] ?? $post['author_name'] ?? null) : ($post['author_name'] ?? null);
|
||||
|
||||
return [
|
||||
'id' => $pivot->id,
|
||||
'post_id' => $pivot->inquirable_id,
|
||||
'user_id' => $userId,
|
||||
'id' => $pivot->id,
|
||||
'post_id' => $pivot->inquirable_id,
|
||||
'user_id' => $userId,
|
||||
'author_name' => $this->maskAuthorName($name),
|
||||
'title' => $post['title'] ?? null,
|
||||
'category' => $post['category'] ?? null,
|
||||
'content' => $post['content'] ?? null,
|
||||
'is_secret' => $post['is_secret'] ?? false,
|
||||
'is_owner' => $isOwner,
|
||||
'title' => $post['title'] ?? null,
|
||||
'category' => $post['category'] ?? null,
|
||||
'content' => $post['content'] ?? null,
|
||||
'is_secret' => $post['is_secret'] ?? false,
|
||||
'is_owner' => $isOwner,
|
||||
'is_answered' => $pivot->is_answered ?? false,
|
||||
'answered_at' => $pivot->answered_at?->toIso8601String(),
|
||||
'created_at' => $pivot->created_at?->toIso8601String(),
|
||||
'reply' => $post['reply'] ?? null,
|
||||
'created_at' => $pivot->created_at?->toIso8601String(),
|
||||
'reply' => $post['reply'] ?? null,
|
||||
'attachments' => $post['attachments'] ?? [],
|
||||
];
|
||||
})->values()->all();
|
||||
@@ -149,14 +153,14 @@ class ProductInquiryService
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'meta' => [
|
||||
'board_settings' => $boardSettings,
|
||||
'meta' => [
|
||||
'board_settings' => $boardSettings,
|
||||
'inquiry_available' => (bool) $boardSlug,
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'last_page' => max(1, $lastPage),
|
||||
'abilities' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'last_page' => max(1, $lastPage),
|
||||
'abilities' => [
|
||||
'can_update' => PermissionHelper::check('sirsoft-ecommerce.inquiries.update', Auth::user()),
|
||||
'can_delete' => PermissionHelper::check('sirsoft-ecommerce.inquiries.delete', Auth::user()),
|
||||
],
|
||||
@@ -172,14 +176,14 @@ class ProductInquiryService
|
||||
private function defaultBoardSettings(): array
|
||||
{
|
||||
return [
|
||||
'secret_mode' => 'disabled',
|
||||
'categories' => [],
|
||||
'use_file_upload' => false,
|
||||
'max_file_count' => 5,
|
||||
'max_file_size' => 10485760,
|
||||
'secret_mode' => 'disabled',
|
||||
'categories' => [],
|
||||
'use_file_upload' => false,
|
||||
'max_file_count' => 5,
|
||||
'max_file_size' => 10485760,
|
||||
'allowed_extensions' => [],
|
||||
'min_title_length' => 2,
|
||||
'max_title_length' => 200,
|
||||
'min_title_length' => 2,
|
||||
'max_title_length' => 200,
|
||||
'min_content_length' => 10,
|
||||
'max_content_length' => 10000,
|
||||
];
|
||||
@@ -209,7 +213,7 @@ class ProductInquiryService
|
||||
$product = $this->productRepository->find($productId);
|
||||
|
||||
if (! $product) {
|
||||
throw new \Illuminate\Database\Eloquent\ModelNotFoundException(
|
||||
throw new ModelNotFoundException(
|
||||
__('sirsoft-ecommerce::messages.products.not_found')
|
||||
);
|
||||
}
|
||||
@@ -312,41 +316,42 @@ class ProductInquiryService
|
||||
$post = $posts[$inquiry->inquirable_id] ?? null;
|
||||
|
||||
return [
|
||||
'id' => $inquiry->id,
|
||||
'product_id' => $inquiry->product_id,
|
||||
'product' => $inquiry->product ? [
|
||||
'id' => $inquiry->product->id,
|
||||
'product_code' => $inquiry->product->product_code,
|
||||
'name' => $inquiry->product->getLocalizedName(),
|
||||
'id' => $inquiry->id,
|
||||
'product_id' => $inquiry->product_id,
|
||||
'product' => $inquiry->product ? [
|
||||
'id' => $inquiry->product->id,
|
||||
'product_code' => $inquiry->product->product_code,
|
||||
'name' => $inquiry->product->getLocalizedName(),
|
||||
'thumbnail_url' => $inquiry->product->getThumbnailUrl(),
|
||||
'url' => '/' . ltrim($this->settingsService->getSetting('basic_info.route_path', 'shop'), '/') . '/products/' . $inquiry->product->product_code,
|
||||
// 주소 없이 운영하는 상점(no_route)까지 반영해야 실제 상품 화면을 가리킨다 (공개 #85)
|
||||
'url' => ShopPathResolver::path('products/'.$inquiry->product->product_code),
|
||||
] : null,
|
||||
'product_name' => $this->localizeProductName($inquiry->product_name_snapshot),
|
||||
'is_answered' => $inquiry->is_answered,
|
||||
'answered_at' => $inquiry->answered_at?->toIso8601String(),
|
||||
'created_at' => $inquiry->created_at?->toIso8601String(),
|
||||
'updated_at' => $inquiry->updated_at?->toIso8601String(),
|
||||
'product_name' => $this->localizeProductName($inquiry->product_name_snapshot),
|
||||
'is_answered' => $inquiry->is_answered,
|
||||
'answered_at' => $inquiry->answered_at?->toIso8601String(),
|
||||
'created_at' => $inquiry->created_at?->toIso8601String(),
|
||||
'updated_at' => $inquiry->updated_at?->toIso8601String(),
|
||||
// 게시판 Post 데이터 (게시판 미연동 시 null)
|
||||
'title' => $post['title'] ?? null,
|
||||
'category' => $post['category'] ?? null,
|
||||
'content' => $post['content'] ?? null,
|
||||
'is_secret' => $post['is_secret'] ?? false,
|
||||
'reply' => $post['reply'] ?? null,
|
||||
'attachments' => $post['attachments'] ?? [],
|
||||
'title' => $post['title'] ?? null,
|
||||
'category' => $post['category'] ?? null,
|
||||
'content' => $post['content'] ?? null,
|
||||
'is_secret' => $post['is_secret'] ?? false,
|
||||
'reply' => $post['reply'] ?? null,
|
||||
'attachments' => $post['attachments'] ?? [],
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'to' => $paginator->lastItem(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'to' => $paginator->lastItem(),
|
||||
'inquiry_available' => (bool) $boardSlug,
|
||||
'abilities' => [
|
||||
'abilities' => [
|
||||
'can_update' => PermissionHelper::check('sirsoft-ecommerce.inquiries.update', Auth::user()),
|
||||
'can_delete' => PermissionHelper::check('sirsoft-ecommerce.inquiries.delete', Auth::user()),
|
||||
],
|
||||
@@ -394,7 +399,7 @@ class ProductInquiryService
|
||||
|
||||
Log::info('상품 문의 수정 완료', [
|
||||
'inquiry_id' => $inquiryId,
|
||||
'user_id' => Auth::id(),
|
||||
'user_id' => Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -442,7 +447,7 @@ class ProductInquiryService
|
||||
|
||||
Log::info('상품 문의 삭제 완료', [
|
||||
'inquiry_id' => $inquiryId,
|
||||
'user_id' => Auth::id(),
|
||||
'user_id' => Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Support;
|
||||
|
||||
/**
|
||||
* 상점 화면 경로의 SSoT.
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다 — `basic_info.route_path` 로 바꿀 수 있고,
|
||||
* `basic_info.no_route` 를 켜면 주소 세그먼트 없이 루트에 붙는다. 그런데 서버가
|
||||
* 만들어 내보내는 상점 주소(주문 완료 이동 · 비회원 주문 조회 안내 · 알림 메일의
|
||||
* 주문 조회 링크 · 통합검색 결과 링크 · 사이트맵/SEO 캐시 URL)는 각 지점에서
|
||||
* `/shop` 리터럴이나 `"/{$routePath}/..."` 조립으로 흩어져 있었다.
|
||||
*
|
||||
* 기본값이 아닌 상점에서는 그 주소가 존재하지 않는 페이지를 가리키는데, 서버는
|
||||
* 문자열을 만들어 내려보냈을 뿐이라 예외도 로그도 남지 않는다. 링크만 조용히 죽는다.
|
||||
*
|
||||
* 설정 접근은 `g7_module_settings()` 로 고정한다. 매 요청 부팅 시 적재된 배열을 읽으므로
|
||||
* 파일 I/O 가 없고, 테스트에서 `Config::set('g7_settings.modules.*')` 로 축을 주입할 수 있다.
|
||||
*
|
||||
* ## 공개 계약 (다른 확장이 소비한다)
|
||||
*
|
||||
* 상점 주소가 필요한 확장은 자체 계산을 두지 말고 이 클래스를 거친다. 결제 플러그인
|
||||
* 4종(tosspayments / pay_kginicis / pay_nhnkcp / pay_nicepayments)이 결제 완료·실패
|
||||
* 리다이렉트 주소를 만들 때 `base()` 를 호출한다. 같은 계산이 여러 벌로 흩어지면
|
||||
* 주소 규칙이 바뀔 때 한 곳을 빠뜨리게 되고, 그 확장에서만 링크가 조용히 죽는다.
|
||||
*
|
||||
* 소비 확장은 `plugin.json` / `module.json` 의 `dependencies.modules.sirsoft-ecommerce`
|
||||
* 를 이 계약이 도입된 `>=1.1.0` 이상으로 선언해야 한다.
|
||||
*
|
||||
* 시그니처를 바꾸거나 제거할 때는 위 소비 확장을 전수 확인하고 각 확장의 최소 버전
|
||||
* 제약을 함께 올린다 (CLAUDE.md "버전 동기화 의무" ②).
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
class ShopPathResolver
|
||||
{
|
||||
/** 상점 주소 설정 키 */
|
||||
public const ROUTE_PATH_KEY = 'basic_info.route_path';
|
||||
|
||||
/** 주소 없이 운영 여부 설정 키 */
|
||||
public const NO_ROUTE_KEY = 'basic_info.no_route';
|
||||
|
||||
/** 기본 상점 주소 (설정 미지정·빈 값 시 폴백) */
|
||||
public const DEFAULT_ROUTE_PATH = 'shop';
|
||||
|
||||
/**
|
||||
* 상점 화면의 기준 경로를 반환합니다.
|
||||
*
|
||||
* `no_route` 가 켜져 있으면 빈 문자열이다 — 상점이 루트에 붙어 있어 앞에 붙일
|
||||
* 세그먼트가 없다는 뜻이며, `path()` 가 이를 이중 슬래시 없이 이어 붙인다.
|
||||
*
|
||||
* @return string 앞에만 슬래시가 붙은 기준 경로 (예: `/shop`), 주소 미사용 시 빈 문자열
|
||||
*/
|
||||
public static function base(): string
|
||||
{
|
||||
if (g7_module_settings('sirsoft-ecommerce', self::NO_ROUTE_KEY)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$routePath = trim((string) g7_module_settings('sirsoft-ecommerce', self::ROUTE_PATH_KEY, self::DEFAULT_ROUTE_PATH), '/');
|
||||
|
||||
return '/'.($routePath !== '' ? $routePath : self::DEFAULT_ROUTE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 경로에 하위 경로를 이어 붙인 상점 화면 경로를 반환합니다.
|
||||
*
|
||||
* 슬래시 중복은 한 겹으로 정규화한다 (`no_route` + 앞 슬래시 붙은 하위 경로 조합에서
|
||||
* `//products` 가 만들어지는 것을 막는다). 코어 라우트 해석기가 같은 이유로 같은
|
||||
* 정규화를 수행한다 (`app/Seo/TemplateRouteResolver.php`).
|
||||
*
|
||||
* @param string $suffix 기준 경로 하위의 화면 경로 (앞 슬래시 유무 무관)
|
||||
* @return string 정규화된 상점 화면 경로
|
||||
*/
|
||||
public static function path(string $suffix): string
|
||||
{
|
||||
return (string) preg_replace('#/+#', '/', self::base().'/'.ltrim($suffix, '/'));
|
||||
}
|
||||
}
|
||||
+81
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
@@ -18,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/**
|
||||
* 회원/비회원 공유 주문 (Public\OrderController) 테스트
|
||||
@@ -184,6 +186,51 @@ class OrderControllerTest extends ModuleTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1-1. 주문 완료 이동 주소는 상점 주소 설정을 따른다 (공개 #85)
|
||||
*
|
||||
* 기본값 상점(`/shop`) · 운영자가 바꾼 주소(`/store`) · 주소 없이 운영(no_route) 3축.
|
||||
* 서버가 기본값을 리터럴로 내려보내면 뒤의 두 상점에서는 결제를 마친 손님이
|
||||
* 존재하지 않는 페이지로 이동한다.
|
||||
*
|
||||
* @param array<string, mixed> $basicInfo 상점 기본 설정
|
||||
*/
|
||||
#[DataProvider('shopRoutePathProvider')]
|
||||
public function test_주문_완료_이동주소가_상점_주소_설정을_따른다(array $basicInfo, string $expectedPrefix): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
|
||||
$this->createGuestTempOrder();
|
||||
|
||||
$response = $this->postJson(
|
||||
'/api/modules/sirsoft-ecommerce/user/orders',
|
||||
$this->guestOrderPayload(),
|
||||
['X-Cart-Key' => $this->cartKey]
|
||||
);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$orderNumber = $response->json('data.order.order_number');
|
||||
$this->assertSame(
|
||||
"{$expectedPrefix}/orders/{$orderNumber}/complete",
|
||||
$response->json('data.redirect_url')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상점 주소 3축 (기본 / 변경 / 주소 없음)
|
||||
*
|
||||
* @return array<string, array{0: array<string, mixed>, 1: string}>
|
||||
*/
|
||||
public static function shopRoutePathProvider(): array
|
||||
{
|
||||
return [
|
||||
'기본 상점 주소' => [['route_path' => 'shop'], '/shop'],
|
||||
'운영자가 바꾼 주소' => [['route_path' => 'store'], '/store'],
|
||||
'주소 없이 운영' => [['route_path' => 'shop', 'no_route' => true], ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 비회원 주문은 is_first_order = false 로 저장된다 (이슈 #55 정책)
|
||||
*/
|
||||
@@ -1165,6 +1212,40 @@ class OrderControllerTest extends ModuleTestCase
|
||||
->assertJsonPath('errors.redirect_to', '/shop/guest/orders');
|
||||
}
|
||||
|
||||
/**
|
||||
* 20-1. 상점 주소를 바꾼 상점에서는 비회원 조회 안내도 그 주소를 가리킨다 (공개 #85)
|
||||
*
|
||||
* 기본값 `/shop` 을 리터럴로 내려보내면 주소를 바꾼 상점에서는 존재하지 않는 화면으로
|
||||
* 안내한다. 서버는 문자열을 만들어 보냈을 뿐이라 예외도 404 로그도 남지 않는다.
|
||||
*/
|
||||
public function test_상점_주소_변경시_비회원_lookup_redirect도_따라간다(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', ['route_path' => 'store']);
|
||||
|
||||
[$orderNumber] = $this->placeGuestOrderAndToken();
|
||||
|
||||
$this->getJson("/api/modules/sirsoft-ecommerce/user/orders/{$orderNumber}")
|
||||
->assertStatus(404)
|
||||
->assertJsonPath('errors.redirect_to', '/store/guest/orders');
|
||||
}
|
||||
|
||||
/**
|
||||
* 20-2. 주소 없이 운영하는 상점(no_route)에서는 세그먼트 없이 루트에 붙는다 (공개 #85)
|
||||
*/
|
||||
public function test_주소_없는_상점은_비회원_lookup_redirect가_루트_경로다(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', [
|
||||
'route_path' => 'shop',
|
||||
'no_route' => true,
|
||||
]);
|
||||
|
||||
[$orderNumber] = $this->placeGuestOrderAndToken();
|
||||
|
||||
$this->getJson("/api/modules/sirsoft-ecommerce/user/orders/{$orderNumber}")
|
||||
->assertStatus(404)
|
||||
->assertJsonPath('errors.redirect_to', '/guest/orders');
|
||||
}
|
||||
|
||||
/**
|
||||
* 21. 토큰으로 주문 취소 성공 (결제완료 상태)
|
||||
*/
|
||||
|
||||
+53
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -75,6 +76,58 @@ class PublicProductInquiryControllerTest extends ModuleTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성자 이름은 회원 계정에서 배치로 모아 온 뒤 마스킹되어 내려간다.
|
||||
*
|
||||
* 이름은 행마다 조회하면 N+1 이 되므로 표시할 ID 만 모아 한 번에 읽는다
|
||||
* (`UserRepositoryInterface::getNamesByIds`). 이 경로가 비면 목록의 작성자
|
||||
* 칸이 게시글 스냅샷 이름으로 조용히 대체되어, 개명 후에도 옛 이름이 남는다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 문의_목록의_작성자_이름은_회원_계정에서_읽어_마스킹된다(): void
|
||||
{
|
||||
app(EcommerceSettingsService::class)->setSetting('inquiry.board_slug', 'test-board');
|
||||
|
||||
$user = $this->createUser();
|
||||
$user->forceFill(['name' => '홍길동'])->save();
|
||||
|
||||
$pivot = ProductInquiry::create([
|
||||
'product_id' => $this->product->id,
|
||||
'inquirable_type' => 'board_post',
|
||||
'inquirable_id' => 777,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.inquiry.get_settings',
|
||||
fn ($defaults) => $defaults,
|
||||
priority: 1
|
||||
);
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.inquiry.get_by_ids',
|
||||
fn () => [[
|
||||
'id' => $pivot->inquirable_id,
|
||||
'user_id' => $user->id,
|
||||
// 게시글 스냅샷에는 다른 이름이 들어 있다 — 회원 계정 이름이 이겨야 한다.
|
||||
'author_name' => '스냅샷이름',
|
||||
'title' => '문의 제목',
|
||||
'is_secret' => false,
|
||||
]],
|
||||
priority: 1
|
||||
);
|
||||
|
||||
$response = $this->getJson(
|
||||
"/api/modules/sirsoft-ecommerce/products/{$this->product->id}/inquiries"
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// 존재 확정 후 값 비교 — 항목이 비면 이름 비교는 아무것도 증명하지 못한다.
|
||||
$items = $response->json('data.items');
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertSame('홍*동', $items[0]['author_name']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function board_slug_미설정_시_빈_목록과_inquiry_available_false를_반환한다(): void
|
||||
{
|
||||
|
||||
+50
-17
@@ -3,6 +3,8 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
@@ -18,7 +20,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
*/
|
||||
class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
private \App\Models\User $user;
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -58,7 +60,7 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => false,
|
||||
]);
|
||||
@@ -73,13 +75,44 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
$this->assertCount(1, $data['items']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문의 목록의 상품 링크가 상점 주소 설정을 따르는지 확인 (공개 #85)
|
||||
*
|
||||
* 종전에는 route_path 만 읽고 no_route 를 무시해, 주소 없이 운영하는 상점의
|
||||
* 문의 목록이 `/shop/products/...` 라는 존재하지 않는 화면을 가리켰다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 문의_목록의_상품_링크가_상점_주소_설정을_따른다(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', [
|
||||
'route_path' => 'shop',
|
||||
'no_route' => true,
|
||||
]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson('/api/modules/sirsoft-ecommerce/user/inquiries');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame(
|
||||
"/products/{$product->product_code}",
|
||||
$response->json('data.items.0.product.url')
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 다른_사용자의_문의는_조회되지_않는다(): void
|
||||
{
|
||||
$otherUser = $this->createUser();
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'user_id' => $otherUser->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
@@ -99,13 +132,13 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => true,
|
||||
]);
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => false,
|
||||
]);
|
||||
|
||||
@@ -123,13 +156,13 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => true,
|
||||
]);
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => false,
|
||||
]);
|
||||
|
||||
@@ -151,13 +184,13 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name_snapshot' => ['ko' => '사과 1박스', 'en' => 'Apple Box'],
|
||||
]);
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name_snapshot' => ['ko' => 'USB 충전 케이블', 'en' => 'USB Cable'],
|
||||
]);
|
||||
|
||||
@@ -181,7 +214,7 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->count(3)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
@@ -201,7 +234,7 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
|
||||
+37
-8
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Search;
|
||||
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
@@ -46,7 +51,7 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$module = app(\App\Extension\ModuleManager::class)->getModule('sirsoft-ecommerce');
|
||||
$module = app(ModuleManager::class)->getModule('sirsoft-ecommerce');
|
||||
if ($module === null) {
|
||||
return;
|
||||
}
|
||||
@@ -57,7 +62,7 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
// filter 큐에 중복 추가되어 searchProducts 가 한 요청에서 2회 실행되어 두 번째
|
||||
// 호출이 첫 번째 결과를 빈 결과로 덮어쓰는 문제 발생.
|
||||
// - registrar dedup 캐시도 함께 비워 register() 가 실제 동작하도록 한다.
|
||||
\App\Extension\HookListenerRegistrar::clear();
|
||||
HookListenerRegistrar::clear();
|
||||
|
||||
foreach ($module->getHookListeners() as $listenerClass) {
|
||||
if (! class_exists($listenerClass)) {
|
||||
@@ -66,10 +71,10 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
try {
|
||||
$subscribed = $listenerClass::getSubscribedHooks();
|
||||
foreach (array_keys($subscribed) as $hookName) {
|
||||
\App\Extension\HookManager::clearFilter($hookName);
|
||||
\App\Extension\HookManager::clearAction($hookName);
|
||||
HookManager::clearFilter($hookName);
|
||||
HookManager::clearAction($hookName);
|
||||
}
|
||||
\App\Extension\HookListenerRegistrar::register($listenerClass, 'sirsoft-ecommerce');
|
||||
HookListenerRegistrar::register($listenerClass, 'sirsoft-ecommerce');
|
||||
} catch (\Throwable $e) {
|
||||
// skip individual listener failures
|
||||
}
|
||||
@@ -88,7 +93,7 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
*/
|
||||
protected function flushProductFulltextIndex(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\DB::statement('ALTER TABLE g7_ecommerce_products ENGINE=InnoDB');
|
||||
DB::statement('ALTER TABLE g7_ecommerce_products ENGINE=InnoDB');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,6 +254,31 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합검색 상품 링크가 상점 주소 설정을 따르는지 확인 (공개 #85)
|
||||
*
|
||||
* 기본값 리터럴을 내려보내면 상점 주소를 바꾼 사이트의 검색 결과가 전부
|
||||
* 존재하지 않는 상품 화면을 가리킨다 — 링크는 만들어졌으므로 오류가 남지 않는다.
|
||||
*/
|
||||
public function test_search_product_url_follows_shop_route_path_setting(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', ['route_path' => 'store']);
|
||||
|
||||
$keyword = 'routepathurlkeyword';
|
||||
$product = Product::factory()->create([
|
||||
'name' => ['ko' => '주소확인용 상품', 'en' => "{$keyword} Route Path"],
|
||||
'display_status' => ProductDisplayStatus::VISIBLE,
|
||||
]);
|
||||
$this->flushProductFulltextIndex();
|
||||
|
||||
$response = $this->getJson("/api/search?q={$keyword}&type=products");
|
||||
$response->assertStatus(200);
|
||||
|
||||
$products = $response->json('data.products') ?? [];
|
||||
$this->assertNotEmpty($products, '검색 결과가 비어 URL 축을 검증할 수 없다');
|
||||
$this->assertSame("/store/products/{$product->product_code}", $products[0]['url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품절/판매중단 상품이 검색 결과에 포함되는지 확인
|
||||
*
|
||||
@@ -326,5 +356,4 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
$ids = collect($data['products'] ?? [])->pluck('id')->toArray();
|
||||
$this->assertContains($product->id, $ids);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\EcommerceNotificationDataListener;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
@@ -123,6 +124,22 @@ class EcommerceNotificationDataListenerTest extends ModuleTestCase
|
||||
$this->assertStringNotContainsString('ORD-GUEST-1', $result['data']['order_url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 주문 안내 메일의 조회 주소는 상점 주소 설정을 따른다 (공개 #85).
|
||||
*
|
||||
* 상수로 `/shop` 을 박아 두면 주소를 바꾼 상점의 손님이 받은 메일이 존재하지 않는
|
||||
* 화면을 가리킨다 — 메일은 정상 발송되므로 아무 오류도 남지 않는다.
|
||||
*/
|
||||
public function test_guest_lookup_url_follows_shop_route_path_setting(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', ['route_path' => 'store']);
|
||||
|
||||
$order = $this->makeGuestOrder('비회원주문자', 'guest@example.com', 'ORD-GUEST-2');
|
||||
$result = $this->listener->extractData($this->emptyDefault(), 'order_confirmed', [$order]);
|
||||
|
||||
$this->assertStringEndsWith('/store/guest/orders', $result['data']['order_url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 주문 컨텍스트 → 코어 알림 표준 키 guest_recipient 제공 (회원과 동일 발송 경로).
|
||||
*/
|
||||
|
||||
+71
@@ -6,6 +6,7 @@ use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
|
||||
@@ -218,6 +219,76 @@ class SeoCategoryCacheListenerTest extends ModuleTestCase
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// syncSitemapIndex() — 상점 주소 설정 반영 (공개 #85)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 사이트맵 색인 URL 이 기본 상점 주소를 따르는지 확인
|
||||
*/
|
||||
public function test_sitemap_index_url_uses_default_shop_path(): void
|
||||
{
|
||||
$this->assertSitemapIndexUrl([], '/shop/category/outer');
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영자가 상점 주소를 바꾸면 사이트맵 색인 URL 도 그 주소를 따르는지 확인
|
||||
*/
|
||||
public function test_sitemap_index_url_follows_custom_route_path(): void
|
||||
{
|
||||
$this->assertSitemapIndexUrl(['route_path' => 'store'], '/store/category/outer');
|
||||
}
|
||||
|
||||
/**
|
||||
* 주소 없이 운영하는 상점(no_route)의 사이트맵 색인 URL 은 세그먼트 없이 루트에 붙는다
|
||||
*
|
||||
* 종전에는 route_path 만 읽고 no_route 를 무시해 `/shop/category/outer` 를 색인했다 —
|
||||
* 실제 화면 주소는 `/category/outer` 이므로 사이트맵이 없는 주소를 검색엔진에 알린다.
|
||||
*/
|
||||
public function test_sitemap_index_url_omits_route_segment_when_no_route(): void
|
||||
{
|
||||
$this->assertSitemapIndexUrl(
|
||||
['route_path' => 'shop', 'no_route' => true],
|
||||
'/category/outer'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 상점 주소 설정에서 색인된 카테고리 URL 이 기대값과 같은지 검증합니다.
|
||||
*
|
||||
* @param array $basicInfo 주입할 `basic_info` 설정
|
||||
* @param string $expectedUrl 기대하는 색인 URL
|
||||
*/
|
||||
private function assertSitemapIndexUrl(array $basicInfo, string $expectedUrl): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
|
||||
$indexedUrl = null;
|
||||
$indexer = Mockery::mock(SitemapIndexer::class);
|
||||
$indexer->shouldReceive('indexResource')
|
||||
->once()
|
||||
->andReturnUsing(function (string $type, int $id, string $owner, array $urls) use (&$indexedUrl) {
|
||||
$indexedUrl = $urls[0]['url'] ?? null;
|
||||
});
|
||||
$this->app->instance(SitemapIndexer::class, $indexer);
|
||||
|
||||
$this->app->instance(SeoCacheManagerInterface::class, $this->createMock(SeoCacheManagerInterface::class));
|
||||
Log::shouldReceive('debug')->atLeast()->once();
|
||||
|
||||
$category = (object) [
|
||||
'id' => 42,
|
||||
'slug' => 'outer',
|
||||
'is_active' => true,
|
||||
'updated_at' => null,
|
||||
];
|
||||
|
||||
$this->listener->onCategoryChange($category);
|
||||
|
||||
// 존재 확정 후 값 비교 — 색인 자체가 일어나지 않으면 URL 비교는 의미가 없다
|
||||
$this->assertNotNull($indexedUrl, '카테고리 사이트맵 색인이 수행되지 않았다');
|
||||
$this->assertSame($expectedUrl, $indexedUrl);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// handle() 메서드 테스트
|
||||
// ========================================
|
||||
|
||||
+30
@@ -7,6 +7,7 @@ use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SeoCacheRegenerator;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
|
||||
@@ -120,6 +121,35 @@ class SeoProductCacheListenerTest extends ModuleTestCase
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주소 없이 운영하는 상점(no_route)의 SEO 캐시 경로는 세그먼트 없이 루트에 붙는다 (공개 #85)
|
||||
*
|
||||
* 종전에는 route_path 만 읽고 no_route 를 무시해 `/shop/products/7` 을 재생성했다 —
|
||||
* 실제 화면 주소는 `/products/7` 이므로 봇이 받는 캐시가 영원히 채워지지 않는다.
|
||||
*/
|
||||
public function test_on_product_update_regenerates_root_path_when_no_route(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', [
|
||||
'route_path' => 'shop',
|
||||
'no_route' => true,
|
||||
]);
|
||||
|
||||
$product = (object) ['id' => 7];
|
||||
|
||||
$this->expectCommonInvalidations($product);
|
||||
|
||||
$this->regeneratorMock->shouldReceive('renderAndCache')
|
||||
->once()
|
||||
->with('/products/7')
|
||||
->andReturn(true);
|
||||
|
||||
Log::shouldReceive('debug')->atLeast()->once();
|
||||
|
||||
$this->listener->onProductUpdate($product);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정 시 home과 search/index 캐시도 무효화되는지 확인
|
||||
*/
|
||||
|
||||
@@ -82,6 +82,26 @@ class EcommerceSitemapContributorTest extends ModuleTestCase
|
||||
$this->assertContains('/shop/products', $urlPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* getUrls: 주소 없이 운영하는 상점(no_route)은 세그먼트 없이 루트 경로를 싣는다 (공개 #85)
|
||||
*
|
||||
* route_path 만 읽고 no_route 를 무시하면 사이트맵이 `/shop/...` 을 검색엔진에
|
||||
* 제출하지만 실제 화면은 `/...` 이다 — 색인된 URL 전부가 404 로 수집된다.
|
||||
*/
|
||||
public function test_get_urls_omits_route_segment_when_no_route(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', [
|
||||
'route_path' => 'shop',
|
||||
'no_route' => true,
|
||||
]);
|
||||
|
||||
$contributor = $this->app->make(EcommerceSitemapContributor::class);
|
||||
$urlPaths = array_column($contributor->getUrls(), 'url');
|
||||
|
||||
$this->assertContains('/products', $urlPaths);
|
||||
$this->assertNotContains('/shop/products', $urlPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* getUrls: 정적/카테고리/상품 URL 항목이 각각 올바른 키 구조를 가진다
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 상점 경로 해석기 단위 테스트
|
||||
*
|
||||
* 상점 주소는 운영자가 바꿀 수 있고(`basic_info.route_path`), 아예 주소 없이 루트에
|
||||
* 붙일 수도 있다(`basic_info.no_route`). 그런데 주문 완료 이동·알림 메일·검색 결과·
|
||||
* 사이트맵이 각자 `/shop` 을 문자열로 조립해 왔다. 기본값을 쓰지 않는 상점에서는
|
||||
* 그 주소가 전부 존재하지 않는 페이지를 가리킨다 — 예외도 404 로그도 남지 않고
|
||||
* 링크만 조용히 죽는다.
|
||||
*
|
||||
* 이 테스트는 해석 규칙 자체를 고정한다. 소비 지점의 반영 여부는 각 지점 테스트가 본다.
|
||||
*
|
||||
* 결제 플러그인 4종의 리다이렉트 주소도 이 해석기를 SSoT 로 삼는다
|
||||
* (plugins/_bundled/sirsoft-tosspayments/tests/scenarios/payment-redirect-shop-route-path.yaml).
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects payment_redirect_follows_route_path, payment_redirect_drops_segment_when_no_route,
|
||||
* default_shop_redirect_unchanged
|
||||
*/
|
||||
class ShopPathResolverTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 상점 기본 설정을 주입합니다.
|
||||
*
|
||||
* 해석기는 `g7_module_settings()` 를 통해 설정을 읽으므로 부팅 시 적재되는
|
||||
* `g7_settings.modules.*` 배열을 직접 주입해 파일 I/O 없이 축을 만든다.
|
||||
*
|
||||
* @param array<string, mixed> $basicInfo basic_info 설정 값
|
||||
*/
|
||||
private function setBasicInfo(array $basicInfo): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 설정이_없으면_기본_상점_주소로_폴백한다(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce', []);
|
||||
|
||||
$this->assertSame('/shop', ShopPathResolver::base());
|
||||
$this->assertSame('/shop/products/A1', ShopPathResolver::path('products/A1'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 운영자가_바꾼_주소를_반영한다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => 'store']);
|
||||
|
||||
$this->assertSame('/store', ShopPathResolver::base());
|
||||
$this->assertSame('/store/orders/20260806-0001/complete', ShopPathResolver::path('orders/20260806-0001/complete'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 주소_없이_운영하면_루트에_붙는다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => 'shop', 'no_route' => true]);
|
||||
|
||||
$this->assertSame('', ShopPathResolver::base());
|
||||
$this->assertSame('/products/A1', ShopPathResolver::path('products/A1'));
|
||||
$this->assertSame('/', ShopPathResolver::path(''));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 주소가_빈_문자열이면_기본값으로_폴백한다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => '']);
|
||||
|
||||
$this->assertSame('/shop', ShopPathResolver::base());
|
||||
$this->assertSame('/shop/guest/orders', ShopPathResolver::path('guest/orders'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 앞뒤_슬래시가_붙은_주소도_한_겹으로_정규화한다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => '/store/']);
|
||||
|
||||
$this->assertSame('/store', ShopPathResolver::base());
|
||||
$this->assertSame('/store/products', ShopPathResolver::path('/products'));
|
||||
$this->assertSame('/store/products', ShopPathResolver::path('products'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function no_route_가_꺼져_있으면_주소를_유지한다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => 'store', 'no_route' => false]);
|
||||
|
||||
$this->assertSame('/store', ShopPathResolver::base());
|
||||
$this->assertSame('/store/category/tops', ShopPathResolver::path('category/tops'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 이 없어 fallback YAML 파서가
|
||||
# nested axes 배열을 읽지 못하는 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도
|
||||
# audit:allow 처리됨). 아래 조합은 PHPUnit 5종(해석기 단위 / 주문 생성 Feature /
|
||||
# 비회원 조회 안내 / 알림 메일 / 통합검색 / SEO·사이트맵)으로 전수 커버되어 green 이다.
|
||||
# 프론트는 서버 값 우선 + 폴백 구조라 변경이 없어 레이아웃·E2E 축을 두지 않는다.
|
||||
feature: 서버가 만드는 상점 주소의 route_path/no_route 반영 (공개 #85)
|
||||
|
||||
description: |
|
||||
상점 주소는 운영자 설정이다 — `basic_info.route_path` 로 바꿀 수 있고,
|
||||
`basic_info.no_route` 를 켜면 주소 세그먼트 없이 루트에 붙는다. 그런데 서버가 만들어
|
||||
내보내던 상점 주소는 각 지점에서 `/shop` 리터럴이나 `"/{$routePath}/..."` 조립으로
|
||||
흩어져 있었다.
|
||||
|
||||
기본값이 아닌 상점에서는 그 주소가 존재하지 않는 화면을 가리키는데, 서버는 문자열을
|
||||
만들어 내려보냈을 뿐이라 예외도 404 로그도 남지 않는다 — 링크만 조용히 죽는다.
|
||||
|
||||
ShopPathResolver 를 단일 해석 지점으로 두고 서버 생성 주소 전 지점을 경유시킨다.
|
||||
프론트 소비처(_checkout_summary.json)는 서버 값 우선 + `_global.shopBase` 폴백
|
||||
구조라 변경하지 않는다.
|
||||
|
||||
범위 제외: PG 플러그인 4종의 `redirect_success_url` 설정 기본값 (후속 이슈 분리).
|
||||
|
||||
axes:
|
||||
route_setting:
|
||||
- default # route_path 미설정/기본값 'shop'
|
||||
- custom # 운영자가 바꾼 주소 (예: 'store')
|
||||
- no_route # 주소 없이 루트 운영
|
||||
- empty_value # route_path 빈 문자열 → 기본값 폴백
|
||||
- slashed_value # 앞뒤 슬래시 포함 값 → 한 겹 정규화
|
||||
surface:
|
||||
- order_complete_redirect # 주문 생성 응답 redirect_url
|
||||
- guest_lookup_redirect # 비회원 주문 404 응답 redirect_to
|
||||
- notification_order_url # 알림 메일 비회원 주문 조회 URL
|
||||
- search_product_url # 통합검색 상품 링크
|
||||
- inquiry_product_url # 상품문의 목록의 상품 링크
|
||||
- seo_detail_cache # 상품 상세 SEO 캐시 재생성 경로
|
||||
- seo_sitemap_index # 사이트맵 상품/카테고리/목록 URL
|
||||
order_actor:
|
||||
- guest
|
||||
- member
|
||||
|
||||
exclusions:
|
||||
- { surface: guest_lookup_redirect, order_actor: member, reason: "회원 분기는 /mypage/orders 로 상점 주소와 무관" }
|
||||
- { surface: notification_order_url, order_actor: member, reason: "회원 알림은 마이페이지 URL 사용" }
|
||||
- { surface: search_product_url, order_actor: member, reason: "검색 링크는 주문 주체와 무관" }
|
||||
- { surface: inquiry_product_url, order_actor: member, reason: "문의 링크는 주문 주체와 무관" }
|
||||
- { surface: seo_detail_cache, order_actor: member, reason: "봇 렌더 경로라 주문 주체 없음" }
|
||||
- { surface: seo_sitemap_index, order_actor: member, reason: "봇 렌더 경로라 주문 주체 없음" }
|
||||
|
||||
effects:
|
||||
# 해석 규칙 (SSoT)
|
||||
- resolver_falls_back_to_default_shop_when_unset # 설정 없음 → /shop
|
||||
- resolver_reflects_custom_route_path # 변경 주소 반영
|
||||
- resolver_returns_empty_base_when_no_route # no_route → 기준 경로 빈 문자열
|
||||
- resolver_falls_back_when_route_path_is_blank # 빈 문자열 → 기본값
|
||||
- resolver_normalizes_duplicate_slashes # 슬래시 중복 한 겹 정규화
|
||||
- resolver_keeps_route_when_no_route_is_false # no_route=false 는 주소 유지
|
||||
# 소비 지점
|
||||
- order_complete_redirect_follows_route_path # 주문 완료 이동 주소
|
||||
- order_complete_redirect_drops_segment_when_no_route # no_route 시 루트 경로
|
||||
- guest_lookup_redirect_follows_route_path # 비회원 조회 안내 404 응답
|
||||
- guest_lookup_redirect_drops_segment_when_no_route
|
||||
- notification_guest_order_url_follows_route_path # 알림 메일 조회 링크
|
||||
- search_product_url_follows_route_path # 통합검색 상품 링크
|
||||
- inquiry_product_url_follows_no_route # 상품문의 목록의 상품 링크
|
||||
- seo_detail_cache_path_follows_no_route # SEO 캐시 재생성 경로
|
||||
- sitemap_urls_omit_segment_when_no_route # 사이트맵 URL
|
||||
- category_sitemap_index_url_follows_route_path # 카테고리 사이트맵 증분 색인 URL
|
||||
# 회귀
|
||||
- default_shop_urls_unchanged # 기본값 상점의 주소는 종전과 동일
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/ShopPathResolverTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/EcommerceNotificationDataListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Search/ProductSearchIntegrationTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/UserInquiryControllerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php
|
||||
|
||||
validation:
|
||||
audit_rule: test-scenario-coverage
|
||||
@@ -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": {}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user