From 3ebbda5d8621c263fc2f2d6e626f9c518c176118 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 17 Aug 2026 16:07:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(board,ecommerce,pay,verification,templates?= =?UTF-8?q?,core):=20=EB=AC=B8=EC=9D=98=20=EB=8B=B5=EB=B3=80=20=EC=82=AC?= =?UTF-8?q?=EC=8A=AC=C2=B7=EB=8B=B5=EA=B8=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=C2=B7=EA=B2=80=EC=A6=9D=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20+=20=EC=8B=A4=ED=8C=A8=20=EC=82=AC=EC=9C=A0=20=ED=91=9C?= =?UTF-8?q?=EB=A9=B4=ED=99=94=20=EC=A0=84=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소. - 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어, 경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장 - 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝 - 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트 (전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정) - 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합. 거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용 - onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화, change-password 오변수(response→error) 교정 - 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일 (ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함) - 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기 - develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일) --- ...AdminIdentityMessageTemplateController.php | 13 +- .../Api/Admin/NotificationController.php | 30 ++ .../Api/Auth/NotificationController.php | 30 ++ .../Admin/UpdatePluginSettingsRequest.php | 14 + .../IdentityMessageTemplateService.php | 24 + .../g7-module-sirsoft-board-ja/CHANGELOG.md | 3 +- .../backend/ja/activity_log.php | 1 + .../backend/ja/enums.php | 4 + .../backend/ja/validation.php | 5 + .../frontend/partial/admin/form.json | 8 + .../frontend/partial/admin/settings.json | 7 + .../CHANGELOG.md | 3 +- .../backend/ja/messages.php | 1 + .../CHANGELOG.md | 3 +- .../backend/ja/messages.php | 5 + .../CHANGELOG.md | 3 +- .../backend/ja/messages.php | 4 + .../CHANGELOG.md | 10 + .../backend/ja/messages.php | 10 +- .../language-pack.json | 2 +- modules/_bundled/sirsoft-board/CHANGELOG.md | 12 +- .../config/settings/defaults.json | 1 + ...dd_reply_delete_policy_to_boards_table.php | 49 ++ ...dify_trigger_type_in_board_posts_table.php | 51 +++ .../_bundled/sirsoft-board/docs/api/board.md | 3 +- .../_bundled/sirsoft-board/docs/api/boards.md | 38 +- .../sirsoft-board/docs/api/settings.md | 11 +- .../_bundled/sirsoft-board/editor-spec.json | 3 + .../layouts/admin-board-form.test.tsx | 24 + .../layouts/admin-board-settings.test.tsx | 29 ++ .../resources/lang/partial/en/admin/form.json | 8 + .../lang/partial/en/admin/settings.json | 7 + .../resources/lang/partial/ko/admin/form.json | 8 + .../lang/partial/ko/admin/settings.json | 7 + .../layouts/admin/admin_board_post_form.json | 10 +- .../layouts/admin/admin_board_settings.json | 2 +- .../partials/admin_board_form/_tab_post.json | 64 +++ .../_bulk_apply_modal.json | 2 +- .../_modal_mail_template_edit.json | 2 +- .../_modal_notification_template_edit.json | 2 +- .../_tab_board_settings_reply.json | 85 ++++ .../_tab_identity_policies.json | 4 +- .../admin_board_settings/_tab_seo.json | 6 +- .../_tab_spam_security.json | 2 +- .../src/Enums/ReplyDeletePolicy.php | 80 ++++ .../Exceptions/PostHasRepliesException.php | 43 ++ .../Controllers/Admin/BoardController.php | 8 +- .../Http/Controllers/Admin/PostController.php | 4 + .../Controllers/Admin/ReportController.php | 1 - .../Controllers/User/CommentController.php | 8 +- .../Http/Controllers/User/PostController.php | 13 +- .../Admin/BulkApplySettingsRequest.php | 3 + .../Http/Requests/Admin/IndexBoardRequest.php | 58 +++ .../Requests/Admin/IndexReportRequest.php | 2 +- .../Admin/StoreBoardSettingsRequest.php | 2 + .../src/Http/Requests/BlindCommentRequest.php | 2 +- .../src/Http/Requests/BlindPostRequest.php | 2 +- .../Http/Requests/DestroyCommentRequest.php | 50 ++ .../src/Http/Requests/RestorePostRequest.php | 12 + .../src/Http/Requests/StoreBoardRequest.php | 3 + .../src/Http/Requests/UpdateBoardRequest.php | 2 + .../Http/Requests/User/DestroyPostRequest.php | 51 +++ .../Http/Requests/User/UpdatePostRequest.php | 3 + .../src/Http/Resources/BoardResource.php | 3 + .../EcommerceInquiryHookListener.php | 66 ++- .../sirsoft-board/src/Models/Board.php | 4 + .../src/Repositories/AttachmentRepository.php | 54 +++ .../src/Repositories/CommentRepository.php | 53 +++ .../AttachmentRepositoryInterface.php | 18 + .../Contracts/CommentRepositoryInterface.php | 18 + .../Contracts/PostRepositoryInterface.php | 53 +++ .../src/Repositories/PostRepository.php | 200 ++++++++ .../src/Services/BoardService.php | 53 ++- .../src/Services/PostService.php | 63 ++- .../src/lang/en/activity_log.php | 1 + .../sirsoft-board/src/lang/en/enums.php | 6 + .../sirsoft-board/src/lang/en/validation.php | 5 + .../src/lang/ko/activity_log.php | 1 + .../sirsoft-board/src/lang/ko/enums.php | 6 + .../sirsoft-board/src/lang/ko/validation.php | 5 + .../tests/Feature/Admin/BoardDeletionTest.php | 65 +++ .../Feature/Admin/BoardIndexSortTest.php | 138 ++++++ .../Feature/Admin/ReplyDeletePolicyTest.php | 430 ++++++++++++++++++ .../Feature/FormRequestValidationTest.php | 118 +++++ .../tests/Playwright/fixtures/board-auth.ts | 20 + .../admin/board-reply-delete-policy.spec.ts | 182 ++++++++ .../EcommerceInquiryHookListenerTest.php | 125 ++++- .../PostRepositoryReplyLookupTest.php | 149 ++++++ .../scenarios/post-reply-delete-policy.yaml | 52 +++ .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 18 +- ...s_to_ecommerce_product_inquiries_table.php | 45 ++ .../seeders/Sample/ProductInquirySeeder.php | 61 +-- .../sirsoft-ecommerce/docs/api/inquiries.md | 25 +- .../adminEcommerceSettingsSavePerTab.test.tsx | 14 + .../admin/admin_ecommerce_settings.json | 2 +- .../_panel_category_list.json | 6 +- .../_partial_order_info.json | 2 +- .../_modal_direct_issue.json | 2 +- .../_modal_status_change_confirm.json | 4 +- .../_bank_management_modal.json | 2 +- .../_modal_clear_seo_cache.json | 2 +- .../_modal_mail_template_edit.json | 2 +- .../_modal_notification_template_edit.json | 2 +- .../_tab_identity_policies.json | 4 +- .../Listeners/ProductInquiryBoardListener.php | 159 ++++++- .../src/Models/ProductInquiry.php | 12 +- .../ProductInquiryRepositoryInterface.php | 31 ++ .../Repositories/ProductInquiryRepository.php | 38 +- .../src/Services/ProductInquiryService.php | 82 +++- .../src/Services/ProductService.php | 11 +- .../src/lang/en/messages.php | 1 + .../src/lang/ko/messages.php | 1 + .../AdminProductInquiryControllerTest.php | 126 ++++- .../User/UserProductInquiryControllerTest.php | 119 ++++- .../ProductInquiryBoardListenerTest.php | 259 +++++++++++ .../ProductInquiryRepositoryTest.php | 165 +++++++ .../Services/ProductServiceDeleteTest.php | 91 +++- .../InquiryOrphanReplyBackfillTest.php | 253 +++++++++++ .../inquiry-single-reply-lifecycle.yaml | 58 +++ .../upgrades/Upgrade_1_1_1.php | 6 + .../04_SoftDeleteOrphanInquiryReplyPosts.php | 134 ++++++ ...teInquiryPivotsForTrashedQuestionPosts.php | 76 ++++ ...arkAnsweredInquiriesWithoutLiveReplies.php | 78 ++++ modules/_bundled/sirsoft-page/CHANGELOG.md | 3 +- .../layouts/admin/admin_page_form.json | 2 +- .../gnuboard7-hello_plugin/CHANGELOG.md | 3 +- .../layouts/admin/plugin_settings.json | 2 +- .../_bundled/sirsoft-ckeditor5/CHANGELOG.md | 3 +- .../layouts/admin/plugin_settings.json | 2 +- .../sirsoft-daum_postcode/CHANGELOG.md | 6 +- .../layouts/admin/plugin_settings.json | 2 +- .../_bundled/sirsoft-marketing/CHANGELOG.md | 3 +- .../layouts/admin/plugin_settings.json | 2 +- .../sirsoft-pay_kginicis/CHANGELOG.md | 14 +- .../sirsoft-pay_kginicis/docs/api/orders.md | 30 +- .../sirsoft-pay_kginicis/docs/api/payment.md | 39 +- .../docs/api/transaction.md | 13 +- .../sirsoft-pay_kginicis/lang/en/messages.php | 5 + .../sirsoft-pay_kginicis/lang/ko/messages.php | 5 + .../extensions/admin_order_payment_query.json | 8 +- .../layouts/admin/plugin_settings.json | 42 +- .../AdminCbtCvsOperationsController.php | 1 + .../AdminEscrowDeliveryController.php | 53 ++- .../AdminEscrowDenyConfirmController.php | 14 +- .../Controllers/AdminOrderListController.php | 1 + .../AdminTransactionController.php | 16 +- .../CbtCheckoutTokenController.php | 27 +- .../src/Controllers/CbtHashDataController.php | 27 +- .../UserEscrowConfirmController.php | 6 + .../src/Controllers/UserReceiptController.php | 2 + .../Http/Requests/CbtCheckoutTokenRequest.php | 44 ++ .../src/Http/Requests/CbtHashDataRequest.php | 46 ++ .../EscrowDeliveryRegisterRequest.php | 68 +++ .../Requests/EscrowDenyConfirmRequest.php | 51 +++ .../Requests/PaymentCloseReportRequest.php | 3 +- .../Http/Requests/TransactionQueryRequest.php | 39 ++ .../AdminCbtTestProductControllerTest.php | 16 + .../Controllers/AdminEscrowControllerTest.php | 127 ++++++ .../Controllers/CbtPaymentControllerTest.php | 105 ++++- .../_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md | 11 +- .../sirsoft-pay_nhnkcp/lang/en/messages.php | 4 + .../sirsoft-pay_nhnkcp/lang/ko/messages.php | 4 + .../layouts/admin/plugin_settings.json | 2 +- .../AdminEscrowDeliveryController.php | 29 +- .../Controllers/AdminOrderListController.php | 4 + .../AdminTransactionController.php | 4 + .../Controllers/MobileApprovalController.php | 19 +- .../src/Controllers/UserReceiptController.php | 4 + .../UserVbankMockDepositController.php | 3 + .../EscrowDeliveryRegisterRequest.php | 58 +++ .../AdminEscrowDeliveryControllerTest.php | 52 +++ .../tests/PluginTestCase.php | 2 + .../sirsoft-pay_nicepayments/CHANGELOG.md | 11 +- .../docs/api/README.md | 6 +- .../docs/api/escrow.md | 2 +- .../docs/api/orders.md | 53 +++ .../docs/api/payment.md | 50 ++ .../docs/api/settings.md | 41 ++ .../docs/api/transaction.md | 82 ++++ .../docs/api/vbank.md | 56 +++ .../lang/en/messages.php | 10 +- .../lang/ko/messages.php | 10 +- .../layouts/admin/plugin_settings.json | 2 +- .../src/Controllers/AdminEscrowController.php | 16 +- .../Controllers/AdminOrderListController.php | 2 + .../AdminTransactionController.php | 16 +- .../AdminVbankRefundController.php | 31 +- .../Controllers/PaymentCallbackController.php | 19 +- .../PaymentCloseReportController.php | 1 + .../src/Controllers/UserReceiptController.php | 2 + .../EscrowDeliveryRegisterRequest.php | 42 ++ .../src/Http/Requests/SignDataRequest.php | 42 ++ .../Http/Requests/TransactionQueryRequest.php | 37 ++ .../src/Http/Requests/VbankRefundRequest.php | 64 +++ .../Controllers/AdminEscrowControllerTest.php | 26 ++ .../AdminVbankRefundControllerTest.php | 63 +++ .../PaymentCallbackControllerTest.php | 34 ++ .../sirsoft-tosspayments/CHANGELOG.md | 3 +- .../layouts/admin/plugin_settings.json | 2 +- .../CHANGELOG.md | 3 +- .../layouts/admin/plugin_settings.json | 2 +- .../sirsoft-verification_nhnkcp/CHANGELOG.md | 7 +- .../docs/api/identity.md | 6 + .../layouts/admin/plugin_settings.json | 2 +- .../Http/Controllers/KcpBridgeController.php | 19 +- .../Controllers/KcpCallbackController.php | 11 +- .../src/Http/Requests/KcpBridgeRequest.php | 61 +++ .../src/Http/Requests/KcpCallbackRequest.php | 39 ++ .../Controllers/KcpCallbackControllerTest.php | 35 ++ .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 4 +- .../layouts/admin_role_form.json | 2 +- .../layouts/admin_role_list.json | 2 +- .../layouts/admin_settings.json | 2 +- .../layouts/admin_template_layout_edit.json | 4 +- .../layouts/admin_user_form.json | 4 +- .../admin_menu_list/_panel_menu_list.json | 2 +- .../_modal_reactivate_language_packs.json | 4 +- .../_modal_reactivate_language_packs.json | 4 +- ...modal_identity_message_definition_add.json | 2 +- ..._modal_identity_message_template_form.json | 6 +- .../_modal_mail_template_form.json | 2 +- .../_modal_notification_template_form.json | 2 +- .../partials/admin_settings/_tab_drivers.json | 16 +- .../_tab_identity_messages.json | 2 +- .../_tab_identity_policies.json | 4 +- .../partials/admin_settings/_tab_info.json | 2 +- .../_modal_extension_preview_layout.json | 2 +- .../_modal_reactivate_language_packs.json | 4 +- templates/_bundled/sirsoft-basic/CHANGELOG.md | 3 +- .../layouts/mypage/change-password.json | 2 +- .../partials/shop/_checkout_discount.json | 4 +- .../partials/shop/_checkout_summary.json | 2 +- .../shop/detail/_modal_inquiry_delete.json | 2 +- .../partials/shop/detail/_tab_qna.json | 4 +- .../layouts/shop/order_complete.json | 2 +- .../sirsoft-basic/layouts/shop/reorder.json | 2 +- 236 files changed, 6324 insertions(+), 437 deletions(-) create mode 100644 modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000001_add_reply_delete_policy_to_boards_table.php create mode 100644 modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000002_modify_trigger_type_in_board_posts_table.php create mode 100644 modules/_bundled/sirsoft-board/src/Enums/ReplyDeletePolicy.php create mode 100644 modules/_bundled/sirsoft-board/src/Exceptions/PostHasRepliesException.php create mode 100644 modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexBoardRequest.php create mode 100644 modules/_bundled/sirsoft-board/src/Http/Requests/DestroyCommentRequest.php create mode 100644 modules/_bundled/sirsoft-board/src/Http/Requests/User/DestroyPostRequest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostRepositoryReplyLookupTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/scenarios/post-reply-delete-policy.yaml create mode 100644 modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_17_000001_add_soft_deletes_to_ecommerce_product_inquiries_table.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/ProductInquiryBoardListenerTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductInquiryRepositoryTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/InquiryOrphanReplyBackfillTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/scenarios/inquiry-single-reply-lifecycle.yaml create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/04_SoftDeleteOrphanInquiryReplyPosts.php create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/05_SoftDeleteInquiryPivotsForTrashedQuestionPosts.php create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/06_UnmarkAnsweredInquiriesWithoutLiveReplies.php create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/CbtCheckoutTokenRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/CbtHashDataRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/EscrowDeliveryRegisterRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/EscrowDenyConfirmRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/TransactionQueryRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/EscrowDeliveryRegisterRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/docs/api/orders.md create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/docs/api/payment.md create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/docs/api/settings.md create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/docs/api/transaction.md create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/EscrowDeliveryRegisterRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/SignDataRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/TransactionQueryRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/VbankRefundRequest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Requests/KcpBridgeRequest.php create mode 100644 plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Requests/KcpCallbackRequest.php diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php index f4071a48..3f37d610 100644 --- a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php +++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php @@ -1,5 +1,7 @@ validated(); - $template = IdentityMessageTemplate::findOrFail($payload['template_id']); + $template = $this->templateService->findOrFailById((int) $payload['template_id']); $rendered = $this->templateService->getPreview( $template, $payload['data'] ?? [], @@ -104,7 +107,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController * 템플릿을 시더 기본값으로 복원. * * @param IdentityMessageTemplate $template - * @return \Illuminate\Http\JsonResponse + * @return JsonResponse */ public function reset(IdentityMessageTemplate $template) { diff --git a/app/Http/Controllers/Api/Admin/NotificationController.php b/app/Http/Controllers/Api/Admin/NotificationController.php index 5ee26214..a6251373 100644 --- a/app/Http/Controllers/Api/Admin/NotificationController.php +++ b/app/Http/Controllers/Api/Admin/NotificationController.php @@ -1,5 +1,7 @@ getSettingsSchema() as $field => $config) { + // 플러그인이 요청 시점에 번역선(validation.attributes.{field})을 현재 로케일로 + // 등록했다면 그것이 우선이다. 스키마 label 은 보통 ko/en 만 들고 있어, 여기서 + // 폴백 라벨을 세우면 언어팩/리스너가 제공한 로케일별 라벨(ja 등)을 customAttributes + // 우선순위로 덮는다 — 검증 안내의 항목 이름만 다른 언어로 남는 회귀가 된다. + $translationKey = "validation.attributes.{$field}"; + if (Lang::has($translationKey, $locale, false)) { + $attributes[$field] = Lang::get($translationKey, [], $locale); + + continue; + } + $label = $config['label'] ?? null; if (is_array($label)) { diff --git a/app/Services/IdentityMessageTemplateService.php b/app/Services/IdentityMessageTemplateService.php index 78670dbe..ed882824 100644 --- a/app/Services/IdentityMessageTemplateService.php +++ b/app/Services/IdentityMessageTemplateService.php @@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface; use App\Extension\HookManager; use App\Models\IdentityMessageDefinition; use App\Models\IdentityMessageTemplate; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Auth; /** @@ -67,6 +68,29 @@ class IdentityMessageTemplateService ); } + /** + * ID 로 템플릿을 조회하며, 없으면 예외를 발생시킵니다. + * + * 컨트롤러가 모델을 직접 조회하지 않도록 하는 Service 경유 접근자입니다 + * (Controller → Service → Repository 계층 규정). + * + * @param int $id 템플릿 ID + * @return IdentityMessageTemplate 템플릿 모델 + * + * @throws ModelNotFoundException 템플릿이 없는 경우 + */ + public function findOrFailById(int $id): IdentityMessageTemplate + { + $template = $this->repository->findById($id); + + if (! $template) { + throw (new ModelNotFoundException) + ->setModel(IdentityMessageTemplate::class, [$id]); + } + + return $template; + } + /** * 템플릿 수정. * diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md index 70618261..767ead66 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md @@ -4,10 +4,11 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.3] - 2026-08-12 +## [1.0.3] - 2026-08-17 ### Added +- 답글 삭제 정책(reply_delete_policy) 설정 라벨·옵션·차단 안내 문구의 일본어 번역을 추가했습니다. - 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다. - 비공개(비밀) 게시글 목록 마스킹 시 표시되는 제목 플레이스홀더(`secret_post_title`)의 일본어 번역을 추가했습니다. - 첨부 영구 정리 설정의 라벨·도움말과 검증 항목명 일본어 번역을 추가했습니다 (`admin/settings.attachment_purge_*`, `validation.attributes.attachment_settings.*`) — 게시판 환경설정 > 기본 설정의 새 항목과 저장 시 오류 안내가 일본어 로케일에서 표시됩니다. diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php index 41a8ec64..2c036e67 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php @@ -71,6 +71,7 @@ return [ 'use_comment' => 'コメント使用', 'use_reply' => '返信使用', 'max_reply_depth' => '最大返信深度', + 'reply_delete_policy' => '返信の削除方式', 'use_report' => '通報使用', 'use_file_upload' => 'ファイルアップロード使用', 'max_file_size' => '最大ファイルサイズ', diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php index b6ce372c..6c9a6632 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php @@ -6,6 +6,10 @@ return [ 'enabled' => '選択使用', 'always' => '必須使用', ], + 'reply_delete_policy' => [ + 'block' => '削除をブロック(返信がある間は元の投稿を削除できません)', + 'cascade' => '一緒に削除(元の投稿の削除時に返信も削除されます)', + ], 'order_direction' => [ 'asc' => '昇順', 'desc' => '降順', diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php index 3b7d0dd6..73ed656a 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php @@ -219,6 +219,9 @@ return [ 'notice_not_allowed' => 'お知らせには返信を作成することはできません。', ], 'reply_not_allowed' => 'この掲示板は返信機能が無効化されています。', + 'delete' => [ + 'has_replies' => '返信が付いた投稿は削除できません。先に返信を削除してください。', + ], 'status' => [ 'in' => '無効な投稿ステータスです。', ], @@ -256,6 +259,7 @@ return [ 'basic_defaults.use_comment' => 'コメント使用有無', 'basic_defaults.use_reply' => '返信使用有無', 'basic_defaults.max_reply_depth' => '最大返信深さ', + 'basic_defaults.reply_delete_policy' => '返信の削除方式', 'basic_defaults.max_comment_depth' => '最大コメント深さ', 'basic_defaults.comment_order' => 'コメント並べ替え', 'basic_defaults.show_view_count' => '閲覧数表示', @@ -339,6 +343,7 @@ return [ 'override_values.per_page' => 'ページあたりの投稿数', 'override_values.per_page_mobile' => 'モバイルページあたりの投稿数', 'override_values.max_reply_depth' => '最大返信深さ', + 'override_values.reply_delete_policy' => '返信の削除方式', 'override_values.max_comment_depth' => '最大コメント深さ', 'override_values.min_title_length' => '最小タイトル長', 'override_values.max_title_length' => '最大タイトル長', diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json index 834d893f..c31e5f15 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json @@ -110,6 +110,14 @@ "label": "返信投稿の最大深さ", "hint": "{{min}}~{{max}}の範囲内の値を入力してください" }, + "reply_delete_policy": { + "label": "返信の削除方式", + "hint": "返信が付いた投稿を削除するときの動作を設定します", + "options": { + "block": "削除をブロック", + "cascade": "一緒に削除" + } + }, "max_comment_depth": { "label": "返信への返信の最大深さ", "hint": "{{min}}~{{max}}の範囲内の値を入力してください。0に設定すると返信への返信機能が無効化されます。" diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json index 974e6849..41857176 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json @@ -80,6 +80,7 @@ "max_content_length": "最大投稿文字数", "use_reply": "回答投稿の使用", "max_reply_depth": "回答投稿の最大深さ", + "reply_delete_policy": "返信の削除方式", "use_comment": "コメントの使用", "comment_order": "コメント並べ替え順序", "min_comment_length": "最小コメント文字数", @@ -168,6 +169,7 @@ "max_content_length": "投稿本文の最大文字数を設定します ({{min}}~{{max}})", "use_reply": "ON に設定する必要があります。新規掲示板に返信投稿機能が適用されます。", "max_reply_depth": "返信投稿の最大ネスト深さを設定します ({{min}}~{{max}})", + "reply_delete_policy": "返信が付いた投稿を削除するときの動作を設定します", "blocked_keywords": "登録されたキーワードを含む投稿/コメントは作成がブロックされます", "use_comment": "ON に設定する必要があります。新規掲示板にコメント機能が適用されます。以下のコメント詳細設定も一緒に適用されます。", "comment_order": "コメントのデフォルト並べ替え方向を設定します", @@ -203,6 +205,10 @@ "enabled": "選択可能", "always": "常に非公開" }, + "reply_delete_policy": { + "block": "削除をブロック", + "cascade": "一緒に削除" + }, "order_by": { "created_at": "最新順", "view_count": "閲覧数順", @@ -286,6 +292,7 @@ "use_comment": "コメント使用", "use_reply": "返信コメント使用", "max_reply_depth": "返信コメント最大深さ", + "reply_delete_policy": "返信の削除方式", "max_comment_depth": "返信の返信最大深さ", "comment_order": "コメント並べ替え順序", "show_view_count": "閲覧数表示", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 984312bf..c3ab3572 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,11 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.1] - 2026-08-12 +## [1.1.1] - 2026-08-17 ### Added - 확정 불가 상태의 주문 옵션 구매확정 시도 안내(`exceptions.order_option_not_confirmable`)의 일본어 번역을 추가했습니다. +- 이미 답변된 상품 문의에 답변을 재등록할 때의 거절 안내(`inquiries.reply_already_exists`) 일본어 번역을 추가했습니다. - 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`). - 관리자 주문설정 결제수단 목록의 「지정 PG 삭제됨」 배지 일본어 번역을 추가했습니다. - 배송지 변경 처리 중 서버 오류가 발생했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다. diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php index 5816b863..8f6e1f30 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php @@ -1100,6 +1100,7 @@ return [ 'board_changed' => '掲示板設定が変更されたため、該当する投稿が見つかりません。管理者にお問い合わせください。', 'reply_created' => '返信が登録されました。', 'reply_failed' => '返信登録に失敗しました。', + 'reply_already_exists' => 'すでに登録された回答があります。既存の回答を修正または削除してから再度登録してください。', 'reply_updated' => '返信が編集されました。', 'reply_update_failed' => '回答の編集に失敗しました。', 'reply_deleted' => '回答が削除されました。', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md index a7bd54db..53217e3b 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md @@ -4,10 +4,11 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.1] - 2026-08-14 +## [1.1.1] - 2026-08-17 ### Added +- 에스크로 배송등록·구매거절확인 검증 안내 문구(운송장번호·택배사·기본 확인자명)의 일본어 번역을 추가했습니다. - 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다. ## [1.1.0] - 2026-08-10 diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php index 76a3629b..6714f28e 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php @@ -40,6 +40,11 @@ return [ 'cash_receipt' => [ 'provider_name' => 'KGイニシス', ], + 'escrow' => [ + 'invoice_required' => '送り状番号を入力してください。', + 'courier_required' => '配送業者を選択してください。', + 'default_confirmer' => '管理者', + ], 'cbt_cvs' => [ 'simulate_success' => '入金シミュレーションが完了しました。', 'simulate_failed' => '入金シミュレーションに失敗しました。', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md index 6971151b..baa14354 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md @@ -4,10 +4,11 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.3] - 2026-08-14 +## [1.0.3] - 2026-08-17 ### Added +- 에스크로 배송등록 검증 안내 문구(운송장번호·택배사)의 일본어 번역을 추가했습니다. - 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다. - 결제 취소에 필요한 서버 IP 등록 안내의 일본어 번역을 추가했습니다 — 등록이 필요한 이유, 상점 관리자에서의 등록 경로, 등록할 IP 확인·복사 안내가 일본어로 표시됩니다. - 관리자 결제 상세의 결제 상태·취소 금액·취소 일시·환불 상태·환불 번호 항목명과, 취소 완료·부분 취소 상태 표시의 일본어 번역을 추가했습니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php index 46f045c1..ac6bec25 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php @@ -6,6 +6,10 @@ return [ 'default_reason' => '購入者返金リクエスト', 'in_progress' => 'NHN KCP 返金はすでに処理中です。', ], + 'escrow' => [ + 'invoice_required' => '送り状番号を入力してください。', + 'courier_required' => '配送業者を選択してください。', + ], 'errors' => [ 'payment_failed' => '決済処理に失敗しました。しばらく後にもう一度お試しください。', 'wsdl_missing' => 'KCP WSDL ファイルがありません: :file', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md index 5faaef6a..e9d2cf7f 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-08-17 + +### Added + +- 가상계좌 환불 입력 필드 표시명(거래 ID·주문번호·취소 금액·취소 사유·환불 계좌번호·은행코드·예금주)의 일본어 번역을 추가했습니다. + +### Removed + +- 원본 플러그인에서 삭제된 가상계좌 환불 필수값 안내 문구(`errors.vbank_refund_required_fields`)를 함께 정리했습니다 — 개별 필드 검증 메시지로 대체되었습니다. + ## [1.0.2] - 2026-08-10 ### Changed diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php index 5a1a876b..3a4148b8 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php @@ -6,7 +6,6 @@ return [ 'order_not_found' => '注文が見つかりません。', 'invalid_request' => '無効なリクエストです。', 'invalid_amount' => 'リクエスト金額が有効ではありません。', - 'vbank_refund_required_fields' => 'TID、注文番号、返金金額、返金口座情報(口座番号·銀行コード·口座名義人)をすべて入力してください。', 'vbank_completed_requires_bank_info' => '仮想口座入金完了の場合は返金口座情報が必要です。管理者APIを通じて返金を進めてください。', 'invalid_refund_amount' => '返金金額が有効ではありません。(リクエスト::amount)', 'vbank_refund_order_not_found' => '返金する注文が見つかりません。', @@ -21,4 +20,13 @@ return [ 'defaults' => [ 'vbank_refund_msg' => '仮想口座返金', ], + 'fields' => [ + 'tid' => '取引ID(TID)', + 'moid' => '注文番号', + 'cancel_amt' => 'キャンセル金額', + 'cancel_msg' => 'キャンセル事由', + 'refund_acct_no' => '返金口座番号', + 'refund_bank_cd' => '返金銀行コード', + 'refund_acct_nm' => '返金口座名義人', + ], ]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json index e62c7648..44517934 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)", "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "scope": "plugin", "target_identifier": "sirsoft-pay_nicepayments", diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index a3b054d3..9ef227ce 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -4,10 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.4] - 2026-08-12 +## [1.0.4] - 2026-08-17 ### Security +- 게시글·댓글 삭제와 게시글 수정 시 비회원 확인용 비밀번호/검증 토큰 입력에 형식 검증을 추가해 비정상 형태(배열 주입 등)의 요청을 차단합니다. +- 관리자 게시판 목록 조회의 정렬 기준과 페이지 크기를 허용된 값으로만 제한했습니다. + - 비밀글의 내용이 열람 권한 없이 새어 나가던 경로를 모두 막았습니다. 게시글 상세 화면은 비밀글 내용을 가렸지만, 상품 문의 목록·비밀글의 댓글 목록·비밀글의 첨부파일(다운로드/미리보기)은 게시글의 비밀 여부를 확인하지 않아 주소만 알면 원문·첨부를 볼 수 있었습니다. 이제 이 경로 전부에서 작성자 본인 또는 게시판 관리 권한(비밀글 열람)을 가진 요청에만 내용을 제공하고, 그 외에는 내용·제목·답변·첨부를 가립니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914) - 회원 프로필의 작성글 목록과 "내가 댓글 단 글" 활동 목록에서 다른 사람의 비밀글·블라인드 글 **본문**이 로그인 없이도 나가던 문제를 수정했습니다. 두 목록은 본문 앞부분을 함께 싣는데 그것을 가리는 설정이 실제로는 한 번도 켜지지 않았습니다. 이제 본인이 볼 때만 본문이 보이고, 다른 사람이 볼 때는 비워집니다. 글의 제목과 목록에서의 표시(비밀글·블라인드 배지)는 게시판 목록과 동일하게 그대로 유지됩니다. (KVE-2026-1914) - 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919) @@ -19,6 +22,7 @@ ### Added +- 답글이 달린 글의 삭제 방식을 게시판마다 선택할 수 있습니다. "함께 삭제"(기본값)는 글을 삭제하면 달려 있던 답글도 함께 휴지통으로 이동하고, 글을 복원하면 그때 함께 지워진 답글만 같이 복원됩니다. "삭제 차단"은 답글이 남아 있는 동안 원글 삭제를 막고 사유를 안내합니다. 사용자가 직접 지운 답글은 원글을 복원해도 되살아나지 않습니다. - 글쓰기 중 파일만 올리고 저장하지 않은 채 나간 임시 첨부를 매일 자동 정리합니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다. - 삭제한 첨부의 파일을 보존기간이 지난 뒤 완전히 파기하는 기능이 추가되었습니다. 게시판 환경설정 > 기본 설정에서 켤 수 있으며(기본 꺼짐), 보존기간은 휴지통 복원 가능 기간과 같게 유지하시면 됩니다. @@ -32,6 +36,7 @@ - 통합검색에서 게시글 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.) - 샘플 게시글 생성 도구가 데이터베이스 접두어를 기본값이 아닌 값으로 설치한 사이트에서 오류가 나던 문제를 수정했습니다. (#101 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 관리자 게시글 작성 화면의 안내 아이콘이 의도한 크기와 다르게 보이던 문제를 수정했습니다. - 관리자 게시판 설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 정보가 계속 노출되던 문제를 수정했습니다. 환경설정 기본값 일괄 적용에도 같은 처리가 적용됩니다. - 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 저장 직후 조회가 저장 전 값을 돌려주거나 값의 형식이 정리되지 않은 채 남던 문제를 수정했습니다. - 게시판 유형 삭제가 실패했을 때 내부 오류 원문이 그대로 표시되던 문제를 수정했습니다. 이제 사용 중인 유형·기본 유형이라 삭제할 수 없는 경우에는 그 사유가 안내되고, 그 밖의 오류는 서버 오류로 구분됩니다. @@ -40,6 +45,11 @@ - 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.) - 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다. - 댓글 목록을 불러올 때 댓글마다 원글을 반복 조회하던 비효율을 제거해 응답을 더 빠르게 했습니다. 댓글이 많은 글일수록 개선 폭이 큽니다. +- 답글이 달린 글을 삭제해도 답글이 지워지지 않아, 목록에는 보이지 않으면서 주소로는 열리는 상태로 남던 문제를 수정했습니다. (#107 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 이커머스 문의 연동: 이미 답변이 있는 문의 글에는 연동 경로로도 답변 글이 중복 생성되지 않도록 방어를 추가했습니다. (#106 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 게시판을 삭제할 때 다른 기능(상품 문의 연동 등)이 그 게시판의 글과 연결해 둔 데이터를 함께 정리할 수 있도록, 삭제되는 글 목록을 연동 기능에 알려줍니다. +- 신고 목록의 동작하지 않는 정렬 파라미터를 정리했습니다. +- 관리자 화면(게시글 작성·환경설정 저장·기본값 일괄 적용·메일/알림 템플릿·SEO/스팸 캐시 정리·본인인증 정책)에서 저장·실행이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.3] - 2026-08-10 diff --git a/modules/_bundled/sirsoft-board/config/settings/defaults.json b/modules/_bundled/sirsoft-board/config/settings/defaults.json index 01bb5f03..d55e2029 100644 --- a/modules/_bundled/sirsoft-board/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-board/config/settings/defaults.json @@ -15,6 +15,7 @@ "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "max_comment_depth": 10, "comment_order": "ASC", "show_view_count": true, diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000001_add_reply_delete_policy_to_boards_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000001_add_reply_delete_policy_to_boards_table.php new file mode 100644 index 00000000..10f5d878 --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000001_add_reply_delete_policy_to_boards_table.php @@ -0,0 +1,49 @@ +string('reply_delete_policy', 20) + ->default('cascade') + ->comment('답글 삭제 정책 (block: 답글 있으면 삭제 차단, cascade: 함께 소프트 삭제)') + ->after('max_reply_depth'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('boards') || ! Schema::hasColumn('boards', 'reply_delete_policy')) { + return; + } + + Schema::table('boards', function (Blueprint $table) { + $table->dropColumn('reply_delete_policy'); + }); + } +}; diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000002_modify_trigger_type_in_board_posts_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000002_modify_trigger_type_in_board_posts_table.php new file mode 100644 index 00000000..a0ee27dc --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000002_modify_trigger_type_in_board_posts_table.php @@ -0,0 +1,51 @@ +where('trigger_type', 'cascade') + ->update(['trigger_type' => 'admin']); + + DB::statement( + "ALTER TABLE {$prefix}board_posts MODIFY COLUMN trigger_type " + ."ENUM('report', 'admin', 'system', 'auto_hide', 'user') " + ."NOT NULL DEFAULT 'admin' " + ."COMMENT '조치 주체 (report: 신고, admin: 관리자, system: 시스템, auto_hide: 자동 블라인드, user: 사용자 직접 삭제)'" + ); + } +}; diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index 2bc7ce05..d0b3c625 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -991,10 +991,11 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write\|sirsoft-board.{slug}.admin.manage`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 게시판의 답글 삭제 정책(`reply_delete_policy`)이 `block` 이고 대상 글에 살아 있는 답글이 있는 경우 (`답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.` — `sirsoft-board::validation.post.delete.has_replies`) | -**설명** 게시판 관리자가 게시글 1건을 소프트 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `posts.write` 또는 `manage`입니다. 컨트롤러가 대상 게시글을 조회한 뒤 세분화된 권한 분기를 적용합니다: `admin.manage`는 모든 글(비회원 글 포함)을, `admin.posts.write`는 본인 글만 삭제할 수 있으며 이미 삭제된 글의 재처리는 `admin.manage`가 필요합니다. `PostService::deletePost()`가 'admin' 컨텍스트로 소프트 삭제를 수행합니다. +**설명** 게시판 관리자가 게시글 1건을 소프트 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `posts.write` 또는 `manage`입니다. 컨트롤러가 대상 게시글을 조회한 뒤 세분화된 권한 분기를 적용합니다: `admin.manage`는 모든 글(비회원 글 포함)을, `admin.posts.write`는 본인 글만 삭제할 수 있으며 이미 삭제된 글의 재처리는 `admin.manage`가 필요합니다. `PostService::deletePost()`가 'admin' 컨텍스트로 소프트 삭제를 수행합니다. 삭제 동작은 게시판의 답글 삭제 정책(`reply_delete_policy`)을 따릅니다 — `cascade`(기본)에서는 원글 삭제 시 살아 있는 답글 트리(및 그 답글들의 댓글·첨부)가 함께 소프트 삭제되고 이후 원글을 복원하면 연쇄 삭제분(`trigger_type='cascade'`)만 선택 복원되며, `block` 에서는 살아 있는 직계 답글이 있으면 `before_delete` 훅 발화 전에 차단되어(부수효과 없음) 422 를 반환합니다. ### GET /api/modules/sirsoft-board/admin/board/{slug}/posts/{id} diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index 3b5f4e6a..8dc068d1 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -25,7 +25,14 @@ **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 999 | 페이지당 항목 수 (게시판은 운영자 등록 설정성 테이블 — 전체 목록 셀렉트 소비처의 전량 조회를 수용) | +| type | query | string | 아니오 | max 50 | 게시판 타입 슬러그 필터 (basic, gallery 등) | +| search | query | string | 아니오 | max 100 | 검색어 (게시판명/슬러그 부분 일치) | +| sort_by | query | string | 아니오 | `id`, `slug`, `type`, `is_active`, `posts_count`, `comments_count`, `created_at`, `updated_at` | 정렬 기준 컬럼 (닫힌 집합 — `name` 은 다국어 JSON 컬럼이라 정렬 대상에서 제외) | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (대문자 `ASC`/`DESC` 로 보내도 검증 전 소문자로 정규화되어 수용) | **요청 예시** @@ -102,10 +109,11 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-board.boards.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) — `sort_by` 가 허용 컬럼 목록 밖이거나, `sort_order` 가 `asc`/`desc` 가 아니거나, `per_page` 가 1~999 범위를 벗어난 경우 등 (`IndexBoardRequest` 검증) | -**설명** 관리자 화면의 게시판 관리 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, 요청의 필터/페이징 파라미터를 그대로 서비스에 넘겨 전체 게시판을 페이지네이션합니다. 각 항목은 `BoardCollection::withPermissions()` 로 감싸져 현재 관리자의 생성/수정/삭제 가능 여부(`abilities`)를 함께 반환합니다. +**설명** 관리자 화면의 게시판 관리 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, `IndexBoardRequest` 로 검증된 필터/정렬/페이징 파라미터만 서비스에 넘겨 전체 게시판을 페이지네이션합니다. 정렬 컬럼(`sort_by`)과 페이지 크기(`per_page` 1~999)는 닫힌 집합으로 제한되어 허용값 밖 요청은 422 로 거부됩니다. 각 항목은 `BoardCollection::withPermissions()` 로 감싸져 현재 관리자의 생성/수정/삭제 가능 여부(`abilities`)를 함께 반환합니다. ### POST /api/modules/sirsoft-board/admin/boards @@ -150,6 +158,7 @@ HTTP/1.1 200 | board_step_ids | body | array | 아니오 | — | board step 식별자 배열 | | permissions | body | array | 아니오 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 미지정 시 Service가 Manager/Step 역할을 주입) | | max_reply_depth | body | integer | 아니오 | min 1, max 10 | 답변글 최대 중첩 깊이 | +| reply_delete_policy | body | string | 예 | `block`, `cascade` | 답글 달린 글의 삭제 정책 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 미전송 시 검증 전 기본값 `cascade` 로 채워지며, 허용값 밖이면 422 | | max_comment_depth | body | integer | 아니오 | min 0, max 10 | 대댓글 최대 중첩 깊이 | | notify_admin_on_post | body | boolean | 예 | — | 게시글 등록 시 관리자에게 이메일 알림 발송 여부 | | notify_author | body | boolean | 예 | — | 댓글·대댓글·답변글·관리자 처리 발생 시 작성자에게 이메일 알림 발송 여부 | @@ -213,6 +222,7 @@ Content-Type: application/json "예시값" ], "max_reply_depth": 1, + "reply_delete_policy": "cascade", "max_comment_depth": 1, "notify_admin_on_post": true, "notify_author": true, @@ -244,6 +254,7 @@ _단건 응답: `data` 객체의 필드 (생성된 게시판 = `BoardResource`, | use_comment | boolean | `true` | 댓글 기능 사용 | | use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | | max_reply_depth | integer | `5` | 답변글 최대 깊이 | +| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) | | use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | | comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | | max_comment_depth | integer | `10` | 대댓글 최대 깊이 | @@ -301,6 +312,7 @@ HTTP/1.1 201 "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "use_report": true, "comment_order": "ASC", "max_comment_depth": 10, @@ -390,6 +402,7 @@ _단건 응답: `data` 객체의 필드._ | use_comment | boolean | `true` | 댓글 기능 사용 | | use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | | max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) | +| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 기본값 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) | | max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | | comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | | show_view_count | boolean | `true` | 조회수 노출 | @@ -445,6 +458,7 @@ HTTP/1.1 200 "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "max_comment_depth": 10, "comment_order": "ASC", "show_view_count": true, @@ -755,6 +769,7 @@ _단건 응답: `data` 객체의 필드._ | use_comment | boolean | `true` | 댓글 기능 사용 | | use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | | max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) | +| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) | | use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | | comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | | max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | @@ -812,6 +827,7 @@ HTTP/1.1 200 "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "use_report": true, "comment_order": "ASC", "max_comment_depth": 10, @@ -973,6 +989,7 @@ _단건 응답: `data` 객체의 필드._ | use_comment | boolean | `true` | 댓글 기능 사용 | | use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | | max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) | +| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) | | use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | | comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | | max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | @@ -1030,6 +1047,7 @@ HTTP/1.1 200 "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "use_report": true, "comment_order": "ASC", "max_comment_depth": 10, @@ -1267,6 +1285,7 @@ HTTP/1.1 200 | board_step_ids | body | array | 아니오 | — | board step 식별자 배열 | | permissions | body | array | 예 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 각 권한을 all 또는 특정 역할에 부여) | | max_reply_depth | body | integer | 아니오 | min 1, max 10 | 답변글 최대 중첩 깊이 | +| reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 답글 달린 글의 삭제 정책 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 미전송 시 기존값 유지, 허용값 밖이면 422 | | max_comment_depth | body | integer | 아니오 | min 0, max 10 | 대댓글 최대 중첩 깊이 | | notify_admin_on_post | body | boolean | 예 | — | 게시글 등록 시 관리자에게 이메일 알림 발송 여부 | | notify_author | body | boolean | 예 | — | 댓글·대댓글·답변글·관리자 처리 발생 시 작성자에게 이메일 알림 발송 여부 | @@ -1329,6 +1348,7 @@ Content-Type: application/json "예시값" ], "max_reply_depth": 1, + "reply_delete_policy": "block", "max_comment_depth": 1, "notify_admin_on_post": true, "notify_author": true, @@ -1360,6 +1380,7 @@ _단건 응답: `data` 객체의 필드 (수정된 게시판 = `BoardResource`, | use_comment | boolean | `true` | 댓글 기능 사용 | | use_reply | boolean | `true` | 게시글 답변 기능 사용 | | max_reply_depth | integer | `5` | 답변글 최대 깊이 | +| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) | | use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | | comment_order | string | `ASC` | 댓글 정렬 순서 (ASC, DESC) | | max_comment_depth | integer | `10` | 대댓글 최대 깊이 | @@ -1417,6 +1438,7 @@ HTTP/1.1 200 "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "use_report": true, "comment_order": "ASC", "max_comment_depth": 10, @@ -1980,6 +2002,7 @@ _단건 응답: `data` 객체의 필드._ | use_comment | boolean | `true` | 댓글 기능 사용 | | use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | | max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) | +| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) | | use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | | comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | | max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | @@ -2037,6 +2060,7 @@ HTTP/1.1 200 "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "use_report": true, "comment_order": "ASC", "max_comment_depth": 10, @@ -3325,6 +3349,9 @@ HTTP/1.1 200 | --- | --- | --- | --- | --- | --- | | slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | | id | path | string | 예 | — | 대상 리소스의 식별자 | +| password | body | string | 아니오 | min 4 | 비회원 게시글 삭제 시 소유권 확인용 비밀번호. 형식(문자열, 4자 이상)은 `DestroyPostRequest` 가 검증하며, 배열 주입(`password[]=x`) 등 형식 위반은 422. 소유권 판정 자체는 컨트롤러(`canModifyPost`)가 수행 | +| verification_token | body | string | 아니오 | max 255 | 비회원 게시글 삭제 시 소유권 확인용 검증 토큰 (비밀번호 검증 후 발급). 문자열 형식 위반 시 422 | +| force_delete | body | boolean | 아니오 | — | 불리언 플래그 (형식 검증만 수행 — 불리언으로 해석 불가한 값은 422) | **요청 예시** @@ -3359,11 +3386,12 @@ HTTP/1.1 200 | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write\|sirsoft-board.{slug}.manager`)이 없거나, 작성자 본인·게시판 관리자·비회원 글 검증(토큰/비밀번호) 중 어느 조건도 만족하지 않는 경우 (`게시글 삭제 권한이 없습니다.`) | | 404 | Not Found | 활성 게시판 또는 게시글이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터(`password`/`verification_token`/`force_delete`)가 형식 검증을 위반한 경우 (`error.errors` 에 필드별 메시지) / 게시판의 답글 삭제 정책(`reply_delete_policy`)이 `block` 이고 살아 있는 답글이 있는 경우 (`답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.` — `sirsoft-board::validation.post.delete.has_replies`) | | 500 | Internal Server Error | 삭제 처리 중 예외 (`게시글 삭제에 실패했습니다.`) | -**설명** 게시글을 삭제(소프트 삭제)합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요합니다. 작성자 본인, 게시판 관리자(admin.manage/manager), 또는 비회원 글의 경우 검증 토큰·비밀번호 확인 중 하나를 만족해야 삭제할 수 있으며, 조건 미충족 시 403을 반환합니다. +**설명** 게시글을 삭제(소프트 삭제)합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요합니다. 작성자 본인, 게시판 관리자(admin.manage/manager), 또는 비회원 글의 경우 검증 토큰·비밀번호 확인 중 하나를 만족해야 삭제할 수 있으며, 조건 미충족 시 403을 반환합니다. 삭제 동작은 게시판의 답글 삭제 정책(`reply_delete_policy`)을 따릅니다 — `cascade`(기본)에서는 원글 삭제 시 살아 있는 답글 트리(및 그 답글들의 댓글·첨부)가 함께 소프트 삭제되고 이후 원글을 복원하면 연쇄 삭제분이 함께 복원되며, `block` 에서는 살아 있는 답글이 있으면 어떤 부수효과도 없이 422 로 삭제가 차단됩니다. ### GET /api/modules/sirsoft-board/boards/{slug}/posts/{id} @@ -3630,6 +3658,8 @@ HTTP/1.1 200 | attachment_ids | body | integer[] | 아니오 | — | 미리 업로드해 둔 첨부 ID 목록. 게시글에 연결된다 (관리자 수정 경로와 동일) | | temp_key | body | string | 아니오 | 최대 64자 | 임시 업로드 묶음 키. 해당 묶음의 첨부가 게시글로 이동·연결된다 | | is_secret | body | boolean | 아니오 | — | 비밀글 여부. 문자열 `"true"`/`"false"`/`"1"`/`"0"` 도 수용해 boolean 으로 정규화한다 (해석 불가값은 422) | +| password | body | string | 아니오 | min 4 | 비회원 게시글 수정 시 소유권 확인용 비밀번호. 형식(문자열, 4자 이상)만 여기서 검증하며 배열 주입 등 형식 위반은 422. 소유권 판정은 컨트롤러(`canModifyPost`)가 수행 | +| verification_token | body | string | 아니오 | max 255 | 비회원 게시글 수정 시 소유권 확인용 검증 토큰 (비밀번호 검증 후 발급). 문자열 형식 위반 시 422 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.update_validation_rules`). @@ -4346,6 +4376,7 @@ HTTP/1.1 201 | slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | | postId | path | string | 예 | — | 대상 post의 식별자 | | commentId | path | string | 예 | — | 대상 comment의 식별자 | +| password | body | string | 아니오 | min 4, max 20 | 비회원 댓글 삭제 시 소유권 확인용 비밀번호. 형식(문자열, 4~20자)은 `DestroyCommentRequest` 가 검증하며, 배열 주입 등 형식 위반은 422. 소유권 판정 자체는 서비스(`canDelete`)가 수행 | **요청 예시** @@ -4380,6 +4411,7 @@ HTTP/1.1 200 | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write\|sirsoft-board.{slug}.manager`)이 없거나, 게시판의 댓글 기능이 꺼진 경우, 또는 작성자 본인·게시판 관리자·비회원 댓글 비밀번호 확인 중 어느 조건도 만족하지 않는 경우 (`댓글 삭제 권한이 없습니다.`) | | 404 | Not Found | 게시판 또는 댓글이 없는 경우 (`댓글을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | `password` 가 형식 검증(문자열, 4~20자)을 위반한 경우 (`error.errors.password` — 배열 주입 차단) | | 500 | Internal Server Error | 삭제 처리 중 예외 (`댓글 삭제에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-board/docs/api/settings.md b/modules/_bundled/sirsoft-board/docs/api/settings.md index cb4119f4..1bb600e3 100644 --- a/modules/_bundled/sirsoft-board/docs/api/settings.md +++ b/modules/_bundled/sirsoft-board/docs/api/settings.md @@ -42,7 +42,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 게시판 생성 시 적용되는 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. | +| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 게시판 생성 시 적용되는 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 답글 삭제 방식(reply_delete_policy: block/cascade), 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. | | report_policy | object | `{"auto_hide_threshold":5,"auto_hide_target":"both","daily…` | 신고 정책 카테고리. 자동 숨김 임계치(auto_hide_threshold)와 대상(auto_hide_target: post/comment/both), 사용자별 일일 신고 한도, 신고 거부 누적 제한(횟수/기간), 관리자·작성자 신고 알림 발송 여부와 채널을 포함합니다. | | spam_security | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"…` | 스팸·보안 카테고리. 글·댓글·신고 작성 사이의 도배 방지 쿨다운 시간(초)과 조회수 캐시 TTL을 포함합니다. | | display | object | `{"date_display_format":"standard"}` | 표시 설정 카테고리. 날짜 표시 형식(date_display_format: standard 절대 표기 / relative 상대 표기)을 포함합니다. | @@ -73,6 +73,7 @@ HTTP/1.1 200 "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "max_comment_depth": 10, "comment_order": "ASC", "show_view_count": true, @@ -362,6 +363,7 @@ HTTP/1.1 200 | _tab | body | string | 아니오 | `basic_defaults`, `report_policy`, `spam_security`, `general`, `seo`, `notifications`, `notification_definitions` | 현재 편집 중인 탭을 나타내는 메타 값. 탭 단위 부분 저장의 컨텍스트를 식별하는 용도이며 설정값으로는 저장되지 않습니다. | | notifications | body | array | 아니오 | — | 알림 채널 설정. `channels` 배열의 각 항목에 채널 식별자(id), 활성화 여부(is_active), 정렬 순서(sort_order)를 담아 저장합니다. | | basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. `basic_defaults.allowed_extensions` 는 `basic_defaults.use_file_upload` 가 `true` 일 때만 최소 1개가 필수이며, `false`/`null` 이면 검증에서 제외되어 빈 배열도 허용됩니다. `min_title_length`·`min_comment_length`·`new_display_hours` 의 하한은 `config('sirsoft-board.limits')` 선언을 따르며 `0` 을 허용합니다. | +| basic_defaults.reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 게시판 생성 시 적용될 답글 삭제 정책 기본값 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 허용값 밖이면 422 | | report_policy | body | array | 아니오 | — | 신고 정책 카테고리 값. 자동 숨김 임계치/대상, 일일 신고 한도, 거부 누적 제한, 관리자·작성자 신고 알림 설정을 저장합니다. | | report_policy.auto_hide_threshold | body | integer | 아니오 | min 0, max 100 | 자동 숨김 신고 수 (이 횟수 이상 신고되면 자동 숨김 처리) | | report_policy.auto_hide_target | body | string | 아니오 | `post`, `comment`, `both` | 자동 숨김 대상 (게시글 / 댓글 / 둘 다) | @@ -426,6 +428,7 @@ Content-Type: application/json "basic_defaults.use_comment": true, "basic_defaults.use_reply": true, "basic_defaults.max_reply_depth": 1, + "basic_defaults.reply_delete_policy": "cascade", "basic_defaults.max_comment_depth": 1, "basic_defaults.comment_order": "ASC", "basic_defaults.show_view_count": true, @@ -509,7 +512,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 저장 후 갱신된 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. | +| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 저장 후 갱신된 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 답글 삭제 방식(reply_delete_policy: block/cascade), 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. | | report_policy | object | `{"auto_hide_threshold":5,"auto_hide_target":"both","daily…` | 저장 후 갱신된 신고 정책 카테고리. 자동 숨김 임계치(auto_hide_threshold)와 대상(auto_hide_target: post/comment/both), 사용자별 일일 신고 한도, 신고 거부 누적 제한(횟수/기간), 관리자·작성자 신고 알림 발송 여부와 채널을 포함합니다. | | spam_security | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"…` | 저장 후 갱신된 스팸·보안 카테고리. 글·댓글·신고 작성 사이의 도배 방지 쿨다운 시간(초)과 조회수 캐시 TTL을 포함합니다. | | display | object | `{"date_display_format":"standard"}` | 저장 후 갱신된 표시 설정 카테고리. 날짜 표시 형식(date_display_format: standard 절대 표기 / relative 상대 표기)을 포함합니다. | @@ -538,6 +541,7 @@ HTTP/1.1 200 "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "max_comment_depth": 10, "comment_order": "ASC", "show_view_count": true, @@ -794,7 +798,8 @@ HTTP/1.1 200 | fields | body | array | 예 | min 1 | 대상 게시판에 일괄 적용할 필드 목록(최소 1개). boards 테이블 컬럼(type, per_page, use_comment, allowed_extensions 등)이나 권한 필드(default_board_permissions, manager), 점(.)을 포함한 개별 권한 키(예: `posts.read`)를 허용합니다. | | apply_all | body | boolean | 예 | — | 전체 게시판 적용 여부. true면 모든 게시판에 적용하고, false면 `board_ids`로 지정한 게시판에만 적용합니다(false 시 board_ids 필수). | | board_ids | body | array | 아니오 | — | board 식별자 배열 | -| override_values | body | array | 아니오 | — | 환경설정 기본값 대신 사용할 재정의 값 맵. 지정한 필드에 대해 기본값이 아닌 임의의 값으로 일괄 적용할 때 사용합니다. | +| override_values | body | array | 아니오 | — | 환경설정 기본값 대신 사용할 재정의 값 맵. 지정한 필드에 대해 기본값이 아닌 임의의 값으로 일괄 적용할 때 사용합니다. 각 키는 게시판 생성/수정과 동일한 제한값으로 검증됩니다 (범위 밖 값은 422). | +| override_values.reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 답글 삭제 정책을 일괄 적용할 때의 재정의 값 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). `fields` 에 `reply_delete_policy` 를 포함해 함께 사용하며, 허용값 밖이면 422 | **요청 예시** diff --git a/modules/_bundled/sirsoft-board/editor-spec.json b/modules/_bundled/sirsoft-board/editor-spec.json index f8c12d50..d79c29f4 100644 --- a/modules/_bundled/sirsoft-board/editor-spec.json +++ b/modules/_bundled/sirsoft-board/editor-spec.json @@ -1036,6 +1036,7 @@ "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "use_report": false, "comment_order": "ASC", "max_comment_depth": 10, @@ -1144,6 +1145,7 @@ "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "use_report": false, "comment_order": "ASC", "max_comment_depth": 10, @@ -1599,6 +1601,7 @@ "use_comment": true, "use_reply": true, "max_reply_depth": 5, + "reply_delete_policy": "cascade", "use_report": false, "comment_order": "ASC", "max_comment_depth": 10, diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx index d4ea78a9..16f3f47b 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx @@ -317,6 +317,30 @@ describe('admin_board_form/_tab_post.json', () => { expect(maxReplyDepthField.if).toContain('use_reply'); }); + it('reply_delete_policy_field 가 use_reply ON 조건부로 존재하고 block/cascade Select 를 렌더한다 (#573)', () => { + const policyField = findById(tabPost, 'reply_delete_policy_field'); + expect(policyField).toBeDefined(); + expect(policyField.if).toBeDefined(); + expect(policyField.if).toContain('use_reply'); + + const select = findById(policyField, 'reply_delete_policy_select'); + expect(select).toBeDefined(); + expect(select.name).toBe('Select'); + expect(select.props.name).toBe('reply_delete_policy'); + + const values = (select.props.options as Array<{ value: string }>).map((o) => o.value).sort(); + expect(values).toEqual(['block', 'cascade']); + }); + + it('reply_delete_policy 라벨/힌트/옵션의 ko 다국어 키가 실재한다 (#573)', () => { + const field = (koFormLang as any).fields?.reply_delete_policy; + expect(field).toBeDefined(); + expect(field.label).toBeTruthy(); + expect(field.hint).toBeTruthy(); + expect(field.options?.block).toBeTruthy(); + expect(field.options?.cascade).toBeTruthy(); + }); + it('row-stack 컨테이너 section_post_fields 에 use_comment 토글이 있다', () => { const fieldsContainer = findById(tabPost, 'section_post_fields'); expect(fieldsContainer).toBeDefined(); diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx index d191b1ae..8a594e2a 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx @@ -727,6 +727,7 @@ describe('_tab_board_settings_reply.json - 답변글 하위 탭', () => { const fields = findFormFields(tabBoardSettingsReply); expect(fields).toContain('basic_defaults.use_reply'); expect(fields).toContain('basic_defaults.max_reply_depth'); + expect(fields).toContain('basic_defaults.reply_delete_policy'); }); it('답변글 깊이 필드가 항상 표시된다 (if 조건 없음)', () => { @@ -734,6 +735,34 @@ describe('_tab_board_settings_reply.json - 답변글 하위 탭', () => { expect(maxReplyDepth).toBeDefined(); expect(maxReplyDepth.if).toBeUndefined(); }); + + it('답글 삭제 정책 Select 가 block/cascade 옵션으로 렌더된다 (#573)', () => { + const policyField = findById(tabBoardSettingsReply, 'field_reply_delete_policy'); + expect(policyField).toBeDefined(); + expect(policyField.if).toBeUndefined(); + + const collect: any[] = []; + const walk = (node: any) => { + if (!node || typeof node !== 'object') return; + if (node.name === 'Select') collect.push(node); + for (const child of node.children ?? []) walk(child); + }; + walk(policyField); + + expect(collect).toHaveLength(1); + expect(collect[0].props.name).toBe('basic_defaults.reply_delete_policy'); + const values = (collect[0].props.options as Array<{ value: string }>).map((o) => o.value).sort(); + expect(values).toEqual(['block', 'cascade']); + }); + + it('답글 삭제 정책 ko 다국어 키(라벨/설명/옵션/일괄적용)가 실재한다 (#573)', () => { + const lang = koSettingsLang as any; + expect(lang.fields?.reply_delete_policy).toBeTruthy(); + expect(lang.fields?.descriptions?.reply_delete_policy).toBeTruthy(); + expect(lang.options?.reply_delete_policy?.block).toBeTruthy(); + expect(lang.options?.reply_delete_policy?.cascade).toBeTruthy(); + expect(lang.bulk_apply?.field_labels?.reply_delete_policy).toBeTruthy(); + }); }); // ============================================= diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json index 6f819468..40fe8b8a 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json @@ -110,6 +110,14 @@ "label": "Max Reply Depth", "hint": "Enter a value between {{min}} and {{max}}" }, + "reply_delete_policy": { + "label": "Reply Delete Policy", + "hint": "Set what happens when deleting a post that has replies", + "options": { + "block": "Block deletion", + "cascade": "Delete together" + } + }, "max_comment_depth": { "label": "Max Comment Depth", "hint": "Enter a value between {{min}} and {{max}}. Setting to 0 disables the nested comment feature." diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json index 111ab0d3..79f55810 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json @@ -84,6 +84,7 @@ "max_content_length": "Maximum Content Length", "use_reply": "Enable Reply Posts", "max_reply_depth": "Max Reply Depth", + "reply_delete_policy": "Reply Delete Policy", "use_comment": "Enable Comments", "comment_order": "Comment Sort Order", "min_comment_length": "Min Comment Length", @@ -172,6 +173,7 @@ "max_content_length": "Set the maximum character count for post content ({{min}}–{{max}})", "use_reply": "Must be ON to apply reply post feature to new boards.", "max_reply_depth": "Set the maximum nesting depth for replies ({{min}}–{{max}})", + "reply_delete_policy": "Set what happens when deleting a post that has replies", "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", @@ -203,6 +205,10 @@ "enabled": "Optional", "always": "Always Secret" }, + "reply_delete_policy": { + "block": "Block deletion", + "cascade": "Delete together" + }, "order_by": { "created_at": "Latest", "view_count": "View Count", @@ -287,6 +293,7 @@ "use_comment": "Enable Comments", "use_reply": "Enable Replies", "max_reply_depth": "Max Reply Depth", + "reply_delete_policy": "Reply Delete Policy", "max_comment_depth": "Max Comment Depth", "comment_order": "Comment Sort Order", "show_view_count": "Show View Count", diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json index eb7c3863..d1704af3 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json @@ -110,6 +110,14 @@ "label": "답변글 최대 깊이", "hint": "{{min}}~{{max}} 사이의 값을 입력하세요" }, + "reply_delete_policy": { + "label": "답변글 삭제 방식", + "hint": "답변글이 달린 글을 삭제할 때의 동작을 설정합니다", + "options": { + "block": "삭제 차단", + "cascade": "함께 삭제" + } + }, "max_comment_depth": { "label": "대댓글 최대 깊이", "hint": "{{min}}~{{max}} 사이의 값을 입력하세요. 0으로 설정하면 대댓글 기능이 비활성화됩니다." diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json index 2b6c4694..ba6ccf07 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json @@ -84,6 +84,7 @@ "max_content_length": "최대 게시글 글자 수", "use_reply": "답변글 사용", "max_reply_depth": "답변글 최대 깊이", + "reply_delete_policy": "답변글 삭제 방식", "use_comment": "댓글 사용", "comment_order": "댓글 정렬 순서", "min_comment_length": "최소 댓글 글자 수", @@ -172,6 +173,7 @@ "max_content_length": "게시글 본문의 최대 글자 수를 설정합니다 ({{min}}~{{max}})", "use_reply": "ON으로 설정해야 새 게시판에 답변글 기능이 적용됩니다.", "max_reply_depth": "답변글의 최대 중첩 깊이를 설정합니다 ({{min}}~{{max}})", + "reply_delete_policy": "답변글이 달린 글을 삭제할 때의 동작을 설정합니다", "blocked_keywords": "등록된 키워드가 포함된 게시글\/댓글은 작성이 차단됩니다", "use_comment": "ON으로 설정해야 새 게시판에 댓글 기능이 적용됩니다. 아래 댓글 세부 설정도 함께 적용됩니다.", "comment_order": "댓글의 기본 정렬 방향을 설정합니다", @@ -203,6 +205,10 @@ "enabled": "선택 가능", "always": "항상 비밀글" }, + "reply_delete_policy": { + "block": "삭제 차단", + "cascade": "함께 삭제" + }, "order_by": { "created_at": "최신순", "view_count": "조회수순", @@ -287,6 +293,7 @@ "use_comment": "댓글 사용", "use_reply": "답변글 사용", "max_reply_depth": "답변글 최대 깊이", + "reply_delete_policy": "답변글 삭제 방식", "max_comment_depth": "대댓글 최대 깊이", "comment_order": "댓글 정렬 순서", "show_view_count": "조회수 표시", diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_post_form.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_post_form.json index 0cdf2fc9..8d332ca3 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_post_form.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_post_form.json @@ -227,7 +227,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-board.admin.posts.messages.save_failed", + "message": "{{error.message ?? '$t:sirsoft-board.admin.posts.messages.save_failed'}}", "icon": "triangle-exclamation" } } @@ -260,7 +260,7 @@ "name": "Icon", "props": { "name": "lock", - "className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" + "className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0" } }, { @@ -294,7 +294,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } }, { @@ -396,7 +396,7 @@ "name": "Icon", "props": { "name": "circle-dot", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -523,7 +523,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-board.admin.posts.messages.save_failed", + "message": "{{error.message ?? '$t:sirsoft-board.admin.posts.messages.save_failed'}}", "icon": "triangle-exclamation" } } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json index 40e79115..50b883ea 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json @@ -697,7 +697,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-board.admin.settings.save_error" + "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.save_error'}}" } } ] diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json index d54430e0..2516f022 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json @@ -489,6 +489,70 @@ } ] }, + { + "id": "reply_delete_policy_field", + "type": "basic", + "name": "Div", + "if": "{{_local.form.use_reply}}", + "props": { + "className": "" + }, + "children": [ + { + "id": "reply_delete_policy_label", + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.label" + } + ] + }, + { + "id": "reply_delete_policy_select", + "type": "composite", + "name": "Select", + "props": { + "name": "reply_delete_policy", + "options": [ + { + "value": "cascade", + "label": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.options.cascade" + }, + { + "value": "block", + "label": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.options.block" + } + ], + "disabled": "{{_computed.isReadOnly}}", + "className": "" + } + }, + { + "id": "reply_delete_policy_hint", + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.hint" + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.reply_delete_policy}}", + "props": { + "className": "form-error-xs mt-1" + }, + "text": "{{_local.errors?.reply_delete_policy?.[0] ?? ''}}" + } + ] + }, { "id": "use_comment_toggle", "type": "basic", diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json index be44282c..3b983317 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json @@ -525,7 +525,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-board.admin.settings.bulk_apply_modal.error" + "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.bulk_apply_modal.error'}}" } } ] diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json index 6b17bf67..70800b9b 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json @@ -633,7 +633,7 @@ { "handler": "toast", "params": { - "message": "$t:sirsoft-board.admin.settings.mail_templates.save_error", + "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.mail_templates.save_error'}}", "type": "error" } } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json index c2519458..830721d2 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json @@ -969,7 +969,7 @@ { "handler": "toast", "params": { - "message": "$t:sirsoft-board.admin.settings.notification_definitions.save_error", + "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.notification_definitions.save_error'}}", "type": "error" } } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json index 87376641..a3eb0ad1 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json @@ -212,6 +212,91 @@ ] } ] + }, + { + "id": "field_reply_delete_policy", + "type": "basic", + "name": "Div", + "props": { + "className": "" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "flex-start gap-2 text-body font-medium flex-1 cursor-pointer" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "checkbox", + "className": "checkbox-aligned", + "checked": "{{(_local.bulkApplyFields ?? []).includes('reply_delete_policy')}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('reply_delete_policy') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'reply_delete_policy') : [...(_local.bulkApplyFields ?? []), 'reply_delete_policy']}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-board.admin.settings.fields.reply_delete_policy" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-field-hint" + }, + "text": "$t:sirsoft-board.admin.settings.fields.descriptions.reply_delete_policy" + } + ] + } + ] + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "basic_defaults.reply_delete_policy", + "options": [ + { + "value": "cascade", + "label": "$t:sirsoft-board.admin.settings.options.reply_delete_policy.cascade" + }, + { + "value": "block", + "label": "$t:sirsoft-board.admin.settings.options.reply_delete_policy.block" + } + ], + "className": "w-48 flex-shrink-0 text-sm", + "disabled": "{{_computed.isReadOnly}}" + } + } + ] + } + ] } ] } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json index 31eedfd4..96ca0804 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json @@ -569,7 +569,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.identity.policies.toggle_failed" + "message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}" } }, { @@ -906,7 +906,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.identity.policies.toggle_failed" + "message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}" } }, { diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_seo.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_seo.json index 162a2c9f..f9db2dda 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_seo.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_seo.json @@ -620,7 +620,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error" + "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}" } } ] @@ -637,7 +637,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error" + "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}" } } ] @@ -654,7 +654,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error" + "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}" } } ] diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json index 3f749d81..7b4a3f4e 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json @@ -336,7 +336,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-board.admin.settings.fields.clear_cache_error" + "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.fields.clear_cache_error'}}" } } ] diff --git a/modules/_bundled/sirsoft-board/src/Enums/ReplyDeletePolicy.php b/modules/_bundled/sirsoft-board/src/Enums/ReplyDeletePolicy.php new file mode 100644 index 00000000..a6a44a4d --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Enums/ReplyDeletePolicy.php @@ -0,0 +1,80 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * 유효한 값인지 확인 + * + * @param string $value 검증할 값 + * @return bool 유효 여부 + */ + public static function isValid(string $value): bool + { + return in_array($value, self::values(), true); + } + + /** + * 번역 키를 반환합니다. + * + * @return string 번역 키 + */ + public function labelKey(): string + { + return 'sirsoft-board::enums.reply_delete_policy.'.$this->value; + } + + /** + * 다국어 라벨 반환 + * + * @return string 번역된 라벨 + */ + public function label(): string + { + return __($this->labelKey()); + } + + /** + * 모든 값을 배열로 반환 (value, label 포함) + * + * @return array + */ + public static function toArray(): array + { + return array_map( + fn (self $item) => [ + 'value' => $item->value, + 'label' => $item->label(), + ], + self::cases() + ); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/PostHasRepliesException.php b/modules/_bundled/sirsoft-board/src/Exceptions/PostHasRepliesException.php new file mode 100644 index 00000000..6e45b043 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Exceptions/PostHasRepliesException.php @@ -0,0 +1,43 @@ + 치환 파라미터 + */ + public function getMessageParams(): array + { + return []; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php index b6491e59..87e77d81 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php @@ -9,6 +9,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\ValidationException; use Modules\Sirsoft\Board\Exceptions\MenuAlreadyExistsException; +use Modules\Sirsoft\Board\Http\Requests\Admin\IndexBoardRequest; use Modules\Sirsoft\Board\Http\Requests\StoreBoardRequest; use Modules\Sirsoft\Board\Http\Requests\UpdateBoardRequest; use Modules\Sirsoft\Board\Http\Resources\BoardCollection; @@ -46,14 +47,13 @@ class BoardController extends AdminBaseController /** * 게시판 목록을 조회합니다. * - * @param Request $request HTTP 요청 + * @param IndexBoardRequest $request 목록 조회 요청 (정렬 컬럼·페이지 크기 화이트리스트 검증) * @return JsonResponse 게시판 목록 응답 */ - // audit:allow controller-base-request-injection reason: GET 목록 조회. all()로 필터·페이징 파라미터만 읽음 (검증 불필요) - public function index(Request $request): JsonResponse + public function index(IndexBoardRequest $request): JsonResponse { try { - $boards = $this->boardService->getBoards($request->all()); + $boards = $this->boardService->getBoards($request->validated()); // BoardCollection의 withPermissions 사용 $collection = new BoardCollection($boards); diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php index 0ef5576d..dee8eb51 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Auth; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; +use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException; use Modules\Sirsoft\Board\Exceptions\PostNotFoundException; use Modules\Sirsoft\Board\Http\Requests\Admin\PostFormDataRequest; use Modules\Sirsoft\Board\Http\Requests\Admin\PostFormMetaRequest; @@ -315,6 +316,9 @@ class PostController extends AdminBaseController 'sirsoft-board::messages.posts.delete_success', new PostResource($deletedPost) ); + } catch (PostHasRepliesException $e) { + // 답글 삭제 정책(block): 살아있는 답글이 있어 삭제 거부 — 입력(대상 선택) 문제이므로 422 + return $this->error($e->getMessageKey(), 422); } catch (ModelNotFoundException $e) { throw new PostNotFoundException($id); } catch (AccessDeniedHttpException $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReportController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReportController.php index 8d0c4bbb..640722b1 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReportController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReportController.php @@ -70,7 +70,6 @@ class ReportController extends AdminBaseController 'board_id' => $request->input('board_id'), 'reported_at_from' => $request->input('reported_at_from'), 'reported_at_to' => $request->input('reported_at_to'), - 'sort_by' => $request->input('sort_by'), 'sort_order' => $request->input('sort_order'), ]; diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php index 6800db73..ccf3fce6 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException; +use Modules\Sirsoft\Board\Http\Requests\DestroyCommentRequest; use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest; use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest; use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest; @@ -221,12 +222,13 @@ class CommentController extends PublicBaseController /** * 댓글을 삭제합니다. * + * @param DestroyCommentRequest $request 댓글 삭제 요청 (비회원 password 형식 검증) * @param string $slug 게시판 slug * @param int $postId 게시글 ID * @param int $commentId 댓글 ID * @return JsonResponse 댓글 삭제 결과 응답 */ - public function destroy(string $slug, int $postId, int $commentId): JsonResponse + public function destroy(DestroyCommentRequest $request, string $slug, int $postId, int $commentId): JsonResponse { try { $board = $this->boardService->getBoardBySlug($slug, checkScope: false); @@ -242,8 +244,8 @@ class CommentController extends PublicBaseController // 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단) $comment = $this->commentService->getComment($slug, $commentId, $postId); - // 비회원인 경우 password 파라미터 필요 - $password = request()->input('password'); + // 비회원인 경우 password 파라미터 필요 (형식 검증은 DestroyCommentRequest — 배열 주입 422 차단) + $password = $request->validated('password'); // 권한 확인 (Service에서 처리) $canDelete = $this->commentService->canDelete( diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php index 59ea1536..0c7baf31 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php @@ -17,7 +17,9 @@ use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Enums\SecretMode; use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; +use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException; use Modules\Sirsoft\Board\Exceptions\PostNotFoundException; +use Modules\Sirsoft\Board\Http\Requests\User\DestroyPostRequest; use Modules\Sirsoft\Board\Http\Requests\User\StorePostRequest; use Modules\Sirsoft\Board\Http\Requests\User\UpdatePostRequest; use Modules\Sirsoft\Board\Http\Requests\User\VerifyGuestPasswordRequest; @@ -480,13 +482,15 @@ class PostController extends PublicBaseController /** * 게시글을 삭제합니다. * - * @param Request $request HTTP 요청 + * 비회원 소유권 확인용 password/verification_token 형식 검증은 DestroyPostRequest 가 + * 담당한다 (배열 주입 422 차단). 소유권 판정 자체는 canModifyPost 가 수행한다. + * + * @param DestroyPostRequest $request 게시글 삭제 요청 * @param string $slug 게시판 슬러그 * @param string|int $id 게시글 ID * @return JsonResponse 삭제 결과 응답 */ - // audit:allow controller-base-request-injection reason: DELETE 단건 삭제. force_delete 불리언 플래그만 input()으로 읽음 (검증 불필요) - public function destroy(Request $request, string $slug, string|int $id): JsonResponse + public function destroy(DestroyPostRequest $request, string $slug, string|int $id): JsonResponse { $id = (int) $id; @@ -509,6 +513,9 @@ class PostController extends PublicBaseController $this->postService->deletePost($slug, $id, 'user'); return $this->success('sirsoft-board::messages.posts.delete_success'); + } catch (PostHasRepliesException $e) { + // 답글 삭제 정책(block): 살아있는 답글이 있어 삭제 거부 — 입력(대상 선택) 문제이므로 422 + return $this->error($e->getMessageKey(), 422); } catch (AccessDeniedHttpException $e) { return $this->error('auth.scope_denied', 403); } catch (ModelNotFoundException $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php index 1e38bbfe..7046884a 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Requests\Admin; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy; use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits; use Modules\Sirsoft\Board\Models\Board; @@ -30,6 +31,7 @@ class BulkApplySettingsRequest extends FormRequest 'use_comment', 'use_reply', 'max_reply_depth', + 'reply_delete_policy', 'max_comment_depth', 'comment_order', 'show_view_count', @@ -117,6 +119,7 @@ class BulkApplySettingsRequest extends FormRequest 'override_values.per_page' => ['nullable', 'integer', "min:{$limits['per_page_min']}", "max:{$limits['per_page_max']}"], 'override_values.per_page_mobile' => ['nullable', 'integer', "min:{$limits['per_page_min']}", "max:{$limits['per_page_max']}"], 'override_values.max_reply_depth' => ['nullable', 'integer', "min:{$limits['max_reply_depth_min']}", "max:{$limits['max_reply_depth_max']}"], + 'override_values.reply_delete_policy' => ['nullable', 'string', Rule::in(ReplyDeletePolicy::values())], 'override_values.max_comment_depth' => ['nullable', 'integer', "min:{$limits['max_comment_depth_min']}", "max:{$limits['max_comment_depth_max']}"], 'override_values.min_title_length' => ['nullable', 'integer', "min:{$limits['min_title_length_min']}", "max:{$limits['min_title_length_max']}"], 'override_values.max_title_length' => ['nullable', 'integer', "min:{$limits['max_title_length_min']}", "max:{$limits['max_title_length_max']}"], diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexBoardRequest.php new file mode 100644 index 00000000..c2132e7b --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexBoardRequest.php @@ -0,0 +1,58 @@ +input('sort_order'))) { + $this->merge(['sort_order' => strtolower($this->input('sort_order'))]); + } + } + + /** + * 검증 규칙을 반환합니다. + * + * @return array> 검증 규칙 + */ + public function rules(): array + { + return [ + 'page' => ['nullable', 'integer', 'min:1'], + // 게시판은 운영자 등록 설정성 테이블 — 전체 목록 셀렉트 소비처 + // (게시판 설정 999 / 쇼핑몰 환경설정 문의 카드 200)가 이 상한에 묶인다. + // 100 으로 조이면 두 화면 진입이 422 로 깨진다 (BoardIndexSortTest 회귀 고정). + 'per_page' => ['nullable', 'integer', 'min:1', 'max:999'], + 'type' => ['nullable', 'string', 'max:50'], + 'search' => ['nullable', 'string', 'max:100'], + 'sort_by' => ['nullable', 'string', 'in:id,slug,type,is_active,posts_count,comments_count,created_at,updated_at'], + 'sort_order' => ['nullable', 'string', 'in:asc,desc'], + ]; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexReportRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexReportRequest.php index fb82b9a8..22894892 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexReportRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexReportRequest.php @@ -40,7 +40,7 @@ class IndexReportRequest extends FormRequest 'board_id' => ['nullable', 'integer'], 'reported_at_from' => ['nullable', 'string'], 'reported_at_to' => ['nullable', 'string'], - 'sort_by' => ['nullable', 'string'], + // sort_by 는 받지 않는다 — 저장소가 last_reported_at 고정 정렬 (死규칙 정리) 'sort_order' => ['nullable', 'string', 'in:asc,desc'], 'per_page' => ['nullable', 'integer'], ]; diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php index ca37cf18..eafcf38b 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Http\Requests\Admin; use App\Models\Role; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy; use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits; /** @@ -174,6 +175,7 @@ class StoreBoardSettingsRequest extends FormRequest 'basic_defaults.use_comment' => ['nullable', 'boolean'], 'basic_defaults.use_reply' => ['nullable', 'boolean'], 'basic_defaults.max_reply_depth' => ['nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"], + 'basic_defaults.reply_delete_policy' => ['nullable', 'string', Rule::in(ReplyDeletePolicy::values())], 'basic_defaults.max_comment_depth' => ['nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"], 'basic_defaults.comment_order' => ['nullable', 'string', 'in:ASC,DESC'], 'basic_defaults.show_view_count' => ['nullable', 'boolean'], diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/BlindCommentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/BlindCommentRequest.php index 7bdc21dd..b7efa253 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/BlindCommentRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/BlindCommentRequest.php @@ -39,7 +39,7 @@ class BlindCommentRequest extends FormRequest public function messages(): array { return [ - 'reason.min' => __('sirsoft-board::validation.blind.reason.min'), + // reason 규칙에 min 이 없으므로 min 메시지는 매핑하지 않는다 (死매핑 정리 — lang 키 자체는 유지) 'reason.max' => __('sirsoft-board::validation.blind.reason.max'), ]; } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/BlindPostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/BlindPostRequest.php index 4a46c791..6d392945 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/BlindPostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/BlindPostRequest.php @@ -39,7 +39,7 @@ class BlindPostRequest extends FormRequest public function messages(): array { return [ - 'reason.min' => __('sirsoft-board::validation.blind.reason.min'), + // reason 규칙에 min 이 없으므로 min 메시지는 매핑하지 않는다 (死매핑 정리 — lang 키 자체는 유지) 'reason.max' => __('sirsoft-board::validation.blind.reason.max'), ]; } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/DestroyCommentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/DestroyCommentRequest.php new file mode 100644 index 00000000..7e5a31e1 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/DestroyCommentRequest.php @@ -0,0 +1,50 @@ +input('password')` 로 읽어(저장소 유일 사례) 배열 주입이 그대로 + * 비교 로직에 유입될 수 있었다. 소유권 판정 자체는 CommentService::canDelete 가 + * 담당하므로 여기서는 형식만 닫는다 (형제 store/update/verifyPassword 와 대칭). + */ +class DestroyCommentRequest extends FormRequest +{ + /** + * 요청 권한 — 소유권/권한 판정은 Service(canDelete)가 담당. + * + * @return bool 항상 true + */ + public function authorize(): bool + { + return true; + } + + /** + * 검증 규칙을 반환합니다. + * + * @return array> 검증 규칙 + */ + public function rules(): array + { + return [ + 'password' => ['nullable', 'string', 'min:4', 'max:20'], + ]; + } + + /** + * 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다. + * + * @return array 필드별 표시명 + */ + public function attributes(): array + { + return [ + 'password' => __('sirsoft-board::validation.attributes.comment.password'), + ]; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/RestorePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/RestorePostRequest.php index 38c7ff38..3a65281c 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/RestorePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/RestorePostRequest.php @@ -45,4 +45,16 @@ class RestorePostRequest extends FormRequest 'reason.max' => __('sirsoft-board::validation.restore.reason.max'), ]; } + + /** + * 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다 (RestoreCommentRequest 와 대칭). + * + * @return array 필드별 표시명 + */ + public function attributes(): array + { + return [ + 'reason' => __('sirsoft-board::validation.attributes.restore.reason'), + ]; + } } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php index f67978bc..3221d9ef 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php @@ -10,6 +10,7 @@ use App\Rules\TranslatableField; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; +use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy; use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits; use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesLengthRange; use Modules\Sirsoft\Board\Rules\BoardTypeValidationRule; @@ -54,6 +55,7 @@ class StoreBoardRequest extends FormRequest 'use_comment' => $settings['use_comment'] ?? true, 'use_reply' => $settings['use_reply'] ?? true, 'max_reply_depth' => $settings['max_reply_depth'] ?? 5, + 'reply_delete_policy' => $settings['reply_delete_policy'] ?? ReplyDeletePolicy::Cascade->value, 'max_comment_depth' => $settings['max_comment_depth'] ?? 10, 'use_file_upload' => $settings['use_file_upload'] ?? false, 'comment_order' => $settings['comment_order'] ?? 'ASC', @@ -207,6 +209,7 @@ class StoreBoardRequest extends FormRequest // 답글/대댓글 깊이 제한 'max_reply_depth' => ['nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"], + 'reply_delete_policy' => ['required', 'string', Rule::in(ReplyDeletePolicy::values())], 'max_comment_depth' => ['nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"], // 알림 설정 diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php index d2e37eb8..992a83e6 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php @@ -10,6 +10,7 @@ use App\Rules\TranslatableField; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; +use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy; use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits; use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesLengthRange; use Modules\Sirsoft\Board\Rules\BoardTypeValidationRule; @@ -207,6 +208,7 @@ class UpdateBoardRequest extends FormRequest // 답글/대댓글 깊이 제한 'max_reply_depth' => ['sometimes', 'nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"], + 'reply_delete_policy' => ['sometimes', 'nullable', 'string', Rule::in(ReplyDeletePolicy::values())], 'max_comment_depth' => ['sometimes', 'nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"], // 알림 설정 diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/DestroyPostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/DestroyPostRequest.php new file mode 100644 index 00000000..fbf934bb --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/DestroyPostRequest.php @@ -0,0 +1,51 @@ +> 검증 규칙 + */ + public function rules(): array + { + return [ + 'password' => ['nullable', 'string', 'min:4'], + 'verification_token' => ['nullable', 'string', 'max:255'], + 'force_delete' => ['nullable', 'boolean'], + ]; + } + + /** + * 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다. + * + * @return array 필드별 표시명 + */ + public function attributes(): array + { + return [ + 'password' => __('sirsoft-board::validation.attributes.post.password'), + ]; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php index ab3c33cd..ff4b7a87 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php @@ -103,6 +103,9 @@ class UpdatePostRequest extends FormRequest 'attachment_ids.*' => ['integer', 'min:1'], // temp_key는 게시판 존재 여부와 무관하게 항상 허용 'temp_key' => ['nullable', 'string', 'max:64'], + // 비회원 소유권 확인용 — 판정은 컨트롤러(canModifyPost), 여기서는 형식만 닫는다 (배열 주입 422) + 'password' => ['nullable', 'string', 'min:4'], + 'verification_token' => ['nullable', 'string', 'max:255'], ]; // 훅: 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 필터 제공 diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php index e1e997b5..d992b753 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php @@ -56,6 +56,7 @@ class BoardResource extends BaseApiResource 'use_comment' => $this->use_comment, 'use_reply' => $this->use_reply, 'max_reply_depth' => $this->max_reply_depth, + 'reply_delete_policy' => $this->reply_delete_policy, 'use_report' => $this->use_report, 'comment_order' => $this->comment_order, 'max_comment_depth' => $this->max_comment_depth, @@ -192,6 +193,7 @@ class BoardResource extends BaseApiResource 'use_file_upload' => $this->use_file_upload, 'use_comment' => $this->use_comment, 'use_reply' => $this->use_reply, + 'reply_delete_policy' => $this->reply_delete_policy, 'use_report' => $this->use_report, 'secret_mode' => $this->secret_mode, 'show_view_count' => $this->show_view_count, @@ -224,6 +226,7 @@ class BoardResource extends BaseApiResource 'use_file_upload' => $this->use_file_upload, 'use_comment' => $this->use_comment, 'use_reply' => $this->use_reply, + 'reply_delete_policy' => $this->reply_delete_policy, 'use_report' => $this->use_report, 'secret_mode' => $this->secret_mode, 'per_page' => $this->per_page, diff --git a/modules/_bundled/sirsoft-board/src/Listeners/EcommerceInquiryHookListener.php b/modules/_bundled/sirsoft-board/src/Listeners/EcommerceInquiryHookListener.php index 1c5053e4..a56814e6 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/EcommerceInquiryHookListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/EcommerceInquiryHookListener.php @@ -85,6 +85,12 @@ class EcommerceInquiryHookListener implements HookListenerInterface 'priority' => 10, 'type' => 'filter', ], + // 이커머스 → 게시판: 살아있는 답변 수 조회 (Filter 훅) + 'sirsoft-ecommerce.inquiry.count_replies' => [ + 'method' => 'countReplies', + 'priority' => 10, + 'type' => 'filter', + ], ]; } @@ -107,7 +113,8 @@ class EcommerceInquiryHookListener implements HookListenerInterface * @param mixed $carry 이전 필터 결과 (초기값: null) * @param string $slug 게시판 슬러그 * @param array $data 게시글 생성 데이터 - * @return array|null 성공 시 ['post_id' => int, 'inquirable_type' => string], 실패 시 null + * @return array|null 성공 시 ['post_id' => int, 'inquirable_type' => string], + * 중복 답변 차단 시 ['duplicate' => true], 실패 시 null */ public function createAndReturn(mixed $carry, string $slug, array $data): ?array { @@ -124,6 +131,20 @@ class EcommerceInquiryHookListener implements HookListenerInterface $data['ip_address'] = '0.0.0.0'; } + // 2차 방어(게시판 실데이터): 이미 살아있는 답변이 있는 부모에는 자식 Post 생성 거부. + // 1차 방어(피벗 is_answered)는 서비스가 담당하지만, API 동시 호출/직접 호출로 + // 플래그가 어긋난 경우에도 게시판에 중복 답변이 쌓이지 않도록 여기서 최종 차단한다. + if (! empty($data['parent_id']) && $this->postRepository->findFirstReplyWithBoard((int) $data['parent_id']) !== null) { + Log::warning('EcommerceInquiryHookListener: 기존 답변이 있는 문의에 중복 답변 생성 시도 차단', [ + 'slug' => $slug, + 'parent_id' => $data['parent_id'], + ]); + + // null(훅 무응답/실패)과 구분되는 중복 마커 — 호출 서비스가 이 마커로 + // "이미 등록된 답변" 사유를 사용자에게 그대로 안내한다. + return ['duplicate' => true]; + } + // parent_id 있으면 답변글 → 부모 Post 제목으로 Re: 원글제목 설정 if (! empty($data['parent_id']) && empty($data['title'])) { $parentPost = $this->postRepository->findWithBoard($data['parent_id']); @@ -342,8 +363,14 @@ class EcommerceInquiryHookListener implements HookListenerInterface throw new ModelNotFoundException("Post {$postId} or its board could not be found."); } - // 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴) - $this->postService->deletePost($post->board->slug, $postId, options: ['skip_notification' => true]); + // 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴). + // cascade_replies: 훅 경유 삭제는 게시판 답글 삭제 정책(block)과 무관하게 답변을 + // 함께 정리한다 — 문의 답변은 시스템 생성이므로 정책에 막히면 안 되고, + // 기설치본의 다건 고아 답변도 cascade 스윕이 함께 정리한다. + $this->postService->deletePost($post->board->slug, $postId, options: [ + 'skip_notification' => true, + 'cascade_replies' => true, + ]); } catch (ModelNotFoundException $e) { Log::warning('EcommerceInquiryHookListener: Post 삭제 실패 - 게시글 또는 게시판 없음', [ 'post_id' => $postId, @@ -446,8 +473,12 @@ class EcommerceInquiryHookListener implements HookListenerInterface throw new ModelNotFoundException("Reply Post {$reply->id}'s board could not be found."); } - // 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴) - $this->postService->deletePost($reply->board->slug, $reply->id, options: ['skip_notification' => true]); + // 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴). + // cascade_replies: 답변 밑에 달린 자식 글까지 함께 정리 (정책 무관 — 시스템 생성 답변) + $this->postService->deletePost($reply->board->slug, $reply->id, options: [ + 'skip_notification' => true, + 'cascade_replies' => true, + ]); } catch (\RuntimeException $e) { throw $e; } catch (ModelNotFoundException $e) { @@ -473,6 +504,31 @@ class EcommerceInquiryHookListener implements HookListenerInterface return $carry; } + /** + * 부모 문의 Post 의 살아있는 답변 수 반환 (`sirsoft-ecommerce.inquiry.count_replies` 필터 훅) + * + * 이커머스 모듈이 답변 삭제 후 `is_answered` 재계산, 게시판 직권 삭제 시 답변완료 + * 해제 판정에 사용합니다. SoftDeletes 전역 스코프가 삭제된 답변을 제외하므로 + * "살아있는 답변" 만 집계됩니다. + * + * @param mixed $carry 이전 필터 결과 (초기값: 0) + * @param int $parentPostId 부모 문의 Post ID + * @return int 살아있는 답변 수 (조회 실패 시 이전 필터 결과 유지) + */ + public function countReplies(mixed $carry, int $parentPostId): int + { + try { + return $this->postRepository->countRepliesByParentId($parentPostId); + } catch (\Exception $e) { + Log::error('EcommerceInquiryHookListener: 답변 수 조회 실패', [ + 'parent_post_id' => $parentPostId, + 'error' => $e->getMessage(), + ]); + + return (int) $carry; + } + } + // ─── 내부 유틸리티 ──────────────────────────────────────── /** diff --git a/modules/_bundled/sirsoft-board/src/Models/Board.php b/modules/_bundled/sirsoft-board/src/Models/Board.php index 0e0afef0..82fcb877 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Board.php +++ b/modules/_bundled/sirsoft-board/src/Models/Board.php @@ -14,6 +14,7 @@ use Modules\Sirsoft\Board\Casts\AsAllowedExtensions; use Modules\Sirsoft\Board\Database\Factories\BoardFactory; use Modules\Sirsoft\Board\Enums\BoardOrderBy; use Modules\Sirsoft\Board\Enums\OrderDirection; +use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy; use Modules\Sirsoft\Board\Enums\SecretMode; class Board extends Model @@ -33,6 +34,7 @@ class Board extends Model 'use_comment' => ['label_key' => 'sirsoft-board::activity_log.fields.use_comment', 'type' => 'boolean'], 'use_reply' => ['label_key' => 'sirsoft-board::activity_log.fields.use_reply', 'type' => 'boolean'], 'max_reply_depth' => ['label_key' => 'sirsoft-board::activity_log.fields.max_reply_depth', 'type' => 'number'], + 'reply_delete_policy' => ['label_key' => 'sirsoft-board::activity_log.fields.reply_delete_policy', 'type' => 'enum', 'enum' => ReplyDeletePolicy::class], 'use_report' => ['label_key' => 'sirsoft-board::activity_log.fields.use_report', 'type' => 'boolean'], 'use_file_upload' => ['label_key' => 'sirsoft-board::activity_log.fields.use_file_upload', 'type' => 'boolean'], 'max_file_size' => ['label_key' => 'sirsoft-board::activity_log.fields.max_file_size', 'type' => 'number'], @@ -126,6 +128,7 @@ class Board extends Model 'use_comment', 'use_reply', 'max_reply_depth', + 'reply_delete_policy', 'use_report', 'new_display_hours', @@ -180,6 +183,7 @@ class Board extends Model 'use_comment' => 'boolean', 'use_reply' => 'boolean', 'max_reply_depth' => 'integer', + 'reply_delete_policy' => ReplyDeletePolicy::class, // Post::isNew() 가 Carbon 시간 연산에 넘기는 값 — 조회 시점과 무관하게 정수를 보장한다. 'new_display_hours' => 'integer', 'use_report' => 'boolean', diff --git a/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php index dc54c4d3..af2419f6 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php @@ -418,6 +418,60 @@ class AttachmentRepository implements AttachmentRepositoryInterface ]); } + /** + * 여러 게시글의 살아있는 첨부를 cascade 로 일괄 소프트 삭제합니다. + * + * 답글 트리 연쇄 삭제(softDeleteCascadeByParentId)로 지워지는 자손 게시글들의 + * 첨부 정리용입니다. 단건 softDeleteByPostId 와 동일한 쿼리를 whereIn 으로 확장했습니다. + * 물리 파일은 보존하며 deleted_at 마킹만 수행합니다. (복원 가능성 유지) + * + * @param string $slug 게시판 슬러그 + * @param array $postIds 게시글 ID 배열 + * @return int 삭제된 첨부 수 + */ + public function softDeleteByPostIds(string $slug, array $postIds): int + { + if ($postIds === []) { + return 0; + } + + $board = Board::where('slug', $slug)->first(); + + return Attachment::where('board_id', $board?->id) + ->whereIn('post_id', $postIds) + ->update([ + 'trigger_type' => TriggerType::Cascade->value, + 'deleted_at' => now(), + ]); + } + + /** + * 여러 게시글의 cascade 로 지워진 첨부만 일괄 복원합니다. + * + * 답글 트리 연쇄 복원(restoreCascadedByParentId)으로 되살아난 자손 게시글들의 + * 첨부 복원용입니다. 사용자가 직접 지운 첨부(trigger_type='user')는 유지됩니다. + * + * @param string $slug 게시판 슬러그 + * @param array $postIds 게시글 ID 배열 + * @return int 복원된 첨부 수 + */ + public function restoreCascadedByPostIds(string $slug, array $postIds): int + { + if ($postIds === []) { + return 0; + } + + $board = Board::where('slug', $slug)->first(); + + return Attachment::onlyTrashed() + ->where('board_id', $board?->id) + ->whereIn('post_id', $postIds) + ->where('trigger_type', TriggerType::Cascade->value) + ->update([ + 'deleted_at' => null, + ]); + } + /** * 게시판 ID 기준으로 첨부파일을 일괄 영구 삭제합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php index 671fe972..0bd5157c 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php @@ -700,6 +700,59 @@ class CommentRepository implements CommentRepositoryInterface ]); } + /** + * 여러 게시글의 살아있는 댓글을 cascade 로 일괄 소프트 삭제합니다. + * + * 답글 트리 연쇄 삭제(softDeleteCascadeByParentId)로 지워지는 자손 게시글들의 + * 댓글 정리용입니다. 단건 softDeleteByPostId 와 동일한 쿼리를 whereIn 으로 확장했습니다. + * + * @param string $slug 게시판 슬러그 + * @param array $postIds 게시글 ID 배열 + * @return int 삭제된 댓글 수 + */ + public function softDeleteByPostIds(string $slug, array $postIds): int + { + if ($postIds === []) { + return 0; + } + + $board = Board::where('slug', $slug)->first(); + + return Comment::where('board_id', $board?->id) + ->whereIn('post_id', $postIds) + ->update([ + 'trigger_type' => TriggerType::Cascade->value, + 'deleted_at' => now(), + ]); + } + + /** + * 여러 게시글의 cascade 로 지워진 댓글만 일괄 복원합니다. + * + * 답글 트리 연쇄 복원(restoreCascadedByParentId)으로 되살아난 자손 게시글들의 + * 댓글 복원용입니다. 사용자가 직접 지운 댓글(trigger_type='user')은 유지됩니다. + * + * @param string $slug 게시판 슬러그 + * @param array $postIds 게시글 ID 배열 + * @return int 복원된 댓글 수 + */ + public function restoreCascadedByPostIds(string $slug, array $postIds): int + { + if ($postIds === []) { + return 0; + } + + $board = Board::where('slug', $slug)->first(); + + return Comment::onlyTrashed() + ->where('board_id', $board?->id) + ->whereIn('post_id', $postIds) + ->where('trigger_type', TriggerType::Cascade->value) + ->update([ + 'deleted_at' => null, + ]); + } + /** * 사용자가 작성한 댓글 목록을 페이지네이션하여 조회합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php index b5d8a0a7..64dacb29 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php @@ -188,6 +188,24 @@ interface AttachmentRepositoryInterface */ public function restoreCascadedByPostId(string $slug, int $postId): int; + /** + * 여러 게시글의 살아있는 첨부를 cascade 로 일괄 소프트 삭제합니다. + * + * @param string $slug 게시판 슬러그 + * @param array $postIds 게시글 ID 배열 + * @return int 삭제된 첨부 수 + */ + public function softDeleteByPostIds(string $slug, array $postIds): int; + + /** + * 여러 게시글의 cascade 로 지워진 첨부만 일괄 복원합니다. + * + * @param string $slug 게시판 슬러그 + * @param array $postIds 게시글 ID 배열 + * @return int 복원된 첨부 수 + */ + public function restoreCascadedByPostIds(string $slug, array $postIds): int; + /** * 게시판 ID 기준으로 첨부파일을 일괄 영구 삭제합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php index e2eb7458..7f924965 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php @@ -213,6 +213,24 @@ interface CommentRepositoryInterface */ public function restoreCascadedByPostId(string $slug, int $postId): int; + /** + * 여러 게시글의 살아있는 댓글을 cascade 로 일괄 소프트 삭제합니다. + * + * @param string $slug 게시판 슬러그 + * @param array $postIds 게시글 ID 배열 + * @return int 삭제된 댓글 수 + */ + public function softDeleteByPostIds(string $slug, array $postIds): int; + + /** + * 여러 게시글의 cascade 로 지워진 댓글만 일괄 복원합니다. + * + * @param string $slug 게시판 슬러그 + * @param array $postIds 게시글 ID 배열 + * @return int 복원된 댓글 수 + */ + public function restoreCascadedByPostIds(string $slug, array $postIds): int; + /** * 사용자가 작성한 댓글 목록을 페이지네이션하여 조회합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php index 128721f8..61869259 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php @@ -368,6 +368,59 @@ interface PostRepositoryInterface */ public function findFirstReplyWithBoard(int $parentPostId): ?Post; + /** + * 부모 게시글 ID로 살아있는 답변(자식) 게시글 수를 조회합니다. + * + * @param int $parentPostId 부모 게시글 ID + * @return int 살아있는 자식 게시글 수 + */ + public function countRepliesByParentId(int $parentPostId): int; + + /** + * 게시글에 살아있는 직계 답글이 있는지 확인합니다. + * + * @param string $slug 게시판 슬러그 + * @param int $postId 게시글 ID + * @return bool 살아있는 직계 답글 존재 여부 + */ + public function hasAliveReplies(string $slug, int $postId): bool; + + /** + * 게시글의 전체 자손(답글 트리) ID 를 수집합니다 (withTrashed 순회 + 방문 가드). + * + * @param string $slug 게시판 슬러그 + * @param int $postId 루트 게시글 ID + * @return array 자손 게시글 ID 배열 (루트 미포함) + */ + public function collectDescendantIds(string $slug, int $postId): array; + + /** + * 게시글의 살아있는 자손 답글 전체를 cascade 로 일괄 소프트 삭제합니다. + * + * @param string $slug 게시판 슬러그 + * @param int $postId 부모 게시글 ID + * @return array 소프트 삭제된 자손 게시글 ID 배열 + */ + public function softDeleteCascadeByParentId(string $slug, int $postId): array; + + /** + * 게시글 복원 시, cascade 로 지워진 자손 답글만 top-down 으로 선택 복원합니다. + * + * @param string $slug 게시판 슬러그 + * @param int $postId 복원된 부모 게시글 ID + * @return array 복원된 자손 게시글 ID 배열 + */ + public function restoreCascadedByParentId(string $slug, int $postId): array; + + /** + * 게시판의 전체 게시글 ID(withTrashed) 를 청크 단위로 순회하며 콜백에 전달합니다. + * + * @param int $boardId 게시판 ID + * @param int $size 청크 크기 + * @param callable $callback 청크마다 호출될 콜백 (int[] $postIds) + */ + public function eachIdChunkByBoardId(int $boardId, int $size, callable $callback): void; + /** * 게시글의 comments_count 컬럼을 활성 댓글 수로 재계산해 갱신합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php index 8e8218a8..6cfdc92c 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php @@ -1719,6 +1719,206 @@ class PostRepository implements PostRepositoryInterface ->first(); } + /** + * 부모 게시글 ID로 살아있는 답변(자식) 게시글 수를 조회합니다. + * + * SoftDeletes 전역 스코프가 삭제된 답변을 자동 제외하므로, + * "삭제된 답변 후 재등록 허용" 판정의 근거로 사용됩니다. + * + * @param int $parentPostId 부모 게시글 ID + * @return int 살아있는 자식 게시글 수 + */ + public function countRepliesByParentId(int $parentPostId): int + { + return Post::where('parent_id', $parentPostId)->count(); + } + + /** + * 게시글에 살아있는 직계 답글이 있는지 확인합니다. + * + * 답글 삭제 정책(block) 의 차단 판정 기준입니다. 살아있는 자손은 살아있는 + * 부모 체인이 필요하므로 직계 검사만으로 판정이 완결됩니다. + * + * @param string $slug 게시판 슬러그 + * @param int $postId 게시글 ID + * @return bool 살아있는 직계 답글 존재 여부 + */ + public function hasAliveReplies(string $slug, int $postId): bool + { + $board = Board::where('slug', $slug)->first(); + + return Post::where('board_id', $board?->id) + ->where('parent_id', $postId) + ->exists(); + } + + /** + * 게시글의 전체 자손(답글 트리) ID 를 수집합니다. + * + * 반복 BFS + 방문 ID 집합 가드로 순환 데이터에서도 유한 종료를 보장합니다(규정). + * 삭제된 중간 노드 밑의 자손도 도달해야 하므로 순회는 withTrashed 로 수행합니다 + * (끊긴 체인 밑 과거 고아 스윕의 전제). + * + * @param string $slug 게시판 슬러그 + * @param int $postId 루트 게시글 ID + * @return array 자손 게시글 ID 배열 (루트 미포함) + */ + public function collectDescendantIds(string $slug, int $postId): array + { + $board = Board::where('slug', $slug)->first(); + + if ($board === null) { + return []; + } + + $visited = [$postId => true]; + $frontier = [$postId]; + $descendants = []; + + while ($frontier !== []) { + $children = Post::withTrashed() + ->where('board_id', $board->id) + ->whereIn('parent_id', $frontier) + ->pluck('id') + ->all(); + + $next = []; + foreach ($children as $childId) { + if (isset($visited[$childId])) { + continue; + } + $visited[$childId] = true; + $next[] = $childId; + $descendants[] = $childId; + } + + $frontier = $next; + } + + return $descendants; + } + + /** + * 게시글의 살아있는 자손 답글 전체를 cascade 로 일괄 소프트 삭제합니다. + * + * 자손 중 살아있는 것만 단일 UPDATE 로 `status='deleted', trigger_type='cascade', + * deleted_at=now()` 마킹합니다 (status 포함: 관리자 삭제 탭 필터가 status 기준). + * 이미 trashed 인 자손(trigger 'user' 등)은 기본 스코프 밖이라 변조되지 않습니다. + * 작업 이력(action_log)은 부모에만 남깁니다 — 댓글 cascade 와 동일한 규약입니다. + * + * @param string $slug 게시판 슬러그 + * @param int $postId 부모 게시글 ID + * @return array 소프트 삭제된 자손 게시글 ID 배열 + */ + public function softDeleteCascadeByParentId(string $slug, int $postId): array + { + $descendantIds = $this->collectDescendantIds($slug, $postId); + + if ($descendantIds === []) { + return []; + } + + // 살아있는 자손만 선별 (기본 스코프가 trashed 제외) + $aliveIds = Post::whereIn('id', $descendantIds)->pluck('id')->all(); + + if ($aliveIds === []) { + return []; + } + + Post::whereIn('id', $aliveIds)->update([ + 'status' => PostStatus::Deleted->value, + 'trigger_type' => TriggerType::Cascade->value, + 'deleted_at' => now(), + ]); + + return $aliveIds; + } + + /** + * 게시글 복원 시, cascade 로 지워진 자손 답글만 top-down 으로 선택 복원합니다. + * + * 레벨 순회 + 방문 가드: 각 레벨에서 `parent_id ∈ (복원됨 ∪ alive)` 인 + * cascade-trashed 자손만 복원합니다. 사용자 직접 삭제('user')로 trashed 인 + * 중간 노드는 복원되지 않고 그 서브트리도 그대로 유지됩니다(고아 재생성 금지). + * + * 트레이드오프(의도): 삭제 전 blinded 였던 자손도 복원 시 published 가 됩니다 — + * 부모 restorePost 의 기존 의미론(updateStatus published)과 동일합니다. + * + * @param string $slug 게시판 슬러그 + * @param int $postId 복원된 부모 게시글 ID + * @return array 복원된 자손 게시글 ID 배열 + */ + public function restoreCascadedByParentId(string $slug, int $postId): array + { + $board = Board::where('slug', $slug)->first(); + + if ($board === null) { + return []; + } + + $visited = [$postId => true]; + $frontier = [$postId]; + $restored = []; + + while ($frontier !== []) { + // 이미 살아있는 자식 — 더 깊은 레벨의 cascade 복원 통로로만 사용 + $aliveChildren = Post::where('board_id', $board->id) + ->whereIn('parent_id', $frontier) + ->pluck('id') + ->all(); + + // cascade 로 지워진 자식만 복원 대상 + $toRestore = Post::onlyTrashed() + ->where('board_id', $board->id) + ->whereIn('parent_id', $frontier) + ->where('trigger_type', TriggerType::Cascade->value) + ->pluck('id') + ->all(); + + if ($toRestore !== []) { + Post::onlyTrashed()->whereIn('id', $toRestore)->update([ + 'status' => PostStatus::Published->value, + 'deleted_at' => null, + ]); + $restored = array_merge($restored, $toRestore); + } + + $next = []; + foreach (array_merge($aliveChildren, $toRestore) as $childId) { + if (isset($visited[$childId])) { + continue; + } + $visited[$childId] = true; + $next[] = $childId; + } + + $frontier = $next; + } + + return $restored; + } + + /** + * 게시판의 전체 게시글 ID 를 청크 단위로 순회하며 콜백에 전달합니다. + * + * 게시판 삭제 벌크 훅(board.posts.before_force_delete)의 페이로드 공급용입니다. + * withTrashed 포함(삭제 대상은 trashed 도 물리 제거되므로), chunkById(키셋) 로 + * OFFSET 밀림 없이 순회합니다. + * + * @param int $boardId 게시판 ID + * @param int $size 청크 크기 + * @param callable $callback 청크마다 호출될 콜백 (int[] $postIds) + */ + public function eachIdChunkByBoardId(int $boardId, int $size, callable $callback): void + { + Post::withTrashed() + ->where('board_id', $boardId) + ->select('id') + ->chunkById($size, function ($posts) use ($callback) { + $callback($posts->pluck('id')->all()); + }); + } + /** * 비활성 게시판 ID 목록을 조회합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Services/BoardService.php b/modules/_bundled/sirsoft-board/src/Services/BoardService.php index b4e5d0f6..db9c4ab9 100644 --- a/modules/_bundled/sirsoft-board/src/Services/BoardService.php +++ b/modules/_bundled/sirsoft-board/src/Services/BoardService.php @@ -13,7 +13,7 @@ use App\Extension\Traits\ClearsTemplateCaches; use App\Helpers\PermissionHelper; use App\Models\Menu; use App\Models\Role; -use App\Models\User; +use App\Repositories\Concerns\ResolvesSortSpec; use App\Services\MenuService; use App\Services\RoleService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -42,6 +42,26 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; class BoardService { use ClearsTemplateCaches; + use ResolvesSortSpec; + + /** + * 관리자 게시판 목록 정렬 허용 컬럼 화이트리스트 + * + * IndexBoardRequest 의 sort_by in: 목록과 동일해야 한다 (화면 ⊆ 게이트 ⊆ 저장소). + * `name` 은 JSON 다국어 컬럼이라 제외한다. + * + * @var array + */ + private const BOARD_SORT_COLUMNS = [ + 'id', + 'slug', + 'type', + 'is_active', + 'posts_count', + 'comments_count', + 'created_at', + 'updated_at', + ]; /** * BoardService 생성자 @@ -106,13 +126,21 @@ class BoardService }); } - // 정렬 - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 — 요청 유래 값은 닫힌 집합으로만 해석 (미허용 컬럼은 기본값 fallback: 이중 방어) + [$sortSpec] = $this->resolveSortSpec( + $filters, + self::BOARD_SORT_COLUMNS, + 'created_at', + ); + $query->orderBy($sortSpec['column'], $sortSpec['direction']); - // 페이지네이션 - $perPage = $filters['per_page'] ?? 20; + // 비고유 컬럼 동률 구간에서도 전순서 보장 (페이지 경계 중복/누락 방지 규정) + $query->orderBy('id', 'desc'); + + // 페이지네이션 (1~999 클램프 — 게이트 우회 내부 호출 대비 이중 방어) + // 상한 999 는 IndexBoardRequest 와 동일해야 한다: 게시판은 운영자 등록 + // 설정성 테이블이라 전체 목록 셀렉트 소비처(999/200)가 이 값을 쓴다. + $perPage = min(max((int) ($filters['per_page'] ?? 20), 1), 999); return $query->paginate($perPage); } @@ -532,6 +560,15 @@ class BoardService // (소프트 삭제 시 게시판이 사라진 뒤 접근 불가한 고아 데이터로 잔존) $this->attachmentRepository->forceDeleteByBoardId($board->id); $this->commentRepository->forceDeleteByBoardId($board->id); + + // 2-1. 훅: 삭제될 글 ID 목록을 연동 확장에 통지 (이커머스 문의 피벗 정리 등) + // withTrashed 전체를 1,000건 chunk 당 1회씩 다회 발화한다 — 리스너는 멱등 의무. + // 트랜잭션 내부 발화라 게시판 삭제와 원자적이다. per-post after_delete 훅을 + // 재사용하지 않는 이유: 글마다 모델 로드 N회 + 소프트/하드 삭제 의미 불일치. + $this->postRepository->eachIdChunkByBoardId($board->id, 1000, function (array $postIds) use ($board) { + HookManager::doAction('sirsoft-board.board.posts.before_force_delete', $board, $postIds); + }); + $this->postRepository->forceDeleteByBoardId($board->id); // 3. 게시판 권한 삭제 (그누보드7 규정: detach 후 삭제) @@ -742,7 +779,7 @@ class BoardService } // 그누보드7 규정: detach 후 삭제 - $role->permissions()->detach(); + $this->roleRepository->detachAllPermissions($role); $role->users()->detach(); $this->roleRepository->delete($role); } diff --git a/modules/_bundled/sirsoft-board/src/Services/PostService.php b/modules/_bundled/sirsoft-board/src/Services/PostService.php index 8ff881c8..5c1727d5 100644 --- a/modules/_bundled/sirsoft-board/src/Services/PostService.php +++ b/modules/_bundled/sirsoft-board/src/Services/PostService.php @@ -19,6 +19,8 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy; +use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Post; use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface; @@ -376,33 +378,57 @@ class PostService * ① 게시글 상태 변경(deleted) + 소프트 삭제 * ② 살아있는 댓글을 cascade 로 소프트 삭제 (trigger_type='cascade') * ③ 살아있는 첨부를 cascade 로 소프트 삭제 (trigger_type='cascade') + * ④ 살아있는 자손 답글 전체를 cascade 로 소프트 삭제 (+ 그 답글들의 댓글/첨부) * - * 이미 사용자가 직접 삭제한 댓글/첨부(trigger_type='user' 등)는 영향을 받지 않으며, - * 게시글 복원 시 cascade 로 지워진 항목만 선택 복원됩니다. - * 위 3단계는 단일 트랜잭션으로 묶여 부분 실패 시 전체 롤백됩니다. + * 게시판 설정 `reply_delete_policy` 가 'block' 이면 살아있는 직계 답글 존재 시 + * before_delete 훅 발화 전에 차단합니다(부수효과 0). 훅 경유 삭제는 + * `options['cascade_replies']=true` 로 정책을 우회해 cascade 를 고정합니다 + * (문의 답변은 시스템 생성이므로 block 정책에 막히면 안 됨). + * + * 이미 사용자가 직접 삭제한 답글/댓글/첨부(trigger_type='user' 등)는 영향을 받지 + * 않으며, 게시글 복원 시 cascade 로 지워진 항목만 선택 복원됩니다. + * 위 단계는 단일 트랜잭션으로 묶여 부분 실패 시 전체 롤백됩니다. + * after_delete 훅은 부모 게시글에 대해서만 발화합니다 (자손별 발화 시 알림 폭주). * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID * @param string|null $triggerType 트리거 유형 (admin, report 등) - * @param array $options 옵션 배열 (skip_notification: 알림 발송 SKIP) + * @param array $options 옵션 배열 (skip_notification: 알림 발송 SKIP, cascade_replies: 답글 삭제 정책 우회) * @return Post 삭제 처리된 게시글 * * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 + * @throws PostHasRepliesException block 정책 게시판에서 살아있는 답글이 있는 경우 */ public function deletePost(string $slug, int $id, ?string $triggerType = null, array $options = []): Post { - // 게시판 존재성 검증 - $this->validateBoardExists($slug); + // 게시판 존재성 검증 (정책 판정에 게시판 모델이 필요해 직접 조회) + $board = $this->boardService->getBoardBySlug($slug, checkScope: false); + + if (! $board) { + throw new ModelNotFoundException(__('sirsoft-board::messages.errors.board_not_found')); + } $post = $this->postRepository->findOrFail($slug, $id); + // 답글 삭제 정책 판정 — 훅 경유 삭제(cascade_replies 옵션)는 시스템 생성 답글을 + // 함께 정리해야 하므로 게시판 정책과 무관하게 cascade 고정 + $policy = ($options['cascade_replies'] ?? false) + ? ReplyDeletePolicy::Cascade + : ($board->reply_delete_policy ?? ReplyDeletePolicy::Cascade); + + // block 정책: 살아있는 직계 답글이 있으면 before_delete 훅 발화 전에 차단 + // (차단 시 부수효과 0 — 훅 리스너의 외부 정리가 먼저 실행되면 되돌릴 수 없다) + if ($policy === ReplyDeletePolicy::Block && $this->postRepository->hasAliveReplies($slug, $id)) { + throw new PostHasRepliesException; + } + // 훅: before_delete HookManager::doAction('sirsoft-board.post.before_delete', $post, $slug); // 작업 이력 생성 $actionLog = $this->buildActionLog('delete', null); - // 게시글 + 하위 데이터(댓글/첨부) 연쇄 소프트 삭제를 단일 트랜잭션으로 처리 + // 게시글 + 하위 데이터(답글/댓글/첨부) 연쇄 소프트 삭제를 단일 트랜잭션으로 처리 $deletedPost = DB::transaction(function () use ($slug, $id, $actionLog, $triggerType) { // ① 상태 변경 (deleted로 변경하고 소프트 삭제) $deletedPost = $this->postRepository->updateStatus($slug, $id, 'deleted', $actionLog, $triggerType); @@ -414,10 +440,22 @@ class PostService // ③ 첨부 cascade 소프트 삭제 (사용자가 이미 삭제한 항목은 미영향) $this->attachmentRepository->softDeleteByPostId($slug, $id); + // ④ 살아있는 자손 답글 cascade 소프트 삭제 — block 통과 시에도 실행 + // (끊긴 체인 밑 과거 고아 답글 스윕. 살아있는 직계가 없어도 고아 자손은 존재 가능) + $descendantIds = $this->postRepository->softDeleteCascadeByParentId($slug, $id); + + // ⑤ 함께 지워진 자손 답글들의 댓글/첨부도 cascade 소프트 삭제 + if ($descendantIds !== []) { + $this->commentRepository->softDeleteByPostIds($slug, $descendantIds); + $this->attachmentRepository->softDeleteByPostIds($slug, $descendantIds); + } + return $deletedPost; }); // 훅: after_delete ($options 전달 — skip_notification 등 수신 리스너에서 활용) + // 자손 답글별로는 발화하지 않는다 — 자손별 발화 시 알림이 폭주하며, + // 이커머스 문의 피벗은 문의 원글에만 걸려 있어 부모 발화만으로 충분하다. HookManager::doAction('sirsoft-board.post.after_delete', $deletedPost, $slug, $options); // 캐시 무효화 @@ -508,6 +546,17 @@ class PostService // ③ cascade 로 지워진 첨부만 선택 복원 $this->attachmentRepository->restoreCascadedByPostId($slug, $id); + // ④ cascade 로 지워진 자손 답글만 top-down 선택 복원 — 정책값과 무관하게 항상 실행 + // (trigger_type='cascade' 로 마킹된 것만 복원되므로 안전. 사용자 직접 삭제 답글의 + // 서브트리는 복원되지 않는다) + $restoredIds = $this->postRepository->restoreCascadedByParentId($slug, $id); + + // ⑤ 되살아난 자손 답글들의 댓글/첨부도 cascade 분만 복원 + if ($restoredIds !== []) { + $this->commentRepository->restoreCascadedByPostIds($slug, $restoredIds); + $this->attachmentRepository->restoreCascadedByPostIds($slug, $restoredIds); + } + return $restoredPost; }); diff --git a/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php b/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php index d77bb3b6..4489de1a 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php @@ -90,6 +90,7 @@ return [ 'use_comment' => 'Use Comments', 'use_reply' => 'Use Replies', 'max_reply_depth' => 'Max Reply Depth', + 'reply_delete_policy' => 'Reply Delete Policy', 'use_report' => 'Use Reports', 'use_file_upload' => 'Use File Upload', 'max_file_size' => 'Max File Size', diff --git a/modules/_bundled/sirsoft-board/src/lang/en/enums.php b/modules/_bundled/sirsoft-board/src/lang/en/enums.php index 1046da3f..a82761a2 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/enums.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/enums.php @@ -14,6 +14,12 @@ return [ 'always' => 'Always Required', ], + // 답글 삭제 정책 + 'reply_delete_policy' => [ + 'block' => 'Block deletion (cannot delete posts with replies)', + 'cascade' => 'Delete together (replies are deleted with the post)', + ], + // Order Direction 'order_direction' => [ 'asc' => 'Ascending', diff --git a/modules/_bundled/sirsoft-board/src/lang/en/validation.php b/modules/_bundled/sirsoft-board/src/lang/en/validation.php index 1645edeb..b0931c47 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/validation.php @@ -263,6 +263,9 @@ return [ 'notice_not_allowed' => 'Replies cannot be created on notice posts.', ], 'reply_not_allowed' => 'Reply feature is disabled for this board.', + 'delete' => [ + 'has_replies' => 'Posts with replies cannot be deleted. Please delete the replies first.', + ], 'status' => [ 'in' => 'Invalid post status.', ], @@ -303,6 +306,7 @@ return [ 'basic_defaults.use_comment' => 'Use Comment', 'basic_defaults.use_reply' => 'Use Reply', 'basic_defaults.max_reply_depth' => 'Max Reply Depth', + 'basic_defaults.reply_delete_policy' => 'Reply Delete Policy', 'basic_defaults.max_comment_depth' => 'Max Comment Depth', 'basic_defaults.comment_order' => 'Comment Order', 'basic_defaults.show_view_count' => 'Show View Count', @@ -391,6 +395,7 @@ return [ 'override_values.per_page' => 'Posts Per Page', 'override_values.per_page_mobile' => 'Posts Per Page (Mobile)', 'override_values.max_reply_depth' => 'Max Reply Depth', + 'override_values.reply_delete_policy' => 'Reply Delete Policy', 'override_values.max_comment_depth' => 'Max Comment Depth', 'override_values.min_title_length' => 'Min Title Length', 'override_values.max_title_length' => 'Max Title Length', diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php b/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php index c32f08b0..54e30ac9 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php @@ -90,6 +90,7 @@ return [ 'use_comment' => '댓글 사용', 'use_reply' => '답글 사용', 'max_reply_depth' => '최대 답글 깊이', + 'reply_delete_policy' => '답글 삭제 방식', 'use_report' => '신고 사용', 'use_file_upload' => '파일 업로드 사용', 'max_file_size' => '최대 파일 크기', diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/enums.php b/modules/_bundled/sirsoft-board/src/lang/ko/enums.php index 383a9deb..d9c72040 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/enums.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/enums.php @@ -14,6 +14,12 @@ return [ 'always' => '필수 사용', ], + // 답글 삭제 정책 + 'reply_delete_policy' => [ + 'block' => '삭제 차단 (답글이 있으면 원글 삭제 불가)', + 'cascade' => '함께 삭제 (원글 삭제 시 답글도 함께 삭제)', + ], + // 정렬 방향 'order_direction' => [ 'asc' => '오름차순', diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php index 8ae61184..e5f33fa8 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php @@ -263,6 +263,9 @@ return [ 'notice_not_allowed' => '공지사항에는 답글을 작성할 수 없습니다.', ], 'reply_not_allowed' => '이 게시판은 답글 기능이 비활성화되어 있습니다.', + 'delete' => [ + 'has_replies' => '답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.', + ], 'status' => [ 'in' => '유효하지 않은 게시글 상태입니다.', ], @@ -303,6 +306,7 @@ return [ 'basic_defaults.use_comment' => '댓글 사용 여부', 'basic_defaults.use_reply' => '답글 사용 여부', 'basic_defaults.max_reply_depth' => '최대 답글 깊이', + 'basic_defaults.reply_delete_policy' => '답글 삭제 방식', 'basic_defaults.max_comment_depth' => '최대 댓글 깊이', 'basic_defaults.comment_order' => '댓글 정렬', 'basic_defaults.show_view_count' => '조회수 표시', @@ -391,6 +395,7 @@ return [ 'override_values.per_page' => '페이지당 게시글 수', 'override_values.per_page_mobile' => '모바일 페이지당 게시글 수', 'override_values.max_reply_depth' => '최대 답글 깊이', + 'override_values.reply_delete_policy' => '답글 삭제 방식', 'override_values.max_comment_depth' => '최대 댓글 깊이', 'override_values.min_title_length' => '최소 제목 길이', 'override_values.max_title_length' => '최대 제목 길이', diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php index ce3ac68f..198210f2 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php @@ -6,6 +6,7 @@ namespace Modules\Sirsoft\Board\Tests\Feature\Admin; require_once __DIR__.'/../../ModuleTestCase.php'; use App\Contracts\Extension\StorageInterface; +use App\Extension\HookManager; use App\Extension\Storage\ModuleStorageDriver; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -61,6 +62,7 @@ class BoardDeletionTest extends ModuleTestCase * 현행(soft delete) 코드에서는 withTrashed count 가 0 이 아니어서 fail (red). * * @scenario child_type=post + * * @effects post_records_force_deleted,comment_records_force_deleted,attachment_records_force_deleted,board_itself_force_deleted */ public function test_deleting_board_force_deletes_child_records(): void @@ -117,6 +119,7 @@ class BoardDeletionTest extends ModuleTestCase * 남아 fail (red), slug 기반 삭제로 수정하면 green. * * @scenario child_type=attachment + * * @effects attachment_physical_files_removed */ public function test_deleting_board_removes_attachment_physical_files(): void @@ -146,6 +149,7 @@ class BoardDeletionTest extends ModuleTestCase * 게시판이 사라져도 신고 레코드는 보존되고 board_id 만 NULL 이 된다. * * @scenario child_type=report + * * @effects reports_preserved_with_null_board_id */ public function test_deleting_board_preserves_reports(): void @@ -173,6 +177,7 @@ class BoardDeletionTest extends ModuleTestCase * 댓글·첨부 등 하위 데이터가 영향받지 않음을 board 격리 관점에서 함께 보장) * * @scenario child_type=comment + * * @effects other_board_files_not_affected */ public function test_deleting_board_does_not_affect_other_board_files(): void @@ -197,6 +202,66 @@ class BoardDeletionTest extends ModuleTestCase $this->boardService->deleteBoard($boardB->id); } + /** + * 게시판 삭제 시 posts.before_force_delete 훅이 게시판 인스턴스와 함께 + * 전체 글 ID(soft-deleted 포함)를 페이로드로 발화하는지 검증합니다. + * + * BoardService::deleteBoard 는 posts forceDelete 직전에 withTrashed 전체를 + * 1,000건 chunk 당 1회 발화합니다 — 수집된 ID 합집합이 전체 글 ID 와 일치해야 + * 연동 확장(이커머스 문의 피벗 정리 등)이 누락 없이 통지받습니다. + */ + public function test_fires_posts_before_force_delete_hook_with_all_ids(): void + { + $board = $this->createTestBoard('del-test-5'); + + // 일반 글 + soft-deleted 글 (둘 다 물리 삭제 대상이므로 훅 페이로드에 포함되어야 함) + $alivePost = Post::create([ + 'board_id' => $board->id, + 'title' => '살아있는 게시글', + 'content' => '본문', + 'status' => PostStatus::Published, + 'ip_address' => '127.0.0.1', + ]); + + $trashedPost = Post::create([ + 'board_id' => $board->id, + 'title' => '소프트 삭제된 게시글', + 'content' => '본문', + 'status' => PostStatus::Deleted, + 'ip_address' => '127.0.0.1', + ]); + $trashedPost->delete(); + + $receivedIds = []; + $receivedBoardIds = []; + + // 훅 스파이 — ModuleTestCase 가 setUp/tearDown 에서 HookManager 상태를 + // 스냅샷/복원하므로 별도 정리는 불필요 + HookManager::addAction( + 'sirsoft-board.board.posts.before_force_delete', + function ($hookBoard, $postIds) use (&$receivedIds, &$receivedBoardIds) { + $receivedIds = array_merge($receivedIds, $postIds); + $receivedBoardIds[] = $hookBoard->id; + } + ); + + $this->boardService->deleteBoard($board->id); + + // 수집된 ID 합집합 = 전체 글 ID (중복 발화 가능성 대비 unique 후 비교) + $union = array_values(array_unique($receivedIds)); + sort($union); + $expected = [$alivePost->id, $trashedPost->id]; + sort($expected); + + $this->assertSame($expected, $union, '훅 페이로드 ID 합집합이 soft-deleted 포함 전체 글 ID 와 일치해야 합니다.'); + + // board 인자 전달 검증 + $this->assertNotEmpty($receivedBoardIds, '훅이 최소 1회 발화되어야 합니다.'); + foreach (array_unique($receivedBoardIds) as $receivedBoardId) { + $this->assertSame($board->id, $receivedBoardId, '훅의 첫 인자는 삭제 대상 게시판이어야 합니다.'); + } + } + /** * 테스트용 게시판을 생성합니다. * diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php new file mode 100644 index 00000000..ea87cd51 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php @@ -0,0 +1,138 @@ + 'basic'], + ['name' => ['ko' => '기본', 'en' => 'Basic'], 'is_active' => true, 'is_default' => true] + ); + + $this->adminUser = $this->createAdminUser([ + 'sirsoft-board.boards.read', + ]); + } + + /** + * 화이트리스트 밖 정렬 컬럼(sort_by=settings) → 422. + */ + public function test_rejects_unknown_sort_column(): void + { + $this->actingAs($this->adminUser) + ->getJson(self::INDEX_URL.'?sort_by=settings') + ->assertStatus(422); + } + + /** + * per_page 상한(999) 초과(per_page=1000) → 422. + */ + public function test_rejects_out_of_range_per_page(): void + { + $this->actingAs($this->adminUser) + ->getJson(self::INDEX_URL.'?per_page=1000') + ->assertStatus(422); + } + + /** + * 전체 목록 셀렉트 소비처의 per_page 를 게이트가 수용한다 (회귀). + * + * 게시판은 운영자 등록 설정성 테이블이라 레이아웃이 사실상 전량 조회를 쓴다: + * - 게시판 설정 화면(admin_board_settings.json): per_page=999 + * - 쇼핑몰 환경설정 문의 카드(admin-ecommerce-inquiry-settings.json): per_page=200 + * 게이트를 100 으로 조였을 때 두 화면 진입이 422 토스트로 깨졌다 (#573 회귀). + */ + public function test_accepts_full_list_consumer_per_page(): void + { + foreach ([200, 999] as $perPage) { + $this->actingAs($this->adminUser) + ->getJson(self::INDEX_URL.'?per_page='.$perPage) + ->assertStatus(200); + } + } + + /** + * 화이트리스트 컬럼 정렬(sort_by=slug&sort_order=asc)은 200 + 순서 보장. + */ + public function test_sorts_by_whitelisted_column(): void + { + $prefix = 'sort-'.uniqid(); + + // 생성 순서를 정렬 기대 순서와 다르게 섞는다 (기본 정렬 fallback 오탐 방지) + foreach (['c', 'a', 'b'] as $suffix) { + Board::create([ + 'name' => ['ko' => "정렬 테스트 {$suffix}"], + 'slug' => "{$prefix}-{$suffix}", + 'type' => 'basic', + 'is_active' => true, + ]); + } + + $rows = $this->actingAs($this->adminUser) + ->getJson(self::INDEX_URL."?search={$prefix}&sort_by=slug&sort_order=asc") + ->assertStatus(200) + ->json('data.data'); + + $slugs = array_column($rows, 'slug'); + + $this->assertSame( + ["{$prefix}-a", "{$prefix}-b", "{$prefix}-c"], + $slugs, + 'slug 오름차순 정렬이 적용되어야 합니다.' + ); + } + + /** + * BoardService::getBoards 직접 호출(게이트 우회 내부 호출)에 미허용 정렬 컬럼을 + * 넘겨도 예외 없이 기본 정렬로 fallback 한다 (ResolvesSortSpec 이중 방어). + */ + public function test_service_ignores_unlisted_sort_column(): void + { + $this->actingAs($this->adminUser); + + $result = app(BoardService::class)->getBoards([ + 'sort_by' => 'evil', + 'sort_order' => 'asc', + ]); + + $this->assertInstanceOf( + LengthAwarePaginator::class, + $result, + '미허용 정렬 컬럼은 예외 없이 기본 정렬로 fallback 되어야 합니다.' + ); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php new file mode 100644 index 00000000..eaec1924 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php @@ -0,0 +1,430 @@ + $slug, + 'name' => ['ko' => '답글 삭제 정책 테스트 게시판', 'en' => 'Reply Delete Policy Board'], + 'is_active' => true, + 'secret_mode' => 'disabled', + 'blocked_keywords' => [], + // 이전 테스트가 block 으로 바꾼 값이 잔류하지 않도록 매 테스트 기본값으로 리셋 + 'reply_delete_policy' => 'cascade', + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->postService = app(PostService::class); + + $slug = $this->board->slug; + + $this->adminWithManage = $this->createAdminUser([ + "sirsoft-board.{$slug}.admin.manage", + ]); + + // 사용자 삭제 경로(회원 본인 글) 검증용 + $this->grantUserRolePermissions(['posts.read', 'posts.write']); + } + + // ========================================== + // Helper + // ========================================== + + /** + * 관리자 게시글 API URL 을 반환합니다. + * + * @param string $suffix URL 접미사 + * @return string 관리자 게시글 API URL + */ + private function adminUrl(string $suffix): string + { + return "/api/modules/sirsoft-board/admin/board/{$this->board->slug}/posts{$suffix}"; + } + + /** + * 사용자 게시글 API URL 을 반환합니다. + * + * @param string $suffix URL 접미사 + * @return string 사용자 게시글 API URL + */ + private function userUrl(string $suffix): string + { + return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts{$suffix}"; + } + + /** + * 답글(자식 게시글)을 직접 DB 에 생성합니다. + * + * @param int $parentId 부모 게시글 ID + * @param int $depth 답글 깊이 + * @param array $attributes 덮어쓸 속성 + * @return int 생성된 답글 ID + */ + private function createReply(int $parentId, int $depth = 1, array $attributes = []): int + { + return $this->createTestPost(array_merge([ + 'title' => "답글 (depth {$depth})", + 'parent_id' => $parentId, + 'depth' => $depth, + ], $attributes)); + } + + /** + * 첨부파일을 직접 DB 에 생성합니다 (PostDeleteCascadeTest 와 동일 패턴). + * + * @param int $postId 게시글 ID + * @param array $attributes 덮어쓸 속성 + * @return int 생성된 첨부 ID + */ + private function createTestAttachment(int $postId, array $attributes = []): int + { + static $seq = 0; + $seq++; + + $defaults = [ + 'board_id' => $this->board->id, + 'post_id' => $postId, + 'hash' => 'r'.str_pad((string) $seq, 11, '0', STR_PAD_LEFT), + 'original_filename' => 'test.png', + 'stored_filename' => 'stored-'.$seq.'.png', + 'disk' => 'local', + 'path' => 'attachments/reply-'.$seq.'.png', + 'mime_type' => 'image/png', + 'size' => 1024, + 'collection' => 'default', + 'order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]; + + return DB::table('board_attachments')->insertGetId(array_merge($defaults, $attributes)); + } + + /** + * 게시글 행을 조회합니다. + * + * @param int $postId 게시글 ID + * @return object|null 게시글 행 + */ + private function postRow(int $postId): ?object + { + return DB::table('board_posts')->where('id', $postId)->first(); + } + + // ========================================== + // cascade 정책 (기본) + // ========================================== + + /** + * cascade 정책: 부모 삭제 시 깊이 3 답글 트리 전체 + 자손별 댓글/첨부가 + * cascade soft delete + trigger_type='cascade' + status='deleted' 로 마킹된다. + */ + public function test_cascade_policy_soft_deletes_all_descendant_replies(): void + { + $parentId = $this->createTestPost(['status' => 'published']); + $reply1 = $this->createReply($parentId, 1); + $reply2 = $this->createReply($reply1, 2); + $reply3 = $this->createReply($reply2, 3); + + $commentIds = []; + $attachmentIds = []; + foreach ([$reply1, $reply2, $reply3] as $replyId) { + $commentIds[$replyId] = $this->createTestComment($replyId); + $attachmentIds[$replyId] = $this->createTestAttachment($replyId); + } + + $this->postService->deletePost($this->board->slug, $parentId, 'admin'); + + foreach ([$reply1, $reply2, $reply3] as $replyId) { + $row = $this->postRow($replyId); + $this->assertNotNull($row->deleted_at, "자손 답글 {$replyId} 이 soft delete 되어야 합니다."); + $this->assertSame('cascade', $row->trigger_type, "자손 답글 {$replyId} 이 cascade 로 마킹되어야 합니다."); + $this->assertSame('deleted', $row->status, "자손 답글 {$replyId} 의 status 가 deleted 여야 합니다."); + + $comment = DB::table('board_comments')->where('id', $commentIds[$replyId])->first(); + $this->assertNotNull($comment->deleted_at, "자손 답글 {$replyId} 의 댓글이 soft delete 되어야 합니다."); + $this->assertSame('cascade', $comment->trigger_type); + + $attachment = DB::table('board_attachments')->where('id', $attachmentIds[$replyId])->first(); + $this->assertNotNull($attachment->deleted_at, "자손 답글 {$replyId} 의 첨부가 soft delete 되어야 합니다."); + $this->assertSame('cascade', $attachment->trigger_type); + } + } + + /** + * 복원 시 cascade 로 지워졌던 자손 답글 + 그 댓글/첨부가 함께 복원된다. + */ + public function test_cascade_restore_restores_descendants_and_their_children(): void + { + $parentId = $this->createTestPost(['status' => 'published']); + $reply1 = $this->createReply($parentId, 1); + $reply2 = $this->createReply($reply1, 2); + + $commentId = $this->createTestComment($reply1); + $attachmentId = $this->createTestAttachment($reply2); + + $this->postService->deletePost($this->board->slug, $parentId, 'admin'); + $this->postService->restorePost($this->board->slug, $parentId, '복원', 'admin'); + + foreach ([$reply1, $reply2] as $replyId) { + $row = $this->postRow($replyId); + $this->assertNull($row->deleted_at, "cascade 자손 답글 {$replyId} 은 복원되어야 합니다."); + $this->assertSame('published', $row->status, "복원된 자손 답글 {$replyId} 의 status 가 published 여야 합니다."); + } + + $comment = DB::table('board_comments')->where('id', $commentId)->first(); + $this->assertNull($comment->deleted_at, 'cascade 자손 답글의 댓글도 복원되어야 합니다.'); + + $attachment = DB::table('board_attachments')->where('id', $attachmentId)->first(); + $this->assertNull($attachment->deleted_at, 'cascade 자손 답글의 첨부도 복원되어야 합니다.'); + } + + /** + * 사용자 직접 삭제(trigger 'user') 로 먼저 지워진 중간 자손의 서브트리는 + * 부모 삭제 → 복원 후에도 trashed 로 유지된다 (고아 재생성 금지). + */ + public function test_user_predeleted_reply_subtree_stays_trashed_after_restore(): void + { + $parentId = $this->createTestPost(['status' => 'published']); + $reply1 = $this->createReply($parentId, 1); + $reply2 = $this->createReply($reply1, 2); + + // 중간 자손(reply1)을 사용자 삭제 경로로 먼저 삭제 → reply1 trigger 'user', + // 그 자식 reply2 는 reply1 삭제의 cascade 로 trashed + $this->postService->deletePost($this->board->slug, $reply1, 'user'); + + $this->assertSame('user', $this->postRow($reply1)->trigger_type, '테스트 전제: reply1 은 user 삭제여야 합니다.'); + $this->assertNotNull($this->postRow($reply2)->deleted_at, '테스트 전제: reply2 는 reply1 삭제로 trashed 여야 합니다.'); + + // 부모 삭제 → 복원 + $this->postService->deletePost($this->board->slug, $parentId, 'admin'); + $this->postService->restorePost($this->board->slug, $parentId, '복원', 'admin'); + + $this->assertNull($this->postRow($parentId)->deleted_at, '부모 글은 복원되어야 합니다.'); + + $reply1Row = $this->postRow($reply1); + $this->assertNotNull($reply1Row->deleted_at, '사용자가 직접 지운 답글은 복원되면 안 됩니다.'); + $this->assertSame('user', $reply1Row->trigger_type, '사용자 삭제 trigger_type 은 유지되어야 합니다.'); + + $this->assertNotNull( + $this->postRow($reply2)->deleted_at, + '사용자 삭제 답글의 서브트리도 trashed 로 유지되어야 합니다 (고아 재생성 금지).' + ); + } + + /** + * cascade 는 이미 trashed 인 자손(trigger 'user')을 변조하지 않는다 + * (trigger_type 유지 + deleted_at 불변). + */ + public function test_cascade_skips_already_trashed_descendants(): void + { + $parentId = $this->createTestPost(['status' => 'published']); + $this->createReply($parentId, 1); // 살아있는 답글 — cascade 가 실제로 수행되는 전제 + + $preDeletedAt = now()->subHour(); + $preDeletedReplyId = $this->createReply($parentId, 1, [ + 'status' => 'deleted', + 'trigger_type' => 'user', + 'deleted_at' => $preDeletedAt, + ]); + + $before = $this->postRow($preDeletedReplyId); + + $this->postService->deletePost($this->board->slug, $parentId, 'admin'); + + $after = $this->postRow($preDeletedReplyId); + $this->assertSame('user', $after->trigger_type, '이미 trashed 인 자손의 trigger_type 은 유지되어야 합니다.'); + $this->assertSame($before->deleted_at, $after->deleted_at, '이미 trashed 인 자손의 deleted_at 은 불변이어야 합니다.'); + } + + // ========================================== + // block 정책 + // ========================================== + + /** + * block 정책: 살아있는 답글이 있는 글의 관리자 삭제 → 422 + 정책 메시지, 부수효과 0. + */ + public function test_block_policy_returns_422_with_message_when_alive_reply_exists(): void + { + $this->updateBoardSettings(['reply_delete_policy' => 'block']); + + $parentId = $this->createTestPost(['status' => 'published']); + $replyId = $this->createReply($parentId, 1); + + $response = $this->actingAs($this->adminWithManage) + ->deleteJson($this->adminUrl("/{$parentId}")); + + $response->assertStatus(422); + $this->assertSame( + __('sirsoft-board::validation.post.delete.has_replies'), + $response->json('message'), + '답글 삭제 정책 차단 메시지가 응답되어야 합니다.' + ); + + // 차단 시 부수효과 0: 부모/답글 모두 미삭제 + $this->assertNull($this->postRow($parentId)->deleted_at, '차단된 부모 글은 삭제되면 안 됩니다.'); + $this->assertNull($this->postRow($replyId)->deleted_at, '차단 시 답글도 영향받지 않아야 합니다.'); + } + + /** + * block 정책: 답글이 전부 trashed 면 삭제가 허용된다. + */ + public function test_block_policy_allows_delete_when_replies_all_trashed(): void + { + $this->updateBoardSettings(['reply_delete_policy' => 'block']); + + $parentId = $this->createTestPost(['status' => 'published']); + $this->createReply($parentId, 1, [ + 'status' => 'deleted', + 'trigger_type' => 'user', + 'deleted_at' => now()->subHour(), + ]); + + $this->actingAs($this->adminWithManage) + ->deleteJson($this->adminUrl("/{$parentId}")) + ->assertStatus(200); + + $this->assertNotNull($this->postRow($parentId)->deleted_at, '답글이 전부 trashed 면 부모 글 삭제가 허용되어야 합니다.'); + } + + /** + * block 정책은 사용자 삭제 경로(회원 본인 글)에도 동일하게 적용된다 → 422. + */ + public function test_user_delete_path_applies_policy(): void + { + $this->updateBoardSettings(['reply_delete_policy' => 'block']); + + $owner = $this->createUser(); + $parentId = $this->createTestPost([ + 'status' => 'published', + 'user_id' => $owner->id, + 'author_name' => $owner->name, + ]); + $this->createReply($parentId, 1); + + $response = $this->actingAs($owner) + ->deleteJson($this->userUrl("/{$parentId}")); + + $response->assertStatus(422); + $this->assertSame( + __('sirsoft-board::validation.post.delete.has_replies'), + $response->json('message') + ); + $this->assertNull($this->postRow($parentId)->deleted_at, '차단된 글은 삭제되면 안 됩니다.'); + } + + /** + * 신고 조치 삭제(trigger 'report')에도 block 정책이 동일하게 적용된다. + * 정책 우회는 오직 cascade_replies 옵션(훅 경유)뿐 — trigger 문자열은 우회 수단이 아니다. + */ + public function test_report_trigger_delete_applies_block_policy(): void + { + $this->updateBoardSettings(['reply_delete_policy' => 'block']); + + $parentId = $this->createTestPost(['status' => 'published']); + $replyId = $this->createReply($parentId, 1); + + try { + $this->postService->deletePost($this->board->slug, $parentId, 'report'); + $this->fail('block 정책에서는 신고 조치 삭제도 PostHasRepliesException 으로 차단되어야 합니다.'); + } catch (PostHasRepliesException $e) { + // 기대 경로: ReportService::deleteContent 의 기존 generic catch 가 이 예외를 + // 조치 실패 사유로 로그 변환한다 (계획서 WP2 §차단 예외 — 정책 적용 유지). + } + + $this->assertNull($this->postRow($parentId)->deleted_at, '차단된 부모 글은 삭제되면 안 됩니다.'); + $this->assertNull($this->postRow($replyId)->deleted_at, '차단 시 답글도 영향받지 않아야 합니다.'); + } + + /** + * 끊긴 체인 밑 고아 답글: 직계 답글이 전부 trashed 라 block 정책이 삭제를 허용하는 + * 경우에도 cascade 스윕이 실행되어, trashed 자손 밑에 살아남아 있던 고아 답글을 + * 함께 정리한다 (계획서 WP2 §PostService — "block 통과 시에도 실행"). + */ + public function test_orphan_reply_under_trashed_chain_is_swept_on_delete(): void + { + $this->updateBoardSettings(['reply_delete_policy' => 'block']); + + $parentId = $this->createTestPost(['status' => 'published']); + $trashedChildId = $this->createReply($parentId, 1, [ + 'status' => 'deleted', + 'trigger_type' => 'user', + 'deleted_at' => now()->subHour(), + ]); + $orphanGrandchildId = $this->createReply($trashedChildId, 2); // 끊긴 체인 밑에 살아있는 고아 + + // 직계 답글이 전부 trashed → block 정책이 삭제를 허용 + $this->actingAs($this->adminWithManage) + ->deleteJson($this->adminUrl("/{$parentId}")) + ->assertStatus(200); + + $orphanRow = $this->postRow($orphanGrandchildId); + $this->assertNotNull($orphanRow->deleted_at, '끊긴 체인 밑 고아 답글도 cascade 스윕으로 정리되어야 합니다.'); + $this->assertSame('cascade', $orphanRow->trigger_type, '고아 답글은 cascade 로 마킹되어야 합니다.'); + $this->assertSame('deleted', $orphanRow->status); + + // 사용자 선삭제 중간 노드는 불변 (trigger_type/deleted_at 유지) + $this->assertSame('user', $this->postRow($trashedChildId)->trigger_type, '기존 trashed 중간 노드는 변조되면 안 됩니다.'); + } + + /** + * 훅 경유 삭제(options cascade_replies=true)는 block 정책을 우회하고 + * 자손 답글까지 cascade 로 함께 정리한다 (시스템 생성 답변 정리 보장). + */ + public function test_ecommerce_hook_delete_bypasses_block_policy(): void + { + $this->updateBoardSettings(['reply_delete_policy' => 'block']); + + $parentId = $this->createTestPost(['status' => 'published']); + $replyId = $this->createReply($parentId, 1); + + $this->postService->deletePost($this->board->slug, $parentId, options: ['cascade_replies' => true]); + + $this->assertNotNull($this->postRow($parentId)->deleted_at, 'cascade_replies 옵션은 block 정책을 우회해야 합니다.'); + + $replyRow = $this->postRow($replyId); + $this->assertNotNull($replyRow->deleted_at, '자손 답글도 함께 cascade 삭제되어야 합니다.'); + $this->assertSame('cascade', $replyRow->trigger_type); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php index 39500b8d..39fafa33 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Tests\Feature; require_once __DIR__.'/../ModuleTestCase.php'; +use Illuminate\Support\Facades\Hash; use Modules\Sirsoft\Board\Tests\BoardTestCase; /** @@ -32,6 +33,16 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase; * * UpdatePostRequest: * - title 최소 길이 미만 → 422 + * - password 배열 주입 → 422 + * + * DestroyCommentRequest: + * - password 배열 주입 → 422 + * - password 최소 길이(4) 미만 → 422 + * - 회원 본인 댓글 password 없이 삭제 → 200 + * + * DestroyPostRequest: + * - verification_token 배열 주입 → 422 + * - 비회원 글 올바른 password 로 삭제 → 200 * * @group board * @group feature @@ -300,4 +311,111 @@ class FormRequestValidationTest extends BoardTestCase ['title' => '가'] // min_title_length=2 미만 )->assertStatus(422); } + + /** + * 게시글 수정에 password 배열 주입 → 422 (string 규칙 차단) + */ + public function test_post_update_rejects_array_password(): void + { + $user = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $user->id]); + + $this->actingAs($user)->putJson( + "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", + [ + 'title' => '정상 제목', + 'password' => ['x'], // 배열 주입 + ] + )->assertStatus(422); + } + + // ========================================== + // DestroyCommentRequest + // ========================================== + + /** + * 댓글 삭제에 password 배열 주입 → 422 (string 규칙 차단) + */ + public function test_comment_destroy_rejects_array_password(): void + { + $postId = $this->createTestPost(); + $commentId = $this->createTestComment($postId, [ + 'password' => Hash::make('pass1234'), + ]); + + $this->deleteJson( + $this->commentUrl($postId)."/{$commentId}", + ['password' => ['x']] // 배열 주입 + )->assertStatus(422); + } + + /** + * 댓글 삭제에 최소 길이(4) 미만 password → 422 + */ + public function test_comment_destroy_rejects_too_short_password(): void + { + $postId = $this->createTestPost(); + $commentId = $this->createTestComment($postId, [ + 'password' => Hash::make('pass1234'), + ]); + + $this->deleteJson( + $this->commentUrl($postId)."/{$commentId}", + ['password' => '123'] // min:4 미만 + )->assertStatus(422); + } + + /** + * 회원 본인 댓글은 password 없이 삭제 가능 → 200 + * (password 는 nullable — 비회원 소유권 확인용으로만 요구) + */ + public function test_member_comment_destroy_without_password_succeeds(): void + { + $user = $this->createUser(); + $postId = $this->createTestPost(); + $commentId = $this->createTestComment($postId, [ + 'user_id' => $user->id, + 'author_name' => $user->name, + ]); + + $this->actingAs($user) + ->deleteJson($this->commentUrl($postId)."/{$commentId}") + ->assertStatus(200); + } + + // ========================================== + // DestroyPostRequest + // ========================================== + + /** + * 게시글 삭제에 verification_token 배열 주입 → 422 (string 규칙 차단) + */ + public function test_post_destroy_rejects_array_verification_token(): void + { + $postId = $this->createTestPost([ + 'user_id' => null, + 'password' => Hash::make('pass1234'), + ]); + + $this->deleteJson( + $this->postUrl()."/{$postId}", + ['verification_token' => ['x']] // 배열 주입 + )->assertStatus(422); + } + + /** + * 비회원 글은 올바른 password 로 삭제 가능 → 200 + */ + public function test_guest_post_destroy_with_valid_password_succeeds(): void + { + $postId = $this->createTestPost([ + 'user_id' => null, + 'password' => Hash::make('pass1234'), + ]); + + $this->deleteJson( + $this->postUrl()."/{$postId}", + ['password' => 'pass1234'] + )->assertStatus(200); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts b/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts index d6f3d45e..733e0686 100644 --- a/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts +++ b/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts @@ -19,6 +19,8 @@ import { issueToken, authenticatePage } from '../../../../../../tests/Playwright type BoardAuthFixtures = { /** 대시보드 진입 권한 + 게시판 조회/신고 조회 권한 토큰 */ dashboardToken: string; + /** 답글 삭제 정책 E2E 용 — 게시판 CRUD + 환경설정 + 전용 게시판(e2e-reply-policy) 글 관리 토큰 */ + replyPolicyToken: string; /** 환경설정 조회/수정 권한 토큰 */ settingsToken: string; /** 권한 없는 일반 사용자 토큰 (영역 부재 검증용) */ @@ -47,6 +49,24 @@ export const test = base.extend({ ), ); }, + replyPolicyToken: async ({}, use) => { + // 게시판 스코프 권한은 slug 가 식별자에 박히므로 전용 게시판 slug 로 발급. + // core.profile.read: 게시판 생성 필수값(board_manager_ids)에 넣을 본인 uuid 조회용. + await use( + issueToken( + 'core.profile.read', + 'sirsoft-board.boards.read', + 'sirsoft-board.boards.create', + 'sirsoft-board.boards.update', + 'sirsoft-board.boards.delete', + 'sirsoft-board.settings.read', + 'sirsoft-board.settings.update', + 'sirsoft-board.e2e-reply-policy.admin.posts.read', + 'sirsoft-board.e2e-reply-policy.admin.posts.write', + 'sirsoft-board.e2e-reply-policy.admin.manage', + ), + ); + }, noPermissionToken: async ({}, use) => { await use(issueToken()); }, diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts new file mode 100644 index 00000000..1dd74d14 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts @@ -0,0 +1,182 @@ +/** + * 게시판 답글 삭제 정책 (block/cascade) — 설정 노출·저장 왕복 + block 차단 (#573, 공개 #106/#107 후속). + * + * 검증 축: + * ① 게시판 환경설정 "답변글" 탭에 정책 Select 렌더 + 기본값 cascade + * ② 게시판 편집 폼에서 정책을 block 으로 저장 → 재조회 왕복 + * ③ block 게시판에서 답글 달린 글 삭제 → 422 + has_replies 메시지 (DB 무변화) + * + * 단위(PHPUnit ReplyDeletePolicyTest)는 서비스/저장소 cascade 의미론을 담당하고, + * 이 spec 은 화면 노출과 API 계약(저장 왕복·422)의 브라우저 수준 확인을 담당한다. + * + * 전용 게시판(e2e-reply-policy)을 spec 안에서 생성/삭제하므로 시드 의존이 없다. + * + * @scenario post-reply-delete-policy + * @axes policy=block, policy=cascade + * @effects settings_and_form_expose_policy, block_returns_422_with_message + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const SLUG = 'e2e-reply-policy'; +const API = '/api/modules/sirsoft-board'; + +/** 브라우저 컨텍스트에서 Bearer 토큰으로 API 를 호출한다 (실 브라우저 실측 규약). */ +async function api( + page: import('@playwright/test').Page, + bearer: string, + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: any }> { + return page.evaluate( + async ({ bearer, method, path, body }) => { + const response = await fetch(path, { + method, + headers: { + Authorization: `Bearer ${bearer}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + let parsed: unknown = null; + try { + parsed = await response.json(); + } catch { + /* 비 JSON 응답 허용 */ + } + return { status: response.status, body: parsed as any }; + }, + { bearer, method, path, body }, + ); +} + +async function deleteBoardIfExists(page: import('@playwright/test').Page, bearer: string): Promise { + await api(page, bearer, 'DELETE', `${API}/admin/boards/${SLUG}`); +} + +/** 전용 게시판을 생성한다 (StoreBoardRequest 필수값 포함). */ +async function createBoard( + page: import('@playwright/test').Page, + bearer: string, + extra: Record = {}, +): Promise<{ status: number; body: any }> { + // board_manager_ids 필수값: 시드 게시판(free)의 관리자 uuid 를 재사용한다 + // (프로필 엔드포인트는 user 타입 권한 게이트라 admin 토큰으로도 403 — 실측) + const freeBoard = await api(page, bearer, 'GET', `${API}/admin/boards/free`); + const managerUuid = (freeBoard.body?.data?.board_manager_ids ?? [])[0]; + + if (!managerUuid) { + throw new Error(`manager uuid unavailable: status=${freeBoard.status} body=${JSON.stringify(freeBoard.body).slice(0, 300)}`); + } + + return api(page, bearer, 'POST', `${API}/admin/boards`, { + slug: SLUG, + name: { ko: '[검수용] 답글정책', en: 'Reply Policy E2E' }, + type: 'basic', + use_reply: true, + show_view_count: true, + use_report: false, + board_manager_ids: [managerUuid], + ...extra, + }); +} + +// 두 테스트가 같은 전용 게시판 slug 를 생성/삭제하므로 병렬 실행 시 경합한다 — 직렬 고정 +test.describe.configure({ mode: 'serial' }); + +test.describe('게시판 답글 삭제 정책 (#573)', () => { + test('환경설정 답변글 탭에 정책 Select 가 렌더되고 기본값이 cascade 다', async ({ page, replyPolicyToken }) => { + await authenticatePage(page, replyPolicyToken); + + // 답변글 서브탭은 basic_defaults 탭의 스크롤 섹션으로 함께 렌더된다 (TabNavigationScroll) + await page.goto('/admin/boards/settings?tab=basic_defaults'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + // 필드 라벨 + Select 렌더 (admin_basic Select 는 커스텀 드롭다운 — name 루트로 조회) + await expect(page.getByText('답변글 삭제 방식').first()).toBeVisible({ timeout: 15_000 }); + + // 저장값(기본 cascade)이 응답에 실려 있는지 API 로 대조 + const settings = await api(page, replyPolicyToken, 'GET', `${API}/admin/settings`); + expect(settings.status).toBe(200); + const basicDefaults = + settings.body?.data?.settings?.basic_defaults ?? settings.body?.data?.basic_defaults ?? {}; + expect(basicDefaults.reply_delete_policy ?? 'cascade').toBe('cascade'); + }); + + test('게시판 편집 폼 저장 왕복 — block 저장 후 재조회에 반영된다', async ({ page, replyPolicyToken }) => { + await authenticatePage(page, replyPolicyToken); + await page.goto('/admin/boards'); + await deleteBoardIfExists(page, replyPolicyToken); + + // 전용 게시판 생성 (기본 cascade) + const created = await createBoard(page, replyPolicyToken); + expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status); + expect(created.body?.data?.reply_delete_policy ?? 'cascade').toBe('cascade'); + + // 편집 폼에서 필드 렌더 확인 (use_reply ON 조건부) + await page.goto(`/admin/boards/${SLUG}/edit`); + await expect(page.getByText('답변글 삭제 방식').first()).toBeVisible({ timeout: 20_000 }); + + // block 으로 저장 왕복 (API PUT — 폼과 동일 엔드포인트/검증 경로) + const updated = await api(page, replyPolicyToken, 'PUT', `${API}/admin/boards/${SLUG}`, { + reply_delete_policy: 'block', + }); + expect(updated.status).toBe(200); + + const fetched = await api(page, replyPolicyToken, 'GET', `${API}/admin/boards/${SLUG}`); + expect(fetched.body?.data?.reply_delete_policy).toBe('block'); + + // 허용 밖 값은 422 (닫힌 집합) + const invalid = await api(page, replyPolicyToken, 'PUT', `${API}/admin/boards/${SLUG}`, { + reply_delete_policy: 'evil', + }); + expect(invalid.status).toBe(422); + + await deleteBoardIfExists(page, replyPolicyToken); + }); + + test('block 게시판에서 답글 달린 글 삭제는 422 로 거부되고 DB 가 변하지 않는다', async ({ page, replyPolicyToken }) => { + await authenticatePage(page, replyPolicyToken); + await page.goto('/admin/boards'); + await deleteBoardIfExists(page, replyPolicyToken); + + const created = await createBoard(page, replyPolicyToken, { reply_delete_policy: 'block' }); + expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status); + + // 부모 글 + 답글 생성 (admin posts API) + const parent = await api(page, replyPolicyToken, 'POST', `${API}/admin/board/${SLUG}/posts`, { + title: '[검수용] 원글', + content: '답글 삭제 정책 검증용 원글입니다.', + }); + expect([200, 201]).toContain(parent.status); + const parentId = parent.body?.data?.id; + expect(parentId).toBeTruthy(); + + const reply = await api(page, replyPolicyToken, 'POST', `${API}/admin/board/${SLUG}/posts`, { + title: 'Re: [검수용] 원글', + content: '답글 삭제 정책 검증용 답글입니다.', + parent_id: parentId, + }); + expect([200, 201], `reply create failed: ${JSON.stringify(reply.body).slice(0, 400)}`).toContain(reply.status); + const replyId = reply.body?.data?.id; + + // block: 원글 삭제 422 + 메시지 + const blocked = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${parentId}`); + expect(blocked.status).toBe(422); + expect(String(blocked.body?.message ?? '')).toContain('답글'); + + // DB 무변화 — 원글이 여전히 조회된다 + const stillThere = await api(page, replyPolicyToken, 'GET', `${API}/admin/board/${SLUG}/posts/${parentId}`); + expect(stillThere.status).toBe(200); + + // 답글 먼저 삭제하면 원글 삭제가 허용된다 + const replyDeleted = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${replyId}`); + expect(replyDeleted.status).toBe(200); + + const parentDeleted = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${parentId}`); + expect(parentDeleted.status).toBe(200); + + await deleteBoardIfExists(page, replyPolicyToken); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php index f44a05fb..5148f299 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php @@ -15,12 +15,14 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase; * EcommerceInquiryHookListener 단위 테스트 * * 검증 목적: - * - getSubscribedHooks: 7개 훅 등록, 모두 filter 타입 - * - createAndReturn: 성공(post_id/inquirable_type 반환), title 자동생성, parent_id Re: 처리, 예외 시 null + * - getSubscribedHooks: 8개 훅 등록, 모두 filter 타입 + * - createAndReturn: 성공(post_id/inquirable_type 반환), title 자동생성, parent_id Re: 처리, 예외 시 null, + * 기존 살아있는 답변 존재 시 중복 생성 차단(duplicate 마커), 답변 soft delete 후 재등록 허용 * - getByIds: 빈 배열 → carry 그대로, 유효 ID 목록 → 필드 포함 배열 반환 * - getBoardSettings: 존재하지 않는 slug → carry 그대로, 유효 slug → 설정 배열 반환 - * - deletePost: 성공 → carry 반환, 존재하지 않는 PostId → RuntimeException + * - deletePost: 성공 → carry 반환, 답글 자식까지 cascade soft delete, 존재하지 않는 PostId → RuntimeException * - deleteReplyPost: reply 없음 → RuntimeException, 성공 → carry 반환 + * - countReplies: 살아있는 자식만 집계 * * @group board * @group unit @@ -65,9 +67,9 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase // ========================================== /** - * 7개 훅이 모두 등록되어 있고, 모두 filter 타입이어야 합니다. + * 8개 훅이 모두 등록되어 있고, 모두 filter 타입이어야 합니다. */ - public function test_subscribed_hooks_registers_all_seven_filter_hooks(): void + public function test_subscribed_hooks_registers_all_eight_filter_hooks(): void { $hooks = EcommerceInquiryHookListener::getSubscribedHooks(); @@ -79,6 +81,7 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase 'sirsoft-ecommerce.inquiry.delete_reply', 'sirsoft-ecommerce.inquiry.get_by_ids', 'sirsoft-ecommerce.inquiry.get_settings', + 'sirsoft-ecommerce.inquiry.count_replies', ]; foreach ($expectedHooks as $hookName) { @@ -86,7 +89,7 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase $this->assertSame('filter', $hooks[$hookName]['type'], "훅 {$hookName}은 filter 타입이어야 합니다."); } - $this->assertCount(7, $hooks, '총 7개의 훅이 등록되어 있어야 합니다.'); + $this->assertCount(8, $hooks, '총 8개의 훅이 등록되어 있어야 합니다.'); } // ========================================== @@ -216,6 +219,65 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase $this->assertNull($result); } + /** + * createAndReturn: 부모에 이미 살아있는 답변이 있으면 중복 생성 차단 → null 반환 + * + * 2차 방어(게시판 실데이터 기준): 피벗 is_answered 플래그가 어긋난 경우에도 + * 게시판에 중복 답변이 쌓이지 않아야 합니다. + */ + public function test_create_and_return_returns_duplicate_marker_when_parent_already_has_reply(): void + { + $parentPostId = $this->createTestPost(['title' => '원본 문의']); + $this->createTestPost([ + 'title' => 'Re: 원본 문의', + 'parent_id' => $parentPostId, + 'depth' => 1, + ]); + + $result = $this->listener->createAndReturn(null, $this->board->slug, [ + 'content' => '두 번째 답변 시도', + 'parent_id' => $parentPostId, + 'ip_address' => '127.0.0.1', + ]); + + // null(무응답/실패)이 아닌 중복 마커 — 호출 서비스가 "이미 등록된 답변" 사유로 + // 변환한다. null 로 되돌리면 사유가 generic 실패로 위장된다 (PO 실측 제보 회귀). + $this->assertSame( + ['duplicate' => true], + $result, + '살아있는 답변이 이미 있으면 중복 마커로 차단되어야 합니다.' + ); + } + + /** + * createAndReturn: 기존 답변이 soft delete 된 뒤에는 답변 재등록이 허용된다. + * + * 중복 차단 판정은 살아있는 답변만 대상으로 해야 합니다 (SoftDeletes 기본 스코프). + */ + public function test_create_and_return_allows_reply_after_previous_reply_soft_deleted(): void + { + $parentPostId = $this->createTestPost(['title' => '원본 문의']); + $replyPostId = $this->createTestPost([ + 'title' => 'Re: 원본 문의', + 'parent_id' => $parentPostId, + 'depth' => 1, + ]); + + // 기존 답변 soft delete + Post::find($replyPostId)->delete(); + + $result = $this->listener->createAndReturn(null, $this->board->slug, [ + 'content' => '삭제 후 재등록 답변', + 'parent_id' => $parentPostId, + 'ip_address' => '127.0.0.1', + ]); + + $this->assertIsArray($result, '기존 답변이 삭제된 뒤에는 답변 재등록이 허용되어야 합니다.'); + $this->assertArrayHasKey('post_id', $result); + $this->assertIsInt($result['post_id']); + $this->assertNotSame($replyPostId, $result['post_id']); + } + // ========================================== // getByIds // ========================================== @@ -384,6 +446,27 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase $this->assertSoftDeleted('board_posts', ['id' => $postId]); } + /** + * deletePost: 부모 문의 삭제 시 답글 자식도 함께 cascade soft delete 된다. + * + * 훅 경유 삭제는 cascade_replies 옵션으로 게시판 답글 삭제 정책과 무관하게 + * 시스템 생성 답변을 함께 정리해야 합니다. + */ + public function test_delete_post_soft_deletes_reply_children_too(): void + { + $parentPostId = $this->createTestPost(['title' => '원본 문의']); + $replyPostId = $this->createTestPost([ + 'title' => 'Re: 원본 문의', + 'parent_id' => $parentPostId, + 'depth' => 1, + ]); + + $this->listener->deletePost(null, $this->board->slug, $parentPostId); + + $this->assertSoftDeleted('board_posts', ['id' => $parentPostId]); + $this->assertSoftDeleted('board_posts', ['id' => $replyPostId]); + } + /** * deletePost: 존재하지 않는 postId → RuntimeException */ @@ -424,4 +507,34 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase $this->assertSame($carry, $result); $this->assertSoftDeleted('board_posts', ['id' => $replyPostId]); } + + // ========================================== + // countReplies + // ========================================== + + /** + * countReplies: 살아있는 자식만 집계한다 (soft delete 된 답변 제외). + */ + public function test_count_replies_counts_only_live_children(): void + { + $parentPostId = $this->createTestPost(['title' => '원본 문의']); + $this->createTestPost([ + 'title' => 'Re: 살아있는 답변', + 'parent_id' => $parentPostId, + 'depth' => 1, + ]); + $trashedReplyId = $this->createTestPost([ + 'title' => 'Re: 삭제될 답변', + 'parent_id' => $parentPostId, + 'depth' => 1, + ]); + + Post::find($trashedReplyId)->delete(); + + $this->assertSame( + 1, + $this->listener->countReplies(0, $parentPostId), + 'soft delete 된 답변은 집계에서 제외되어야 합니다.' + ); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostRepositoryReplyLookupTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostRepositoryReplyLookupTest.php new file mode 100644 index 00000000..a843c462 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostRepositoryReplyLookupTest.php @@ -0,0 +1,149 @@ +repository = app(PostRepository::class); + } + + // ========================================== + // countRepliesByParentId + // ========================================== + + /** + * countRepliesByParentId: 살아있는 자식만 집계한다 (trashed 자식 제외). + */ + public function test_count_replies_by_parent_id_counts_only_live_children(): void + { + $parentId = $this->createTestPost(['title' => '부모 글']); + + $this->createTestPost([ + 'title' => '살아있는 답글', + 'parent_id' => $parentId, + 'depth' => 1, + ]); + $this->createTestPost([ + 'title' => '삭제된 답글', + 'parent_id' => $parentId, + 'depth' => 1, + 'status' => 'deleted', + 'trigger_type' => 'user', + 'deleted_at' => now()->subHour(), + ]); + + $this->assertSame( + 1, + $this->repository->countRepliesByParentId($parentId), + 'trashed 자식은 집계에서 제외되어야 합니다.' + ); + } + + // ========================================== + // hasAliveReplies + // ========================================== + + /** + * hasAliveReplies: 살아있는 직계 답글이 있으면 true. + */ + public function test_has_alive_replies_returns_true_for_direct_live_reply(): void + { + $parentId = $this->createTestPost(['title' => '부모 글']); + $this->createTestPost([ + 'title' => '살아있는 답글', + 'parent_id' => $parentId, + 'depth' => 1, + ]); + + $this->assertTrue($this->repository->hasAliveReplies($this->board->slug, $parentId)); + } + + /** + * hasAliveReplies: 직계 답글이 전부 trashed 이면 false. + */ + public function test_has_alive_replies_returns_false_when_all_replies_trashed(): void + { + $parentId = $this->createTestPost(['title' => '부모 글']); + $this->createTestPost([ + 'title' => '삭제된 답글 1', + 'parent_id' => $parentId, + 'depth' => 1, + 'status' => 'deleted', + 'trigger_type' => 'user', + 'deleted_at' => now()->subHour(), + ]); + $this->createTestPost([ + 'title' => '삭제된 답글 2', + 'parent_id' => $parentId, + 'depth' => 1, + 'status' => 'deleted', + 'trigger_type' => 'user', + 'deleted_at' => now()->subHour(), + ]); + + $this->assertFalse( + $this->repository->hasAliveReplies($this->board->slug, $parentId), + '직계 답글이 전부 trashed 이면 살아있는 답글이 없는 것으로 판정되어야 합니다.' + ); + } + + /** + * hasAliveReplies: 직계는 trashed 이고 손자만 살아있으면 false (직계만 판정). + * + * 끊긴 체인 밑 고아 손자는 별도로 cascade 스윕이 정리하며, block 정책 차단 + * 판정은 직계 검사만으로 완결됩니다. + */ + public function test_has_alive_replies_returns_false_when_only_grandchild_alive(): void + { + $parentId = $this->createTestPost(['title' => '부모 글']); + $trashedChildId = $this->createTestPost([ + 'title' => '삭제된 직계 답글', + 'parent_id' => $parentId, + 'depth' => 1, + 'status' => 'deleted', + 'trigger_type' => 'user', + 'deleted_at' => now()->subHour(), + ]); + $this->createTestPost([ + 'title' => '살아있는 손자 답글', + 'parent_id' => $trashedChildId, + 'depth' => 2, + ]); + + $this->assertFalse( + $this->repository->hasAliveReplies($this->board->slug, $parentId), + '손자만 살아있는 경우 직계 판정은 false 여야 합니다.' + ); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/post-reply-delete-policy.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/post-reply-delete-policy.yaml new file mode 100644 index 00000000..cf830d3c --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/post-reply-delete-policy.yaml @@ -0,0 +1,52 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는 +# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은 +# PostService/PostRepository cascade + IndexBoardRequest 게이트를 PHPUnit Feature + +# Playwright E2E(admin/board-reply-delete-policy.spec.ts)가 커버하며 green. +feature: 게시판 답글 삭제 정책 (block/cascade) + 게시판 삭제 벌크 훅 + +description: | + 답글이 달린 글의 삭제 방식을 게시판별 설정(reply_delete_policy)으로 선택한다. + cascade(기본): 원글 삭제 시 자손 답글 트리 전체가 trigger_type='cascade' 로 소프트 + 삭제되고(그 답글들의 댓글·첨부 포함), 복원 시 cascade 분만 top-down 선택 복원된다. + 사용자가 직접 지운 답글 서브트리는 복원되지 않는다. block: 살아있는 직계 답글이 + 있으면 422 로 차단(before_delete 훅 발화 전 — 부수효과 0). 훅 경유 삭제 + (cascade_replies 옵션)는 정책과 무관하게 cascade 고정. 게시판 삭제 시 삭제될 글 + ID 를 board.posts.before_force_delete 벌크 훅으로 연동 확장에 통지한다. + 관리자 게시판 목록의 정렬 컬럼·페이지 크기는 닫힌 집합으로만 허용된다. + +axes: + policy: + - block + - cascade + delete_trigger: + - admin + - user + - ecommerce_hook # cascade_replies 옵션 — 정책 우회 + - report # 신고 조치 삭제 — 정책 적용 유지 + tree_state: + - single_reply + - deep_tree # 깊이 3 이상 중첩 + - user_predeleted_mid # 중간 자손이 사용자 직접 삭제됨 + - orphan_under_trashed # 끊긴 체인 밑 고아 답글 + +effects: + - block_returns_422_with_message # has_replies 메시지 + DB 무변화 + - block_allows_when_replies_trashed # 답글 전부 trashed 면 삭제 허용 + - cascade_marks_descendants_deleted # status='deleted' + trigger_type='cascade' + deleted_at + - cascade_children_comments_attachments # 자손 답글의 댓글·첨부도 cascade 소프트 삭제 + - restore_top_down_cascade_only # 복원은 cascade 마킹분만 top-down + - user_predeleted_subtree_stays # 사용자 선삭제 서브트리는 복원 제외 + - hook_delete_bypasses_block # cascade_replies 옵션이 block 정책 우회 + - board_delete_fires_bulk_hook # before_force_delete 훅 chunk 발화 (withTrashed 전체) + - board_index_sort_whitelisted # sort_by 화이트리스트 밖 422, per_page 클램프 + - settings_and_form_expose_policy # 환경설정/게시판 폼에 정책 Select 노출·저장 왕복 + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php + - modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php + - modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php + - modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php + - modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts + - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx + - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index b7e7546d..c668a3ae 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.1] - 2026-08-12 +## [1.1.1] - 2026-08-17 ### Security @@ -29,9 +29,20 @@ - 코어 최소 요구 버전을 7.0.7 로 상향했습니다. - 환불 예상금액 계산이 쿠폰 사용 상태를 일시적으로 변경하지 않습니다. 계산 과정의 쿠폰 상태를 참조하던 외부 확장이 있다면 동작을 확인해 주세요. +- 게시판에서 삭제한 문의 글을 복원하면 상품 문의 목록에도 함께 복원됩니다. 문의 데이터는 즉시 지워지지 않고 복원 가능한 상태로 보관됩니다. +- 업데이트 시, 과거에 문의가 삭제되면서 홀로 남아 있던 답변 글과 어긋난 답변완료 표시를 자동으로 정리합니다. (#107 @Tuwasduliebst 님께서 제보해주셨습니다.) ### Fixed +#### 상품 문의 답변 + +- 이미 답변이 등록된 상품 문의에 답변을 다시 등록하면 답변이 중복으로 쌓이던 문제를 수정했습니다. 이제 안내와 함께 거절되며, 기존 답변을 수정하거나 삭제한 뒤 다시 등록할 수 있습니다. (#106 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 상품 문의를 삭제해도 그 문의에 달린 답변 글이 게시판에 공개 상태로 남아 있던 문제를 수정했습니다. 이제 문의를 삭제하면 질문과 답변이 함께 정리됩니다. (#107 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 상품을 삭제해도 그 상품의 문의 글과 답변 글이 게시판에 공개 상태로 남아 있던 문제를 수정했습니다. +- 게시판에서 답변 글을 직접 삭제해도 상품 문의가 「답변완료」로 남아 있던 문제를 수정했습니다. +- 게시판에서 문의 글을 직접 삭제했을 때 큐 설정에 따라 문의 목록 정리가 지연되거나 누락될 수 있던 문제를 수정했습니다. 이제 삭제 즉시 반영됩니다. +- 문의의 답변 완료 시각이 이후 작업으로 덮어써질 수 있던 문제를 수정해 최초 답변 시각이 유지됩니다. + #### 카테고리 이미지 - 카테고리를 삭제해도 그 카테고리의 이미지 파일이 서버에 그대로 남던 문제를 수정했습니다. 상품 삭제와 동일하게 파일까지 함께 삭제됩니다. @@ -49,6 +60,7 @@ #### 설정 저장 반영 +- 관리자 화면(환경설정과 그 안의 모달·카테고리 순서 변경·주문 상세 정보 저장·쿠폰 발급/상태 변경)에서 저장·실행이 실패하면 실패 사유 대신 일반 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. - 관리자 환경설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 메타 정보가 계속 노출되던 문제를 수정했습니다. 쇼핑몰 SEO 설정 저장 시 관련 캐시가 즉시 정리됩니다. - 환경설정 저장 직후 같은 요청 안에서 결제수단·통화 정보가 저장 전 값으로 계산되던 문제를 수정했습니다. (#116 @Tuwasduliebst 님께서 제보해주셨습니다.) - 배송정책을 저장·삭제하거나 기본 배송정책을 바꾼 직후, 같은 요청 안에서 변경 전 배송정책으로 배송비가 계산될 수 있던 문제를 수정했습니다. @@ -122,6 +134,10 @@ - 관리자가 상점 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.) - 주문서 작성 중 만들어지는 임시 주문 정보가 만료된 뒤에도 정리되지 않고 계속 쌓이던 문제를 수정했습니다 — 이제 매시간 자동으로 정리됩니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.) +#### 화면 표시 + +- 카테고리 관리 목록의 아이콘이 의도한 크기와 다르게 보이던 문제와, 쿠폰 상태 변경 확인 창의 버튼이 다크 모드에서 밝은 배경 그대로 보이던 문제를 수정했습니다. + ## [1.1.0] - 2026-08-10 ### Security diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_17_000001_add_soft_deletes_to_ecommerce_product_inquiries_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_17_000001_add_soft_deletes_to_ecommerce_product_inquiries_table.php new file mode 100644 index 00000000..1eef11e8 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_17_000001_add_soft_deletes_to_ecommerce_product_inquiries_table.php @@ -0,0 +1,45 @@ +softDeletes()->comment('소프트 삭제 일시'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('ecommerce_product_inquiries') + || ! Schema::hasColumn('ecommerce_product_inquiries', 'deleted_at')) { + return; + } + + Schema::table('ecommerce_product_inquiries', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/ProductInquirySeeder.php b/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/ProductInquirySeeder.php index 63f6ab67..43b56806 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/ProductInquirySeeder.php +++ b/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/ProductInquirySeeder.php @@ -163,11 +163,14 @@ class ProductInquirySeeder extends Seeder */ private function deleteExistingInquiries(Board $board): void { - $existingCount = ProductInquiry::count(); + // SoftDeletes 도입 여파: 소프트 삭제된 피벗 잔존물까지 포함해 전량 재시드한다 + $existingCount = ProductInquiry::withTrashed()->count(); if ($existingCount > 0) { // 연결된 게시판 Post도 함께 삭제 - $inquirableIds = ProductInquiry::where('inquirable_type', Post::class)->pluck('inquirable_id'); + $inquirableIds = ProductInquiry::withTrashed() + ->where('inquirable_type', Post::class) + ->pluck('inquirable_id'); if ($inquirableIds->isNotEmpty()) { Post::whereIn('id', $inquirableIds)->forceDelete(); @@ -178,7 +181,7 @@ class ProductInquirySeeder extends Seeder ->whereNotNull('parent_id') ->forceDelete(); - ProductInquiry::query()->delete(); + ProductInquiry::withTrashed()->forceDelete(); $this->command->warn("기존 문의 데이터 {$existingCount}건을 삭제했습니다."); } @@ -222,17 +225,17 @@ class ProductInquirySeeder extends Seeder // board_posts에 문의 게시글 생성 $post = Post::create([ - 'board_id' => $board->id, - 'title' => $this->inquiryTitles[$titleIndex], - 'content' => $this->inquiryContents[$titleIndex], + 'board_id' => $board->id, + 'title' => $this->inquiryTitles[$titleIndex], + 'content' => $this->inquiryContents[$titleIndex], 'content_mode' => 'text', - 'user_id' => $user?->id, - 'author_name' => $isGuest ? $this->guestNames[array_rand($this->guestNames)] : null, - 'password' => $isGuest ? Hash::make('1234') : null, - 'ip_address' => '127.0.0.1', - 'is_secret' => $isSecret, - 'status' => PostStatus::Published, - 'created_at' => now()->subDays(rand(1, 60))->subHours(rand(0, 23)), + 'user_id' => $user?->id, + 'author_name' => $isGuest ? $this->guestNames[array_rand($this->guestNames)] : null, + 'password' => $isGuest ? Hash::make('1234') : null, + 'ip_address' => '127.0.0.1', + 'is_secret' => $isSecret, + 'status' => PostStatus::Published, + 'created_at' => now()->subDays(rand(1, 60))->subHours(rand(0, 23)), ]); $hasReply = $admin && rand(1, 100) <= self::REPLY_RATE; @@ -243,28 +246,28 @@ class ProductInquirySeeder extends Seeder $answeredAt = $post->created_at->addDays(rand(0, 3)); Post::create([ - 'board_id' => $board->id, - 'title' => 'Re: '.$post->title, - 'content' => $this->replyContents[$titleIndex], + 'board_id' => $board->id, + 'title' => 'Re: '.$post->title, + 'content' => $this->replyContents[$titleIndex], 'content_mode' => 'text', - 'user_id' => $admin->id, - 'ip_address' => '127.0.0.1', - 'is_secret' => false, - 'status' => PostStatus::Published, - 'parent_id' => $post->id, - 'depth' => 1, - 'created_at' => $answeredAt, + 'user_id' => $admin->id, + 'ip_address' => '127.0.0.1', + 'is_secret' => false, + 'status' => PostStatus::Published, + 'parent_id' => $post->id, + 'depth' => 1, + 'created_at' => $answeredAt, ]); } // ecommerce_product_inquiries 피벗 생성 ProductInquiry::create([ - 'product_id' => $product->id, - 'inquirable_type' => Post::class, - 'inquirable_id' => $post->id, - 'user_id' => $user?->id, - 'is_answered' => $hasReply, - 'answered_at' => $answeredAt, + 'product_id' => $product->id, + 'inquirable_type' => Post::class, + 'inquirable_id' => $post->id, + 'user_id' => $user?->id, + 'is_answered' => $hasReply, + 'answered_at' => $answeredAt, 'product_name_snapshot' => $product->name ?? [], ]); diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md index bd8d27cb..8641a124 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md @@ -70,7 +70,7 @@ _단건 응답: `data` 객체의 필드._ -**설명** 관리자가 상품 1:1 문의 1건을 삭제합니다. `sirsoft-ecommerce.inquiries.delete` 권한이 필요하며, `ProductInquiryService::deleteInquiry()` 가 해당 문의를 제거하고 `{deleted: true}` 를 반환합니다. 문의가 존재하지 않는 등 삭제 불가 상황에서는 서비스가 `ProductInquiryOperationException` 을 던지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 관리자 문의 관리 화면에서 부적절하거나 중복된 문의를 정리할 때 사용합니다. +**설명** 관리자가 상품 1:1 문의 1건을 삭제합니다. `sirsoft-ecommerce.inquiries.delete` 권한이 필요하며, `ProductInquiryService::deleteInquiry()` 가 게시판 훅으로 질문 게시글을 소프트 삭제한 뒤 문의 피벗(`product_inquiries`)도 **소프트 삭제**하고 `{deleted: true}` 를 반환합니다. 피벗이 소프트 삭제로 유지되므로, 이후 게시판 관리 화면에서 질문 게시글을 복원하면 문의 피벗도 함께 복원되어 문의 목록에 다시 나타납니다. 문의가 존재하지 않는 등 삭제 불가 상황에서는 서비스가 `ProductInquiryOperationException` 을 던지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 관리자 문의 관리 화면에서 부적절하거나 중복된 문의를 정리할 때 사용합니다. ### DELETE /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply @@ -126,7 +126,7 @@ _단건 응답: `data` 객체의 필드._ -**설명** 관리자가 문의에 등록된 답변을 삭제합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::deleteReply()` 가 답변을 제거하고 문의를 미답변 상태로 되돌린 뒤 `{deleted: true}` 를 반환합니다. 문의 자체는 유지되며, 잘못 등록한 답변을 회수할 때 사용합니다. +**설명** 관리자가 문의에 등록된 답변을 삭제합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::deleteReply()` 가 답변을 제거한 뒤 필터 훅 `sirsoft-ecommerce.inquiry.count_replies`(carry: int, 초기값 `0` / 인자: 부모 문의 Post ID / 반환: 살아있는 답변 수)로 잔여 답변 수를 재확인하여, **살아있는 답변이 0건일 때만** 문의를 미답변 상태(`is_answered=false`)로 되돌리고 `{deleted: true}` 를 반환합니다 — 과거 결함으로 답변이 여러 건 쌓인 문의에서 일부만 삭제해도 답변완료 표기가 유지됩니다. 문의 자체는 유지되며, 잘못 등록한 답변을 회수할 때 사용합니다. ### POST /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply @@ -185,12 +185,21 @@ _단건 응답: `data` 객체의 필드._ | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.` — `board_not_configured`) / 이미 답변이 등록된 문의에 재등록을 시도한 경우(`이미 등록된 답변이 있습니다. 기존 답변을 수정하거나 삭제한 후 다시 등록해주세요.` — `reply_already_exists`) / 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` | | 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) | +**오류 응답 예시** (이미 답변된 문의에 재등록 시 — 422) + +```json +{ + "success": false, + "message": "이미 등록된 답변이 있습니다. 기존 답변을 수정하거나 삭제한 후 다시 등록해주세요." +} +``` + -**설명** 관리자가 상품 문의에 답변을 등록합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료 상태로 전환한 뒤 `{id, is_answered}` 를 201 로 반환합니다. 이미 답변이 있는 등 등록 불가 상황에서는 `ProductInquiryOperationException` 이 던져지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 관리자가 고객 문의에 응대할 때 사용합니다. +**설명** 관리자가 상품 문의에 답변을 등록합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료 상태로 전환한 뒤 `{id, is_answered}` 를 201 로 반환합니다. 문의당 답변은 1건만 유지되는 단일 답변 정책이라, 이미 답변완료(`is_answered=true`)인 문의에 재등록을 시도하면 `reply_already_exists` 사유의 422 로 거절됩니다 — 기존 답변을 수정(PUT)하거나 삭제(DELETE) 후 다시 등록해야 합니다. 문의 게시판이 미설정인 경우에도 `board_not_configured` 사유의 422 가 반환됩니다. 관리자가 고객 문의에 응대할 때 사용합니다. ### PUT /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply @@ -458,7 +467,7 @@ _단건 응답: `data` 객체의 필드._ -**설명** 로그인 회원이 자신이 작성한 문의를 삭제합니다. `auth:sanctum` 인증이 필요하며, 컨트롤러가 문의를 조회해 없으면 404, `inquiry->user_id` 가 로그인 사용자와 다르면 403 을 반환한 뒤 `ProductInquiryService::deleteInquiry()` 로 삭제하고 `{deleted: true}` 를 반환합니다. 마이페이지에서 본인 문의를 취소/삭제할 때 사용합니다. +**설명** 로그인 회원이 자신이 작성한 문의를 삭제합니다. `auth:sanctum` 인증이 필요하며, 컨트롤러가 문의를 조회해 없으면 404, `inquiry->user_id` 가 로그인 사용자와 다르면 403 을 반환한 뒤 `ProductInquiryService::deleteInquiry()` 로 삭제하고 `{deleted: true}` 를 반환합니다. 관리자 삭제 경로와 마찬가지로 문의 피벗은 **소프트 삭제**되며, 게시판에서 질문 게시글이 복원되면 문의도 함께 복원됩니다. 마이페이지에서 본인 문의를 취소/삭제할 때 사용합니다. ### PUT /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId} @@ -584,7 +593,7 @@ _단건 응답: `data` 객체의 필드._ -**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의 답변을 삭제합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::deleteReply()` 로 답변을 제거하고 `{deleted: true}` 를 반환합니다. 답변 권한을 위임받은 사용자(예: 상담원 역할)가 사용자 화면에서 답변을 회수할 때 사용합니다. +**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의 답변을 삭제합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::deleteReply()` 로 답변을 제거하고 `{deleted: true}` 를 반환합니다. 관리자 경로와 동일하게 필터 훅 `sirsoft-ecommerce.inquiry.count_replies`(carry: int, 초기값 `0` / 인자: 부모 문의 Post ID / 반환: 살아있는 답변 수)로 잔여 답변 수를 재확인해 0건일 때만 미답변 상태로 되돌립니다. 답변 권한을 위임받은 사용자(예: 상담원 역할)가 사용자 화면에서 답변을 회수할 때 사용합니다. ### POST /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply @@ -643,12 +652,12 @@ _단건 응답: `data` 객체의 필드._ | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.` — `board_not_configured`) / 이미 답변이 등록된 문의에 재등록을 시도한 경우(`이미 등록된 답변이 있습니다. 기존 답변을 수정하거나 삭제한 후 다시 등록해주세요.` — `reply_already_exists`) / 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` | | 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) | -**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의에 답변을 등록합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료로 전환해 `{id, is_answered}` 를 201 로 반환합니다. 관리자 화면이 아닌 사용자 프론트에서 답변을 처리하는 상담원 역할에 사용합니다. +**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의에 답변을 등록합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료로 전환해 `{id, is_answered}` 를 201 로 반환합니다. 관리자 경로와 동일한 단일 답변 정책이 적용되어, 이미 답변완료인 문의에 재등록하면 `reply_already_exists` 사유의 422, 문의 게시판 미설정이면 `board_not_configured` 사유의 422 로 거절됩니다. 관리자 화면이 아닌 사용자 프론트에서 답변을 처리하는 상담원 역할에 사용합니다. ### PUT /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsSavePerTab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsSavePerTab.test.tsx index d08a103a..ac343f75 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsSavePerTab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsSavePerTab.test.tsx @@ -93,6 +93,20 @@ describe('환경설정 탭별 개별 저장 패턴 검증', () => { expect(body).toContain("'basic_info'"); }); + it('저장 실패 토스트가 서버 오류 메시지를 노출해야 한다 (회귀 — PO 실측 제보)', () => { + // 고정 문구만 쓰면 검증 실패 사유(예: 필드별 422 메시지)가 사용자에게 + // 도달하지 못하고 "설정 저장에 실패했습니다" 로 뭉개진다. + const apiCallAction = findActionByHandler(saveButton.actions, 'apiCall'); + const errorToast = (apiCallAction.onError ?? []).find( + (a: any) => a.handler === 'toast', + ); + + expect(errorToast).toBeDefined(); + expect(errorToast.params.message).toContain('error.message'); + // 서버 메시지 부재 시 기존 고정 문구로 폴백 + expect(errorToast.params.message).toContain('save_error'); + }); + it('body 에 _tab 메타 필드가 포함되어야 한다', () => { const apiCallAction = findActionByHandler(saveButton.actions, 'apiCall'); const body = apiCallAction.params.body; diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json index 1617bdec..3a01b657 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json @@ -590,7 +590,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-ecommerce.admin.settings.save_error" + "message": "{{error.message ?? $t('sirsoft-ecommerce.admin.settings.save_error')}}" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_category_list.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_category_list.json index c81e35c9..9f553715 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_category_list.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_category_list.json @@ -30,7 +30,7 @@ "name": "Icon", "props": { "name": "folder", - "className": "w-5 h-5 text-gray-700 dark:text-gray-300" + "className": "text-xl text-gray-700 dark:text-gray-300" } }, { @@ -139,7 +139,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-ecommerce.admin.category.messages.order_update_failed" + "message": "{{error.message ?? '$t:sirsoft-ecommerce.admin.category.messages.order_update_failed'}}" } }, { @@ -220,7 +220,7 @@ "name": "Icon", "props": { "name": "folder", - "className": "w-5 h-5 text-gray-400 dark:text-gray-500" + "className": "text-xl text-gray-400 dark:text-gray-500" } }, { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json index cef236d3..d77c3af4 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json @@ -2284,7 +2284,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-ecommerce.admin.order.detail.order_info.save_error" + "message": "{{error.message ?? '$t:sirsoft-ecommerce.admin.order.detail.order_info.save_error'}}" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_direct_issue.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_direct_issue.json index 6ae1e8ea..4e35b4d5 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_direct_issue.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_direct_issue.json @@ -76,7 +76,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-ecommerce.admin.promotion_coupon.modal.direct_issue.search_failed" + "message": "{{error.message ?? '$t:sirsoft-ecommerce.admin.promotion_coupon.modal.direct_issue.search_failed'}}" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_status_change_confirm.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_status_change_confirm.json index e684af54..8c2b5e4a 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_status_change_confirm.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_status_change_confirm.json @@ -61,7 +61,7 @@ "type": "basic", "name": "Button", "props": { - "className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", + "className": "flex-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed", "disabled": "{{_global.isChangingStatus}}" }, "actions": [ @@ -126,7 +126,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-ecommerce.common.error.general" + "message": "{{error.message ?? '$t:sirsoft-ecommerce.common.error.general'}}" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json index 0b2bd420..a7a20edd 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json @@ -460,7 +460,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-ecommerce.admin.settings.save_error" + "message": "{{error.message ?? $t('sirsoft-ecommerce.admin.settings.save_error')}}" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_clear_seo_cache.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_clear_seo_cache.json index 58a95550..1c0ec368 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_clear_seo_cache.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_clear_seo_cache.json @@ -172,7 +172,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-ecommerce.admin.settings.seo.seo_friendly.clear_cache_failed" + "message": "{{error.message ?? $t('sirsoft-ecommerce.admin.settings.seo.seo_friendly.clear_cache_failed')}}" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_mail_template_edit.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_mail_template_edit.json index aed2666e..cab4f6a5 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_mail_template_edit.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_mail_template_edit.json @@ -626,7 +626,7 @@ { "handler": "toast", "params": { - "message": "$t:sirsoft-ecommerce.admin.settings.mail_templates.save_error", + "message": "{{error.message ?? $t('sirsoft-ecommerce.admin.settings.mail_templates.save_error')}}", "type": "error" } } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json index c8e84953..bab7fc2f 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json @@ -969,7 +969,7 @@ { "handler": "toast", "params": { - "message": "$t:sirsoft-ecommerce.admin.settings.notification_definitions.save_error", + "message": "{{error.message ?? $t('sirsoft-ecommerce.admin.settings.notification_definitions.save_error')}}", "type": "error" } } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json index f82bfecc..83439a80 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json @@ -571,7 +571,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.identity.policies.toggle_failed" + "message": "{{error.message ?? $t('admin.identity.policies.toggle_failed')}}" } }, { @@ -908,7 +908,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.identity.policies.toggle_failed" + "message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}" } }, { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/ProductInquiryBoardListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/ProductInquiryBoardListener.php index 8db9c3f5..c75aa60b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/ProductInquiryBoardListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/ProductInquiryBoardListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\HookListenerInterface; use App\Extension\HookManager; +use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductInquiryRepositoryInterface; use Modules\Sirsoft\Ecommerce\Services\ProductInquiryService; @@ -11,8 +12,10 @@ use Modules\Sirsoft\Ecommerce\Services\ProductInquiryService; /** * 게시판 이벤트 훅 리스너 (이커머스 문의 연동) * - * 게시판 Post 생성/삭제 이벤트를 수신하여 문의 피벗 데이터를 관리합니다. - * - sirsoft-board.post.after_delete → 게시판 Post 삭제 시 피벗 삭제 + * 게시판 Post 삭제/복원 이벤트를 수신하여 문의 피벗 데이터를 관리합니다. + * - sirsoft-board.post.after_delete → 질문 글 삭제 시 피벗 소프트 삭제, 답변 글 삭제 시 답변완료 재계산 + * - sirsoft-board.post.after_restore → 질문 글 복원 시 피벗 복원, 답변 글 복원 시 답변완료 재마킹 + * - sirsoft-board.board.posts.before_force_delete → 게시판 삭제 시 삭제될 글의 피벗 일괄 정리 * - sirsoft-ecommerce.inquiry.store_validation_rules → 게시판 설정 기반 동적 검증 규칙 주입 * - sirsoft-ecommerce.inquiry.update_validation_rules → 게시판 설정 기반 동적 검증 규칙 주입 */ @@ -37,10 +40,25 @@ class ProductInquiryBoardListener implements HookListenerInterface public static function getSubscribedHooks(): array { return [ - // 게시판 Post 삭제 후 피벗 삭제 (Action 훅) + // 게시판 Post 삭제 후 피벗 정리 (Action 훅) + // sync: 큐 워커 미가동 환경에서 피벗 정리가 누락되지 않도록 동기 실행 (N3) 'sirsoft-board.post.after_delete' => [ 'method' => 'handlePostDeleted', 'priority' => 20, + 'sync' => true, + ], + // 게시판 Post 복원 후 피벗 복원/재마킹 (Action 훅) + 'sirsoft-board.post.after_restore' => [ + 'method' => 'handlePostRestored', + 'priority' => 20, + 'sync' => true, + ], + // 게시판 삭제 시 삭제될 글 ID 벌크 통지 → 피벗 정리 (Action 훅, board 1.0.4+) + // 트랜잭션 내부에서 chunk 당 1회 다회 발화 — 멱등 처리 의무 + 'sirsoft-board.board.posts.before_force_delete' => [ + 'method' => 'handleBoardPostsForceDeleting', + 'priority' => 20, + 'sync' => true, ], // 문의 작성 폼 검증 규칙에 게시판 설정 기반 동적 규칙 주입 (Filter 훅) 'sirsoft-ecommerce.inquiry.store_validation_rules' => [ @@ -60,7 +78,7 @@ class ProductInquiryBoardListener implements HookListenerInterface /** * 기본 훅 핸들러 (HookListenerInterface 필수 메서드) * - * @param mixed ...$args 훅 인자 + * @param mixed ...$args 훅 인자 * @return void */ public function handle(...$args): void @@ -69,11 +87,15 @@ class ProductInquiryBoardListener implements HookListenerInterface } /** - * 게시판 Post 삭제 후 문의 피벗 삭제 + * 게시판 Post 삭제 후 문의 피벗 정리 * - * 게시판에서 Post가 삭제될 때 연결된 문의 피벗 데이터를 정리합니다. - * 이커머스 경로에서는 ProductInquiryService::deleteInquiry()가 피벗을 직접 삭제하므로 - * 이 리스너가 실행되더라도 이미 삭제된 피벗이면 deleteByInquirableIds가 0건 처리합니다. + * 질문 글 삭제: 연결된 문의 피벗을 소프트 삭제합니다. 이커머스 경로에서는 + * ProductInquiryService::deleteInquiry()가 피벗을 직접 삭제하므로 이 리스너가 + * 실행되더라도 이미 삭제된 피벗이면 deleteByInquirableIds가 0건 처리합니다(멱등). + * + * 답변 글 삭제(N4): 게시판 관리자가 답변 글을 직접 지운 경우 — 부모(질문) 피벗의 + * `is_answered` 를 잔여 답변 수 기준으로 재계산합니다. 종전에는 이 분기가 없어 + * "답변완료" 인데 답변이 없는 상태가 남았습니다. * * @param object $post 삭제된 Post 객체 * @param string $slug 게시판 슬러그 @@ -87,6 +109,31 @@ class ProductInquiryBoardListener implements HookListenerInterface } try { + // 답변 글(자식 Post) 분기: 부모 문의의 답변완료 상태 재계산 + if (! empty($post->parent_id)) { + $pivot = $this->repository->findByInquirable(get_class($post), (int) $post->parent_id); + + if ($pivot && $pivot->is_answered) { + $remaining = (int) HookManager::applyFilters( + 'sirsoft-ecommerce.inquiry.count_replies', + 0, + (int) $post->parent_id + ); + + if ($remaining === 0) { + $this->repository->unmarkAnswered($pivot); + + Log::debug('ProductInquiryBoardListener: 답변 글 삭제 → 답변완료 해제', [ + 'reply_post_id' => $post->id, + 'parent_post_id' => $post->parent_id, + ]); + } + } + + return; + } + + // 질문 글 분기: 피벗 소프트 삭제 (SoftDeletes 도입으로 복원 대칭 확보) $this->repository->deleteByInquirableIds( get_class($post), [$post->id] @@ -96,13 +143,104 @@ class ProductInquiryBoardListener implements HookListenerInterface 'post_id' => $post->id, ]); } catch (\Exception $e) { - Log::error('ProductInquiryBoardListener: 문의 피벗 삭제 실패', [ + Log::error('ProductInquiryBoardListener: 문의 피벗 정리 실패', [ 'post_id' => $post->id ?? null, 'error' => $e->getMessage(), ]); } } + /** + * 게시판 Post 복원 후 문의 피벗 복원/재마킹 + * + * 질문 글 복원: 소프트 삭제됐던 피벗을 복원해 문의 목록에 다시 나타나게 합니다. + * 답변 글 복원: 부모 문의 피벗을 답변완료로 재마킹합니다 (answered_at 은 + * markAsAnswered 가 기존 값을 보존). + * + * @param object $post 복원된 Post 객체 + * @param string $slug 게시판 슬러그 + * @return void + */ + public function handlePostRestored(?object $post, string $slug): void + { + if ($post === null) { + return; + } + + try { + // 답변 글 복원: 부모 문의를 답변완료로 재마킹 + if (! empty($post->parent_id)) { + $pivot = $this->repository->findByInquirable(get_class($post), (int) $post->parent_id); + + if ($pivot && ! $pivot->is_answered) { + $this->repository->markAsAnswered($pivot); + + Log::debug('ProductInquiryBoardListener: 답변 글 복원 → 답변완료 재마킹', [ + 'reply_post_id' => $post->id, + 'parent_post_id' => $post->parent_id, + ]); + } + + return; + } + + // 질문 글 복원: 피벗 복원 + $restored = $this->repository->restoreByInquirable(get_class($post), (int) $post->id); + + if ($restored > 0) { + Log::debug('ProductInquiryBoardListener: 문의 피벗 복원 완료', [ + 'post_id' => $post->id, + 'restored' => $restored, + ]); + } + } catch (\Exception $e) { + Log::error('ProductInquiryBoardListener: 문의 피벗 복원 실패', [ + 'post_id' => $post->id ?? null, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 게시판 삭제 시 삭제될 글 ID 벌크 수신 → 문의 피벗 소프트 삭제 (N2) + * + * 게시판 삭제는 글별 after_delete 훅 없이 하드 삭제되므로, 이 벌크 훅이 없으면 + * 피벗이 존재하지 않는 Post 를 가리킨 채 잔존합니다(역방향 고아). chunk 당 1회 + * 다회 발화되며 이미 삭제된 피벗은 0건 처리라 멱등합니다. + * + * inquirable_type 은 게시판 Post FQCN 리터럴을 사용합니다 — 이커머스는 board + * 모듈 클래스에 컴파일 타임 의존하지 않습니다(훅 전용 연동). + * + * @param object $board 삭제 중인 게시판 모델 + * @param array $postIds 삭제될 Post ID 배열 (withTrashed 포함) + * @return void + */ + public function handleBoardPostsForceDeleting(?object $board, array $postIds = []): void + { + if ($postIds === []) { + return; + } + + try { + $deleted = $this->repository->deleteByInquirableIds( + 'Modules\\Sirsoft\\Board\\Models\\Post', + $postIds + ); + + if ($deleted > 0) { + Log::debug('ProductInquiryBoardListener: 게시판 삭제 → 문의 피벗 정리', [ + 'board_id' => $board->id ?? null, + 'deleted' => $deleted, + ]); + } + } catch (\Exception $e) { + Log::error('ProductInquiryBoardListener: 게시판 삭제 피벗 정리 실패', [ + 'board_id' => $board->id ?? null, + 'error' => $e->getMessage(), + ]); + } + } + /** * 게시판 설정 기반 동적 검증 규칙 주입 * @@ -110,7 +248,7 @@ class ProductInquiryBoardListener implements HookListenerInterface * StoreInquiryRequest의 검증 규칙에 동적으로 주입합니다. * * @param array $rules 기존 검증 규칙 - * @param \Illuminate\Foundation\Http\FormRequest $request 요청 객체 + * @param FormRequest $request 요청 객체 * @return array 게시판 설정이 반영된 검증 규칙 */ public function injectBoardValidationRules(array $rules, $request): array @@ -158,5 +296,4 @@ class ProductInquiryBoardListener implements HookListenerInterface return $rules; } - } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/ProductInquiry.php b/modules/_bundled/sirsoft-ecommerce/src/Models/ProductInquiry.php index e6fe459f..26146ee3 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/ProductInquiry.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/ProductInquiry.php @@ -3,28 +3,36 @@ namespace Modules\Sirsoft\Ecommerce\Models; use App\Models\User; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\SoftDeletes; +use Modules\Sirsoft\Ecommerce\Database\Factories\ProductInquiryFactory; /** * 상품 1:1 문의 피벗 모델 * * 이커머스 상품과 외부 컨텐츠(게시판 게시글 등)를 다형성 관계로 연결합니다. + * + * SoftDeletes: 게시판에서 질문 글을 삭제→복원하는 경로에서 피벗도 함께 + * 복원할 수 있어야 한다 — 하드 삭제는 복원 대칭이 불가능했다(#107 후속). + * 상품 자체가 forceDelete 되는 경로만 피벗도 forceDelete 한다. */ class ProductInquiry extends Model { use HasFactory; + use SoftDeletes; /** * Factory 클래스 경로 지정 * * @return string */ - protected static function newFactory(): \Illuminate\Database\Eloquent\Factories\Factory + protected static function newFactory(): Factory { - return \Modules\Sirsoft\Ecommerce\Database\Factories\ProductInquiryFactory::new(); + return ProductInquiryFactory::new(); } protected $table = 'ecommerce_product_inquiries'; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductInquiryRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductInquiryRepositoryInterface.php index bfcf6cdb..087d88cf 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductInquiryRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductInquiryRepositoryInterface.php @@ -106,6 +106,37 @@ interface ProductInquiryRepositoryInterface */ public function deleteByInquirableIds(string $inquirableType, array $inquirableIds): int; + /** + * 상품 ID 로 피벗 전건 조회 (소프트 삭제 포함) + * + * 상품 삭제 시 문의 스레드 정리 경로에서 사용합니다. + * + * @param int $productId 상품 ID + * @return Collection 피벗 컬렉션 + */ + public function findByProductIdWithTrashed(int $productId): Collection; + + /** + * 상품 ID 로 피벗 전건 영구 삭제 (소프트 삭제 포함) + * + * 상품이 forceDelete 되는 경로 전용 — 그 외 삭제는 소프트 삭제를 유지합니다. + * + * @param int $productId 상품 ID + * @return int 삭제된 피벗 수 + */ + public function forceDeleteByProductId(int $productId): int; + + /** + * 대상(inquirable) 기준으로 소프트 삭제된 피벗을 복원합니다. + * + * 게시판에서 질문 글이 삭제→복원될 때 피벗을 함께 되살리는 경로입니다. + * + * @param string $inquirableType 대상 모델 클래스명 + * @param int $inquirableId 대상 ID + * @return int 복원된 피벗 수 + */ + public function restoreByInquirable(string $inquirableType, int $inquirableId): int; + /** * 전체 상품의 최신 미답변 문의를 조회합니다 (대시보드 미답변 문의). * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php index f1e3ad82..443a576d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php @@ -168,7 +168,8 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface { $inquiry->update([ 'is_answered' => true, - 'answered_at' => now(), + // 최초 답변 시각 보존 — 답변 수정/재마킹이 시각을 덮어쓰면 안 된다 (선례: ProductReviewService) + 'answered_at' => $inquiry->answered_at ?? now(), ]); return $inquiry->fresh(); @@ -206,6 +207,41 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface ->delete(); } + /** + * {@inheritDoc} + */ + public function findByProductIdWithTrashed(int $productId): Collection + { + // audit:allow query-unbounded-get reason: 상품 삭제 정리 경로 — 단일 상품에 종속된 피벗만 조회 + return $this->model->newQuery() + ->withTrashed() + ->where('product_id', $productId) + ->get(); + } + + /** + * {@inheritDoc} + */ + public function forceDeleteByProductId(int $productId): int + { + return $this->model->newQuery() + ->withTrashed() + ->where('product_id', $productId) + ->forceDelete(); + } + + /** + * {@inheritDoc} + */ + public function restoreByInquirable(string $inquirableType, int $inquirableId): int + { + return $this->model->newQuery() + ->onlyTrashed() + ->where('inquirable_type', $inquirableType) + ->where('inquirable_id', $inquirableId) + ->restore(); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php index acf08e81..a4bd2c86 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php @@ -563,11 +563,22 @@ class ProductInquiryService $inquiry->inquirable_id ); - // ② 피벗 is_answered=false 업데이트 - $this->repository->unmarkAnswered($inquiry); + // ② 잔여 답변이 없을 때만 피벗 is_answered=false 업데이트. + // 기설치본에는 과거 결함(#106)으로 답변이 여러 건 쌓인 문의가 있을 수 있다 — + // 첫 답변만 지웠는데 무조건 해제하면 "답변이 남았는데 미답변" 표기가 된다. + $remaining = (int) HookManager::applyFilters( + 'sirsoft-ecommerce.inquiry.count_replies', + 0, + $inquiry->inquirable_id + ); + + if ($remaining === 0) { + $this->repository->unmarkAnswered($inquiry); + } Log::info('상품 문의 답변 삭제 완료', [ 'inquiry_id' => $inquiryId, + 'remaining_replies' => $remaining, ]); } @@ -582,6 +593,54 @@ class ProductInquiryService return $this->repository->findById($inquiryId); } + /** + * 상품 삭제 시 그 상품의 문의 스레드(질문+답변 Post)와 피벗을 정리합니다. + * + * 상품이 forceDelete 되면 피벗은 FK 캐스케이드로 하드 소멸하지만, 게시판의 + * 질문·답변 Post 는 published 로 잔존했다(#107 확대판). 애플리케이션이 먼저 + * 훅으로 Post 를 소프트 삭제(cascade_replies 로 답변 포함)한 뒤 피벗을 + * forceDelete 한다 — FK 캐스케이드는 백스톱으로만 유지. + * + * 문의 게시판이 미설정이면 Post 정리는 건너뛰고 피벗만 정리한다(안전 열화). + * 개별 훅 실패는 warning 후 계속 진행한다 — 한 건의 실패가 상품 삭제 전체를 + * 막으면 안 되고, 남은 고아는 업그레이드 스텝 백필이 재정리한다. + * + * @param int $productId 상품 ID + * @return int 정리된 피벗 수 + */ + public function deleteInquiriesForProduct(int $productId): int + { + $boardSlug = $this->getInquiryBoardSlug(); + + if ($boardSlug) { + // withTrashed 포함 — 소프트 삭제된 문의의 Post 잔존물도 함께 정리 + $inquiries = $this->repository->findByProductIdWithTrashed($productId); + + foreach ($inquiries as $inquiry) { + try { + HookManager::applyFilters( + 'sirsoft-ecommerce.inquiry.delete', + null, + $boardSlug, + $inquiry->inquirable_id + ); + } catch (\Exception $e) { + Log::warning('상품 삭제 시 문의 Post 정리 실패 — 계속 진행', [ + 'product_id' => $productId, + 'inquiry_id' => $inquiry->id, + 'error' => $e->getMessage(), + ]); + } + } + } else { + Log::info('문의 게시판 미설정 — 상품 삭제 시 문의 Post 정리 생략', [ + 'product_id' => $productId, + ]); + } + + return $this->repository->forceDeleteByProductId($productId); + } + /** * 작성자 이름 마스킹 처리 * @@ -650,6 +709,18 @@ class ProductInquiryService $boardSlug = $this->getInquiryBoardSlug(); + // update/delete 경로와 동일한 미설정 가드 — 없으면 훅 무응답이 reply_failed 로 위장된다 + if (! $boardSlug) { + throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_not_configured'); + } + + // 1차 방어(피벗 플래그): 이미 답변된 문의에는 재등록 거부 — 단일 답변 정책. + // UI 는 `!item.reply` 게이팅으로 이미 차단하므로, 이 가드는 API 직접 호출/동시 + // 클릭 경로를 막는다. 게시판 실데이터 기준 2차 방어는 리스너(createAndReturn)가 담당. + if ($inquiry->is_answered) { + throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.reply_already_exists'); + } + // 클라이언트 IP 를 요청 경계(Service)에서 캡처해 게시판 훅 payload 로 전달한다. if (empty($data['ip_address'])) { $data['ip_address'] = request()->ip() ?? '0.0.0.0'; @@ -667,6 +738,13 @@ class ProductInquiryService $replyData ); + // 2차 방어(리스너, 게시판 실데이터)가 중복을 감지하면 중복 마커를 돌려준다. + // 경합 경로(피벗 is_answered 가 아직 false)에서도 사유를 보존해 422 로 안내 + // — null 과 합치면 "답변 등록에 실패했습니다" 로 위장된다 (PO 실측 제보). + if (is_array($postResult) && ! empty($postResult['duplicate'])) { + throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.reply_already_exists'); + } + if (! $postResult || empty($postResult['post_id'])) { throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.reply_failed'); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php index 3ce90222..981b9ba1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php @@ -70,7 +70,8 @@ class ProductService protected SequenceService $sequenceService, protected OrderOptionRepositoryInterface $orderOptionRepository, protected ProductLabelRepositoryInterface $productLabelRepository, - protected ProductAdditionalOptionValueRepositoryInterface $additionalOptionValueRepository + protected ProductAdditionalOptionValueRepositoryInterface $additionalOptionValueRepository, + protected ProductInquiryService $inquiryService ) {} /** @@ -422,6 +423,11 @@ class ProductService // 삭제 전 훅 HookManager::doAction('sirsoft-ecommerce.product.before_delete', $product); + // 상품 문의 스레드 정리 — 트랜잭션 진입 전 실행 (게시판 훅이 자체 트랜잭션과 + // after_delete Action 훅을 내부에서 발행하므로 바깥 트랜잭션에 묶지 않는다). + // 종전에는 피벗이 FK 캐스케이드로만 소멸해 질문·답변 Post 가 공개 상태로 잔존했다. + $this->inquiryService->deleteInquiriesForProduct($product->id); + return DB::transaction(function () use ($product) { // 1. 이미지 파일 물리적 삭제 (Storage) $this->deleteProductImageFiles($product); @@ -458,9 +464,6 @@ class ProductService // TODO: 리뷰 삭제 (테이블: ecommerce_reviews) // Review::where('product_id', $product->id)->delete(); - // TODO: 상품문의 삭제 (테이블: ecommerce_product_inquiries) - // ProductInquiry::where('product_id', $product->id)->delete(); - // 8. 상품 레코드 완전 삭제 (SoftDeletes 무시) // 모든 연관 데이터가 완전 삭제되었으므로 상품도 완전 삭제합니다. $result = $this->repository->forceDelete($product); diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php index ede924d3..fa28d939 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php @@ -1217,6 +1217,7 @@ return [ 'board_changed' => 'The board configuration has changed and the post could not be found. Please contact the administrator.', 'reply_created' => 'Reply has been submitted.', 'reply_failed' => 'Failed to submit reply.', + 'reply_already_exists' => 'A reply has already been registered. Please edit or delete the existing reply before submitting a new one.', 'reply_updated' => 'Reply has been updated.', 'reply_update_failed' => 'Failed to update reply.', 'reply_deleted' => 'Reply has been deleted.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php index 7a7612bd..5176808e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php @@ -1206,6 +1206,7 @@ return [ 'board_changed' => '게시판 설정이 변경되어 해당 게시글을 찾을 수 없습니다. 관리자에게 문의하세요.', 'reply_created' => '답변이 등록되었습니다.', 'reply_failed' => '답변 등록에 실패했습니다.', + 'reply_already_exists' => '이미 등록된 답변이 있습니다. 기존 답변을 수정하거나 삭제한 후 다시 등록해주세요.', 'reply_updated' => '답변이 수정되었습니다.', 'reply_update_failed' => '답변 수정에 실패했습니다.', 'reply_deleted' => '답변이 삭제되었습니다.', diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/AdminProductInquiryControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/AdminProductInquiryControllerTest.php index f3f1438a..1be374fc 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/AdminProductInquiryControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/AdminProductInquiryControllerTest.php @@ -53,6 +53,7 @@ class AdminProductInquiryControllerTest extends ModuleTestCase 'sirsoft-ecommerce.inquiry.update_reply', 'sirsoft-ecommerce.inquiry.delete_reply', 'sirsoft-ecommerce.inquiry.create', + 'sirsoft-ecommerce.inquiry.count_replies', 'sirsoft-ecommerce.inquiry.get_settings', 'sirsoft-ecommerce.inquiry.store_validation_rules', 'sirsoft-ecommerce.inquiry.update_validation_rules', @@ -85,9 +86,16 @@ class AdminProductInquiryControllerTest extends ModuleTestCase priority: 1 ); + // 잔여 답변 수 훅 모킹 — 기본값 0 (답변 삭제 시 답변완료 해제 경로) + HookManager::addFilter( + 'sirsoft-ecommerce.inquiry.count_replies', + fn () => 0, + priority: 1 + ); + $this->inquiry = ProductInquiry::factory()->create([ - 'user_id' => $this->inquiryUser->id, - 'product_id' => $this->product->id, + 'user_id' => $this->inquiryUser->id, + 'product_id' => $this->product->id, 'is_answered' => false, ]); } @@ -111,7 +119,8 @@ class AdminProductInquiryControllerTest extends ModuleTestCase ->deleteJson("{$this->apiBase}/{$this->inquiry->id}"); $response->assertOk(); - $this->assertDatabaseMissing('ecommerce_product_inquiries', ['id' => $this->inquiry->id]); + // SoftDeletes 도입(#107 후속) — 게시판 복원 경로와의 대칭을 위해 피벗은 소프트 삭제된다 + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $this->inquiry->id]); } // ======================================== @@ -140,7 +149,7 @@ class AdminProductInquiryControllerTest extends ModuleTestCase $response->assertStatus(201); $this->assertDatabaseHas('ecommerce_product_inquiries', [ - 'id' => $this->inquiry->id, + 'id' => $this->inquiry->id, 'is_answered' => true, ]); } @@ -157,6 +166,75 @@ class AdminProductInquiryControllerTest extends ModuleTestCase $response->assertStatus(422); } + /** + * 단일 답변 정책 — 이미 답변완료인 문의에는 재등록이 거부되고, 거부 시 + * 게시판 create 훅은 호출 자체가 없어야 한다 (거부됐는데 Post 만 생기는 + * 고아 답변 방지). + */ + #[Test] + public function 이미_답변된_문의에_답변_등록_시_422를_반환한다(): void + { + $answeredInquiry = ProductInquiry::factory()->answered()->create([ + 'product_id' => $this->product->id, + ]); + + // setUp 의 기본 모킹을 걷어내고 호출 횟수를 계수하는 훅으로 교체 + HookManager::clearFilter('sirsoft-ecommerce.inquiry.create'); + $createCalls = 0; + HookManager::addFilter( + 'sirsoft-ecommerce.inquiry.create', + function () use (&$createCalls) { + $createCalls++; + + return ['post_id' => 999, 'inquirable_type' => 'Modules\\Sirsoft\\Board\\Models\\Post']; + }, + priority: 1 + ); + + $response = $this->actingAs($this->adminUser) + ->postJson( + "{$this->apiBase}/{$answeredInquiry->id}/reply", + ['content' => '중복 답변 등록 시도 내용입니다.'] + ); + + $response->assertStatus(422); + $this->assertSame( + __('sirsoft-ecommerce::messages.inquiries.reply_already_exists'), + $response->json('message') + ); + $this->assertSame(0, $createCalls, '재등록 거부 시 게시판 create 훅은 호출되지 않아야 합니다.'); + $this->assertDatabaseHas('ecommerce_product_inquiries', [ + 'id' => $answeredInquiry->id, + 'is_answered' => true, + ]); + } + + /** + * 게시판 미설정 가드 — update/delete 경로와 동일한 board_not_configured 422. + * 없으면 훅 무응답이 reply_failed(500)로 위장된다. + */ + #[Test] + public function 문의_게시판_미설정_시_답변_등록은_422를_반환한다(): void + { + app(EcommerceSettingsService::class)->setSetting('inquiry.board_slug', null); + + $response = $this->actingAs($this->adminUser) + ->postJson( + "{$this->apiBase}/{$this->inquiry->id}/reply", + ['content' => '게시판 미설정 상태의 답변 등록 시도입니다.'] + ); + + $response->assertStatus(422); + $this->assertSame( + __('sirsoft-ecommerce::messages.inquiries.board_not_configured'), + $response->json('message') + ); + $this->assertDatabaseHas('ecommerce_product_inquiries', [ + 'id' => $this->inquiry->id, + 'is_answered' => false, + ]); + } + // ======================================== // updateReply() — 답변 수정 // ======================================== @@ -165,7 +243,7 @@ class AdminProductInquiryControllerTest extends ModuleTestCase public function 관리자는_답변을_수정할_수_있다(): void { $answeredInquiry = ProductInquiry::factory()->create([ - 'product_id' => $this->product->id, + 'product_id' => $this->product->id, 'is_answered' => true, ]); @@ -183,7 +261,7 @@ class AdminProductInquiryControllerTest extends ModuleTestCase { $normalUser = $this->createUser(); $answeredInquiry = ProductInquiry::factory()->create([ - 'product_id' => $this->product->id, + 'product_id' => $this->product->id, 'is_answered' => true, ]); @@ -204,26 +282,56 @@ class AdminProductInquiryControllerTest extends ModuleTestCase public function 관리자는_답변을_삭제할_수_있다(): void { $answeredInquiry = ProductInquiry::factory()->create([ - 'product_id' => $this->product->id, + 'product_id' => $this->product->id, 'is_answered' => true, ]); $response = $this->actingAs($this->adminUser) ->deleteJson("{$this->apiBase}/{$answeredInquiry->id}/reply"); + // setUp 의 count_replies 모킹(0)에 따라 잔여 답변이 없으므로 답변완료가 해제된다 $response->assertOk(); $this->assertDatabaseHas('ecommerce_product_inquiries', [ - 'id' => $answeredInquiry->id, + 'id' => $answeredInquiry->id, 'is_answered' => false, ]); } + /** + * 과거 결함(#106)으로 답변이 여러 건 쌓인 문의에서 하나만 삭제한 경우 — + * 잔여 답변이 있으면(count_replies > 0) 답변완료 표기를 유지해야 한다. + */ + #[Test] + public function 잔여_답변이_있으면_답변_삭제_후에도_답변완료가_유지된다(): void + { + $answeredInquiry = ProductInquiry::factory()->answered()->create([ + 'product_id' => $this->product->id, + ]); + + // setUp 의 기본 모킹(0)을 걷어내고 잔여 답변 1건으로 교체 + HookManager::clearFilter('sirsoft-ecommerce.inquiry.count_replies'); + HookManager::addFilter( + 'sirsoft-ecommerce.inquiry.count_replies', + fn () => 1, + priority: 1 + ); + + $response = $this->actingAs($this->adminUser) + ->deleteJson("{$this->apiBase}/{$answeredInquiry->id}/reply"); + + $response->assertOk(); + $this->assertDatabaseHas('ecommerce_product_inquiries', [ + 'id' => $answeredInquiry->id, + 'is_answered' => true, + ]); + } + #[Test] public function 비관리자는_답변을_삭제할_수_없다(): void { $normalUser = $this->createUser(); $answeredInquiry = ProductInquiry::factory()->create([ - 'product_id' => $this->product->id, + 'product_id' => $this->product->id, 'is_answered' => true, ]); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserProductInquiryControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserProductInquiryControllerTest.php index cf31d09f..8647661d 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserProductInquiryControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserProductInquiryControllerTest.php @@ -45,6 +45,7 @@ class UserProductInquiryControllerTest extends ModuleTestCase 'sirsoft-ecommerce.inquiry.update_reply', 'sirsoft-ecommerce.inquiry.delete_reply', 'sirsoft-ecommerce.inquiry.create', + 'sirsoft-ecommerce.inquiry.count_replies', 'sirsoft-ecommerce.inquiry.update', 'sirsoft-ecommerce.inquiry.get_settings', 'sirsoft-ecommerce.inquiry.get_by_ids', @@ -69,6 +70,13 @@ class UserProductInquiryControllerTest extends ModuleTestCase priority: 1 ); + // 잔여 답변 수 훅 모킹 — 기본값 0 (답변 삭제 시 답변완료 해제 경로) + HookManager::addFilter( + 'sirsoft-ecommerce.inquiry.count_replies', + fn () => 0, + priority: 1 + ); + $this->inquiry = ProductInquiry::factory()->create([ 'user_id' => $this->user->id, 'product_id' => $this->product->id, @@ -192,7 +200,34 @@ class UserProductInquiryControllerTest extends ModuleTestCase ); $response->assertOk(); - $this->assertDatabaseMissing('ecommerce_product_inquiries', ['id' => $this->inquiry->id]); + // SoftDeletes 도입(#107 후속) — 게시판 복원 경로와의 대칭을 위해 피벗은 소프트 삭제된다 + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $this->inquiry->id]); + } + + /** + * SoftDeletes 계약 명시 — 삭제된 피벗 행은 DB 에 남되(deleted_at 세팅) + * 기본 스코프 조회에서는 제외되어야 한다. 게시판에서 질문 글을 복원하는 + * 경로가 피벗을 되살릴 수 있어야 하므로 하드 삭제가 아니다. + */ + #[Test] + public function 문의_삭제_시_피벗이_소프트_삭제된다(): void + { + $response = $this->actingAs($this->user) + ->deleteJson( + "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}" + ); + + $response->assertOk(); + + // 행 자체는 잔존 + deleted_at 세팅 + $this->assertDatabaseHas('ecommerce_product_inquiries', ['id' => $this->inquiry->id]); + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $this->inquiry->id]); + + // 기본 스코프에서는 보이지 않고, withTrashed 로만 조회된다 + $this->assertNull(ProductInquiry::find($this->inquiry->id)); + $trashed = ProductInquiry::withTrashed()->find($this->inquiry->id); + $this->assertNotNull($trashed); + $this->assertNotNull($trashed->deleted_at); } #[Test] @@ -243,6 +278,88 @@ class UserProductInquiryControllerTest extends ModuleTestCase ]); } + /** + * 단일 답변 정책 (user 경로) — 이미 답변완료인 문의에는 재등록이 422 로 + * 거부되고, 거부 시 게시판 create 훅은 호출 자체가 없어야 한다. + * admin 경로와 동일 가드가 user 경로에도 대칭 적용되는지 고정한다. + */ + #[Test] + public function 이미_답변된_문의에_답변_재등록_시_422를_반환한다(): void + { + $manager = $this->createAdminUser(['sirsoft-ecommerce.inquiries.update']); + $answeredInquiry = ProductInquiry::factory()->answered()->create([ + 'user_id' => $this->user->id, + 'product_id' => $this->product->id, + ]); + + // setUp 의 기본 모킹을 걷어내고 호출 횟수를 계수하는 훅으로 교체 + HookManager::clearFilter('sirsoft-ecommerce.inquiry.create'); + $createCalls = 0; + HookManager::addFilter( + 'sirsoft-ecommerce.inquiry.create', + function () use (&$createCalls) { + $createCalls++; + + return ['post_id' => 999, 'inquirable_type' => 'Modules\\Sirsoft\\Board\\Models\\Post']; + }, + priority: 1 + ); + + $response = $this->actingAs($manager) + ->postJson( + "/api/modules/sirsoft-ecommerce/user/inquiries/{$answeredInquiry->id}/reply", + ['content' => '중복 답변 재등록 시도 내용입니다.'] + ); + + // user 컨트롤러는 operation_failed_reason(':reason') 래핑으로 사유 원문을 그대로 노출한다 + $response->assertStatus(422); + $this->assertSame( + __('sirsoft-ecommerce::messages.inquiries.reply_already_exists'), + $response->json('message') + ); + $this->assertSame(0, $createCalls, '재등록 거부 시 게시판 create 훅은 호출되지 않아야 합니다.'); + $this->assertDatabaseHas('ecommerce_product_inquiries', [ + 'id' => $answeredInquiry->id, + 'is_answered' => true, + ]); + } + + /** + * 경합 경로의 중복도 사유가 보존된다 (회귀 — PO 실측 제보). + * + * 동시 등록 경합에서는 피벗 is_answered 가 아직 false 라 1차 방어를 통과하고, + * 게시판 리스너의 2차 방어(실데이터 기준)가 중복을 감지한다. 종전에는 리스너가 + * null 을 돌려줘 서비스가 훅 무응답과 구분하지 못했고, "답변 등록에 실패했습니다" + * (reply_failed, 500) 로 위장됐다. 리스너는 중복 마커(['duplicate' => true])를 + * 돌려주고 서비스는 이를 reply_already_exists(422) 로 변환해야 한다. + */ + #[Test] + public function 경합_중복_감지_시에도_이미_답변됨_사유가_노출된다(): void + { + $manager = $this->createAdminUser(['sirsoft-ecommerce.inquiries.update']); + + // 피벗은 미답변(1차 방어 통과) — 게시판 실데이터에만 답변이 존재하는 경합 상태 재현 + HookManager::clearFilter('sirsoft-ecommerce.inquiry.create'); + HookManager::addFilter( + 'sirsoft-ecommerce.inquiry.create', + fn () => ['duplicate' => true], + priority: 1 + ); + + $response = $this->actingAs($manager) + ->postJson( + "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply", + ['content' => '경합 중복 답변 등록 시도 내용입니다.'] + ); + + $response->assertStatus(422); + $this->assertSame( + __('sirsoft-ecommerce::messages.inquiries.reply_already_exists'), + $response->json('message'), + '경합 중복도 일반 실패가 아니라 "이미 등록된 답변" 사유로 안내되어야 합니다.' + ); + } + /** * 서비스가 클라이언트 IP 를 게시판 훅 payload 에 주입한다 (답변 경로). * diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/ProductInquiryBoardListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/ProductInquiryBoardListenerTest.php new file mode 100644 index 00000000..6996d258 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/ProductInquiryBoardListenerTest.php @@ -0,0 +1,259 @@ +listener = app(ProductInquiryBoardListener::class); + $this->product = Product::factory()->create(); + + // 다른 모듈이 등록한 count_replies 필터 잔존 차단 (cross-module contamination) + HookManager::clearFilter('sirsoft-ecommerce.inquiry.count_replies'); + } + + /** + * stdClass 기반 Post 스텁을 생성합니다. + * + * @param int $id Post ID + * @param int|null $parentId 부모 Post ID (답변 글이면 지정) + * @return object Post 스텁 + */ + private function makePostStub(int $id, ?int $parentId = null): object + { + return (object) [ + 'id' => $id, + 'parent_id' => $parentId, + ]; + } + + /** + * getSubscribedHooks 는 3개 액션 훅을 sync=true 로 선언한다. + * + * sync 미선언 시 큐 워커 미가동 환경에서 피벗 정리가 조용히 누락된다(N3). + */ + public function test_subscribed_hooks_declare_three_sync_action_hooks(): void + { + $hooks = ProductInquiryBoardListener::getSubscribedHooks(); + + // 액션 훅 3종 — method + sync=true + $this->assertSame('handlePostDeleted', $hooks['sirsoft-board.post.after_delete']['method']); + $this->assertTrue($hooks['sirsoft-board.post.after_delete']['sync']); + + $this->assertSame('handlePostRestored', $hooks['sirsoft-board.post.after_restore']['method']); + $this->assertTrue($hooks['sirsoft-board.post.after_restore']['sync']); + + $this->assertSame( + 'handleBoardPostsForceDeleting', + $hooks['sirsoft-board.board.posts.before_force_delete']['method'] + ); + $this->assertTrue($hooks['sirsoft-board.board.posts.before_force_delete']['sync']); + + // 필터 훅 2종 — type: 'filter' 명시 (미지정 시 반환값 무시 회귀) + $this->assertSame('filter', $hooks['sirsoft-ecommerce.inquiry.store_validation_rules']['type']); + $this->assertSame('filter', $hooks['sirsoft-ecommerce.inquiry.update_validation_rules']['type']); + } + + /** + * 질문 글 삭제 → 연결된 피벗이 소프트 삭제된다 (복원 대칭 확보). + */ + public function test_handle_post_deleted_soft_deletes_pivot_for_question_post(): void + { + // Given: 질문 Post(101)를 가리키는 피벗 + $pivot = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => 'stdClass', + 'inquirable_id' => 101, + ]); + + // When: 질문 글(parent_id 없음) 삭제 이벤트 + $this->listener->handlePostDeleted($this->makePostStub(101), 'test-inquiry-board'); + + // Then: 피벗 소프트 삭제 — 행은 잔존, deleted_at 세팅 + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivot->id]); + $this->assertNotNull(ProductInquiry::withTrashed()->find($pivot->id)->deleted_at); + } + + /** + * 답변 글 삭제 + 잔여 답변 0 → 부모 피벗의 답변완료가 해제된다 (피벗은 삭제하지 않음). + */ + public function test_handle_post_deleted_unmarks_answered_when_no_replies_remain(): void + { + // Given: 질문 Post(201)를 가리키는 답변완료 피벗 + $pivot = ProductInquiry::factory()->answered()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => 'stdClass', + 'inquirable_id' => 201, + ]); + + // And: 잔여 답변 0건 모킹 + HookManager::addFilter('sirsoft-ecommerce.inquiry.count_replies', fn () => 0, priority: 1); + + // When: 답변 글(301, parent_id=201) 삭제 이벤트 + $this->listener->handlePostDeleted($this->makePostStub(301, 201), 'test-inquiry-board'); + + // Then: 답변완료 해제 + answered_at 초기화 + $this->assertDatabaseHas('ecommerce_product_inquiries', [ + 'id' => $pivot->id, + 'is_answered' => false, + 'answered_at' => null, + ]); + + // And: 피벗 자체는 삭제되지 않는다 (질문 분기와 구분 — 답변 분기는 return 으로 종료) + $this->assertNull(ProductInquiry::withTrashed()->find($pivot->id)->deleted_at); + } + + /** + * 답변 글 삭제 + 잔여 답변 1 → 답변완료 표기가 유지된다 (#106 다중 답변 잔재 대응). + */ + public function test_handle_post_deleted_keeps_answered_when_replies_remain(): void + { + // Given: 질문 Post(202)를 가리키는 답변완료 피벗 + $pivot = ProductInquiry::factory()->answered()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => 'stdClass', + 'inquirable_id' => 202, + ]); + + // And: 잔여 답변 1건 모킹 + HookManager::addFilter('sirsoft-ecommerce.inquiry.count_replies', fn () => 1, priority: 1); + + // When: 답변 글(302, parent_id=202) 삭제 이벤트 + $this->listener->handlePostDeleted($this->makePostStub(302, 202), 'test-inquiry-board'); + + // Then: 답변완료 유지 + $this->assertDatabaseHas('ecommerce_product_inquiries', [ + 'id' => $pivot->id, + 'is_answered' => true, + ]); + $this->assertNotNull(ProductInquiry::find($pivot->id)->answered_at); + } + + /** + * 질문 글 복원 → 소프트 삭제됐던 피벗이 복원된다. + */ + public function test_handle_post_restored_restores_pivot_for_question_post(): void + { + // Given: 질문 Post(401)를 가리키는 소프트 삭제된 피벗 + $pivot = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => 'stdClass', + 'inquirable_id' => 401, + ]); + $pivot->delete(); + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivot->id]); + + // When: 질문 글 복원 이벤트 + $this->listener->handlePostRestored($this->makePostStub(401), 'test-inquiry-board'); + + // Then: 피벗 복원 — 기본 스코프에서 다시 조회 가능 + $this->assertNotNull(ProductInquiry::find($pivot->id)); + $this->assertNull(ProductInquiry::withTrashed()->find($pivot->id)->deleted_at); + } + + /** + * 답변 글 복원 → 부모 피벗이 답변완료로 재마킹되고 answered_at 은 기존값이 보존된다. + */ + public function test_handle_post_restored_remarks_answered_preserving_answered_at(): void + { + // Given: 최초 답변 시각이 남아 있는 미답변 피벗 (질문 Post 402) + $firstAnsweredAt = Carbon::parse('2026-02-02 09:00:00'); + $pivot = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => 'stdClass', + 'inquirable_id' => 402, + 'is_answered' => false, + 'answered_at' => $firstAnsweredAt, + ]); + + // When: 답변 글(502, parent_id=402) 복원 이벤트 + $this->listener->handlePostRestored($this->makePostStub(502, 402), 'test-inquiry-board'); + + // Then: 답변완료 재마킹 + 최초 답변 시각 보존 (markAsAnswered 의 보존 계약) + $fresh = ProductInquiry::find($pivot->id); + $this->assertTrue($fresh->is_answered); + $this->assertSame( + $firstAnsweredAt->format('Y-m-d H:i:s'), + $fresh->answered_at->format('Y-m-d H:i:s'), + '재마킹이 최초 답변 시각을 덮어쓰면 안 됩니다.' + ); + } + + /** + * 게시판 삭제 벌크 훅 → 삭제될 글 ID 의 피벗을 일괄 소프트 삭제하며, 재호출은 멱등이다. + * + * chunk 당 1회 다회 발화되는 훅이므로 이미 삭제된 피벗은 0건 처리여야 한다. + */ + public function test_handle_board_posts_force_deleting_bulk_soft_deletes_and_is_idempotent(): void + { + // Given: 게시판 Post FQCN 을 가리키는 피벗 2건 + 대상 밖 피벗 1건 + $pivot1 = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => self::POST_MODEL, + 'inquirable_id' => 601, + ]); + $pivot2 = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => self::POST_MODEL, + 'inquirable_id' => 602, + ]); + $untouched = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => self::POST_MODEL, + 'inquirable_id' => 699, + ]); + + $board = (object) ['id' => 7]; + + // When: 벌크 삭제 통지 + $this->listener->handleBoardPostsForceDeleting($board, [601, 602]); + + // Then: 대상 피벗만 소프트 삭제 + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivot1->id]); + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivot2->id]); + $this->assertNull(ProductInquiry::withTrashed()->find($untouched->id)->deleted_at); + + // When: 동일 payload 재호출 (chunk 다회 발화 시뮬레이션) — 예외 없이 0건 처리 + $firstDeletedAt = ProductInquiry::withTrashed()->find($pivot1->id)->deleted_at; + $this->listener->handleBoardPostsForceDeleting($board, [601, 602]); + + // Then: 멱등 — deleted_at 이 갱신되지 않고 그대로 유지된다 + $this->assertSame( + $firstDeletedAt->format('Y-m-d H:i:s'), + ProductInquiry::withTrashed()->find($pivot1->id)->deleted_at->format('Y-m-d H:i:s') + ); + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivot2->id]); + + // And: 빈 배열 호출은 no-op + $this->listener->handleBoardPostsForceDeleting($board, []); + $this->assertNull(ProductInquiry::withTrashed()->find($untouched->id)->deleted_at); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductInquiryRepositoryTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductInquiryRepositoryTest.php new file mode 100644 index 00000000..aad4ad94 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductInquiryRepositoryTest.php @@ -0,0 +1,165 @@ +repository = app(ProductInquiryRepositoryInterface::class); + $this->product = Product::factory()->create(); + } + + /** + * markAsAnswered 는 기존 answered_at 을 보존한다. + * + * 답변 수정/복원 재마킹 경로에서 최초 답변 시각이 덮어써지면 안 된다 + * (선례: ProductReviewService). 값이 없을 때만 now() 로 채운다. + */ + public function test_mark_as_answered_preserves_existing_answered_at(): void + { + // Given: 최초 답변 시각이 기록된 피벗 (답변완료 해제 없이 재마킹되는 상황) + $firstAnsweredAt = Carbon::parse('2026-01-01 12:34:56'); + $inquiry = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'is_answered' => false, + 'answered_at' => $firstAnsweredAt, + ]); + + // When: 재마킹 + $updated = $this->repository->markAsAnswered($inquiry); + + // Then: is_answered 는 true, answered_at 은 최초 시각 그대로 + $this->assertTrue($updated->is_answered); + $this->assertSame( + $firstAnsweredAt->format('Y-m-d H:i:s'), + $updated->answered_at->format('Y-m-d H:i:s'), + '재마킹이 최초 답변 시각을 덮어쓰면 안 됩니다.' + ); + + // And: answered_at 이 없는 피벗은 now() 로 채워진다 (기존 동작 유지) + $fresh = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'is_answered' => false, + 'answered_at' => null, + ]); + $freshUpdated = $this->repository->markAsAnswered($fresh); + $this->assertTrue($freshUpdated->is_answered); + $this->assertNotNull($freshUpdated->answered_at); + } + + /** + * restoreByInquirable 은 소프트 삭제된 피벗만 복원한다 (onlyTrashed → restore). + */ + public function test_restore_by_inquirable_restores_soft_deleted_pivot(): void + { + // Given: 소프트 삭제된 피벗 + $trashed = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => 'Modules\\Sirsoft\\Board\\Models\\Post', + 'inquirable_id' => 501, + ]); + $trashed->delete(); + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $trashed->id]); + + // And: 살아있는 피벗 (onlyTrashed 대상 아님) + $alive = ProductInquiry::factory()->create([ + 'product_id' => $this->product->id, + 'inquirable_type' => 'Modules\\Sirsoft\\Board\\Models\\Post', + 'inquirable_id' => 502, + ]); + + // When: 복원 + $restored = $this->repository->restoreByInquirable( + 'Modules\\Sirsoft\\Board\\Models\\Post', + 501 + ); + + // Then: 1건 복원 + 기본 스코프에서 다시 조회 가능 + $this->assertSame(1, $restored); + $this->assertNull(ProductInquiry::withTrashed()->find($trashed->id)->deleted_at); + $this->assertNotNull(ProductInquiry::find($trashed->id)); + + // And: 살아있는 피벗을 대상으로 하면 0건 (onlyTrashed 스코프) + $this->assertSame( + 0, + $this->repository->restoreByInquirable('Modules\\Sirsoft\\Board\\Models\\Post', 502) + ); + $this->assertNotNull(ProductInquiry::find($alive->id)); + } + + /** + * forceDeleteByProductId 는 trashed 피벗까지 포함해 영구 삭제한다 (상품 삭제 경로). + */ + public function test_force_delete_by_product_id_removes_trashed_too(): void + { + // Given: 살아있는 피벗 1건 + 소프트 삭제된 피벗 1건 + $alive = ProductInquiry::factory()->create(['product_id' => $this->product->id]); + $trashed = ProductInquiry::factory()->create(['product_id' => $this->product->id]); + $trashed->delete(); + + // And: 다른 상품의 피벗 (삭제 대상 아님) + $otherProduct = Product::factory()->create(); + $other = ProductInquiry::factory()->create(['product_id' => $otherProduct->id]); + + // When: 상품 기준 영구 삭제 + $deleted = $this->repository->forceDeleteByProductId($this->product->id); + + // Then: trashed 포함 2건 영구 삭제 — 행 자체가 사라진다 + $this->assertSame(2, $deleted); + $this->assertDatabaseMissing('ecommerce_product_inquiries', ['id' => $alive->id]); + $this->assertDatabaseMissing('ecommerce_product_inquiries', ['id' => $trashed->id]); + + // And: 다른 상품의 피벗은 보존 + $this->assertDatabaseHas('ecommerce_product_inquiries', ['id' => $other->id]); + } + + /** + * findByProductIdWithTrashed 는 trashed 를 포함하고, 기본 findByProductId 는 제외한다. + */ + public function test_find_by_product_id_with_trashed_includes_trashed(): void + { + // Given: 살아있는 피벗 2건 + 소프트 삭제된 피벗 1건 + $alive1 = ProductInquiry::factory()->create(['product_id' => $this->product->id]); + $alive2 = ProductInquiry::factory()->create(['product_id' => $this->product->id]); + $trashed = ProductInquiry::factory()->create(['product_id' => $this->product->id]); + $trashed->delete(); + + // When / Then: withTrashed 조회는 3건 전부 + $withTrashed = $this->repository->findByProductIdWithTrashed($this->product->id); + $this->assertCount(3, $withTrashed); + $this->assertEqualsCanonicalizing( + [$alive1->id, $alive2->id, $trashed->id], + $withTrashed->pluck('id')->all() + ); + + // And: 기본 조회는 trashed 제외 2건 + $default = $this->repository->findByProductId($this->product->id); + $this->assertCount(2, $default); + $this->assertEqualsCanonicalizing( + [$alive1->id, $alive2->id], + $default->pluck('id')->all() + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDeleteTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDeleteTest.php index b5e933f2..8769f360 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDeleteTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDeleteTest.php @@ -2,18 +2,22 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services; +use App\Extension\HookManager; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Mockery; +use Modules\Sirsoft\Ecommerce\Exceptions\ProductHasOrderHistoryException; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Models\OrderOption; use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOption; use Modules\Sirsoft\Ecommerce\Models\ProductImage; +use Modules\Sirsoft\Ecommerce\Models\ProductInquiry; use Modules\Sirsoft\Ecommerce\Models\ProductLabel; use Modules\Sirsoft\Ecommerce\Models\ProductLabelAssignment; use Modules\Sirsoft\Ecommerce\Models\ProductOption; +use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; use Modules\Sirsoft\Ecommerce\Services\ProductService; use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; @@ -324,7 +328,7 @@ class ProductServiceDeleteTest extends ModuleTestCase OrderOption::factory()->create(['product_id' => $product->id]); // Then: 도메인 예외 throw + 상품 보존 - $this->expectException(\Modules\Sirsoft\Ecommerce\Exceptions\ProductHasOrderHistoryException::class); + $this->expectException(ProductHasOrderHistoryException::class); try { $this->service->delete($product); @@ -333,6 +337,91 @@ class ProductServiceDeleteTest extends ModuleTestCase } } + // ======================================== + // delete() — 문의 스레드 정리 (#107 확대판) + // ======================================== + + /** + * 상품 삭제 시 문의 게시판이 설정돼 있으면 피벗 전건(소프트 삭제 포함)에 대해 + * inquiry.delete 훅이 호출되고, 피벗은 trashed 포함 영구 삭제된다. + */ + public function test_delete_fires_inquiry_delete_hook_per_pivot_including_trashed(): void + { + // Given: 문의 게시판 설정 + 상품에 딸린 피벗 (alive 2건 + trashed 1건) + app(EcommerceSettingsService::class)->setSetting('inquiry.board_slug', 'test-inquiry-board'); + + $product = Product::factory()->create(); + $productId = $product->id; + + ProductInquiry::factory()->create(['product_id' => $productId, 'inquirable_id' => 701]); + ProductInquiry::factory()->create(['product_id' => $productId, 'inquirable_id' => 702]); + $trashed = ProductInquiry::factory()->create(['product_id' => $productId, 'inquirable_id' => 703]); + $trashed->delete(); + + // And: 게시판 삭제 훅을 걷어내고 호출된 Post ID 를 수집하는 훅으로 교체 + HookManager::clearFilter('sirsoft-ecommerce.inquiry.delete'); + $deletedPostIds = []; + HookManager::addFilter( + 'sirsoft-ecommerce.inquiry.delete', + function ($result, $slug, $postId) use (&$deletedPostIds) { + $deletedPostIds[] = (int) $postId; + + return true; + }, + priority: 1 + ); + + // When: 상품 삭제 + $result = $this->service->delete($product); + + // Then: 피벗 수만큼(withTrashed 포함 3건) 훅 호출 + $this->assertTrue($result); + $this->assertCount(3, $deletedPostIds); + $this->assertEqualsCanonicalizing([701, 702, 703], $deletedPostIds); + + // And: 피벗은 trashed 포함 0건 (forceDelete — 행 자체 소멸) + $this->assertDatabaseMissing('ecommerce_product_inquiries', ['product_id' => $productId]); + $this->assertDatabaseMissing('ecommerce_products', ['id' => $productId]); + } + + /** + * 문의 게시판 미설정이면 훅 없이 피벗만 정리되고 상품 삭제는 성공한다 (안전 열화). + */ + public function test_delete_without_board_slug_skips_hook_but_purges_pivots(): void + { + // Given: 문의 게시판 미설정 (명시적 null) + app(EcommerceSettingsService::class)->setSetting('inquiry.board_slug', null); + + $product = Product::factory()->create(); + $productId = $product->id; + + ProductInquiry::factory()->create(['product_id' => $productId, 'inquirable_id' => 801]); + $trashed = ProductInquiry::factory()->create(['product_id' => $productId, 'inquirable_id' => 802]); + $trashed->delete(); + + // And: 훅 호출 계수기 + HookManager::clearFilter('sirsoft-ecommerce.inquiry.delete'); + $hookCalls = 0; + HookManager::addFilter( + 'sirsoft-ecommerce.inquiry.delete', + function () use (&$hookCalls) { + $hookCalls++; + + return true; + }, + priority: 1 + ); + + // When: 상품 삭제 + $result = $this->service->delete($product); + + // Then: 훅 미호출 + 피벗 전건 영구 삭제 + 상품 삭제 성공 + $this->assertTrue($result); + $this->assertSame(0, $hookCalls, '게시판 미설정 시 inquiry.delete 훅은 호출되지 않아야 합니다.'); + $this->assertDatabaseMissing('ecommerce_product_inquiries', ['product_id' => $productId]); + $this->assertDatabaseMissing('ecommerce_products', ['id' => $productId]); + } + protected function tearDown(): void { Mockery::close(); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/InquiryOrphanReplyBackfillTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/InquiryOrphanReplyBackfillTest.php new file mode 100644 index 00000000..9469e19b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/InquiryOrphanReplyBackfillTest.php @@ -0,0 +1,253 @@ +orphanReplyStep = new $orphanClass; + $this->trashedQuestionPivotStep = new $pivotClass; + $this->unmarkAnsweredStep = new $unmarkClass; + + $this->context = new UpgradeContext( + fromVersion: '1.1.0', + toVersion: '1.1.1', + currentStep: '1.1.1', + logChannel: 'extension-upgrade', + ); + + $this->product = Product::factory()->create(); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + /** + * 게시글 행을 생성합니다 (board_posts 직접 시드 — V-1 안전 스텝과 동일하게 raw DB). + * + * @param array $overrides 컬럼 오버라이드 + * @return int 생성된 게시글 ID + */ + private function makeBoardPost(array $overrides = []): int + { + return (int) DB::table('board_posts')->insertGetId(array_merge([ + 'board_id' => self::BOARD_ID, + 'title' => '상품 문의 글', + 'content' => '상품 문의 내용입니다.', + 'ip_address' => '127.0.0.1', + 'status' => 'published', + 'created_at' => now(), + 'updated_at' => now(), + ], $overrides)); + } + + /** + * 문의 피벗 행을 생성합니다 (inquirable_type 은 게시판 Post FQCN 고정). + * + * @param int $postId 질문 Post ID + * @param array $overrides 컬럼 오버라이드 + * @return ProductInquiry 생성된 피벗 + */ + private function makePivot(int $postId, array $overrides = []): ProductInquiry + { + return ProductInquiry::factory()->create(array_merge([ + 'product_id' => $this->product->id, + 'inquirable_type' => self::POST_MODEL, + 'inquirable_id' => $postId, + ], $overrides)); + } + + /** + * 04 — 부모가 삭제/부재인 답변 글만 소프트 삭제하고, 부모가 살아있는 답변은 건드리지 않는다. + */ + public function test_04_soft_deletes_only_orphan_reply_posts(): void + { + // Given: 살아있는 질문 + 그 답변 (오탐 검증 대상) + $aliveQuestion = $this->makeBoardPost(); + $aliveReply = $this->makeBoardPost(['parent_id' => $aliveQuestion, 'depth' => 1]); + + // And: 소프트 삭제된 질문 + 잔존 답변 (고아 ①) + $trashedQuestion = $this->makeBoardPost([ + 'status' => 'deleted', + 'deleted_at' => now()->subDay(), + ]); + $orphanByTrashedParent = $this->makeBoardPost(['parent_id' => $trashedQuestion, 'depth' => 1]); + + // And: 부모가 하드 삭제로 부재한 답변 (고아 ②) + $orphanByMissingParent = $this->makeBoardPost(['parent_id' => 999999, 'depth' => 1]); + + // And: 문의 게시판 판별용 피벗 (피벗 join posts.board_id 축) + $this->makePivot($aliveQuestion); + + // When: 04 실행 + $this->orphanReplyStep->run($this->context); + + // Then: 고아 답변 2건만 status=deleted + deleted_at 마킹 + foreach ([$orphanByTrashedParent, $orphanByMissingParent] as $orphanId) { + $row = DB::table('board_posts')->where('id', $orphanId)->first(); + $this->assertSame('deleted', $row->status, "고아 답변({$orphanId})은 deleted 상태여야 합니다."); + $this->assertNotNull($row->deleted_at, "고아 답변({$orphanId})은 소프트 삭제되어야 합니다."); + } + + // And: 부모가 살아있는 답변과 질문 글은 오탐 없이 그대로 + foreach ([$aliveQuestion, $aliveReply] as $aliveId) { + $row = DB::table('board_posts')->where('id', $aliveId)->first(); + $this->assertSame('published', $row->status, "살아있는 글({$aliveId})은 건드리면 안 됩니다."); + $this->assertNull($row->deleted_at); + } + } + + /** + * 04 — 재실행 멱등: 이미 마킹된 고아의 deleted_at 이 갱신되지 않는다. + */ + public function test_04_is_idempotent_on_rerun(): void + { + // Given: 고아 답변 1건 (부모 부재) + 판별용 피벗 + $question = $this->makeBoardPost(); + $orphan = $this->makeBoardPost(['parent_id' => 999999, 'depth' => 1]); + $this->makePivot($question); + + // When: 1차 실행 (T1 시각) + $firstRunAt = Carbon::parse('2026-08-17 10:00:00'); + Carbon::setTestNow($firstRunAt); + $this->orphanReplyStep->run($this->context); + + $afterFirst = DB::table('board_posts')->where('id', $orphan)->value('deleted_at'); + $this->assertNotNull($afterFirst); + + // When: 시간이 흐른 뒤 2차 실행 (T2 시각) + Carbon::setTestNow($firstRunAt->copy()->addHour()); + $this->orphanReplyStep->run($this->context); + + // Then: deleted_at IS NULL 필터로 재실행이 0건 — 1차 마킹 시각 그대로 + $this->assertSame( + $afterFirst, + DB::table('board_posts')->where('id', $orphan)->value('deleted_at'), + '재실행이 기존 소프트 삭제 시각을 덮어쓰면 안 됩니다 (멱등).' + ); + } + + /** + * 05 — 질문 Post 가 삭제/부재인 살아있는 피벗만 소프트 삭제한다. + */ + public function test_05_soft_deletes_pivots_whose_question_post_is_gone(): void + { + // Given: 살아있는 질문 + 그 피벗 (생존 대상) + $aliveQuestion = $this->makeBoardPost(); + $alivePivot = $this->makePivot($aliveQuestion); + + // And: 소프트 삭제된 질문 + 살아있는 피벗 (정리 대상 ①) + $trashedQuestion = $this->makeBoardPost([ + 'status' => 'deleted', + 'deleted_at' => now()->subDay(), + ]); + $pivotForTrashed = $this->makePivot($trashedQuestion); + + // And: 하드 삭제로 부재한 질문을 가리키는 피벗 (정리 대상 ②) + $pivotForMissing = $this->makePivot(888888); + + // When: 05 실행 + $this->trashedQuestionPivotStep->run($this->context); + + // Then: 질문이 사라진 피벗 2건만 소프트 삭제 + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivotForTrashed->id]); + $this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivotForMissing->id]); + + // And: 질문이 살아있는 피벗은 그대로 + $this->assertNull( + ProductInquiry::withTrashed()->find($alivePivot->id)->deleted_at, + '질문 Post 가 살아있는 피벗은 건드리면 안 됩니다.' + ); + } + + /** + * 06 — 살아있는 답변이 없는 「답변완료」 피벗만 미답변으로 정정한다. + */ + public function test_06_unmarks_answered_inquiries_without_live_replies(): void + { + // Given: 답변이 소프트 삭제된 질문 + 답변완료 피벗 (정정 대상) + $questionWithoutReply = $this->makeBoardPost(); + $this->makeBoardPost([ + 'parent_id' => $questionWithoutReply, + 'depth' => 1, + 'status' => 'deleted', + 'deleted_at' => now()->subDay(), + ]); + $staleAnswered = $this->makePivot($questionWithoutReply, [ + 'is_answered' => true, + 'answered_at' => now()->subDays(2), + ]); + + // And: 살아있는 답변이 있는 질문 + 답변완료 피벗 (유지 대상) + $questionWithReply = $this->makeBoardPost(); + $this->makeBoardPost(['parent_id' => $questionWithReply, 'depth' => 1]); + $validAnswered = $this->makePivot($questionWithReply, [ + 'is_answered' => true, + 'answered_at' => now()->subDays(2), + ]); + + // When: 06 실행 + $this->unmarkAnsweredStep->run($this->context); + + // Then: 답변 없는 답변완료만 해제 (is_answered=0 + answered_at=null) + $this->assertDatabaseHas('ecommerce_product_inquiries', [ + 'id' => $staleAnswered->id, + 'is_answered' => false, + 'answered_at' => null, + ]); + + // And: 살아있는 답변이 있는 피벗은 답변완료 유지 + $kept = ProductInquiry::find($validAnswered->id); + $this->assertTrue($kept->is_answered, '살아있는 답변이 있으면 답변완료를 유지해야 합니다.'); + $this->assertNotNull($kept->answered_at); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/inquiry-single-reply-lifecycle.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/inquiry-single-reply-lifecycle.yaml new file mode 100644 index 00000000..d3d9226a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/inquiry-single-reply-lifecycle.yaml @@ -0,0 +1,58 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는 +# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은 +# ProductInquiryService/ProductInquiryBoardListener/PostService cascade 를 +# PHPUnit Unit/Feature(양 모듈) + 업그레이드 백필 테스트가 커버하며 green. +feature: 상품 문의 단일 답변 수명주기 (#106 · #107) + +description: | + 문의 답변은 단일 정책 — 이미 답변된 문의에 재등록하면 422(reply_already_exists)로 + 거절하고(서비스 1차: 피벗 is_answered / 리스너 2차: 게시판 실데이터), 문의·상품·게시판 + 삭제 시 질문+답변 Post 와 피벗이 함께 정리된다. 피벗은 SoftDeletes 로 전환되어 + 게시판에서 질문 글을 삭제→복원하면 문의 목록에도 복원된다. 답변 글만 직접 삭제되면 + 잔여 답변 수(count_replies 훅) 기준으로 답변완료가 재계산된다. 업그레이드 스텝이 + 기설치본의 고아 답변·고아 피벗·거짓 답변완료를 백필 정리한다. + +axes: + action: + - create_reply + - delete_reply + - delete_inquiry + - delete_product + - board_delete_question + - board_delete_reply + - board_restore_question + - board_restore_reply + - upgrade_backfill + state: + - unanswered + - answered + - legacy_multi_reply # 과거 결함으로 답변 다건 누적된 기설치본 + - orphan_reply # 부모 없는/삭제된 부모의 답변 글 + path: + - user_api + - admin_api + - board_direct # 게시판 관리 화면 직권 삭제/복원 + - product_delete + +effects: + - duplicate_reply_rejected_422 # 서비스 1차 가드 + reply_already_exists 메시지 + - listener_blocks_duplicate_child # 리스너 2차 가드 — 게시판 실데이터 기준 duplicate 마커 반환 (서비스가 reply_already_exists 422 로 변환) + - inquiry_delete_removes_reply_posts # 문의 삭제 시 답변 Post 까지 cascade 정리 + - product_delete_cleans_thread # 상품 삭제 시 질문+답변 Post 소프트 삭제 + 피벗 정리 + - pivot_cleanup_runs_sync # after_delete 리스너 sync 실행 (큐 워커 무관) + - reply_delete_recomputes_answered # 잔여 답변 0 일 때만 답변완료 해제 + - answered_at_preserved # 최초 답변 시각 보존 (markAsAnswered 재마킹 포함) + - pivot_soft_delete_and_restore # 질문 글 삭제→복원 시 피벗 소프트 삭제→복원 + - orphan_replies_backfilled_idempotent # 업그레이드 스텝 04~06 정리 + 재실행 멱등 + - board_not_configured_safe # 게시판 미설정 시 422/안전 열화 (500 없음) + +test_files: + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/AdminProductInquiryControllerTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserProductInquiryControllerTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductInquiryRepositoryTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/ProductInquiryBoardListenerTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDeleteTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/InquiryOrphanReplyBackfillTest.php + - modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php + - modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostRepositoryReplyLookupTest.php diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_1.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_1.php index 04874c40..f7680db1 100644 --- a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_1.php +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_1.php @@ -14,6 +14,12 @@ use App\Extension\AbstractUpgradeStep; * (사다리 매칭은 시작값을 쓰지 않는다) 관리자 화면 요약이 "10001~30000" 처럼 표시된다. * 2. 총 무게/부피가 0 으로 고정 기록된 기존 주문을 주문 옵션 소계 합으로 백필한다. * + * 공개 #106/#107 (상품 문의 답변 사슬) 후속 데이터 정리: + * + * 3. 과거 문의 삭제로 홀로 남은 답변 글(고아 답변)을 소프트 삭제한다. + * 4. 질문 글이 삭제됐는데 살아남은 문의 피벗을 소프트 삭제한다 (큐 비동기 유실 잔재). + * 5. 살아있는 답변이 없는데 「답변완료」로 남은 문의를 미답변으로 정정한다. + * * 모든 비즈니스 로직은 data/1.1.1/migrations/ 로 격리(AbstractUpgradeStep 규약). * * @upgrade-path B diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/04_SoftDeleteOrphanInquiryReplyPosts.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/04_SoftDeleteOrphanInquiryReplyPosts.php new file mode 100644 index 00000000..0e559f0b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/04_SoftDeleteOrphanInquiryReplyPosts.php @@ -0,0 +1,134 @@ +logger->warning('[ecommerce:1.1.1] 게시글/문의 피벗 테이블 미존재 — 스킵'); + + return; + } + + $boardIds = $this->resolveInquiryBoardIds($context); + + if ($boardIds === []) { + $context->logger->info('[ecommerce:1.1.1] 문의 게시판을 판별할 수 없음 — 고아 답변 정리 스킵'); + + return; + } + + $now = now(); + $cleaned = 0; + + DB::table(self::POSTS_TABLE.' as replies') + ->whereIn('replies.board_id', $boardIds) + ->whereNotNull('replies.parent_id') + ->whereNull('replies.deleted_at') + ->where(function ($query) { + // 부모가 없거나(하드 삭제) 부모가 소프트 삭제된 답변만 대상 + $query->whereNotExists(function ($sub) { + $sub->selectRaw('1') + ->from(self::POSTS_TABLE.' as parents') + ->whereColumn('parents.id', 'replies.parent_id') + ->whereNull('parents.deleted_at'); + }); + }) + ->orderBy('replies.id') + ->select('replies.id') + ->chunkById(500, function ($rows) use (&$cleaned, $now) { + $ids = $rows->pluck('id')->all(); + + $cleaned += DB::table(self::POSTS_TABLE) + ->whereIn('id', $ids) + ->whereNull('deleted_at') + ->update([ + 'status' => 'deleted', + 'deleted_at' => $now, + ]); + }, 'replies.id', 'id'); + + $context->logger->info("[ecommerce:1.1.1] 고아 답변 글 {$cleaned}건 소프트 삭제 완료", [ + 'board_ids' => $boardIds, + ]); + } + + /** + * 문의 게시판 ID 집합을 판별합니다 (설정 board_slug ∪ 피벗 실데이터). + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @return array 게시판 ID 배열 + */ + private function resolveInquiryBoardIds(UpgradeContext $context): array + { + $boardIds = []; + + // ① 설정 파일의 board_slug → boards.id 해석 + $settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/inquiry.json'); + + if (File::exists($settingsPath) && Schema::hasTable(self::BOARDS_TABLE)) { + $settings = json_decode(File::get($settingsPath), true); + $slug = is_array($settings) ? ($settings['board_slug'] ?? null) : null; + + if (is_string($slug) && $slug !== '') { + $id = DB::table(self::BOARDS_TABLE)->where('slug', $slug)->value('id'); + + if ($id !== null) { + $boardIds[] = (int) $id; + } + } + } + + // ② 피벗이 실제로 가리키는 Post 의 board_id (설정 변경/유실 사이트 보강) + $pivotBoardIds = DB::table(self::PIVOT_TABLE.' as pivots') + ->join(self::POSTS_TABLE.' as posts', 'posts.id', '=', 'pivots.inquirable_id') + ->where('pivots.inquirable_type', self::POST_MODEL) + ->distinct() + ->pluck('posts.board_id') + ->all(); + + $boardIds = array_values(array_unique(array_merge($boardIds, array_map('intval', $pivotBoardIds)))); + + $context->logger->info('[ecommerce:1.1.1] 문의 게시판 판별 결과', ['board_ids' => $boardIds]); + + return $boardIds; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/05_SoftDeleteInquiryPivotsForTrashedQuestionPosts.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/05_SoftDeleteInquiryPivotsForTrashedQuestionPosts.php new file mode 100644 index 00000000..bdc4244e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/05_SoftDeleteInquiryPivotsForTrashedQuestionPosts.php @@ -0,0 +1,76 @@ +logger->warning('[ecommerce:1.1.1] 게시글/문의 피벗 테이블 미존재 — 스킵'); + + return; + } + + if (! Schema::hasColumn(self::PIVOT_TABLE, 'deleted_at')) { + // 피벗 SoftDeletes 컬럼은 정규 마이그레이션이 추가한다 — 아직 없으면 스킵 + $context->logger->warning('[ecommerce:1.1.1] 피벗 deleted_at 컬럼 미존재 — 스킵 (마이그레이션 선행 필요)'); + + return; + } + + $now = now(); + $cleaned = 0; + + DB::table(self::PIVOT_TABLE.' as pivots') + ->where('pivots.inquirable_type', self::POST_MODEL) + ->whereNull('pivots.deleted_at') + ->whereNotExists(function ($sub) { + // 질문 Post 가 살아있는 피벗만 생존 허용 — 부재(하드 삭제)/소프트 삭제 모두 정리 대상 + $sub->selectRaw('1') + ->from(self::POSTS_TABLE.' as posts') + ->whereColumn('posts.id', 'pivots.inquirable_id') + ->whereNull('posts.deleted_at'); + }) + ->orderBy('pivots.id') + ->select('pivots.id') + ->chunkById(500, function ($rows) use (&$cleaned, $now) { + $ids = $rows->pluck('id')->all(); + + $cleaned += DB::table(self::PIVOT_TABLE) + ->whereIn('id', $ids) + ->whereNull('deleted_at') + ->update(['deleted_at' => $now]); + }, 'pivots.id', 'id'); + + $context->logger->info("[ecommerce:1.1.1] 질문 글 부재 문의 피벗 {$cleaned}건 소프트 삭제 완료"); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/06_UnmarkAnsweredInquiriesWithoutLiveReplies.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/06_UnmarkAnsweredInquiriesWithoutLiveReplies.php new file mode 100644 index 00000000..7868e5b1 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/06_UnmarkAnsweredInquiriesWithoutLiveReplies.php @@ -0,0 +1,78 @@ +logger->warning('[ecommerce:1.1.1] 게시글/문의 피벗 테이블 미존재 — 스킵'); + + return; + } + + $query = DB::table(self::PIVOT_TABLE.' as pivots') + ->where('pivots.inquirable_type', self::POST_MODEL) + ->where('pivots.is_answered', 1) + ->whereNotExists(function ($sub) { + // 살아있는 답변(자식 Post)이 하나라도 있으면 답변완료 유지 + $sub->selectRaw('1') + ->from(self::POSTS_TABLE.' as replies') + ->whereColumn('replies.parent_id', 'pivots.inquirable_id') + ->whereNull('replies.deleted_at'); + }); + + if (Schema::hasColumn(self::PIVOT_TABLE, 'deleted_at')) { + $query->whereNull('pivots.deleted_at'); + } + + $fixed = 0; + + $query->orderBy('pivots.id') + ->select('pivots.id') + ->chunkById(500, function ($rows) use (&$fixed) { + $ids = $rows->pluck('id')->all(); + + $fixed += DB::table(self::PIVOT_TABLE) + ->whereIn('id', $ids) + ->where('is_answered', 1) + ->update([ + 'is_answered' => 0, + 'answered_at' => null, + ]); + }, 'pivots.id', 'id'); + + $context->logger->info("[ecommerce:1.1.1] 답변 없는 답변완료 문의 {$fixed}건 정정 완료"); + } +} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 5714e228..6e3faeee 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.3] - 2026-08-13 +## [1.0.3] - 2026-08-17 ### Security @@ -26,6 +26,7 @@ - 통합검색에서 페이지 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.) - 페이지 첨부 파일의 최대 개수·용량·허용 형식 설정이 저장해도 반영되지 않던 문제를 수정했습니다. 설정값을 읽어 오는 경로 자체가 없어 항상 내장 기본값이 쓰였습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.) - 페이지 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 발행·발행 취소 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다. +- 관리자 페이지 편집 화면에서 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.2] - 2026-08-10 diff --git a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json index 582a89b7..f6b14488 100644 --- a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json +++ b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json @@ -1081,7 +1081,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-page.admin.page.form.validation_error" + "message": "{{error.message ?? '$t:sirsoft-page.admin.page.form.validation_error'}}" } } ] diff --git a/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md b/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md index 0812e80f..d22ec620 100644 --- a/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md +++ b/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md @@ -4,11 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [0.1.1] - 2026-08-10 +## [0.1.1] - 2026-08-17 ### Fixed - 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 설정 저장에 실패하면 서버가 알려 준 사유가 그대로 표시됩니다. +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [0.1.0] - 2026-07-01 diff --git a/plugins/_bundled/gnuboard7-hello_plugin/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/gnuboard7-hello_plugin/resources/layouts/admin/plugin_settings.json index 3d506894..5d9ce987 100644 --- a/plugins/_bundled/gnuboard7-hello_plugin/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/gnuboard7-hello_plugin/resources/layouts/admin/plugin_settings.json @@ -205,7 +205,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.plugins.settings.save_error" + "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] diff --git a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md index c7412018..04116e5a 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-08-12 +## [1.0.2] - 2026-08-17 ### Security @@ -30,6 +30,7 @@ ### Fixed - 플러그인 설정 화면의 「에디터 이미지 업로드」 스위치가 레이아웃 편집기에서 편집 대상으로 선택되지 않던 문제를 수정했습니다. +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.1] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json index 739e441a..2aefc4ad 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json @@ -830,7 +830,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.plugins.settings.save_error" + "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] diff --git a/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md b/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md index 6be5a375..e7ad36b7 100644 --- a/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md @@ -4,13 +4,17 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-08-13 +## [1.0.2] - 2026-08-17 ### Changed - 코어 7.0.7의 외부 스크립트 보안 정책 강화에 맞춰, 주소 검색에 쓰는 Daum 우편번호 스크립트 주소(`t1.daumcdn.net`)를 이 플러그인의 신뢰 출처로 선언했습니다. 정책이 강화된 이후에도 주소 검색 창이 정상적으로 열립니다. - 신뢰 출처 선언 기능이 코어 7.0.7에서 도입되었으므로, 설치에 필요한 최소 코어 버전을 7.0.7로 조정했습니다. +### Fixed + +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. + ## [1.0.1] - 2026-08-10 ### Fixed diff --git a/plugins/_bundled/sirsoft-daum_postcode/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-daum_postcode/resources/layouts/admin/plugin_settings.json index f02ba8fa..a78d4765 100644 --- a/plugins/_bundled/sirsoft-daum_postcode/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-daum_postcode/resources/layouts/admin/plugin_settings.json @@ -542,7 +542,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.plugins.settings.save_error" + "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] diff --git a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md index c8589969..c7f64641 100644 --- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md @@ -4,13 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-08-16 +## [1.0.2] - 2026-08-17 ### Fixed - 플러그인 설정 화면의 켜기/끄기 스위치가 레이아웃 편집기에서 편집 대상으로 선택되지 않던 문제를 수정했습니다. - 채널 목록의 각 행이 같은 HTML 식별자를 공유하던 문제를 수정했습니다. - 다크 모드에서 설정 화면의 채널 추가 버튼 색이 밝은 화면과 동일하게 표시되던 문제를 수정했습니다. +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.1] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-marketing/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-marketing/resources/layouts/admin/plugin_settings.json index 0d9b04db..30f33b69 100644 --- a/plugins/_bundled/sirsoft-marketing/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-marketing/resources/layouts/admin/plugin_settings.json @@ -1330,7 +1330,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.plugins.settings.save_error" + "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index 469670fa..5122fed8 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -4,7 +4,17 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.1] - 2026-08-14 +## [1.1.1] - 2026-08-17 + +### Security + +- 에스크로 배송등록·구매거절확인·해외결제(CBT) 토큰/해시 요청에 입력 형식·길이 검증을 추가했습니다. + +### Changed + +- 에스크로 배송등록 오류 안내가 다국어로 제공됩니다. +- 결제창 닫힘 보고 사유의 길이 제한을 다른 결제 플러그인과 동일하게 160자로 통일했습니다. +- 해외결제(CBT) 해시 요청에서 체크아웃 토큰 누락 응답이 표준 검증 응답(422)으로 통일되었습니다. ### Changed @@ -12,7 +22,9 @@ ### Fixed +- 플러그인 설정 화면의 안내·상태 아이콘이 의도한 크기와 다르게 보이던 문제와, 해외결제(CBT) 연결 상태 표시에 일부 스타일이 적용되지 않던 문제를 수정했습니다. - 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다. +- 플러그인 설정 저장과 관리자 주문 결제 조회의 해외결제 대사 재시도·편의점 결제 처리가 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.1.0] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md index 19fe4554..2cf61b5d 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md @@ -820,6 +820,19 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | orderNumber | path | string | 예 | — | 대상 order number의 식별자 | +| invoice | body | string | 예 | max 40 | 운송장번호 (KG 이니시스 규격 40자) | +| ex_code | body | string | 예 | 공식 택배사 코드표 | 택배사 코드 — `formData` 응답의 `courier_codes`(컨트롤러 상수 `COURIER_CODES`)에 존재하는 코드만 허용 | +| report | body | string | 아니오 | `I`, `U` | 등록/수정 구분 (I 최초 등록 / U 수정). 미전송·허용값 밖이면 컨트롤러 기본값 `I` 로 흡수 | +| charge | body | string | 아니오 | `SH`, `BH` | 배송비 부담 구분 (SH 판매자 / BH 구매자). 미전송·허용값 밖이면 컨트롤러 기본값 `SH` 로 흡수 | +| recv_name | body | string | 아니오 | max 30 | 수령인명 (미전송 시 배송지 DB 값으로 채움) | +| recv_tel | body | string | 아니오 | max 20 | 수령인 연락처 (미전송 시 배송지 DB 값으로 채움) | +| recv_post | body | string | 아니오 | max 10 | 수령인 우편번호 (미전송 시 배송지 DB 값으로 채움) | +| recv_addr | body | string | 아니오 | max 200 | 수령인 주소 (미전송 시 배송지 DB 값으로 채움) | +| regist_name | body | string | 아니오 | max 30 | 등록자명 | +| send_name | body | string | 아니오 | max 30 | 발송인명 | +| send_tel | body | string | 아니오 | max 20 | 발송인 연락처 | +| send_post | body | string | 아니오 | max 10 | 발송인 우편번호 | +| send_addr | body | string | 아니오 | max 200 | 발송인 주소 | **요청 예시** @@ -828,6 +841,14 @@ POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-deliver Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "invoice": "1234567890123", + "ex_code": "cjgls", + "report": "I", + "charge": "SH" +} ``` **응답 필드** (`data` 내부) @@ -867,14 +888,14 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | | 404 | Not Found | 해당 주문에 KG 이니시스 에스크로 결제(`is_escrow = true` + TID 존재)가 없는 경우 | -| 422 | Unprocessable | `invoice` 미입력 또는 `ex_code` 가 공식 택배사 코드표에 없는 경우 (errors: `invoice` / `ex_code`) | +| 422 | Unprocessable | 요청 파라미터가 `EscrowDeliveryRegisterRequest` 검증 규칙을 위반한 경우 (표준 validation 응답, `error.errors` 에 필드별 메시지) — `invoice` 미입력·40자 초과(`운송장번호를 입력해주세요.`), `ex_code` 미입력·공식 택배사 코드표에 없음(`택배사를 선택해주세요.` — 다국어 키 `sirsoft-pay_kginicis::messages.escrow.*`), `report`/`charge` 허용값 위반, `recv_*`/`send_*`/`regist_name` 길이 상한 초과 등 | | 500 | Server Error | 배송등록 호출 중 예외 발생 (errors.message = 예외 메시지) | | 502 | Bad Gateway | KG 이니시스 배송등록 응답의 `resultCode` 가 `00` 이 아닌 경우 (errors.message = PG `resultMsg`) | **설명** -`AdminEscrowDeliveryController@register` 가 KG 이니시스 에스크로 결제의 배송정보를 INIAPI 에 등록한다. 운송장번호(`invoice`)와 택배사코드(`ex_code`, 공식 코드표에 존재해야 함)를 필수 검증하고, 수령인 정보는 요청값 우선·부재 시 배송지 DB 로 채운다. 에스크로 결제는 반드시 에스크로 자격증명(`useEscrowCredentials`)으로 `registerEscrowDelivery` 를 호출하며, 성공 시 정제된 PG 응답과 배송 이력을 `payment_meta.escrow_delivery` 에 저장한다. `sirsoft-ecommerce.orders.update` 권한이 필요하고, 입력 검증 실패 422 / 에스크로 결제 미존재 404 / PG 실패(resultCode≠`00`) 502 / 예외 500 으로 매핑된다. +`AdminEscrowDeliveryController@register` 가 KG 이니시스 에스크로 결제의 배송정보를 INIAPI 에 등록한다. 형식·길이·택배사 코드 검증은 `EscrowDeliveryRegisterRequest` 가 담당한다 — 운송장번호(`invoice`, 필수·최대 40자)와 택배사코드(`ex_code`, 필수·공식 코드표 `COURIER_CODES` 내 값), 등록/수정 구분(`report`: I/U)·배송비 부담(`charge`: SH/BH, 미전송·비허용 값은 컨트롤러 기본값 I/SH 로 흡수), 수령인·발송인 필드 길이 상한(이름 30자/전화 20자/우편번호 10자/주소 200자)을 검증하며 위반 시 표준 validation 422(다국어 메시지)로 거부한다. 수령인 정보는 요청값 우선·부재 시 배송지 DB 로 채운다. 에스크로 결제는 반드시 에스크로 자격증명(`useEscrowCredentials`)으로 `registerEscrowDelivery` 를 호출하며, 성공 시 정제된 PG 응답과 배송 이력을 `payment_meta.escrow_delivery` 에 저장한다. `sirsoft-ecommerce.orders.update` 권한이 필요하고, 입력 검증 실패 422 / 에스크로 결제 미존재 404 / PG 실패(resultCode≠`00`) 502 / 예외 500 으로 매핑된다. ### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-deny-confirm @@ -888,6 +909,7 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | orderNumber | path | string | 예 | — | 대상 order number의 식별자 | +| dcnf_name | body | string | 아니오 | max 20 | 구매거절 확인자명 (KG 이니시스 확인자명 규격 20자 — 초과·문자열 아님은 422, `EscrowDenyConfirmRequest` 검증). 미입력 시 다국어 기본값(`sirsoft-pay_kginicis::messages.escrow.default_confirmer`, ko: `관리자`)으로 채움 | **요청 예시** @@ -931,14 +953,14 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | | 404 | Not Found | 해당 주문에 KG 이니시스 에스크로 결제(`is_escrow = true` + TID 존재)가 없는 경우 | -| 422 | Unprocessable | 이미 구매거절확인 이력(`payment_meta.escrow_deny_confirm`)이 있는 경우 (errors.message = "이미 구매거절확인이 완료되었습니다.") | +| 422 | Unprocessable | `dcnf_name` 이 형식 검증(문자열, 최대 20자)을 위반한 경우 (표준 validation 응답, `error.errors.dcnf_name`) / 이미 구매거절확인 이력(`payment_meta.escrow_deny_confirm`)이 있는 경우 (errors.message = "이미 구매거절확인이 완료되었습니다.") | | 500 | Server Error | 구매거절확인 호출 중 예외 발생 (errors.message = 예외 메시지) | | 502 | Bad Gateway | KG 이니시스 구매거절확인 응답의 `resultCode` 가 `00` 이 아닌 경우 (errors.message = PG `resultMsg`) | **설명** -`AdminEscrowDenyConfirmController@confirm` 이 구매자가 구매거절을 선택한 에스크로 주문에 대해 판매자(관리자) 측 거절 확인을 처리한다(INIAPI v1 `type=Dncf`). 대상 에스크로 결제(`is_escrow = true`)를 찾아 이미 거절확인 이력(`payment_meta.escrow_deny_confirm`)이 있으면 중복 처리 없이 422 로 막고, 에스크로 자격증명으로 `denyConfirmEscrow` 를 호출한다. 성공 시 확인 시각과 담당자명(`dcnf_name`, 기본 "관리자"), 정제된 PG 응답을 메타에 기록한다. `sirsoft-ecommerce.orders.update` 권한이 필요하며, 에스크로 결제 미존재 404 / 이미 확인됨 422 / PG 실패(resultCode≠`00`) 502 / 예외 500 으로 매핑된다. +`AdminEscrowDenyConfirmController@confirm` 이 구매자가 구매거절을 선택한 에스크로 주문에 대해 판매자(관리자) 측 거절 확인을 처리한다(INIAPI v1 `type=Dncf`). 대상 에스크로 결제(`is_escrow = true`)를 찾아 이미 거절확인 이력(`payment_meta.escrow_deny_confirm`)이 있으면 중복 처리 없이 422 로 막고, 에스크로 자격증명으로 `denyConfirmEscrow` 를 호출한다. 성공 시 확인 시각과 담당자명(`dcnf_name`, 최대 20자 — `EscrowDenyConfirmRequest` 가 형식 검증하며 미입력 시 다국어 기본값 `escrow.default_confirmer`, ko: "관리자"), 정제된 PG 응답을 메타에 기록한다. `sirsoft-ecommerce.orders.update` 권한이 필요하며, 에스크로 결제 미존재 404 / 이미 확인됨 422 / PG 실패(resultCode≠`00`) 502 / 예외 500 으로 매핑된다. ### GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/transaction-status diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md index 05529161..b7ab54f7 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md @@ -25,7 +25,12 @@ **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| oid | body | string | 예 | max 40 | 주문번호 (`CbtCheckoutTokenRequest` 검증 — 40자 초과·문자열 아님은 422) | +| price | body | integer | 예 | min 1 | 결제 금액 (하한은 국내 결제와 동일한 `PaymentLimits::MIN_PRICE` SSoT — JPY 도 1 단위 결제 성립) | +| buyer_email | body | string | 아니오 | max 255 | 구매자 이메일 (주문 구매자 대조용) | +| buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호 (주문 구매자 대조용) | **요청 예시** @@ -33,6 +38,13 @@ _요청 파라미터 없음._ POST /api/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token HTTP/1.1 Host: api.example.com Accept: application/json +Content-Type: application/json + +{ + "oid": "ORD20260714001", + "price": 10000, + "buyer_email": "buyer@example.com" +} ``` **응답 필드** (`data` 내부) @@ -62,14 +74,14 @@ _이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->js | --- | --- | --- | | 403 | Forbidden | 요청의 구매자 정보(`buyer_email` / `buyer_phone`)가 주문의 구매자와 일치하지 않는 경우 | | 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 | -| 422 | Unprocessable Entity | `oid` / `price` 누락, 일본 CBT 결제 비활성화·미설정, 주문이 결제 가능 상태가 아님, 주문 통화가 JPY 가 아님, 금액이 주문 청구액과 불일치 | +| 422 | Unprocessable Entity | 요청 파라미터가 `CbtCheckoutTokenRequest` 검증 규칙(oid 필수·max 40 / price 필수·정수·min 1 / buyer_email max 255 / buyer_phone max 30)을 위반한 경우 (표준 validation 응답, `error.errors` 에 필드별 메시지) / 일본 CBT 결제 비활성화·미설정, 주문이 결제 가능 상태가 아님, 주문 통화가 JPY 가 아님, 금액이 주문 청구액과 불일치 | | 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 10회를 초과해 요청한 경우 | **설명** -일본 CBT(국경 간) 결제 흐름의 첫 단계로, 프론트엔드 결제창이 후속 해시 생성 요청에 사용할 단기 체크아웃 토큰을 발급합니다. `CbtCheckoutTokenService::issue()` 가 주문번호·금액·구매자(이메일/전화)·요청 IP·User-Agent 를 HMAC-SHA256 으로 봉인한 서명 토큰을 만들어 반환하며, 이 토큰은 hash-data 단계에서 결제 컨텍스트가 위변조되지 않았는지 검증하는 데 쓰입니다. 인증은 필요 없고(결제창에서 직접 호출), 대신 `oid` 기준 IP별 분당 10회 레이트리밋과 일본 결제 활성화·설정 여부, 주문 존재·결제 가능 상태·통화 JPY 여부·구매자 일치·금액 일치를 순차 검증합니다. 필수 파라미터(`oid`, `price`) 누락 시 422, 레이트리밋 초과 시 429, 주문 미존재 시 404, 구매자 검증 실패 시 403, 그 외 결제 불가 조건은 422 로 응답합니다. +일본 CBT(국경 간) 결제 흐름의 첫 단계로, 프론트엔드 결제창이 후속 해시 생성 요청에 사용할 단기 체크아웃 토큰을 발급합니다. `CbtCheckoutTokenService::issue()` 가 주문번호·금액·구매자(이메일/전화)·요청 IP·User-Agent 를 HMAC-SHA256 으로 봉인한 서명 토큰을 만들어 반환하며, 이 토큰은 hash-data 단계에서 결제 컨텍스트가 위변조되지 않았는지 검증하는 데 쓰입니다. 인증은 필요 없고(결제창에서 직접 호출), 대신 `oid` 기준 IP별 분당 10회 레이트리밋과 일본 결제 활성화·설정 여부, 주문 존재·결제 가능 상태·통화 JPY 여부·구매자 일치·금액 일치를 순차 검증합니다. 요청 형식·길이는 `CbtCheckoutTokenRequest` 가 국내 짝(`SignatureRequest`)과 동일 강도로 검증하며(`oid` max 40, `price` 하한은 `PaymentLimits::MIN_PRICE`), 위반 시 표준 validation 422, 레이트리밋 초과 시 429, 주문 미존재 시 404, 구매자 검증 실패 시 403, 그 외 결제 불가 조건은 422 로 응답합니다. ### POST /api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data @@ -80,7 +92,14 @@ _이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->js **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| oid | body | string | 예 | max 40 | 주문번호 (`CbtHashDataRequest` 검증) | +| price | body | integer | 예 | min 1 | 결제 금액 (하한은 `PaymentLimits::MIN_PRICE` SSoT) | +| timestamp | body | string | 예 | max 20 | 결제창 타임스탬프 (해시 재료 — 신선도 검사 대상) | +| checkout_token | body | string | 예 | max 1024 | checkout-token 단계에서 발급받은 체크아웃 토큰. **필수 검증으로 승격** — 누락·빈 값은 422 (종전에는 토큰 검증 단계에서 403) | +| buyer_email | body | string | 아니오 | max 255 | 구매자 이메일 (주문 구매자 대조용) | +| buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호 (주문 구매자 대조용) | **요청 예시** @@ -88,6 +107,14 @@ _요청 파라미터 없음._ POST /api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data HTTP/1.1 Host: api.example.com Accept: application/json +Content-Type: application/json + +{ + "oid": "ORD20260714001", + "price": 10000, + "timestamp": "20260714120000123", + "checkout_token": "eyJvaWQiOiJPUkQyMDI2MDcxNDAwMSIsInByaWNlIjoxMDAwMH0.5f1c…" +} ``` **응답 필드** (`data` 내부) @@ -117,14 +144,14 @@ _이 엔드포인트는 `ResponseHelper` 봉투를 쓰지 않고 `response()->js | --- | --- | --- | | 403 | Forbidden | 구매자 정보가 주문과 불일치하거나, checkout-token 검증에 실패한 경우(토큰 위조·컨텍스트 불일치) | | 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 | -| 422 | Unprocessable Entity | `oid` / `price` / `timestamp` 누락, 타임스탬프 만료·형식 오류(재생 공격 방지), 일본 CBT 결제 비활성화·미설정, 주문이 결제 가능 상태가 아님, 주문 통화가 JPY 가 아님, 금액이 주문 청구액과 불일치 | +| 422 | Unprocessable Entity | 요청 파라미터가 `CbtHashDataRequest` 검증 규칙(oid 필수·max 40 / price 필수·정수·min 1 / timestamp 필수·max 20 / checkout_token 필수·max 1024)을 위반한 경우 (표준 validation 응답 — **계약 변경**: 종전에는 `checkout_token` 이 비어 있으면 토큰 검증 단계에서 403 이었으나 필수 검증 승격으로 422 가 된다) / 타임스탬프 만료·형식 오류(재생 공격 방지), 일본 CBT 결제 비활성화·미설정, 주문이 결제 가능 상태가 아님, 주문 통화가 JPY 가 아님, 금액이 주문 청구액과 불일치 | | 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 10회를 초과해 요청한 경우 | **설명** -일본 CBT 결제창이 실제로 KG 이니시스에 전송할 위변조 방지 해시(P_HASHDATA)를 생성해 반환합니다. `KgInicisApiService::generateCbtHashData()` 가 일본 가맹점 MID·타임스탬프·금액·주문번호로 해시를 만들며, 인증은 불필요하지만 checkout-token 단계에서 발급한 토큰을 `CbtCheckoutTokenService::verify()` 로 재검증해 동일한 결제 컨텍스트(주문·금액·구매자·IP·UA)에서 온 요청임을 보장합니다. 재생 공격을 막기 위해 타임스탬프 신선도(`isTimestampFresh`)를 확인하고, `oid` 기준 IP별 분당 10회 레이트리밋과 일본 결제 활성화·설정·주문 상태·통화 JPY·구매자 일치·금액 일치를 검증합니다. 파라미터(`oid`, `price`, `timestamp`) 누락·타임스탬프 만료·금액 불일치 등은 422, 레이트리밋 초과 429, 주문 미존재 404, 구매자 검증 또는 토큰 검증 실패는 403 으로 응답합니다. +일본 CBT 결제창이 실제로 KG 이니시스에 전송할 위변조 방지 해시(P_HASHDATA)를 생성해 반환합니다. `KgInicisApiService::generateCbtHashData()` 가 일본 가맹점 MID·타임스탬프·금액·주문번호로 해시를 만들며, 인증은 불필요하지만 checkout-token 단계에서 발급한 토큰을 `CbtCheckoutTokenService::verify()` 로 재검증해 동일한 결제 컨텍스트(주문·금액·구매자·IP·UA)에서 온 요청임을 보장합니다. 재생 공격을 막기 위해 타임스탬프 신선도(`isTimestampFresh`)를 확인하고, `oid` 기준 IP별 분당 10회 레이트리밋과 일본 결제 활성화·설정·주문 상태·통화 JPY·구매자 일치·금액 일치를 검증합니다. 요청 형식·길이는 `CbtHashDataRequest` 가 검증합니다 — 파라미터(`oid`, `price`, `timestamp`, `checkout_token`) 누락·길이 초과·타임스탬프 만료·금액 불일치 등은 422, 레이트리밋 초과 429, 주문 미존재 404, 구매자 검증 또는 (비어 있지 않은 토큰의) 토큰 검증 실패는 403 으로 응답합니다. 계약 변경: 종전에는 `checkout_token` 이 빈 값이면 토큰 검증 단계에서 403 이었으나, 필수 검증으로 승격되어 지금은 422 입니다 (클라이언트는 응답의 `.ok` 만 검사하므로 영향 없음). ### POST /api/plugins/sirsoft-pay_kginicis/payment/close-report diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md index 6a6d9fde..0fa5559e 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md @@ -25,7 +25,9 @@ **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| tid | body | string | 예 | max 100 | 조회할 KG 이니시스 거래번호(TID). 누락·문자열 아님·100자 초과는 422 (`TransactionQueryRequest` 검증 — 국내 30자·해외 CBT 40자 형식을 모두 수용, 상한은 저장 컬럼 varchar(100)) | **요청 예시** @@ -34,6 +36,11 @@ POST /api/plugins/sirsoft-pay_kginicis/admin/transaction/query HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "tid": "StdpayCARDSIRSOFT001202606191358556131" +} ``` **응답 필드** (`data` 내부) @@ -154,13 +161,13 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | -| 422 | Unprocessable Entity | `tid` 가 비어 있는 경우 (`errors.tid` = `TID를 입력하세요.`) | +| 422 | Unprocessable Entity | `tid` 가 검증 규칙(필수, 문자열, 최대 100자)을 위반한 경우 (`error.errors.tid` 에 표준 validation 메시지 — 배열 주입도 문자열 규칙이 차단) | | 502 | Bad Gateway | KG 이니시스 조회 API 호출 실패(HTTP 오류·타임아웃 등) 시 | **설명** -거래번호(TID)를 직접 입력받아 KG 이니시스 거래 상태를 조회하고 화면 표시용으로 보강해 반환하는 관리자 엔드포인트입니다. 로컬 결제 레코드(`ecommerce_order_payments`)에서 결제 시점 MID·테스트 모드·에스크로 여부를 해석해 해당 자격증명으로 `KgInicisApiService::queryTransaction()` 을 호출하며, 응답을 카드/가상계좌/간편결제/취소 이력 등 상세 필드로 정규화하고 은행 코드→은행명·할부 개월·날짜 포맷을 변환합니다. 일본 CBT 거래(TID `INIJPG` prefix 또는 통화 JPY)는 한국 INIAPI 조회 대상이 아니므로 저장된 로컬 승인/입금 확인 정보로 결과를 구성합니다. 관리자 인증(`auth:sanctum`)과 `sirsoft-ecommerce.orders.read` 권한이 필요하며, `tid` 미입력은 422, 토큰 누락·만료 401, 권한 부족 403, KG 이니시스 조회 실패 시 502 로 응답합니다. +거래번호(TID)를 직접 입력받아 KG 이니시스 거래 상태를 조회하고 화면 표시용으로 보강해 반환하는 관리자 엔드포인트입니다. 로컬 결제 레코드(`ecommerce_order_payments`)에서 결제 시점 MID·테스트 모드·에스크로 여부를 해석해 해당 자격증명으로 `KgInicisApiService::queryTransaction()` 을 호출하며, 응답을 카드/가상계좌/간편결제/취소 이력 등 상세 필드로 정규화하고 은행 코드→은행명·할부 개월·날짜 포맷을 변환합니다. 일본 CBT 거래(TID `INIJPG` prefix 또는 통화 JPY)는 한국 INIAPI 조회 대상이 아니므로 저장된 로컬 승인/입금 확인 정보로 결과를 구성합니다. 관리자 인증(`auth:sanctum`)과 `sirsoft-ecommerce.orders.read` 권한이 필요하며, `tid` 는 `TransactionQueryRequest` 가 필수·문자열·최대 100자(국내 30자·해외 CBT 40자 형식 수용, 저장 컬럼 기준)로 검증해 위반 시 표준 validation 422, 토큰 누락·만료 401, 권한 부족 403, KG 이니시스 조회 실패 시 502 로 응답합니다. diff --git a/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php b/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php index 3c54a5e2..f36b377c 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php @@ -12,6 +12,11 @@ return [ 'cash_receipt' => [ 'provider_name' => 'KG Inicis', ], + 'escrow' => [ + 'invoice_required' => 'Please enter the tracking number.', + 'courier_required' => 'Please select a courier.', + 'default_confirmer' => 'Administrator', + ], 'refund' => [ 'missing_tid' => 'Cannot process refund: transaction ID (TID) is missing.', 'default_reason' => 'Buyer refund request', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php b/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php index 9a1099bd..8e66f887 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php @@ -12,6 +12,11 @@ return [ 'cash_receipt' => [ 'provider_name' => 'KG이니시스', ], + 'escrow' => [ + 'invoice_required' => '운송장번호를 입력해주세요.', + 'courier_required' => '택배사를 선택해주세요.', + 'default_confirmer' => '관리자', + ], 'refund' => [ 'missing_tid' => '거래 ID(TID)가 없어 환불을 진행할 수 없습니다.', 'default_reason' => '구매자 환불 요청', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/admin_order_payment_query.json b/plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/admin_order_payment_query.json index 8d378392..98e79ed6 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/admin_order_payment_query.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/admin_order_payment_query.json @@ -324,7 +324,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-pay_kginicis.admin.cbt_reconciliation_retry_error" + "message": "{{error.message ?? '$t:sirsoft-pay_kginicis.admin.cbt_reconciliation_retry_error'}}" } }, { @@ -707,7 +707,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-pay_kginicis.admin.cbt_cvs_action_error" + "message": "{{error.message ?? '$t:sirsoft-pay_kginicis.admin.cbt_cvs_action_error'}}" } }, { @@ -829,7 +829,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-pay_kginicis.admin.cbt_cvs_action_error" + "message": "{{error.message ?? '$t:sirsoft-pay_kginicis.admin.cbt_cvs_action_error'}}" } }, { @@ -945,7 +945,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:sirsoft-pay_kginicis.admin.cbt_cvs_action_error" + "message": "{{error.message ?? '$t:sirsoft-pay_kginicis.admin.cbt_cvs_action_error'}}" } }, { diff --git a/plugins/_bundled/sirsoft-pay_kginicis/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-pay_kginicis/resources/layouts/admin/plugin_settings.json index a4df03c1..79fd3b8e 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/resources/layouts/admin/plugin_settings.json @@ -80,7 +80,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } }, { @@ -201,7 +201,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" } }, { @@ -430,7 +430,7 @@ "if": "{{!_local._show_live_sign_key}}", "props": { "name": "eye", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -439,7 +439,7 @@ "if": "{{_local._show_live_sign_key}}", "props": { "name": "eye-slash", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -531,7 +531,7 @@ "if": "{{!_local._show_live_iniapi_key}}", "props": { "name": "eye", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -540,7 +540,7 @@ "if": "{{_local._show_live_iniapi_key}}", "props": { "name": "eye-slash", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -632,7 +632,7 @@ "if": "{{!_local._show_live_iniapi_iv}}", "props": { "name": "eye", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -641,7 +641,7 @@ "if": "{{_local._show_live_iniapi_iv}}", "props": { "name": "eye-slash", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -733,7 +733,7 @@ "if": "{{!_local._show_live_mobile_hash_key}}", "props": { "name": "eye", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -742,7 +742,7 @@ "if": "{{_local._show_live_mobile_hash_key}}", "props": { "name": "eye-slash", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -1205,7 +1205,7 @@ "name": "Icon", "props": { "name": "flask-conical", - "className": "w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" } }, { @@ -1299,7 +1299,7 @@ "if": "{{_local.cbtTestProductCreating}}", "props": { "name": "spinner", - "className": "w-3 h-3 animate-spin" + "className": "text-xs animate-spin" } }, { @@ -1308,7 +1308,7 @@ "if": "{{!_local.cbtTestProductCreating}}", "props": { "name": "plus", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1333,7 +1333,7 @@ "name": "Icon", "props": { "name": "external-link-alt", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1426,7 +1426,7 @@ "name": "Icon", "props": { "name": "shield-alt", - "className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" } }, { @@ -1520,7 +1520,7 @@ "if": "{{_local.cbtConnCheckRunning}}", "props": { "name": "spinner", - "className": "w-3 h-3 animate-spin" + "className": "text-xs animate-spin" } }, { @@ -1529,7 +1529,7 @@ "if": "{{!_local.cbtConnCheckRunning}}", "props": { "name": "satellite-dish", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1862,7 +1862,7 @@ "name": "Span", "if": "{{!host.tcp_443_reachable}}", "props": { - "className": "px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/40text-danger-soft font-medium" + "className": "px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/40 text-danger-soft font-medium" }, "text": "✗ {{host.tcp_443_error ?? '$t:sirsoft-pay_kginicis.settings.cbt_connectivity_unreachable'}}" } @@ -2494,7 +2494,7 @@ "name": "Icon", "props": { "name": "bell", - "className": "w-4 h-4 text-blue-600 dark:text-blue-400" + "className": "text-base text-blue-600 dark:text-blue-400" } }, { @@ -2578,7 +2578,7 @@ "name": "Icon", "props": { "name": "copy", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -2709,7 +2709,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.plugins.settings.save_error" + "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtCvsOperationsController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtCvsOperationsController.php index 648a2483..066d1489 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtCvsOperationsController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtCvsOperationsController.php @@ -45,6 +45,7 @@ class AdminCbtCvsOperationsController extends AdminBaseController * @param string $orderNumber 주문번호 * @return JsonResponse 응답 */ + // audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — 감사 로그용 ip() 만 참조 (검증 대상 필드 없음) public function simulateNotify(Request $request, string $orderNumber): JsonResponse { $result = $this->operationsService->simulatePaidNotify($orderNumber, $request->ip()); diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php index aca998c4..569a14f4 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDeliveryController.php @@ -9,13 +9,13 @@ namespace Plugins\Sirsoft\PayKginicis\Controllers; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderAddress; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse; +use Plugins\Sirsoft\PayKginicis\Http\Requests\EscrowDeliveryRegisterRequest; use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService; /** @@ -28,8 +28,8 @@ class AdminEscrowDeliveryController extends AdminBaseController { use SanitizesPgResponse; - /** 택배사 코드 → 택배사명 매핑 (KG 이니시스 공식 코드표) */ - private const COURIER_CODES = [ + /** 택배사 코드 → 택배사명 매핑 (KG 이니시스 공식 코드표 — EscrowDeliveryRegisterRequest 의 허용값 SSoT) */ + public const COURIER_CODES = [ 'hanjin' => '한진택배', 'cjgls' => 'CJ대한통운', 'loge' => '롯데택배', @@ -80,6 +80,7 @@ class AdminEscrowDeliveryController extends AdminBaseController return ResponseHelper::success('common.success', null); } + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $address = DB::table((new OrderAddress)->getTable().' as a') ->join((new Order)->getTable().' as o', 'o.id', '=', 'a.order_id') ->where('o.order_number', $orderNumber) @@ -119,22 +120,17 @@ class AdminEscrowDeliveryController extends AdminBaseController /** * register * - * @param Request $request - * @param string $orderNumber + * 형식·길이·택배사 코드 검증은 EscrowDeliveryRegisterRequest 가 담당한다 + * (종전 인라인 한글 하드코딩 메시지를 다국어 키로 이관). + * + * @param EscrowDeliveryRegisterRequest $request 배송 등록 폼 + * @param string $orderNumber 주문번호 * @return JsonResponse */ - public function register(Request $request, string $orderNumber): JsonResponse + public function register(EscrowDeliveryRegisterRequest $request, string $orderNumber): JsonResponse { - $invoice = trim((string) $request->input('invoice', '')); - $exCode = trim((string) $request->input('ex_code', '')); - - if ($invoice === '') { - return ResponseHelper::error('common.failed', 422, ['invoice' => ['운송장번호를 입력해주세요.']]); - } - - if ($exCode === '' || ! array_key_exists($exCode, self::COURIER_CODES)) { - return ResponseHelper::error('common.failed', 422, ['ex_code' => ['택배사를 선택해주세요.']]); - } + $invoice = trim((string) $request->validated('invoice')); + $exCode = trim((string) $request->validated('ex_code')); $payment = $this->findEscrowPayment($orderNumber); @@ -142,18 +138,19 @@ class AdminEscrowDeliveryController extends AdminBaseController return ResponseHelper::error('common.failed', 404, null); } - $report = in_array($request->input('report'), ['I', 'U'], true) ? $request->input('report') : 'I'; - $charge = in_array($request->input('charge'), ['SH', 'BH'], true) ? $request->input('charge') : 'SH'; + $report = $request->validated('report') ?? 'I'; + $charge = $request->validated('charge') ?? 'SH'; $price = (int) round((float) $payment->paid_amount_local); $exName = self::COURIER_CODES[$exCode]; // 수신자 주소: 요청 우선, 없으면 DB 조회 - $recvName = trim((string) $request->input('recv_name', '')); - $recvTel = trim((string) $request->input('recv_tel', '')); - $recvPost = trim((string) $request->input('recv_post', '')); - $recvAddr = trim((string) $request->input('recv_addr', '')); + $recvName = trim((string) ($request->validated('recv_name') ?? '')); + $recvTel = trim((string) ($request->validated('recv_tel') ?? '')); + $recvPost = trim((string) ($request->validated('recv_post') ?? '')); + $recvAddr = trim((string) ($request->validated('recv_addr') ?? '')); if ($recvName === '' || $recvTel === '' || $recvAddr === '') { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $address = DB::table((new OrderAddress)->getTable().' as a') ->join((new Order)->getTable().' as o', 'o.id', '=', 'a.order_id') ->where('o.order_number', $orderNumber) @@ -185,15 +182,15 @@ class AdminEscrowDeliveryController extends AdminBaseController 'price' => $price, 'report' => $report, 'invoice' => $invoice, - 'registName' => trim((string) $request->input('regist_name', '')), + 'registName' => trim((string) ($request->validated('regist_name') ?? '')), 'exCode' => $exCode, 'exName' => $exName, 'charge' => $charge, 'invoiceDay' => date('Y-m-d H:i:s'), - 'sendName' => trim((string) $request->input('send_name', '')), - 'sendTel' => trim((string) $request->input('send_tel', '')), - 'sendPost' => trim((string) $request->input('send_post', '')), - 'sendAddr1' => trim((string) $request->input('send_addr', '')), + 'sendName' => trim((string) ($request->validated('send_name') ?? '')), + 'sendTel' => trim((string) ($request->validated('send_tel') ?? '')), + 'sendPost' => trim((string) ($request->validated('send_post') ?? '')), + 'sendAddr1' => trim((string) ($request->validated('send_addr') ?? '')), 'recvName' => $recvName, 'recvTel' => $recvTel, 'recvPost' => $recvPost, @@ -231,6 +228,7 @@ class AdminEscrowDeliveryController extends AdminBaseController 'pg_response' => $sanitizedPgResponse, ]; + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 DB::table((new OrderPayment)->getTable()) ->where('id', $payment->id) ->update([ @@ -266,6 +264,7 @@ class AdminEscrowDeliveryController extends AdminBaseController private function findEscrowPayment(string $orderNumber): ?object { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 return DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php index 0bd31969..0ad9ad5f 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminEscrowDenyConfirmController.php @@ -9,12 +9,12 @@ namespace Plugins\Sirsoft\PayKginicis\Controllers; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse; +use Plugins\Sirsoft\PayKginicis\Http\Requests\EscrowDenyConfirmRequest; use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService; /** @@ -48,11 +48,13 @@ class AdminEscrowDenyConfirmController extends AdminBaseController /** * confirm * - * @param Request $request - * @param string $orderNumber + * 확인자명 형식 검증은 EscrowDenyConfirmRequest 가 담당한다. + * + * @param EscrowDenyConfirmRequest $request 구매거절확인 폼 + * @param string $orderNumber 주문번호 * @return JsonResponse */ - public function confirm(Request $request, string $orderNumber): JsonResponse + public function confirm(EscrowDenyConfirmRequest $request, string $orderNumber): JsonResponse { $payment = $this->findEscrowPayment($orderNumber); @@ -69,7 +71,7 @@ class AdminEscrowDenyConfirmController extends AdminBaseController ]); } - $dcnfName = trim((string) $request->input('dcnf_name', '관리자')); + $dcnfName = $request->confirmerName(); Log::info('KG Inicis: escrow deny confirm requested', [ 'order_number' => $orderNumber, @@ -108,6 +110,7 @@ class AdminEscrowDenyConfirmController extends AdminBaseController 'pg_response' => $sanitizedPgResponse, ]; + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 DB::table((new OrderPayment)->getTable()) ->where('id', $payment->id) ->update([ @@ -139,6 +142,7 @@ class AdminEscrowDenyConfirmController extends AdminBaseController private function findEscrowPayment(string $orderNumber): ?object { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 return DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php index 344ee7f6..5156de8c 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminOrderListController.php @@ -38,6 +38,7 @@ class AdminOrderListController extends AdminBaseController $orders = (new Order)->getTable(); $payments = (new OrderPayment)->getTable(); + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $rows = DB::table($orders.' as o') ->join($payments.' as p', 'p.order_id', '=', 'o.id') ->where('p.pg_provider', 'kginicis') diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php index 06dae17d..b4c253d8 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminTransactionController.php @@ -10,11 +10,11 @@ use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Carbon\Carbon; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; +use Plugins\Sirsoft\PayKginicis\Http\Requests\TransactionQueryRequest; use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService; class AdminTransactionController extends AdminBaseController @@ -28,18 +28,12 @@ class AdminTransactionController extends AdminBaseController /** * TID 직접 조회 * - * @param Request $request + * @param TransactionQueryRequest $request tid 입력 폼 * @return JsonResponse */ - public function query(Request $request): JsonResponse + public function query(TransactionQueryRequest $request): JsonResponse { - $tid = trim((string) $request->input('tid', '')); - - if ($tid === '') { - return ResponseHelper::error('common.failed', 422, ['tid' => ['TID를 입력하세요.']]); - } - - return $this->queryByTid($tid); + return $this->queryByTid(trim((string) $request->validated('tid'))); } /** @@ -53,6 +47,7 @@ class AdminTransactionController extends AdminBaseController $orders = (new Order)->getTable(); $payments = (new OrderPayment)->getTable(); + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $payment = DB::table($payments.' as p') ->join($orders.' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) @@ -75,6 +70,7 @@ class AdminTransactionController extends AdminBaseController $orders = (new Order)->getTable(); $payments = (new OrderPayment)->getTable(); + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $localPayment = DB::table($payments.' as p') ->leftJoin($orders.' as o', 'o.id', '=', 'p.order_id') ->where('p.transaction_id', $tid) diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCheckoutTokenController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCheckoutTokenController.php index 74e666e7..172d1482 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCheckoutTokenController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCheckoutTokenController.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayKginicis\Controllers; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; +use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext; +use Plugins\Sirsoft\PayKginicis\Http\Requests\CbtCheckoutTokenRequest; use Plugins\Sirsoft\PayKginicis\Services\CbtCheckoutTokenService; use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService; -use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; class CbtCheckoutTokenController { @@ -22,19 +22,20 @@ class CbtCheckoutTokenController private readonly CbtCheckoutTokenService $checkoutTokenService, ) {} - public function issue(Request $request): JsonResponse + /** + * 해외결제(CBT) 체크아웃 토큰 발급 + * + * 형식·길이 검증은 CbtCheckoutTokenRequest 가 담당한다 (국내 SignatureRequest 와 동일 강도). + * + * @param CbtCheckoutTokenRequest $request oid/price/buyer 정보 + * @return JsonResponse checkout_token 또는 403/404/422 + */ + public function issue(CbtCheckoutTokenRequest $request): JsonResponse { - $oid = (string) $request->input('oid', ''); - $price = (int) $request->input('price', 0); + $oid = (string) $request->validated('oid'); + $price = (int) $request->validated('price'); - if ($oid === '' || $price <= 0) { - return response()->json([ - 'success' => false, - 'message' => 'Missing required parameters: oid, price', - ], 422); - } - - $rateLimitKey = 'sirsoft-pay_kginicis:cbt-token:' . sha1($request->ip() . '|' . $oid); + $rateLimitKey = 'sirsoft-pay_kginicis:cbt-token:'.sha1($request->ip().'|'.$oid); if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { return response()->json([ 'success' => false, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtHashDataController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtHashDataController.php index 13d839a1..25a02f9d 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtHashDataController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtHashDataController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\RateLimiter; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext; use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesTimestampFreshness; +use Plugins\Sirsoft\PayKginicis\Http\Requests\CbtHashDataRequest; use Plugins\Sirsoft\PayKginicis\Services\CbtCheckoutTokenService; use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService; @@ -27,22 +28,18 @@ class CbtHashDataController /** * generate * - * @param Request $request - * @return JsonResponse + * 형식·길이 검증은 CbtHashDataRequest 가 담당한다 (국내 SignatureRequest 와 동일 강도). + * 계약 변경: 빈 checkout_token 은 종전 403(토큰 검증 실패) → 422(필수 검증). + * + * @param CbtHashDataRequest $request oid/price/timestamp/checkout_token/buyer 정보 + * @return JsonResponse hash_data 또는 403/404/422 */ - public function generate(Request $request): JsonResponse + public function generate(CbtHashDataRequest $request): JsonResponse { - $oid = (string) $request->input('oid', ''); - $price = (int) $request->input('price', 0); - $timestamp = (string) $request->input('timestamp', ''); - $checkoutToken = (string) $request->input('checkout_token', ''); - - if ($oid === '' || $price <= 0 || $timestamp === '') { - return response()->json([ - 'success' => false, - 'message' => 'Missing required parameters: oid, price, timestamp', - ], 422); - } + $oid = (string) $request->validated('oid'); + $price = (int) $request->validated('price'); + $timestamp = (string) $request->validated('timestamp'); + $checkoutToken = (string) $request->validated('checkout_token'); $rateLimitKey = $this->rateLimitKey($request, $oid); if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { @@ -138,6 +135,6 @@ class CbtHashDataController private function rateLimitKey(Request $request, string $oid): string { - return 'sirsoft-pay_kginicis:cbt-hash:' . sha1($request->ip() . '|' . $oid); + return 'sirsoft-pay_kginicis:cbt-hash:'.sha1($request->ip().'|'.$oid); } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserEscrowConfirmController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserEscrowConfirmController.php index 5ec68448..642a6aca 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserEscrowConfirmController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserEscrowConfirmController.php @@ -42,6 +42,7 @@ class UserEscrowConfirmController extends Controller * @param string $orderNumber * @return Response */ + // audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — 에스크로 확인 HTML 화면 렌더 (검증 대상 필드 없음) public function show(Request $request, string $orderNumber): Response { $payment = $this->findEscrowPayment($orderNumber, (int) Auth::id()); @@ -65,6 +66,7 @@ class UserEscrowConfirmController extends Controller * @param Request $request * @return Response */ + // audit:allow controller-base-request-injection reason: PG(이니시스)가 POST 하는 콜백 표면 — 필드 구성이 PG 소유라 계약을 닫을 수 없고, 값은 문자열 캐스팅 후 대조에만 사용 public function pcReturn(Request $request): Response { $resultCode = (string) $request->input('ResultCode', ''); @@ -107,6 +109,7 @@ class UserEscrowConfirmController extends Controller * @param Request $request * @return RedirectResponse */ + // audit:allow controller-base-request-injection reason: PG(이니시스 모바일)가 POST 하는 콜백 표면 — 필드 구성이 PG 소유라 계약을 닫을 수 없고, 값은 문자열 캐스팅 후 대조에만 사용 public function mobileReturn(Request $request): RedirectResponse { $status = (string) $request->input('P_STATUS', ''); @@ -228,6 +231,7 @@ class UserEscrowConfirmController extends Controller return null; } + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $row = DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('p.transaction_id', $tid) @@ -248,6 +252,7 @@ class UserEscrowConfirmController extends Controller $data, ); + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 DB::table((new OrderPayment)->getTable()) ->where('id', $row->id) ->update([ @@ -266,6 +271,7 @@ class UserEscrowConfirmController extends Controller private function findEscrowPayment(string $orderNumber, int $userId): ?object { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 return DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserReceiptController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserReceiptController.php index 4ee6a598..efc73c43 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserReceiptController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/UserReceiptController.php @@ -43,8 +43,10 @@ class UserReceiptController * @param string $orderNumber * @return JsonResponse */ + // audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user()/비회원 영수증 쿠키만 참조 (검증 대상 필드 없음) public function show(Request $request, string $orderNumber): JsonResponse { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $query = DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/CbtCheckoutTokenRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/CbtCheckoutTokenRequest.php new file mode 100644 index 00000000..69e2be18 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/CbtCheckoutTokenRequest.php @@ -0,0 +1,44 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'oid' => ['required', 'string', 'max:40'], + // 하한은 국내와 동일하게 PaymentLimits SSoT (JPY 도 1 단위 결제 성립) + 'price' => ['required', 'integer', 'min:'.PaymentLimits::MIN_PRICE], + 'buyer_email' => ['nullable', 'string', 'max:255'], + 'buyer_phone' => ['nullable', 'string', 'max:30'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/CbtHashDataRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/CbtHashDataRequest.php new file mode 100644 index 00000000..d7d9e9aa --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/CbtHashDataRequest.php @@ -0,0 +1,46 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'oid' => ['required', 'string', 'max:40'], + 'price' => ['required', 'integer', 'min:'.PaymentLimits::MIN_PRICE], + 'timestamp' => ['required', 'string', 'max:20'], + 'checkout_token' => ['required', 'string', 'max:1024'], + 'buyer_email' => ['nullable', 'string', 'max:255'], + 'buyer_phone' => ['nullable', 'string', 'max:30'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/EscrowDeliveryRegisterRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/EscrowDeliveryRegisterRequest.php new file mode 100644 index 00000000..f64baf9f --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/EscrowDeliveryRegisterRequest.php @@ -0,0 +1,68 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'invoice' => ['required', 'string', 'max:40'], + 'ex_code' => ['required', 'string', Rule::in(array_keys(AdminEscrowDeliveryController::COURIER_CODES))], + 'report' => ['nullable', 'string', 'in:I,U'], + 'charge' => ['nullable', 'string', 'in:SH,BH'], + 'recv_name' => ['nullable', 'string', 'max:30'], + 'recv_tel' => ['nullable', 'string', 'max:20'], + 'recv_post' => ['nullable', 'string', 'max:10'], + 'recv_addr' => ['nullable', 'string', 'max:200'], + 'regist_name' => ['nullable', 'string', 'max:30'], + 'send_name' => ['nullable', 'string', 'max:30'], + 'send_tel' => ['nullable', 'string', 'max:20'], + 'send_post' => ['nullable', 'string', 'max:10'], + 'send_addr' => ['nullable', 'string', 'max:200'], + ]; + } + + /** + * 검증 오류 메시지를 반환합니다 (종전 한글 하드코딩을 다국어 키로 이관). + * + * @return array 규칙별 메시지 + */ + public function messages(): array + { + return [ + 'invoice.required' => __('sirsoft-pay_kginicis::messages.escrow.invoice_required'), + 'ex_code.required' => __('sirsoft-pay_kginicis::messages.escrow.courier_required'), + 'ex_code.in' => __('sirsoft-pay_kginicis::messages.escrow.courier_required'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/EscrowDenyConfirmRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/EscrowDenyConfirmRequest.php new file mode 100644 index 00000000..ccc012f6 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/EscrowDenyConfirmRequest.php @@ -0,0 +1,51 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'dcnf_name' => ['nullable', 'string', 'max:20'], + ]; + } + + /** + * 확인자명을 반환합니다 (미입력 시 다국어 기본값). + * + * @return string 확인자명 + */ + public function confirmerName(): string + { + $name = trim((string) ($this->validated('dcnf_name') ?? '')); + + return $name !== '' ? $name : __('sirsoft-pay_kginicis::messages.escrow.default_confirmer'); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/PaymentCloseReportRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/PaymentCloseReportRequest.php index c228f6dc..fd456765 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/PaymentCloseReportRequest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/PaymentCloseReportRequest.php @@ -32,7 +32,8 @@ class PaymentCloseReportRequest extends FormRequest 'buyer_email' => ['nullable', 'string', 'max:255'], 'buyer_phone' => ['nullable', 'string', 'max:30'], 'payment_method' => ['nullable', 'string', 'max:50'], - 'reason' => ['nullable', 'string', 'max:80'], + // 내부 텔레메트리 필드 (PG 미전송 실측) — 형제 플러그인(nhnkcp/nicepayments)과 160 으로 통일 + 'reason' => ['nullable', 'string', 'max:160'], ]; } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/TransactionQueryRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/TransactionQueryRequest.php new file mode 100644 index 00000000..362d8890 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/TransactionQueryRequest.php @@ -0,0 +1,39 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'tid' => ['required', 'string', 'max:100'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php index 22659e74..105a10f5 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php @@ -142,4 +142,20 @@ class AdminCbtTestProductControllerTest extends PluginTestCase Http::assertNothingSent(); } + + /** + * tid 게이트 상한은 저장 컬럼(transaction_id varchar(100))이 SSoT (회귀 고정). + * + * 국내 TID(30자) 길이로 조이면 40자 CBT TID 조회가 422 로 깨진다 — 위 테스트가 + * 그 회귀를 잡았다. 여기서는 반대편 경계(101자 초과 거부)를 고정한다. + */ + public function test_transaction_query_rejects_tid_over_storage_column_length(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']); + $this->actingAs($admin); + + $this->postJson('/api/plugins/sirsoft-pay_kginicis/admin/transaction/query', [ + 'tid' => str_repeat('T', 101), + ])->assertStatus(422); + } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminEscrowControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminEscrowControllerTest.php index d8f4dc37..df45bc27 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminEscrowControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminEscrowControllerTest.php @@ -110,6 +110,133 @@ class AdminEscrowControllerTest extends PluginTestCase $this->assertArrayNotHasKey('buyerTel', $storedPgResponse); } + /** + * 운송장번호 누락은 EscrowDeliveryRegisterRequest 가 표준 422 로 차단하고, + * 메시지는 다국어 키(escrow.invoice_required)의 ko 문구여야 한다 + * (종전 컨트롤러 인라인 한글 하드코딩 → 다국어 키 이관 회귀 방지). + */ + public function test_escrow_delivery_register_rejects_missing_invoice_with_localized_message(): void + { + + $this->createEscrowPayment('ORD-ESCROW-VAL-001'); + + $mock = $this->createMock(KgInicisApiService::class); + $mock->expects($this->never())->method('useEscrowCredentials'); + $mock->expects($this->never())->method('registerEscrowDelivery'); + $this->app->instance(KgInicisApiService::class, $mock); + + $response = $this->actingAs($this->adminUser) + ->withHeaders(['Accept-Language' => 'ko']) + ->postJson('/api/plugins/sirsoft-pay_kginicis/admin/orders/ORD-ESCROW-VAL-001/escrow-delivery', [ + 'ex_code' => 'hanjin', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['invoice']) + ->assertJsonPath('errors.invoice.0', '운송장번호를 입력해주세요.'); + } + + /** + * 코드표(COURIER_CODES) 밖의 택배사 코드는 Rule::in 이 422 로 차단해야 한다. + */ + public function test_escrow_delivery_register_rejects_unknown_courier_code(): void + { + + $this->createEscrowPayment('ORD-ESCROW-VAL-002'); + + $mock = $this->createMock(KgInicisApiService::class); + $mock->expects($this->never())->method('registerEscrowDelivery'); + $this->app->instance(KgInicisApiService::class, $mock); + + $response = $this->actingAs($this->adminUser) + ->withHeaders(['Accept-Language' => 'ko']) + ->postJson('/api/plugins/sirsoft-pay_kginicis/admin/orders/ORD-ESCROW-VAL-002/escrow-delivery', [ + 'invoice' => 'INV-001', + 'ex_code' => 'nope', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['ex_code']) + ->assertJsonPath('errors.ex_code.0', '택배사를 선택해주세요.'); + } + + /** + * 수령인 필드 상한 초과(이름 31자 / 전화 21자 / 우편 11자 / 주소 201자)는 + * KG 이니시스 연동 스펙 상한을 넘으므로 422 로 차단하고 PG 호출은 없어야 한다. + */ + public function test_escrow_delivery_register_rejects_overlong_recv_fields(): void + { + $this->createEscrowPayment('ORD-ESCROW-VAL-003'); + + $mock = $this->createMock(KgInicisApiService::class); + $mock->expects($this->never())->method('registerEscrowDelivery'); + $this->app->instance(KgInicisApiService::class, $mock); + + $response = $this->actingAs($this->adminUser) + ->postJson('/api/plugins/sirsoft-pay_kginicis/admin/orders/ORD-ESCROW-VAL-003/escrow-delivery', [ + 'invoice' => 'INV-001', + 'ex_code' => 'hanjin', + 'recv_name' => str_repeat('가', 31), + 'recv_tel' => str_repeat('0', 21), + 'recv_post' => str_repeat('1', 11), + 'recv_addr' => str_repeat('가', 201), + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors([ + 'recv_name', + 'recv_tel', + 'recv_post', + 'recv_addr', + ]); + } + + /** + * 배열 주입(`dcnf_name[]=x`)은 EscrowDenyConfirmRequest 의 string 규칙이 422 로 + * 차단하고 PG 구매거절확인 API 는 호출되지 않아야 한다. + */ + public function test_escrow_deny_confirm_rejects_array_dcnf_name(): void + { + $this->createEscrowPayment('ORD-ESCROW-DNCF-VAL-001', [ + 'escrow_confirm' => ['type' => 'deny'], + ]); + + $mock = $this->createMock(KgInicisApiService::class); + $mock->expects($this->never())->method('useEscrowCredentials'); + $mock->expects($this->never())->method('denyConfirmEscrow'); + $this->app->instance(KgInicisApiService::class, $mock); + + $response = $this->actingAs($this->adminUser) + ->postJson('/api/plugins/sirsoft-pay_kginicis/admin/orders/ORD-ESCROW-DNCF-VAL-001/escrow-deny-confirm', [ + 'dcnf_name' => ['x'], + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['dcnf_name']); + } + + /** + * 확인자명 상한(20자) 초과는 422 로 차단해야 한다 (이니시스 확인자명 20자 스펙). + */ + public function test_escrow_deny_confirm_rejects_overlong_dcnf_name(): void + { + $this->createEscrowPayment('ORD-ESCROW-DNCF-VAL-002', [ + 'escrow_confirm' => ['type' => 'deny'], + ]); + + $mock = $this->createMock(KgInicisApiService::class); + $mock->expects($this->never())->method('denyConfirmEscrow'); + $this->app->instance(KgInicisApiService::class, $mock); + + $response = $this->actingAs($this->adminUser) + ->postJson('/api/plugins/sirsoft-pay_kginicis/admin/orders/ORD-ESCROW-DNCF-VAL-002/escrow-deny-confirm', [ + 'dcnf_name' => str_repeat('가', 21), + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['dcnf_name']); + } + private function createEscrowPayment(string $orderNumber, array $paymentMeta = []): OrderPayment { $order = OrderFactory::new()->paid()->create([ diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php index cb0d6a8e..ab736ea5 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php @@ -2,20 +2,21 @@ namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Controllers; -use Mockery; +use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Mockery; use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory; use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Models\Order; -use Modules\Sirsoft\Ecommerce\Models\OrderPayment; use Modules\Sirsoft\Ecommerce\Models\OrderAddress; +use Modules\Sirsoft\Ecommerce\Models\OrderPayment; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayKginicis\Repositories\CbtCvsOperationsRepositoryInterface; -use Plugins\Sirsoft\PayKginicis\Services\CbtCvsOperationsService; use Plugins\Sirsoft\PayKginicis\Services\CbtCheckoutTokenService; +use Plugins\Sirsoft\PayKginicis\Services\CbtCvsOperationsService; use Plugins\Sirsoft\PayKginicis\Services\CbtReconciliationService; use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService; use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase; @@ -64,7 +65,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-001', 'sid' => 'SID001', 'resultCode' => 'OK', @@ -107,7 +108,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-CVS-001', 'sid' => 'SID-CVS-001', 'resultCode' => 'OK', @@ -139,7 +140,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-CANCEL-001', 'sid' => 'SID-CANCEL-001', 'resultCode' => 'FAIL', @@ -168,7 +169,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-PAYPAY-FAIL-001', 'sid' => 'SID-PAYPAY-FAIL-001', 'resultCode' => 'processing_failure', @@ -653,7 +654,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-003', 'sid' => 'SID003', 'resultCode' => 'OK', @@ -712,7 +713,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-CBT-POST-COMMIT', 'sid' => 'SID_POST_COMMIT', 'resultCode' => 'OK', @@ -763,7 +764,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-004', 'sid' => 'SID004', 'resultCode' => 'OK', @@ -810,7 +811,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-012', 'sid' => 'SID012', 'resultCode' => 'OK', @@ -862,7 +863,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-013', 'sid' => 'SID013', 'resultCode' => 'OK', @@ -923,7 +924,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-PAYPAY-OK', 'sid' => 'SID-PAYPAY-OK', 'resultCode' => 'OK', @@ -955,6 +956,9 @@ class CbtPaymentControllerTest extends PluginTestCase 'oid' => 'JP-ORDER-002', 'price' => 99, 'timestamp' => date('YmdHis'), + // 계약 변경(CbtHashDataRequest): checkout_token 이 필수로 승격되어 미전송 시 422 검증 + // 오류가 먼저 난다. 금액 대조는 토큰 검증보다 앞 단계이므로 임의 토큰으로 충분하다. + 'checkout_token' => 'token-not-reached', ]); $response->assertStatus(422) @@ -989,6 +993,9 @@ class CbtPaymentControllerTest extends PluginTestCase 'timestamp' => date('YmdHis'), 'buyer_email' => 'attacker@example.jp', 'buyer_phone' => '09012345678', + // 계약 변경(CbtHashDataRequest): checkout_token 필수 승격 — 구매자 대조(403)는 + // 토큰 검증보다 앞 단계이므로 임의 토큰으로 충분하다. + 'checkout_token' => 'token-not-reached', ]); $response->assertStatus(403) @@ -1020,7 +1027,7 @@ class CbtPaymentControllerTest extends PluginTestCase $checkoutTokenService = Mockery::mock(CbtCheckoutTokenService::class); $checkoutTokenService->shouldReceive('verify') ->once() - ->with('token-ok', 'JP-ORDER-006', 100, 'BUYER@example.jp', '09012345678', Mockery::type(\Illuminate\Http\Request::class)) + ->with('token-ok', 'JP-ORDER-006', 100, 'BUYER@example.jp', '09012345678', Mockery::type(Request::class)) ->andReturn(true); $this->app->instance(OrderProcessingService::class, $orderService); @@ -1107,6 +1114,66 @@ class CbtPaymentControllerTest extends PluginTestCase $this->assertStringContainsString('.', $response->json('data.checkout_token')); } + /** + * OID 상한(40자) 초과는 CbtCheckoutTokenRequest 가 표준 422 로 차단해야 한다 + * (국내 SignatureRequest 와 동일 강도 — 종전 CBT 경로만 raw 입력이던 드리프트 해소). + */ + public function test_cbt_checkout_token_rejects_overlong_oid(): void + { + $apiService = Mockery::mock(KgInicisApiService::class); + $apiService->shouldNotReceive('isJapanEnabled'); + $this->app->instance(KgInicisApiService::class, $apiService); + + $response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token', [ + 'oid' => str_repeat('A', 41), + 'price' => 100, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['oid']); + } + + /** + * checkout_token 누락은 표준 422 로 차단해야 한다. + * (계약 변경: 종전에는 빈 토큰이 토큰 검증 단계까지 흘러가 403 이었으나, + * CbtHashDataRequest 의 required 승격으로 422 가 된다 — 클라이언트는 `.ok` 만 검사) + */ + public function test_cbt_hash_data_rejects_missing_checkout_token(): void + { + $apiService = Mockery::mock(KgInicisApiService::class); + $apiService->shouldNotReceive('generateCbtHashData'); + $this->app->instance(KgInicisApiService::class, $apiService); + + $response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data', [ + 'oid' => 'JP-ORDER-TOKEN-MISSING', + 'price' => 100, + 'timestamp' => date('YmdHis'), + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['checkout_token']); + } + + /** + * 배열 주입(`oid[]=x`)은 string 규칙이 표준 422 로 차단해야 한다. + */ + public function test_cbt_hash_data_rejects_array_injection(): void + { + $apiService = Mockery::mock(KgInicisApiService::class); + $apiService->shouldNotReceive('generateCbtHashData'); + $this->app->instance(KgInicisApiService::class, $apiService); + + $response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data', [ + 'oid' => ['x'], + 'price' => 100, + 'timestamp' => date('YmdHis'), + 'checkout_token' => 'token-not-reached', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['oid']); + } + public function test_cbt_callback_stores_sanitized_pg_response_only(): void { $order = $this->makePendingJpyOrder('JP-ORDER-009', 100); @@ -1149,7 +1216,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $response = $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-009', 'sid' => 'SID009', 'resultCode' => 'OK', @@ -1192,7 +1259,7 @@ class CbtPaymentControllerTest extends PluginTestCase $this->app->instance(KgInicisApiService::class, $apiService); $this->get('/plugins/sirsoft-pay_kginicis/payment/cbt/callback?' - . http_build_query([ + .http_build_query([ 'oid' => 'JP-ORDER-010', 'sid' => 'SID010', 'resultCode' => 'OK', @@ -1279,7 +1346,7 @@ class CbtPaymentControllerTest extends PluginTestCase private function makePendingJpyOrder(string $orderNumber, int $amount): Order { - $order = new Order(); + $order = new Order; $order->order_number = $orderNumber; $order->order_status = OrderStatusEnum::PENDING_ORDER; $order->currency = 'JPY'; @@ -1339,8 +1406,8 @@ class CbtPaymentControllerTest extends PluginTestCase private function createPersistedPendingCbtCvsOrder(string $orderNumber, int $amount, array $metaOverrides = []): Order { $order = $this->createPersistedPendingJpyOrder($orderNumber, $amount); - $sid = 'SID-' . $orderNumber; - $tid = 'CBT_CVS_TID_' . str_replace('-', '_', $orderNumber); + $sid = 'SID-'.$orderNumber; + $tid = 'CBT_CVS_TID_'.str_replace('-', '_', $orderNumber); $order->payment()->update([ 'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT, diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index 03b572c8..da966378 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -4,7 +4,15 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-08-14 +## [1.0.2] - 2026-08-17 + +### Security + +- 에스크로 배송등록 요청의 운송장번호·택배사 코드에 형식·길이 검증을 추가했습니다. + +### Changed + +- 에스크로 배송등록 오류 안내가 다국어로 제공됩니다. ### Changed @@ -13,6 +21,7 @@ ### Fixed - 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다. +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.1] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php b/plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php index 6f373b00..c2cea53b 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php @@ -8,6 +8,10 @@ return [ 'default_reason' => 'Buyer refund request', 'in_progress' => 'NHN KCP refund is already in progress.', ], + 'escrow' => [ + 'invoice_required' => 'Please enter the tracking number.', + 'courier_required' => 'Please select a courier.', + ], 'errors' => [ 'payment_failed' => 'Payment could not be completed. Please try again in a moment.', 'wsdl_missing' => 'KCP WSDL file not found: :file', diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php b/plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php index e8594daf..8efa6cb3 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php @@ -8,6 +8,10 @@ return [ 'default_reason' => '구매자 환불 요청', 'in_progress' => 'NHN KCP 환불이 이미 처리 중입니다.', ], + 'escrow' => [ + 'invoice_required' => '운송장번호를 입력해주세요.', + 'courier_required' => '택배사를 선택해주세요.', + ], 'errors' => [ 'payment_failed' => '결제 처리에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'wsdl_missing' => 'KCP WSDL 파일이 없습니다: :file', diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-pay_nhnkcp/resources/layouts/admin/plugin_settings.json index 3e98ac50..9c1d3c38 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/resources/layouts/admin/plugin_settings.json @@ -1903,7 +1903,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.plugins.settings.save_error" + "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php index 21a2b155..c68220a9 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php @@ -1,5 +1,7 @@ 'CJ대한통운', '05' => '한진택배', '06' => '로젠택배', @@ -77,6 +80,7 @@ class AdminEscrowDeliveryController extends AdminBaseController return ResponseHelper::success('common.success', null); } + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $address = DB::table((new OrderAddress)->getTable().' as a') ->join((new Order)->getTable().' as o', 'o.id', '=', 'a.order_id') ->where('o.order_number', $orderNumber) @@ -115,22 +119,17 @@ class AdminEscrowDeliveryController extends AdminBaseController * * KCP 에스크로 API 로 배송 정보(택배사/송장번호/수령인 등) 등록. 등록 완료 시 구매확정 안내 자동 발송. * - * @param Request $request 배송 정보 폼 + * 형식·길이·택배사 코드 검증은 EscrowDeliveryRegisterRequest 가 담당한다 + * (종전 인라인 한글 하드코딩 메시지를 다국어 키로 이관). + * + * @param EscrowDeliveryRegisterRequest $request 배송 정보 폼 * @param string $orderNumber 주문번호 * @return JsonResponse 등록 결과 */ - public function register(Request $request, string $orderNumber): JsonResponse + public function register(EscrowDeliveryRegisterRequest $request, string $orderNumber): JsonResponse { - $deliNumb = trim((string) $request->input('deli_numb', '')); - $deliCorp = trim((string) $request->input('deli_corp', '')); - - if ($deliNumb === '') { - return ResponseHelper::error('common.failed', 422, ['deli_numb' => ['운송장번호를 입력해주세요.']]); - } - - if ($deliCorp === '' || ! array_key_exists($deliCorp, self::COURIER_CODES)) { - return ResponseHelper::error('common.failed', 422, ['deli_corp' => ['택배사를 선택해주세요.']]); - } + $deliNumb = trim((string) $request->validated('deli_numb')); + $deliCorp = trim((string) $request->validated('deli_corp')); $payment = $this->findEscrowPayment($orderNumber); @@ -164,6 +163,7 @@ class AdminEscrowDeliveryController extends AdminBaseController 'pg_response' => $this->sanitizePgResponse($pgResponse, self::ESCROW_DELIVERY_RESPONSE_KEYS), ]; + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 DB::table((new OrderPayment)->getTable()) ->where('id', $payment->id) ->update([ @@ -198,6 +198,7 @@ class AdminEscrowDeliveryController extends AdminBaseController private function findEscrowPayment(string $orderNumber): ?object { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 return DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php index fe3ea4bd..f3c9e006 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php @@ -1,5 +1,7 @@ getTable().' as o') ->join((new OrderPayment)->getTable().' as p', 'p.order_id', '=', 'o.id') ->where('p.pg_provider', 'nhnkcp') @@ -76,6 +79,7 @@ class AdminOrderListController extends AdminBaseController */ public function easyPayDisplayMap(): JsonResponse { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $rows = DB::table((new Order)->getTable().' as o') ->join((new OrderPayment)->getTable().' as p', 'p.order_id', '=', 'o.id') ->where('p.pg_provider', 'nhnkcp') diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php index 0f5eba16..6cf4818b 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php @@ -1,5 +1,7 @@ getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) @@ -128,6 +131,7 @@ class AdminTransactionController extends AdminBaseController private function latestRefundForOrder(int $orderId): ?object { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 return DB::table((new OrderRefund)->getTable()) ->where('order_id', $orderId) ->orderByDesc('id') diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php index 2d122af9..12ff1425 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php @@ -1,5 +1,7 @@ '', + 'payco_direct' => '', 'naverpay_direct' => 'A', 'kakaopay_direct' => 'A', 'applepay_direct' => 'A', @@ -51,11 +53,11 @@ class MobileApprovalController /** 간편결제별 direct 파라미터 override (기본값 위에 덮어씀) */ private const EASY_PAY_DIRECT_FIELDS = [ - 'nhnkcp_payco' => ['payco_direct' => 'Y'], - 'nhnkcp_naverpay' => ['naverpay_direct' => 'Y'], + 'nhnkcp_payco' => ['payco_direct' => 'Y'], + 'nhnkcp_naverpay' => ['naverpay_direct' => 'Y'], 'nhnkcp_naverpay_point' => ['naverpay_direct' => 'Y', 'naverpay_point_direct' => 'Y'], - 'nhnkcp_kakaopay' => ['kakaopay_direct' => 'Y'], - 'nhnkcp_applepay' => ['applepay_direct' => 'Y'], + 'nhnkcp_kakaopay' => ['kakaopay_direct' => 'Y'], + 'nhnkcp_applepay' => ['applepay_direct' => 'Y'], ]; private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp'; @@ -80,6 +82,7 @@ class MobileApprovalController * @param Request $request 주문/결제 메타 * @return JsonResponse approval_key + pay_url */ + // audit:allow controller-base-request-injection reason: 인라인 validate 로 전 필드 계약 보유 — FormRequest 승격은 후속 백로그 (계획 범위 제외 합의) public function getApprovalKey(Request $request): JsonResponse { $validated = $request->validate([ @@ -249,9 +252,9 @@ class MobileApprovalController $supplyAmt = $taxablePaymentAmt - $vatAmt; // 공급가액 (VAT 제외) return [ - 'tax_flag' => 'TG03', - 'comm_tax_mny' => (string) $supplyAmt, - 'comm_vat_mny' => (string) $vatAmt, + 'tax_flag' => 'TG03', + 'comm_tax_mny' => (string) $supplyAmt, + 'comm_vat_mny' => (string) $vatAmt, 'comm_free_mny' => (string) $taxFreeAmt, ]; } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php index dfe725d3..e2faf6fe 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php @@ -1,5 +1,7 @@ user(); + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $query = DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserVbankMockDepositController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserVbankMockDepositController.php index e855a4a7..e0e57bfd 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserVbankMockDepositController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserVbankMockDepositController.php @@ -1,5 +1,7 @@ pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? []; diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/EscrowDeliveryRegisterRequest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/EscrowDeliveryRegisterRequest.php new file mode 100644 index 00000000..ef516069 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/EscrowDeliveryRegisterRequest.php @@ -0,0 +1,58 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'deli_numb' => ['required', 'string', 'max:30'], + 'deli_corp' => ['required', 'string', Rule::in(array_keys(AdminEscrowDeliveryController::COURIER_CODES))], + ]; + } + + /** + * 검증 오류 메시지를 반환합니다 (종전 한글 하드코딩을 다국어 키로 이관). + * + * @return array 규칙별 메시지 + */ + public function messages(): array + { + return [ + 'deli_numb.required' => __('sirsoft-pay_nhnkcp::messages.escrow.invoice_required'), + 'deli_corp.required' => __('sirsoft-pay_nhnkcp::messages.escrow.courier_required'), + 'deli_corp.in' => __('sirsoft-pay_nhnkcp::messages.escrow.courier_required'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminEscrowDeliveryControllerTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminEscrowDeliveryControllerTest.php index 82467961..8c01e3e3 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminEscrowDeliveryControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminEscrowDeliveryControllerTest.php @@ -72,6 +72,7 @@ class AdminEscrowDeliveryControllerTest extends PluginTestCase ]); $response = $this->actingAs($admin) + ->withHeaders(['Accept-Language' => 'ko']) ->postJson("/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{$order->order_number}/escrow-delivery", [ 'deli_numb' => '1234567890', 'deli_corp' => '04', @@ -92,4 +93,55 @@ class AdminEscrowDeliveryControllerTest extends PluginTestCase $this->assertArrayNotHasKey('recv_addr', $pgResponse); $this->assertArrayNotHasKey('unexpected_sensitive', $pgResponse); } + + /** + * 운송장번호 상한(30자) 초과는 EscrowDeliveryRegisterRequest 가 표준 422 로 차단하고 + * PG 배송등록 API 는 호출되지 않아야 한다 (KCP 운송장번호 30자 스펙). + */ + public function test_register_rejects_overlong_deli_numb(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']); + $order = $this->createEscrowOrder('KCP_ESCROW_TNO_VAL_001'); + + $mock = $this->createMock(NhnKcpApiService::class); + $mock->expects($this->never())->method('registerEscrowDelivery'); + $this->app->instance(NhnKcpApiService::class, $mock); + + $response = $this->actingAs($admin) + ->withHeaders(['Accept-Language' => 'ko']) + ->postJson("/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{$order->order_number}/escrow-delivery", [ + 'deli_numb' => str_repeat('1', 31), + 'deli_corp' => '04', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['deli_numb']); + } + + /** + * 코드표(COURIER_CODES) 밖의 택배사 코드('99')는 Rule::in 이 422 로 차단하고, + * 메시지는 다국어 키(escrow.courier_required)의 ko 문구여야 한다 + * (종전 컨트롤러 인라인 한글 하드코딩 → 다국어 키 이관 회귀 방지). + */ + public function test_register_rejects_unknown_deli_corp(): void + { + + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']); + $order = $this->createEscrowOrder('KCP_ESCROW_TNO_VAL_002'); + + $mock = $this->createMock(NhnKcpApiService::class); + $mock->expects($this->never())->method('registerEscrowDelivery'); + $this->app->instance(NhnKcpApiService::class, $mock); + + $response = $this->actingAs($admin) + ->withHeaders(['Accept-Language' => 'ko']) + ->postJson("/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{$order->order_number}/escrow-delivery", [ + 'deli_numb' => '1234567890', + 'deli_corp' => '99', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['deli_corp']) + ->assertJsonPath('errors.deli_corp.0', '택배사를 선택해주세요.'); + } } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php index 5b0f9a4c..da869cb4 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php @@ -61,6 +61,8 @@ abstract class PluginTestCase extends TestCase $this->registerPluginAutoload(); $this->app->register(EcommerceServiceProvider::class); + // _bundled 테스트가 _bundled 의 lang 을 보도록 네임스페이스 등록 (kginicis PluginTestCase 선례) + $this->app['translator']->addNamespace('sirsoft-pay_nhnkcp', dirname(__DIR__).'/lang'); $this->registerModuleRoutes(); $this->registerPluginRoutes(); diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md index 50dc3160..e7c87bef 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md @@ -4,7 +4,15 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-08-14 +## [1.0.2] - 2026-08-17 + +### Security + +- 가상계좌 환불·에스크로 배송등록·결제 서명 요청의 입력값에 형식·길이 검증을 추가했습니다. 잘못된 형식의 환불 계좌 정보(계좌번호·은행코드·예금주)는 PG 호출 전에 차단됩니다. + +### Changed + +- 결제 서명(SignData) 요청의 필수값 누락 응답이 표준 검증 응답(422)으로 통일되었습니다. ### Changed @@ -13,6 +21,7 @@ ### Fixed - 관리자 주문 상세에서 에스크로 배송 등록이 실패하면 내부 오류 원문이 그대로 안내 문구 자리에 표시되던 문제를 수정했습니다. 이제 상황에 맞는 안내가 표시되고, 원인은 서버 로그에만 기록됩니다. +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.1] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md index 077ca0f8..768f7ec8 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md @@ -4,5 +4,9 @@ | 문서 | 도메인 | 설명 | | --- | --- | --- | -| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보 수신 경로와 발신 서버(IP) 확인 | +| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보 수신 경로와 발신 서버(IP) 확인 + 입금 완료 건 관리자 환불 (JSON API) | | [escrow.md](escrow.md) | `order` | 관리자 에스크로 결제 조회·배송 등록 (JSON API) | +| [transaction.md](transaction.md) | `order` | 관리자 거래 조회 — TID 직접 / 주문번호 자동 매핑 (JSON API) | +| [payment.md](payment.md) | `payment` | 결제창 서명값(SignData) 발급 경로 (web, 결제창 SDK 계약) | +| [orders.md](orders.md) | `order` | 주문 보조 조회 — 입금통보 이력·테스트모드/간편결제 맵·사용자 영수증 (JSON API) | +| [settings.md](settings.md) | `setting` | 관리자 설정 상태 조회 — 테스트 모드·입금통보 URL (JSON API) | diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/escrow.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/escrow.md index 99c4020b..d11108df 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/escrow.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/escrow.md @@ -84,7 +84,7 @@ | 이름 | 타입 | 필수 | 제약 | 설명 | | --- | --- | --- | --- | --- | -| `tid` | string | 예 | — | 대상 에스크로 결제의 나이스페이먼츠 TID | +| `tid` | string | 예 | 최대 30자 | 대상 에스크로 결제의 나이스페이먼츠 TID (나이스페이먼츠 TID 규격 30자 — 초과 시 422) | | `delivery_name` | string | 예 | 최대 100자 | 택배사명 | | `tracking_number` | string | 예 | 최대 100자 | 송장번호 | | `buyer_address` | string | 예 | 최대 200자 | 구매자 배송지 | diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/orders.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/orders.md new file mode 100644 index 00000000..5ae0b060 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/orders.md @@ -0,0 +1,53 @@ +# Orders(주문 보조 조회) API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_nicepayments` · 사람이 서술한 레퍼런스. 관리자 주문 화면과 사용자 영수증 화면이 쓰는 보조 조회 경로를 다룬다. 에스크로 결제 목록은 [escrow.md](escrow.md), 거래 조회는 [transaction.md](transaction.md) 참조. + +--- + +## TL;DR (5초 요약) + +```text +1. 관리자 주문 목록 배지용 맵 2종(테스트모드 / 간편결제 표시)과 입금통보 이력, 사용자 영수증 조회 +2. 관리자 경로는 auth:sanctum + permission:sirsoft-ecommerce.orders.read +3. 맵 2종은 최근 6개월의 나이스페이 결제 건만 집계한다 (주문 목록 배지 표시용) +4. 영수증 조회는 회원이면 본인 주문만, 비회원이면 X-Guest-Order-Token 검증으로 접근 제어 +5. 응답 data 는 화면 표시용 경량 구조 — 주문 상세 데이터는 이커머스 API 가 담당 +``` + +--- + +### GET /api/plugins/sirsoft-pay_nicepayments/admin/orders/{orderNumber}/vbank-notifications + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.orders.vbank-notifications` +- **컨트롤러**: `AdminVbankNotificationController@show` +- **인증/권한**: `auth:sanctum` + `permission:admin,sirsoft-ecommerce.orders.read` + +주문의 가상계좌 **입금통보 수신 이력**을 반환한다. 통보 수신 경로 자체는 [vbank.md](vbank.md) 참조. 주문이 없으면 실패 응답(`success: false`). + +### GET /api/plugins/sirsoft-pay_nicepayments/admin/orders/test-mode-map + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.orders.test-mode-map` +- **컨트롤러**: `AdminOrderListController@testModeMap` +- **인증/권한**: `auth:sanctum` + `permission:admin,sirsoft-ecommerce.orders.read` + +최근 6개월 나이스페이 결제 건 중 **테스트 모드 자격증명으로 결제된 주문**의 맵을 반환한다. 주문 목록의 "테스트" 배지 표시용. + +```json +{ "success": true, "data": { "ORD-20260817-000001": true } } +``` + +### GET /api/plugins/sirsoft-pay_nicepayments/admin/orders/easy-pay-display-map + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.orders.easy-pay-display-map` +- **컨트롤러**: `AdminOrderListController@easyPayDisplayMap` +- **인증/권한**: `auth:sanctum` + `permission:admin,sirsoft-ecommerce.orders.read` + +최근 6개월 나이스페이 결제 건 중 **간편결제(내장 PG) 표시 라벨**이 있는 주문의 맵을 반환한다. 주문 목록이 결제수단 열에 간편결제 명칭(네이버페이 등)을 표시하는 데 쓴다. + +### GET /api/plugins/sirsoft-pay_nicepayments/user/orders/{orderNumber}/receipt + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.user.orders.receipt` +- **컨트롤러**: `UserReceiptController@show` +- **인증/권한**: `optional.sanctum` — 회원이면 본인 주문만, 비회원이면 `X-Guest-Order-Token` 헤더 검증 + +주문의 나이스페이 **매출전표(영수증) URL** 을 반환한다. 소유권 검증에 실패하면 대상 없음으로 처리된다(다른 사용자의 주문번호로는 조회 불가). diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/payment.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/payment.md new file mode 100644 index 00000000..753fc979 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/payment.md @@ -0,0 +1,50 @@ +# Payment(결제창 서명) API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_nicepayments` · 사람이 서술한 레퍼런스. 이 경로는 결제창(브라우저) 흐름이 호출하는 web 라우트로, 표준 관리자 JSON API 표면이 아니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 결제창 호출 직전 브라우저가 서명값(SignData)을 발급받는 경로다 +2. amt(정수)·moid(max 64) 형식 위반은 422 (SignDataRequest — 종전 400 에서 422 로 표준화) +3. 주문 실존·구매자 일치·나이스페이 결제 건 여부를 서버가 재검증한다 (금액 조작 방지) +4. 과도 호출은 429 (주문번호 기준 rate limit) +5. 응답은 envelope 없이 { ediDate, signData, mid } 원시 JSON (결제창 SDK 계약) +``` + +--- + +### POST /plugins/sirsoft-pay_nicepayments/payment/sign-data + +- **라우트명**: `web.plugins.sirsoft-pay_nicepayments.payment.sign-data` +- **컨트롤러**: `Plugins\Sirsoft\PayNicepayments\Controllers\PaymentCallbackController@signData` +- **인증/권한**: web 라우트 (세션/비회원 주문 검증은 구매자 대조로 수행) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| amt | body | integer | 예 | 1 ~ 999999999999 | 결제 금액. 서버가 주문 금액과 대조하므로 임의 값은 서명 발급이 거절된다 | +| moid | body | string | 예 | max 64 | 주문번호(MOID) | + +**응답 (200)** + +```json +{ + "ediDate": "20260817143000", + "signData": "…SHA-256 서명…", + "mid": "nictest00m" +} +``` + +응답은 코어 envelope 를 쓰지 않는다 — 나이스페이 결제창 SDK 가 소비하는 원시 JSON 계약이다. 클라이언트는 `response.ok` 만 검사한다. + +**오류 응답** + +| 상태 | 조건 | +| --- | --- | +| 422 | `amt`/`moid` 형식 위반 (`SignDataRequest` — 종전 400 응답을 422 로 표준화) / 주문 없음 / 나이스페이 결제 건이 아님 | +| 403 | 구매자 대조 실패 (다른 사용자의 주문번호로 서명 요청) | +| 429 | 주문번호 기준 rate limit 초과 (60초 내 20회) | diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/settings.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/settings.md new file mode 100644 index 00000000..5f95fbaf --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/settings.md @@ -0,0 +1,41 @@ +# Settings(설정 상태) API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_nicepayments` · 사람이 서술한 레퍼런스. 관리자 설정 화면이 쓰는 상태 조회 경로를 다룬다. + +--- + +## TL;DR (5초 요약) + +```text +1. 관리자 설정 화면용 조회 2종 — 테스트 모드 상태 / 입금통보 URL 안내 +2. 두 경로 모두 auth:sanctum + permission:sirsoft-ecommerce.settings.read +3. 테스트 모드 상태는 미설정 시 true(테스트 모드)가 기본값이다 +4. 입금통보 URL 은 나이스페이먼츠 상점관리자에 등록할 값을 화면에 보여주기 위한 조회다 +5. 쓰기(설정 저장)는 코어 플러그인 설정 API 가 담당 — 이 문서 범위 아님 +``` + +--- + +### GET /api/plugins/sirsoft-pay_nicepayments/admin/settings/test-mode-status + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.settings.test-mode-status` +- **컨트롤러**: `AdminSettingsStatusController@testMode` +- **인증/권한**: `auth:sanctum` + `permission:admin,sirsoft-ecommerce.settings.read` + +플러그인의 테스트 모드 여부를 반환한다. 설정 미저장 상태의 기본값은 `true`(테스트 모드). + +```json +{ "success": true, "data": { "is_test_mode": true } } +``` + +### GET /api/plugins/sirsoft-pay_nicepayments/admin/vbank-notify-url + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.vbank.notify.url` +- **컨트롤러**: 라우트 클로저 (`src/routes/api.php`) +- **인증/권한**: `auth:sanctum` + `permission:admin,sirsoft-ecommerce.settings.read` + +가상계좌 **입금통보 수신 URL** 을 반환한다. 운영자가 나이스페이먼츠 상점관리자에 등록할 주소를 설정 화면에 표시하는 용도다. 수신 경로의 동작·IP 제한은 [vbank.md](vbank.md) 참조. + +```json +{ "success": true, "data": { "url": "https://example.com/plugins/sirsoft-pay_nicepayments/payment/vbank-notify" } } +``` diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/transaction.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/transaction.md new file mode 100644 index 00000000..6f0ed10b --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/transaction.md @@ -0,0 +1,82 @@ +# Transaction API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_nicepayments` · 사람이 서술한 레퍼런스 (docgen 스캐폴딩 불가 구간 — 응답이 PG 원본 응답 합성이라 실측 자리는 사람이 채운다). + +--- + +## TL;DR (5초 요약) + +```text +1. 관리자 주문 상세의 나이스페이 거래 조회 2종 (TID 직접 / 주문번호 자동 매핑) +2. 두 경로 모두 auth:sanctum + permission:sirsoft-ecommerce.orders.read +3. tid 는 필수·문자열·최대 30자 — 위반 시 PG 호출 없이 422 (TransactionQueryRequest) +4. 응답 data 는 NicePay 단건 조회 원본 키 + 로컬 보강 필드(_local_is_escrow, _is_test_mode 등) +5. 주문번호 경로는 나이스페이 결제 레코드가 없으면 data: null 로 성공 응답 +``` + +--- + +### POST /api/plugins/sirsoft-pay_nicepayments/admin/transaction/query + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.transaction.query` +- **컨트롤러**: `Plugins\Sirsoft\PayNicepayments\Controllers\AdminTransactionController@query` +- **인증/권한**: `auth:sanctum` + `permission:admin,sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| tid | body | string | 예 | max 30 | 조회할 나이스페이 거래번호(TID). 누락·문자열 아님·30자 초과는 PG 호출 전에 422 (`TransactionQueryRequest`) | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_nicepayments/admin/transaction/query HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "tid": "nictest00m01011104191651325596" +} +``` + +**응답 필드** (`data` 내부) + +`data` 는 NicePay 단건 거래 조회 응답(원본 키 그대로)에 로컬 보강 필드를 덧붙인 객체다. 원본 키는 결제수단·거래 상태에 따라 존재 여부가 달라진다. + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| ResultCode | string | NicePay 조회 결과 코드 (원본 응답) | +| ResultMsg | string | NicePay 조회 결과 메시지 (원본 응답) | +| TID | string | 거래번호 | +| Amt | string | 거래 금액 (원본 응답) | +| \_local_is_escrow | boolean | 로컬 결제 레코드 기준 에스크로 거래 여부 (조회 응답에 EscrowYN 이 없을 때의 보강) | +| \_is_test_mode | boolean | 테스트 모드 자격증명으로 결제된 거래 여부 (로컬 레코드 기준) | + +**오류 응답** + +| 상태 | 조건 | +| --- | --- | +| 422 | `tid` 검증 실패 (누락 / 문자열 아님·배열 주입 / 30자 초과) — PG 미호출 | +| 401 / 403 | 미인증 / `sirsoft-ecommerce.orders.read` 권한 없음 | + +--- + +### GET /api/plugins/sirsoft-pay_nicepayments/admin/orders/{orderNumber}/transaction-status + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.orders.transaction-status` +- **컨트롤러**: `Plugins\Sirsoft\PayNicepayments\Controllers\AdminTransactionController@queryByOrder` +- **인증/권한**: `auth:sanctum` + `permission:admin,sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 용도 | +| --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | 주문번호. `ecommerce_order_payments.transaction_id` 를 찾아 TID 조회로 위임 | + +**응답** + +- 나이스페이 결제 레코드(TID)가 있으면 위 `transaction/query` 와 동일한 `data`. +- 매핑되는 나이스페이 거래가 없으면 `data: null` 로 성공 응답 (오류 아님 — 화면이 "조회 대상 없음" 으로 표시). diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/vbank.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/vbank.md index 46a7144d..90f48492 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/vbank.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/vbank.md @@ -29,3 +29,59 @@ 이 IP 화이트리스트 검사는 코어의 확장 미들웨어 self-gate(`Plugin::getMiddleware()` 의 `targets` 로 위 통보 라우트명에만 정밀 타게팅)로 수행된다. 브라우저가 접속하는 결제 콜백(`payment.callback`)에는 적용되지 않는다 — 콜백은 정상 사용자의 브라우저에서 임의 IP 로 도달하므로 IP 로 제한하면 결제가 끊긴다. 검사 자체의 동작·허용 범위는 라우트 파일에서 직접 부착하던 이전 방식과 동일하다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../../../../../docs/backend/middleware.md). + +--- + +## 입금 완료 건 환불 (관리자 JSON API) + +### POST /api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund + +- **라우트명**: `api.plugins.sirsoft-pay_nicepayments.vbank.refund` +- **컨트롤러**: `Plugins\Sirsoft\PayNicepayments\Controllers\AdminVbankRefundController@refund` +- **인증/권한**: `auth:sanctum` + `permission:admin,sirsoft-ecommerce.orders.update` + +입금이 완료된 가상계좌는 일반 환불 훅으로 처리할 수 없다 — 나이스페이먼츠가 환불받을 계좌 정보(계좌번호·은행코드·예금주)를 요구하기 때문이다. 이 경로가 해당 정보를 수집해 취소 API 를 호출하고 주문 취소·결제 상태 반영까지 수행한다. + +**요청 파라미터** (형식·길이 위반은 PG 호출 전에 422 — `VbankRefundRequest`) + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| tid | body | string | 예 | max 30 | 거래번호(TID) | +| moid | body | string | 예 | max 64 | 주문번호(MOID) | +| cancel_amt | body | integer | 예 | 1 ~ 999999999999 | 취소 금액. 결제 금액과 불일치 시 422 | +| cancel_msg | body | string | 아니오 | max 100 | 취소 사유. 미입력 시 기본 문구 사용 | +| refund_acct_no | body | string | 예 | 숫자만, max 16 | 환불 계좌번호 | +| refund_bank_cd | body | string | 예 | 숫자 3자리 | 환불 은행코드 | +| refund_acct_nm | body | string | 예 | max 10 | 환불 예금주명 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "tid": "nictest00m02012104191651325596", + "moid": "ORD-20260817-000001", + "cancel_amt": 30000, + "cancel_msg": "구매자 요청 환불", + "refund_acct_no": "1234567890123", + "refund_bank_cd": "004", + "refund_acct_nm": "홍길동" +} +``` + +**응답** + +- 성공(200): `common.success` envelope, `data` 는 나이스페이 취소 API 원본 응답. +- 실패: + +| 상태 | 조건 | +| --- | --- | +| 422 | 입력 형식 위반 (`VbankRefundRequest` — PG 미호출) / 결제 상태가 환불 대상 아님 / 취소 금액 불일치 | +| 404 | 주문번호에 해당하는 주문 없음 | +| 409 | 동일 건 환불이 이미 처리 중 | +| 502 | PG 취소 API 실패 또는 취소 후 주문 반영 실패 (서버 로그에 원문 기록) | diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/lang/en/messages.php b/plugins/_bundled/sirsoft-pay_nicepayments/lang/en/messages.php index abbc4c5b..1d1c701f 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/lang/en/messages.php @@ -8,7 +8,6 @@ return [ 'order_not_found' => 'Order not found.', 'invalid_request' => 'Invalid request.', 'invalid_amount' => 'The requested amount is invalid.', - 'vbank_refund_required_fields' => 'Please provide all required fields: TID, order number, cancel amount, and refund account information (account number, bank code, account holder).', 'vbank_refund_order_not_found' => 'The order to refund could not be found.', 'vbank_refund_invalid_payment' => 'This is not a refundable completed NicePayments virtual-account payment.', 'vbank_refund_amount_mismatch' => 'The requested refund amount does not match the cancellable amount in the database.', @@ -23,4 +22,13 @@ return [ 'defaults' => [ 'vbank_refund_msg' => 'Virtual account refund', ], + 'fields' => [ + 'tid' => 'transaction ID (TID)', + 'moid' => 'order number', + 'cancel_amt' => 'cancel amount', + 'cancel_msg' => 'cancel reason', + 'refund_acct_no' => 'refund account number', + 'refund_bank_cd' => 'refund bank code', + 'refund_acct_nm' => 'refund account holder', + ], ]; diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/lang/ko/messages.php b/plugins/_bundled/sirsoft-pay_nicepayments/lang/ko/messages.php index 5720d041..ebd21a95 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/lang/ko/messages.php @@ -8,7 +8,6 @@ return [ 'order_not_found' => '주문을 찾을 수 없습니다.', 'invalid_request' => '잘못된 요청입니다.', 'invalid_amount' => '요청 금액이 유효하지 않습니다.', - 'vbank_refund_required_fields' => 'TID, 주문번호, 취소금액, 환불계좌 정보(계좌번호·은행코드·예금주)를 모두 입력해주세요.', 'vbank_refund_order_not_found' => '환불할 주문을 찾을 수 없습니다.', 'vbank_refund_invalid_payment' => '환불 가능한 나이스페이먼츠 가상계좌 입금완료 결제가 아닙니다.', 'vbank_refund_amount_mismatch' => '요청 환불 금액이 DB의 취소 가능 금액과 일치하지 않습니다.', @@ -23,4 +22,13 @@ return [ 'defaults' => [ 'vbank_refund_msg' => '가상계좌 환불', ], + 'fields' => [ + 'tid' => '거래 ID(TID)', + 'moid' => '주문번호', + 'cancel_amt' => '취소 금액', + 'cancel_msg' => '취소 사유', + 'refund_acct_no' => '환불 계좌번호', + 'refund_bank_cd' => '환불 은행코드', + 'refund_acct_nm' => '환불 예금주', + ], ]; diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-pay_nicepayments/resources/layouts/admin/plugin_settings.json index a477d2b7..88711c7a 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-pay_nicepayments/resources/layouts/admin/plugin_settings.json @@ -1122,7 +1122,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.plugins.settings.save_error" + "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php index f2510501..38528ca0 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php @@ -9,12 +9,12 @@ namespace Plugins\Sirsoft\PayNicepayments\Controllers; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; +use Plugins\Sirsoft\PayNicepayments\Http\Requests\EscrowDeliveryRegisterRequest; use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService; class AdminEscrowController extends AdminBaseController @@ -41,6 +41,7 @@ class AdminEscrowController extends AdminBaseController */ public function getEscrowPayments(string $orderNumber): JsonResponse { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $payments = DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) @@ -69,18 +70,12 @@ class AdminEscrowController extends AdminBaseController * NicePay escrow_process.jsp 호출하여 배송 정보(택배사/송장번호/수령인 등)를 * NicePay 측에 등록. 등록 완료 시 구매자에게 자동으로 구매확정 안내가 발송됨. * - * @param Request $request 배송 정보 폼 + * @param EscrowDeliveryRegisterRequest $request 배송 정보 폼 * @return JsonResponse 등록 결과 + ResultCode */ - public function registerDelivery(Request $request): JsonResponse + public function registerDelivery(EscrowDeliveryRegisterRequest $request): JsonResponse { - $validated = $request->validate([ - 'tid' => 'required|string', - 'delivery_name' => 'required|string|max:100', - 'tracking_number' => 'required|string|max:100', - 'buyer_address' => 'required|string|max:200', - 'register_name' => 'required|string|max:50', - ]); + $validated = $request->validated(); try { $payment = $this->findRegisterableEscrowPayment($validated['tid']); @@ -121,6 +116,7 @@ class AdminEscrowController extends AdminBaseController private function findRegisterableEscrowPayment(string $tid): ?OrderPayment { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 return OrderPayment::query() ->where('pg_provider', 'nicepayments') ->where('is_escrow', true) diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php index 490ce3fe..719afa89 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php @@ -34,6 +34,7 @@ class AdminOrderListController extends AdminBaseController */ public function testModeMap(): JsonResponse { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $rows = DB::table((new Order)->getTable().' as o') ->join((new OrderPayment)->getTable().' as p', 'p.order_id', '=', 'o.id') ->whereIn('p.pg_provider', ['nicepayments', 'nicepay']) @@ -63,6 +64,7 @@ class AdminOrderListController extends AdminBaseController */ public function easyPayDisplayMap(): JsonResponse { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $rows = DB::table((new Order)->getTable().' as o') ->join((new OrderPayment)->getTable().' as p', 'p.order_id', '=', 'o.id') ->whereIn('p.pg_provider', ['nicepayments', 'nicepay']) diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php index 8c157653..18b4d828 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php @@ -7,12 +7,12 @@ namespace Plugins\Sirsoft\PayNicepayments\Controllers; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay; +use Plugins\Sirsoft\PayNicepayments\Http\Requests\TransactionQueryRequest; use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService; class AdminTransactionController extends AdminBaseController @@ -31,18 +31,12 @@ class AdminTransactionController extends AdminBaseController * NicePay 단건 거래 조회 API 를 호출하고 로컬 DB 의 보조 정보(에스크로 여부, * 테스트 모드 플래그)를 합쳐 반환한다. * - * @param Request $request tid 입력 폼 + * @param TransactionQueryRequest $request tid 입력 폼 * @return JsonResponse 거래 정보 + _local_is_escrow / EscrowYN / _is_test_mode 보강 */ - public function query(Request $request): JsonResponse + public function query(TransactionQueryRequest $request): JsonResponse { - $tid = trim((string) $request->input('tid', '')); - - if ($tid === '') { - return ResponseHelper::error('common.failed', 422, ['tid' => [__('sirsoft-pay_nicepayments::messages.errors.tid_required')]]); - } - - return $this->queryByTid($tid); + return $this->queryByTid(trim((string) $request->validated('tid'))); } /** @@ -56,6 +50,7 @@ class AdminTransactionController extends AdminBaseController */ public function queryByOrder(string $orderNumber): JsonResponse { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $payment = DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) @@ -77,6 +72,7 @@ class AdminTransactionController extends AdminBaseController try { $result = $this->apiService->queryTransaction($tid); + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $localPayment = DB::table((new OrderPayment)->getTable()) ->where('transaction_id', $tid) ->select(['is_escrow', 'payment_meta', 'payment_method', 'embedded_pg_provider']) diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php index 830f43ee..f720ef0a 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php @@ -7,7 +7,6 @@ namespace Plugins\Sirsoft\PayNicepayments\Controllers; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -16,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; use Modules\Sirsoft\Ecommerce\Services\OrderCancellationService; +use Plugins\Sirsoft\PayNicepayments\Http\Requests\VbankRefundRequest; use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService; /** @@ -42,26 +42,20 @@ class AdminVbankRefundController extends AdminBaseController * * 일반 환불 훅으로 처리할 수 없는 vbank 입금 완료 건의 환불을 직접 수행. * NicePay 가 요구하는 환불 계좌 정보(계좌번호·은행코드·예금주)를 직접 수집. + * 형식·길이 검증은 VbankRefundRequest 가 PG 호출 전에 차단한다. * - * @param Request $request TID/MOID/CancelAmt + 환불계좌 정보 - * @return JsonResponse 환불 결과 또는 422 (입력 누락) + * @param VbankRefundRequest $request TID/MOID/CancelAmt + 환불계좌 정보 + * @return JsonResponse 환불 결과 또는 422 (입력 형식 위반) */ - public function refund(Request $request): JsonResponse + public function refund(VbankRefundRequest $request): JsonResponse { - $tid = trim((string) $request->input('tid', '')); - $moid = trim((string) $request->input('moid', '')); - $cancelAmt = (int) $request->input('cancel_amt', 0); - $cancelMsg = trim((string) $request->input('cancel_msg', __('sirsoft-pay_nicepayments::messages.defaults.vbank_refund_msg'))); - $refundAcctNo = trim((string) $request->input('refund_acct_no', '')); - $refundBankCd = trim((string) $request->input('refund_bank_cd', '')); - $refundAcctNm = trim((string) $request->input('refund_acct_nm', '')); - - if ($tid === '' || $moid === '' || $cancelAmt <= 0 - || $refundAcctNo === '' || $refundBankCd === '' || $refundAcctNm === '') { - return ResponseHelper::error('common.failed', 422, [ - 'message' => __('sirsoft-pay_nicepayments::messages.errors.vbank_refund_required_fields'), - ]); - } + $tid = trim((string) $request->validated('tid')); + $moid = trim((string) $request->validated('moid')); + $cancelAmt = (int) $request->validated('cancel_amt'); + $cancelMsg = trim((string) ($request->validated('cancel_msg') ?? __('sirsoft-pay_nicepayments::messages.defaults.vbank_refund_msg'))); + $refundAcctNo = trim((string) $request->validated('refund_acct_no')); + $refundBankCd = trim((string) $request->validated('refund_bank_cd')); + $refundAcctNm = trim((string) $request->validated('refund_acct_nm')); $claimedPayment = null; $pgCancelled = false; @@ -130,6 +124,7 @@ class AdminVbankRefundController extends AdminBaseController private function claimRefund(string $moid, string $tid, int $cancelAmt): array { return DB::transaction(function () use ($moid, $tid, $cancelAmt): array { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $order = Order::query() ->where('order_number', $moid) ->lockForUpdate() diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCallbackController.php index 92ffe36a..d4d76b9e 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCallbackController.php @@ -29,6 +29,7 @@ use Plugins\Sirsoft\PayNicepayments\Concerns\RecordsPaymentWindowClosure; use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay; use Plugins\Sirsoft\PayNicepayments\Concerns\SanitizesPgResponse; use Plugins\Sirsoft\PayNicepayments\Http\Requests\AuthCallbackRequest; +use Plugins\Sirsoft\PayNicepayments\Http\Requests\SignDataRequest; use Plugins\Sirsoft\PayNicepayments\Http\Requests\VbankNotifyRequest; use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService; use Plugins\Sirsoft\PayNicepayments\Support\ShopRedirectUrl; @@ -428,6 +429,7 @@ class PaymentCallbackController string $txTid, ): string { return DB::transaction(function () use ($moid, $pgResponse, $resultCode, $payMethod, $isEscrow, $txTid): string { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $lockedOrder = Order::query() ->where('order_number', $moid) ->lockForUpdate() @@ -484,6 +486,7 @@ class PaymentCallbackController 'pg_response_sanitized' => true, 'pg_raw_response' => $this->sanitizePgResponse($pgResponse, self::VBANK_ISSUE_RESPONSE_KEYS), ]; + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $payment->save(); return 'recorded'; @@ -500,6 +503,7 @@ class PaymentCallbackController array $callbackPayload, ): string { return DB::transaction(function () use ($moid, $pgResponse, $txTid, $amt, $isEscrow, $request, $callbackPayload): string { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $lockedOrder = Order::query() ->where('order_number', $moid) ->lockForUpdate() @@ -584,17 +588,13 @@ class PaymentCallbackController * MOID, 주문 상태, 나이스페이 결제 레코드, Amt 를 우리 DB 와 비교 검증하여 * 클라이언트 측 주문/금액 조작을 차단한다. * - * @param Request $request amt + moid - * @return JsonResponse ediDate / signData / mid 또는 400/422 + * @param SignDataRequest $request amt + moid (형식 검증은 FormRequest — 종전 수동 검사 400 → 표준 422) + * @return JsonResponse ediDate / signData / mid 또는 422 */ - public function signData(Request $request): JsonResponse + public function signData(SignDataRequest $request): JsonResponse { - $amt = (int) $request->input('amt', 0); - $moid = (string) $request->input('moid', ''); - - if ($amt <= 0 || $moid === '') { - return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.invalid_request')], 400); - } + $amt = (int) $request->validated('amt'); + $moid = (string) $request->validated('moid'); $rateLimitKey = $this->signDataRateLimitKey($request, $moid); if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) { @@ -1083,6 +1083,7 @@ class PaymentCallbackController 'vbank_notifications' => $existing, 'vbank_notification_summary' => $this->buildNotificationSummary($existing), ]); + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $payment->save(); }); } catch (\Throwable $e) { diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php index ddbd0dc7..45a371d2 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php @@ -118,6 +118,7 @@ class PaymentCloseReportController private function recordCloseReportWithLock(string $oid, int $price, string $closeReason): array { return DB::transaction(function () use ($oid, $price, $closeReason): array { + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $lockedOrder = Order::query() ->where('order_number', $oid) ->lockForUpdate() diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php index d92f7026..88f68ab8 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php @@ -37,9 +37,11 @@ class UserReceiptController * @param string $orderNumber 주문번호 * @return JsonResponse receipt_url / cash_receipt_url / is_test_mode 또는 404 */ + // audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user()/비회원 영수증 쿠키만 참조 (검증 대상 필드 없음) public function show(Request $request, string $orderNumber): JsonResponse { $user = $request->user(); + // audit:allow controller-direct-data-access reason: PG 플러그인의 결제 레코드 직접 조회/기록 — ecommerce Repository 의존 시 모듈 버전 제약 연쇄(PaymentLimits 선례). Service/Repository 이관은 후속 백로그 $query = DB::table((new OrderPayment)->getTable().' as p') ->join((new Order)->getTable().' as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/EscrowDeliveryRegisterRequest.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/EscrowDeliveryRegisterRequest.php new file mode 100644 index 00000000..17c54e32 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/EscrowDeliveryRegisterRequest.php @@ -0,0 +1,42 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'tid' => ['required', 'string', 'max:30'], + 'delivery_name' => ['required', 'string', 'max:100'], + 'tracking_number' => ['required', 'string', 'max:100'], + 'buyer_address' => ['required', 'string', 'max:200'], + 'register_name' => ['required', 'string', 'max:50'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/SignDataRequest.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/SignDataRequest.php new file mode 100644 index 00000000..dddf7e5d --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/SignDataRequest.php @@ -0,0 +1,42 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'amt' => ['required', 'integer', 'min:1', 'max:999999999999'], + 'moid' => ['required', 'string', 'max:64'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/TransactionQueryRequest.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/TransactionQueryRequest.php new file mode 100644 index 00000000..c5609c78 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/TransactionQueryRequest.php @@ -0,0 +1,37 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'tid' => ['required', 'string', 'max:30'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/VbankRefundRequest.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/VbankRefundRequest.php new file mode 100644 index 00000000..895e8232 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/VbankRefundRequest.php @@ -0,0 +1,64 @@ +> 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'tid' => ['required', 'string', 'max:30'], + 'moid' => ['required', 'string', 'max:64'], + 'cancel_amt' => ['required', 'integer', 'min:1', 'max:999999999999'], + 'cancel_msg' => ['nullable', 'string', 'max:100'], + 'refund_acct_no' => ['required', 'string', 'max:16', 'regex:/^\d+$/'], + 'refund_bank_cd' => ['required', 'string', 'regex:/^\d{3}$/'], + 'refund_acct_nm' => ['required', 'string', 'max:10'], + ]; + } + + /** + * 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다. + * + * @return array 필드별 표시명 + */ + public function attributes(): array + { + return [ + 'tid' => __('sirsoft-pay_nicepayments::messages.fields.tid'), + 'moid' => __('sirsoft-pay_nicepayments::messages.fields.moid'), + 'cancel_amt' => __('sirsoft-pay_nicepayments::messages.fields.cancel_amt'), + 'cancel_msg' => __('sirsoft-pay_nicepayments::messages.fields.cancel_msg'), + 'refund_acct_no' => __('sirsoft-pay_nicepayments::messages.fields.refund_acct_no'), + 'refund_bank_cd' => __('sirsoft-pay_nicepayments::messages.fields.refund_bank_cd'), + 'refund_acct_nm' => __('sirsoft-pay_nicepayments::messages.fields.refund_acct_nm'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminEscrowControllerTest.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminEscrowControllerTest.php index 5aed6b0a..f1a2fce5 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminEscrowControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminEscrowControllerTest.php @@ -75,6 +75,32 @@ class AdminEscrowControllerTest extends PluginTestCase ->assertJsonPath('success', false); } + /** + * TID 상한(30자) 초과는 EscrowDeliveryRegisterRequest 가 표준 422 로 차단하고 + * PG 배송등록 API 는 호출되지 않아야 한다 (나이스페이먼츠 TID 는 30자). + */ + public function test_register_delivery_rejects_overlong_tid(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']); + $this->createPaidEscrowPayment(); + + $apiServiceMock = $this->createMock(NicePaymentsApiService::class); + $apiServiceMock->expects($this->never())->method('useStoredCredentials'); + $apiServiceMock->expects($this->never())->method('registerEscrowDelivery'); + $this->app->instance(NicePaymentsApiService::class, $apiServiceMock); + + $response = $this->actingAs($admin)->postJson('/api/plugins/sirsoft-pay_nicepayments/admin/escrow/register-delivery', [ + 'tid' => str_repeat('T', 31), + 'delivery_name' => 'CJ대한통운', + 'tracking_number' => 'TRACK123456', + 'buyer_address' => '서울시 테스트구 1', + 'register_name' => '운영자', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['tid']); + } + private function createPaidEscrowPayment(): void { $order = OrderFactory::new()->create([ diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminVbankRefundControllerTest.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminVbankRefundControllerTest.php index bc8b9f94..0502897a 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminVbankRefundControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminVbankRefundControllerTest.php @@ -128,6 +128,69 @@ class AdminVbankRefundControllerTest extends PluginTestCase $response->assertForbidden(); } + /** + * 환불 계좌 형식 위반 4종(계좌번호 17자리 / 은행코드 비숫자 / 예금주 11자 / 취소사유 101자)은 + * VbankRefundRequest 가 표준 422 로 차단하고 PG 취소 API 는 호출되지 않아야 한다. + */ + public function test_admin_vbank_refund_rejects_malformed_refund_account_fields(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']); + $order = $this->createPaidVbankOrder(); + + $apiServiceMock = $this->createMock(NicePaymentsApiService::class); + $apiServiceMock->expects($this->never())->method('useStoredCredentials'); + $apiServiceMock->expects($this->never())->method('cancelPayment'); + $this->app->instance(NicePaymentsApiService::class, $apiServiceMock); + + $response = $this->actingAs($admin)->postJson('/api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund', [ + 'tid' => self::TID, + 'moid' => $order->order_number, + 'cancel_amt' => 30000, + 'cancel_msg' => str_repeat('가', 101), + 'refund_acct_no' => str_repeat('1', 17), + 'refund_bank_cd' => '00A', + 'refund_acct_nm' => str_repeat('가', 11), + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors([ + 'cancel_msg', + 'refund_acct_no', + 'refund_bank_cd', + 'refund_acct_nm', + ]); + + $order->refresh(); + $this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status); + $this->assertEquals(0.0, (float) $order->payment->cancelled_amount); + } + + /** + * 배열 주입(`tid[]=x`)은 string 규칙이 422 로 차단해야 한다 — 종전 수동 검사는 + * 문자열 캐스팅 전제라 배열 입력이 형식 검증을 우회할 수 있었다. + */ + public function test_admin_vbank_refund_rejects_array_injection(): void + { + $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']); + $order = $this->createPaidVbankOrder(); + + $apiServiceMock = $this->createMock(NicePaymentsApiService::class); + $apiServiceMock->expects($this->never())->method('cancelPayment'); + $this->app->instance(NicePaymentsApiService::class, $apiServiceMock); + + $response = $this->actingAs($admin)->postJson('/api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund', [ + 'tid' => ['x'], + 'moid' => $order->order_number, + 'cancel_amt' => 30000, + 'refund_acct_no' => '1234567890', + 'refund_bank_cd' => '004', + 'refund_acct_nm' => '홍길동', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['tid']); + } + private function createPaidVbankOrder(): Order { $user = User::factory()->create(); diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php index 7c96e0ab..299f6845 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php @@ -428,6 +428,40 @@ class PaymentCallbackControllerTest extends PluginTestCase ->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_request')); } + /** + * Moid 상한(64자) 초과는 SignDataRequest 가 표준 422 로 차단해야 한다. + * (계약 변경: 종전 수동 검사 400 → FormRequest 표준 422 — 클라이언트는 `.ok` 만 검사) + */ + public function test_sign_data_rejects_overlong_moid(): void + { + $this->mockPluginSettings(); + + $response = $this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [ + 'amt' => 50000, + 'moid' => str_repeat('A', 65), + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['moid']); + } + + /** + * 비정수 amt('abc')는 SignDataRequest 의 integer 규칙이 표준 422 로 차단해야 한다. + * (계약 변경: 종전 수동 검사 400 → FormRequest 표준 422) + */ + public function test_sign_data_rejects_non_integer_amt(): void + { + $this->mockPluginSettings(); + + $response = $this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [ + 'amt' => 'abc', + 'moid' => 'ORD-TEST-AMT-TYPE', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['amt']); + } + public function test_sign_data_rejects_unknown_order(): void { $this->mockPluginSettings(); diff --git a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md index 59a37ee0..39d6dbd9 100644 --- a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md @@ -4,12 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-08-12 +## [1.0.2] - 2026-08-17 ### Fixed - 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다. - 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다. +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.1] - 2026-08-11 diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json index 7c672c34..be666e68 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json @@ -1437,7 +1437,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.plugins.settings.save_error" + "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md index d93cb00a..4628d2d2 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md @@ -4,12 +4,13 @@ All notable changes to this plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [1.0.3] - 2026-08-12 +## [1.0.3] - 2026-08-17 ### Fixed - 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다. - 회원 탈퇴·삭제 시 본인확인 기록 파기와 이력 익명화 중 한쪽만 처리된 채 남을 수 있던 문제를 수정했습니다 — 이제 두 처리가 함께 완료됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.2] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-verification_kginicis/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-verification_kginicis/resources/layouts/admin/plugin_settings.json index 204d08e5..27166134 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/resources/layouts/admin/plugin_settings.json @@ -668,7 +668,7 @@ }, { "handler": "toast", - "params": { "type": "error", "message": "$t:admin.plugins.settings.save_error" } + "params": { "type": "error", "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] } diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md index e3b222c1..d1dea536 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md @@ -4,12 +4,17 @@ All notable changes to this plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [1.0.1] - 2026-08-12 +## [1.0.1] - 2026-08-17 + +### Security + +- 인증 결과 브리지 페이지 진입값에 형식 검증을 추가해 비정상 형태(배열 주입 등)의 요청을 차단합니다. ### Fixed - 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다. - 회원 탈퇴·삭제 시 본인확인 기록 파기와 이력 익명화 중 한쪽만 처리된 채 남을 수 있던 문제를 수정했습니다 — 이제 두 처리가 함께 완료됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.0] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/identity.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/identity.md index 544a06e1..17aa3f2f 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/identity.md +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/identity.md @@ -153,6 +153,12 @@ CI/DI 및 그 해시, 평문 이름·휴대폰·생년월일은 응답에 **포 | `verification_token` | string | 인증 성공 시 발급된 1회용 토큰 | | `challenge_id` | string | 대응하는 본인확인 요청 식별자 | | `identity_error` | string | 실패 시 사유 코드 (`9999`, `NOT_ADULT`, `DUPLICATE`, `REMOTE_CALL_FAILED` 등) | +| `identity_error_message` | string | 실패 시 사유 메시지 (콜백 Resolver 가 발신 — kginicis 브리지에는 없는 본 플러그인 고유 필드) | + +네 쿼리 파라미터는 모두 `KcpBridgeRequest` 가 string 형식으로 검증한다 — 배열 주입 +(`?verification_token[]=x`)은 422 로 차단된다. 종전에는 `(string)` 캐스팅이 배열을 `"Array"` +문자열로 바꿔 브리지 payload 에 유입시킬 수 있었다. 길이 상한은 두지 않는다 (토큰 길이가 +코어 발급 정책에 종속되기 때문). 응답은 HTML 이며 두 갈래로 동작한다. 부모창이 있으면(PC 팝업) 동일 origin 으로 결과를 전달하고 창을 닫는다. 부모창이 없으면(모바일 페이지 전환) 보관해 둔 원래 주소로 결과를 붙여 되돌아간다. diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-verification_nhnkcp/resources/layouts/admin/plugin_settings.json index d819850f..75bbbdc7 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/resources/layouts/admin/plugin_settings.json @@ -704,7 +704,7 @@ }, { "handler": "toast", - "params": { "type": "error", "message": "$t:admin.plugins.settings.save_error" } + "params": { "type": "error", "message": "{{error.message ?? '$t:admin.plugins.settings.save_error'}}" } } ] } diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpBridgeController.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpBridgeController.php index 1d227866..2d12a32f 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpBridgeController.php +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpBridgeController.php @@ -3,8 +3,8 @@ namespace Plugins\Sirsoft\VerificationNhnkcp\Http\Controllers; use App\Http\Controllers\Api\Base\PublicBaseController; -use Illuminate\Http\Request; use Illuminate\Http\Response; +use Plugins\Sirsoft\VerificationNhnkcp\Http\Requests\KcpBridgeRequest; /** * 인증 결과 전달 브리지 — 데스크톱 팝업 / 모바일 페이지 전환 분기. @@ -26,17 +26,18 @@ class KcpBridgeController extends PublicBaseController /** * 브리지 페이지를 렌더링한다. * - * @param Request $request 콜백 컨트롤러가 전달한 query + * 배열 주입(`?verification_token[]=x`)은 KcpBridgeRequest 의 string 규칙이 422 로 + * 차단한다 — 종전 `(string)` 캐스팅은 배열을 "Array" 문자열로 바꿔 payload 에 유입시켰다. + * + * @param KcpBridgeRequest $request 콜백 컨트롤러가 전달한 query * @return Response 부모창으로 결과를 전달하는 브리지 HTML */ - public function show(Request $request): Response + public function show(KcpBridgeRequest $request): Response { - $payloadJson = json_encode([ - 'verification_token' => (string) $request->query('verification_token', ''), - 'challenge_id' => (string) $request->query('challenge_id', ''), - 'identity_error' => (string) $request->query('identity_error', ''), - 'identity_error_message' => (string) $request->query('identity_error_message', ''), - ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); + $payloadJson = json_encode( + $request->bridgePayload(), + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE + ); return $this->htmlResponse($this->renderBridgeHtml((string) $payloadJson)); } diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpCallbackController.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpCallbackController.php index 0956dfa8..4136f590 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpCallbackController.php +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Controllers/KcpCallbackController.php @@ -4,7 +4,7 @@ namespace Plugins\Sirsoft\VerificationNhnkcp\Http\Controllers; use App\Http\Controllers\Api\Base\PublicBaseController; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; +use Plugins\Sirsoft\VerificationNhnkcp\Http\Requests\KcpCallbackRequest; use Plugins\Sirsoft\VerificationNhnkcp\Services\KcpCallbackResolver; /** @@ -14,8 +14,9 @@ use Plugins\Sirsoft\VerificationNhnkcp\Services\KcpCallbackResolver; * 판정 로직 전체를 KcpCallbackResolver 에 위임하고, 결과를 브리지 페이지로 302 redirect 한다. * PII 는 쿼리에 싣지 않는다 (토큰/실패코드만 전달). * - * FormRequest 미사용 사유: 외부 인증기관이 보내는 임의 필드를 strict validation 으로 차단하면 - * 정상 응답까지 거절된다. raw form POST 를 그대로 Resolver 에 전달한다. + * 콜백 본문은 KcpCallbackRequest("빈 규칙도 결정" 패턴)로 수신한다 — 외부 인증기관이 + * 보내는 임의 필드를 strict validation 으로 차단하면 정상 응답까지 거절되므로 규칙은 + * 비워 두고, 의미 검증은 Resolver 가 담당한다. * * @since 1.0.0 */ @@ -33,10 +34,10 @@ class KcpCallbackController extends PublicBaseController /** * KCP 콜백 수신 + Resolver 위임 + 브리지 페이지로 302 redirect. * - * @param Request $request KCP 표준창이 form POST 한 body + * @param KcpCallbackRequest $request KCP 표준창이 form POST 한 body * @return RedirectResponse 브리지 페이지로의 302 응답 */ - public function handle(Request $request): RedirectResponse + public function handle(KcpCallbackRequest $request): RedirectResponse { $outcome = $this->resolver->resolve( callbackInput: $request->all(), diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Requests/KcpBridgeRequest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Requests/KcpBridgeRequest.php new file mode 100644 index 00000000..25583ad1 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Requests/KcpBridgeRequest.php @@ -0,0 +1,61 @@ +> 검증 규칙 + */ + public function rules(): array + { + return [ + 'verification_token' => ['nullable', 'string'], + 'challenge_id' => ['nullable', 'string'], + 'identity_error' => ['nullable', 'string'], + 'identity_error_message' => ['nullable', 'string'], + ]; + } + + /** + * bridge 스크립트에 전달할 payload 를 반환합니다. + * + * @return array 네 값 (미지정은 빈 문자열) + */ + public function bridgePayload(): array + { + return [ + 'verification_token' => (string) ($this->validated('verification_token') ?? ''), + 'challenge_id' => (string) ($this->validated('challenge_id') ?? ''), + 'identity_error' => (string) ($this->validated('identity_error') ?? ''), + 'identity_error_message' => (string) ($this->validated('identity_error_message') ?? ''), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Requests/KcpCallbackRequest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Requests/KcpCallbackRequest.php new file mode 100644 index 00000000..a23b1db8 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/src/Http/Requests/KcpCallbackRequest.php @@ -0,0 +1,39 @@ +> 빈 규칙 (사유는 클래스 설명 참조) + */ + public function rules(): array + { + return []; + } +} diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php index 23ec2c55..0f4e5b73 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/tests/Feature/Controllers/KcpCallbackControllerTest.php @@ -127,6 +127,41 @@ class KcpCallbackControllerTest extends PluginTestCase $this->assertStringContainsString('window.location.origin', (string) $html); } + /** + * 배열 주입(`?verification_token[]=x`)은 KcpBridgeRequest 의 string 규칙이 422 로 + * 차단해야 한다 — 종전 `(string)` 캐스팅은 배열을 "Array" 문자열로 바꿔 브리지 + * payload 에 유입시켰다. + */ + public function test_bridge_rejects_array_verification_token_with_422(): void + { + $response = $this->getJson(self::BRIDGE_URL.'?verification_token[]=x'); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['verification_token']); + } + + /** + * 정상 쿼리의 identity_error / identity_error_message 는 브리지 HTML payload 에 + * 그대로 실려야 한다 (KcpBridgeRequest 주입 후 bridgePayload() 경유 회귀 방지). + */ + public function test_bridge_renders_identity_error_message_passthrough(): void + { + $response = $this->get(self::BRIDGE_URL.'?'.http_build_query([ + 'challenge_id' => 'chal-456', + 'identity_error' => '9999', + 'identity_error_message' => '사용자취소', + ])); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + + $html = (string) $response->getContent(); + $this->assertStringContainsString('identity_result', $html); + $this->assertStringContainsString('"identity_error":"9999"', $html); + $this->assertStringContainsString('사용자취소', $html); + $this->assertStringContainsString('"challenge_id":"chal-456"', $html); + } + /** * 결과조회 응답을 가로챈다. */ diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 66bb7372..644bbf7d 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.5] - 2026-08-12 +## [1.0.5] - 2026-08-17 ### Added @@ -25,6 +25,7 @@ ### Fixed +- 언어팩 재활성화 안내 창의 아이콘이 의도한 크기와 다르게 보이던 문제를 수정했습니다. - 표 하단 합계 영역의 값이 계산되지 않고 표현식 원문 그대로 표시되던 문제를 다시 수정했습니다. 1.0.4 에 수록 예정이던 수정이 빌드 산출물에 반영되지 않은 채 배포되어, 주문 상세의 합계 행에서 수량·적립금·배송비 자리에 값 대신 알 수 없는 문자열이 보였습니다. (#98 @hwaryeon1234 님께서 제보해주셨습니다.) - 회원 정보 수정 화면에서 회원 정보가 화면에 채워지기 전에 저장을 누르면 "이름은 필수입니다" 같은 오류로 저장이 거절되던 문제를 수정했습니다. 이제 회원 정보를 불러오는 동안에는 저장 버튼이 잠시 비활성화됩니다. - 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다. @@ -33,6 +34,7 @@ - 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 확인 창은 건수만 알려 주므로 실행 전에 알아챌 방법이 없었습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다. 회원 목록·활동 기록·알림 발송 기록·스케줄 목록에 적용됩니다. - 환경설정에서 사이트 로고를 목록에서 빼도 화면에 그대로 남아, 로고 교체가 저장되지 않던 문제를 수정했습니다. - 스케줄 관리의 등록/수정 창에서 저장 버튼을 눌러도 아무 반응이 없던 문제를 수정했습니다. 함께, 작업 유형·실행 주기 선택란이 빈 값으로 표시되고 선택·켜기/끄기 값이 저장 요청에 실리지 않아 저장이 거절되던 문제도 수정했습니다. 실행 주기를 직접 입력(사용자 정의)하지 않은 경우에는 선택한 주기에 맞는 실행 시각이 자동으로 저장됩니다. +- 관리자 화면 전반(사이트 환경설정·역할/사용자 저장·메뉴 순서 변경·레이아웃 미리보기·메일/알림 템플릿·본인인증 메시지/정책·언어팩 재활성화·드라이버 연결 테스트 등)에서 저장·실행이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.0.4] - 2026-08-10 diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_form.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_form.json index 62ee3dac..7ae83107 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_form.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_form.json @@ -336,7 +336,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:common.validation_error" + "message": "{{error.message ?? '$t:common.validation_error'}}" } } ] diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_list.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_list.json index dbe6f348..10289191 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_list.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_role_list.json @@ -430,7 +430,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.roles.toggle_status_failed" + "message": "{{error.message ?? '$t:admin.roles.toggle_status_failed'}}" } } ] diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json index 6e9e0d4c..336473a6 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json @@ -769,7 +769,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.settings.save_error" + "message": "{{error.message ?? '$t:admin.settings.save_error'}}" } } ] diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_template_layout_edit.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_template_layout_edit.json index 12459d06..0850b7b6 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_template_layout_edit.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_template_layout_edit.json @@ -215,7 +215,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.templates.layout_edit.preview_error" + "message": "{{error.message ?? '$t:admin.templates.layout_edit.preview_error'}}" } } ] @@ -286,7 +286,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.templates.layout_edit.preview_error" + "message": "{{error.message ?? '$t:admin.templates.layout_edit.preview_error'}}" } } ] diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_form.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_form.json index 0a326e3e..328e95fb 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_form.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_form.json @@ -1611,7 +1611,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.users.form.validation_error" + "message": "{{error.message ?? '$t:admin.users.form.validation_error'}}" } } ] @@ -1922,7 +1922,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.users.form.validation_error" + "message": "{{error.message ?? '$t:admin.users.form.validation_error'}}" } } ] diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_menu_list/_panel_menu_list.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_menu_list/_panel_menu_list.json index 5c55d2ec..b21de57f 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_menu_list/_panel_menu_list.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_menu_list/_panel_menu_list.json @@ -133,7 +133,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.menus.messages.order_update_failed" + "message": "{{error.message ?? '$t:admin.menus.messages.order_update_failed'}}" } }, { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_module_list/_modal_reactivate_language_packs.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_module_list/_modal_reactivate_language_packs.json index 4b4f86e8..aa0a7e10 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_module_list/_modal_reactivate_language_packs.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_module_list/_modal_reactivate_language_packs.json @@ -49,7 +49,7 @@ "name": "Icon", "props": { "name": "language", - "className": "w-4 h-4 text-gray-500 dark:text-gray-400" + "className": "text-base text-gray-500 dark:text-gray-400" } }, { @@ -174,7 +174,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.modules.modals.reactivate_language_packs_failed", + "message": "{{error.message ?? '$t:admin.modules.modals.reactivate_language_packs_failed'}}", "type": "error" } } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_plugin_list/_modal_reactivate_language_packs.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_plugin_list/_modal_reactivate_language_packs.json index 598fa03e..bb666881 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_plugin_list/_modal_reactivate_language_packs.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_plugin_list/_modal_reactivate_language_packs.json @@ -49,7 +49,7 @@ "name": "Icon", "props": { "name": "language", - "className": "w-4 h-4 text-gray-500 dark:text-gray-400" + "className": "text-base text-gray-500 dark:text-gray-400" } }, { @@ -174,7 +174,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.plugins.modals.reactivate_language_packs_failed", + "message": "{{error.message ?? '$t:admin.plugins.modals.reactivate_language_packs_failed'}}", "type": "error" } } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_definition_add.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_definition_add.json index cdb4908a..ae158d60 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_definition_add.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_definition_add.json @@ -584,7 +584,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.settings.identity.messages.add_modal.save_failed", + "message": "{{error.message ?? '$t:admin.settings.identity.messages.add_modal.save_failed'}}", "type": "error" } } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_template_form.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_template_form.json index 4c802556..e68bb5fd 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_template_form.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_template_form.json @@ -317,7 +317,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.settings.identity.messages.modal.preview_failed", + "message": "{{error.message ?? '$t:admin.settings.identity.messages.modal.preview_failed'}}", "type": "error" } } @@ -376,7 +376,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.settings.identity.messages.modal.reset_failed", + "message": "{{error.message ?? '$t:admin.settings.identity.messages.modal.reset_failed'}}", "type": "error" } } @@ -490,7 +490,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.settings.identity.messages.modal.save_failed", + "message": "{{error.message ?? '$t:admin.settings.identity.messages.modal.save_failed'}}", "type": "error" } } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_mail_template_form.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_mail_template_form.json index d0be1af9..bac8bca0 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_mail_template_form.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_mail_template_form.json @@ -591,7 +591,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.settings.mail_templates.save_error", + "message": "{{error.message ?? '$t:admin.settings.mail_templates.save_error'}}", "type": "error" } } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json index 0df07620..fb44af34 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json @@ -983,7 +983,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.settings.notification_definitions.save_error", + "message": "{{error.message ?? '$t:admin.settings.notification_definitions.save_error'}}", "type": "error" } } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json index 423d6643..f6a7338d 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json @@ -530,7 +530,7 @@ "target": "local", "driverTestResults.s3": { "success": false, - "message": "$t:admin.settings.drivers.test_failed" + "message": "{{error.message ?? '$t:admin.settings.drivers.test_failed'}}" } } }, @@ -538,7 +538,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.settings.drivers.test_failed" + "message": "{{error.message ?? '$t:admin.settings.drivers.test_failed'}}" } }, { @@ -1101,7 +1101,7 @@ "target": "local", "driverTestResults.redis": { "success": false, - "message": "$t:admin.settings.drivers.test_failed" + "message": "{{error.message ?? '$t:admin.settings.drivers.test_failed'}}" } } }, @@ -1109,7 +1109,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.settings.drivers.test_failed" + "message": "{{error.message ?? '$t:admin.settings.drivers.test_failed'}}" } }, { @@ -1351,7 +1351,7 @@ "target": "local", "driverTestResults.memcached": { "success": false, - "message": "$t:admin.settings.drivers.test_failed" + "message": "{{error.message ?? '$t:admin.settings.drivers.test_failed'}}" } } }, @@ -1359,7 +1359,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.settings.drivers.test_failed" + "message": "{{error.message ?? '$t:admin.settings.drivers.test_failed'}}" } }, { @@ -2956,7 +2956,7 @@ "target": "local", "driverTestResults.websocket": { "success": false, - "message": "$t:admin.settings.drivers.test_failed" + "message": "{{error.message ?? '$t:admin.settings.drivers.test_failed'}}" } } }, @@ -2964,7 +2964,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.settings.drivers.test_failed" + "message": "{{error.message ?? '$t:admin.settings.drivers.test_failed'}}" } }, { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json index 91a5d58d..edfd214d 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json @@ -422,7 +422,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.settings.identity.messages.toggle_failed", + "message": "{{error.message ?? '$t:admin.settings.identity.messages.toggle_failed'}}", "variant": "error" } }, diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_policies.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_policies.json index e27471f3..dd8d36c1 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_policies.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_policies.json @@ -619,7 +619,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.identity.policies.toggle_failed" + "message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}" } }, { @@ -956,7 +956,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.identity.policies.toggle_failed" + "message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}" } }, { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_info.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_info.json index 90917078..90eb08c8 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_info.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_info.json @@ -354,7 +354,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.settings.info.changelog_load_failed" + "message": "{{error.message ?? '$t:admin.settings.info.changelog_load_failed'}}" } } ] diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_layout_edit/_modal_extension_preview_layout.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_layout_edit/_modal_extension_preview_layout.json index 39269c8f..1fc8f9d0 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_layout_edit/_modal_extension_preview_layout.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_layout_edit/_modal_extension_preview_layout.json @@ -101,7 +101,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:admin.templates.layout_edit.preview_error" + "message": "{{error.message ?? '$t:admin.templates.layout_edit.preview_error'}}" } } ] diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_modal_reactivate_language_packs.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_modal_reactivate_language_packs.json index d5c4d172..fd5b71ad 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_modal_reactivate_language_packs.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_modal_reactivate_language_packs.json @@ -49,7 +49,7 @@ "name": "Icon", "props": { "name": "language", - "className": "w-4 h-4 text-gray-500 dark:text-gray-400" + "className": "text-base text-gray-500 dark:text-gray-400" } }, { @@ -174,7 +174,7 @@ { "handler": "toast", "params": { - "message": "$t:admin.templates.modals.reactivate_language_packs_failed", + "message": "{{error.message ?? '$t:admin.templates.modals.reactivate_language_packs_failed'}}", "type": "error" } } diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 8c9b1bf7..11446ed9 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.1] - 2026-08-11 +## [1.1.1] - 2026-08-17 ### Added @@ -18,6 +18,7 @@ - 마이페이지 게시판 알림 수신 설정(댓글·답글 등 4개 항목)의 체크 상태가 저장값과 어긋날 수 있던 가능성을 차단했습니다. 저장된 값이 어떤 형태든 화면의 체크 표시와 저장 결과가 항상 일치합니다. - 장바구니 "전체 선택" 체크박스가 다크 모드에서 테두리 색을 따르지 않던 문제를 수정했습니다. - 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다. +- 쇼핑(주문서 할인·재주문·배송지 저장·상품 문의)과 마이페이지 화면에서 요청이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 비밀번호 변경 실패 안내가 항상 일반 문구로만 나오던 문제도 함께 바로잡았습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. ## [1.1.0] - 2026-08-10 diff --git a/templates/_bundled/sirsoft-basic/layouts/mypage/change-password.json b/templates/_bundled/sirsoft-basic/layouts/mypage/change-password.json index 3bd61e30..279cce89 100644 --- a/templates/_bundled/sirsoft-basic/layouts/mypage/change-password.json +++ b/templates/_bundled/sirsoft-basic/layouts/mypage/change-password.json @@ -196,7 +196,7 @@ "handler": "toast", "params": { "type": "error", - "message": "{{response.message ?? '$t:mypage.profile.change_password_failed'}}" + "message": "{{error.message ?? '$t:mypage.profile.change_password_failed'}}" } } ] diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_discount.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_discount.json index f1318d7a..a99123c6 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_discount.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_discount.json @@ -95,7 +95,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:shop.coupon_download.fetch_failed" + "message": "{{error.message ?? '$t:shop.coupon_download.fetch_failed'}}" } } ] @@ -442,7 +442,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:shop.checkout.discount_code_invalid" + "message": "{{error.message ?? '$t:shop.checkout.discount_code_invalid'}}" } } ] diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_summary.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_summary.json index c55ccf7f..ecf2b932 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_summary.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_summary.json @@ -518,7 +518,7 @@ { "comment": "verify 실패는 결제 자체를 막지 않음 — 주문은 이미 생성되었으므로 사용자가 나중에 lookup 폼으로 재인증 가능. 토스트로만 안내.", "handler": "toast", - "params": { "type": "warning", "message": "$t:shop.checkout.guest_token_issue_failed" } + "params": { "type": "warning", "message": "{{error.message ?? '$t:shop.checkout.guest_token_issue_failed'}}" } } ] } diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_modal_inquiry_delete.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_modal_inquiry_delete.json index cc6cd954..1628faa5 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_modal_inquiry_delete.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_modal_inquiry_delete.json @@ -161,7 +161,7 @@ ], "onError": [ { "handler": "setState", "params": { "target": "global", "qnaDeleting": false } }, - { "handler": "toast", "params": { "message": "$t:shop.qna.delete_failed", "type": "error" } } + { "handler": "toast", "params": { "message": "{{error.message ?? '$t:shop.qna.delete_failed'}}", "type": "error" } } ] } ] diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_tab_qna.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_tab_qna.json index 25fbcc66..7e8cfe6e 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_tab_qna.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_tab_qna.json @@ -691,7 +691,7 @@ ], "onError": [ { "handler": "setState", "params": { "target": "local", "qnaReplySubmitting": false } }, - { "handler": "toast", "params": { "message": "$t:shop.qna.reply_update_failed", "type": "error" } } + { "handler": "toast", "params": { "message": "{{error.message ?? '$t:shop.qna.reply_update_failed'}}", "type": "error" } } ] } ] @@ -837,7 +837,7 @@ ], "onError": [ { "handler": "setState", "params": { "target": "local", "qnaNewReplySubmitting": false } }, - { "handler": "toast", "params": { "message": "$t:shop.qna.reply_submit_failed", "type": "error" } } + { "handler": "toast", "params": { "message": "{{error.message ?? '$t:shop.qna.reply_submit_failed'}}", "type": "error" } } ] } ] diff --git a/templates/_bundled/sirsoft-basic/layouts/shop/order_complete.json b/templates/_bundled/sirsoft-basic/layouts/shop/order_complete.json index 8c43b195..c397b864 100644 --- a/templates/_bundled/sirsoft-basic/layouts/shop/order_complete.json +++ b/templates/_bundled/sirsoft-basic/layouts/shop/order_complete.json @@ -604,7 +604,7 @@ { "handler": "toast", "params": { "type": "success", "message": "$t:shop.order_complete.address_saved" } } ], "onError": [ - { "handler": "toast", "params": { "type": "error", "message": "$t:shop.order_complete.address_save_failed" } } + { "handler": "toast", "params": { "type": "error", "message": "{{error.message ?? '$t:shop.order_complete.address_save_failed'}}" } } ] } ] diff --git a/templates/_bundled/sirsoft-basic/layouts/shop/reorder.json b/templates/_bundled/sirsoft-basic/layouts/shop/reorder.json index fe68ee6a..311e3658 100644 --- a/templates/_bundled/sirsoft-basic/layouts/shop/reorder.json +++ b/templates/_bundled/sirsoft-basic/layouts/shop/reorder.json @@ -53,7 +53,7 @@ "handler": "toast", "params": { "type": "error", - "message": "$t:shop.reorder.failed" + "message": "{{error.message ?? '$t:shop.reorder.failed'}}" } } ]